"""Zeilengetreue Abschnitte: Register, Code, Verse – Fließtext bleibt Fließtext.""" import zipfile import pymupdf import pytest import pdf2md as p from conftest import needs_pandoc, needs_tesseract from test_e2e import run NBSP = " " PROSE = ( "

Dieser Absatz ist ganz normaler Fließtext im Flattersatz. Er wird vom " "Satzprogramm umbrochen, weil die Zeilen voll sind, und muss deshalb in der " "Ausgabe wieder zu einer einzigen Zeile zusammengefügt werden, ohne harte " "Umbrüche und ohne Leerzeilen dazwischen.

" ) def make_pdf(path, lines, html=PROSE): """Fließtext oben (legt die Spaltenbreite fest), darunter Zeilen an festen Positionen.""" doc = pymupdf.open() page = doc.new_page(width=420, height=595) page.insert_htmlbox(pymupdf.Rect(50, 40, 370, 200), html) y = 240 for x, text in lines: page.insert_text((x, y), text, fontname="tiro", fontsize=11) y += 14 doc.save(path) return path def convert(tmp_path, lines, html=PROSE) -> str: source = make_pdf(tmp_path / "probe.pdf", lines, html) out = tmp_path / "probe.md" run(source, out) return out.read_text(encoding="utf-8") def test_prose_stays_one_paragraph(tmp_path): md = convert(tmp_path, []) assert "Fließtext im Flattersatz. Er wird vom Satzprogramm umbrochen" in md assert "\\" not in md def test_register_keeps_lines_and_indentation(tmp_path): md = convert(tmp_path, [ (50, "Backslash"), (62, "Maskierung, 79"), (50, "BaseException, 227"), (50, "Basisklasse, 206"), (62, "abstrakte, 212"), ]) # "Backslash" hält das Layout-Modell hier für eine Überschrift. assert ( f"{NBSP * 4}Maskierung, 79\\\n" "BaseException, 227\\\n" "Basisklasse, 206\\\n" f"{NBSP * 4}abstrakte, 212" ) in md def test_code_in_proportional_font_becomes_code_block(tmp_path): md = convert(tmp_path, [ (50, "def quadrat(x):"), (62, "y = x * x"), (62, "return y"), (50, "print(quadrat(3))"), ]) assert "```\ndef quadrat(x):\n y = x * x\n return y\nprint(quadrat(3))\n```" in md def test_poem_keeps_line_breaks(tmp_path): html = PROSE + ( "

Der Mond ist aufgegangen,
die goldnen Sternlein prangen
" "am Himmel hell und klar.

" ) md = convert(tmp_path, [], html) assert "Der Mond ist aufgegangen,\\\ndie goldnen Sternlein prangen\\\nam Himmel hell und klar." in md # --- normalize_markdown mit Markierungen ------------------------------------------- H, P, B = p.HARD_BREAK, p.PARAGRAPH_END, p.BLOCK_END def test_hard_breaks_become_backslashes_except_last(): md = f"Erste{H}\n\nZweite{H}\n\nDritte{H}\n\nDanach Fließtext.\n" assert p.normalize_markdown(md) == "Erste\\\nZweite\\\nDritte\n\nDanach Fließtext.\n" def test_block_end_continues_when_word_did_not_fit(): md = f"Er sprach mit dem{B}3{B}\n\nGeheimdienstchef über alles.\n" assert p.normalize_markdown(md) == "Er sprach mit dem Geheimdienstchef über alles.\n" def test_block_end_is_hard_when_word_would_have_fit(): md = f"Moskau, im Mai{B}40{B}\n\nDer Zug fuhr ab.\n" assert p.normalize_markdown(md) == "Moskau, im Mai\n\nDer Zug fuhr ab.\n" def test_multiline_block_without_sentence_end_ends_paragraph(): md = f"Ein Absatz mit Fußnote.[8 ]{B}30P{B}\n\nDer nächste Absatz.\n" assert p.normalize_markdown(md) == "Ein Absatz mit Fußnote.[8 ]\n\nDer nächste Absatz.\n" def test_paragraph_end_closes_verbatim_run_without_blank(): md = f"Die Wahrheit erzählen,{H}\ndas ist der größte Witz.{P}\n\nWeiter.\n" assert p.normalize_markdown(md) == ( "Die Wahrheit erzählen,\\\ndas ist der größte Witz.\n\nWeiter.\n" ) def test_code_blocks_across_page_break_are_merged(): md = "```\nfn main() {\n```\n\n17\n\n```\n}\n```\n" assert p.normalize_markdown(md) == "```\nfn main() {\n}\n```\n" @pytest.mark.parametrize("text", ["fn main() {", "use std::io;", "def f(x):", "print(x)"]) def test_code_hints(text): assert p.CODE_HINT.search(text) @pytest.mark.parametrize("text", ["Maskierung, 79", "Der Mond ist aufgegangen,", "Kapitel (1)"]) def test_no_code_hints(text): assert not p.CODE_HINT.search(text) @needs_pandoc def test_epub_wraps_long_code_lines(pdfs, tmp_path): run(pdfs["digital"], tmp_path / "buch.md", "--epub") with zipfile.ZipFile(tmp_path / "buch.epub") as z: css = "".join(z.read(n).decode() for n in z.namelist() if n.endswith(".css")) assert "white-space: pre-wrap" in css def test_literal_fence_text_is_escaped_and_markers_never_leak(): md = f"```{B}66{B}\n\nText danach.\n\nRest{H}\n" out = p.normalize_markdown(md) assert out.startswith("\\```") assert not p.MARKERS.search(out) def test_contents_entries_are_uniform_paragraphs(): outline = {"vorwort": 2, "1 der notar": 3, "2 ein brief": 3} md = ( f"## Inhaltsverzeichnis\n\nTitel{H}\nVorwort{H}\n1 Der Notar{P}\n\n" f"2 Ein Brief{B}40{B}\n\n## Vorwort\n\nText.\n" ) assert p.normalize_markdown(md, outline) == ( "## Inhaltsverzeichnis\n\nTitel\n\nVorwort\n\n1 Der Notar\n\n2 Ein Brief\n\n" "## Vorwort\n\nText.\n" ) def test_outline_like_lines_outside_contents_become_paragraphs(): md = f"## Vorschau Band Zwei\n\n32 Oberst a.D.{H}\n33 Wolga-Attacke{P}\n\nTeil VI: Polen{H}\nOperation Morgenlicht{P}\n" assert p.normalize_markdown(md) == ( "## Vorschau Band Zwei\n\n32 Oberst a.D.\n\n33 Wolga-Attacke\n\n" "Teil VI: Polen\n\nOperation Morgenlicht\n" ) def test_poem_lines_with_sentence_ends_stay_lines(tmp_path): md = convert(tmp_path, [ (50, "Der Mond ist aufgegangen."), (50, "Die Sterne prangen hell."), (50, "Der Wald steht schwarz."), ]) assert "Der Mond ist aufgegangen.\\\nDie Sterne prangen hell.\\\nDer Wald steht schwarz." in md def test_indented_dialogue_lines_stay_paragraphs(tmp_path): md = convert(tmp_path, [ (62, "»Und sind es heute noch«, lachte er."), (62, "»Und sind es heute noch«, echote sie."), ]) assert "lachte er.\n\n»Und sind es heute noch«, echote sie." in md assert "lachte er.\\" not in md def test_config_and_command_lines_become_code_between_text(tmp_path): md = convert(tmp_path, [ (50, "Add to Cargo.toml:"), (50, "[dependencies]"), (50, 'serde = "1.0"'), (50, "Then build it:"), (50, "cargo build"), ]) assert "Add to Cargo.toml:\n\n```\n[dependencies]\nserde = \"1.0\"\n```\n\nThen build it:" in md assert "```\ncargo build\n```" in md def test_single_code_like_register_entry_stays_text(tmp_path): md = convert(tmp_path, [ (50, "case-sensitive, 48"), (50, "@classmethod, 190"), (50, "match(), 247"), (50, "match-case, 110"), (50, "clear(), 154, 158"), (50, "Closure, 128"), ]) 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" def test_ocr_rows_join_pieces_but_not_neighbour_lines(): segments = [ # [x0, y0, x1, y1, text, grundlinie] [120, 227, 256, 239, "Sie kam vom Markte, und", 237], [264, 227, 364, 239, "sie ging zum Markt,", 237], [123, 245, 320, 256, "Ein bildhübsch, übermütig junges Ding.", 254], [208, 414, 366, 436, "Gieb uns ein Lied zum besten", 430], # höherer Kasten [123, 424, 198, 436, "Bis einer ruft:", 433], ] rows = [row[4] for row in p._ocr_rows(segments)] assert rows == [ "Sie kam vom Markte, und sie ging zum Markt,", "Ein bildhübsch, übermütig junges Ding.", "Bis einer ruft: Gieb uns ein Lied zum besten", ] @needs_tesseract def test_keep_lines_on_scan_uses_ocr_lines_and_outline_title(tmp_path): doc = pymupdf.open() page = doc.new_page(width=420, height=595) y = 90 page.insert_text((150, y), "Unterwegs.", fontname="tiro", fontsize=16) for line in ["Sie kam vom Markte, und sie ging zum Markt,", "Ein bildhübsches, übermütig junges Ding.", None, "Die Laute ihr von der Schulter hing,", "Ein rotes Tuch sie überm Arme trug."]: y += 30 if line is None else 16 if line: page.insert_text((80, y), line, fontname="tiro", fontsize=12) page.insert_text((10, 300), "|", fontsize=14) # Buchkante scan = pymupdf.open() scan.new_page(width=420, height=595).insert_image( scan[0].rect, pixmap=page.get_pixmap(dpi=300)) scan.set_toc([[1, "Unterwegs", 1]]) scan.save(tmp_path / "gedicht.pdf") out = tmp_path / "gedicht.md" run(tmp_path / "gedicht.pdf", out, "--keep-lines") md = out.read_text(encoding="utf-8") assert "# Unterwegs\n\n" in md # Titel aus der Gliederung assert "Markt,\\\nEin bildh" in md # Verse als Zeilen assert "Ding.\n\nDie Laute" in md # Strophengrenze assert "|" not in md.split("---", 2)[2]