pdf2md/tests/test_normalize.py
Dieter Schlüter 1ba5b5005f Links: URLs über Blockgrenzen, Wortpaare, abweichende Linktexte
- _join_split_urls: URL, die in einem neuen Block (auch als Listenpunkt)
  weitergeht, mit der Fortsetzung verbinden; mit Linkziel per Abgleich,
  ohne nur an typischen Umbruchstellen und bei pfadartiger Fortsetzung
- "und/oder" nach einer URL nicht anhängen; Dateiendung nur nach Punkt
- _fuzzy_anchor: Linktext mit Abweichungen (verlorene Ligatur) über
  Kontext/Anfang und letztes Wort finden (Zensur-Report: 76 von 76)
- tests/test_links.py: Test-PDF mit typischen Varianten
- README, TODO

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:35:01 +02:00

453 lines
16 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 -----**<br>\nKauder welsch<br>"
"**----- End of picture text -----**<br>\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: <https://www.theeuropean.de/insa-umfrage-deutsche-sehen/>.\n"
"Siehe <https://www.fsm.de/en/fsm/netzdg/>. 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 <https://example.org/a_b>, [www.example.org](https://www.example.org)"
" und (<https://x.de/y>).\n"
)
def test_link_urls_leaves_images_and_existing_links_alone():
md = "![](bilder/x.jpg)\n\n<https://a.de/> 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 <https://a.de/artikel>, <https://b.de/news/Gesetz-Kritik-3718401.html> und "
"<https://c.de/seite>.\n"
)
def test_link_urls_joins_printed_continuation_without_pdf_target():
md = (
"Siehe https://a.org/digital_ dispatches/towards-x/. und https://b.de/ "
"SharedDocs/schwerpunkte/artikel. html. Oder https://c.de/ und mehr.\n"
)
assert p.link_urls(md) == (
"Siehe <https://a.org/digital_dispatches/towards-x/>. und "
"<https://b.de/SharedDocs/schwerpunkte/artikel.html>. Oder <https://c.de/> und mehr.\n"
)
# --- Markdown-Sonderzeichen --------------------------------------------------------
@pytest.mark.parametrize("text, escaped", [
("__add__", "\\_\\_add\\_\\_"),
("snake_case", "snake_case"),
("a*b [1] <p> $5 x^2 ~x", "a\\*b \\[1\\] \\<p\\> \\$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 "<https://a.de/Leitlinien_x>" in out
# --- Zahlen als Überschrift --------------------------------------------------------
def test_number_heading_without_title_is_dropped():
md = "# Private Stiftungen\n\n## 90\n\nText.\n\n## XXIV\n\nMehr.\n"
assert norm(md) == "# Private Stiftungen\n\nText.\n\nMehr.\n"
def test_number_heading_kept_before_title_or_with_dot_or_in_outline():
md = "## 1\n\n## Wenn Ostdeutsche fragen\n\nText.\n\n## IV.\n\nMehr.\n\n## 7\n\nNoch.\n"
assert norm(md, {"7": 2}) == md
def test_register_letters_stay_headings():
md = "## C\n\ncalendar, 266\n\n## D\n\ndata stream, 249\n\n## I\n\nif, 105\n"
assert norm(md) == md
def test_number_heading_kept_as_text_when_page_numbers_kept():
assert norm("## 90\n\nText.\n", drop_page_numbers=False) == "90\n\nText.\n"