Quelltext ohne unsichtbare/verwechselbare Unicode-Zeichen

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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-03 00:03:39 +02:00
commit b05e2d9cf5

131
pdf2md.py
View file

@ -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 <https://…>, den pandoc im
# das korrekte Ziel und schreiben sie als Autolink <https://...>, den pandoc im
# EPUB als echten Link ausgibt.
URL_START = re.compile(
r"(?<![<\]/A-Za-z0-9])(?<!\]\()(?:https?://|www\.)", re.IGNORECASE
) # nicht in <…>-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<U+00AD> 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 ("<name>_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)