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:
parent
182bb27c53
commit
b05e2d9cf5
1 changed files with 66 additions and 65 deletions
131
pdf2md.py
131
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 <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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue