diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6e8a555 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,19 @@ +# CLAUDE.md + +PDF → Markdown → EPUB für den PocketBook-Reader (digitale und gescannte PDFs). +Aktuelles Programm: `pdf2md_v5.py` (Einzeldatei). v1–v4 sind Historie, nicht ändern. + +## Befehle + +- Tests: `python3 -m pytest` (Fixtures werden synthetisch erzeugt; OCR braucht `tesseract-ocr-deu`) +- Lint: `python3 -m ruff check pdf2md_v5.py tests/` +- Echttest: `python3 pdf2md_v5.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf /band1.md` + +## Konventionen + +- Hilfetexte, Meldungen, Kommentare und Docstrings auf Deutsch (wie v4); Bezeichner Englisch. +- Echttests nie nach `Fertig/` schreiben (dort liegen die Ergebnisse des Nutzers). +- PDFs/EPUBs/Bilder sind per `.gitignore` ausgeschlossen – keine Binärdateien committen. +- `pdf2md_v5.py` patcht zwei Funktionen in `pymupdf4llm.helpers.document_layout` + (`get_styled_text`, `text_to_md`). Nach einem pymupdf4llm-Update prüfen, ob + `tests/test_normalize.py::test_pymupdf4llm_patch_keeps_compound_hyphen` noch grün ist. diff --git a/README.md b/README.md index d67a0e9..afe4a4c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,77 @@ Werkzeug, um PDF-Bücher (hier: *Operation Ljutsch*, Band 1 und 2) in sauberes Markdown und anschließend in ein EPUB für den PocketBook-Reader umzuwandeln – mit Überschriften, -Bildern und echten Absätzen. +Bildern und echten Absätzen. Funktioniert für **digitale** PDFs (Textebene vorhanden) +ebenso wie für **gescannte** PDFs (nur Bilder, per OCR) und für Scans, die bereits eine +OCR-Ebene haben (z. B. von `ocrmypdf`). -- `pdf2md_v1.py` … `pdf2md_v4.py` – bisherige Entwicklungsstände (siehe `Info/`) +## Installation (Ubuntu 24.04) + +```bash +sudo apt install tesseract-ocr tesseract-ocr-deu pandoc +pip install -r requirements.txt # pymupdf4llm, pymupdf-layout, pytest +``` + +`tesseract-ocr` wird nur für Scans gebraucht, `pandoc` nur für `--epub`. + +## Verwendung + +```bash +# digitales oder gescanntes PDF -> Markdown (OCR wird automatisch entschieden) +python3 pdf2md_v5.py buch.pdf buch.md + +# zusätzlich Bilder und EPUB (Titel/Autor aus den PDF-Metadaten, Seite 1 als Cover) +python3 pdf2md_v5.py --epub buch.pdf buch.md + +# Scan in Deutsch und Englisch, 8 parallele OCR-Prozesse +python3 pdf2md_v5.py --ocr-language deu+eng --jobs 8 scan.pdf scan.md +``` + +| Option | Bedeutung | +| :-- | :-- | +| `--ocr auto` (Standard) | Scanseiten per OCR lesen; vorhandene, gerade OCR-Ebenen übernehmen, schiefe neu erkennen; reine Bildseiten in digitalen PDFs (Fotos, Cover) nur nach erfolgreicher Probe-OCR | +| `--ocr always` / `--ocr never` (`--no-ocr`) | alle Seiten neu erkennen / keine OCR | +| `--ocr-language`, `--ocr-dpi` | Tesseract-Sprache(n) (`deu`), Auflösung (300) | +| `--jobs N` | parallele OCR-Prozesse (Standard 4); reine Textdokumente laufen immer sequenziell | +| `--images` | Grafiken als PNG in `_images/` speichern und verlinken | +| `--epub`, `--epub-file PFAD` | EPUB 3 mit pandoc erzeugen (schaltet `--images` ein) | +| `--lang` | Sprache im EPUB (`de`) | +| `--keep-headers`, `--keep-footers` | Kolumnentitel bzw. Seitenzahlen nicht entfernen | + +## Was die Nachbearbeitung macht + +- **Absätze:** Zeilen werden verbunden; eine Absatzgrenze gilt nur nach Satzende + (`.` `!` `?` `…`, auch mit `«` `“` `»` usw. dahinter). Auf Scans werden Absätze am + Einzug erkannt. +- **Silbentrennung:** `Geheim-|dienst` → `Geheimdienst`, `Militär-|Geheimdienst` bleibt mit + Bindestrich, `Arbeits- und` bleibt. +- **Überschriften:** Ebenen aus den PDF-Lesezeichen (Teile `##`, Kapitel `###`); + Satzreste, die fälschlich als Überschrift erkannt wurden, kommen zurück in den Text. +- **Inhaltsverzeichnis:** zusammengeklebte Einträge werden getrennt. +- **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt. + +## Tests + +```bash +python3 -m pytest # ca. 30 s; OCR-/EPUB-Tests werden ohne Tesseract/pandoc übersprungen +``` + +Die Test-PDFs (digital, gescannt, mit OCR-Ebene, gemischt, Fotoseite) werden zur Laufzeit +synthetisch erzeugt. + +## Grenzen + +- Die Ergebnisqualität hängt von pymupdf4llm/pymupdf-layout ab. Zwei Schwächen der + Bibliothek gleicht `pdf2md_v5.py` per Patch aus (Bindestriche am Zeilenende, Absätze in + Scan-Blöcken). Eine weitere ist bekannt: Bei Schriften mit Ligaturen (ff, fi) mit + übergroßer Glyphenbox kann pymupdf4llm einzelne Zeilen verlieren. Bei den Ljutsch-Bänden + tritt das nicht auf (geprüft per Wortabgleich PDF ↔ Markdown). +- OCR-Fehler (z. B. fehlende Leerzeichen) werden nicht korrigiert. +- Bilder werden als PNG gespeichert; das EPUB wird dadurch relativ groß (Band 1 ca. 13 MB). + +## Dateien + +- `pdf2md_v5.py` – aktuelle Version +- `pdf2md_v1.py` … `pdf2md_v4.py` – frühere Entwicklungsstände (siehe `Info/`) - `Info/` – Recherche und Entstehungsgeschichte der Skripte +- `tests/` – pytest-Suite diff --git a/pdf2md_v5.py b/pdf2md_v5.py new file mode 100644 index 0000000..6f7da8c --- /dev/null +++ b/pdf2md_v5.py @@ -0,0 +1,1110 @@ +#!/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* 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()) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..9ee0463 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -p no:asyncio diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..47fad81 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,151 @@ +"""Synthetische Test-PDFs: digital, gescannt, gescannt mit OCR-Ebene, gemischt.""" + +import random +import shutil +import sys +from pathlib import Path + +import pymupdf +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +TITLE = "Das Testbuch" + +CSS = """ +body { font-family: serif; font-size: 12pt; } +h1 { font-size: 24pt; font-weight: bold; margin-bottom: 12pt; } +h2 { font-size: 18pt; font-weight: bold; margin-top: 14pt; margin-bottom: 10pt; } +p { margin-top: 0; margin-bottom: 8pt; text-align: left; } +""" + +PAGES = [ + f""" +

