#!/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
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
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
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 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
# ===========================================================================
# Korrektur für pymupdf4llm: Bindestrich-Komposita am Zeilenende
# ===========================================================================
# 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.
KEEP_HYPHEN = "\ue000" # Strich behalten, ohne Leerzeichen anschließen
KEEP_HYPHEN_SPACE = "\ue001" # Strich und Leerzeichen behalten
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
text = span["text"].rstrip()
if (
nxt is not None
and text.endswith("-")
and (span.get("block"), span.get("line"))
!= (nxt.get("block"), nxt.get("line"))
):
word = FIRST_WORD.match(nxt["text"].lstrip())
if 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="png",
)
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)
def make_image_links_relative(markdown: str, image_dir: Path) -> str:
"""Absolute Bildpfade durch Pfade relativ zur Markdown-Datei ersetzen."""
absolute_dir = image_dir.resolve().as_posix()
relative_dir = image_dir.name
return markdown.replace(
absolute_dir + "/",
relative_dir + "/",
)
# ===========================================================================
# 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)
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",
"--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=min(4, os.cpu_count() or 1),
help="Anzahl paralleler Prozesse (Standard: %(default)s)",
)
parser.add_argument(
"--images",
action="store_true",
help="Grafiken speichern und in Markdown verlinken",
)
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",
default="de",
help="Sprache im EPUB (Standard: de)",
)
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()
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)
page_count = doc.page_count
title = (doc.metadata or {}).get("title") or source.stem
author = (doc.metadata or {}).get("author") or ""
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 / "cover.png"
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 "",
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),
)
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())