diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index edfb491..733b205 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -228,7 +228,9 @@ python3 pdf2md.py --epub scan.pdf Output/scan.md | Buch in Deutsch (Standard) | – (`--ocr-language deu`) | | Deutsch und Englisch gemischt | `--ocr-language deu+eng` (Sprachpaket `tesseract-ocr-eng` nötig) | | mehr oder weniger parallele OCR-Prozesse | `--jobs N` (Standard: halbe Kernzahl, höchstens 8) | -| OCR erzwingen, auch für vorhandene OCR-Ebenen | `--ocr always` | +| OCR erzwingen, auch für vorhandene OCR-Ebenen | `--ocr always` (alte Ebene wird verworfen) | +| Gedichtband, Liederbuch | zusätzlich `--keep-lines` (siehe unten) | +| Fraktur | `--ocr-language frk+deu` (Paket `tesseract-ocr-frk` nötig; „ſ“ bleibt erhalten) | | englisches Buch | `--ocr-language eng` (Sprachpaket `tesseract-ocr-eng` nötig; EPUB-Sprache wird automatisch `en`) | | keine OCR | `--ocr never` oder `--no-ocr` | @@ -240,7 +242,17 @@ So entscheidet das Programm von selbst (`--ocr auto`): übernommen, das geht sehr schnell. Eine schiefe Ebene (Scan nicht entzerrt) wird neu erkannt, weil ihre Lesereihenfolge oft falsch ist. Alte OCR-Ebenen (z. B. aus Digitalisaten älterer Bücher) sind oft schlecht: Kolumnentitel werden zu Überschriften, Buchstaben sind falsch - erkannt. Dann mit `--ocr always` neu erkennen lassen. + erkannt. Dann mit `--ocr always` neu erkennen lassen; die alte Ebene wird dabei verworfen. +- **Rauschen vom Scanrand** (Buchstabenfetzen neben dem Textbereich) wird entfernt. + +**Gedichtbände und Liederbücher:** Füllen die Verse die Zeile fast, kann das Programm sie nicht +von Fließtext unterscheiden und fügt sie zusammen. Mit `--keep-lines` bleibt jede Zeile eine +Zeile, Strophen werden durch Leerzeilen getrennt, Einrückungen bleiben; nichts wird +zusammengefügt, auch keine Silbentrennung: + +```bash +python3 pdf2md.py --epub --ocr always --keep-lines Neue_Gedichte_-_Rainer_Maria_Rilke.pdf Output/Neue_Gedichte_-_Rainer_Maria_Rilke.md +``` - **Reine Bildseiten in einem sonst digitalen PDF** (Fotos, Cover) werden nur übernommen, wenn eine Probe-OCR echten Fließtext liefert. Sonst bleiben sie Bild, und das Programm meldet einen „Hinweis“. @@ -270,6 +282,7 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g | `--from-md PFAD` | nur EPUB aus `.md`-Datei oder `--split`-Ordner (Abschnitt 3, 4) | | `--title`, `--author` | Titel/Autor im EPUB vorgeben | | `--lang` | Sprache im EPUB (Standard: aus `--ocr-language` abgeleitet, `deu`→`de`, `eng`→`en`; sonst `de`) | +| `--keep-lines` | jede Zeile der PDF bleibt eine Zeile (Gedichte, Liedtexte); nichts wird zusammengefügt | | `--keep-headers` | Kolumnentitel/Seitenköpfe nicht entfernen | | `--keep-footers` | Seitenzahlen/Seitenfüße nicht entfernen | @@ -310,6 +323,7 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g | Fließtext zerfällt in einzelne Zeilen mit `\` | in Weg B die `\` am Zeilenende entfernen | | Formeln erscheinen als Bilder | bei Mathebüchern normal; dafür ist das Programm nicht gebaut | | Alter Scan: falsche Überschriften, viele Lesefehler | `--ocr always` (vorhandene OCR-Ebene ignorieren) | +| Gedicht wird zu Fließtext (Verse zusammengefügt) | `--keep-lines` | | Auf Scans fehlen Leerzeichen („deutetin“) | OCR-Fehler; höhere Auflösung versuchen (`--ocr-dpi 400`) oder von Hand korrigieren | | EPUB zu groß | `--image-format jpg` (Standard) verwenden; PNG ist ca. 3× größer; `--jpeg-quality 70` spart weiter | | `--from-md`: „In index.md genannt, aber nicht vorhanden“ | Datei umbenannt oder gelöscht → Link in `index.md` anpassen | diff --git a/README.md b/README.md index 8d13c48..12b3019 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ 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`. +`tesseract-ocr` wird nur für Scans gebraucht, `pandoc` nur für `--epub`. Für Fraktur-Scans +zusätzlich `sudo apt install tesseract-ocr-frk` und `--ocr-language frk+deu`. ## Verwendung @@ -59,6 +60,7 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP | `--from-md PFAD` | nur EPUB erzeugen – aus einer (bearbeiteten) `.md` oder einem `--split`-Ordner | | `--title`, `--author` | Titel/Autor im EPUB vorgeben (statt PDF-Metadaten bzw. YAML-Kopf) | | `--lang` | Sprache im EPUB (Standard: aus `--ocr-language`, `deu`→`de`, `eng`→`en`) | +| `--keep-lines` | jede Zeile der PDF bleibt eine Zeile (Gedichtbände, Liedtexte): Strophen als Absätze, Einrückungen bleiben; nichts wird zusammengefügt, auch keine Silbentrennung | | `--keep-headers`, `--keep-footers` | Kolumnentitel bzw. Seitenzahlen nicht entfernen | ## Was die Nachbearbeitung macht @@ -72,7 +74,9 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP (Markdown: `\` am Zeilenende, Einrückung als geschütztes Leerzeichen). Codeblöcke über eine Seitengrenze werden zusammengeführt; lange Codezeilen bricht der Reader um. - **Verse:** Eine kurze Zeile mit Satzende gilt nur dann als Absatzende, wenn die nächste - Zeile eingerückt ist (Roman); sonst ist sie eine Verszeile und bleibt eine Zeile. + Zeile eingerückt ist (Roman); sonst ist sie eine Verszeile und bleibt eine Zeile. Füllen + Verse die Zeile fast (schmale Gedichtbände), ist das geometrisch nicht von Fließtext zu + unterscheiden – dafür gibt es `--keep-lines`. - **Absätze:** eine Absatzgrenze gilt nach Satzende (`.` `!` `?` `…`, auch mit `«` `“` `»` usw. dahinter) oder an einem harten Umbruch; auf Scans werden Absätze am Einzug erkannt. - **Silbentrennung:** `Geheim-|dienst` → `Geheimdienst`, `Militär-|Geheimdienst` bleibt mit @@ -124,7 +128,12 @@ synthetisch erzeugt. 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. Bei alten Scans mit - mitgelieferter OCR-Ebene liefert `--ocr always` meist deutlich besseren Text. + mitgelieferter OCR-Ebene liefert `--ocr always` meist deutlich besseren Text (die alte + Ebene wird dabei verworfen, z. B. „bliht“ → „blüht“). OCR-Rauschen vom Scanrand (Buchkante + als „|“, Buchstabenfetzen neben dem Textbereich) wird entfernt. +- Fraktur (`--ocr-language frk+deu`): Das lange s bleibt als „ſ“ erhalten; bei manchen Scans + legt die Zeilenbildung von pymupdf4llm zwei eng stehende Verszeilen zusammen + („UndDon tauchteweißen …“). - **Mathematische Formeln** werden als Bilder übernommen; für Mathebücher ist ein anderes Werkzeug besser geeignet (z. B. Marker, Nougat). - **Was am PDF liegt, bleibt:** fehlende Ligaturen ohne Textzuordnung („Offzielle“), @@ -134,7 +143,7 @@ synthetisch erzeugt. Link (Zensur-Report: alle 76 gesetzt). Ohne Linkziel in der PDF wird eine URL nur über typische Umbruchstellen (`-`, `/`, `_`) mit ihrer Fortsetzung verbunden. - Zeilengetreue Erkennung: Ist ein Gedicht genauso eingerückt wie Absatzanfänge, werden - Verszeilen mit Satzzeichen am Ende zu eigenen Absätzen. Listings mit eingedruckten + Verszeilen mit Satzzeichen am Ende zu eigenen Absätzen (Abhilfe: `--keep-lines`). Listings mit eingedruckten Zeilennummern werden nicht immer als ein Codeblock erkannt. Weitere bekannte Schwächen: [TODO.md](TODO.md). - Bilder werden als JPEG (Qualität 85) gespeichert; EPUB Band 1 ca. 3 MB. diff --git a/TODO.md b/TODO.md index 4070b01..b83a313 100644 --- a/TODO.md +++ b/TODO.md @@ -7,5 +7,9 @@ - Zeilengetreu: Ein Gedicht, das genauso eingerückt ist wie Absatzanfänge (Band 2, „Blinde Flecken“: alle Verse bei x = 94 wie Absatzanfänge), ist geometrisch nicht von mehreren einzeiligen Dialogabsätzen zu unterscheiden; Verszeilen mit Satzende werden dort Absätze. + Für reine Gedichtbände: `--keep-lines`. +- Fraktur-Scans (Löwenberg 1895): Zwei eng stehende OCR-Zeilen werden von der Zeilenbildung + in pymupdf4llm/pymupdf-layout gelegentlich zu einer verschränkt („UndDon tauchteweißen“); + im OCR-PDF selbst sind sie getrennt. Idee: Option, „ſ“ in „s“ umzuwandeln. - Listings mit eingedruckten Zeilennummern (Markdown Guide: „4 “, „5 “) werden nicht immer als ein Codeblock erkannt; Programmausgaben ohne typische Merkmale bleiben Text. diff --git a/pdf2md.py b/pdf2md.py index 46ce5a7..bd5e066 100644 --- a/pdf2md.py +++ b/pdf2md.py @@ -32,7 +32,7 @@ from pathlib import Path import pymupdf import pymupdf4llm from pymupdf4llm.helpers import document_layout -from pymupdf4llm.ocr import tesseract_api +from pymupdf4llm.ocr import OCRMode, tesseract_api # =========================================================================== @@ -499,6 +499,9 @@ def normalize_markdown( standalone(text) continue + if not HAS_ALNUM.search(text): + continue # Überschrift nur aus Zeichen ("# ||", OCR-Rauschen) + if level is None and NUMBER_HEADING.match(text) and not heading_follows( lines, index ): @@ -1023,6 +1026,8 @@ def _mark_line_end_hyphens(spans: list[dict]) -> list[dict]: 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} + if not span.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT: # OCR-Text + span = {**span, "text": OCR_NOISE.sub("", span["text"])} # Scanrand-"|" if not (span.get("flags", 0) & MONOSPACE_FLAG): # Code bleibt wörtlich span = {**span, "text": md_escape(span["text"])} text = span["text"].rstrip() @@ -1161,14 +1166,22 @@ COMMAND_LINE = re.compile( ) _page_lines: list[tuple[float, float]] = [] # (x0, x1) der Textzeilen der Seite +_page_text: list[tuple[float, float] | None] = [None] # Textbereich aus echten Zeilen +_keep_lines = [False] # --keep-lines: jede PDF-Zeile bleibt eine Zeile +REAL_WORD = re.compile(r"[^\W\d_]{3,}") def _remember_page_lines(boxes) -> None: _page_lines.clear() + real: list[tuple[float, float]] = [] 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])) + text = "".join(span["text"] for span in line["spans"]) + if len(REAL_WORD.findall(text)) >= 2: # keine Seitenzahl, kein Scan-Rauschen + real.append((line["bbox"][0], line["bbox"][2])) + _page_text[0] = (min(x0 for x0, _ in real), max(x1 for _, x1 in real)) if real else None def _visual_lines(textlines: list[dict]) -> list[dict]: @@ -1258,16 +1271,20 @@ def _indent_levels(lines: list[dict], em: float) -> list[int]: return levels +def _is_monospace(lines: list[dict]) -> bool: + return 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() + ) + + def _is_code(lines: list[dict], ignore_code: bool, short: bool = False) -> bool: """Ganzer Block Code? short: kurzer Block – dann zählen auch Konfiguration, Befehle und Programmausgaben (sonst nur zeilenweise, siehe _mixed_block).""" 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() - ): + if _is_monospace(lines): return True # Schreibmaschinenschrift hints = sum( 1 for t in texts @@ -1366,11 +1383,68 @@ def _block_end(lines: list[dict], right: float, em: float, tol: float) -> str: return f"{BLOCK_END}{max(0, int(free))}{paragraph}{BLOCK_END}" +OCR_NOISE = re.compile(r"[|¦]") # Buchkante/Seitenrand als "|" gelesen +HAS_ALNUM = re.compile(r"[^\W_]") + + +def _clean_ocr_lines(textlines: list[dict]) -> list[dict]: + """OCR-Seiten: Zeilen mit Wörtern auf verschiedenen Grundlinien trennen + (schiefer Scan: "UndDon tauchteweißen") und Rauschen entfernen.""" + result = [] + for line in textlines: + groups: list[list[dict]] = [] + for span in sorted(line["spans"], key=lambda s: s["origin"][1]): + text = OCR_NOISE.sub("", span["text"]) + if not text.strip(): + continue + span = {**span, "text": text} + if groups and abs(span["origin"][1] - groups[-1][0]["origin"][1]) <= 0.4 * span["size"]: + groups[-1].append(span) + else: + groups.append([span]) + for spans in groups: + spans.sort(key=lambda s: s["bbox"][0]) + if not HAS_ALNUM.search("".join(s["text"] for s in spans)): + continue # nur Rauschen ("„+", "%*") + bbox = ( + min(s["bbox"][0] for s in spans), min(s["bbox"][1] for s in spans), + max(s["bbox"][2] for s in spans), max(s["bbox"][3] for s in spans), + ) + result.append({**line, "bbox": bbox, "spans": spans}) + result.sort(key=lambda line: (line["bbox"][1], line["bbox"][0])) + return result + + +def _is_ocr_text(textlines: list[dict]) -> bool: + """OCR-Text ist unsichtbar (Render-Modus 3) – wie utils.is_ocr_text in pymupdf4llm.""" + spans = [s for line in textlines for s in line["spans"] if s["text"].strip()] + return bool(spans) and not any(s.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT for s in spans) + + +def _outside_text_area(lines: list[dict], em: float) -> bool: + """Block ganz links/rechts neben dem Textbereich (OCR-Rauschen vom Scanrand).""" + if _page_text[0] is None: + return False + left, right = _page_text[0] + bx0 = min(line["bbox"][0] for line in lines) + bx1 = max(line["bbox"][2] for line in lines) + return bx1 < left - 0.5 * em or bx0 > right + 0.5 * em + + def lines_to_md(textlines: list[dict], original, ignore_code: bool = False) -> str: """Textblock ausgeben: Fließtext wie bisher, sonst zeilengetreu.""" + ocr = _is_ocr_text(textlines) + if ocr: + textlines = _clean_ocr_lines(textlines) + if not textlines: + return "" 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 + if ocr and _outside_text_area(lines, em): + return "" # OCR-Seite: Rauschen am Rand verwerfen + if _keep_lines[0]: + return _keep_lines_md(lines, em, ignore_code) right = _column_right(lines, em) tol = _fit_tolerance(lines, right, em) end = _block_end(lines, right, em, tol) @@ -1421,6 +1495,24 @@ def lines_to_md(textlines: list[dict], original, ignore_code: bool = False) -> s return _verbatim_block(logical, levels, gaps, end) +def _keep_lines_md(lines: list[dict], em: float, ignore_code: bool) -> str: + """--keep-lines: jede Zeile bleibt eine Zeile, nichts wird zusammengefügt. + + Strophen-/Absatzabstände werden Leerzeilen, Einrückungen bleiben. Ein Block + mit mehreren Zeilen endet mit Absatzende (Strophe), eine Einzelzeile mit + hartem Umbruch (sie kann zur nächsten Zeile gehören). + """ + logical = [[line] for line in lines] + levels = _indent_levels(lines, em) + if len(lines) > 1 and not ignore_code and _is_monospace(lines): + return _code_block(logical, levels) + pitches = [b["bbox"][1] - a["bbox"][1] for a, b in zip(lines, lines[1:])] + usual = sorted(pitches)[len(pitches) // 2] if pitches else 0 + gaps = [usual > 0 and pitch > STANZA_GAP * usual for pitch in pitches] + end = PARAGRAPH_END if len(lines) > 1 else HARD_BREAK + 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): @@ -1686,6 +1778,7 @@ def keep_existing_ocr(page, **kwargs) -> None: 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 + _keep_lines[0] = options.get("keep_lines", False) kwargs = dict( pages=pages, header=options["keep_headers"], @@ -1695,8 +1788,10 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str image_format=options["image_format"], ) if run == RUN_OCR: + # FORCE_DROP_OLD: vorhandene (schlechte) OCR-Ebene verwerfen und neu + # erkennen; force_ocr=True hieße FORCE_KEEP_OLD und behielte den alten Text. kwargs.update( - force_ocr=True, + use_ocr=OCRMode.FORCE_DROP_OLD, ocr_function=tesseract_api.exec_ocr, ocr_language=options["ocr_language"], ocr_dpi=options["ocr_dpi"], @@ -1708,7 +1803,24 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str # pymupdf4llm schreibt Statusmeldungen auf stdout; wir melden selbst. with contextlib.redirect_stdout(io.StringIO()): - return pymupdf4llm.to_markdown(source, **kwargs) + if run != RUN_OCR: + return pymupdf4llm.to_markdown(source, **kwargs) + # Fremde OCR-Ebenen entfernt pymupdf4llm nicht (nur eigene); sonst stünde + # alter und neuer Text doppelt da. Nur die geöffnete Kopie wird geändert. + with pymupdf.open(source) as doc: + for pno in pages: + drop_invisible_text(doc[pno]) + return pymupdf4llm.to_markdown(doc, **kwargs) + + +def drop_invisible_text(page: pymupdf.Page) -> None: + """Unsichtbaren Text (vorhandene OCR-Ebene) entfernen; Bild und Grafik bleiben.""" + page.add_redact_annot(page.rect, fill=False) + page.apply_redactions( + images=pymupdf.PDF_REDACT_IMAGE_NONE, + graphics=pymupdf.PDF_REDACT_LINE_ART_NONE, + text=pymupdf.PDF_REDACT_TEXT_REMOVE_INVISIBLE, + ) def _init_worker() -> None: @@ -2232,6 +2344,12 @@ def main() -> int: help="Sprache im EPUB, z. B. en (Standard: aus --ocr-language, " "deu -> de, eng -> en)", ) + parser.add_argument( + "--keep-lines", + action="store_true", + help="jede Zeile der PDF bleibt eine Zeile (Gedichte, Liedtexte); kein " + "Zusammenfügen, auch keine Silbentrennung", + ) parser.add_argument( "--keep-headers", action="store_true", @@ -2319,6 +2437,7 @@ def main() -> int: ocr_language=args.ocr_language, ocr_dpi=args.ocr_dpi, jpeg_quality=args.jpeg_quality, + keep_lines=args.keep_lines, ) markdown = extract_markdown(source, plan, options, args.jobs) diff --git a/tests/conftest.py b/tests/conftest.py index 47fad81..0446ed1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -141,6 +141,15 @@ def pdfs(tmp_path_factory) -> dict[str, Path]: paths["blank"] = folder / "blank.pdf" blank.save(paths["blank"]) + # Scan mit falscher (unsichtbarer) OCR-Ebene, wie von einem schlechten OCR-Lauf + bad_ocr = pymupdf.open() + for page in digital: + _scan_page(bad_ocr, page) + for y in range(80, 500, 20): + bad_ocr[-1].insert_text((60, y), "Falscher Text " * 4, fontsize=10, render_mode=3) + paths["bad_ocr"] = folder / "bad_ocr.pdf" + bad_ocr.save(paths["bad_ocr"]) + if pymupdf.get_tessdata(): scanned_ocr = pymupdf.open() for page in digital: diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 32038ca..e237e4c 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -81,6 +81,22 @@ def test_existing_ocr_layer_is_reused(pdfs, tmp_path, digital_md): assert ratio >= 0.9, ratio +@needs_tesseract +def test_ocr_always_replaces_bad_ocr_layer(pdfs, tmp_path, digital_md): + out = tmp_path / "bad_ocr.md" + run(pdfs["bad_ocr"], out, "--ocr", "always") + md = out.read_text(encoding="utf-8") + assert "Falscher" not in md # alte Ebene verworfen, nicht verdoppelt + ratio = difflib.SequenceMatcher(None, words(digital_md), words(md), autojunk=False).ratio() + assert ratio >= 0.9, ratio + + +def test_auto_keeps_existing_ocr_layer(pdfs, tmp_path): + out = tmp_path / "bad_ocr_auto.md" + run(pdfs["bad_ocr"], out) + assert "Falscher Text" in out.read_text(encoding="utf-8") + + @needs_tesseract def test_mixed_keeps_page_order(pdfs, tmp_path): out = tmp_path / "mixed.md" diff --git a/tests/test_verbatim.py b/tests/test_verbatim.py index c34c5fe..a2139d7 100644 --- a/tests/test_verbatim.py +++ b/tests/test_verbatim.py @@ -204,3 +204,84 @@ def test_single_code_like_register_entry_stays_text(tmp_path): ]) assert "```" not in md assert "@classmethod, 190\\" in md + + +# --- --keep-lines (Gedichtbände) ---------------------------------------------------- + +POEM = [ + "O wie blüht mein Leib aus jeder Ader", + "duftender, seitdem ich dich erkenn;", + "sieh, ich gehe schlanker und gerader,", + "und du wartest nur: wer bist du denn?", + None, # Strophenabstand + "Sieh: ich fühle, wie ich mich entferne,", + "wie ich Altes, Blatt um Blatt, verlier. Lei-", + "be und Seele sind wie lauter Sterne.", +] + + +def make_poem_pdf(path): + """Schmale Seite, Verse füllen die Zeile fast (wie im Rilke-Band).""" + doc = pymupdf.open() + page = doc.new_page(width=272, height=400) + y = 60 + for line in POEM: + if line is None: + y += 14 + continue + page.insert_text((44, y), line, fontname="tiro", fontsize=10) + y += 13 + doc.save(path) + return path + + +def test_poem_without_keep_lines_becomes_prose(tmp_path): + out = tmp_path / "gedicht.md" + run(make_poem_pdf(tmp_path / "gedicht.pdf"), out) + assert "aus jeder Ader duftender" in out.read_text(encoding="utf-8") + + +def test_keep_lines_keeps_every_line_and_stanza(tmp_path): + out = tmp_path / "gedicht.md" + run(make_poem_pdf(tmp_path / "gedicht.pdf"), out, "--keep-lines") + md = out.read_text(encoding="utf-8") + assert ( + "O wie blüht mein Leib aus jeder Ader\\\n" + "duftender, seitdem ich dich erkenn;\\\n" + "sieh, ich gehe schlanker und gerader,\\\n" + "und du wartest nur: wer bist du denn?\n\n" + "Sieh: ich fühle, wie ich mich entferne,\\\n" + "wie ich Altes, Blatt um Blatt, verlier. Lei-\\\n" + "be und Seele sind wie lauter Sterne." + ) in md + assert "```" not in md # Zeilen auf ";" sind kein Code + + +def test_ocr_noise_outside_text_area_is_dropped(): + line = {"bbox": (0, 10, 12, 20), "spans": [{"text": "aoe", "size": 10}]} + p._page_text[0] = (44, 212) + try: + assert p._outside_text_area([line], 10) + assert not p._outside_text_area([{**line, "bbox": (44, 10, 200, 20)}], 10) + finally: + p._page_text[0] = None + + +def _ocr_span(text, x, y, size=10): + return {"text": text, "size": size, "flags": 0, "char_flags": 0, "font": "GlyphLessFont", + "origin": (x, y), "bbox": (x, y - size, x + 6 * len(text), y)} + + +def test_clean_ocr_lines_removes_edge_noise_and_splits_baselines(): + lines = [ + {"bbox": (0, 0, 400, 20), "spans": [_ocr_span("||", 10, 20), _ocr_span("Und", 60, 20), + _ocr_span("Don", 62, 36), _ocr_span("tauchte", 90, 20), + _ocr_span("|", 390, 20)]}, + {"bbox": (0, 40, 30, 50), "spans": [_ocr_span("„+|", 5, 50)]}, # nur Rauschen + ] + cleaned = p._clean_ocr_lines(lines) + assert ["".join(s["text"] for s in line["spans"]) for line in cleaned] == ["Undtauchte", "Don"] + + +def test_heading_without_letters_is_dropped(): + assert p.normalize_markdown("# ||\n\nText.\n") == "Text.\n"