{TITLE}

+

Ein kurzer Roman zum Testen der Umwandlung.

+

Inhaltsverzeichnis

+

Teil I: Der Anfang
1 Der Notar
2 Ein Brief

+ """, + """ +

Teil I: Der Anfang

+

1 Der Notar

+

Albrecht van Oie stand am Fenster und blickte auf die nasse Straße + hinunter. Der Regen hatte am Morgen begonnen und wollte nicht aufhören.

+

Er dachte an die alten Zeiten in Berlin, an die Freunde von damals und + an die vielen Briefe, die er nie beantwortet hatte.

+

Am Nachmittag ging er zum Notar in der Kantstraße. Dort sprach er + lange mit dem Geheim-

+ """, + """ +

dienst über die Lage in der Stadt und über die Zukunft des Landes.

+

2 Ein Brief

+

Der Brief lag auf dem Tisch. Oie nahm ihn langsam zur Hand und las die + ersten Zeilen mit wachsender Spannung.

+

»Teurer Albrecht!« stand da in einer schönen alten Handschrift.

+ """, + """ +

Am Abend saß er lange in der Küche und trank einen Tee. Die Gedanken + an den Brief ließen ihn nicht los.

+

Schließlich ging er zu Bett und schlief tief und fest bis zum + nächsten Morgen.

