pdf2md/tests/test_normalize.py
Dieter Schlüter 85a01ac5c7 Bedienungsanleitung ergänzt, Bildpfade relativ zur Markdown-Datei
- BEDIENUNGSANLEITUNG.md: Installation, Weg A (PDF -> EPUB), Weg B (Markdown
  bearbeiten, dann pandoc), Scans, Optionen, Prüfen, häufige Probleme;
  Verweis in README.md
- Fix: pymupdf4llm schreibt Bildpfade relativ zum Arbeitsverzeichnis
  ("Output/x_images/…"), wenn der Bilderordner darunter liegt. Die Links
  zeigen jetzt immer relativ zur Markdown-Datei; Regressionstests ergänzt.

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

281 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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_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 -----**<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"
)