From b05e2d9cf5e38850d228e869e0375c90aa1f6a43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sat, 3 Oct 2026 00:03:39 +0200 Subject: [PATCH] Quelltext ohne unsichtbare/verwechselbare Unicode-Zeichen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forgejo warnte beim Anzeigen von pdf2md.py. Sonderzeichen in Strings und Regexen stehen jetzt als \u-Escapes; in Kommentaren, Docstrings und Meldungen durch ASCII ersetzt (– -> -, … -> ...). Verhalten unverändert. Co-Authored-By: Claude Opus 5.5 --- pdf2md.py | 131 +++++++++++++++++++++++++++--------------------------- 1 file changed, 66 insertions(+), 65 deletions(-) diff --git a/pdf2md.py b/pdf2md.py index ad483e5..100793a 100644 --- a/pdf2md.py +++ b/pdf2md.py @@ -42,16 +42,17 @@ from pymupdf4llm.ocr import OCRMode, tesseract_api HEADING = re.compile(r"^\s{0,3}(#{1,6})\s*(\S.*?)\s*#*\s*$") -# Schließende Anführungszeichen (deutsch „…“ ‚…‘ »…«, englisch, Schweiz «…»), +# 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"""”“’‘"'»«›‹)\]*_""" +CLOSERS = r"""\u201d\u201c\u2019\u2018"'\u00bb\u00ab\u203a\u2039)\]*_""" -# Satzzeichen, optional gefolgt von schließenden Zeichen (z. B. "…da?«"). -SENTENCE_END = re.compile(rf"""[.!?…]+[{CLOSERS}]*$""") +# 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"""(?:[.…][{CLOSERS}]*|[!?]+[{CLOSERS}]+)$""") +SENTENCE_LIKE_END = re.compile(rf"""(?:[.\u2026][{CLOSERS}]*|[!?]+[{CLOSERS}]+)$""") # Überschriften, die legitim mit Punkt enden: "3.", "IV.", "Kapitel 3." NUMBERING_ONLY = re.compile( @@ -105,16 +106,16 @@ HTML_BLOCK = re.compile( # Seitenzahl allein in einer Zeile: "12", "- 12 -", "Seite 12" PAGE_NUMBER = re.compile( - r"^(?:[-–—=]\s*)?(?:(?:seite|page|s\.)\s*)?\d{1,4}(?:\s*[-–—=])?$" - r"|^[-–—=]\s*[ivxlc]{1,7}\s*[-–—=]$", # "— VIII —" (OCR: "= 58 —") + r"^(?:[-\u2013\u2014=]\s*)?(?:(?:seite|page|s\.)\s*)?\d{1,4}(?:\s*[-\u2013\u2014=])?$" + r"|^[-\u2013\u2014=]\s*[ivxlc]{1,7}\s*[-\u2013\u2014=]$", # "- VIII -" (OCR: "= 58 -") re.IGNORECASE, ) -QUOTE_CHARS = set("«»„“”\"‚‘’›‹") -DASHES = str.maketrans({"–": "-", "—": "-", "‐": "-", "‑": "-"}) +QUOTE_CHARS = set("\u00ab\u00bb\u201e\u201c\u201d\"\u201a\u2018\u2019\u203a\u2039") +DASHES = str.maketrans({"\u2013": "-", "\u2014": "-", "\u2010": "-", "\u2011": "-"}) -# Silbentrennung am Zeilen-/Blockende (Bindestrich, weicher Trennstrich, ¬). -HYPHEN_END = re.compile(r"[^\W\d_]{2,}[-\u00ad¬]$") +# Silbentrennung am Zeilen-/Blockende (Bindestrich, weicher Trennstrich U+00AD, U+00AC). +HYPHEN_END = re.compile(r"[^\W\d_]{2,}[-\u00ad\u00ac]$") FIRST_WORD = re.compile(r"[^\W\d_]+") # Nach "Arbeits-" folgt "und Krieger-Skills": Bindestrich + Leerzeichen bleiben. @@ -136,7 +137,7 @@ PART_TITLE = re.compile( r"^(?:teil|buch|band|part|book)\s+(?:[ivxlcdm]+|\d+)\b", re.IGNORECASE, ) -# Zeile, die wie ein Gliederungseintrag aussieht ("Teil V: …", "32 Oberst …"). +# Zeile, die wie ein Gliederungseintrag aussieht ("Teil V: ...", "32 Oberst ..."). OUTLINE_ENTRY = re.compile( r"^(?:\d{1,3}\.?\s+\S|(?:teil|buch|band|part|book|kapitel|chapter)\s+(?:[ivxlcdm]+|\d+)\b)", re.IGNORECASE, @@ -199,7 +200,7 @@ def join_parts(prev: str, nxt: str) -> str: "Geheim-" + "dienst" -> "Geheimdienst" "Militär-" + "Geheimdienst" -> "Militär-Geheimdienst" - "Arbeits-" + "und …" -> "Arbeits- und …" + "Arbeits-" + "und ..." -> "Arbeits- und ..." """ if not HYPHEN_END.search(prev) or not nxt: return f"{prev} {nxt}" @@ -217,14 +218,14 @@ 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 "–—-…,;" or text[-1] in ",;": + 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" + 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 @@ -247,7 +248,7 @@ def running_head_key(line: str) -> str | 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(" .-–—|·").lower() + key = re.sub(r"\s+", " ", key).strip(" .-\u2013\u2014|\u00b7").lower() if sum(ch.isalpha() for ch in key) < 4: return None return key @@ -574,7 +575,7 @@ def normalize_markdown( heading(outline[key], text) continue - # "Die Mauer fiel am" + "9. November …" ist keine Liste, + # "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 ( @@ -594,7 +595,7 @@ def normalize_markdown( return finish_hard_breaks("\n".join(result)).strip() + "\n" -# Überschrift nur aus einer Zahl ohne Punkt: arabisch oder römisch ii–xcix +# Ü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}))$", @@ -675,19 +676,19 @@ def load_outline(doc: pymupdf.Document) -> dict[str, int]: # 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 +# 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 = ".,;:!?)]}»«“”„\"'*_\\" # "\\": harter Zeilenumbruch +) # nicht in <...>-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. +# "und/oder", "Ein/Aus": nur Buchstaben und ein "/" - ein Wortpaar, kein URL-Pfad. WORD_PAIR = re.compile(r"^[^\W\d_]+/[^\W\d_]+$") @@ -699,7 +700,7 @@ def url_key(text: str) -> str: 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"). + Gedruckt fehlen oft Abfrageteil ("?utm_source=...") oder Endung (".html"). """ base = uri.split("#")[0] keys = set() @@ -710,7 +711,7 @@ def uri_keys(uri: str) -> set[str]: def clean_uri(uri: str) -> str: - """Tracking-Parameter (utm_…) entfernen.""" + """Tracking-Parameter (utm_...) entfernen.""" parts = urllib.parse.urlsplit(uri) if "utm_" not in parts.query.lower(): return uri @@ -787,7 +788,7 @@ def _unescape_map(text: str) -> tuple[str, list[int]]: def _link_line(line: str, known: dict[str, str]) -> str: - # URLs auf dem unmaskierten Text suchen ("Leitlinien\_" → "Leitlinien_"), + # URLs auf dem unmaskierten Text suchen ("Leitlinien\_" -> "Leitlinien_"), # den übrigen Text unverändert (maskiert) übernehmen. plain, index = _unescape_map(line) out = [] @@ -822,7 +823,7 @@ def _url_tail(line: str) -> str | None: def _join_split_urls(lines: list[str], known: dict[str, str]) -> list[str]: - """URL, die in einem neuen Block weitergeht (oft als Listenpunkt "- …"), + """URL, die in einem neuen Block weitergeht (oft als Listenpunkt "- ..."), mit ihrer Fortsetzung verbinden.""" i = 0 while i < len(lines): @@ -917,7 +918,7 @@ def load_link_anchors(doc: pymupdf.Document) -> list[tuple[str, list[str], list[ def _md_literal(text: str) -> str: - """Regex für Text, der im Markdown maskiert sein kann ("\\_", "\\*" …).""" + """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 ) @@ -932,7 +933,7 @@ def _words_pattern(words: list[str]) -> str: return r"[\s*_]*".join(parts) -# Zwischen Kontextwörtern: Satzzeichen, Fußnotenziffern, Linkziele "](…)". +# Zwischen Kontextwörtern: Satzzeichen, Fußnotenziffern, Linkziele "](...)". CONTEXT_GAP = r"(?:[\W_\d]|\]\([^)]*\))*" @@ -995,7 +996,7 @@ def _fuzzy_anchor( def _inside_link(markdown: str, index: int) -> bool: - """Liegt die Stelle schon in einem Link (<…>, [..](…))?""" + """Liegt die Stelle schon in einem Link (<...>, [..](...))?""" line_start = markdown.rfind("\n", 0, index) + 1 before = markdown[line_start:index] @@ -1014,17 +1015,17 @@ def _inside_link(markdown: str, index: int) -> bool: # "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 "¬" kennt pymupdf4llm nicht ("wach­ senden"); +# 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 `…`. +# 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", "¬") +SOFT_HYPHENS = ("\u00ad", "\u00ac") MONOSPACE_FLAG = 8 @@ -1093,7 +1094,7 @@ def _line_text(line: dict) -> str: 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 + column_left: linker Rand der Textspalte, falls bekannt - sonst der Blockrand (dann bleiben gleich eingerückte einzeilige Absätze unerkannt). """ if len(textlines) < 2: @@ -1121,7 +1122,7 @@ def split_by_indent(textlines: list[dict], column_left: float | None = None) -> # --------------------------------------------------------------------------- -# Zeilengetreue Abschnitte: Code, Register, Verse, Adressen … +# 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 @@ -1143,7 +1144,7 @@ TEXT_BOXES = ("text", "list-item", "footnote") 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 (× üblichem) für Leerzeile +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 @@ -1159,12 +1160,12 @@ CODE_HINT = re.compile( r"|\w\([^()]*\)\s*$" # endet mit Aufruf f(x) ) -# Zeilen rund um Code: Konfiguration (zählt erst ab zwei Zeilen als Code) … +# 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). +# ... 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|" @@ -1181,7 +1182,7 @@ 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.""" + (Gedichtseiten in Scans) - jede Zeile nur im ersten Block behalten.""" seen: set = set() for box in boxes: lines = getattr(box, "textlines", None) @@ -1285,7 +1286,7 @@ def _is_soft_break(prev: dict, line: dict, right: float, em: float, tol: float) def _indent_levels(lines: list[dict], em: float) -> list[int]: - """Einrückung jeder Zeile als Stufe 0, 1, 2 … (geometrisch).""" + """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: @@ -1306,7 +1307,7 @@ def _is_monospace(lines: list[dict]) -> bool: def _is_code(lines: list[dict], ignore_code: bool, short: bool = False) -> bool: - """Ganzer Block Code? short: kurzer Block – dann zählen auch Konfiguration, + """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 @@ -1322,8 +1323,8 @@ def _is_code(lines: list[dict], ignore_code: bool, short: bool = False) -> bool: 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*[-–]\s*\d+)?(?:\s*,\s*\d+(?:\s*[-–]\s*\d+)?)*\s*$") +# 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: @@ -1410,7 +1411,7 @@ def _block_end(lines: list[dict], right: float, em: float, tol: float) -> str: return f"{BLOCK_END}{max(0, int(free))}{paragraph}{BLOCK_END}" -OCR_NOISE = re.compile(r"[|¦]") # Buchkante/Seitenrand als "|" gelesen +OCR_NOISE = re.compile(r"[|\u00a6]") # Buchkante/Seitenrand als "|" gelesen HAS_ALNUM = re.compile(r"[^\W_]") @@ -1432,7 +1433,7 @@ def _clean_ocr_lines(textlines: list[dict]) -> list[dict]: 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 ("„+", "%*") + 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), @@ -1443,7 +1444,7 @@ def _clean_ocr_lines(textlines: list[dict]) -> list[dict]: def _is_ocr_text(textlines: list[dict]) -> bool: - """OCR-Text ist unsichtbar (Render-Modus 3) – wie utils.is_ocr_text in pymupdf4llm.""" + """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) @@ -1568,7 +1569,7 @@ def patch_pymupdf4llm_paragraphs() -> None: class _DiscardMessages(io.StringIO): - """Ersatz für pymupdf4llms Meldungspuffer ("OCR on page …").""" + """Ersatz für pymupdf4llms Meldungspuffer ("OCR on page ...").""" def write(self, text: str) -> int: return len(text) @@ -1611,7 +1612,7 @@ 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,}(?:[-'’][^\W\d_]+)*)\W*$") +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. @@ -1636,7 +1637,7 @@ FUNCTION_WORDS = { ), } -SKEW_TOLERANCE = 0.002 # sin(Winkel), ca. 0,1° +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 @@ -1681,7 +1682,7 @@ def is_wordlike(token: str) -> bool: match = WORDLIKE.match(token) if not match: return False - core = re.sub(r"[-'’]", "", match.group(1)) + core = re.sub(r"[-'\u2019]", "", match.group(1)) return core.islower() or core.isupper() or core.istitle() @@ -1708,7 +1709,7 @@ def looks_like_text(text: str, language: str = "deu") -> bool: ) if not function_words: return True # Sprache ohne Wortliste: nur die Wortform zählt - lowered = [w.strip(".,;:!?»«„“”\"'‚‘’()").lower() for w in words] + 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 @@ -1720,7 +1721,7 @@ def check_ocr_language(language: str) -> None: tessdata = None if not tessdata: raise RuntimeError( - "Tesseract nicht gefunden – installieren mit: " + "Tesseract nicht gefunden - installieren mit: " "sudo apt install tesseract-ocr tesseract-ocr-deu" ) missing = [ @@ -1730,7 +1731,7 @@ def check_ocr_language(language: str) -> None: if missing: packages = " ".join(f"tesseract-ocr-{lang}" for lang in missing) raise RuntimeError( - f"Tesseract-Sprachdaten fehlen: {', '.join(missing)} – " + f"Tesseract-Sprachdaten fehlen: {', '.join(missing)} - " f"installieren mit: sudo apt install {packages}" ) @@ -1850,11 +1851,11 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str # --keep-lines auf Scanseiten: Zeilen direkt aus Tesseract, ohne Layout-Modell. -# Dieses legt bei Gedicht-Scans eng stehende Verse zusammen ("UndDon tauchte…") +# 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 (× üblichem) für eine Strophengrenze +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" @@ -1885,7 +1886,7 @@ def _ocr_segments(page: pymupdf.Page, language: str, dpi: int) -> list[list]: 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 + 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 [] @@ -1974,7 +1975,7 @@ def extract_markdown(source: Path, plan: list[str], options: dict, jobs: int) -> def report(i: int, run: str, pages: list[int]) -> None: print( - f" [{i}/{total}] Seiten {pages[0] + 1}–{pages[-1] + 1} " + f" [{i}/{total}] Seiten {pages[0] + 1}-{pages[-1] + 1} " f"({labels[run]}) fertig", flush=True, ) @@ -2011,7 +2012,7 @@ 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. + Bilderordner darunter liegt (z. B. "Output/x_images/..."), sonst absolut. """ image_dir = image_dir.resolve() @@ -2030,7 +2031,7 @@ def make_image_links_relative(markdown: str, image_dir: Path) -> str: # =========================================================================== def heading_depth(markdown: str) -> int: - """Tiefste verwendete Überschriftenebene (1–3) für die Kapitelaufteilung.""" + """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) @@ -2099,7 +2100,7 @@ def write_epub( ) -> None: pandoc = shutil.which("pandoc") if not pandoc: - raise RuntimeError("pandoc nicht gefunden – installieren mit: sudo apt install pandoc") + raise RuntimeError("pandoc nicht gefunden - installieren mit: sudo apt install pandoc") cmd = [ pandoc, @@ -2130,7 +2131,7 @@ def write_epub( # 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, +# 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) @@ -2264,7 +2265,7 @@ 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. + 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] @@ -2291,7 +2292,7 @@ def add_source_pages(doc: pymupdf.Document, sections: list[dict]) -> None: def _starts_page(page: pymupdf.Page, key: str) -> bool: - """Steht über der Überschrift nur Kolumnentitel/Seitenzahl (≤ 3 Wörter je Block)?""" + """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): @@ -2530,7 +2531,7 @@ def main() -> int: 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 + 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())) @@ -2549,7 +2550,7 @@ def main() -> int: 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"OCR-Ebene, {counts[PAGE_EMPTY]} leer -> " f"{plan.count(RUN_OCR)} Seiten werden per OCR gelesen" ) unread = sum( @@ -2607,7 +2608,7 @@ def main() -> int: if folder: sections = split_markdown(markdown, args.split) if sections and sections[0]["title"] == "Abschnitt 1": - print("Hinweis: keine Gliederung erkannt – nach Länge geteilt.") + 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)