"""Unit-Tests für die Markdown-Nachbearbeitung (ohne PDF, schnell)."""
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 -----**
\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"