#!/usr/bin/env python3 """PDF (digital oder gescannt) nach Markdown und optional EPUB konvertieren. Ablauf: 1. Jede Seite wird klassifiziert: Textseite, Scan ohne Text, Scan mit vorhandener OCR-Ebene oder leer. 2. Seitengruppen gehen mit passender OCR-Einstellung an pymupdf4llm (optional parallel). 3. normalize_markdown() gliedert Fließtext nach Satzenden, setzt Überschriften anhand der PDF-Gliederung, fügt Silbentrennungen zusammen und entfernt Seitenzahlen und Kolumnentitel. 4. Optional erzeugt pandoc daraus ein EPUB. """ import argparse import bisect import contextlib import difflib import io import json import multiprocessing import os import re import shutil import subprocess import sys import tempfile import unicodedata import urllib.parse from collections import Counter from concurrent.futures import ProcessPoolExecutor from pathlib import Path import pymupdf import pymupdf4llm from pymupdf4llm.helpers import document_layout from pymupdf4llm.ocr import OCRMode, tesseract_api # =========================================================================== # Markdown-Muster # =========================================================================== HEADING = re.compile(r"^\s{0,3}(#{1,6})\s*(\S.*?)\s*#*\s*$") # Schließende Anführungszeichen (deutsch U+201E...U+201C, U+201A...U+2018, U+00BB...U+00AB; # englisch; Schweiz U+00AB...U+00BB) - als Escapes, damit keine verwechselbaren Zeichen im Quelltext stehen. # Klammern und Markdown-Hervorhebungen. CLOSERS = r"""\u201d\u201c\u2019\u2018"'\u00bb\u00ab\u203a\u2039)\]*_""" # Satzzeichen, optional gefolgt von schließenden Zeichen (z. B. "...da?" + U+00AB). SENTENCE_END = re.compile(rf"""[.!?\u2026]+[{CLOSERS}]*$""") # Typische Endungen von Sätzen, die fälschlich als Überschrift erkannt wurden: # Punkt/Auslassung oder Frage/Ausruf mit schließendem Anführungszeichen. SENTENCE_LIKE_END = re.compile(rf"""(?:[.\u2026][{CLOSERS}]*|[!?]+[{CLOSERS}]+)$""") # Überschriften, die legitim mit Punkt enden: "3.", "IV.", "Kapitel 3." NUMBERING_ONLY = re.compile( r"^(?:(?:kapitel|teil|chapter|part)\s+)?(?:\d+|[ivxlcdm]+)\.$", re.IGNORECASE, ) FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})") LIST_ITEM = re.compile(r"^\s{0,3}(?:[-+*]|\d+[.)])\s+") ORDERED_ITEM = re.compile(r"^\s{0,3}\d+[.)]\s+") BLOCKQUOTE = re.compile(r"^\s{0,3}>\s?") RULE = re.compile(r"^\s{0,3}(?:-\s*){3,}$|^\s{0,3}(?:\*\s*){3,}$") IMAGE = re.compile(r"^\s*!\[.*?\]\(.*\)\s*$") # Beispiel: **==> picture [426 x 480] intentionally omitted <==** OMITTED_IMAGE = re.compile( r"^\s*(?:\*\*|__)?\s*==>\s*" r"(?:picture|image|bild)\b.*?" r"(?:intentionally omitted|omitted|ausgelassen)\s*" r"<==\s*(?:\*\*|__)?\s*$", re.IGNORECASE, ) # Text, den pymupdf4llm innerhalb von Bildern findet (Fotos, OCR-Reste). PICTURE_TEXT = re.compile( # Format bis pymupdf4llm 1.27 bzw. ab 1.28 r"(?:\*\*----- Start of picture text -----\*\*
|)" r".*?" r"(?:\*\*----- End of picture text -----\*\*
|)\n?", re.DOTALL, ) CONTENTS_TITLE = re.compile( r"^(?:inhaltsverzeichnis|inhalt|contents|table of contents)$", re.IGNORECASE, ) TOC_WITH_PAGE = re.compile( r"^\s*(?:[-*]\s+)?\S.+?" r"(?:\.{2,}|\s{2,})" r"(?:\d+|[ivxlcdm]+)\s*$", re.IGNORECASE, ) # Beginn eines nummerierten Eintrags mitten in einer Inhaltsverzeichniszeile. NEXT_TOC_NUMBER = re.compile(r"\s(?=\d{1,3}\s+[^\W\d_])") HTML_BLOCK = re.compile( r"^\s*$~^" MD_ESCAPE = re.compile(r"[\\`*\[\]<>$~^]|(?$~^])") def md_escape(text: str) -> str: return MD_ESCAPE.sub(lambda m: "\\" + m.group(0), text) def md_unescape(text: str) -> str: return MD_UNESCAPE.sub(r"\1", text) def ends_sentence(text: str) -> bool: return bool(SENTENCE_END.search(text.rstrip())) def clean_inline(text: str) -> str: """Markdown-Hervorhebungen entfernen und Leerraum vereinheitlichen.""" text = re.sub(r"(? str: """Vergleichsschlüssel für Überschriften und Gliederungseinträge.""" return md_unescape(clean_inline(text)).translate(DASHES).lower() def is_other_structure(line: str) -> bool: """Markdown-Strukturen, die nicht zu Fließtext verbunden werden.""" return bool( LIST_ITEM.match(line) or BLOCKQUOTE.match(line) or RULE.match(line) or IMAGE.match(line) or HTML_BLOCK.match(line) or line.lstrip().startswith("|") or line.startswith((" ", "\t")) ) def join_parts(prev: str, nxt: str) -> str: """Zwei Teile eines Absatzes verbinden und Silbentrennung auflösen. "Geheim-" + "dienst" -> "Geheimdienst" "Militär-" + "Geheimdienst" -> "Militär-Geheimdienst" "Arbeits-" + "und ..." -> "Arbeits- und ..." """ if not HYPHEN_END.search(prev) or not nxt: return f"{prev} {nxt}" stem = prev[:-1] word = FIRST_WORD.match(nxt) if word and word.group(0).lower() in CONJUNCTIONS: return f"{stem}- {nxt}" if nxt[0].islower(): return stem + nxt return f"{stem}-{nxt}" def is_false_heading(text: str, has_outline: bool, paragraph_open: bool) -> bool: """Erkennen, ob eine "Überschrift" in Wahrheit ein Satz(-rest) ist.""" if not text: return True if text[0].islower() or text[0] in "\u2013\u2014-\u2026,;" or text[-1] in ",;": return True if NUMBERING_ONLY.match(text): return False if paragraph_open and ends_sentence(text): return True if paragraph_open and text.split()[-1].lower() in TRAILING_STOPWORDS: return True # "Mitglied des Künstlerverbandes - damals eine" if SENTENCE_LIKE_END.search(text): return True # Mit Gliederung stehen echte Überschriften im Outline; was dort fehlt # und wie ein Satz endet, ist Fließtext. return has_outline and ends_sentence(text) def running_head_key(line: str) -> str | None: """Vergleichsschlüssel für mögliche Kolumnentitel (sonst None).""" if ( HEADING.match(line) or OMITTED_IMAGE.match(line) or is_other_structure(line) or FENCE.match(line) ): return None text = clean_inline(line) if not text or len(text) > 60 or ends_sentence(text): return None if QUOTE_CHARS & set(text): return None # wörtliche Rede ist kein Kolumnentitel key = re.sub(r"\d+", "", text) key = re.sub(r"\s+", " ", key).strip(" .-\u2013\u2014|\u00b7").lower() if sum(ch.isalpha() for ch in key) < 4: return None return key def find_running_heads(lines: list[str], min_repeats: int = 3) -> set[str]: """Kurze Zeilen, die (ohne Ziffern) sehr oft vorkommen.""" counts = Counter(k for k in map(running_head_key, lines) if k) return {key for key, count in counts.items() if count >= min_repeats} def split_contents_line(line: str, titles: list[str]) -> list[str]: """Zusammengeklebte Inhaltsverzeichniseinträge trennen. titles: normalisierte Gliederungstitel, längste zuerst. """ text = clean_inline(line) entries = [] while text: low = text.translate(DASHES).lower() match = next( ( t for t in titles if low.startswith(t) and (len(low) == len(t) or low[len(t)] == " ") ), None, ) if match: entries.append(text[: len(match)]) text = text[len(match):].strip() continue # Nächsten bekannten Titel oder nächste Kapitelnummer suchen. cuts = [ pos for t in titles if (pos := low.find(" " + t, 1)) > 0 and (pos + 1 + len(t) == len(low) or low[pos + 1 + len(t)] == " ") ] number = NEXT_TOC_NUMBER.search(text) if number: cuts.append(number.start()) if not cuts: entries.append(text) break cut = min(cuts) entries.append(text[:cut].strip()) text = text[cut:].strip() return [e for e in entries if e] # =========================================================================== # Markdown normalisieren # =========================================================================== def normalize_markdown( markdown: str, outline: dict[str, int] | None = None, *, drop_page_numbers: bool = True, drop_running_heads: bool = True, running_head_min: int = 3, ) -> str: outline = outline or {} titles = sorted(outline, key=len, reverse=True) markdown = PICTURE_TEXT.sub("", markdown) lines = markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n") running_heads = ( find_running_heads(lines, running_head_min) if drop_running_heads else set() ) result: list[str] = [] paragraph: list[str] = [] in_fence = False fence_char = "" fence_length = 0 in_contents = False contents_seen: set[str] = set() # Titel, die im Inhaltsverzeichnis standen contents_table = False # Inhaltsverzeichnis liegt als Markdown-Tabelle vor headings_seen: set[str] = set() def paragraph_open() -> bool: return bool(paragraph) and not ends_sentence(paragraph[-1]) def blank_line() -> None: if result and result[-1] != "": result.append("") def flush_paragraph() -> None: if paragraph: text = paragraph[0] for part in paragraph[1:]: text = join_parts(text, part) if result and result[-1].endswith(HARD_BREAK): result.append("") # Fließtext nach zeilengetreuen Zeilen result.append(text) paragraph.clear() def standalone(line: str) -> None: flush_paragraph() blank_line() result.append(line) result.append("") def heading(level: int, text: str) -> None: headings_seen.add(norm_title(text)) standalone("#" * min(max(level, 1), 6) + " " + text) for index, original in enumerate(lines): line = original.rstrip() marker = FENCE.match(line) if marker and not in_fence and MARKERS.search(line): # Text aus einem Absatz, der mit ``` beginnt: kein Codezaun. line = "\\" + line marker = None if in_fence: result.append(line) if marker: token = marker.group(1) if token[0] == fence_char and len(token) >= fence_length: in_fence = False continue if marker: flush_paragraph() in_fence = True fence_char = marker.group(1)[0] fence_length = len(marker.group(1)) # Listing über eine Seitengrenze: an den vorigen Codeblock anhängen. while result and result[-1] == "": result.pop() if result and result[-1] == line.strip() and line.strip() == fence_char * fence_length: result.pop() continue blank_line() result.append(line) continue if end_mark := BLOCK_END_MARK.search(line): # Blockende ohne Satzende: Hätte das erste Wort des nächsten Blocks # noch in die Zeile gepasst, war der Umbruch hart. line = line[: end_mark.start()] if not continues_block(lines[index + 1:], int(end_mark.group(1)), running_heads): # Mehrzeiliger Absatz: Absatzende; einzelne Zeile: harter Umbruch line += PARAGRAPH_END if end_mark.group(2) else HARD_BREAK if in_contents and line.endswith((HARD_BREAK, PARAGRAPH_END)): # Inhaltsverzeichnis: jeder Eintrag ein eigener Absatz (einheitlich, # auch wenn das Verzeichnis in der PDF zeilengetreu erscheint). line = line.rstrip(HARD_BREAK + PARAGRAPH_END) elif line.endswith((HARD_BREAK, PARAGRAPH_END)) and OUTLINE_ENTRY.match( clean_inline(line.rstrip(HARD_BREAK + PARAGRAPH_END)) ): # Verzeichnis ohne Überschrift "Inhalt" (z. B. "Vorschau Band Zwei"): # Einträge als eigene Absätze statt als Zeilen mit "\". flush_paragraph() standalone(clean_inline(line.rstrip(HARD_BREAK + PARAGRAPH_END))) continue if line.endswith(PARAGRAPH_END) and drop_page_numbers and PAGE_NUMBER.match( clean_inline(line.removesuffix(PARAGRAPH_END)) ): continue # Seitenzahl am Strophen-/Seitenende if line.endswith(PARAGRAPH_END): content = line.removesuffix(PARAGRAPH_END) if not paragraph and result and result[-1].endswith(HARD_BREAK): result.append(content) # letzte Zeile eines zeilengetreuen Blocks else: if not paragraph_open(): flush_paragraph() blank_line() paragraph.append(content.strip()) flush_paragraph() blank_line() continue if line.endswith(HARD_BREAK): # Gliederungstitel oder "Inhalt" bleiben Überschriften. check = clean_inline(line.removesuffix(HARD_BREAK)) key = norm_title(check) if CONTENTS_TITLE.match(check) or ( key in outline and key not in headings_seen and not in_contents ): line = line.removesuffix(HARD_BREAK) if line.endswith(HARD_BREAK) or line == STANZA_BREAK: # Zeilengetreue Zeile: Einrückung (geschützte Leerzeichen) behalten. content = line.removesuffix(HARD_BREAK) check = clean_inline(content) if in_contents: contents_seen.add(norm_title(check)) if drop_page_numbers and PAGE_NUMBER.match(check): continue if running_heads and running_head_key(check) in running_heads: continue if paragraph_open() and line != STANZA_BREAK: # Letzte Zeile eines Absatzes aus dem vorigen Block paragraph.append(content.strip()) flush_paragraph() result[-1] += HARD_BREAK continue flush_paragraph() while result and result[-1] == "" and len(result) > 1 and result[-2].endswith(HARD_BREAK): result.pop() # Zeilen aus getrennten Blöcken zusammenhalten if line == STANZA_BREAK: if result and result[-1].endswith(HARD_BREAK): result[-1] = result[-1].removesuffix(HARD_BREAK) blank_line() continue if not result or result[-1] == "" or result[-1].endswith(HARD_BREAK): result.append(line) else: blank_line() result.append(line) continue if not line.strip(" \t"): # Leerzeile nur dann als Absatzgrenze behandeln, # wenn der bisherige Fließtext mit einem Satzende endet. if paragraph and ends_sentence(paragraph[-1]): flush_paragraph() blank_line() elif not paragraph: blank_line() continue stripped = line.strip() if not in_contents: if drop_page_numbers and PAGE_NUMBER.match(clean_inline(stripped)): continue if running_heads and running_head_key(stripped) in running_heads: continue match = HEADING.match(line) text = clean_inline(match.group(2)) if match else clean_inline(stripped) if CONTENTS_TITLE.match(text): heading(len(match.group(1)) if match else 2, text) in_contents = True contents_table = False continue if match: key = norm_title(text) level = outline.get(key) if in_contents and level is not None and key not in contents_seen: # Überschrift-formatierter Eintrag im Inhaltsverzeichnis contents_seen.add(key) standalone(text) continue if not HAS_ALNUM.search(text): continue # Überschrift nur aus Zeichen ("# ||", OCR-Rauschen) if level is None and NUMBER_HEADING.match(text) and not heading_follows( lines, index ): # "## 90", "## XXIV": Fußnotenzeichen oder Seitenzahl, keine # Kapitelnummer (die steht direkt vor einer Titel-Überschrift). if drop_page_numbers: continue standalone(text) continue if level is None and is_false_heading( text, bool(outline), paragraph_open() ): # Satz(-rest) zurück in den Fließtext: setzt einen offenen # Absatz fort, sonst beginnt er einen neuen. in_contents = False if paragraph and not paragraph_open(): flush_paragraph() blank_line() paragraph.append(text) continue in_contents = False heading(level or len(match.group(1)), text) continue if OMITTED_IMAGE.match(line): standalone(line) continue if in_contents: if stripped.startswith("|"): # Verzeichnis als Tabelle: Zeilen unverändert übernehmen, # sonst zerfällt die Tabelle in einzelne Absätze. if not contents_table: flush_paragraph() blank_line() contents_table = True for cell in stripped.strip("|").split("|"): contents_seen.add(norm_title(cell)) result.append(line) continue if contents_table: in_contents = False # Tabelle zu Ende: Fließtext folgt blank_line() elif ends_sentence(text) and len(text) > 80: in_contents = False # erster Fließtext nach dem Verzeichnis else: for entry in split_contents_line(stripped, titles): contents_seen.add(norm_title(entry)) standalone(entry) continue if TOC_WITH_PAGE.match(line): standalone(line) continue # Eigenständige Zeile, die genau einem Gliederungstitel entspricht # (z. B. nur fett gesetzt): zur Überschrift machen. key = norm_title(stripped) if key in outline and key not in headings_seen and len(stripped) <= 150: heading(outline[key], text) continue # "Die Mauer fiel am" + "9. November ..." ist keine Liste, # "Liste:" + "1. Erstens" dagegen schon. continues_sentence = paragraph_open() and paragraph[-1][-1:].isalnum() if is_other_structure(line) and not ( ORDERED_ITEM.match(line) and continues_sentence ): if paragraph: flush_paragraph() blank_line() # pandoc braucht eine Leerzeile vor Listen/Tabellen result.append(line) continue # Einfache Zeilenumbrüche und nicht bestätigte Absatzgrenzen # werden beim Flush durch Leerzeichen ersetzt. paragraph.append(stripped) flush_paragraph() return finish_hard_breaks("\n".join(result)).strip() + "\n" # Überschrift nur aus einer Zahl ohne Punkt: arabisch oder römisch ii-xcix # (mindestens zwei Zeichen, damit Register-Buchstaben "C", "D", "M" bleiben). NUMBER_HEADING = re.compile( r"^(?:\d{1,4}|(?=[ivxlc]{2})(?:xc|xl|l?x{0,3})(?:ix|iv|v?i{0,3}))$", re.IGNORECASE, ) def heading_follows(lines: list[str], index: int) -> bool: """Ist die nächste nicht leere Zeile eine Überschrift?""" following = next((line for line in lines[index + 1:] if line.strip()), "") return bool(HEADING.match(following)) def continues_block(following: list[str], free: int, running_heads: set[str]) -> bool: """Geht der Satz im nächsten Block weiter? (erstes Wort passte nicht mehr)""" for line in following: text = line.strip(" \t") if not text or PAGE_NUMBER.match(clean_inline(text)): continue if running_heads and running_head_key(text) in running_heads: continue if heading := HEADING.match(text): text = heading.group(2) # oft falsch erkannt ("## trösten.") elif text.startswith(("|", "!", ">", "```", "~~~", "**==>")): return False elif LIST_ITEM.match(text) and not ORDERED_ITEM.match(text): return False # "14. Jahrhundert" ist dagegen oft keine Liste word = md_unescape(clean_inline(text)).replace(NBSP, " ").split() # Breite aus der vorigen Zeile geschätzt: ein Zeichen Spielraum. return bool(word) and len(word[0]) + 2 > free return False def finish_hard_breaks(markdown: str) -> str: """Markierungen harter Umbrüche in Markdown umsetzen. Folgt eine weitere zeilengetreue Zeile, wird daraus "\" (harter Umbruch); die letzte Zeile eines Abschnitts endet ohne. """ lines = markdown.split("\n") for i, line in enumerate(lines): if line.endswith(HARD_BREAK): text = line.removesuffix(HARD_BREAK).rstrip() following = lines[i + 1] if i + 1 < len(lines) else "" if following.strip(" \t") and not FENCE.match(following): text += "\\" lines[i] = text return MARKERS.sub("", "\n".join(lines)) # Sicherheitsnetz # =========================================================================== # PDF-Gliederung # =========================================================================== def load_outline(doc: pymupdf.Document) -> dict[str, int]: """PDF-Lesezeichen als {normalisierter Titel: Überschriftenebene}.""" original: dict[str, int] = {} for level, title, _page in doc.get_toc(simple=True): key = norm_title(title) if key: original.setdefault(key, level) # Teile und nummerierte Kapitel stehen oft auf derselben Ebene; # dann rücken die Kapitel eine Ebene tiefer. levels = dict(original) for level in set(original.values()): keys = [k for k, v in original.items() if v == level] if any(PART_TITLE.match(k) for k in keys): for key in keys: if NUMBERED_TITLE.match(key) and not PART_TITLE.match(key): levels[key] = level + 1 return levels # =========================================================================== # Links # =========================================================================== # pymupdf4llm übernimmt keine Link-Annotationen, und gedruckte URLs zerbrechen # beim Zeilenumbruch ("deutsche-|sehen" -> "deutschesehen", ".../ youtube-..."). # Wir gleichen URLs im Text mit den Linkzielen der PDF ab, ersetzen sie durch # das korrekte Ziel und schreiben sie als Autolink , den pandoc im # EPUB als echten Link ausgibt. URL_START = re.compile( r"(?-Links und [Text](...)-Zielen URL_TRAILING = ".,;:!?)]}\u00bb\u00ab\u201c\u201d\u201e\"'*_\\" # "\\": harter Zeilenumbruch MAX_URL_PARTS = 4 # eine URL kann über so viele Zeilen gebrochen sein # Ohne Linkziel: Folgewort gehört zur URL, wenn diese an einer typischen # Umbruchstelle endet und das Wort wie ein URL-Teil aussieht. URL_BREAK_AFTER = "/_-.=&?#" URL_CONTINUATION = re.compile(r"^(?:\S*/\S*|(?:html?|php|pdf|aspx?)\W*)$", re.IGNORECASE) # "und/oder", "Ein/Aus": nur Buchstaben und ein "/" - ein Wortpaar, kein URL-Pfad. WORD_PAIR = re.compile(r"^[^\W\d_]+/[^\W\d_]+$") def url_key(text: str) -> str: """Vergleichsschlüssel: Bindestriche, Leerraum und "/" am Ende zählen nicht.""" return re.sub(r"[-\s]", "", text).rstrip("/").lower() def uri_keys(uri: str) -> set[str]: """Schlüssel, unter denen ein Linkziel im gedruckten Text auftauchen kann. Gedruckt fehlen oft Abfrageteil ("?utm_source=...") oder Endung (".html"). """ base = uri.split("#")[0] keys = set() for variant in (uri, base, base.split("?")[0]): keys.add(url_key(variant)) keys.add(url_key(re.sub(r"\.(?:html?|php|aspx?)$", "", variant.rstrip("/")))) return keys def clean_uri(uri: str) -> str: """Tracking-Parameter (utm_...) entfernen.""" parts = urllib.parse.urlsplit(uri) if "utm_" not in parts.query.lower(): return uri query = [ (k, v) for k, v in urllib.parse.parse_qsl(parts.query, keep_blank_values=True) if not k.lower().startswith("utm_") ] return urllib.parse.urlunsplit(parts._replace(query=urllib.parse.urlencode(query))) def load_link_uris(doc: pymupdf.Document) -> list[str]: """Web-Linkziele der PDF (Link-Annotationen), ohne Dubletten.""" uris: dict[str, None] = {} for page in doc: for link in page.get_links(): uri = (link.get("uri") or "").strip() if re.match(r"https?://", uri, re.IGNORECASE): uris.setdefault(uri.replace(" ", "%20")) return list(uris) def _autolink(url: str) -> str: if url.lower().startswith("www."): return f"[{url}](https://{url})" return f"<{url}>" def _match_known(tokens: list[str], known: dict[str, str]) -> tuple[str, int] | None: """Längste Verbindung von Wörtern, die zu einem Linkziel der PDF passt.""" for count in range(min(MAX_URL_PARTS, len(tokens)), 0, -1): parts = [t.rstrip() for t in tokens[:count]] joined = "".join(parts) consumed = sum(map(len, tokens[: count - 1])) + len(parts[-1]) for strip in range(len(joined)): candidate = joined[: len(joined) - strip] if uri := known.get(url_key(candidate)): return uri, consumed - strip if candidate[-1] not in URL_TRAILING: break return None def _match_printed(tokens: list[str]) -> tuple[str, int]: """URL ohne (passendes) Linkziel: aus dem gedruckten Text zusammensetzen.""" parts = [tokens[0].rstrip()] for token in tokens[1:MAX_URL_PARTS]: word = token.strip().rstrip(URL_TRAILING) extension_only = "/" not in word # "html": nur nach "artikel." if ( parts[-1][-1] in URL_BREAK_AFTER and URL_CONTINUATION.match(token.rstrip()) and not WORD_PAIR.match(word) and not (extension_only and not parts[-1].endswith(".")) ): parts.append(token.rstrip()) else: break joined = "".join(parts) url = joined.rstrip(URL_TRAILING) consumed = sum(map(len, tokens[: len(parts) - 1])) + len(parts[-1]) return url, consumed - (len(joined) - len(url)) def _unescape_map(text: str) -> tuple[str, list[int]]: """Text ohne Markdown-Maskierung und Position jedes Zeichens im Original.""" plain, index, i = [], [], 0 while i < len(text): index.append(i) # Position samt Backslash, damit er beim Kopieren bleibt if text[i] == "\\" and i + 1 < len(text) and text[i + 1] in MD_SPECIAL: i += 1 plain.append(text[i]) i += 1 return "".join(plain), index + [len(text)] def _link_line(line: str, known: dict[str, str]) -> str: # URLs auf dem unmaskierten Text suchen ("Leitlinien\_" -> "Leitlinien_"), # den übrigen Text unverändert (maskiert) übernehmen. plain, index = _unescape_map(line) out = [] pos = 0 while match := URL_START.search(plain, pos): out.append(line[index[pos]:index[match.start()]]) tokens = re.findall(r"\S+\s*", plain[match.start():]) url, consumed = _match_known(tokens, known) or _match_printed(tokens) end = match.start() + consumed out.append(_autolink(url) if "." in url else line[index[match.start()]:index[end]]) pos = end out.append(line[index[pos]:]) return "".join(out) LIST_MARKER = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+") URL_PATH = re.compile(r"^[\w\-./%?=&#~+:]+$") def _url_tail(line: str) -> str | None: """URL am Zeilenende (unmaskiert), sonst None.""" plain = _unescape_map(line)[0].rstrip() tokens = plain.split() if not tokens: return None for n in range(len(tokens) - 1, -1, -1): if URL_START.search(tokens[n]): if all(URL_CONTINUATION.match(t) for t in tokens[n + 1:]): return "".join(tokens[n:]) return None return None def _join_split_urls(lines: list[str], known: dict[str, str]) -> list[str]: """URL, die in einem neuen Block weitergeht (oft als Listenpunkt "- ..."), mit ihrer Fortsetzung verbinden.""" i = 0 while i < len(lines): tail = _url_tail(lines[i]) j = i + 1 while j < len(lines) and not lines[j].strip() and j - i <= 2: j += 1 if tail is None or j >= len(lines): i += 1 continue nxt = lines[j] if HEADING.match(nxt) or FENCE.match(nxt) or IMAGE.match(nxt) or nxt.lstrip().startswith(("|", ">")): i += 1 continue rest = LIST_MARKER.sub("", nxt, count=1).lstrip(" \t" + NBSP) first = _unescape_map(rest)[0].split() if not first: i += 1 continue piece = first[0].rstrip(URL_TRAILING) joined = url_key(tail.rstrip(URL_TRAILING) + piece) if known: fits = url_key(tail.rstrip(URL_TRAILING)) not in known and any( key.startswith(joined) for key in known ) else: fits = ( tail.endswith(("-", "/", "_")) and URL_PATH.match(piece) is not None and (piece[:1].islower() or piece[:1].isdigit()) and any(c in piece for c in "-/.") and not WORD_PAIR.match(piece) ) if not fits: i += 1 continue lines[i] = lines[i].rstrip() + rest # ohne Leerzeichen: dieselbe URL del lines[i + 1:j + 1] return lines def link_urls(markdown: str, uris: list[str] | None = None) -> str: """URLs im Fließtext anklickbar machen und mit PDF-Linkzielen reparieren.""" known: dict[str, str] = {} for uri in uris or []: for key in uri_keys(uri): known.setdefault(key, clean_uri(uri)) lines = _join_split_urls(markdown.split("\n"), known) in_fence = False for i, line in enumerate(lines): if FENCE.match(line): in_fence = not in_fence elif not in_fence and not IMAGE.match(line): lines[i] = _link_line(line, known) return "\n".join(lines) # Links mit eigenem Text ("hier", "Bericht"): Linktext und die Wörter davor aus # der PDF lesen und die Stelle im Markdown als [Text](Ziel) markieren. ANCHOR_CONTEXT_WORDS = 3 URL_LIKE_WORD = re.compile(r"://|^www\.|/|\w\.(?:[a-z]{2,4})\.?$", re.IGNORECASE) def load_link_anchors(doc: pymupdf.Document) -> list[tuple[str, list[str], list[str]]]: """(Ziel, Wörter davor, Linkwörter) für Links, deren Text keine URL ist.""" anchors: list[tuple[str, list[str], list[str]]] = [] for page in doc: words = page.get_text("words", sort=True) links = [link for link in page.get_links() if (link.get("uri") or "").startswith("http")] merged: list[tuple[str, list[int]]] = [] # (Ziel, Indizes der Wörter) for link in links: rect = link["from"] hits = [ i for i, w in enumerate(words) if rect.contains(pymupdf.Point((w[0] + w[2]) / 2, (w[1] + w[3]) / 2)) ] if not hits: continue if merged and merged[-1][0] == link["uri"] and hits[0] <= merged[-1][1][-1] + 1: merged[-1][1].extend(i for i in hits if i not in merged[-1][1]) else: merged.append((link["uri"], hits)) for uri, hits in merged: text = [words[i][4] for i in sorted(hits)] if any(URL_LIKE_WORD.search(word) for word in text): continue # URL als Linktext: erledigt link_urls first = min(hits) before = [w[4] for w in words[max(0, first - ANCHOR_CONTEXT_WORDS):first]] anchors.append((clean_uri(uri), before, text)) return anchors def _md_literal(text: str) -> str: """Regex für Text, der im Markdown maskiert sein kann ("\\_", "\\*" ...).""" return "".join( (r"\\?" if char in MD_SPECIAL else "") + re.escape(char) for char in text ) def _words_pattern(words: list[str]) -> str: """Wörter mit beliebigem Leerraum, Hervorhebung oder Trennung dazwischen.""" parts = [] for word in words: core = word.rstrip("-\u00ad") # Silbentrennung am Zeilenende parts.append(_md_literal(core) + (r"-?" if core != word else "")) return r"[\s*_]*".join(parts) # Zwischen Kontextwörtern: Satzzeichen, Fußnotenziffern, Linkziele "](...)". CONTEXT_GAP = r"(?:[\W_\d]|\]\([^)]*\))*" def _context_pattern(words: list[str]) -> str: cores = [core for word in words for core in re.findall(r"[^\W\d_]+", word)] return CONTEXT_GAP.join(re.escape(core) for core in cores) def link_anchors(markdown: str, anchors: list[tuple[str, list[str], list[str]]]) -> str: """Linktexte im Markdown als [Text](Ziel) setzen, in Lesereihenfolge.""" pos = 0 for uri, before, text in anchors: anchor = r"(?P" + _words_pattern(text) + r")" patterns = [] if context := _context_pattern(before): patterns.append(re.compile(context + CONTEXT_GAP + anchor)) if len(" ".join(text)) >= 6: patterns.append(re.compile(r"(? tuple[int, int] | None: """Linktext mit Abweichungen (verlorene Ligatur: "Verifcation") finden: Anfang über Kontext und erste Wörter, Ende über das letzte Wort.""" if len(text) < 3 or not (context := _context_pattern(before)): return None head = re.compile(context + CONTEXT_GAP + r"(?P" + _words_pattern(text[:2]) + r")") match = next( (m for m in head.finditer(markdown, pos) if not _inside_link(markdown, m.start("anchor"))), None, ) if match is None: return None start = match.start("anchor") window = markdown[start:start + int(len(" ".join(text)) * 1.2) + 10] if "\n\n" in window: window = window[:window.index("\n\n")] # nicht über den Absatz hinaus last = None for last in re.finditer(_words_pattern(text[-1:]), window): pass return (start, start + last.end()) if last else None def _inside_link(markdown: str, index: int) -> bool: """Liegt die Stelle schon in einem Link (<...>, [..](...))?""" line_start = markdown.rfind("\n", 0, index) + 1 before = markdown[line_start:index] def count(char: str) -> int: # maskierte Zeichen ("\\[") zählen nicht return len(re.findall(r"(? count(">") or count("[") > count("]") or ( before.rfind("](") > before.rfind(")") ) # =========================================================================== # Korrektur für pymupdf4llm: Trennstriche am Zeilenende, OCR-Ebenen # =========================================================================== # pymupdf4llm entfernt jeden Bindestrich am Zeilenende ("Militär-" + Zeile # "Geheimdienst" -> "MilitärGeheimdienst"). Für Großbuchstaben und # Konjunktionen ("Arbeits- und") markieren wir den Strich vorher, sodass er # erhalten bleibt. Echte Silbentrennung (Kleinbuchstabe) bleibt unverändert. # Weiche Trennstriche (U+00AD) und U+00AC kennt pymupdf4llm nicht ("wach senden"); # sie trennen immer innerhalb eines Wortes und werden zu "-", das dann entfällt. # # Unsichtbarer Text (OCR-Ebene eines Scans) ist oft in Courier gesetzt; # pymupdf4llm hielte ihn sonst für Programmcode und setzte jedes Wort in `...`. KEEP_HYPHEN = "\ue000" # Strich behalten, ohne Leerzeichen anschließen KEEP_HYPHEN_SPACE = "\ue001" # Strich und Leerzeichen behalten SOFT_HYPHENS = ("\u00ad", "\u00ac") MONOSPACE_FLAG = 8 def _mark_line_end_hyphens(spans: list[dict]) -> list[dict]: marked = [] for i, span in enumerate(spans): nxt = spans[i + 1] if i + 1 < len(spans) else None if not span.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT: span = {**span, "flags": span.get("flags", 0) & ~MONOSPACE_FLAG} if not span.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT: # OCR-Text span = {**span, "text": OCR_NOISE.sub("", span["text"])} # Scanrand-"|" if not (span.get("flags", 0) & MONOSPACE_FLAG): # Code bleibt wörtlich span = {**span, "text": md_escape(span["text"])} text = span["text"].rstrip() line_end = nxt is not None and (span.get("block"), span.get("line")) != ( nxt.get("block"), nxt.get("line") ) if line_end and text.endswith(SOFT_HYPHENS): span = {**span, "text": text[:-1] + "-"} elif line_end and text.endswith("-"): word = FIRST_WORD.match(nxt["text"].lstrip()) last = text.split()[-1] if URL_START.search(last) or "/" in last: span = {**span, "text": text + KEEP_HYPHEN} # URLs trennt niemand elif word and word.group(0).lower() in CONJUNCTIONS: span = {**span, "text": text + KEEP_HYPHEN_SPACE} elif word and word.group(0)[0].isupper(): span = {**span, "text": text + KEEP_HYPHEN} marked.append(span) return marked def patch_pymupdf4llm_hyphens() -> None: original = getattr(document_layout, "get_styled_text", None) if original is None or getattr(original, "pdf2md_patched", False): return def get_styled_text(spans): output, suffix = original(_mark_line_end_hyphens(spans)) output = ( output.replace(KEEP_HYPHEN + " ", "") .replace(KEEP_HYPHEN, "") .replace(KEEP_HYPHEN_SPACE, "") ) return output, suffix get_styled_text.pdf2md_patched = True document_layout.get_styled_text = get_styled_text # Gedruckte Bücher trennen Absätze durch Einzug statt Leerraum. Auf Scanseiten # fasst das Layout-Modell dann mehrere Absätze zu einem Block zusammen, und # pymupdf4llm verbindet alle Zeilen. Wir trennen den Block vorher dort, wo # eine Zeile mit Satzende schließt und die nächste eingerückt ist bzw. die # vorige Zeile deutlich kürzer war. INDENT_MIN_EM = 0.8 # Einzug ab ca. 1 Geviert INDENT_MAX_EM = 8.0 # mehr ist eher zentrierter Text SHORT_LINE_RATIO = 0.7 # Absatzende: Zeile kürzer als 70 % der längsten def _line_text(line: dict) -> str: return "".join(span["text"] for span in line["spans"]).strip() def split_by_indent(textlines: list[dict], column_left: float | None = None) -> list[list[dict]]: """Zeilen eines Textblocks in Absätze aufteilen. column_left: linker Rand der Textspalte, falls bekannt - sonst der Blockrand (dann bleiben gleich eingerückte einzeilige Absätze unerkannt). """ if len(textlines) < 2: return [textlines] sizes = sorted(s["size"] for line in textlines for s in line["spans"]) em = sizes[len(sizes) // 2] if sizes else 10.0 lefts = sorted(line["bbox"][0] for line in textlines) left = lefts[len(lefts) // 4] # Blockrand, robust gegen Einzüge if column_left is not None: left = min(left, column_left) widths = [line["bbox"][2] - line["bbox"][0] for line in textlines] longest = max(widths) groups = [[textlines[0]]] for prev, line, prev_width in zip(textlines, textlines[1:], widths): indent = line["bbox"][0] - left indented = INDENT_MIN_EM * em <= indent <= INDENT_MAX_EM * em short = prev_width < SHORT_LINE_RATIO * longest if (indented or short) and ends_sentence(_line_text(prev)): groups.append([line]) else: groups[-1].append(line) return groups # --------------------------------------------------------------------------- # Zeilengetreue Abschnitte: Code, Register, Verse, Adressen ... # --------------------------------------------------------------------------- # Ein Zeilenumbruch stammt vom Satzprogramm (weich), wenn das erste Wort der # nächsten Zeile nicht mehr in die Zeile gepasst hätte. Sonst hat ihn der Autor # gesetzt (hart); dann bleibt die Zeile samt Einrückung erhalten. Überwiegen in # einem Block die harten Umbrüche, wird er zeilengetreu ausgegeben: als # Codeblock (```) oder als Zeilen mit hartem Umbruch ("\" am Zeilenende). HARD_BREAK = "\ue002" # Zeile endet mit hartem Umbruch (wird zu "\") BLOCK_END = "\ue004" # Block endet ohne Satzende: "\ue004[P]\ue004" BLOCK_END_MARK = re.compile("\ue004(\\d+)(P?)\ue004$") # P: mehrzeiliger Absatz PARAGRAPH_END = "\ue005" # Absatz endet hier, auch ohne Satzende MARKERS = re.compile("\ue004\\d*P?\ue004|[\ue002-\ue005]") # alle internen Markierungen STANZA_BREAK = "\ue003" # Leerzeile innerhalb eines zeilengetreuen Blocks NBSP = "\u00a0" TEXT_BOXES = ("text", "list-item", "footnote") # Spielraum beim "Hätte das Wort noch gepasst?": Satzprogramme brechen nicht # exakt um (Band 1, Flattersatz: bis 3 % der Spalte), Register und Verse # lassen fast immer weit mehr frei. FIT_TOLERANCE = 0.05 # Anteil der Spaltenbreite FIT_TOLERANCE_EM = 0.3 # mindestens HARD_BLOCK_RATIO = 0.5 # ab diesem Anteil harter Umbrüche: zeilengetreu STANZA_GAP = 1.6 # Zeilenabstand (x üblichem) für Leerzeile INDENT_STEP_EM = 0.8 # Einrückungsstufen: Abstand mindestens so groß CODE_INDENT = " " # Leerzeichen pro Einrückungsstufe im Code TEXT_INDENT = NBSP * 4 # geschützte Leerzeichen pro Stufe sonst CODE_HINT = re.compile( r"[{};]\s*$" # Zeile endet mit { } ; r"|^\s*[}\])]" # beginnt mit } ] ) r"|::|=>|->|==|!=|\+=|&&|\|\|" # Operatoren r"|^\s*(?:def|class|fn|let|mut|use|import|from|return|pub|impl|struct|" r"enum|match|const|var|function|elif|lambda|async|await|#include)\b" r"|^\s*(?:if|for|while|else|try|except|with)\b.*:\s*$" # Python-Block r"|^\s*(?:#\[|@\w+|//|/\*|\$ )" # Attribut, Dekorator, Kommentar r"|\w\([^()]*\)\s*$" # endet mit Aufruf f(x) ) # Zeilen rund um Code: Konfiguration (zählt erst ab zwei Zeilen als Code) ... CONFIG_LINE = re.compile( r"^\s*\[[\w.\-]+\]\s*$" # [dependencies] r"|^\s*[\w.\-]+\s*=\s*\S" # name = "wert" ) # ... sowie Befehle und Programmausgaben (auch als einzelne Zeile Code). COMMAND_LINE = re.compile( r"^\s*(?:\$|>>>|C:\\>)\s" # Eingabeaufforderung r"|^\s*(?:cargo|rustup|rustc|pip3?|python3?|pytest|npm|npx|git|sudo|" r"apt(?:-get)?|docker|conda|javac)\s+\S" # Befehl r"|^\s*(?:Compiling|Finished|Traceback|error(?:\[\w+\])?:|warning:)\s" r"|^\s*running \d+ tests?\b|^\s*test result:|\.\.\. (?:ok|FAILED|ignored)\s*$" ) _page_lines: list[tuple[float, float]] = [] # (x0, x1) der Textzeilen der Seite _page_text: list[tuple[float, float] | None] = [None] # Textbereich aus echten Zeilen _keep_lines = [False] # --keep-lines: jede PDF-Zeile bleibt eine Zeile REAL_WORD = re.compile(r"[^\W\d_]{3,}") def _dedupe_box_lines(boxes) -> None: """Überlappende Layout-Blöcke enthalten dieselben Zeilen doppelt (Gedichtseiten in Scans) - jede Zeile nur im ersten Block behalten.""" seen: set = set() for box in boxes: lines = getattr(box, "textlines", None) if not lines or getattr(box, "boxclass", None) not in TEXT_BOXES: continue kept = [] for line in lines: key = (tuple(round(v) for v in line["bbox"]), "".join(span["text"] for span in line["spans"])) if key not in seen: seen.add(key) kept.append(line) box.textlines = kept def _remember_page_lines(boxes) -> None: _dedupe_box_lines(boxes) _page_lines.clear() real: list[tuple[float, float]] = [] for box in boxes: if getattr(box, "boxclass", None) in TEXT_BOXES: for line in getattr(box, "textlines", None) or []: _page_lines.append((line["bbox"][0], line["bbox"][2])) text = "".join(span["text"] for span in line["spans"]) if len(REAL_WORD.findall(text)) >= 2: # keine Seitenzahl, kein Scan-Rauschen real.append((line["bbox"][0], line["bbox"][2])) _page_text[0] = (min(x0 for x0, _ in real), max(x1 for _, x1 in real)) if real else None def _visual_lines(textlines: list[dict]) -> list[dict]: """Textzeilen auf gleicher Höhe (z. B. Wechsel der Schrift) zusammenfassen.""" merged: list[dict] = [] for line in textlines: x0, y0, x1, y1 = line["bbox"] if merged: last = merged[-1] ly0, ly1 = last["bbox"][1], last["bbox"][3] if (y0 + y1) / 2 < ly1 and (y0 + y1) / 2 > ly0 and x0 >= last["bbox"][0]: bx0, by0, bx1, by1 = last["bbox"] last["bbox"] = (bx0, min(by0, y0), max(bx1, x1), max(by1, y1)) last["spans"] = last["spans"] + line["spans"] continue merged.append({"bbox": tuple(line["bbox"]), "spans": list(line["spans"])}) return merged def _raw_text(line: dict) -> str: """Text einer Zeile; Leerzeichen dort ergänzen, wo die PDF Wörter nur durch Abstand trennt (sonst "Openthefile." in Codeblöcken).""" text, prev = "", None for span in line["spans"]: if prev is not None and text and not text[-1].isspace() and not span["text"][:1].isspace(): if span["bbox"][0] - prev["bbox"][2] > 0.15 * span.get("size", 10): text += " " text += span["text"] prev = span return text def _char_width(line: dict, em: float) -> float: chars = sum(len(s["text"]) for s in line["spans"]) width = sum(s["bbox"][2] - s["bbox"][0] for s in line["spans"]) return width / chars if chars and width > 0 else 0.5 * em def _column_right(lines: list[dict], em: float) -> float: """Rechter Rand der Textspalte, in der der Block steht.""" bx0 = min(line["bbox"][0] for line in lines) bx1 = max(line["bbox"][2] for line in lines) edges = [ x1 for x0, x1 in _page_lines if x0 < bx1 and x1 > bx0 and x0 >= bx0 - 2 * em ] return max(edges + [bx1]) def _fit_tolerance(lines: list[dict], right: float, em: float) -> float: left = min(line["bbox"][0] for line in lines) return max(FIT_TOLERANCE_EM * em, FIT_TOLERANCE * (right - left)) def _column_left(lines: list[dict], em: float) -> float: """Linker Rand der Textspalte (Einzüge einzeiliger Absätze erkennen).""" bx0 = min(line["bbox"][0] for line in lines) bx1 = max(line["bbox"][2] for line in lines) edges = [x0 for x0, x1 in _page_lines if x0 < bx1 and x1 > bx0 and x0 >= bx0 - 10 * em] return min(edges + [bx0]) def _is_soft_break(prev: dict, line: dict, right: float, em: float, tol: float) -> bool: """Hätte das erste Wort der Zeile nicht mehr in die vorige gepasst?""" prev_text = _raw_text(prev).rstrip() text = _raw_text(line).strip() if not text: return True if prev_text.endswith(("-",) + SOFT_HYPHENS) and text[0].islower(): return True # Silbentrennung setzt nur das Satzprogramm word = text.split()[0] needed = (len(word) + 1) * _char_width(line, em) return prev["bbox"][2] + needed > right - tol def _indent_levels(lines: list[dict], em: float) -> list[int]: """Einrückung jeder Zeile als Stufe 0, 1, 2 ... (geometrisch).""" lefts = sorted({round(line["bbox"][0], 1) for line in lines}) stops: list[float] = [] for x in lefts: if not stops or x - stops[-1] >= INDENT_STEP_EM * em: stops.append(x) levels = [] for line in lines: x = line["bbox"][0] levels.append(max(i for i, stop in enumerate(stops) if stop <= x + 0.5)) return levels def _is_monospace(lines: list[dict]) -> bool: return all( s["flags"] & 8 and s.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT for line in lines for s in line["spans"] if s["text"].strip() ) def _is_code(lines: list[dict], ignore_code: bool, short: bool = False) -> bool: """Ganzer Block Code? short: kurzer Block - dann zählen auch Konfiguration, Befehle und Programmausgaben (sonst nur zeilenweise, siehe _mixed_block).""" if ignore_code: return False texts = [_raw_text(line) for line in lines] if _is_monospace(lines): return True # Schreibmaschinenschrift hints = sum( 1 for t in texts if not REGISTER_ENTRY.search(t) and CODE_HINT.search(t.replace(NBSP, " ")) or (short and (_code_like(t) or COMMAND_LINE.search(t))) ) indented = sum(1 for t in texts if t[:1] in (" ", NBSP, "\t")) return hints >= max(1, (len(texts) + 1) // 2) or (hints and indented) # Registereintrag ("match(), 247", "Closure, 128, 130-132"): nie Code. REGISTER_ENTRY = re.compile(r",\s*\d+(?:\s*[-\u2013]\s*\d+)?(?:\s*,\s*\d+(?:\s*[-\u2013]\s*\d+)?)*\s*$") def _code_like(text: str) -> bool: text = text.replace(NBSP, " ") if REGISTER_ENTRY.search(text): return False return bool(CODE_HINT.search(text) or CONFIG_LINE.search(text)) def _mixed_block( logical: list[list[dict]], levels: list[int], gaps: list[bool], end: str ) -> str | None: """Zeilengetreuer Block aus Text und Code (Anleitung, Konfiguration, Befehl, Ausgabe): Code-Abschnitte als ```-Block, den Rest als Zeilen.""" texts = [" ".join(_raw_text(line).strip() for line in parts) for parts in logical] strong = [bool(COMMAND_LINE.search(t)) and not REGISTER_ENTRY.search(t) for t in texts] code = [s or _code_like(t) for s, t in zip(strong, texts)] for i in range(1, len(code) - 1): # Zeile zwischen zwei Codezeilen gehört dazu, prose = texts[i].endswith(":") or ends_sentence(texts[i]) # außer Anleitung code[i] = code[i] or (code[i - 1] and code[i + 1] and not prose) segments: list[list] = [] # [start, ende, ist_code] for i, is_code in enumerate(code): if segments and segments[-1][2] == is_code: segments[-1][1] = i + 1 else: segments.append([i, i + 1, is_code]) for segment in segments: # einzelne Treffer ("@classmethod, 190") sind kein Code a, b, is_code = segment if is_code and b - a < 2 and not any(strong[a:b]): segment[2] = False if not any(is_code for _, _, is_code in segments): return None out = [] for n, (a, b, is_code) in enumerate(segments): last = n == len(segments) - 1 if is_code: base = min(levels[a:b]) out.append(_code_block(logical[a:b], [lvl - base for lvl in levels[a:b]])) else: out.append(_verbatim_block(logical[a:b], levels[a:b], gaps[a:b - 1], end if last else "")) return "".join(out) def _code_block(logical: list[list[dict]], levels: list[int]) -> str: rows = [] for parts, level in zip(logical, levels): text = " ".join(_raw_text(line).replace(NBSP, " ").rstrip() for line in parts) rows.append(CODE_INDENT * level + text) fence = "~~~" if any("```" in row for row in rows) else "```" return fence + "\n" + "\n".join(rows) + "\n" + fence + "\n\n" def _verbatim_block( logical: list[list[dict]], levels: list[int], gaps: list[bool], end: str ) -> str: rows = [] for i, (parts, level) in enumerate(zip(logical, levels)): spans = [span for line in parts for span in line["spans"]] text, _ = document_layout.get_styled_text(spans) lead = len(_raw_text(parts[0])) - len(_raw_text(parts[0]).lstrip()) row = TEXT_INDENT * level + NBSP * lead + text.strip() last = i == len(logical) - 1 row += (end or PARAGRAPH_END) if last else HARD_BREAK rows.append(row) if not last and gaps[i]: rows.append(STANZA_BREAK) return "\n".join(rows) + "\n\n" def _block_end(lines: list[dict], right: float, em: float, tol: float) -> str: """Markierung für ein Blockende ohne Satzende: freie Zeichen in der Zeile. Ob der Satz im nächsten Block weitergeht, entscheidet normalize_markdown mit dem ersten Wort dieses Blocks ("Hätte es noch gepasst?"). """ last = lines[-1] text = _raw_text(last).strip() if not text or ends_sentence(text) or text.endswith(("-",) + SOFT_HYPHENS): return "" free = (right - tol - last["bbox"][2]) / _char_width(last, em) paragraph = "P" if len(lines) > 1 else "" return f"{BLOCK_END}{max(0, int(free))}{paragraph}{BLOCK_END}" OCR_NOISE = re.compile(r"[|\u00a6]") # Buchkante/Seitenrand als "|" gelesen HAS_ALNUM = re.compile(r"[^\W_]") def _clean_ocr_lines(textlines: list[dict]) -> list[dict]: """OCR-Seiten: Zeilen mit Wörtern auf verschiedenen Grundlinien trennen (schiefer Scan: "UndDon tauchteweißen") und Rauschen entfernen.""" result = [] for line in textlines: groups: list[list[dict]] = [] for span in sorted(line["spans"], key=lambda s: s["origin"][1]): text = OCR_NOISE.sub("", span["text"]) if not text.strip(): continue span = {**span, "text": text} if groups and abs(span["origin"][1] - groups[-1][0]["origin"][1]) <= 0.4 * span["size"]: groups[-1].append(span) else: groups.append([span]) for spans in groups: spans.sort(key=lambda s: s["bbox"][0]) if not HAS_ALNUM.search("".join(s["text"] for s in spans)): continue # nur Rauschen (U+201E + "+", "%*") bbox = ( min(s["bbox"][0] for s in spans), min(s["bbox"][1] for s in spans), max(s["bbox"][2] for s in spans), max(s["bbox"][3] for s in spans), ) result.append({**line, "bbox": bbox, "spans": spans}) result.sort(key=lambda line: (line["bbox"][1], line["bbox"][0])) return result def _is_ocr_text(textlines: list[dict]) -> bool: """OCR-Text ist unsichtbar (Render-Modus 3) - wie utils.is_ocr_text in pymupdf4llm.""" spans = [s for line in textlines for s in line["spans"] if s["text"].strip()] return bool(spans) and not any(s.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT for s in spans) def _outside_text_area(lines: list[dict], em: float) -> bool: """Block ganz links/rechts neben dem Textbereich (OCR-Rauschen vom Scanrand).""" if _page_text[0] is None: return False left, right = _page_text[0] bx0 = min(line["bbox"][0] for line in lines) bx1 = max(line["bbox"][2] for line in lines) return bx1 < left - 0.5 * em or bx0 > right + 0.5 * em def lines_to_md(textlines: list[dict], original, ignore_code: bool = False) -> str: """Textblock ausgeben: Fließtext wie bisher, sonst zeilengetreu.""" ocr = _is_ocr_text(textlines) if ocr: textlines = _clean_ocr_lines(textlines) if not textlines: return "" lines = _visual_lines(textlines) sizes = sorted(s["size"] for line in lines for s in line["spans"]) em = sizes[len(sizes) // 2] if sizes else 10.0 if ocr and _outside_text_area(lines, em): return "" # OCR-Seite: Rauschen am Rand verwerfen if _keep_lines[0]: return _keep_lines_md(lines, em, ignore_code) right = _column_right(lines, em) tol = _fit_tolerance(lines, right, em) end = _block_end(lines, right, em, tol) # Harte Umbrüche zählen; ein Satzende mit eingerückter Folgezeile ist dagegen # ein Absatzende (Roman), ohne Einrückung eine Verszeile (Gedicht). soft = [_is_soft_break(a, b, right, em, tol) for a, b in zip(lines, lines[1:])] left = _column_left(lines, em) def new_paragraph(prev: dict, line: dict) -> bool: indent = line["bbox"][0] - left return ends_sentence(_raw_text(prev).strip()) and ( INDENT_MIN_EM * em <= indent <= INDENT_MAX_EM * em ) hard = sum( 1 for is_soft, a, b in zip(soft, lines, lines[1:]) if not is_soft and not new_paragraph(a, b) ) verbatim = bool(soft) and hard / len(soft) >= HARD_BLOCK_RATIO if not verbatim: if end and len(lines) <= 3 and _is_code(lines, ignore_code, short=True): return _code_block([lines], [0]) # kurze (umbrochene) Codezeile # Fließtext: Absätze am Einzug trennen, wie bisher ausgeben. groups = split_by_indent(textlines, left) md = "".join(original(group, ignore_code) for group in groups) return md.rstrip("\n").rstrip() + end + "\n\n" if end else md # Zeilengetreu: weich umbrochene Zeilen zu logischen Zeilen verbinden. logical: list[list[dict]] = [[lines[0]]] for is_soft, line in zip(soft, lines[1:]): if is_soft: logical[-1].append(line) else: logical.append([line]) firsts = [parts[0] for parts in logical] levels = _indent_levels(firsts, em) if _is_code(lines, ignore_code): return _code_block(logical, levels) pitches = [b["bbox"][1] - a["bbox"][1] for a, b in zip(firsts, firsts[1:])] usual = sorted(pitches)[len(pitches) // 2] if pitches else 0 gaps = [usual > 0 and pitch > STANZA_GAP * usual for pitch in pitches] if not ignore_code and (mixed := _mixed_block(logical, levels, gaps, end)): return mixed return _verbatim_block(logical, levels, gaps, end) def _keep_lines_md(lines: list[dict], em: float, ignore_code: bool) -> str: """--keep-lines: jede Zeile bleibt eine Zeile, nichts wird zusammengefügt. Strophen-/Absatzabstände werden Leerzeilen, Einrückungen bleiben. Ein Block mit mehreren Zeilen endet mit Absatzende (Strophe), eine Einzelzeile mit hartem Umbruch (sie kann zur nächsten Zeile gehören). """ logical = [[line] for line in lines] levels = _indent_levels(lines, em) if len(lines) > 1 and not ignore_code and _is_monospace(lines): return _code_block(logical, levels) pitches = [b["bbox"][1] - a["bbox"][1] for a, b in zip(lines, lines[1:])] usual = sorted(pitches)[len(pitches) // 2] if pitches else 0 gaps = [usual > 0 and pitch > STANZA_GAP * usual for pitch in pitches] end = PARAGRAPH_END if len(lines) > 1 else HARD_BREAK return _verbatim_block(logical, levels, gaps, end) def patch_pymupdf4llm_paragraphs() -> None: original = getattr(document_layout, "text_to_md", None) if original is None or getattr(original, "pdf2md_patched", False): return def text_to_md(textlines, ignore_code: bool = False): if not textlines or document_layout.is_superscripted(textlines[0]): return original(textlines, ignore_code) try: return lines_to_md(textlines, original, ignore_code) except (KeyError, IndexError, TypeError, ValueError, ZeroDivisionError): return original(textlines, ignore_code) # unerwartete Struktur text_to_md.pdf2md_patched = True document_layout.text_to_md = text_to_md levels = getattr(document_layout, "create_list_item_levels", None) if levels is not None and not getattr(levels, "pdf2md_patched", False): def create_list_item_levels(layout_info): _remember_page_lines(layout_info) # einmal pro Seite aufgerufen return levels(layout_info) create_list_item_levels.pdf2md_patched = True document_layout.create_list_item_levels = create_list_item_levels # =========================================================================== # Formeln seitenweise erkennen (--formulas) # =========================================================================== # pymupdf4llm schneidet nur Blöcke der Layout-Klasse "formula" als Bild aus. Darunter # sind auch Merkkästen mit Text und Grafiken, und Formeln im Fließtext bleiben (oft # verstümmelter) Text ("x4n" statt x^{4n}). Mit --formulas sucht die Formelerkennung # von pix2text (MFD) jede Formel auf der Seite, eingebettet oder abgesetzt, auch in # Kästen. Jede wird als Bild ausgeschnitten und in /formulas-*.json mit Seite # und Rechteck vermerkt; formula_img2latex übersetzt sie dann in LaTeX. FORMULA_DPI = 150 # Seitenbild für die Formelerkennung FORMULA_CROP_DPI = 300 # gespeicherter Ausschnitt (bleibt im Buch, solange keine Übersetzung bestätigt ist) FORMULA_MIN_SCORE = 0.3 # schwächere Treffer bleiben Text FORMULA_BOX_WORDS = 3 # so viele Wörter in Textschrift außerhalb der Treffer: "Formel"-Box ist ein Kasten FORMULA_FIGURE_PATHS = 3 # so viele gekrümmte/schräge Linien: "Formel"-Box ist eine Grafik FORMULA_FIGURE_WORDS = 40 # ... sofern sie nicht vor allem Text enthält # Mathematikschriften (TeX und Office); ihre Buchstaben ("lim", "sin") sind keine Wörter MATH_FONT = re.compile(r"CMMI|CMSY|CMEX|CMBSY|MSBM|MSAM|EUFM|EURM|EUSM|RSFS|Math|math|Symbol|SY\d|rsfs|" r"txsy|txex|pxsy|pxex|txmi|pxmi|NewTXMI|NewPXMI|STIX.*Math|MTMI|MTSY|MTEX|LMMath") FOOTNOTE_MARK = re.compile(r"[\d*\u2020\u2021\u00a7]+") # Fußnotenzeichen: Ziffern, *, Kreuze, Paragraph _formulas: dict = {"on": False, "detector": None, "docs": {}, "entries": {}, "tokens": {}} TEXT_LIKE_BOXES = ("text", "list-item", "footnote", "title", "section-header", "caption") def _formula_detector(): if _formulas["detector"] is None: from pix2text import MathFormulaDetector # optional und schwer: nur mit --formulas laden _formulas["detector"] = MathFormulaDetector() return _formulas["detector"] def detect_formulas(page: pymupdf.Page) -> list[dict]: """Formeln auf der Seite: [{"rect": Rect in pt, "type": "embedding"|"isolated", "score": float}].""" from PIL import Image pix = page.get_pixmap(dpi=FORMULA_DPI) img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples) f = 72.0 / FORMULA_DPI hits = [] for hit in _formula_detector().detect(img): if hit["score"] < FORMULA_MIN_SCORE: continue xs = [p[0] for p in hit["box"]] ys = [p[1] for p in hit["box"]] hits.append({"rect": pymupdf.Rect(min(xs) * f, min(ys) * f, max(xs) * f, max(ys) * f), "type": hit["type"], "score": float(hit["score"])}) return hits def _hit_for(bbox, hits: list[dict]) -> int | None: """Index des Treffers, in dem die Mitte von bbox liegt.""" x, y = (bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2 for k, hit in enumerate(hits): r = hit["rect"] if r.x0 - 1 <= x <= r.x1 + 1 and r.y0 - 1 <= y <= r.y1 + 1: return k return None def _text_words(textlines: list[dict], hits: list[dict]) -> int: """Wörter in Textschrift außerhalb der Formeltreffer.""" return sum(len(REAL_WORD.findall(span["text"])) for line in textlines for span in line["spans"] if not MATH_FONT.search(span.get("font", "")) and _hit_for(span["bbox"], hits) is None) def _figure_paths(page: pymupdf.Page, rect: pymupdf.Rect) -> int: """Gekrümmte oder schräge Linien in rect: Kurven, Achsen mit Pfeilen. Bruch- und Wurzelstriche sind waagerecht, Rahmen und Hinterlegungen Rechtecke.""" n = 0 for path in page.get_drawings(): if not rect.contains(path["rect"]): continue for item in path["items"]: if item[0] == "c" or (item[0] == "l" and abs(item[1].y - item[2].y) > 0.5 and abs(item[1].x - item[2].x) > 0.5): n += 1 break return n def _formula_token(page: pymupdf.Page, hits: list[dict], k: int, image_path: str, filename: str, chars: list | None = None) -> str: """Platzhalter für Treffer k; schneidet ihn beim ersten Mal aus und vermerkt ihn. Nur Buchstaben und Ziffern: übersteht jede Textausgabe und Maskierung, finish_formula_links macht das Bild daraus.""" token = f"FORMELBILD{page.number + 1:04d}N{k:03d}" if token not in _formulas["tokens"]: hit = hits[k] rect = hit["rect"] + (-1, -1, 1, 1) name = f"{filename}-{page.number + 1:04d}-f{k:03d}.png" md_name, save_name = document_layout.utils.md_path(image_path, name) page.get_pixmap(dpi=FORMULA_CROP_DPI, clip=rect).save(save_name) text, flat = _formula_text(hit["rect"], chars or []) _formulas["entries"][Path(save_name).name] = {"page": page.number + 1, "rect": [round(v, 2) for v in rect], "type": hit["type"], "score": round(hit["score"], 3), "text": text, "flat": flat} _formulas["tokens"][token] = (md_name, hit["type"] == "isolated") return token def _page_chars(page: pymupdf.Page) -> list[tuple[pymupdf.Rect, str, float, float]]: """Alle Zeichen der Seite mit Position, Grundlinie und Größe (die Textstücke der PDF mischen Text und Formel: "if 0").""" chars = [] for block in page.get_text("rawdict")["blocks"]: for line in block.get("lines", []): for span in line["spans"]: chars.extend((pymupdf.Rect(c["bbox"]), c["c"], c["origin"][1], span["size"]) for c in span["chars"]) return chars def _formula_text(rect: pymupdf.Rect, chars: list) -> tuple[str, bool]: """Text der PDF-Textebene im Formelrechteck und ob er "flach" ist: alle Zeichen auf einer Grundlinie in gleicher Größe, also ohne Hoch-/Tiefstellung, Bruch oder Grenzen, die die Textebene verlieren würde.""" inside = [ch for ch in chars if rect.contains(pymupdf.Point((ch[0].x0 + ch[0].x1) / 2, (ch[0].y0 + ch[0].y1) / 2))] inside.sort(key=lambda ch: (round(ch[2]), ch[0].x0)) text = "".join(ch[1] for ch in inside).strip() flat = bool(inside) and max(ch[2] for ch in inside) - min(ch[2] for ch in inside) <= 0.6 \ and max(ch[3] for ch in inside) - min(ch[3] for ch in inside) <= 0.2 return text, flat def _span_chars(span: dict, chars: list[tuple[pymupdf.Rect, str]]) -> list[tuple[pymupdf.Rect, str]]: """Die Zeichen eines Textstücks mit Position; sonst gleichmäßig über seine Breite verteilt.""" box = pymupdf.Rect(span["bbox"]) + (-0.5, -0.5, 0.5, 0.5) inside = [(ch[0], ch[1]) for ch in chars if box.contains(pymupdf.Point((ch[0].x0 + ch[0].x1) / 2, (ch[0].y0 + ch[0].y1) / 2))] inside.sort(key=lambda rc: rc[0].x0) if "".join(c for _, c in inside) == span["text"]: return inside x0, y0, x1, y1 = span["bbox"] n = max(1, len(span["text"])) w = (x1 - x0) / n return [(pymupdf.Rect(x0 + i * w, y0, x0 + (i + 1) * w, y1), c) for i, c in enumerate(span["text"])] def _replace_formula_spans(page, textlines: list[dict], hits: list[dict], chars, image_path: str, filename: str ) -> list[dict]: """Zeichen innerhalb eines Treffers durch einen Platzhalter ersetzen (einen je Treffer und Seite); gemischte Textstücke werden dabei geteilt. Eine Formel über mehrere Textzeilen (Summe mit Grenzen) steht in der Zeile, deren Mitte ihrer Mitte am nächsten liegt.""" split: list[list[tuple[dict, list]]] = [] # je Zeile: (Textstück, [[treffer oder None, zeichen]]) anchor: dict[int, tuple[float, int]] = {} # treffer -> (abstand, zeile) for n, line in enumerate(textlines): parts = [] for span in line["spans"]: if not any(pymupdf.Rect(span["bbox"]).intersects(h["rect"]) for h in hits): parts.append((span, [[None, None]])) continue groups: list[list] = [] for rect, c in _span_chars(span, chars): k = _hit_for(tuple(rect), hits) if groups and groups[-1][0] == k: groups[-1][1].append((rect, c)) else: groups.append([k, [(rect, c)]]) for k, _ in groups: if k is not None: mid = (line["bbox"][1] + line["bbox"][3]) / 2 dist = abs(mid - (hits[k]["rect"].y0 + hits[k]["rect"].y1) / 2) if k not in anchor or dist < anchor[k][0]: anchor[k] = (dist, n) parts.append((span, groups)) split.append(parts) out = [] for n, (line, parts) in enumerate(zip(textlines, split)): spans = [] has_formula = any(k is not None for _, groups in parts for k, _ in groups) for span, groups in parts: if groups == [[None, None]]: if has_formula and span["flags"] & pymupdf.TEXT_FONT_SUPERSCRIPT and not FOOTNOTE_MARK.fullmatch(span["text"].strip()): span = dict(span, flags=span["flags"] & ~pymupdf.TEXT_FONT_SUPERSCRIPT) # Wort neben hoher Formel spans.append(span) continue for k, part in groups: if k is None: text = "".join(c for _, c in part) flags = span["flags"] if not FOOTNOTE_MARK.fullmatch(text.strip()): # neben hoher Formel nicht hochgestellt (Fußnote: "22") flags &= ~pymupdf.TEXT_FONT_SUPERSCRIPT spans.append(dict(span, text=text, flags=flags, bbox=tuple(pymupdf.Rect(part[0][0]) | part[-1][0]))) elif anchor[k][1] == n and k not in _formulas["page_done"]: _formulas["page_done"].add(k) spans.append(dict(span, text=_formula_token(page, hits, k, image_path, filename, chars), flags=0, char_flags=0, font="Formula", bbox=tuple(hits[k]["rect"]))) if any(sp["text"].strip() for sp in spans): out.append(dict(line, spans=spans)) return out def _font_hits(textlines: list[dict], hits: list[dict]) -> list[dict]: """Formeln in Mathematikschrift, die die Formelerkennung übersehen hat (einzelnes θ, π, "n ≥ m"): Textstücke in Mathematikschrift samt angrenzender kurzer kursiver Buchstaben und Ziffern.""" found = [] for line in textlines: spans = line["spans"] math = [bool(MATH_FONT.search(sp.get("font", ""))) and sp["text"].strip() != "" and _hit_for(sp["bbox"], hits) is None for sp in spans] i = 0 while i < len(spans): if not math[i]: i += 1 continue a = b = i while a > 0 and _short_math_neighbor(spans[a - 1], hits): a -= 1 while b + 1 < len(spans) and (math[b + 1] or _short_math_neighbor(spans[b + 1], hits)): b += 1 rect = pymupdf.Rect(spans[a]["bbox"]) for sp in spans[a:b + 1]: rect |= pymupdf.Rect(sp["bbox"]) found.append({"rect": rect, "type": "embedding", "score": 0.0}) i = b + 1 return found def _short_math_neighbor(span: dict, hits: list[dict]) -> bool: """Kursiver Einzelbuchstabe, Ziffern oder Operator neben Mathematikschrift ("n" in "n ≥ m").""" text = span["text"].strip() return (_hit_for(span["bbox"], hits) is None and 0 < len(text) <= 3 and not REAL_WORD.search(text) and (bool(span["flags"] & pymupdf.TEXT_FONT_ITALIC) or bool(re.fullmatch(r"[\d.,+\-=<>()]+", text)))) def _prepare_formula_page(page: pymupdf.Page, layout, image_path: str, filename: str) -> None: """Formeln der Seite finden; Boxen der Klasse "formula" neu einordnen (Kasten mit Text -> Text, Grafik -> Bild, sonst abgesetzte Formel) und in allen Textboxen die Formeln durch Platzhalter ersetzen.""" hits = detect_formulas(page) _formulas["page_done"] = set() chars = _page_chars(page) if hits else [] for box in layout.boxes: if box.boxclass == "formula": rect = pymupdf.Rect(box.x0, box.y0, box.x1, box.y1) lines = [{"bbox": ln[0], "spans": ln[1]} for ln in document_layout.get_raw_lines(textpage=None, blocks=layout.fulltext, clip=rect, ignore_invisible=False)] words = _text_words(lines, hits) if _figure_paths(page, rect) >= FORMULA_FIGURE_PATHS and words < FORMULA_FIGURE_WORDS: box.boxclass = "picture" elif words >= FORMULA_BOX_WORDS: if isinstance(box.image, str): # pymupdf4llm hat den ganzen Kasten schon als Bild gespeichert Path(box.image).unlink(missing_ok=True) box.boxclass, box.image, box.textlines = "text", None, lines if box.boxclass in TEXT_LIKE_BOXES and box.textlines: extra = _font_hits(box.textlines, hits) if extra and not chars: chars = _page_chars(page) hits.extend(extra) if hits: box.textlines = _replace_formula_spans(page, box.textlines, hits, chars, image_path, filename) def patch_pymupdf4llm_formulas() -> None: parse = document_layout.parse_document if getattr(parse, "pdf2md_formulas", False): return def parse_document(doc, *args, **kwargs): parsed = parse(doc, *args, **kwargs) if _formulas["on"]: if not isinstance(doc, pymupdf.Document): doc = _formulas["docs"].setdefault(str(doc), pymupdf.open(doc)) for layout in parsed.pages: _prepare_formula_page(doc[layout.page_number - 1], layout, parsed.image_path, parsed.filename) return parsed parse_document.pdf2md_formulas = True document_layout.parse_document = parse_document FORMULA_TOKEN = re.compile(r"[ \t]*FORMELBILD\d{4}N\d{3}[ \t]*") def finish_formula_links(markdown: str) -> str: """Platzhalter -> Bildverweis; abgesetzte Formeln aus Textblöcken (Merkkästen) in einen eigenen Absatz.""" def link(m: re.Match) -> str: token = m.group(0).strip() md_name, isolated = _formulas["tokens"].get(token, (None, False)) if md_name is None: return m.group(0) return f"\n\n![]({md_name})\n\n" if isolated else m.group(0).replace(token, f"![]({md_name})") return FORMULA_TOKEN.sub(link, markdown) def write_formula_list(image_path: str, first_page: int) -> None: """Die Formeln dieses Arbeitsprozesses als /formulas-.json.""" if _formulas["entries"]: out = Path(image_path) / f"formulas-{first_page + 1:04d}.json" out.write_text(json.dumps(_formulas["entries"], indent=1), encoding="utf-8") _formulas["entries"] = {} class _DiscardMessages(io.StringIO): """Ersatz für pymupdf4llms Meldungspuffer ("OCR on page ...").""" def write(self, text: str) -> int: return len(text) # JPEG-Qualität: pymupdf4llm speichert Bilder mit Pixmap.save() ohne Angabe # der Qualität (Standard 95); wir setzen sie über --jpeg-quality. DEFAULT_JPEG_QUALITY = 85 _jpeg_quality = [DEFAULT_JPEG_QUALITY] _pixmap_save = pymupdf.Pixmap.save def _save_pixmap(self, filename, output=None, jpg_quality=None): quality = _jpeg_quality[0] if jpg_quality is None else jpg_quality return _pixmap_save(self, filename, output, quality) pymupdf.Pixmap.save = _save_pixmap patch_pymupdf4llm_hyphens() patch_pymupdf4llm_paragraphs() patch_pymupdf4llm_formulas() document_layout.INFO_MESSAGES = _DiscardMessages() # =========================================================================== # Seiten klassifizieren und OCR planen # =========================================================================== PAGE_TEXT = "text" # digitale Textseite PAGE_OCR_LAYER = "ocr-layer" # Scan mit vorhandener (unsichtbarer) OCR-Ebene PAGE_SCAN = "scan" # Bildseite ohne Text PAGE_EMPTY = "empty" MIN_TEXT_CHARS = 50 MAX_BAD_CHAR_RATIO = 0.1 MIN_IMAGE_COVER = 0.3 MIN_OCR_LAYER_CHARS = 20 PLAUSIBLE_MIN_CHARS = 40 PLAUSIBLE_MIN_WORDS = 20 # Cover/Titelbilder haben weniger Wörter PLAUSIBLE_WORD_RATIO = 0.7 PLAUSIBLE_FUNCTION_RATIO = 0.1 WORDLIKE = re.compile(r"^\W*([^\W\d_]{2,}(?:[-'\u2019][^\W\d_]+)*)\W*$") # Häufige Funktionswörter: In echtem Fließtext machen sie gut 10 % aus, # im "Text", den die OCR aus Fotos oder Rauschen liest, praktisch nie. FUNCTION_WORDS = { "deu": set( "der die das den dem des ein eine einen einem einer und oder aber in im " "an am auf aus bei mit von zu zum zur für über unter vor nach ist war " "sind waren hat hatte wird wurde nicht er sie es ich wir ihr du man sich " "so wie als auch noch nur schon dass daß wenn da dann doch ja".split() ), "eng": set( "the a an and or but in on at of to for with is was are were be been he " "she it i we you they not that this as by from his her had have".split() ), "fra": set( "le la les un une et ou de du des en dans est sont pas que qui il elle " "au aux ce se sur pour par avec".split() ), "spa": set( "el la los las un una y o de del en es son no que se al por con para " "su lo como".split() ), } SKEW_TOLERANCE = 0.002 # sin(Winkel), ca. 0,1 Grad MAX_SKEWED_LINES = 0.2 # mehr schiefe Zeilen -> OCR-Ebene neu erkennen VISIBLE_TEXT = pymupdf.mupdf.FZ_STEXT_FILLED | pymupdf.mupdf.FZ_STEXT_STROKED # Verarbeitung pro Seite RUN_PLAIN = "plain" # vorhandenen Text verwenden, keine OCR RUN_OCR = "ocr" # Tesseract-OCR RUN_REUSE = "reuse" # vorhandene OCR-Ebene übernehmen CHUNK_SIZE = {RUN_PLAIN: 50, RUN_OCR: 10, RUN_REUSE: 25} def classify_page(page: pymupdf.Page) -> str: visible = invisible = bad = 0 for block in page.get_text("dict")["blocks"]: for line in block.get("lines", []): for span in line["spans"]: text = span["text"].strip() if not text: continue if span["char_flags"] & VISIBLE_TEXT and span.get("alpha", 255): visible += len(text) bad += text.count("\ufffd") else: invisible += len(text) image_rect = pymupdf.EMPTY_RECT() for kind, bbox in page.get_bboxlog(): if kind in ("fill-image", "fill-imgmask"): image_rect |= pymupdf.Rect(bbox) & page.rect cover = abs(image_rect) / abs(page.rect) if abs(page.rect) else 0.0 if visible >= MIN_TEXT_CHARS: return PAGE_SCAN if bad / visible > MAX_BAD_CHAR_RATIO else PAGE_TEXT if cover >= MIN_IMAGE_COVER: return PAGE_OCR_LAYER if invisible >= MIN_OCR_LAYER_CHARS else PAGE_SCAN return PAGE_TEXT if visible else PAGE_EMPTY def is_wordlike(token: str) -> bool: """Buchstabenwort mit üblicher Schreibweise (nicht "erNee", "eeBR").""" match = WORDLIKE.match(token) if not match: return False core = re.sub(r"[-'\u2019]", "", match.group(1)) return core.islower() or core.isupper() or core.istitle() def skewed_line_ratio(page: pymupdf.Page) -> float: """Anteil schräg liegender Textzeilen (z. B. OCR-Ebene eines schiefen Scans).""" total = skewed = 0 for block in page.get_text("dict")["blocks"]: for line in block.get("lines", []): total += 1 skewed += abs(line["dir"][1]) > SKEW_TOLERANCE return skewed / total if total else 0.0 def looks_like_text(text: str, language: str = "deu") -> bool: """Plausibilitätsprüfung: Ergibt die OCR echten Text oder Kauderwelsch?""" words = text.split() if len(text.strip()) < PLAUSIBLE_MIN_CHARS or len(words) < PLAUSIBLE_MIN_WORDS: return False if sum(map(is_wordlike, words)) / len(words) < PLAUSIBLE_WORD_RATIO: return False function_words = set().union( *(FUNCTION_WORDS.get(lang, set()) for lang in language.split("+")) ) if not function_words: return True # Sprache ohne Wortliste: nur die Wortform zählt lowered = [w.strip(".,;:!?\u00bb\u00ab\u201e\u201c\u201d\"'\u201a\u2018\u2019()").lower() for w in words] hits = sum(1 for w in lowered if w in function_words) return hits / len(words) >= PLAUSIBLE_FUNCTION_RATIO def check_ocr_language(language: str) -> None: try: tessdata = pymupdf.get_tessdata() except Exception: tessdata = None if not tessdata: raise RuntimeError( "Tesseract nicht gefunden - installieren mit: " "sudo apt install tesseract-ocr tesseract-ocr-deu" ) missing = [ lang for lang in language.split("+") if not (Path(tessdata) / f"{lang}.traineddata").is_file() ] if missing: packages = " ".join(f"tesseract-ocr-{lang}" for lang in missing) raise RuntimeError( f"Tesseract-Sprachdaten fehlen: {', '.join(missing)} - " f"installieren mit: sudo apt install {packages}" ) def probe_ocr(page: pymupdf.Page, language: str, dpi: int) -> bool: textpage = page.get_textpage_ocr(language=language, dpi=dpi, full=True) return looks_like_text(page.get_text(textpage=textpage), language) def plan_pages( doc: pymupdf.Document, mode: str, language: str, dpi: int ) -> tuple[list[str], list[str]]: """Seitentypen ermitteln und pro Seite die Verarbeitung festlegen.""" kinds = [classify_page(page) for page in doc] counts = Counter(kinds) if mode == "never": return kinds, [RUN_REUSE if k == PAGE_OCR_LAYER else RUN_PLAIN for k in kinds] if mode == "always": check_ocr_language(language) return kinds, [RUN_PLAIN if k == PAGE_EMPTY else RUN_OCR for k in kinds] # auto if counts[PAGE_SCAN] or counts[PAGE_OCR_LAYER]: check_ocr_language(language) scanned_doc = counts[PAGE_SCAN] + counts[PAGE_OCR_LAYER] > counts[PAGE_TEXT] plan = [] for page, kind in zip(doc, kinds): if kind == PAGE_OCR_LAYER: # Gute OCR-Ebenen übernehmen; schiefe (Scan nicht entzerrt) # liefern eine falsche Lesereihenfolge -> neu erkennen. skewed = skewed_line_ratio(page) > MAX_SKEWED_LINES plan.append(RUN_OCR if skewed else RUN_REUSE) elif kind == PAGE_SCAN and (scanned_doc or probe_ocr(page, language, dpi)): # In digitalen PDFs sind reine Bildseiten meist Fotos/Cover; # die werden nur übernommen, wenn die Probe-OCR echten Text liefert. plan.append(RUN_OCR) else: plan.append(RUN_PLAIN) return kinds, plan def make_tasks(plan: list[str]) -> list[tuple[str, list[int]]]: """Aufeinanderfolgende Seiten gleicher Verarbeitung zu Paketen bündeln.""" tasks: list[tuple[str, list[int]]] = [] for pno, run in enumerate(plan): if ( tasks and tasks[-1][0] == run and tasks[-1][1][-1] == pno - 1 and len(tasks[-1][1]) < CHUNK_SIZE[run] ): tasks[-1][1].append(pno) else: tasks.append((run, [pno])) return tasks # =========================================================================== # Konvertierung mit pymupdf4llm # =========================================================================== def keep_existing_ocr(page, **kwargs) -> None: """OCR-Funktion, die nichts tut: Die vorhandene OCR-Ebene wird genutzt. pymupdf4llm verwendet unsichtbaren Text nur auf Seiten, die als OCR-Seiten gelten; mit dieser Funktion entfällt eine zweite OCR. """ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> list[str]: """Markdown je Seite (in der Reihenfolge von pages).""" _jpeg_quality[0] = options.get("jpeg_quality", DEFAULT_JPEG_QUALITY) # auch im Worker _keep_lines[0] = options.get("keep_lines", False) _formulas["on"] = bool(options.get("formulas")) kwargs = dict( pages=pages, header=options["keep_headers"], footer=options["keep_footers"], write_images=options["images"], image_path=options["image_path"], image_format=options["image_format"], # Text je Seite; aneinandergehängt identisch mit page_chunks=False page_chunks=True, ) if run == RUN_OCR: # FORCE_DROP_OLD: vorhandene (schlechte) OCR-Ebene verwerfen und neu # erkennen; force_ocr=True hieße FORCE_KEEP_OLD und behielte den alten Text. kwargs.update( use_ocr=OCRMode.FORCE_DROP_OLD, ocr_function=tesseract_api.exec_ocr, ocr_language=options["ocr_language"], ocr_dpi=options["ocr_dpi"], ) elif run == RUN_REUSE: kwargs.update(force_ocr=True, ocr_function=keep_existing_ocr) else: kwargs.update(use_ocr=False) if _formulas["on"]: texts = _convert_pages(source, run, pages, options, kwargs) write_formula_list(options["image_path"], pages[0]) return [finish_formula_links(t) for t in texts] return _convert_pages(source, run, pages, options, kwargs) def _convert_pages(source: str, run: str, pages: list[int], options: dict, kwargs: dict) -> list[str]: # pymupdf4llm schreibt Statusmeldungen auf stdout; wir melden selbst. with contextlib.redirect_stdout(io.StringIO()): if run != RUN_OCR: return [chunk["text"] for chunk in pymupdf4llm.to_markdown(source, **kwargs)] if options.get("keep_lines"): with pymupdf.open(source) as doc: return [ ocr_lines_md( doc[pno], options["ocr_language"], options["ocr_dpi"], options.get("titles", {}).get(pno, []), ) for pno in pages ] # Fremde OCR-Ebenen entfernt pymupdf4llm nicht (nur eigene); sonst stünde # alter und neuer Text doppelt da. Nur die geöffnete Kopie wird geändert. with pymupdf.open(source) as doc: for pno in pages: drop_invisible_text(doc[pno]) return [chunk["text"] for chunk in pymupdf4llm.to_markdown(doc, **kwargs)] # --keep-lines auf Scanseiten: Zeilen direkt aus Tesseract, ohne Layout-Modell. # Dieses legt bei Gedicht-Scans eng stehende Verse zusammen ("UndDon tauchte...") # und gibt Zeilen sich überlappender Blöcke doppelt aus. OCR_TITLE_LINES = 8 # Titel suchen wir unter den ersten Zeilen der Seite OCR_STANZA_GAP = 1.4 # Grundlinienabstand (x üblichem) für eine Strophengrenze PAGE_LABEL = re.compile(r"^seite\s+\d+$", re.IGNORECASE) # Gliederungseintrag "Seite 4" def _ocr_segments(page: pymupdf.Page, language: str, dpi: int) -> list[list]: """Textzeilen-Stücke [x0, y0, x1, y1, text] aus Tesseract, ohne Rauschen.""" pix = page.get_pixmap(dpi=dpi) # RGB: MuPDFs OCR liest Graustufen nicht segments = [] with pymupdf.open("pdf", pix.pdfocr_tobytes(language=language)) as ocr: for block in ocr[0].get_text("dict")["blocks"]: for line in block.get("lines", []): text = OCR_NOISE.sub("", "".join(s["text"] for s in line["spans"])) text = re.sub(r"\s+", " ", text).strip() if HAS_ALNUM.search(text): # Grundlinie für Zeilen- und Strophenabstand segments.append([*line["bbox"], text, line["spans"][0]["origin"][1]]) real = [seg for seg in segments if len(REAL_WORD.findall(seg[4])) >= 2] if real: # Spalte aus echten Zeilen; Fetzen am Scanrand liegen außerhalb left = min(seg[0] for seg in real) right = max(seg[2] for seg in real) height = sorted(seg[3] - seg[1] for seg in real)[len(real) // 2] segments = [ seg for seg in segments if seg[2] > left - height and seg[0] < right + height and (len(REAL_WORD.findall(seg[4])) >= 1 or PAGE_NUMBER.match(seg[4])) ] return segments def _ocr_rows(segments: list[list]) -> list[list]: """Stücke auf gleicher Höhe zu Zeilen verbinden: [x0, y0, x1, y1, text]. Verglichen wird mit dem Mittelpunkt des ersten Stücks einer Zeile - ein wachsender Bereich würde sonst die Nachbarzeilen mit einsammeln.""" if not segments: return [] heights = sorted(seg[3] - seg[1] for seg in segments) height = heights[len(heights) // 2] rows: list[list] = [] # [x0, y0, x1, y1, stücke, mittelpunkt] for seg in sorted(segments, key=lambda s: (s[1] + s[3]) / 2): center = (seg[1] + seg[3]) / 2 row = next((r for r in rows if abs(r[5] - center) < 0.5 * height and all(seg[0] >= o[2] or seg[2] <= o[0] for o in r[4])), None) if row is None: rows.append([*seg[:4], [seg], center]) else: row[0], row[1] = min(row[0], seg[0]), min(row[1], seg[1]) row[2], row[3] = max(row[2], seg[2]), max(row[3], seg[3]) row[4].append(seg) rows.sort(key=lambda r: r[5]) return [ [*r[:4], " ".join(seg[4] for seg in sorted(r[4], key=lambda s: s[0])), max(seg[5] for seg in r[4])] for r in rows ] def _title_key(text: str) -> str: return re.sub(r"[\W\d_]+", "", text.lower()) def ocr_lines_md(page: pymupdf.Page, language: str, dpi: int, titles: list) -> str: """Scanseite zeilengetreu: Verse, Strophen (Abstand), Einrückung, Titel aus der PDF-Gliederung (richtige Schreibweise statt OCR-Lesart).""" rows = _ocr_rows(_ocr_segments(page, language, dpi)) if not rows: return "" heights = sorted(r[3] - r[1] for r in rows) height = heights[len(heights) // 2] left = min(r[0] for r in rows if not PAGE_NUMBER.match(r[4])) pitches = sorted(b[5] - a[5] for a, b in zip(rows, rows[1:])) or [height] pitch = pitches[len(pitches) // 2] # Abstand der Grundlinien headings: dict[int, str] = {} for level, title in titles: # Gliederungstitel dieser Seite wiederfinden key = _title_key(title) scored = [ (difflib.SequenceMatcher(None, key, _title_key(r[4])).ratio(), i) for i, r in enumerate(rows[:OCR_TITLE_LINES]) if i not in headings ] if scored and (best := max(scored))[0] >= 0.6: headings[best[1]] = "#" * min(level, 6) + " " + md_escape(title) out: list[str] = [] for i, row in enumerate(rows): if i in headings: out += ["", headings[i], ""] continue indent = int(max(0.0, row[0] - left) / (1.2 * height)) text = TEXT_INDENT * indent + md_escape(row[4]) gap = i + 1 < len(rows) and rows[i + 1][5] - row[5] > OCR_STANZA_GAP * pitch last = i + 1 == len(rows) or (i + 1) in headings out.append(text + (PARAGRAPH_END if gap or (last and i + 1 < len(rows)) else HARD_BREAK)) if gap: out.append("") return "\n".join(out).strip("\n") + "\n\n" def drop_invisible_text(page: pymupdf.Page) -> None: """Unsichtbaren Text (vorhandene OCR-Ebene) entfernen; Bild und Grafik bleiben.""" page.add_redact_annot(page.rect, fill=False) page.apply_redactions( images=pymupdf.PDF_REDACT_IMAGE_NONE, graphics=pymupdf.PDF_REDACT_LINE_ART_NONE, text=pymupdf.PDF_REDACT_TEXT_REMOVE_INVISIBLE, ) def _init_worker() -> None: # Ein Thread pro Prozess, falls Tesseract mit OpenMP gebaut ist; # die Parallelität kommt von den Prozessen. os.environ["OMP_THREAD_LIMIT"] = "1" def extract_markdown( source: Path, plan: list[str], options: dict, jobs: int ) -> tuple[str, dict[int, str]]: """Gesamt-Markdown und Rohtext je Seite (für --split seiten).""" tasks = make_tasks(plan) total = len(tasks) labels = {RUN_PLAIN: "Text", RUN_OCR: "OCR", RUN_REUSE: "OCR-Ebene"} def report(i: int, run: str, pages: list[int]) -> None: print( f" [{i}/{total}] Seiten {pages[0] + 1}-{pages[-1] + 1} " f"({labels[run]}) fertig", flush=True, ) parts = [] page_texts: dict[int, str] = {} def collect(pages: list[int], texts: list[str]) -> None: page_texts.update(zip(pages, texts)) parts.append("".join(texts)) # Nur OCR profitiert von mehreren Prozessen; das Layout-Modell nutzt # ohnehin alle Kerne. if jobs <= 1 or total == 1 or RUN_OCR not in plan: for i, (run, pages) in enumerate(tasks, 1): collect(pages, convert_pages(str(source), run, pages, options)) report(i, run, pages) else: context = multiprocessing.get_context("spawn") with ProcessPoolExecutor( max_workers=min(jobs, total), mp_context=context, initializer=_init_worker, ) as pool: futures = [ pool.submit(convert_pages, str(source), run, pages, options) for run, pages in tasks ] for i, (future, (run, pages)) in enumerate(zip(futures, tasks), 1): collect(pages, future.result()) report(i, run, pages) return "\n\n".join(parts), page_texts IMAGE_LINK = re.compile(r"(!\[[^\]]*\]\()([^)\s]+)\)") def make_image_links_relative(markdown: str, image_dir: Path) -> str: """Bildpfade relativ zur Markdown-Datei machen ("_images/datei.jpg"). pymupdf4llm schreibt den Pfad relativ zum Arbeitsverzeichnis, wenn der Bilderordner darunter liegt (z. B. "Output/x_images/..."), sonst absolut. """ image_dir = image_dir.resolve() def fix(match: re.Match) -> str: path = Path(match.group(2)) full = path if path.is_absolute() else Path.cwd() / path if full.resolve().parent != image_dir: return match.group(0) return f"{match.group(1)}{image_dir.name}/{path.name})" return IMAGE_LINK.sub(fix, markdown) # =========================================================================== # EPUB mit pandoc # =========================================================================== def heading_depth(markdown: str) -> int: """Tiefste verwendete Überschriftenebene (1-3) für die Kapitelaufteilung.""" levels = [ len(m.group(1)) for m in re.finditer(r"^(#{1,6}) ", markdown, re.MULTILINE) ] return min(max(levels, default=1), 3) # Tesseract-Sprachcode -> Sprachcode im EPUB (ISO 639-1) EPUB_LANGUAGES = { "deu": "de", "deu_latf": "de", "frk": "de", "eng": "en", "fra": "fr", "spa": "es", "ita": "it", "nld": "nl", "por": "pt", "pol": "pl", "ces": "cs", "dan": "da", "swe": "sv", "nor": "no", "fin": "fi", "rus": "ru", "ukr": "uk", "lat": "la", "ell": "el", "tur": "tr", } def epub_language(ocr_language: str) -> str: """EPUB-Sprache aus der (ersten) OCR-Sprache: "eng+deu" -> "en".""" first = ocr_language.split("+")[0].strip().lower() return EPUB_LANGUAGES.get(first, "de") def default_jobs() -> int: """Halbe Kernzahl, höchstens 8: Jeder Prozess lädt das Layout-Modell.""" return max(1, min(8, (os.cpu_count() or 2) // 2)) def title_author_from_filename(stem: str) -> tuple[str, str]: """Titel und Autor aus einem Dateinamen nach dem Muster "Autor_-_Titel". "Katja_Adler_-_Rolle_rückwärts" -> ("Rolle rückwärts", "Katja Adler"); ohne " - " ist der ganze Name der Titel. """ name = re.sub(r"\s+", " ", stem.replace("_", " ")).strip() author, sep, title = name.partition(" - ") if sep and author.strip() and title.strip(): return title.strip(), author.strip() return name, "" # Ergänzung zum Standard-Stylesheet von pandoc: Code auf dem kleinen # Bildschirm umbrechen statt abschneiden, Einrückung bleibt erhalten. EPUB_CSS = """ pre, pre code { white-space: pre-wrap; overflow-wrap: anywhere; } pre { font-size: 0.85em; line-height: 1.3; } """ def epub_stylesheet(pandoc: str) -> str: done = subprocess.run( [pandoc, "--print-default-data-file", "epub.css"], capture_output=True, text=True, ) return (done.stdout if done.returncode == 0 else "") + EPUB_CSS def write_epub( markdown_path: Path, epub_path: Path, *, title: str, author: str, language: str, cover: Path | None, split_level: int, ) -> None: pandoc = shutil.which("pandoc") if not pandoc: raise RuntimeError("pandoc nicht gefunden - installieren mit: sudo apt install pandoc") cmd = [ pandoc, str(markdown_path), "--from", "markdown-yaml_metadata_block+autolink_bare_uris", "--to", "epub3", "--mathml", # $$...$$ (e.g. from formula_img2latex) as MathML, not as TeX text "--output", str(epub_path), "--resource-path", str(markdown_path.parent), f"--split-level={split_level}", f"--toc-depth={split_level}", "--metadata", f"title={title}", "--metadata", f"lang={language}", ] if author: cmd += ["--metadata", f"author={author}"] if cover: cmd += ["--epub-cover-image", str(cover)] with tempfile.TemporaryDirectory() as tmp: css = Path(tmp) / "pdf2md.css" css.write_text(epub_stylesheet(pandoc), encoding="utf-8") done = subprocess.run(cmd + ["--css", str(css)], capture_output=True, text=True) if done.returncode != 0: raise RuntimeError(f"pandoc fehlgeschlagen: {done.stderr.strip()}") # =========================================================================== # Metadaten-Kopf, Aufteilen in Einzeldateien, EPUB aus Markdown # =========================================================================== # Jede erzeugte Markdown-Datei beginnt mit einem YAML-Kopf (title, author, lang, # cover ...). Wir schreiben und lesen ihn selbst; pandoc bekommt nur den Text, # weil "---"-Zeilen im Buchtext sonst als YAML missverstanden werden könnten. FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n+", re.DOTALL) INDEX_LINK = re.compile(r"\]\(([^)\s]+\.md)\)") UMLAUTS = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"}) def make_frontmatter(meta: dict) -> str: lines = ["---"] for key, value in meta.items(): if value not in (None, ""): lines.append(f"{key}: {json.dumps(value, ensure_ascii=False)}") return "\n".join(lines + ["---"]) + "\n\n" def split_frontmatter(text: str) -> tuple[dict, str]: """YAML-Kopf (nur "schlüssel: wert") abtrennen und auswerten.""" match = FRONTMATTER.match(text) if not match: return {}, text meta = {} for line in match.group(1).splitlines(): key, sep, value = line.partition(":") if sep: value = value.strip() try: meta[key.strip()] = json.loads(value) except ValueError: meta[key.strip()] = value.strip("'\"") return meta, text[match.end():] def slugify(text: str, limit: int = 50) -> str: text = clean_inline(text).lower().translate(UMLAUTS) text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-") return slug[:limit].rstrip("-") or "abschnitt" def iter_headings(lines: list[str]): """(Zeilennummer, Ebene, Text) aller Überschriften außerhalb von Codeblöcken.""" fence = None for index, line in enumerate(lines): marker = FENCE.match(line) if fence: if marker and marker.group(1)[0] == fence: fence = None continue if marker: fence = marker.group(1)[0] elif match := re.match(r"^(#{1,6}) (.+)$", line): yield index, len(match.group(1)), match.group(2).strip() SPLIT_CHARS = 30_000 # ohne Gliederung: Abschnitte von etwa dieser Länge PAGES_PER_FILE = 20 # --split seiten: Standard für --pages-per-file HEADING_BLOCK = re.compile(r"^(#{1,6}) (.+)$") # Nachspann: erste dieser Überschriften nach dem letzten Kapitel/Teil. BACKMATTER_TITLE = re.compile( r"^(?:nachwort|nachbemerkung|anhang|anhänge|der autor|die autorin|" r"über den autor|über die autorin|zum autor|zur autorin|impressum|danksagung|dank|" r"quellen|quellenverzeichnis|quellennachweis|literatur|literaturverzeichnis|" r"bibliographie|bibliografie|register|personenregister|sachregister|" r"stichwortverzeichnis|glossar|anmerkungen|endnoten|bildnachweis|" r"about the author|acknowledgements|acknowledgments|appendix|bibliography|" r"glossary|index|notes)\b", re.IGNORECASE, ) def md_blocks(markdown: str) -> list[str]: """Absätze samt folgendem Leerraum; Codeblöcke bleiben ganz. Aneinandergehängt ergeben die Blöcke wieder genau den Text. """ blocks, fence = [], False for part in re.split(r"(\n\n+)", markdown): if blocks and fence: blocks[-1] += part # Codeblöcke nicht zerteilen elif part.strip() or not blocks: blocks.append(part) else: blocks[-1] += part fence ^= sum(1 for line in part.split("\n") if FENCE.match(line)) % 2 == 1 return blocks def block_heading(block: str) -> str | None: """Text der Überschrift, wenn der Block nur aus einer Überschrift besteht.""" lines = block.strip().split("\n") match = HEADING_BLOCK.match(lines[0]) if len(lines) == 1 else None return md_unescape(clean_inline(match.group(2))) if match else None def split_by_length(markdown: str, size: int = SPLIT_CHARS) -> list[dict]: """Ohne verwertbare Überschriften: an Absatzgrenzen in etwa gleich lange Stücke.""" sections, current = [], [] for block in md_blocks(markdown): if current and sum(map(len, current)) + len(block) > size: sections.append(current) current = [] current.append(block) if "".join(current).strip(): sections.append(current) return [ { "title": block_heading(blocks[0]) or f"Abschnitt {n}", "body": "".join(blocks).strip() + "\n", } for n, blocks in enumerate(sections, start=1) ] def split_markdown(markdown: str, mode: str, size: int = SPLIT_CHARS) -> list[dict]: """Buch in Abschnitte teilen: "teile" (oberste Ebene) oder "kapitel". Titelei und Inhaltsverzeichnis bilden den Vorspann, Anhang/Autor/Impressum nach dem letzten Kapitel den Nachspann. Ohne Gliederung: nach Länge (size). """ lines = markdown.split("\n") headings = list(iter_headings(lines)) counts = Counter(level for _, level, _ in headings) levels = sorted(level for level, n in counts.items() if n >= 2 and level <= 3) if not levels: return split_by_length(markdown, size) part_level, chapter_level = levels[0], levels[-1] cut_levels = {part_level} if mode == "teile" else {part_level, chapter_level} # Bis einschließlich Inhaltsverzeichnis (falls am Anfang) alles Vorspann. start = 0 for index, _level, text in headings: if CONTENTS_TITLE.match(clean_inline(text)) and index < len(lines) // 5: start = index + 1 break cuts = [(i, lvl, t) for i, lvl, t in headings if lvl in cut_levels and i >= start] # Nachspann (Ebene 0): Anhang & Co. unterhalb der Schnittebenen, sonst hinge # er am letzten Kapitel. after = cuts[-1][0] if cuts else start - 1 back = next( ( (i, 0, t) for i, lvl, t in headings if i > after and lvl <= 3 and lvl not in cut_levels and BACKMATTER_TITLE.match(md_unescape(clean_inline(t))) ), None, ) if back: cuts.append(back) sections = [] first = cuts[0][0] if cuts else len(lines) front = "\n".join(lines[:first]).strip() if front: sections.append({"title": "Vorspann", "body": front + "\n"}) part = "" for n, (index, level, text) in enumerate(cuts): end = cuts[n + 1][0] if n + 1 < len(cuts) else len(lines) body = "\n".join(lines[index:end]).strip() + "\n" if level == 0: sections.append({"title": "Nachspann", "body": body}) continue title = md_unescape(clean_inline(text)) if level == part_level: part = title number = re.match(r"(\d+)\b", title) if level == chapter_level != part_level else None sections.append({ "title": title, "part": part if part != title else "", "chapter": int(number.group(1)) if number else None, "body": body, }) return sections def parse_page_ranges(spec: str, page_count: int) -> list[int]: """--page-ranges "1-12,13-40,41-" -> Startseiten (0-basiert), immer mit 0. Seiten vor dem ersten, zwischen und nach den Bereichen werden eigene Abschnitte; so geht kein Text verloren. """ ranges = [] for item in spec.replace(" ", "").split(","): match = re.fullmatch(r"(\d+)(?:-(\d*))?", item) if not match: raise ValueError(f"Seitenbereich nicht lesbar: {item!r} (Beispiel: 1-12,13-40,41-)") first = int(match.group(1)) last = page_count if match.group(2) == "" else int(match.group(2) or first) if not 1 <= first <= last <= page_count: raise ValueError(f"Seitenbereich {item!r} passt nicht zu {page_count} Seiten") ranges.append((first - 1, last - 1)) ranges.sort() for (_, last), (first, _) in zip(ranges, ranges[1:]): if first <= last: raise ValueError(f"Seitenbereiche überlappen sich: Seite {first + 1}") starts = {0} for first, last in ranges: starts.add(first) if last + 1 < page_count: starts.add(last + 1) return sorted(starts) PAGE_KEY_MIN = 6 # kürzere Absätze ("Ja.") erben die Seite des Vorgängers PAGE_SEARCH = 50_000 # so weit (Zeichen ohne Leerraum) wird vorausgesucht def block_pages(blocks: list[str], page_texts: dict[int, str]) -> list[tuple[int, int]]: """Start- und Endseite (0-basiert) jedes Blocks. Die Rohtexte aller Seiten bilden einen fortlaufenden Vergleichstext; Anfang und Ende jedes Blocks werden darin nur vorwärts gesucht. Nicht gefundene Blöcke (Bilder, stark veränderter Text) erben die Seite des Vorgängers. """ stream, offsets = [], [] length = 0 for pno in range(max(page_texts, default=-1) + 1): offsets.append(length) key = _page_key(page_texts.get(pno, "")) stream.append(key) length += len(key) text = "".join(stream) def page_of(position: int) -> int: return bisect.bisect_right(offsets, position) - 1 result, pos, page = [], 0, 0 for block in blocks: key = _page_key(block) end = page if len(key) >= PAGE_KEY_MIN: found = text.find(key[:30], pos, pos + PAGE_SEARCH + 30) if found >= 0: pos, page = found, page_of(found) tail = text.find(key[-30:], pos, pos + 2 * len(key) + 1000) end = page_of(tail + len(key[-30:]) - 1) if tail >= 0 else page result.append((page, max(page, end))) return result def split_by_pages( markdown: str, starts: list[int], page_texts: dict[int, str], align: bool = True, ) -> list[dict]: """Nach Seiten der PDF teilen; starts = Startseiten (0-basiert). Geschnitten wird vor dem ersten Block, der auf der Startseite beginnt; ein Absatz über die Seitengrenze bleibt ganz in der Datei davor. Überschriften direkt vor dem Schnitt gehören zur neuen Datei. Mit align wird an einer Überschrift höchstens eine Seite vor oder nach der Grenze geschnitten. """ blocks = md_blocks(markdown) if not blocks: return [] spans = block_pages(blocks, page_texts) pages = [first for first, _ in spans] heading = [block_heading(b) is not None for b in blocks] cuts: list[int] = [] for start in starts: if start == 0: continue cut = next((i for i, page in enumerate(pages) if page >= start), None) if cut is None: break if align: near = [ i for i in range(1, len(blocks)) if heading[i] and not heading[i - 1] and abs(pages[i] - start) <= 1 and (not cuts or i > cuts[-1]) ] if near: cut = min(near, key=lambda i: (abs(pages[i] - start), abs(i - cut))) while cut > 0 and heading[cut - 1]: cut -= 1 if cut > (cuts[-1] if cuts else 0): cuts.append(cut) last_page = max(page_texts, default=0) bounds = [0, *cuts, len(blocks)] sections = [] for a, b in zip(bounds, bounds[1:]): first = pages[a] if a else 0 # Titelei ab Seite 1, auch ohne Text (Cover) # Ende: bis vor die nächste Startseite, mindestens bis zum Ende des letzten # Absatzes (der über die Seitengrenze reichen kann). end = max( max(last for _, last in spans[a:b]), pages[b] - 1 if b < len(blocks) else last_page, ) name = f"Seite {first + 1}" if end == first else f"Seiten {first + 1}-{end + 1}" sections.append({ "title": block_heading(blocks[a]) or name, "body": "".join(blocks[a:b]).strip() + "\n", "source_pages": [first + 1, end + 1], }) return [s for s in sections if s["body"].strip()] def _page_key(text: str) -> str: return re.sub(r"[\W_]+", "", clean_inline(text).lower()) def add_source_pages(doc: pymupdf.Document, sections: list[dict]) -> None: """Seiten der PDF (1-basiert) je Abschnitt. Startseite aus der PDF-Gliederung, sonst die erste Seite (nach der vorigen), auf der die Überschrift steht - Verzeichnisseiten ausgenommen. """ outline = {norm_title(title): page - 1 for _, title, page in doc.get_toc() if page > 0} pages = [_page_key(page.get_text()) for page in doc] keys = [_page_key(section["title"])[:40] for section in sections] listing = { # Verzeichnisse: viele Abschnittstitel auf einer Seite i for i, text in enumerate(pages) if sum(1 for k in keys if k and k in text) >= 3 } starts, page = [], 0 for section, key in zip(sections, keys): found = outline.get(norm_title(section["title"])) if found is None and section["title"] != "Vorspann" and key: found = next( (i for i in range(page, len(pages)) if i not in listing and key in pages[i]), None, ) page = max(page, found) if found is not None else page starts.append(page) for n, section in enumerate(sections): end = starts[n + 1] if n + 1 < len(sections) else len(pages) - 1 # Beginnt der nächste Abschnitt oben auf einer Seite, endet dieser davor. if n + 1 < len(sections) and end > starts[n] and _starts_page(doc[end], keys[n + 1]): end -= 1 section["source_pages"] = [starts[n] + 1, max(starts[n], end) + 1] def _starts_page(page: pymupdf.Page, key: str) -> bool: """Steht über der Überschrift nur Kolumnentitel/Seitenzahl (<= 3 Wörter je Block)?""" blocks = [b for b in page.get_text("blocks") if b[6] == 0 and b[4].strip()] heading = next((b for b in blocks if key and key in _page_key(b[4])), None) if heading is None or not _page_key(heading[4]).startswith(key): return False # nicht gefunden oder Text davor im selben Block above = [b for b in blocks if b[3] <= heading[1] + 1 and b is not heading] return all(len(b[4].split()) <= 3 for b in above) def write_split(folder: Path, sections: list[dict], meta: dict) -> list[Path]: """Abschnitte als nummerierte Dateien plus index.md (Reihenfolge) schreiben.""" folder.mkdir(parents=True, exist_ok=True) width = max(2, len(str(len(sections)))) files = [] for order, section in enumerate(sections): name = f"{order:0{width}d}_{slugify(section['title'])}.md" head = { "title": section["title"], "part": section.get("part"), "chapter": section.get("chapter"), "order": order, "source_pages": section.get("source_pages"), "book": meta.get("title"), "author": meta.get("author"), } (folder / name).write_text(make_frontmatter(head) + section["body"], encoding="utf-8") files.append(folder / name) entries = "\n".join( f"- [{md_escape(s['title'])}]({f.name})" for s, f in zip(sections, files) ) index = ( make_frontmatter(meta) + "\n\n" + entries + "\n" ) (folder / "index.md").write_text(index, encoding="utf-8") return files def read_markdown_source(path: Path) -> tuple[dict, str, Path]: """Metadaten, Text und Basisordner aus einer .md-Datei oder einem Split-Ordner.""" if path.is_dir(): index = path / "index.md" if not index.is_file(): raise RuntimeError(f"index.md fehlt in {path}") meta, listing = split_frontmatter(index.read_text(encoding="utf-8")) parts = [] for name in INDEX_LINK.findall(listing): file = path / name if not file.is_file(): raise RuntimeError(f"In index.md genannt, aber nicht vorhanden: {file}") parts.append(split_frontmatter(file.read_text(encoding="utf-8"))[1].strip()) if not parts: raise RuntimeError(f"index.md nennt keine Dateien: {index}") return meta, "\n\n".join(parts) + "\n", path meta, body = split_frontmatter(path.read_text(encoding="utf-8")) return meta, body, path.parent def markdown_to_epub( source: Path, epub_path: Path, *, title: str | None = None, author: str | None = None, language: str | None = None, cover: Path | None = None, ) -> None: """EPUB aus Markdown-Datei oder Split-Ordner; Angaben aus dem YAML-Kopf.""" meta, body, base = read_markdown_source(source) if cover is None and meta.get("cover"): cover = base / meta["cover"] with tempfile.NamedTemporaryFile( "w", encoding="utf-8", suffix=".md", dir=base, delete=False ) as tmp: tmp.write(body) try: write_epub( Path(tmp.name), epub_path, title=title or meta.get("title") or source.stem, author=author or meta.get("author") or "", language=language or meta.get("lang") or "de", cover=cover if cover and cover.is_file() else None, split_level=heading_depth(body), ) finally: Path(tmp.name).unlink(missing_ok=True) def epubs_per_file(folder: Path, files: list[Path], meta: dict) -> Path: """--epub-per-file: ein EPUB je Einzeldatei im Unterordner "epub/".""" target = folder / "epub" target.mkdir(exist_ok=True) cover = folder / meta["cover"] if meta.get("cover") else None for file in files: head, _ = split_frontmatter(file.read_text(encoding="utf-8")) book = meta.get("title") or folder.name markdown_to_epub( file, target / f"{file.stem}.epub", title=f"{book} - {head.get('title') or file.stem}", author=meta.get("author"), language=meta.get("lang"), cover=cover if cover and cover.is_file() else None, ) return target def rebase_image_links(markdown: str, old_base: Path, new_base: Path) -> str: """Relative Bildpfade von old_base auf new_base umrechnen.""" def fix(match: re.Match) -> str: link = match.group(2) if re.match(r"^[a-z][a-z0-9+.-]*:", link, re.IGNORECASE) or Path(link).is_absolute(): return match.group(0) # URL, data: oder absoluter Pfad new = os.path.relpath(old_base / link, new_base) return f"{match.group(1)}{Path(new).as_posix()})" return IMAGE_LINK.sub(fix, markdown) # =========================================================================== # Kommandozeile # =========================================================================== def main() -> int: parser = argparse.ArgumentParser( description=( "PDF (digital oder gescannt) in Markdown umwandeln; Fließtext nach " "Satzenden gliedern, optional Grafiken speichern und EPUB erzeugen." ) ) parser.add_argument("pdf", type=Path, nargs="?", help="Eingabe-PDF") parser.add_argument( "markdown", type=Path, nargs="?", help="Ausgabe-Datei; Standard: gleicher Name mit .md", ) parser.add_argument( "--ocr", choices=["auto", "always", "never"], default="auto", help=( "auto: nur Scanseiten per OCR lesen, gerade vorhandene OCR-Ebenen " "übernehmen, schiefe neu erkennen (Standard); always: alle Seiten " "neu erkennen; never: keine OCR" ), ) parser.add_argument( "--no-ocr", dest="ocr", action="store_const", const="never", help="Kurzform für --ocr never", ) parser.add_argument( "--ocr-language", default="deu", help="Tesseract-Sprachcode bei OCR, z. B. deu+eng (Standard: deu)", ) parser.add_argument( "--ocr-dpi", type=int, default=300, help="Auflösung für die OCR (Standard: 300)", ) parser.add_argument( "--jobs", type=int, default=default_jobs(), help="Anzahl paralleler OCR-Prozesse (Standard: halbe Kernzahl, " "höchstens 8; hier %(default)s)", ) parser.add_argument( "--images", action="store_true", help="Grafiken speichern und in Markdown verlinken", ) parser.add_argument( "--formulas", action="store_true", help="Formeln seitenweise erkennen (pix2text), auch im Fließtext und in Kästen, und als " "Bilder ausschneiden - für formula_img2latex (schaltet --images ein)", ) parser.add_argument( "--image-format", choices=["jpg", "png"], default="jpg", help="Bildformat: jpg (klein, Standard) oder png (verlustfrei, groß)", ) parser.add_argument( "--epub", action="store_true", help="zusätzlich EPUB mit pandoc erzeugen (schaltet --images ein)", ) parser.add_argument( "--epub-file", type=Path, metavar="PFAD", help="Pfad des EPUB; Standard: gleicher Name wie Markdown mit .epub", ) parser.add_argument( "--jpeg-quality", type=int, default=DEFAULT_JPEG_QUALITY, metavar="1-100", help="JPEG-Qualität der Bilder (Standard: %(default)s; 95 = fast verlustfrei, größer)", ) parser.add_argument( "--split", choices=["teile", "kapitel", "seiten"], help="Teile, Kapitel oder Seitenbereiche der PDF als einzelne Markdown-Dateien " "in einem Ordner (mit index.md für die Reihenfolge) statt einer Datei", ) parser.add_argument( "--pages-per-file", type=int, metavar="N", help=f"mit --split seiten: etwa N PDF-Seiten je Datei, Schnitt an Absatzgrenzen, " f"bevorzugt an einer Überschrift (Standard: {PAGES_PER_FILE})", ) parser.add_argument( "--page-ranges", metavar="BEREICHE", help="mit --split seiten: feste Bereiche der PDF-Seiten, z. B. 1-12,13-40,41- " "(Seiten außerhalb werden eigene Dateien)", ) parser.add_argument( "--split-chars", type=int, default=SPLIT_CHARS, metavar="N", help="--split teile|kapitel ohne Gliederung: Abschnitte von etwa N Zeichen " "(Standard: %(default)s)", ) parser.add_argument( "--epub-per-file", action="store_true", help="mit --split: zusätzlich ein EPUB je Einzeldatei im Unterordner epub/", ) parser.add_argument( "--from-md", type=Path, metavar="PFAD", help="nur EPUB erzeugen: aus einer (bearbeiteten) Markdown-Datei oder einem " "--split-Ordner; Titel, Autor, Cover aus dem YAML-Kopf", ) parser.add_argument("--title", help="Titel im EPUB (statt PDF-Metadaten/Dateiname)") parser.add_argument("--author", help="Autor im EPUB (statt PDF-Metadaten/Dateiname)") parser.add_argument( "--lang", help="Sprache im EPUB, z. B. en (Standard: aus --ocr-language, " "deu -> de, eng -> en)", ) parser.add_argument( "--keep-lines", action="store_true", help="jede Zeile der PDF bleibt eine Zeile (Gedichte, Liedtexte); kein " "Zusammenfügen, auch keine Silbentrennung", ) parser.add_argument( "--keep-headers", action="store_true", help="Seitenköpfe/Kolumnentitel übernehmen", ) parser.add_argument( "--keep-footers", action="store_true", help="Seitenfüße/Seitenzahlen übernehmen", ) args = parser.parse_args() if not 1 <= args.jpeg_quality <= 100: parser.error("--jpeg-quality muss zwischen 1 und 100 liegen.") if (args.pages_per_file is not None or args.page_ranges) and args.split != "seiten": parser.error("--pages-per-file und --page-ranges nur mit --split seiten.") if args.pages_per_file is not None and args.page_ranges: parser.error("--pages-per-file und --page-ranges schließen sich aus.") if args.pages_per_file is not None and args.pages_per_file < 1: parser.error("--pages-per-file muss mindestens 1 sein.") if args.split_chars < 1000: parser.error("--split-chars muss mindestens 1000 sein.") if args.epub_per_file and not args.split: parser.error("--epub-per-file nur mit --split.") if args.from_md: return epub_from_markdown(args, parser) if args.pdf is None: parser.error("Eingabe-PDF fehlt (oder --from-md PFAD angeben).") args.lang = args.lang or epub_language(args.ocr_language) source = args.pdf.expanduser() target = (args.markdown or source.with_suffix(".md")).expanduser() if not source.is_file(): parser.error(f"Eingabedatei nicht gefunden: {source}") if source.resolve() == target.resolve(): parser.error("Eingabe- und Ausgabedatei müssen verschieden sein.") epub_path = None if args.epub or args.epub_file: epub_path = (args.epub_file or target.with_suffix(".epub")).expanduser() if epub_path or args.epub_per_file or args.formulas: args.images = True # EPUB ohne Bilder wäre unvollständig; Formeln sind Bilder # --split: Ordner "/" mit Einzeldateien und "images/" statt einer Datei folder = target.with_suffix("") if args.split else None image_dir = folder / "images" if folder else target.parent / f"{target.stem}_images" try: with pymupdf.open(source) as doc: kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi) outline = load_outline(doc) titles: dict[int, list[tuple[int, str]]] = {} # Seite -> Gliederungstitel for level, title, page_no in doc.get_toc(): if page_no > 0 and title.strip() and not PAGE_LABEL.match(title.strip()): titles.setdefault(page_no - 1, []).append((level, title.strip())) uris = load_link_uris(doc) anchors = load_link_anchors(doc) page_count = doc.page_count file_title, file_author = title_author_from_filename(source.stem) title = ( args.title or ((doc.metadata or {}).get("title") or "").strip() or file_title ) author = ( args.author or ((doc.metadata or {}).get("author") or "").strip() or file_author ) counts = Counter(kinds) print( f"{page_count} Seiten: {counts[PAGE_TEXT]} Text, " f"{counts[PAGE_SCAN]} Scan/Bild, {counts[PAGE_OCR_LAYER]} mit " f"OCR-Ebene, {counts[PAGE_EMPTY]} leer -> " f"{plan.count(RUN_OCR)} Seiten werden per OCR gelesen" ) unread = sum( 1 for k, run in zip(kinds, plan) if k == PAGE_SCAN and run != RUN_OCR ) if unread: print( f"Hinweis: {unread} Bildseite(n) ohne Text bleiben ohne OCR " "(Fotos/Cover oder --ocr never)." ) target.parent.mkdir(parents=True, exist_ok=True) cover = None if args.images: # Cover immer mit ablegen, damit --from-md es später findet. image_dir.mkdir(parents=True, exist_ok=True) cover = image_dir / f"cover.{args.image_format}" _jpeg_quality[0] = args.jpeg_quality doc[0].get_pixmap(dpi=150).save(cover) options = dict( keep_headers=args.keep_headers, keep_footers=args.keep_footers, images=args.images, image_path=str(image_dir.resolve()) if args.images else "", image_format=args.image_format, ocr_language=args.ocr_language, ocr_dpi=args.ocr_dpi, jpeg_quality=args.jpeg_quality, keep_lines=args.keep_lines, titles=titles, formulas=args.formulas, ) markdown, page_texts = extract_markdown(source, plan, options, args.jobs) if args.images: markdown = make_image_links_relative(markdown, image_dir) markdown = normalize_markdown( markdown, outline, drop_page_numbers=not args.keep_footers, drop_running_heads=not args.keep_headers, running_head_min=max(3, page_count // 10), ) markdown = link_urls(markdown, uris) markdown = link_anchors(markdown, anchors) base = folder or target.parent meta = { "title": title, "author": author, "lang": args.lang, "cover": cover.relative_to(base).as_posix() if cover else None, } if args.split == "seiten": if args.page_ranges: starts = parse_page_ranges(args.page_ranges, page_count) else: starts = list(range(0, page_count, args.pages_per_file or PAGES_PER_FILE)) sections = split_by_pages( markdown, starts, page_texts, align=not args.page_ranges ) files = write_split(folder, sections, meta) elif folder: sections = split_markdown(markdown, args.split, args.split_chars) if sections and sections[0]["title"] == "Abschnitt 1": print("Hinweis: keine Gliederung erkannt - nach Länge geteilt.") with pymupdf.open(source) as doc: add_source_pages(doc, sections) files = write_split(folder, sections, meta) else: target.write_text(make_frontmatter(meta) + markdown, encoding="utf-8") if epub_path: markdown_to_epub(folder or target, epub_path) if args.epub_per_file: epub_dir = epubs_per_file(folder, files, meta) except Exception as exc: print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr) return 1 if folder: print(f"Markdown geschrieben: {len(files)} Dateien in {folder} (Reihenfolge: index.md)") else: print(f"Markdown geschrieben: {target}") if args.images: print(f"Bilder gespeichert in: {image_dir}") if epub_path: print(f"EPUB geschrieben: {epub_path}") if args.epub_per_file: print(f"Einzel-EPUBs geschrieben: {len(files)} in {epub_dir}") return 0 def epub_from_markdown(args, parser) -> int: """--from-md: EPUB aus bearbeitetem Markdown bzw. Split-Ordner erzeugen.""" source = args.from_md.expanduser() if not source.exists(): parser.error(f"Nicht gefunden: {source}") if args.split: return split_from_markdown(args, parser, source) base = source if source.is_dir() else source.with_suffix("") epub_path = (args.epub_file or base.with_suffix(".epub")).expanduser() try: markdown_to_epub( source, epub_path, title=args.title, author=args.author, language=args.lang ) except Exception as exc: print(f"Fehler beim EPUB-Erzeugen: {exc}", file=sys.stderr) return 1 print(f"EPUB geschrieben: {epub_path}") return 0 def split_from_markdown(args, parser, source: Path) -> int: """--from-md DATEI --split teile|kapitel: Gesamt-Markdown in einen Ordner teilen.""" if args.split == "seiten": parser.error("--split seiten braucht die PDF (Seitenzuordnung), nicht --from-md.") if not source.is_file(): parser.error(f"--split mit --from-md braucht eine Markdown-Datei: {source}") folder = source.with_suffix("") try: meta, body = split_frontmatter(source.read_text(encoding="utf-8")) body = rebase_image_links(body, source.parent, folder) if meta.get("cover"): meta["cover"] = Path(os.path.relpath(source.parent / meta["cover"], folder)).as_posix() meta["title"] = args.title or meta.get("title") or source.stem meta["author"] = args.author or meta.get("author") meta["lang"] = args.lang or meta.get("lang") sections = split_markdown(body, args.split, args.split_chars) if sections and sections[0]["title"] == "Abschnitt 1": print("Hinweis: keine Gliederung erkannt - nach Länge geteilt.") files = write_split(folder, sections, meta) print(f"Markdown geschrieben: {len(files)} Dateien in {folder} (Reihenfolge: index.md)") if args.epub or args.epub_file: epub_path = (args.epub_file or folder.with_suffix(".epub")).expanduser() markdown_to_epub(folder, epub_path) print(f"EPUB geschrieben: {epub_path}") if args.epub_per_file: epub_dir = epubs_per_file(folder, files, meta) print(f"Einzel-EPUBs geschrieben: {len(files)} in {epub_dir}") except Exception as exc: print(f"Fehler beim Aufteilen: {exc}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())