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*$")
|
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.
|
# 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?«").
|
# Satzzeichen, optional gefolgt von schließenden Zeichen (z. B. "...da?" + U+00AB).
|
||||||
SENTENCE_END = re.compile(rf"""[.!?…]+[{CLOSERS}]*$""")
|
SENTENCE_END = re.compile(rf"""[.!?\u2026]+[{CLOSERS}]*$""")
|
||||||
|
|
||||||
# Typische Endungen von Sätzen, die fälschlich als Überschrift erkannt wurden:
|
# Typische Endungen von Sätzen, die fälschlich als Überschrift erkannt wurden:
|
||||||
# Punkt/Auslassung oder Frage/Ausruf mit schließendem Anführungszeichen.
|
# 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."
|
# Überschriften, die legitim mit Punkt enden: "3.", "IV.", "Kapitel 3."
|
||||||
NUMBERING_ONLY = re.compile(
|
NUMBERING_ONLY = re.compile(
|
||||||
|
|
@ -105,16 +106,16 @@ HTML_BLOCK = re.compile(
|
||||||
|
|
||||||
# Seitenzahl allein in einer Zeile: "12", "- 12 -", "Seite 12"
|
# Seitenzahl allein in einer Zeile: "12", "- 12 -", "Seite 12"
|
||||||
PAGE_NUMBER = re.compile(
|
PAGE_NUMBER = re.compile(
|
||||||
r"^(?:[-–—=]\s*)?(?:(?:seite|page|s\.)\s*)?\d{1,4}(?:\s*[-–—=])?$"
|
r"^(?:[-\u2013\u2014=]\s*)?(?:(?:seite|page|s\.)\s*)?\d{1,4}(?:\s*[-\u2013\u2014=])?$"
|
||||||
r"|^[-–—=]\s*[ivxlc]{1,7}\s*[-–—=]$", # "— VIII —" (OCR: "= 58 —")
|
r"|^[-\u2013\u2014=]\s*[ivxlc]{1,7}\s*[-\u2013\u2014=]$", # "- VIII -" (OCR: "= 58 -")
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
QUOTE_CHARS = set("«»„“”\"‚‘’›‹")
|
QUOTE_CHARS = set("\u00ab\u00bb\u201e\u201c\u201d\"\u201a\u2018\u2019\u203a\u2039")
|
||||||
DASHES = str.maketrans({"–": "-", "—": "-", "‐": "-", "‑": "-"})
|
DASHES = str.maketrans({"\u2013": "-", "\u2014": "-", "\u2010": "-", "\u2011": "-"})
|
||||||
|
|
||||||
# Silbentrennung am Zeilen-/Blockende (Bindestrich, weicher Trennstrich, ¬).
|
# Silbentrennung am Zeilen-/Blockende (Bindestrich, weicher Trennstrich U+00AD, U+00AC).
|
||||||
HYPHEN_END = re.compile(r"[^\W\d_]{2,}[-\u00ad¬]$")
|
HYPHEN_END = re.compile(r"[^\W\d_]{2,}[-\u00ad\u00ac]$")
|
||||||
FIRST_WORD = re.compile(r"[^\W\d_]+")
|
FIRST_WORD = re.compile(r"[^\W\d_]+")
|
||||||
|
|
||||||
# Nach "Arbeits-" folgt "und Krieger-Skills": Bindestrich + Leerzeichen bleiben.
|
# 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",
|
r"^(?:teil|buch|band|part|book)\s+(?:[ivxlcdm]+|\d+)\b",
|
||||||
re.IGNORECASE,
|
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(
|
OUTLINE_ENTRY = re.compile(
|
||||||
r"^(?:\d{1,3}\.?\s+\S|(?:teil|buch|band|part|book|kapitel|chapter)\s+(?:[ivxlcdm]+|\d+)\b)",
|
r"^(?:\d{1,3}\.?\s+\S|(?:teil|buch|band|part|book|kapitel|chapter)\s+(?:[ivxlcdm]+|\d+)\b)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
|
|
@ -199,7 +200,7 @@ def join_parts(prev: str, nxt: str) -> str:
|
||||||
|
|
||||||
"Geheim-" + "dienst" -> "Geheimdienst"
|
"Geheim-" + "dienst" -> "Geheimdienst"
|
||||||
"Militär-" + "Geheimdienst" -> "Militär-Geheimdienst"
|
"Militär-" + "Geheimdienst" -> "Militär-Geheimdienst"
|
||||||
"Arbeits-" + "und …" -> "Arbeits- und …"
|
"Arbeits-" + "und ..." -> "Arbeits- und ..."
|
||||||
"""
|
"""
|
||||||
if not HYPHEN_END.search(prev) or not nxt:
|
if not HYPHEN_END.search(prev) or not nxt:
|
||||||
return f"{prev} {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."""
|
"""Erkennen, ob eine "Überschrift" in Wahrheit ein Satz(-rest) ist."""
|
||||||
if not text:
|
if not text:
|
||||||
return True
|
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
|
return True
|
||||||
if NUMBERING_ONLY.match(text):
|
if NUMBERING_ONLY.match(text):
|
||||||
return False
|
return False
|
||||||
if paragraph_open and ends_sentence(text):
|
if paragraph_open and ends_sentence(text):
|
||||||
return True
|
return True
|
||||||
if paragraph_open and text.split()[-1].lower() in TRAILING_STOPWORDS:
|
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):
|
if SENTENCE_LIKE_END.search(text):
|
||||||
return True
|
return True
|
||||||
# Mit Gliederung stehen echte Überschriften im Outline; was dort fehlt
|
# 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):
|
if QUOTE_CHARS & set(text):
|
||||||
return None # wörtliche Rede ist kein Kolumnentitel
|
return None # wörtliche Rede ist kein Kolumnentitel
|
||||||
key = re.sub(r"\d+", "", text)
|
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:
|
if sum(ch.isalpha() for ch in key) < 4:
|
||||||
return None
|
return None
|
||||||
return key
|
return key
|
||||||
|
|
@ -574,7 +575,7 @@ def normalize_markdown(
|
||||||
heading(outline[key], text)
|
heading(outline[key], text)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# "Die Mauer fiel am" + "9. November …" ist keine Liste,
|
# "Die Mauer fiel am" + "9. November ..." ist keine Liste,
|
||||||
# "Liste:" + "1. Erstens" dagegen schon.
|
# "Liste:" + "1. Erstens" dagegen schon.
|
||||||
continues_sentence = paragraph_open() and paragraph[-1][-1:].isalnum()
|
continues_sentence = paragraph_open() and paragraph[-1][-1:].isalnum()
|
||||||
if is_other_structure(line) and not (
|
if is_other_structure(line) and not (
|
||||||
|
|
@ -594,7 +595,7 @@ def normalize_markdown(
|
||||||
return finish_hard_breaks("\n".join(result)).strip() + "\n"
|
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).
|
# (mindestens zwei Zeichen, damit Register-Buchstaben "C", "D", "M" bleiben).
|
||||||
NUMBER_HEADING = re.compile(
|
NUMBER_HEADING = re.compile(
|
||||||
r"^(?:\d{1,4}|(?=[ivxlc]{2})(?:xc|xl|l?x{0,3})(?:ix|iv|v?i{0,3}))$",
|
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
|
# pymupdf4llm übernimmt keine Link-Annotationen, und gedruckte URLs zerbrechen
|
||||||
# beim Zeilenumbruch ("deutsche-|sehen" -> "deutschesehen", ".../ youtube-...").
|
# beim Zeilenumbruch ("deutsche-|sehen" -> "deutschesehen", ".../ youtube-...").
|
||||||
# Wir gleichen URLs im Text mit den Linkzielen der PDF ab, ersetzen sie durch
|
# 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.
|
# EPUB als echten Link ausgibt.
|
||||||
|
|
||||||
URL_START = re.compile(
|
URL_START = re.compile(
|
||||||
r"(?<![<\]/A-Za-z0-9])(?<!\]\()(?:https?://|www\.)", re.IGNORECASE
|
r"(?<![<\]/A-Za-z0-9])(?<!\]\()(?:https?://|www\.)", re.IGNORECASE
|
||||||
) # nicht in <…>-Links und [Text](…)-Zielen
|
) # nicht in <...>-Links und [Text](...)-Zielen
|
||||||
URL_TRAILING = ".,;:!?)]}»«“”„\"'*_\\" # "\\": harter Zeilenumbruch
|
URL_TRAILING = ".,;:!?)]}\u00bb\u00ab\u201c\u201d\u201e\"'*_\\" # "\\": harter Zeilenumbruch
|
||||||
MAX_URL_PARTS = 4 # eine URL kann über so viele Zeilen gebrochen sein
|
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
|
# Ohne Linkziel: Folgewort gehört zur URL, wenn diese an einer typischen
|
||||||
# Umbruchstelle endet und das Wort wie ein URL-Teil aussieht.
|
# Umbruchstelle endet und das Wort wie ein URL-Teil aussieht.
|
||||||
URL_BREAK_AFTER = "/_-.=&?#"
|
URL_BREAK_AFTER = "/_-.=&?#"
|
||||||
URL_CONTINUATION = re.compile(r"^(?:\S*/\S*|(?:html?|php|pdf|aspx?)\W*)$", re.IGNORECASE)
|
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_]+$")
|
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]:
|
def uri_keys(uri: str) -> set[str]:
|
||||||
"""Schlüssel, unter denen ein Linkziel im gedruckten Text auftauchen kann.
|
"""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]
|
base = uri.split("#")[0]
|
||||||
keys = set()
|
keys = set()
|
||||||
|
|
@ -710,7 +711,7 @@ def uri_keys(uri: str) -> set[str]:
|
||||||
|
|
||||||
|
|
||||||
def clean_uri(uri: str) -> str:
|
def clean_uri(uri: str) -> str:
|
||||||
"""Tracking-Parameter (utm_…) entfernen."""
|
"""Tracking-Parameter (utm_...) entfernen."""
|
||||||
parts = urllib.parse.urlsplit(uri)
|
parts = urllib.parse.urlsplit(uri)
|
||||||
if "utm_" not in parts.query.lower():
|
if "utm_" not in parts.query.lower():
|
||||||
return uri
|
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:
|
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.
|
# den übrigen Text unverändert (maskiert) übernehmen.
|
||||||
plain, index = _unescape_map(line)
|
plain, index = _unescape_map(line)
|
||||||
out = []
|
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]:
|
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."""
|
mit ihrer Fortsetzung verbinden."""
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(lines):
|
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:
|
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(
|
return "".join(
|
||||||
(r"\\?" if char in MD_SPECIAL else "") + re.escape(char) for char in text
|
(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)
|
return r"[\s*_]*".join(parts)
|
||||||
|
|
||||||
|
|
||||||
# Zwischen Kontextwörtern: Satzzeichen, Fußnotenziffern, Linkziele "](…)".
|
# Zwischen Kontextwörtern: Satzzeichen, Fußnotenziffern, Linkziele "](...)".
|
||||||
CONTEXT_GAP = r"(?:[\W_\d]|\]\([^)]*\))*"
|
CONTEXT_GAP = r"(?:[\W_\d]|\]\([^)]*\))*"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -995,7 +996,7 @@ def _fuzzy_anchor(
|
||||||
|
|
||||||
|
|
||||||
def _inside_link(markdown: str, index: int) -> bool:
|
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
|
line_start = markdown.rfind("\n", 0, index) + 1
|
||||||
before = markdown[line_start:index]
|
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
|
# "Geheimdienst" -> "MilitärGeheimdienst"). Für Großbuchstaben und
|
||||||
# Konjunktionen ("Arbeits- und") markieren wir den Strich vorher, sodass er
|
# Konjunktionen ("Arbeits- und") markieren wir den Strich vorher, sodass er
|
||||||
# erhalten bleibt. Echte Silbentrennung (Kleinbuchstabe) bleibt unverändert.
|
# 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.
|
# sie trennen immer innerhalb eines Wortes und werden zu "-", das dann entfällt.
|
||||||
#
|
#
|
||||||
# Unsichtbarer Text (OCR-Ebene eines Scans) ist oft in Courier gesetzt;
|
# 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 = "\ue000" # Strich behalten, ohne Leerzeichen anschließen
|
||||||
KEEP_HYPHEN_SPACE = "\ue001" # Strich und Leerzeichen behalten
|
KEEP_HYPHEN_SPACE = "\ue001" # Strich und Leerzeichen behalten
|
||||||
|
|
||||||
|
|
||||||
SOFT_HYPHENS = ("\u00ad", "¬")
|
SOFT_HYPHENS = ("\u00ad", "\u00ac")
|
||||||
MONOSPACE_FLAG = 8
|
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]]:
|
def split_by_indent(textlines: list[dict], column_left: float | None = None) -> list[list[dict]]:
|
||||||
"""Zeilen eines Textblocks in Absätze aufteilen.
|
"""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).
|
(dann bleiben gleich eingerückte einzeilige Absätze unerkannt).
|
||||||
"""
|
"""
|
||||||
if len(textlines) < 2:
|
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
|
# 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
|
# 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 = 0.05 # Anteil der Spaltenbreite
|
||||||
FIT_TOLERANCE_EM = 0.3 # mindestens
|
FIT_TOLERANCE_EM = 0.3 # mindestens
|
||||||
HARD_BLOCK_RATIO = 0.5 # ab diesem Anteil harter Umbrüche: zeilengetreu
|
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ß
|
INDENT_STEP_EM = 0.8 # Einrückungsstufen: Abstand mindestens so groß
|
||||||
CODE_INDENT = " " # Leerzeichen pro Einrückungsstufe im Code
|
CODE_INDENT = " " # Leerzeichen pro Einrückungsstufe im Code
|
||||||
TEXT_INDENT = NBSP * 4 # geschützte Leerzeichen pro Stufe sonst
|
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)
|
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(
|
CONFIG_LINE = re.compile(
|
||||||
r"^\s*\[[\w.\-]+\]\s*$" # [dependencies]
|
r"^\s*\[[\w.\-]+\]\s*$" # [dependencies]
|
||||||
r"|^\s*[\w.\-]+\s*=\s*\S" # name = "wert"
|
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(
|
COMMAND_LINE = re.compile(
|
||||||
r"^\s*(?:\$|>>>|C:\\>)\s" # Eingabeaufforderung
|
r"^\s*(?:\$|>>>|C:\\>)\s" # Eingabeaufforderung
|
||||||
r"|^\s*(?:cargo|rustup|rustc|pip3?|python3?|pytest|npm|npx|git|sudo|"
|
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:
|
def _dedupe_box_lines(boxes) -> None:
|
||||||
"""Überlappende Layout-Blöcke enthalten dieselben Zeilen doppelt
|
"""Ü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()
|
seen: set = set()
|
||||||
for box in boxes:
|
for box in boxes:
|
||||||
lines = getattr(box, "textlines", None)
|
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]:
|
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})
|
lefts = sorted({round(line["bbox"][0], 1) for line in lines})
|
||||||
stops: list[float] = []
|
stops: list[float] = []
|
||||||
for x in lefts:
|
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:
|
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)."""
|
Befehle und Programmausgaben (sonst nur zeilenweise, siehe _mixed_block)."""
|
||||||
if ignore_code:
|
if ignore_code:
|
||||||
return False
|
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)
|
return hints >= max(1, (len(texts) + 1) // 2) or (hints and indented)
|
||||||
|
|
||||||
|
|
||||||
# Registereintrag ("match(), 247", "Closure, 128, 130–132"): nie Code.
|
# 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*$")
|
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:
|
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}"
|
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_]")
|
HAS_ALNUM = re.compile(r"[^\W_]")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1432,7 +1433,7 @@ def _clean_ocr_lines(textlines: list[dict]) -> list[dict]:
|
||||||
for spans in groups:
|
for spans in groups:
|
||||||
spans.sort(key=lambda s: s["bbox"][0])
|
spans.sort(key=lambda s: s["bbox"][0])
|
||||||
if not HAS_ALNUM.search("".join(s["text"] for s in spans)):
|
if not HAS_ALNUM.search("".join(s["text"] for s in spans)):
|
||||||
continue # nur Rauschen ("„+", "%*")
|
continue # nur Rauschen (U+201E + "+", "%*")
|
||||||
bbox = (
|
bbox = (
|
||||||
min(s["bbox"][0] for s in spans), min(s["bbox"][1] for s in spans),
|
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),
|
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:
|
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()]
|
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)
|
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):
|
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:
|
def write(self, text: str) -> int:
|
||||||
return len(text)
|
return len(text)
|
||||||
|
|
@ -1611,7 +1612,7 @@ PLAUSIBLE_MIN_CHARS = 40
|
||||||
PLAUSIBLE_MIN_WORDS = 20 # Cover/Titelbilder haben weniger Wörter
|
PLAUSIBLE_MIN_WORDS = 20 # Cover/Titelbilder haben weniger Wörter
|
||||||
PLAUSIBLE_WORD_RATIO = 0.7
|
PLAUSIBLE_WORD_RATIO = 0.7
|
||||||
PLAUSIBLE_FUNCTION_RATIO = 0.1
|
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,
|
# 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.
|
# 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
|
MAX_SKEWED_LINES = 0.2 # mehr schiefe Zeilen -> OCR-Ebene neu erkennen
|
||||||
|
|
||||||
VISIBLE_TEXT = pymupdf.mupdf.FZ_STEXT_FILLED | pymupdf.mupdf.FZ_STEXT_STROKED
|
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)
|
match = WORDLIKE.match(token)
|
||||||
if not match:
|
if not match:
|
||||||
return False
|
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()
|
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:
|
if not function_words:
|
||||||
return True # Sprache ohne Wortliste: nur die Wortform zählt
|
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)
|
hits = sum(1 for w in lowered if w in function_words)
|
||||||
return hits / len(words) >= PLAUSIBLE_FUNCTION_RATIO
|
return hits / len(words) >= PLAUSIBLE_FUNCTION_RATIO
|
||||||
|
|
||||||
|
|
@ -1720,7 +1721,7 @@ def check_ocr_language(language: str) -> None:
|
||||||
tessdata = None
|
tessdata = None
|
||||||
if not tessdata:
|
if not tessdata:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Tesseract nicht gefunden – installieren mit: "
|
"Tesseract nicht gefunden - installieren mit: "
|
||||||
"sudo apt install tesseract-ocr tesseract-ocr-deu"
|
"sudo apt install tesseract-ocr tesseract-ocr-deu"
|
||||||
)
|
)
|
||||||
missing = [
|
missing = [
|
||||||
|
|
@ -1730,7 +1731,7 @@ def check_ocr_language(language: str) -> None:
|
||||||
if missing:
|
if missing:
|
||||||
packages = " ".join(f"tesseract-ocr-{lang}" for lang in missing)
|
packages = " ".join(f"tesseract-ocr-{lang}" for lang in missing)
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Tesseract-Sprachdaten fehlen: {', '.join(missing)} – "
|
f"Tesseract-Sprachdaten fehlen: {', '.join(missing)} - "
|
||||||
f"installieren mit: sudo apt install {packages}"
|
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.
|
# --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.
|
# und gibt Zeilen sich überlappender Blöcke doppelt aus.
|
||||||
|
|
||||||
OCR_TITLE_LINES = 8 # Titel suchen wir unter den ersten Zeilen der Seite
|
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"
|
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]:
|
def _ocr_rows(segments: list[list]) -> list[list]:
|
||||||
"""Stücke auf gleicher Höhe zu Zeilen verbinden: [x0, y0, x1, y1, text].
|
"""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."""
|
wachsender Bereich würde sonst die Nachbarzeilen mit einsammeln."""
|
||||||
if not segments:
|
if not segments:
|
||||||
return []
|
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:
|
def report(i: int, run: str, pages: list[int]) -> None:
|
||||||
print(
|
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",
|
f"({labels[run]}) fertig",
|
||||||
flush=True,
|
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").
|
"""Bildpfade relativ zur Markdown-Datei machen ("<name>_images/datei.jpg").
|
||||||
|
|
||||||
pymupdf4llm schreibt den Pfad relativ zum Arbeitsverzeichnis, wenn der
|
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()
|
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:
|
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 = [
|
levels = [
|
||||||
len(m.group(1))
|
len(m.group(1))
|
||||||
for m in re.finditer(r"^(#{1,6}) ", markdown, re.MULTILINE)
|
for m in re.finditer(r"^(#{1,6}) ", markdown, re.MULTILINE)
|
||||||
|
|
@ -2099,7 +2100,7 @@ def write_epub(
|
||||||
) -> None:
|
) -> None:
|
||||||
pandoc = shutil.which("pandoc")
|
pandoc = shutil.which("pandoc")
|
||||||
if not 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 = [
|
cmd = [
|
||||||
pandoc,
|
pandoc,
|
||||||
|
|
@ -2130,7 +2131,7 @@ def write_epub(
|
||||||
# Metadaten-Kopf, Aufteilen in Einzeldateien, EPUB aus Markdown
|
# Metadaten-Kopf, Aufteilen in Einzeldateien, EPUB aus Markdown
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Jede erzeugte Markdown-Datei beginnt mit einem YAML-Kopf (title, author, lang,
|
# 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.
|
# weil "---"-Zeilen im Buchtext sonst als YAML missverstanden werden könnten.
|
||||||
|
|
||||||
FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n+", re.DOTALL)
|
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.
|
"""Seiten der PDF (1-basiert) je Abschnitt.
|
||||||
|
|
||||||
Startseite aus der PDF-Gliederung, sonst die erste Seite (nach der vorigen),
|
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}
|
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]
|
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:
|
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()]
|
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)
|
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):
|
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:
|
with pymupdf.open(source) as doc:
|
||||||
kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi)
|
kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi)
|
||||||
outline = load_outline(doc)
|
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():
|
for level, title, page_no in doc.get_toc():
|
||||||
if page_no > 0 and title.strip() and not PAGE_LABEL.match(title.strip()):
|
if page_no > 0 and title.strip() and not PAGE_LABEL.match(title.strip()):
|
||||||
titles.setdefault(page_no - 1, []).append((level, title.strip()))
|
titles.setdefault(page_no - 1, []).append((level, title.strip()))
|
||||||
|
|
@ -2549,7 +2550,7 @@ def main() -> int:
|
||||||
print(
|
print(
|
||||||
f"{page_count} Seiten: {counts[PAGE_TEXT]} Text, "
|
f"{page_count} Seiten: {counts[PAGE_TEXT]} Text, "
|
||||||
f"{counts[PAGE_SCAN]} Scan/Bild, {counts[PAGE_OCR_LAYER]} mit "
|
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"
|
f"{plan.count(RUN_OCR)} Seiten werden per OCR gelesen"
|
||||||
)
|
)
|
||||||
unread = sum(
|
unread = sum(
|
||||||
|
|
@ -2607,7 +2608,7 @@ def main() -> int:
|
||||||
if folder:
|
if folder:
|
||||||
sections = split_markdown(markdown, args.split)
|
sections = split_markdown(markdown, args.split)
|
||||||
if sections and sections[0]["title"] == "Abschnitt 1":
|
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:
|
with pymupdf.open(source) as doc:
|
||||||
add_source_pages(doc, sections)
|
add_source_pages(doc, sections)
|
||||||
files = write_split(folder, sections, meta)
|
files = write_split(folder, sections, meta)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue