#!/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 json
import multiprocessing
import os
import re
import shutil
import subprocess
import sys
import tempfile
import unicodedata
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,
)
# Zeile, die wie ein Gliederungseintrag aussieht ("Teil V: …", "32 Oberst …").
OUTLINE_ENTRY = re.compile(
r"^(?:\d{1,3}\.?\s+\S|(?:teil|buch|band|part|book|kapitel|chapter)\s+(?:[ivxlcdm]+|\d+)\b)",
re.IGNORECASE,
)
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)
if result and result[-1].endswith(HARD_BREAK):
result.append("") # Fließtext nach zeilengetreuen Zeilen
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 index, original in enumerate(lines):
line = original.rstrip()
marker = FENCE.match(line)
if marker and not in_fence and MARKERS.search(line):
# Text aus einem Absatz, der mit ``` beginnt: kein Codezaun.
line = "\\" + line
marker = None
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()
in_fence = True
fence_char = marker.group(1)[0]
fence_length = len(marker.group(1))
# Listing über eine Seitengrenze: an den vorigen Codeblock anhängen.
while result and result[-1] == "":
result.pop()
if result and result[-1] == line.strip() and line.strip() == fence_char * fence_length:
result.pop()
continue
blank_line()
result.append(line)
continue
if end_mark := BLOCK_END_MARK.search(line):
# Blockende ohne Satzende: Hätte das erste Wort des nächsten Blocks
# noch in die Zeile gepasst, war der Umbruch hart.
line = line[: end_mark.start()]
if not continues_block(lines[index + 1:], int(end_mark.group(1)), running_heads):
# Mehrzeiliger Absatz: Absatzende; einzelne Zeile: harter Umbruch
line += PARAGRAPH_END if end_mark.group(2) else HARD_BREAK
if in_contents and line.endswith((HARD_BREAK, PARAGRAPH_END)):
# Inhaltsverzeichnis: jeder Eintrag ein eigener Absatz (einheitlich,
# auch wenn das Verzeichnis in der PDF zeilengetreu erscheint).
line = line.rstrip(HARD_BREAK + PARAGRAPH_END)
elif line.endswith((HARD_BREAK, PARAGRAPH_END)) and OUTLINE_ENTRY.match(
clean_inline(line.rstrip(HARD_BREAK + PARAGRAPH_END))
):
# Verzeichnis ohne Überschrift "Inhalt" (z. B. "Vorschau Band Zwei"):
# Einträge als eigene Absätze statt als Zeilen mit "\".
flush_paragraph()
standalone(clean_inline(line.rstrip(HARD_BREAK + PARAGRAPH_END)))
continue
if line.endswith(PARAGRAPH_END):
content = line.removesuffix(PARAGRAPH_END)
if not paragraph and result and result[-1].endswith(HARD_BREAK):
result.append(content) # letzte Zeile eines zeilengetreuen Blocks
else:
if not paragraph_open():
flush_paragraph()
blank_line()
paragraph.append(content.strip())
flush_paragraph()
blank_line()
continue
if line.endswith(HARD_BREAK):
# Gliederungstitel oder "Inhalt" bleiben Überschriften.
check = clean_inline(line.removesuffix(HARD_BREAK))
key = norm_title(check)
if CONTENTS_TITLE.match(check) or (
key in outline and key not in headings_seen and not in_contents
):
line = line.removesuffix(HARD_BREAK)
if line.endswith(HARD_BREAK) or line == STANZA_BREAK:
# Zeilengetreue Zeile: Einrückung (geschützte Leerzeichen) behalten.
content = line.removesuffix(HARD_BREAK)
check = clean_inline(content)
if in_contents:
contents_seen.add(norm_title(check))
if drop_page_numbers and PAGE_NUMBER.match(check):
continue
if running_heads and running_head_key(check) in running_heads:
continue
if paragraph_open() and line != STANZA_BREAK:
# Letzte Zeile eines Absatzes aus dem vorigen Block
paragraph.append(content.strip())
flush_paragraph()
result[-1] += HARD_BREAK
continue
flush_paragraph()
while result and result[-1] == "" and len(result) > 1 and result[-2].endswith(HARD_BREAK):
result.pop() # Zeilen aus getrennten Blöcken zusammenhalten
if line == STANZA_BREAK:
if result and result[-1].endswith(HARD_BREAK):
result[-1] = result[-1].removesuffix(HARD_BREAK)
blank_line()
continue
if not result or result[-1] == "" or result[-1].endswith(HARD_BREAK):
result.append(line)
else:
blank_line()
result.append(line)
continue
if not line.strip(" \t"):
# 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 finish_hard_breaks("\n".join(result)).strip() + "\n"
def continues_block(following: list[str], free: int, running_heads: set[str]) -> bool:
"""Geht der Satz im nächsten Block weiter? (erstes Wort passte nicht mehr)"""
for line in following:
text = line.strip(" \t")
if not text or PAGE_NUMBER.match(clean_inline(text)):
continue
if running_heads and running_head_key(text) in running_heads:
continue
if heading := HEADING.match(text):
text = heading.group(2) # oft falsch erkannt ("## trösten.")
elif text.startswith(("|", "!", ">", "```", "~~~", "**==>")):
return False
elif LIST_ITEM.match(text) and not ORDERED_ITEM.match(text):
return False # "14. Jahrhundert" ist dagegen oft keine Liste
word = clean_inline(text).replace(NBSP, " ").split()
# Breite aus der vorigen Zeile geschätzt: ein Zeichen Spielraum.
return bool(word) and len(word[0]) + 2 > free
return False
def finish_hard_breaks(markdown: str) -> str:
"""Markierungen harter Umbrüche in Markdown umsetzen.
Folgt eine weitere zeilengetreue Zeile, wird daraus "\" (harter Umbruch);
die letzte Zeile eines Abschnitts endet ohne.
"""
lines = markdown.split("\n")
for i, line in enumerate(lines):
if line.endswith(HARD_BREAK):
text = line.removesuffix(HARD_BREAK).rstrip()
following = lines[i + 1] if i + 1 < len(lines) else ""
if following.strip(" \t") and not FENCE.match(following):
text += "\\"
lines[i] = text
return MARKERS.sub("", "\n".join(lines)) # Sicherheitsnetz
# ===========================================================================
# 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 = ".,;:!?)]}»«“”„\"'*_\\" # "\\": harter Zeilenumbruch
MAX_URL_PARTS = 4 # eine URL kann über so viele Zeilen gebrochen sein
# Ohne Linkziel: Folgewort gehört zur URL, wenn diese an einer typischen
# Umbruchstelle endet und das Wort wie ein URL-Teil aussieht.
URL_BREAK_AFTER = "/_-.=&?#"
URL_CONTINUATION = re.compile(r"^(?:\S*/\S*|(?:html?|php|pdf|aspx?)\W*)$", re.IGNORECASE)
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
# ---------------------------------------------------------------------------
# Zeilengetreue Abschnitte: Code, Register, Verse, Adressen …
# ---------------------------------------------------------------------------
# Ein Zeilenumbruch stammt vom Satzprogramm (weich), wenn das erste Wort der
# nächsten Zeile nicht mehr in die Zeile gepasst hätte. Sonst hat ihn der Autor
# gesetzt (hart); dann bleibt die Zeile samt Einrückung erhalten. Überwiegen in
# einem Block die harten Umbrüche, wird er zeilengetreu ausgegeben: als
# Codeblock (```) oder als Zeilen mit hartem Umbruch ("\" am Zeilenende).
HARD_BREAK = "\ue002" # Zeile endet mit hartem Umbruch (wird zu "\")
BLOCK_END = "\ue004" # Block endet ohne Satzende: "\ue004[P]\ue004"
BLOCK_END_MARK = re.compile("\ue004(\\d+)(P?)\ue004$") # P: mehrzeiliger Absatz
PARAGRAPH_END = "\ue005" # Absatz endet hier, auch ohne Satzende
MARKERS = re.compile("\ue004\\d*P?\ue004|[\ue002-\ue005]") # alle internen Markierungen
STANZA_BREAK = "\ue003" # Leerzeile innerhalb eines zeilengetreuen Blocks
NBSP = "\u00a0"
TEXT_BOXES = ("text", "list-item", "footnote")
# Spielraum beim "Hätte das Wort noch gepasst?": Satzprogramme brechen nicht
# exakt um (Band 1, Flattersatz: bis 3 % der Spalte), Register und Verse
# lassen fast immer weit mehr frei.
FIT_TOLERANCE = 0.05 # Anteil der Spaltenbreite
FIT_TOLERANCE_EM = 0.3 # mindestens
HARD_BLOCK_RATIO = 0.5 # ab diesem Anteil harter Umbrüche: zeilengetreu
STANZA_GAP = 1.6 # Zeilenabstand (× üblichem) für Leerzeile
INDENT_STEP_EM = 0.8 # Einrückungsstufen: Abstand mindestens so groß
CODE_INDENT = " " # Leerzeichen pro Einrückungsstufe im Code
TEXT_INDENT = NBSP * 4 # geschützte Leerzeichen pro Stufe sonst
CODE_HINT = re.compile(
r"[{};]\s*$" # Zeile endet mit { } ;
r"|^\s*[}\])]" # beginnt mit } ] )
r"|::|=>|->|==|!=|\+=|&&|\|\|" # Operatoren
r"|^\s*(?:def|class|fn|let|mut|use|import|from|return|pub|impl|struct|"
r"enum|match|const|var|function|elif|lambda|async|await|#include)\b"
r"|^\s*(?:if|for|while|else|try|except|with)\b.*:\s*$" # Python-Block
r"|^\s*(?:#\[|@\w+|//|/\*|\$ )" # Attribut, Dekorator, Kommentar
r"|\w\([^()]*\)\s*$" # endet mit Aufruf f(x)
)
_page_lines: list[tuple[float, float]] = [] # (x0, x1) der Textzeilen der Seite
def _remember_page_lines(boxes) -> None:
_page_lines.clear()
for box in boxes:
if getattr(box, "boxclass", None) in TEXT_BOXES:
for line in getattr(box, "textlines", None) or []:
_page_lines.append((line["bbox"][0], line["bbox"][2]))
def _visual_lines(textlines: list[dict]) -> list[dict]:
"""Textzeilen auf gleicher Höhe (z. B. Wechsel der Schrift) zusammenfassen."""
merged: list[dict] = []
for line in textlines:
x0, y0, x1, y1 = line["bbox"]
if merged:
last = merged[-1]
ly0, ly1 = last["bbox"][1], last["bbox"][3]
if (y0 + y1) / 2 < ly1 and (y0 + y1) / 2 > ly0 and x0 >= last["bbox"][0]:
bx0, by0, bx1, by1 = last["bbox"]
last["bbox"] = (bx0, min(by0, y0), max(bx1, x1), max(by1, y1))
last["spans"] = last["spans"] + line["spans"]
continue
merged.append({"bbox": tuple(line["bbox"]), "spans": list(line["spans"])})
return merged
def _raw_text(line: dict) -> str:
return "".join(span["text"] for span in line["spans"])
def _char_width(line: dict, em: float) -> float:
chars = sum(len(s["text"]) for s in line["spans"])
width = sum(s["bbox"][2] - s["bbox"][0] for s in line["spans"])
return width / chars if chars and width > 0 else 0.5 * em
def _column_right(lines: list[dict], em: float) -> float:
"""Rechter Rand der Textspalte, in der der Block steht."""
bx0 = min(line["bbox"][0] for line in lines)
bx1 = max(line["bbox"][2] for line in lines)
edges = [
x1 for x0, x1 in _page_lines
if x0 < bx1 and x1 > bx0 and x0 >= bx0 - 2 * em
]
return max(edges + [bx1])
def _fit_tolerance(lines: list[dict], right: float, em: float) -> float:
left = min(line["bbox"][0] for line in lines)
return max(FIT_TOLERANCE_EM * em, FIT_TOLERANCE * (right - left))
def _is_soft_break(prev: dict, line: dict, right: float, em: float, tol: float) -> bool:
"""Hätte das erste Wort der Zeile nicht mehr in die vorige gepasst?"""
prev_text = _raw_text(prev).rstrip()
text = _raw_text(line).strip()
if not text:
return True
if prev_text.endswith(("-",) + SOFT_HYPHENS) and text[0].islower():
return True # Silbentrennung setzt nur das Satzprogramm
word = text.split()[0]
needed = (len(word) + 1) * _char_width(line, em)
return prev["bbox"][2] + needed > right - tol
def _indent_levels(lines: list[dict], em: float) -> list[int]:
"""Einrückung jeder Zeile als Stufe 0, 1, 2 … (geometrisch)."""
lefts = sorted({round(line["bbox"][0], 1) for line in lines})
stops: list[float] = []
for x in lefts:
if not stops or x - stops[-1] >= INDENT_STEP_EM * em:
stops.append(x)
levels = []
for line in lines:
x = line["bbox"][0]
levels.append(max(i for i, stop in enumerate(stops) if stop <= x + 0.5))
return levels
def _is_code(lines: list[dict], ignore_code: bool) -> bool:
if ignore_code:
return False
texts = [_raw_text(line) for line in lines]
if all(
s["flags"] & 8 and s.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT
for line in lines for s in line["spans"] if s["text"].strip()
):
return True # Schreibmaschinenschrift
hints = sum(1 for t in texts if CODE_HINT.search(t.replace(NBSP, " ")))
indented = sum(1 for t in texts if t[:1] in (" ", NBSP, "\t"))
return hints >= max(1, (len(texts) + 1) // 2) or (hints and indented)
def _code_block(logical: list[list[dict]], levels: list[int]) -> str:
rows = []
for parts, level in zip(logical, levels):
text = " ".join(_raw_text(line).replace(NBSP, " ").rstrip() for line in parts)
rows.append(CODE_INDENT * level + text)
fence = "~~~" if any("```" in row for row in rows) else "```"
return fence + "\n" + "\n".join(rows) + "\n" + fence + "\n\n"
def _verbatim_block(
logical: list[list[dict]], levels: list[int], gaps: list[bool], end: str
) -> str:
rows = []
for i, (parts, level) in enumerate(zip(logical, levels)):
spans = [span for line in parts for span in line["spans"]]
text, _ = document_layout.get_styled_text(spans)
lead = len(_raw_text(parts[0])) - len(_raw_text(parts[0]).lstrip())
row = TEXT_INDENT * level + NBSP * lead + text.strip()
last = i == len(logical) - 1
row += (end or PARAGRAPH_END) if last else HARD_BREAK
rows.append(row)
if not last and gaps[i]:
rows.append(STANZA_BREAK)
return "\n".join(rows) + "\n\n"
def _block_end(lines: list[dict], right: float, em: float, tol: float) -> str:
"""Markierung für ein Blockende ohne Satzende: freie Zeichen in der Zeile.
Ob der Satz im nächsten Block weitergeht, entscheidet normalize_markdown
mit dem ersten Wort dieses Blocks ("Hätte es noch gepasst?").
"""
last = lines[-1]
text = _raw_text(last).strip()
if not text or ends_sentence(text) or text.endswith(("-",) + SOFT_HYPHENS):
return ""
free = (right - tol - last["bbox"][2]) / _char_width(last, em)
paragraph = "P" if len(lines) > 1 else ""
return f"{BLOCK_END}{max(0, int(free))}{paragraph}{BLOCK_END}"
def lines_to_md(textlines: list[dict], original, ignore_code: bool = False) -> str:
"""Textblock ausgeben: Fließtext wie bisher, sonst zeilengetreu."""
lines = _visual_lines(textlines)
sizes = sorted(s["size"] for line in lines for s in line["spans"])
em = sizes[len(sizes) // 2] if sizes else 10.0
right = _column_right(lines, em)
tol = _fit_tolerance(lines, right, em)
end = _block_end(lines, right, em, tol)
# Harte Umbrüche ohne Satzende; nach Satzende ist es ein Absatzende.
soft = [_is_soft_break(a, b, right, em, tol) for a, b in zip(lines, lines[1:])]
hard = sum(
1 for is_soft, line in zip(soft, lines)
if not is_soft and not ends_sentence(_raw_text(line).strip())
)
verbatim = bool(soft) and hard / len(soft) >= HARD_BLOCK_RATIO
if not verbatim:
if end and len(lines) == 1 and _is_code(lines, ignore_code):
return _code_block([lines], [0])
# Fließtext: Absätze am Einzug trennen, wie bisher ausgeben.
groups = split_by_indent(textlines)
md = "".join(original(group, ignore_code) for group in groups)
return md.rstrip("\n").rstrip() + end + "\n\n" if end else md
# Zeilengetreu: weich umbrochene Zeilen zu logischen Zeilen verbinden.
logical: list[list[dict]] = [[lines[0]]]
for is_soft, line in zip(soft, lines[1:]):
if is_soft:
logical[-1].append(line)
else:
logical.append([line])
firsts = [parts[0] for parts in logical]
levels = _indent_levels(firsts, em)
if _is_code(lines, ignore_code):
return _code_block(logical, levels)
pitches = [b["bbox"][1] - a["bbox"][1] for a, b in zip(firsts, firsts[1:])]
usual = sorted(pitches)[len(pitches) // 2] if pitches else 0
gaps = [usual > 0 and pitch > STANZA_GAP * usual for pitch in pitches]
return _verbatim_block(logical, levels, gaps, end)
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):
if not textlines or document_layout.is_superscripted(textlines[0]):
return original(textlines, ignore_code)
try:
return lines_to_md(textlines, original, ignore_code)
except (KeyError, IndexError, TypeError, ValueError, ZeroDivisionError):
return original(textlines, ignore_code) # unerwartete Struktur
text_to_md.pdf2md_patched = True
document_layout.text_to_md = text_to_md
levels = getattr(document_layout, "create_list_item_levels", None)
if levels is not None and not getattr(levels, "pdf2md_patched", False):
def create_list_item_levels(layout_info):
_remember_page_lines(layout_info) # einmal pro Seite aufgerufen
return levels(layout_info)
create_list_item_levels.pdf2md_patched = True
document_layout.create_list_item_levels = create_list_item_levels
class _DiscardMessages(io.StringIO):
"""Ersatz für pymupdf4llms Meldungspuffer ("OCR on page …")."""
def write(self, text: str) -> int:
return len(text)
# JPEG-Qualität: pymupdf4llm speichert Bilder mit Pixmap.save() ohne Angabe
# der Qualität (Standard 95); wir setzen sie über --jpeg-quality.
DEFAULT_JPEG_QUALITY = 85
_jpeg_quality = [DEFAULT_JPEG_QUALITY]
_pixmap_save = pymupdf.Pixmap.save
def _save_pixmap(self, filename, output=None, jpg_quality=None):
quality = _jpeg_quality[0] if jpg_quality is None else jpg_quality
return _pixmap_save(self, filename, output, quality)
pymupdf.Pixmap.save = _save_pixmap
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:
_jpeg_quality[0] = options.get("jpeg_quality", DEFAULT_JPEG_QUALITY) # auch im Worker
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, ""
# Ergänzung zum Standard-Stylesheet von pandoc: Code auf dem kleinen
# Bildschirm umbrechen statt abschneiden, Einrückung bleibt erhalten.
EPUB_CSS = """
pre, pre code { white-space: pre-wrap; overflow-wrap: anywhere; }
pre { font-size: 0.85em; line-height: 1.3; }
"""
def epub_stylesheet(pandoc: str) -> str:
done = subprocess.run(
[pandoc, "--print-default-data-file", "epub.css"],
capture_output=True, text=True,
)
return (done.stdout if done.returncode == 0 else "") + EPUB_CSS
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)]
with tempfile.TemporaryDirectory() as tmp:
css = Path(tmp) / "pdf2md.css"
css.write_text(epub_stylesheet(pandoc), encoding="utf-8")
done = subprocess.run(cmd + ["--css", str(css)], capture_output=True, text=True)
if done.returncode != 0:
raise RuntimeError(f"pandoc fehlgeschlagen: {done.stderr.strip()}")
# ===========================================================================
# Metadaten-Kopf, Aufteilen in Einzeldateien, EPUB aus Markdown
# ===========================================================================
# Jede erzeugte Markdown-Datei beginnt mit einem YAML-Kopf (title, author, lang,
# cover …). Wir schreiben und lesen ihn selbst; pandoc bekommt nur den Text,
# weil "---"-Zeilen im Buchtext sonst als YAML missverstanden werden könnten.
FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n+", re.DOTALL)
INDEX_LINK = re.compile(r"\]\(([^)\s]+\.md)\)")
UMLAUTS = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
def make_frontmatter(meta: dict) -> str:
lines = ["---"]
for key, value in meta.items():
if value not in (None, ""):
lines.append(f"{key}: {json.dumps(value, ensure_ascii=False)}")
return "\n".join(lines + ["---"]) + "\n\n"
def split_frontmatter(text: str) -> tuple[dict, str]:
"""YAML-Kopf (nur "schlüssel: wert") abtrennen und auswerten."""
match = FRONTMATTER.match(text)
if not match:
return {}, text
meta = {}
for line in match.group(1).splitlines():
key, sep, value = line.partition(":")
if sep:
value = value.strip()
try:
meta[key.strip()] = json.loads(value)
except ValueError:
meta[key.strip()] = value.strip("'\"")
return meta, text[match.end():]
def slugify(text: str, limit: int = 50) -> str:
text = clean_inline(text).lower().translate(UMLAUTS)
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
return slug[:limit].rstrip("-") or "abschnitt"
def iter_headings(lines: list[str]):
"""(Zeilennummer, Ebene, Text) aller Überschriften außerhalb von Codeblöcken."""
fence = None
for index, line in enumerate(lines):
marker = FENCE.match(line)
if fence:
if marker and marker.group(1)[0] == fence:
fence = None
continue
if marker:
fence = marker.group(1)[0]
elif match := re.match(r"^(#{1,6}) (.+)$", line):
yield index, len(match.group(1)), match.group(2).strip()
def split_markdown(markdown: str, mode: str) -> list[dict]:
"""Buch in Abschnitte teilen: "teile" (oberste Ebene) oder "kapitel".
Titelei und Inhaltsverzeichnis bilden den Vorspann.
"""
lines = markdown.split("\n")
headings = list(iter_headings(lines))
counts = Counter(level for _, level, _ in headings)
levels = sorted(level for level, n in counts.items() if n >= 2 and level <= 3)
if not levels:
return [{"title": "Vorspann", "body": markdown}]
part_level, chapter_level = levels[0], levels[-1]
cut_levels = {part_level} if mode == "teile" else {part_level, chapter_level}
# Bis einschließlich Inhaltsverzeichnis (falls am Anfang) alles Vorspann.
start = 0
for index, _level, text in headings:
if CONTENTS_TITLE.match(clean_inline(text)) and index < len(lines) // 5:
start = index + 1
break
cuts = [(i, lvl, t) for i, lvl, t in headings if lvl in cut_levels and i >= start]
sections = []
first = cuts[0][0] if cuts else len(lines)
front = "\n".join(lines[:first]).strip()
if front:
sections.append({"title": "Vorspann", "body": front + "\n"})
part = ""
for n, (index, level, text) in enumerate(cuts):
end = cuts[n + 1][0] if n + 1 < len(cuts) else len(lines)
title = clean_inline(text)
if level == part_level:
part = title
number = re.match(r"(\d+)\b", title) if level == chapter_level != part_level else None
sections.append({
"title": title,
"part": part if part != title else "",
"chapter": int(number.group(1)) if number else None,
"body": "\n".join(lines[index:end]).strip() + "\n",
})
return sections
def write_split(folder: Path, sections: list[dict], meta: dict) -> list[Path]:
"""Abschnitte als nummerierte Dateien plus index.md (Reihenfolge) schreiben."""
folder.mkdir(parents=True, exist_ok=True)
width = max(2, len(str(len(sections))))
files = []
for order, section in enumerate(sections):
name = f"{order:0{width}d}_{slugify(section['title'])}.md"
head = {
"title": section["title"],
"part": section.get("part"),
"chapter": section.get("chapter"),
"order": order,
"book": meta.get("title"),
"author": meta.get("author"),
}
(folder / name).write_text(make_frontmatter(head) + section["body"], encoding="utf-8")
files.append(folder / name)
entries = "\n".join(
f"- [{s['title']}]({f.name})" for s, f in zip(sections, files)
)
index = (
make_frontmatter(meta)
+ "\n\n"
+ entries + "\n"
)
(folder / "index.md").write_text(index, encoding="utf-8")
return files
def read_markdown_source(path: Path) -> tuple[dict, str, Path]:
"""Metadaten, Text und Basisordner aus einer .md-Datei oder einem Split-Ordner."""
if path.is_dir():
index = path / "index.md"
if not index.is_file():
raise RuntimeError(f"index.md fehlt in {path}")
meta, listing = split_frontmatter(index.read_text(encoding="utf-8"))
parts = []
for name in INDEX_LINK.findall(listing):
file = path / name
if not file.is_file():
raise RuntimeError(f"In index.md genannt, aber nicht vorhanden: {file}")
parts.append(split_frontmatter(file.read_text(encoding="utf-8"))[1].strip())
if not parts:
raise RuntimeError(f"index.md nennt keine Dateien: {index}")
return meta, "\n\n".join(parts) + "\n", path
meta, body = split_frontmatter(path.read_text(encoding="utf-8"))
return meta, body, path.parent
def markdown_to_epub(
source: Path,
epub_path: Path,
*,
title: str | None = None,
author: str | None = None,
language: str | None = None,
) -> None:
"""EPUB aus Markdown-Datei oder Split-Ordner; Angaben aus dem YAML-Kopf."""
meta, body, base = read_markdown_source(source)
cover = base / meta["cover"] if meta.get("cover") else None
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", suffix=".md", dir=base, delete=False
) as tmp:
tmp.write(body)
try:
write_epub(
Path(tmp.name),
epub_path,
title=title or meta.get("title") or source.stem,
author=author or meta.get("author") or "",
language=language or meta.get("lang") or "de",
cover=cover if cover and cover.is_file() else None,
split_level=heading_depth(body),
)
finally:
Path(tmp.name).unlink(missing_ok=True)
# ===========================================================================
# 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, nargs="?", 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(
"--jpeg-quality",
type=int,
default=DEFAULT_JPEG_QUALITY,
metavar="1-100",
help="JPEG-Qualität der Bilder (Standard: %(default)s; 95 = fast verlustfrei, größer)",
)
parser.add_argument(
"--split",
choices=["teile", "kapitel"],
help="Teile bzw. Kapitel als einzelne Markdown-Dateien in einem Ordner "
"(mit index.md für die Reihenfolge) statt einer Datei",
)
parser.add_argument(
"--from-md",
type=Path,
metavar="PFAD",
help="nur EPUB erzeugen: aus einer (bearbeiteten) Markdown-Datei oder einem "
"--split-Ordner; Titel, Autor, Cover aus dem YAML-Kopf",
)
parser.add_argument("--title", help="Titel im EPUB (statt PDF-Metadaten/Dateiname)")
parser.add_argument("--author", help="Autor im EPUB (statt PDF-Metadaten/Dateiname)")
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()
if not 1 <= args.jpeg_quality <= 100:
parser.error("--jpeg-quality muss zwischen 1 und 100 liegen.")
if args.from_md:
return epub_from_markdown(args, parser)
if args.pdf is None:
parser.error("Eingabe-PDF fehlt (oder --from-md PFAD angeben).")
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()
# --split: Ordner "/" mit Einzeldateien und "images/" statt einer Datei
folder = target.with_suffix("") if args.split else None
image_dir = folder / "images" if folder else 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 = (
args.title or ((doc.metadata or {}).get("title") or "").strip() or file_title
)
author = (
args.author or ((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:
# Cover immer mit ablegen, damit --from-md es später findet.
image_dir.mkdir(parents=True, exist_ok=True)
cover = image_dir / f"cover.{args.image_format}"
_jpeg_quality[0] = args.jpeg_quality
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,
jpeg_quality=args.jpeg_quality,
)
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)
base = folder or target.parent
meta = {
"title": title,
"author": author,
"lang": args.lang,
"cover": cover.relative_to(base).as_posix() if cover else None,
}
if folder:
files = write_split(folder, split_markdown(markdown, args.split), meta)
else:
target.write_text(make_frontmatter(meta) + markdown, encoding="utf-8")
if epub_path:
markdown_to_epub(folder or target, epub_path)
except Exception as exc:
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
return 1
if folder:
print(f"Markdown geschrieben: {len(files)} Dateien in {folder} (Reihenfolge: index.md)")
else:
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
def epub_from_markdown(args, parser) -> int:
"""--from-md: EPUB aus bearbeitetem Markdown bzw. Split-Ordner erzeugen."""
source = args.from_md.expanduser()
if not source.exists():
parser.error(f"Nicht gefunden: {source}")
base = source if source.is_dir() else source.with_suffix("")
epub_path = (args.epub_file or base.with_suffix(".epub")).expanduser()
try:
markdown_to_epub(
source, epub_path, title=args.title, author=args.author, language=args.lang
)
except Exception as exc:
print(f"Fehler beim EPUB-Erzeugen: {exc}", file=sys.stderr)
return 1
print(f"EPUB geschrieben: {epub_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())