"""Unit-Tests für die Markdown-Nachbearbeitung (ohne PDF, schnell).""" from pathlib import Path import pymupdf import pytest import pdf2md 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." def test_pymupdf4llm_patch_resolves_soft_hyphens(): # Weicher Trennstrich (U+00AD) bzw. "¬" am Zeilenende, auch vor Großbuchstaben spans = [ {"text": "einer wach­", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 0}, {"text": "senden Demo¬", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 1}, {"text": "kratie und Lebens­ ", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 2}, {"text": "Läufe.", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 3}, ] output, _ = p.document_layout.get_styled_text(spans) assert output.strip() == "einer wachsenden Demokratie und LebensLäufe." def test_pymupdf4llm_patch_ignores_monospace_of_invisible_text(): # OCR-Ebene in Courier: unsichtbar, aber mit Monospace-Flag spans = [ {"text": "Nature reioicej", "flags": 8, "char_flags": 0, "alpha": 0, "font": "Courier", "block": 0, "line": 0}, {"text": "of many frendes", "flags": 8, "char_flags": 0, "alpha": 0, "font": "Courier", "block": 0, "line": 1}, ] output, _ = p.document_layout.get_styled_text(spans) assert output.strip() == "Nature reioicej of many frendes" def test_visible_monospace_stays_code(): span = {"text": "print()", "flags": 8, "char_flags": int(p.VISIBLE_TEXT), "font": "Courier", "block": 0, "line": 0} output, _ = p.document_layout.get_styled_text([span]) assert output.strip() == "`print()`" # --- Ü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_contents_table_stays_table_and_contents_end_after_it(): outline = {"vorwort": 1, "der staat": 1} md = ( "## Inhaltsverzeichnis\n\n" "|Vorwort|3|\n|---|---|\n|Der Staat|19|\n\n" "Dieser Bericht steht unter CC BY-SA 4.0 (Lizenz)\n\n" "Vorwort\n\nDer Text beginnt hier.\n" ) assert norm(md, outline) == ( "## Inhaltsverzeichnis\n\n" "|Vorwort|3|\n|---|---|\n|Der Staat|19|\n\n" "Dieser Bericht steht unter CC BY-SA 4.0 (Lizenz)\n\n" "# Vorwort\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" def test_make_image_links_relative(tmp_path, monkeypatch): image_dir = tmp_path / "Output" / "buch_images" image_dir.mkdir(parents=True) monkeypatch.chdir(tmp_path) md = ( f"![](Output/buch_images/a.jpg)\n\n![]({image_dir}/b.jpg)\n\n" "![](https://example.org/c.jpg)\n" ) assert p.make_image_links_relative(md, Path("Output/buch_images")) == ( "![](buch_images/a.jpg)\n\n![](buch_images/b.jpg)\n\n" "![](https://example.org/c.jpg)\n" ) # --- Links ------------------------------------------------------------------------- def test_link_urls_repairs_broken_urls_with_pdf_targets(): uris = [ "https://www.theeuropean.de/insa-umfrage-deutsche-sehen/", "https://www.fsm.de/en/fsm/netzdg/", ] md = ( "Quelle: https://www.theeuropean.de/insa-umfrage-deutschesehen/.\n" "Siehe https://www.fsm.de/en/fsm/ netzdg/. Danach Text.\n" ) assert p.link_urls(md, uris) == ( "Quelle: .\n" "Siehe . Danach Text.\n" ) def test_link_urls_without_pdf_targets(): md = "Mehr unter https://example.org/a_b, www.example.org und (https://x.de/y).\n" assert p.link_urls(md) == ( "Mehr unter , [www.example.org](https://www.example.org)" " und ().\n" ) def test_link_urls_leaves_images_and_existing_links_alone(): md = "![](bilder/x.jpg)\n\n und [b](https://b.de/)\n" assert p.link_urls(md) == md def test_pymupdf4llm_patch_keeps_hyphen_inside_url(): spans = [ {"text": "Siehe https://a.de/umfrage-deutsche-", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 0}, {"text": "sehen/ und mehr.", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 1}, ] output, _ = p.document_layout.get_styled_text(spans) assert output.strip() == "Siehe https://a.de/umfrage-deutsche-sehen/ und mehr." def test_load_link_uris(): doc = pymupdf.open() page = doc.new_page() for uri in ("https://a.de/x", "mailto:a@b.de", "https://a.de/x"): page.insert_link({"kind": pymupdf.LINK_URI, "from": pymupdf.Rect(0, 0, 10, 10), "uri": uri}) doc = pymupdf.open("pdf", doc.tobytes()) assert p.load_link_uris(doc) == ["https://a.de/x"] def test_link_urls_tolerates_printed_variants(): uris = [ "https://a.de/artikel?utm_source=chatgpt.com", "https://b.de/news/Gesetz-Kritik-3718401.html", "https://c.de/seite", ] md = "Siehe https://a.de/artikel/, https://b.de/news/ Gesetz-Kritik-3718401 und https://c.de/seite/.\n" assert p.link_urls(md, uris) == ( "Siehe , und " ".\n" ) def test_link_urls_joins_printed_continuation_without_pdf_target(): md = ( "Siehe https://a.org/digital_ dispatches/towards-x/. und https://b.de/ " "Docs/artikel. html. Oder https://c.de/ und mehr.\n" ) assert p.link_urls(md) == ( "Siehe . und " ". Oder und mehr.\n" ) # --- Markdown-Sonderzeichen -------------------------------------------------------- @pytest.mark.parametrize("text, escaped", [ ("__add__", "\\_\\_add\\_\\_"), ("snake_case", "snake_case"), ("a*b [1]

$5 x^2 ~x", "a\\*b \\[1\\] \\ \\$5 x\\^2 \\~x"), ]) def test_md_escape_round_trip(text, escaped): assert p.md_escape(text) == escaped assert p.md_unescape(escaped) == text def test_pymupdf4llm_patch_escapes_text_but_not_code(): spans = [ {"text": "__add__ und *", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 0}, {"text": "a_b*c", "flags": 8, "char_flags": int(p.VISIBLE_TEXT), "font": "Courier", "block": 0, "line": 0}, ] output, _ = p.document_layout.get_styled_text(spans) assert output.strip() == "\\_\\_add\\_\\_ und \\* `a_b*c`" def test_escaped_text_survives_link_steps(): md = "\\_\\_add\\_\\_, 180\\\nSiehe https://a.de/Leitlinien\\_ x.\n" out = p.link_anchors(p.link_urls(md, ["https://a.de/Leitlinien_x"]), []) assert out.startswith("\\_\\_add\\_\\_, 180\\\n") assert "" in out