#!/usr/bin/env python3
"""PDF (digital oder gescannt) nach Markdown und optional EPUB konvertieren.
Ablauf:
1. Jede Seite wird klassifiziert: Textseite, Scan ohne Text, Scan mit
vorhandener OCR-Ebene oder leer.
2. Seitengruppen gehen mit passender OCR-Einstellung an pymupdf4llm
(optional parallel).
3. normalize_markdown() gliedert Fließtext nach Satzenden, setzt
Überschriften anhand der PDF-Gliederung, fügt Silbentrennungen
zusammen und entfernt Seitenzahlen und Kolumnentitel.
4. Optional erzeugt pandoc daraus ein EPUB.
"""
import argparse
import contextlib
import io
import multiprocessing
import os
import re
import shutil
import subprocess
import sys
import urllib.parse
from collections import Counter
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
import pymupdf
import pymupdf4llm
from pymupdf4llm.helpers import document_layout
from pymupdf4llm.ocr import tesseract_api
# ===========================================================================
# Markdown-Muster
# ===========================================================================
HEADING = re.compile(r"^\s{0,3}(#{1,6})\s*(\S.*?)\s*#*\s*$")
# Schließende Anführungszeichen (deutsch „…“ ‚…‘ »…«, englisch, Schweiz «…»),
# Klammern und Markdown-Hervorhebungen.
CLOSERS = r"""”“’‘"'»«›‹)\]*_"""
# Satzzeichen, optional gefolgt von schließenden Zeichen (z. B. "…da?«").
SENTENCE_END = re.compile(rf"""[.!?…]+[{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}]+)$""")
# Überschriften, die legitim mit Punkt enden: "3.", "IV.", "Kapitel 3."
NUMBERING_ONLY = re.compile(
r"^(?:(?:kapitel|teil|chapter|part)\s+)?(?:\d+|[ivxlcdm]+)\.$",
re.IGNORECASE,
)
FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
LIST_ITEM = re.compile(r"^\s{0,3}(?:[-+*]|\d+[.)])\s+")
ORDERED_ITEM = re.compile(r"^\s{0,3}\d+[.)]\s+")
BLOCKQUOTE = re.compile(r"^\s{0,3}>\s?")
RULE = re.compile(r"^\s{0,3}(?:-\s*){3,}$|^\s{0,3}(?:\*\s*){3,}$")
IMAGE = re.compile(r"^\s*!\[.*?\]\(.*\)\s*$")
# Beispiel: **==> picture [426 x 480] intentionally omitted <==**
OMITTED_IMAGE = re.compile(
r"^\s*(?:\*\*|__)?\s*==>\s*"
r"(?:picture|image|bild)\b.*?"
r"(?:intentionally omitted|omitted|ausgelassen)\s*"
r"<==\s*(?:\*\*|__)?\s*$",
re.IGNORECASE,
)
# Text, den pymupdf4llm innerhalb von Bildern findet (Fotos, OCR-Reste).
PICTURE_TEXT = re.compile(
r"\*\*----- Start of picture text -----\*\*
.*?"
r"\*\*----- End of picture text -----\*\*
\n?",
re.DOTALL,
)
CONTENTS_TITLE = re.compile(
r"^(?:inhaltsverzeichnis|inhalt|contents|table of contents)$",
re.IGNORECASE,
)
TOC_WITH_PAGE = re.compile(
r"^\s*(?:[-*]\s+)?\S.+?"
r"(?:\.{2,}|\s{2,})"
r"(?:\d+|[ivxlcdm]+)\s*$",
re.IGNORECASE,
)
# Beginn eines nummerierten Eintrags mitten in einer Inhaltsverzeichniszeile.
NEXT_TOC_NUMBER = re.compile(r"\s(?=\d{1,3}\s+[^\W\d_])")
HTML_BLOCK = re.compile(
r"^\s*?(?:table|tr|td|th|div|pre|p|h[1-6])\b",
re.IGNORECASE,
)
# Seitenzahl allein in einer Zeile: "12", "- 12 -", "Seite 12"
PAGE_NUMBER = re.compile(
r"^(?:[-–—]\s*)?(?:(?:seite|page|s\.)\s*)?\d{1,4}(?:\s*[-–—])?$",
re.IGNORECASE,
)
QUOTE_CHARS = set("«»„“”\"‚‘’›‹")
DASHES = str.maketrans({"–": "-", "—": "-", "‐": "-", "‑": "-"})
# Silbentrennung am Zeilen-/Blockende (Bindestrich, weicher Trennstrich, ¬).
HYPHEN_END = re.compile(r"[^\W\d_]{2,}[-\u00ad¬]$")
FIRST_WORD = re.compile(r"[^\W\d_]+")
# Nach "Arbeits-" folgt "und Krieger-Skills": Bindestrich + Leerzeichen bleiben.
CONJUNCTIONS = {
"und", "oder", "bis", "sowie", "als", "wie", "bzw", "noch",
"beziehungsweise", "respektive", "and", "or", "to",
}
# Wörter, mit denen eine echte Überschrift praktisch nie endet (Artikel,
# Konjunktionen). Präpositionen fehlen bewusst: "Die Spur nehmen sie auf".
TRAILING_STOPWORDS = {
"der", "die", "das", "den", "dem", "des", "ein", "eine", "einen", "einem",
"einer", "eines", "und", "oder", "aber", "dass", "daß", "sowie",
"the", "a", "an", "and", "or", "of",
}
# Gliederungseinträge für Teile bzw. nummerierte Kapitel.
PART_TITLE = re.compile(
r"^(?:teil|buch|band|part|book)\s+(?:[ivxlcdm]+|\d+)\b",
re.IGNORECASE,
)
NUMBERED_TITLE = re.compile(
r"^(?:\d+\.?|kapitel\s+\d+|chapter\s+\d+)\s",
re.IGNORECASE,
)
# ===========================================================================
# Hilfsfunktionen für Text
# ===========================================================================
def ends_sentence(text: str) -> bool:
return bool(SENTENCE_END.search(text.rstrip()))
def clean_inline(text: str) -> str:
"""Markdown-Hervorhebungen entfernen und Leerraum vereinheitlichen."""
text = re.sub(r"\*\*|__|(? str:
"""Vergleichsschlüssel für Überschriften und Gliederungseinträge."""
return clean_inline(text).translate(DASHES).lower()
def is_other_structure(line: str) -> bool:
"""Markdown-Strukturen, die nicht zu Fließtext verbunden werden."""
return bool(
LIST_ITEM.match(line)
or BLOCKQUOTE.match(line)
or RULE.match(line)
or IMAGE.match(line)
or HTML_BLOCK.match(line)
or line.lstrip().startswith("|")
or line.startswith((" ", "\t"))
)
def join_parts(prev: str, nxt: str) -> str:
"""Zwei Teile eines Absatzes verbinden und Silbentrennung auflösen.
"Geheim-" + "dienst" -> "Geheimdienst"
"Militär-" + "Geheimdienst" -> "Militär-Geheimdienst"
"Arbeits-" + "und …" -> "Arbeits- und …"
"""
if not HYPHEN_END.search(prev) or not nxt:
return f"{prev} {nxt}"
stem = prev[:-1]
word = FIRST_WORD.match(nxt)
if word and word.group(0).lower() in CONJUNCTIONS:
return f"{stem}- {nxt}"
if nxt[0].islower():
return stem + nxt
return f"{stem}-{nxt}"
def is_false_heading(text: str, has_outline: bool, paragraph_open: bool) -> bool:
"""Erkennen, ob eine "Überschrift" in Wahrheit ein Satz(-rest) ist."""
if not text:
return True
if text[0].islower() or text[0] in "–—-…,;" or text[-1] in ",;":
return True
if NUMBERING_ONLY.match(text):
return False
if paragraph_open and ends_sentence(text):
return True
if paragraph_open and text.split()[-1].lower() in TRAILING_STOPWORDS:
return True # "Mitglied des Künstlerverbandes – damals eine"
if SENTENCE_LIKE_END.search(text):
return True
# Mit Gliederung stehen echte Überschriften im Outline; was dort fehlt
# und wie ein Satz endet, ist Fließtext.
return has_outline and ends_sentence(text)
def running_head_key(line: str) -> str | None:
"""Vergleichsschlüssel für mögliche Kolumnentitel (sonst None)."""
if (
HEADING.match(line)
or OMITTED_IMAGE.match(line)
or is_other_structure(line)
or FENCE.match(line)
):
return None
text = clean_inline(line)
if not text or len(text) > 60 or ends_sentence(text):
return None
if QUOTE_CHARS & set(text):
return None # wörtliche Rede ist kein Kolumnentitel
key = re.sub(r"\d+", "", text)
key = re.sub(r"\s+", " ", key).strip(" .-–—|·").lower()
if sum(ch.isalpha() for ch in key) < 4:
return None
return key
def find_running_heads(lines: list[str], min_repeats: int = 3) -> set[str]:
"""Kurze Zeilen, die (ohne Ziffern) sehr oft vorkommen."""
counts = Counter(k for k in map(running_head_key, lines) if k)
return {key for key, count in counts.items() if count >= min_repeats}
def split_contents_line(line: str, titles: list[str]) -> list[str]:
"""Zusammengeklebte Inhaltsverzeichniseinträge trennen.
titles: normalisierte Gliederungstitel, längste zuerst.
"""
text = clean_inline(line)
entries = []
while text:
low = text.translate(DASHES).lower()
match = next(
(
t for t in titles
if low.startswith(t) and (len(low) == len(t) or low[len(t)] == " ")
),
None,
)
if match:
entries.append(text[: len(match)])
text = text[len(match):].strip()
continue
# Nächsten bekannten Titel oder nächste Kapitelnummer suchen.
cuts = [
pos for t in titles
if (pos := low.find(" " + t, 1)) > 0
and (pos + 1 + len(t) == len(low) or low[pos + 1 + len(t)] == " ")
]
number = NEXT_TOC_NUMBER.search(text)
if number:
cuts.append(number.start())
if not cuts:
entries.append(text)
break
cut = min(cuts)
entries.append(text[:cut].strip())
text = text[cut:].strip()
return [e for e in entries if e]
# ===========================================================================
# Markdown normalisieren
# ===========================================================================
def normalize_markdown(
markdown: str,
outline: dict[str, int] | None = None,
*,
drop_page_numbers: bool = True,
drop_running_heads: bool = True,
running_head_min: int = 3,
) -> str:
outline = outline or {}
titles = sorted(outline, key=len, reverse=True)
markdown = PICTURE_TEXT.sub("", markdown)
lines = markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n")
running_heads = (
find_running_heads(lines, running_head_min) if drop_running_heads else set()
)
result: list[str] = []
paragraph: list[str] = []
in_fence = False
fence_char = ""
fence_length = 0
in_contents = False
contents_seen: set[str] = set() # Titel, die im Inhaltsverzeichnis standen
contents_table = False # Inhaltsverzeichnis liegt als Markdown-Tabelle vor
headings_seen: set[str] = set()
def paragraph_open() -> bool:
return bool(paragraph) and not ends_sentence(paragraph[-1])
def blank_line() -> None:
if result and result[-1] != "":
result.append("")
def flush_paragraph() -> None:
if paragraph:
text = paragraph[0]
for part in paragraph[1:]:
text = join_parts(text, part)
result.append(text)
paragraph.clear()
def standalone(line: str) -> None:
flush_paragraph()
blank_line()
result.append(line)
result.append("")
def heading(level: int, text: str) -> None:
headings_seen.add(norm_title(text))
standalone("#" * min(max(level, 1), 6) + " " + text)
for original in lines:
line = original.rstrip()
marker = FENCE.match(line)
if in_fence:
result.append(line)
if marker:
token = marker.group(1)
if token[0] == fence_char and len(token) >= fence_length:
in_fence = False
continue
if marker:
flush_paragraph()
blank_line()
result.append(line)
in_fence = True
fence_char = marker.group(1)[0]
fence_length = len(marker.group(1))
continue
if not line.strip():
# Leerzeile nur dann als Absatzgrenze behandeln,
# wenn der bisherige Fließtext mit einem Satzende endet.
if paragraph and ends_sentence(paragraph[-1]):
flush_paragraph()
blank_line()
elif not paragraph:
blank_line()
continue
stripped = line.strip()
if not in_contents:
if drop_page_numbers and PAGE_NUMBER.match(clean_inline(stripped)):
continue
if running_heads and running_head_key(stripped) in running_heads:
continue
match = HEADING.match(line)
text = clean_inline(match.group(2)) if match else clean_inline(stripped)
if CONTENTS_TITLE.match(text):
heading(len(match.group(1)) if match else 2, text)
in_contents = True
contents_table = False
continue
if match:
key = norm_title(text)
level = outline.get(key)
if in_contents and level is not None and key not in contents_seen:
# Überschrift-formatierter Eintrag im Inhaltsverzeichnis
contents_seen.add(key)
standalone(text)
continue
if level is None and is_false_heading(
text, bool(outline), paragraph_open()
):
# Satz(-rest) zurück in den Fließtext: setzt einen offenen
# Absatz fort, sonst beginnt er einen neuen.
in_contents = False
if paragraph and not paragraph_open():
flush_paragraph()
blank_line()
paragraph.append(text)
continue
in_contents = False
heading(level or len(match.group(1)), text)
continue
if OMITTED_IMAGE.match(line):
standalone(line)
continue
if in_contents:
if stripped.startswith("|"):
# Verzeichnis als Tabelle: Zeilen unverändert übernehmen,
# sonst zerfällt die Tabelle in einzelne Absätze.
if not contents_table:
flush_paragraph()
blank_line()
contents_table = True
for cell in stripped.strip("|").split("|"):
contents_seen.add(norm_title(cell))
result.append(line)
continue
if contents_table:
in_contents = False # Tabelle zu Ende: Fließtext folgt
blank_line()
elif ends_sentence(text) and len(text) > 80:
in_contents = False # erster Fließtext nach dem Verzeichnis
else:
for entry in split_contents_line(stripped, titles):
contents_seen.add(norm_title(entry))
standalone(entry)
continue
if TOC_WITH_PAGE.match(line):
standalone(line)
continue
# Eigenständige Zeile, die genau einem Gliederungstitel entspricht
# (z. B. nur fett gesetzt): zur Überschrift machen.
key = norm_title(stripped)
if key in outline and key not in headings_seen and len(stripped) <= 150:
heading(outline[key], text)
continue
# "Die Mauer fiel am" + "9. November …" ist keine Liste,
# "Liste:" + "1. Erstens" dagegen schon.
continues_sentence = paragraph_open() and paragraph[-1][-1:].isalnum()
if is_other_structure(line) and not (
ORDERED_ITEM.match(line) and continues_sentence
):
if paragraph:
flush_paragraph()
blank_line() # pandoc braucht eine Leerzeile vor Listen/Tabellen
result.append(line)
continue
# Einfache Zeilenumbrüche und nicht bestätigte Absatzgrenzen
# werden beim Flush durch Leerzeichen ersetzt.
paragraph.append(stripped)
flush_paragraph()
return "\n".join(result).strip() + "\n"
# ===========================================================================
# PDF-Gliederung
# ===========================================================================
def load_outline(doc: pymupdf.Document) -> dict[str, int]:
"""PDF-Lesezeichen als {normalisierter Titel: Überschriftenebene}."""
original: dict[str, int] = {}
for level, title, _page in doc.get_toc(simple=True):
key = norm_title(title)
if key:
original.setdefault(key, level)
# Teile und nummerierte Kapitel stehen oft auf derselben Ebene;
# dann rücken die Kapitel eine Ebene tiefer.
levels = dict(original)
for level in set(original.values()):
keys = [k for k, v in original.items() if v == level]
if any(PART_TITLE.match(k) for k in keys):
for key in keys:
if NUMBERED_TITLE.match(key) and not PART_TITLE.match(key):
levels[key] = level + 1
return levels
# ===========================================================================
# Links
# ===========================================================================
# pymupdf4llm übernimmt keine Link-Annotationen, und gedruckte URLs zerbrechen
# beim Zeilenumbruch ("deutsche-|sehen" -> "deutschesehen", ".../ youtube-...").
# Wir gleichen URLs im Text mit den Linkzielen der PDF ab, ersetzen sie durch
# das korrekte Ziel und schreiben sie als Autolink , den pandoc im
# EPUB als echten Link ausgibt.
URL_START = re.compile(
r"(?-Links und [Text](…)-Zielen
URL_TRAILING = ".,;:!?)]}»«“”„\"'*_"
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)
def url_key(text: str) -> str:
"""Vergleichsschlüssel: Bindestriche, Leerraum und "/" am Ende zählen nicht."""
return re.sub(r"[-\s]", "", text).rstrip("/").lower()
def uri_keys(uri: str) -> set[str]:
"""Schlüssel, unter denen ein Linkziel im gedruckten Text auftauchen kann.
Gedruckt fehlen oft Abfrageteil ("?utm_source=…") oder Endung (".html").
"""
base = uri.split("#")[0]
keys = set()
for variant in (uri, base, base.split("?")[0]):
keys.add(url_key(variant))
keys.add(url_key(re.sub(r"\.(?:html?|php|aspx?)$", "", variant.rstrip("/"))))
return keys
def clean_uri(uri: str) -> str:
"""Tracking-Parameter (utm_…) entfernen."""
parts = urllib.parse.urlsplit(uri)
if "utm_" not in parts.query.lower():
return uri
query = [
(k, v) for k, v in urllib.parse.parse_qsl(parts.query, keep_blank_values=True)
if not k.lower().startswith("utm_")
]
return urllib.parse.urlunsplit(parts._replace(query=urllib.parse.urlencode(query)))
def load_link_uris(doc: pymupdf.Document) -> list[str]:
"""Web-Linkziele der PDF (Link-Annotationen), ohne Dubletten."""
uris: dict[str, None] = {}
for page in doc:
for link in page.get_links():
uri = (link.get("uri") or "").strip()
if re.match(r"https?://", uri, re.IGNORECASE):
uris.setdefault(uri.replace(" ", "%20"))
return list(uris)
def _autolink(url: str) -> str:
if url.lower().startswith("www."):
return f"[{url}](https://{url})"
return f"<{url}>"
def _match_known(tokens: list[str], known: dict[str, str]) -> tuple[str, int] | None:
"""Längste Verbindung von Wörtern, die zu einem Linkziel der PDF passt."""
for count in range(min(MAX_URL_PARTS, len(tokens)), 0, -1):
parts = [t.rstrip() for t in tokens[:count]]
joined = "".join(parts)
consumed = sum(map(len, tokens[: count - 1])) + len(parts[-1])
for strip in range(len(joined)):
candidate = joined[: len(joined) - strip]
if uri := known.get(url_key(candidate)):
return uri, consumed - strip
if candidate[-1] not in URL_TRAILING:
break
return None
def _match_printed(tokens: list[str]) -> tuple[str, int]:
"""URL ohne (passendes) Linkziel: aus dem gedruckten Text zusammensetzen."""
parts = [tokens[0].rstrip()]
for token in tokens[1:MAX_URL_PARTS]:
if parts[-1][-1] in URL_BREAK_AFTER and URL_CONTINUATION.match(token.rstrip()):
parts.append(token.rstrip())
else:
break
joined = "".join(parts)
url = joined.rstrip(URL_TRAILING)
consumed = sum(map(len, tokens[: len(parts) - 1])) + len(parts[-1])
return url, consumed - (len(joined) - len(url))
def _link_line(line: str, known: dict[str, str]) -> str:
out = []
pos = 0
while match := URL_START.search(line, pos):
out.append(line[pos:match.start()])
tokens = re.findall(r"\S+\s*", line[match.start():])
url, consumed = _match_known(tokens, known) or _match_printed(tokens)
out.append(_autolink(url) if "." in url else line[match.start():][:consumed])
pos = match.start() + consumed
out.append(line[pos:])
return "".join(out)
def link_urls(markdown: str, uris: list[str] | None = None) -> str:
"""URLs im Fließtext anklickbar machen und mit PDF-Linkzielen reparieren."""
known: dict[str, str] = {}
for uri in uris or []:
for key in uri_keys(uri):
known.setdefault(key, clean_uri(uri))
lines = markdown.split("\n")
in_fence = False
for i, line in enumerate(lines):
if FENCE.match(line):
in_fence = not in_fence
elif not in_fence and not IMAGE.match(line):
lines[i] = _link_line(line, known)
return "\n".join(lines)
# ===========================================================================
# Korrektur für pymupdf4llm: Trennstriche am Zeilenende, OCR-Ebenen
# ===========================================================================
# pymupdf4llm entfernt jeden Bindestrich am Zeilenende ("Militär-" + Zeile
# "Geheimdienst" -> "MilitärGeheimdienst"). Für Großbuchstaben und
# Konjunktionen ("Arbeits- und") markieren wir den Strich vorher, sodass er
# erhalten bleibt. Echte Silbentrennung (Kleinbuchstabe) bleibt unverändert.
# Weiche Trennstriche (U+00AD) und "¬" kennt pymupdf4llm nicht ("wach senden");
# sie trennen immer innerhalb eines Wortes und werden zu "-", das dann entfällt.
#
# Unsichtbarer Text (OCR-Ebene eines Scans) ist oft in Courier gesetzt;
# pymupdf4llm hielte ihn sonst für Programmcode und setzte jedes Wort in `…`.
KEEP_HYPHEN = "\ue000" # Strich behalten, ohne Leerzeichen anschließen
KEEP_HYPHEN_SPACE = "\ue001" # Strich und Leerzeichen behalten
SOFT_HYPHENS = ("\u00ad", "¬")
MONOSPACE_FLAG = 8
def _mark_line_end_hyphens(spans: list[dict]) -> list[dict]:
marked = []
for i, span in enumerate(spans):
nxt = spans[i + 1] if i + 1 < len(spans) else None
if not span.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT:
span = {**span, "flags": span.get("flags", 0) & ~MONOSPACE_FLAG}
text = span["text"].rstrip()
line_end = nxt is not None and (span.get("block"), span.get("line")) != (
nxt.get("block"), nxt.get("line")
)
if line_end and text.endswith(SOFT_HYPHENS):
span = {**span, "text": text[:-1] + "-"}
elif line_end and text.endswith("-"):
word = FIRST_WORD.match(nxt["text"].lstrip())
last = text.split()[-1]
if URL_START.search(last) or "/" in last:
span = {**span, "text": text + KEEP_HYPHEN} # URLs trennt niemand
elif word and word.group(0).lower() in CONJUNCTIONS:
span = {**span, "text": text + KEEP_HYPHEN_SPACE}
elif word and word.group(0)[0].isupper():
span = {**span, "text": text + KEEP_HYPHEN}
marked.append(span)
return marked
def patch_pymupdf4llm_hyphens() -> None:
original = getattr(document_layout, "get_styled_text", None)
if original is None or getattr(original, "pdf2md_patched", False):
return
def get_styled_text(spans):
output, suffix = original(_mark_line_end_hyphens(spans))
output = (
output.replace(KEEP_HYPHEN + " ", "")
.replace(KEEP_HYPHEN, "")
.replace(KEEP_HYPHEN_SPACE, "")
)
return output, suffix
get_styled_text.pdf2md_patched = True
document_layout.get_styled_text = get_styled_text
# Gedruckte Bücher trennen Absätze durch Einzug statt Leerraum. Auf Scanseiten
# fasst das Layout-Modell dann mehrere Absätze zu einem Block zusammen, und
# pymupdf4llm verbindet alle Zeilen. Wir trennen den Block vorher dort, wo
# eine Zeile mit Satzende schließt und die nächste eingerückt ist bzw. die
# vorige Zeile deutlich kürzer war.
INDENT_MIN_EM = 0.8 # Einzug ab ca. 1 Geviert
INDENT_MAX_EM = 8.0 # mehr ist eher zentrierter Text
SHORT_LINE_RATIO = 0.7 # Absatzende: Zeile kürzer als 70 % der längsten
def _line_text(line: dict) -> str:
return "".join(span["text"] for span in line["spans"]).strip()
def split_by_indent(textlines: list[dict]) -> list[list[dict]]:
"""Zeilen eines Textblocks in Absätze aufteilen."""
if len(textlines) < 2:
return [textlines]
sizes = sorted(s["size"] for line in textlines for s in line["spans"])
em = sizes[len(sizes) // 2] if sizes else 10.0
lefts = sorted(line["bbox"][0] for line in textlines)
left = lefts[len(lefts) // 4] # Blockrand, robust gegen Einzüge
widths = [line["bbox"][2] - line["bbox"][0] for line in textlines]
longest = max(widths)
groups = [[textlines[0]]]
for prev, line, prev_width in zip(textlines, textlines[1:], widths):
indent = line["bbox"][0] - left
indented = INDENT_MIN_EM * em <= indent <= INDENT_MAX_EM * em
short = prev_width < SHORT_LINE_RATIO * longest
if (indented or short) and ends_sentence(_line_text(prev)):
groups.append([line])
else:
groups[-1].append(line)
return groups
def patch_pymupdf4llm_paragraphs() -> None:
original = getattr(document_layout, "text_to_md", None)
if original is None or getattr(original, "pdf2md_patched", False):
return
def text_to_md(textlines, ignore_code: bool = False):
try:
groups = split_by_indent(textlines)
except (KeyError, IndexError, TypeError):
groups = [textlines] # unerwartete Struktur: Originalverhalten
return "".join(original(group, ignore_code) for group in groups)
text_to_md.pdf2md_patched = True
document_layout.text_to_md = text_to_md
class _DiscardMessages(io.StringIO):
"""Ersatz für pymupdf4llms Meldungspuffer ("OCR on page …")."""
def write(self, text: str) -> int:
return len(text)
patch_pymupdf4llm_hyphens()
patch_pymupdf4llm_paragraphs()
document_layout.INFO_MESSAGES = _DiscardMessages()
# ===========================================================================
# Seiten klassifizieren und OCR planen
# ===========================================================================
PAGE_TEXT = "text" # digitale Textseite
PAGE_OCR_LAYER = "ocr-layer" # Scan mit vorhandener (unsichtbarer) OCR-Ebene
PAGE_SCAN = "scan" # Bildseite ohne Text
PAGE_EMPTY = "empty"
MIN_TEXT_CHARS = 50
MAX_BAD_CHAR_RATIO = 0.1
MIN_IMAGE_COVER = 0.3
MIN_OCR_LAYER_CHARS = 20
PLAUSIBLE_MIN_CHARS = 40
PLAUSIBLE_MIN_WORDS = 20 # Cover/Titelbilder haben weniger Wörter
PLAUSIBLE_WORD_RATIO = 0.7
PLAUSIBLE_FUNCTION_RATIO = 0.1
WORDLIKE = re.compile(r"^\W*([^\W\d_]{2,}(?:[-'’][^\W\d_]+)*)\W*$")
# Häufige Funktionswörter: In echtem Fließtext machen sie gut 10 % aus,
# im "Text", den die OCR aus Fotos oder Rauschen liest, praktisch nie.
FUNCTION_WORDS = {
"deu": set(
"der die das den dem des ein eine einen einem einer und oder aber in im "
"an am auf aus bei mit von zu zum zur für über unter vor nach ist war "
"sind waren hat hatte wird wurde nicht er sie es ich wir ihr du man sich "
"so wie als auch noch nur schon dass daß wenn da dann doch ja".split()
),
"eng": set(
"the a an and or but in on at of to for with is was are were be been he "
"she it i we you they not that this as by from his her had have".split()
),
"fra": set(
"le la les un une et ou de du des en dans est sont pas que qui il elle "
"au aux ce se sur pour par avec".split()
),
"spa": set(
"el la los las un una y o de del en es son no que se al por con para "
"su lo como".split()
),
}
SKEW_TOLERANCE = 0.002 # sin(Winkel), ca. 0,1°
MAX_SKEWED_LINES = 0.2 # mehr schiefe Zeilen -> OCR-Ebene neu erkennen
VISIBLE_TEXT = pymupdf.mupdf.FZ_STEXT_FILLED | pymupdf.mupdf.FZ_STEXT_STROKED
# Verarbeitung pro Seite
RUN_PLAIN = "plain" # vorhandenen Text verwenden, keine OCR
RUN_OCR = "ocr" # Tesseract-OCR
RUN_REUSE = "reuse" # vorhandene OCR-Ebene übernehmen
CHUNK_SIZE = {RUN_PLAIN: 50, RUN_OCR: 10, RUN_REUSE: 25}
def classify_page(page: pymupdf.Page) -> str:
visible = invisible = bad = 0
for block in page.get_text("dict")["blocks"]:
for line in block.get("lines", []):
for span in line["spans"]:
text = span["text"].strip()
if not text:
continue
if span["char_flags"] & VISIBLE_TEXT and span.get("alpha", 255):
visible += len(text)
bad += text.count("\ufffd")
else:
invisible += len(text)
image_rect = pymupdf.EMPTY_RECT()
for kind, bbox in page.get_bboxlog():
if kind in ("fill-image", "fill-imgmask"):
image_rect |= pymupdf.Rect(bbox) & page.rect
cover = abs(image_rect) / abs(page.rect) if abs(page.rect) else 0.0
if visible >= MIN_TEXT_CHARS:
return PAGE_SCAN if bad / visible > MAX_BAD_CHAR_RATIO else PAGE_TEXT
if cover >= MIN_IMAGE_COVER:
return PAGE_OCR_LAYER if invisible >= MIN_OCR_LAYER_CHARS else PAGE_SCAN
return PAGE_TEXT if visible else PAGE_EMPTY
def is_wordlike(token: str) -> bool:
"""Buchstabenwort mit üblicher Schreibweise (nicht "erNee", "eeBR")."""
match = WORDLIKE.match(token)
if not match:
return False
core = re.sub(r"[-'’]", "", match.group(1))
return core.islower() or core.isupper() or core.istitle()
def skewed_line_ratio(page: pymupdf.Page) -> float:
"""Anteil schräg liegender Textzeilen (z. B. OCR-Ebene eines schiefen Scans)."""
total = skewed = 0
for block in page.get_text("dict")["blocks"]:
for line in block.get("lines", []):
total += 1
skewed += abs(line["dir"][1]) > SKEW_TOLERANCE
return skewed / total if total else 0.0
def looks_like_text(text: str, language: str = "deu") -> bool:
"""Plausibilitätsprüfung: Ergibt die OCR echten Text oder Kauderwelsch?"""
words = text.split()
if len(text.strip()) < PLAUSIBLE_MIN_CHARS or len(words) < PLAUSIBLE_MIN_WORDS:
return False
if sum(map(is_wordlike, words)) / len(words) < PLAUSIBLE_WORD_RATIO:
return False
function_words = set().union(
*(FUNCTION_WORDS.get(lang, set()) for lang in language.split("+"))
)
if not function_words:
return True # Sprache ohne Wortliste: nur die Wortform zählt
lowered = [w.strip(".,;:!?»«„“”\"'‚‘’()").lower() for w in words]
hits = sum(1 for w in lowered if w in function_words)
return hits / len(words) >= PLAUSIBLE_FUNCTION_RATIO
def check_ocr_language(language: str) -> None:
try:
tessdata = pymupdf.get_tessdata()
except Exception:
tessdata = None
if not tessdata:
raise RuntimeError(
"Tesseract nicht gefunden – installieren mit: "
"sudo apt install tesseract-ocr tesseract-ocr-deu"
)
missing = [
lang for lang in language.split("+")
if not (Path(tessdata) / f"{lang}.traineddata").is_file()
]
if missing:
packages = " ".join(f"tesseract-ocr-{lang}" for lang in missing)
raise RuntimeError(
f"Tesseract-Sprachdaten fehlen: {', '.join(missing)} – "
f"installieren mit: sudo apt install {packages}"
)
def probe_ocr(page: pymupdf.Page, language: str, dpi: int) -> bool:
textpage = page.get_textpage_ocr(language=language, dpi=dpi, full=True)
return looks_like_text(page.get_text(textpage=textpage), language)
def plan_pages(
doc: pymupdf.Document, mode: str, language: str, dpi: int
) -> tuple[list[str], list[str]]:
"""Seitentypen ermitteln und pro Seite die Verarbeitung festlegen."""
kinds = [classify_page(page) for page in doc]
counts = Counter(kinds)
if mode == "never":
return kinds, [RUN_REUSE if k == PAGE_OCR_LAYER else RUN_PLAIN for k in kinds]
if mode == "always":
check_ocr_language(language)
return kinds, [RUN_PLAIN if k == PAGE_EMPTY else RUN_OCR for k in kinds]
# auto
if counts[PAGE_SCAN] or counts[PAGE_OCR_LAYER]:
check_ocr_language(language)
scanned_doc = counts[PAGE_SCAN] + counts[PAGE_OCR_LAYER] > counts[PAGE_TEXT]
plan = []
for page, kind in zip(doc, kinds):
if kind == PAGE_OCR_LAYER:
# Gute OCR-Ebenen übernehmen; schiefe (Scan nicht entzerrt)
# liefern eine falsche Lesereihenfolge -> neu erkennen.
skewed = skewed_line_ratio(page) > MAX_SKEWED_LINES
plan.append(RUN_OCR if skewed else RUN_REUSE)
elif kind == PAGE_SCAN and (scanned_doc or probe_ocr(page, language, dpi)):
# In digitalen PDFs sind reine Bildseiten meist Fotos/Cover;
# die werden nur übernommen, wenn die Probe-OCR echten Text liefert.
plan.append(RUN_OCR)
else:
plan.append(RUN_PLAIN)
return kinds, plan
def make_tasks(plan: list[str]) -> list[tuple[str, list[int]]]:
"""Aufeinanderfolgende Seiten gleicher Verarbeitung zu Paketen bündeln."""
tasks: list[tuple[str, list[int]]] = []
for pno, run in enumerate(plan):
if (
tasks
and tasks[-1][0] == run
and tasks[-1][1][-1] == pno - 1
and len(tasks[-1][1]) < CHUNK_SIZE[run]
):
tasks[-1][1].append(pno)
else:
tasks.append((run, [pno]))
return tasks
# ===========================================================================
# Konvertierung mit pymupdf4llm
# ===========================================================================
def keep_existing_ocr(page, **kwargs) -> None:
"""OCR-Funktion, die nichts tut: Die vorhandene OCR-Ebene wird genutzt.
pymupdf4llm verwendet unsichtbaren Text nur auf Seiten, die als
OCR-Seiten gelten; mit dieser Funktion entfällt eine zweite OCR.
"""
def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str:
kwargs = dict(
pages=pages,
header=options["keep_headers"],
footer=options["keep_footers"],
write_images=options["images"],
image_path=options["image_path"],
image_format=options["image_format"],
)
if run == RUN_OCR:
kwargs.update(
force_ocr=True,
ocr_function=tesseract_api.exec_ocr,
ocr_language=options["ocr_language"],
ocr_dpi=options["ocr_dpi"],
)
elif run == RUN_REUSE:
kwargs.update(force_ocr=True, ocr_function=keep_existing_ocr)
else:
kwargs.update(use_ocr=False)
# pymupdf4llm schreibt Statusmeldungen auf stdout; wir melden selbst.
with contextlib.redirect_stdout(io.StringIO()):
return pymupdf4llm.to_markdown(source, **kwargs)
def _init_worker() -> None:
# Ein Thread pro Prozess, falls Tesseract mit OpenMP gebaut ist;
# die Parallelität kommt von den Prozessen.
os.environ["OMP_THREAD_LIMIT"] = "1"
def extract_markdown(source: Path, plan: list[str], options: dict, jobs: int) -> str:
tasks = make_tasks(plan)
total = len(tasks)
labels = {RUN_PLAIN: "Text", RUN_OCR: "OCR", RUN_REUSE: "OCR-Ebene"}
def report(i: int, run: str, pages: list[int]) -> None:
print(
f" [{i}/{total}] Seiten {pages[0] + 1}–{pages[-1] + 1} "
f"({labels[run]}) fertig",
flush=True,
)
parts = []
# Nur OCR profitiert von mehreren Prozessen; das Layout-Modell nutzt
# ohnehin alle Kerne.
if jobs <= 1 or total == 1 or RUN_OCR not in plan:
for i, (run, pages) in enumerate(tasks, 1):
parts.append(convert_pages(str(source), run, pages, options))
report(i, run, pages)
else:
context = multiprocessing.get_context("spawn")
with ProcessPoolExecutor(
max_workers=min(jobs, total),
mp_context=context,
initializer=_init_worker,
) as pool:
futures = [
pool.submit(convert_pages, str(source), run, pages, options)
for run, pages in tasks
]
for i, (future, (run, pages)) in enumerate(zip(futures, tasks), 1):
parts.append(future.result())
report(i, run, pages)
return "\n\n".join(parts)
IMAGE_LINK = re.compile(r"(!\[[^\]]*\]\()([^)\s]+)\)")
def make_image_links_relative(markdown: str, image_dir: Path) -> str:
"""Bildpfade relativ zur Markdown-Datei machen ("_images/datei.jpg").
pymupdf4llm schreibt den Pfad relativ zum Arbeitsverzeichnis, wenn der
Bilderordner darunter liegt (z. B. "Output/x_images/…"), sonst absolut.
"""
image_dir = image_dir.resolve()
def fix(match: re.Match) -> str:
path = Path(match.group(2))
full = path if path.is_absolute() else Path.cwd() / path
if full.resolve().parent != image_dir:
return match.group(0)
return f"{match.group(1)}{image_dir.name}/{path.name})"
return IMAGE_LINK.sub(fix, markdown)
# ===========================================================================
# EPUB mit pandoc
# ===========================================================================
def heading_depth(markdown: str) -> int:
"""Tiefste verwendete Überschriftenebene (1–3) für die Kapitelaufteilung."""
levels = [
len(m.group(1))
for m in re.finditer(r"^(#{1,6}) ", markdown, re.MULTILINE)
]
return min(max(levels, default=1), 3)
# Tesseract-Sprachcode -> Sprachcode im EPUB (ISO 639-1)
EPUB_LANGUAGES = {
"deu": "de", "deu_latf": "de", "frk": "de", "eng": "en", "fra": "fr",
"spa": "es", "ita": "it", "nld": "nl", "por": "pt", "pol": "pl",
"ces": "cs", "dan": "da", "swe": "sv", "nor": "no", "fin": "fi",
"rus": "ru", "ukr": "uk", "lat": "la", "ell": "el", "tur": "tr",
}
def epub_language(ocr_language: str) -> str:
"""EPUB-Sprache aus der (ersten) OCR-Sprache: "eng+deu" -> "en"."""
first = ocr_language.split("+")[0].strip().lower()
return EPUB_LANGUAGES.get(first, "de")
def default_jobs() -> int:
"""Halbe Kernzahl, höchstens 8: Jeder Prozess lädt das Layout-Modell."""
return max(1, min(8, (os.cpu_count() or 2) // 2))
def title_author_from_filename(stem: str) -> tuple[str, str]:
"""Titel und Autor aus einem Dateinamen nach dem Muster "Autor_-_Titel".
"Katja_Adler_-_Rolle_rückwärts" -> ("Rolle rückwärts", "Katja Adler");
ohne " - " ist der ganze Name der Titel.
"""
name = re.sub(r"\s+", " ", stem.replace("_", " ")).strip()
author, sep, title = name.partition(" - ")
if sep and author.strip() and title.strip():
return title.strip(), author.strip()
return name, ""
def write_epub(
markdown_path: Path,
epub_path: Path,
*,
title: str,
author: str,
language: str,
cover: Path | None,
split_level: int,
) -> None:
pandoc = shutil.which("pandoc")
if not pandoc:
raise RuntimeError("pandoc nicht gefunden – installieren mit: sudo apt install pandoc")
cmd = [
pandoc,
str(markdown_path),
"--from", "markdown-yaml_metadata_block+autolink_bare_uris",
"--to", "epub3",
"--output", str(epub_path),
"--resource-path", str(markdown_path.parent),
f"--split-level={split_level}",
f"--toc-depth={split_level}",
"--metadata", f"title={title}",
"--metadata", f"lang={language}",
]
if author:
cmd += ["--metadata", f"author={author}"]
if cover:
cmd += ["--epub-cover-image", str(cover)]
done = subprocess.run(cmd, capture_output=True, text=True)
if done.returncode != 0:
raise RuntimeError(f"pandoc fehlgeschlagen: {done.stderr.strip()}")
# ===========================================================================
# Kommandozeile
# ===========================================================================
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"PDF (digital oder gescannt) in Markdown umwandeln; Fließtext nach "
"Satzenden gliedern, optional Grafiken speichern und EPUB erzeugen."
)
)
parser.add_argument("pdf", type=Path, help="Eingabe-PDF")
parser.add_argument(
"markdown",
type=Path,
nargs="?",
help="Ausgabe-Datei; Standard: gleicher Name mit .md",
)
parser.add_argument(
"--ocr",
choices=["auto", "always", "never"],
default="auto",
help=(
"auto: nur Scanseiten per OCR lesen, gerade vorhandene OCR-Ebenen "
"übernehmen, schiefe neu erkennen (Standard); always: alle Seiten "
"neu erkennen; never: keine OCR"
),
)
parser.add_argument(
"--no-ocr",
dest="ocr",
action="store_const",
const="never",
help="Kurzform für --ocr never",
)
parser.add_argument(
"--ocr-language",
default="deu",
help="Tesseract-Sprachcode bei OCR, z. B. deu+eng (Standard: deu)",
)
parser.add_argument(
"--ocr-dpi",
type=int,
default=300,
help="Auflösung für die OCR (Standard: 300)",
)
parser.add_argument(
"--jobs",
type=int,
default=default_jobs(),
help="Anzahl paralleler OCR-Prozesse (Standard: halbe Kernzahl, "
"höchstens 8; hier %(default)s)",
)
parser.add_argument(
"--images",
action="store_true",
help="Grafiken speichern und in Markdown verlinken",
)
parser.add_argument(
"--image-format",
choices=["jpg", "png"],
default="jpg",
help="Bildformat: jpg (klein, Standard) oder png (verlustfrei, groß)",
)
parser.add_argument(
"--epub",
action="store_true",
help="zusätzlich EPUB mit pandoc erzeugen (schaltet --images ein)",
)
parser.add_argument(
"--epub-file",
type=Path,
metavar="PFAD",
help="Pfad des EPUB; Standard: gleicher Name wie Markdown mit .epub",
)
parser.add_argument(
"--lang",
help="Sprache im EPUB, z. B. en (Standard: aus --ocr-language, "
"deu -> de, eng -> en)",
)
parser.add_argument(
"--keep-headers",
action="store_true",
help="Seitenköpfe/Kolumnentitel übernehmen",
)
parser.add_argument(
"--keep-footers",
action="store_true",
help="Seitenfüße/Seitenzahlen übernehmen",
)
args = parser.parse_args()
args.lang = args.lang or epub_language(args.ocr_language)
source = args.pdf.expanduser()
target = (args.markdown or source.with_suffix(".md")).expanduser()
if not source.is_file():
parser.error(f"Eingabedatei nicht gefunden: {source}")
if source.resolve() == target.resolve():
parser.error("Eingabe- und Ausgabedatei müssen verschieden sein.")
epub_path = None
if args.epub or args.epub_file:
args.images = True # EPUB ohne Bilder wäre unvollständig
epub_path = (args.epub_file or target.with_suffix(".epub")).expanduser()
image_dir = target.parent / f"{target.stem}_images"
try:
with pymupdf.open(source) as doc:
kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi)
outline = load_outline(doc)
uris = load_link_uris(doc)
page_count = doc.page_count
file_title, file_author = title_author_from_filename(source.stem)
title = ((doc.metadata or {}).get("title") or "").strip() or file_title
author = ((doc.metadata or {}).get("author") or "").strip() or file_author
counts = Counter(kinds)
print(
f"{page_count} Seiten: {counts[PAGE_TEXT]} Text, "
f"{counts[PAGE_SCAN]} Scan/Bild, {counts[PAGE_OCR_LAYER]} mit "
f"OCR-Ebene, {counts[PAGE_EMPTY]} leer → "
f"{plan.count(RUN_OCR)} Seiten werden per OCR gelesen"
)
unread = sum(
1 for k, run in zip(kinds, plan) if k == PAGE_SCAN and run != RUN_OCR
)
if unread:
print(
f"Hinweis: {unread} Bildseite(n) ohne Text bleiben ohne OCR "
"(Fotos/Cover oder --ocr never)."
)
target.parent.mkdir(parents=True, exist_ok=True)
cover = None
if args.images:
image_dir.mkdir(parents=True, exist_ok=True)
if epub_path:
cover = image_dir / f"cover.{args.image_format}"
doc[0].get_pixmap(dpi=150).save(cover)
options = dict(
keep_headers=args.keep_headers,
keep_footers=args.keep_footers,
images=args.images,
image_path=str(image_dir.resolve()) if args.images else "",
image_format=args.image_format,
ocr_language=args.ocr_language,
ocr_dpi=args.ocr_dpi,
)
markdown = extract_markdown(source, plan, options, args.jobs)
if args.images:
markdown = make_image_links_relative(markdown, image_dir)
markdown = normalize_markdown(
markdown,
outline,
drop_page_numbers=not args.keep_footers,
drop_running_heads=not args.keep_headers,
running_head_min=max(3, page_count // 10),
)
markdown = link_urls(markdown, uris)
target.write_text(markdown, encoding="utf-8")
if epub_path:
write_epub(
target,
epub_path,
title=title,
author=author,
language=args.lang,
cover=cover,
split_level=heading_depth(markdown),
)
except Exception as exc:
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
return 1
print(f"Markdown geschrieben: {target}")
if args.images:
print(f"Bilder gespeichert in: {image_dir}")
if epub_path:
print(f"EPUB geschrieben: {epub_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())