+ """, +] + +OUTLINE = [ + [1, TITLE, 1], + [2, "Teil I: Der Anfang", 2], + [2, "1 Der Notar", 2], + [2, "2 Ein Brief", 3], +] + +needs_tesseract = pytest.mark.skipif( + not (pymupdf.get_tessdata() and (Path(pymupdf.get_tessdata()) / "deu.traineddata").is_file()), + reason="Tesseract mit deutschen Sprachdaten nicht installiert", +) +needs_pandoc = pytest.mark.skipif( + shutil.which("pandoc") is None, reason="pandoc nicht installiert" +) + + +def _make_digital() -> pymupdf.Document: + doc = pymupdf.open() + for number, html in enumerate(PAGES, start=1): + page = doc.new_page(width=420, height=595) + body = pymupdf.Rect(50, 60, 370, 540) + spare, _scale = page.insert_htmlbox(body, html, css=CSS, scale_low=1) + assert spare >= 0, f"Testseite {number} zu lang" + if number > 1: + # Kolumnentitel und Seitenzahl wie in einem gedruckten Buch + page.insert_text((170, 35), TITLE, fontsize=9) + page.insert_text((205, 570), str(number), fontsize=9) + # Ein Bild auf Seite 3 + pix = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 120, 80), 0) + pix.set_rect(pix.irect, (40, 90, 160)) + pix.set_rect(pymupdf.IRect(20, 20, 100, 60), (230, 200, 60)) + doc[2].insert_image(pymupdf.Rect(150, 420, 270, 500), pixmap=pix) + doc.set_toc(OUTLINE) + doc.set_metadata({"title": TITLE, "author": "Erika Muster"}) + return doc + + +def _scan_page(target: pymupdf.Document, page: pymupdf.Page) -> None: + pix = page.get_pixmap(dpi=300, colorspace=pymupdf.csGRAY) + new = target.new_page(width=page.rect.width, height=page.rect.height) + new.insert_image(new.rect, pixmap=pix) + + +def _ocr_page(target: pymupdf.Document, page: pymupdf.Page) -> None: + # Achtung: MuPDFs OCR liefert bei Graustufen-Pixmaps keinen Text -> RGB. + pix = page.get_pixmap(dpi=300) + with pymupdf.open("pdf", pix.pdfocr_tobytes(language="deu")) as ocr: + target.insert_pdf(ocr) + + +@pytest.fixture(scope="session") +def pdfs(tmp_path_factory) -> dict[str, Path]: + folder = tmp_path_factory.mktemp("pdfs") + paths = {} + + digital = _make_digital() + paths["digital"] = folder / "digital.pdf" + digital.save(paths["digital"]) + + scanned = pymupdf.open() + for page in digital: + _scan_page(scanned, page) + paths["scanned"] = folder / "scanned.pdf" + scanned.save(paths["scanned"]) + + mixed = pymupdf.open() + mixed.insert_pdf(digital, from_page=0, to_page=1) + for page in list(digital)[2:]: + _scan_page(mixed, page) + paths["mixed"] = folder / "mixed.pdf" + mixed.save(paths["mixed"]) + + # Digitales PDF mit einer reinen Fotoseite (Rauschen) am Ende + photo = pymupdf.open() + photo.insert_pdf(digital) + rng = random.Random(7) + noise = pymupdf.Pixmap( + pymupdf.csRGB, 200, 280, bytes(rng.randrange(256) for _ in range(200 * 280 * 3)), 0 + ) + page = photo.new_page(width=420, height=595) + page.insert_image(page.rect, pixmap=noise) + paths["photo"] = folder / "photo.pdf" + photo.save(paths["photo"]) + + blank = pymupdf.open() + blank.new_page() + paths["blank"] = folder / "blank.pdf" + blank.save(paths["blank"]) + + if pymupdf.get_tessdata(): + scanned_ocr = pymupdf.open() + for page in digital: + _ocr_page(scanned_ocr, page) + paths["scanned_ocr"] = folder / "scanned_ocr.pdf" + scanned_ocr.save(paths["scanned_ocr"]) + + return paths diff --git a/tests/test_classify.py b/tests/test_classify.py new file mode 100644 index 0000000..adc8dbd --- /dev/null +++ b/tests/test_classify.py @@ -0,0 +1,144 @@ +"""Seitenklassifizierung und OCR-Planung.""" + +import pymupdf +import pytest + +import pdf2md_v5 as p +from conftest import needs_tesseract + + +def kinds_of(path): + with pymupdf.open(path) as doc: + return [p.classify_page(page) for page in doc] + + +def test_digital_pages_are_text(pdfs): + assert kinds_of(pdfs["digital"]) == [p.PAGE_TEXT] * 4 + + +def test_scanned_pages_are_scans(pdfs): + assert kinds_of(pdfs["scanned"]) == [p.PAGE_SCAN] * 4 + + +@needs_tesseract +def test_scans_with_ocr_layer_are_detected(pdfs): + assert kinds_of(pdfs["scanned_ocr"]) == [p.PAGE_OCR_LAYER] * 4 + + +def test_mixed_document(pdfs): + assert kinds_of(pdfs["mixed"]) == [p.PAGE_TEXT, p.PAGE_TEXT, p.PAGE_SCAN, p.PAGE_SCAN] + + +def test_blank_page_is_empty(pdfs): + assert kinds_of(pdfs["blank"]) == [p.PAGE_EMPTY] + + +def test_plan_never_uses_no_ocr(pdfs): + with pymupdf.open(pdfs["scanned"]) as doc: + _, plan = p.plan_pages(doc, "never", "deu", 300) + assert plan == [p.RUN_PLAIN] * 4 + + +@needs_tesseract +def test_plan_auto_scanned(pdfs): + with pymupdf.open(pdfs["scanned"]) as doc: + _, plan = p.plan_pages(doc, "auto", "deu", 300) + assert plan == [p.RUN_OCR] * 4 + + +@needs_tesseract +def test_plan_auto_reuses_existing_ocr_layer(pdfs): + with pymupdf.open(pdfs["scanned_ocr"]) as doc: + _, plan = p.plan_pages(doc, "auto", "deu", 300) + assert plan == [p.RUN_REUSE] * 4 + + +def _scan_with_text_layer(path, angle): + """Scanseite mit unsichtbarer, um `angle` Grad gedrehter Textebene.""" + doc = pymupdf.open() + page = doc.new_page(width=420, height=595) + pix = pymupdf.Pixmap(pymupdf.csGRAY, pymupdf.IRect(0, 0, 200, 280), 0) + pix.set_rect(pix.irect, (230,)) + page.insert_image(page.rect, pixmap=pix) + for i in range(20): + point = pymupdf.Point(50, 80 + 20 * i) + page.insert_text( + point, + "Eine Zeile der vorhandenen OCR-Ebene", + render_mode=3, + morph=(point, pymupdf.Matrix(1, 1).prerotate(-angle)), + ) + doc.save(path) + return path + + +@needs_tesseract +@pytest.mark.parametrize("angle, expected", [(0, p.RUN_REUSE), (0.5, p.RUN_OCR)]) +def test_plan_auto_redoes_skewed_ocr_layer(tmp_path, angle, expected): + path = _scan_with_text_layer(tmp_path / f"layer_{angle}.pdf", angle) + with pymupdf.open(path) as doc: + kinds, plan = p.plan_pages(doc, "auto", "deu", 300) + assert kinds == [p.PAGE_OCR_LAYER] + assert plan == [expected] + + +@needs_tesseract +def test_plan_auto_mixed_probes_scan_pages(pdfs): + # 2 Text- zu 2 Scanseiten gilt nicht als Scan-Dokument; die Scanseiten + # werden per Probe-OCR geprüft und bestehen sie. + with pymupdf.open(pdfs["mixed"]) as doc: + _, plan = p.plan_pages(doc, "auto", "deu", 300) + assert plan == [p.RUN_PLAIN, p.RUN_PLAIN, p.RUN_OCR, p.RUN_OCR] + + +@needs_tesseract +def test_plan_auto_skips_photo_page(pdfs): + with pymupdf.open(pdfs["photo"]) as doc: + kinds, plan = p.plan_pages(doc, "auto", "deu", 300) + assert kinds[-1] == p.PAGE_SCAN + assert plan == [p.RUN_PLAIN] * 5 + + +@needs_tesseract +def test_plan_always(pdfs): + with pymupdf.open(pdfs["digital"]) as doc: + _, plan = p.plan_pages(doc, "always", "deu", 300) + assert plan == [p.RUN_OCR] * 4 + + +def test_missing_language_gives_clear_error(): + if not pymupdf.get_tessdata(): + pytest.skip("Tesseract nicht installiert") + with pytest.raises(RuntimeError, match="tesseract-ocr-xyz"): + p.check_ocr_language("deu+xyz") + + +@pytest.mark.parametrize( + "text, language, expected", + [ + ("Der Brief lag auf dem Tisch. " * 10, "deu", True), + ("Am Abend saß er lange in der Küche und trank einen Tee. Die Gedanken " + "an den Brief ließen ihn nicht los.", "deu", True), + ("The letter was lying on the table in the kitchen, and he read it " + "twice before he put it away.", "deu+eng", True), + # Cover mit Titel und Autor: zu wenig Wörter + ("Reinhard Otto Kranz OPERATION LJUTSCH Band I Schlüssel zur Deutschen Einheit", "deu", False), + ("fi@ |l1 ;; ¦¦ ~~ ‚, ., " * 20, "deu", False), + # echte Tesseract-Ausgabe einer Rauschseite + ("©Se te\nansa |\naa\n.\nerNee =\nae\neea\nBa:Ben\nBena\needaaf\neeee\naar.\nEl eeBR\neen\n", "deu", False), + ("Zu kurz.", "deu", False), + ], +) +def test_looks_like_text(text, language, expected): + assert p.looks_like_text(text, language) is expected + + +def test_make_tasks_groups_and_chunks(): + plan = [p.RUN_PLAIN] * 3 + [p.RUN_OCR] * 12 + [p.RUN_PLAIN] + tasks = p.make_tasks(plan) + assert [(run, pages[0], pages[-1]) for run, pages in tasks] == [ + (p.RUN_PLAIN, 0, 2), + (p.RUN_OCR, 3, 12), + (p.RUN_OCR, 13, 14), + (p.RUN_PLAIN, 15, 15), + ] diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..e0ca5da --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,121 @@ +"""Ende-zu-Ende: Kommandozeilenaufruf auf den Test-PDFs.""" + +import difflib +import re +import subprocess +import sys +import zipfile + +import pytest + +from conftest import ROOT, TITLE, needs_pandoc, needs_tesseract + +SCRIPT = ROOT / "pdf2md_v5.py" + + +def run(*args) -> subprocess.CompletedProcess: + done = subprocess.run( + [sys.executable, str(SCRIPT), *map(str, args)], + capture_output=True, + text=True, + timeout=600, + ) + assert done.returncode == 0, done.stderr + return done + + +def words(text: str) -> list[str]: + return re.findall(r"[^\W\d_]+", text.lower()) + + +@pytest.fixture(scope="module") +def digital_md(pdfs, tmp_path_factory): + out = tmp_path_factory.mktemp("digital") / "digital.md" + run(pdfs["digital"], out) + return out.read_text(encoding="utf-8") + + +def test_digital_structure(digital_md): + headings = re.findall(r"^#+ .*$", digital_md, re.MULTILINE) + assert headings == [ + "# Das Testbuch", + "## Inhaltsverzeichnis", + "## Teil I: Der Anfang", + "### 1 Der Notar", + "### 2 Ein Brief", + ] + # Absatz über die Seitengrenze mit Silbentrennung + assert "Dort sprach er lange mit dem Geheimdienst über die Lage" in digital_md + # keine Seitenzahlen, keine Kolumnentitel außer der Titelüberschrift + assert not re.search(r"^\d+$", digital_md, re.MULTILINE) + assert digital_md.count(TITLE) == 1 + + +@needs_tesseract +def test_scanned_matches_digital(pdfs, tmp_path, digital_md): + out = tmp_path / "scanned.md" + done = run(pdfs["scanned"], out, "--jobs", "2") + assert "4 Seiten werden per OCR gelesen" in done.stdout + md = out.read_text(encoding="utf-8") + + ratio = difflib.SequenceMatcher(None, words(digital_md), words(md), autojunk=False).ratio() + assert ratio >= 0.9, ratio + assert "Geheimdienst über die Lage" in md + assert re.search(r"^#+ 1 Der Notar$", md, re.MULTILINE) + assert re.search(r"^#+ 2 Ein Brief$", md, re.MULTILINE) + assert not re.search(r"^\d+$", md, re.MULTILINE) + + +@needs_tesseract +def test_existing_ocr_layer_is_reused(pdfs, tmp_path, digital_md): + out = tmp_path / "scanned_ocr.md" + done = run(pdfs["scanned_ocr"], out) + assert "4 mit OCR-Ebene" in done.stdout + assert "0 Seiten werden per OCR gelesen" in done.stdout + md = out.read_text(encoding="utf-8") + ratio = difflib.SequenceMatcher(None, words(digital_md), words(md), autojunk=False).ratio() + assert ratio >= 0.9, ratio + + +@needs_tesseract +def test_mixed_keeps_page_order(pdfs, tmp_path): + out = tmp_path / "mixed.md" + done = run(pdfs["mixed"], out) + assert "2 Seiten werden per OCR gelesen" in done.stdout + md = out.read_text(encoding="utf-8") + positions = [md.find(s) for s in ("Albrecht van Oie", "Geheimdienst", "Teurer Albrecht", "nächsten Morgen")] + assert -1 not in positions + assert positions == sorted(positions) + + +@needs_tesseract +def test_photo_page_produces_no_garbage(pdfs, tmp_path, digital_md): + out = tmp_path / "photo.md" + done = run(pdfs["photo"], out) + assert "0 Seiten werden per OCR gelesen" in done.stdout + assert "Hinweis: 1 Bildseite" in done.stdout + md = out.read_text(encoding="utf-8") + assert words(md)[-5:] == words(digital_md)[-5:] + + +def test_no_ocr_on_scan_yields_hint(pdfs, tmp_path): + out = tmp_path / "noocr.md" + done = run(pdfs["scanned"], out, "--no-ocr") + assert "Hinweis: 4 Bildseite(n)" in done.stdout + assert "Albrecht" not in out.read_text(encoding="utf-8") + + +@needs_pandoc +def test_epub(pdfs, tmp_path): + out = tmp_path / "buch.md" + run(pdfs["digital"], out, "--epub") + epub = tmp_path / "buch.epub" + assert (tmp_path / "buch_images" / "cover.png").is_file() + with zipfile.ZipFile(epub) as z: + assert z.read("mimetype") == b"application/epub+zip" + nav = z.read("EPUB/nav.xhtml").decode() + opf = z.read("EPUB/content.opf").decode() + for title in ("Teil I: Der Anfang", "1 Der Notar", "2 Ein Brief"): + assert title in nav + assert "Erika Muster" in opf + assert "cover" in opf diff --git a/tests/test_normalize.py b/tests/test_normalize.py new file mode 100644 index 0000000..48ef565 --- /dev/null +++ b/tests/test_normalize.py @@ -0,0 +1,265 @@ +"""Unit-Tests für die Markdown-Nachbearbeitung (ohne PDF, schnell).""" + +import pymupdf +import pytest + +import pdf2md_v5 as p + + +def norm(md: str, outline=None, **kwargs) -> str: + return p.normalize_markdown(md, outline, **kwargs) + + +# --- Satzende-Regel --------------------------------------------------------- + +@pytest.mark.parametrize("text", ["Satz.", "Frage?", "»Ja.«", "„Gut!“", "da?«", "(so.)", "Ende.“_"]) +def test_ends_sentence_true(text): + assert p.ends_sentence(text) + + +@pytest.mark.parametrize("text", ["Teil I", "geht weiter", "»Nee«,", "Geheim-"]) +def test_ends_sentence_false(text): + assert not p.ends_sentence(text) + + +def test_soft_line_breaks_are_joined(): + assert norm("Dies ist die erste Zeile\ndesselben Absatzes.\n") == ( + "Dies ist die erste Zeile desselben Absatzes.\n" + ) + + +def test_blank_line_without_sentence_end_continues_paragraph(): + md = "Ein vermeintlicher Absatz\n\nder weitergeht.\n\nNeuer Absatz.\n" + assert norm(md) == "Ein vermeintlicher Absatz der weitergeht.\n\nNeuer Absatz.\n" + + +def test_dialogue_with_guillemets_ends_paragraph(): + md = "»War kein Schrank da?«\n\n»Nee«, sagte Oie.\n" + assert norm(md) == "»War kein Schrank da?«\n\n»Nee«, sagte Oie.\n" + + +# --- Silbentrennung ------------------------------------------------------------- + +@pytest.mark.parametrize( + "prev, nxt, expected", + [ + ("mit dem Geheim-", "dienst sprach", "mit dem Geheimdienst sprach"), + ("der Militär-", "Geheimdienst wusste", "der Militär-Geheimdienst wusste"), + ("gehirnoptimierte Arbeits-", "und Krieger-Skills", "gehirnoptimierte Arbeits- und Krieger-Skills"), + ("ein normaler", "Satz", "ein normaler Satz"), + ("Gedanken –", "und mehr", "Gedanken – und mehr"), + ], +) +def test_join_parts(prev, nxt, expected): + assert p.join_parts(prev, nxt) == expected + + +def test_hyphenation_across_page_break(): + md = "Er sprach mit dem Geheim-\n\n12\n\ndienst über alles.\n" + assert norm(md) == "Er sprach mit dem Geheimdienst über alles.\n" + + +def test_pymupdf4llm_patch_keeps_compound_hyphen(): + # Zwei Zeilen desselben Blocks: "Militär-" | "Geheimdienst" + spans = [ + {"text": "Der Militär-", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 0}, + {"text": "Geheimdienst wusste", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 1}, + {"text": "es. Arbeits-", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 2}, + {"text": "und Krieger-", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 3}, + {"text": "geschrei.", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 4}, + ] + output, _ = p.document_layout.get_styled_text(spans) + assert output.strip() == "Der Militär-Geheimdienst wusste es. Arbeits- und Kriegergeschrei." + + +# --- Überschriften ----------------------------------------------------------------- + +def test_heading_without_space_and_bold_is_cleaned(): + assert norm("##**Inhaltsverzeichnis**\n") == "## Inhaltsverzeichnis\n" + + +def test_false_heading_inside_open_paragraph_is_demoted(): + md = "Er wusste, dass du von der\n\n## Staatssicherheit beschädigt werden solltest.«\n\nNeu.\n" + assert norm(md) == ( + "Er wusste, dass du von der Staatssicherheit beschädigt werden solltest.«\n\nNeu.\n" + ) + + +@pytest.mark.parametrize( + "heading", + [ + "## Sie verabschiedeten sich.", + "## – ist dir das klar?", + "## aber dann kam alles anders", + "## Klappe in Augenhöhe der Tür,", + ], +) +def test_sentence_like_headings_are_demoted(heading): + assert not norm(f"Vorher.\n\n{heading}\n").count("#") + + +@pytest.mark.parametrize("heading", ["## 3 Die Grauen nehmen die Spur auf", "## IV.", "## Wer war Antonow?"]) +def test_real_headings_are_kept(heading): + assert norm(f"Vorher.\n\n{heading}\n\nText.\n").count("\n## ") == 1 + + +def test_trailing_article_marks_false_heading_only_in_open_paragraph(): + md = "Er war Mitglied des Verbandes, damals\n\n## eine Ehre für ihn.\n" + assert "#" not in norm(md) + md = "Er war fertig\n\n## Mitglied des Künstlerverbandes - damals eine\n\nmächtige Organisation.\n" + assert "#" not in norm(md) + + +OUTLINE = { + "das testbuch": 1, + "teil i: der anfang": 2, + "1 der notar": 3, + "2 ein brief": 3, +} + + +def test_outline_sets_heading_levels(): + md = "## **Teil I: Der Anfang**\n\n## 1 Der Notar\n\nText.\n" + assert norm(md, OUTLINE) == "## Teil I: Der Anfang\n\n### 1 Der Notar\n\nText.\n" + + +def test_outline_promotes_bold_line_to_heading(): + md = "Text davor.\n\n**2 Ein Brief**\n\nDer Brief lag da.\n" + assert norm(md, OUTLINE) == "Text davor.\n\n### 2 Ein Brief\n\nDer Brief lag da.\n" + + +def test_outline_demotes_unknown_sentence_heading(): + md = "Text.\n\n## Sie verabschiedeten sich.\n\n## Wer war es?\n" + assert "#" not in norm(md, OUTLINE) + + +def test_load_outline_puts_chapters_below_parts(): + doc = pymupdf.open() + for _ in range(3): + doc.new_page() + doc.set_toc([ + [1, "Titel", 1], + [2, "Vorwort: Berlin 2015", 1], + [2, "Teil I: Das Vermächtnis", 2], + [2, "1 Der Notar", 2], + [2, "Der Autor", 3], + ]) + assert p.load_outline(doc) == { + "titel": 1, + "vorwort: berlin 2015": 2, + "teil i: das vermächtnis": 2, + "1 der notar": 3, + "der autor": 2, + } + + +# --- Inhaltsverzeichnis ------------------------------------------------------------ + +def test_contents_lines_are_split_by_outline_titles(): + titles = sorted(["3 die grauen nehmen die spur auf", "4 flucht aufs land", "teil ii: verbündete"], key=len, reverse=True) + line = "3 Die Grauen nehmen die Spur auf 4 Flucht aufs Land Teil II: Verbündete" + assert p.split_contents_line(line, titles) == [ + "3 Die Grauen nehmen die Spur auf", + "4 Flucht aufs Land", + "Teil II: Verbündete", + ] + + +def test_contents_lines_split_by_numbers_without_outline(): + assert p.split_contents_line("9 Brand-Jagd 10 Samuel Nussbaum – Kryptologe", []) == [ + "9 Brand-Jagd", + "10 Samuel Nussbaum – Kryptologe", + ] + + +def test_contents_block_keeps_entries_and_headings_follow(): + outline = {"1 der notar": 3, "2 ein brief": 3} + md = ( + "## Inhaltsverzeichnis\n\n1 Der Notar 2 Ein Brief\n\n" + "## 1 Der Notar\n\nDer Text beginnt hier.\n" + ) + assert norm(md, outline) == ( + "## Inhaltsverzeichnis\n\n1 Der Notar\n\n2 Ein Brief\n\n" + "### 1 Der Notar\n\nDer Text beginnt hier.\n" + ) + + +def test_heading_formatted_contents_entries_stay_plain(): + outline = {"1 der notar": 3} + md = "## Inhaltsverzeichnis\n\n## 1 Der Notar\n\n## 1 Der Notar\n\nText.\n" + assert norm(md, outline) == "## Inhaltsverzeichnis\n\n1 Der Notar\n\n### 1 Der Notar\n\nText.\n" + + +# --- Seitenzahlen, Kolumnen, Bildtext --------------------------------------------- + +def test_page_numbers_are_dropped(): + md = "Ein Satz ohne\n\n- 12 -\n\nEnde, der weitergeht.\n\nSeite 13\n" + assert norm(md) == "Ein Satz ohne Ende, der weitergeht.\n" + + +def test_page_numbers_kept_on_request(): + assert "12" in norm("Text.\n\n12\n", drop_page_numbers=False) + + +def test_running_heads_are_dropped(): + md = "".join(f"Operation Ljutsch {n}\n\nText Nummer {n}.\n\n" for n in range(1, 5)) + out = norm(md) + assert "Operation Ljutsch" not in out + assert out.count("Text Nummer") == 4 + + +def test_repeated_dialogue_is_not_a_running_head(): + md = "»Ja«\n\nEr nickte.\n\n" * 4 + assert norm(md).count("»Ja«") == 4 + + +def test_picture_text_is_removed(): + md = ( + "Vorher.\n\n**----- Start of picture text -----**
\nKauder welsch
" + "**----- End of picture text -----**
\n\nNachher.\n" + ) + assert norm(md) == "Vorher.\n\nNachher.\n" + + +def test_numbered_line_continuing_paragraph_is_no_list(): + md = "Die Mauer fiel am\n9. November 1989 in Berlin.\n" + assert norm(md) == "Die Mauer fiel am 9. November 1989 in Berlin.\n" + + +def test_real_list_stays_list(): + md = "Liste:\n\n1. Erstens\n2. Zweitens\n" + assert norm(md) == "Liste:\n\n1. Erstens\n2. Zweitens\n" + + +def test_omitted_image_placeholder_stands_alone(): + md = "Ein Satz ohne\n**==> picture [426 x 480] intentionally omitted <==**\nEnde.\n" + assert "\n\n**==> picture [426 x 480] intentionally omitted <==**\n\n" in norm(md) + + +# --- Absatztrennung per Einzug ---------------------------------------------------- + +def _line(x0, x1, y, text): + return {"bbox": (x0, y, x1, y + 12), "spans": [{"text": text, "size": 12.0}]} + + +def test_split_by_indent(): + lines = [ + _line(62, 400, 0, "Erster Absatz beginnt eingerückt und"), + _line(50, 400, 14, "läuft über die volle Breite bis zum"), + _line(50, 380, 28, "Ende des Satzes."), + _line(62, 400, 42, "Zweiter Absatz, wieder eingerückt und"), + _line(50, 200, 56, "kurz."), + _line(50, 400, 70, "Dritter Absatz ohne Einzug nach kurzer Zeile."), + ] + groups = p.split_by_indent(lines) + assert [len(g) for g in groups] == [3, 2, 1] + + +def test_split_by_indent_needs_sentence_end(): + lines = [_line(50, 400, 0, "Ein Satz ohne Ende und"), _line(62, 400, 14, "eingerückte Fortsetzung.")] + assert len(p.split_by_indent(lines)) == 1 + + +def test_demoted_heading_after_closed_paragraph_starts_new_paragraph(): + md = "Erster Absatz.\n## Sie verabschiedeten sich.\n" + assert norm(md) == "Erster Absatz.\n\nSie verabschiedeten sich.\n"