pdf2md v5: gescannte und digitale PDFs, EPUB-Export, Tests
- Seitenklassifizierung (Text / Scan / OCR-Ebene / leer) mit OCR-Plan pro Seite:
Scans per Tesseract, gerade OCR-Ebenen übernehmen, schiefe neu erkennen,
Foto-/Coverseiten nur nach Probe-OCR; --ocr auto|always|never, --jobs
- Nachbearbeitung: Satzende-Erkennung mit « “ ‹, Silbentrennung, Überschriften-
ebenen aus PDF-Lesezeichen, Rückstufung von Satzresten, Inhaltsverzeichnis
trennen, Seitenzahlen/Kolumnentitel/Bildtext entfernen
- Patches für pymupdf4llm: Bindestrich-Komposita am Zeilenende, Absätze per
Einzug in Scan-Blöcken
- --epub über pandoc (Metadaten, Cover, Kapitelaufteilung)
- pytest-Suite mit synthetischen Test-PDFs (79 Tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:33:21 +02:00
|
|
|
|
"""Unit-Tests für die Markdown-Nachbearbeitung (ohne PDF, schnell)."""
|
|
|
|
|
|
|
2026-10-02 01:23:36 +02:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
pdf2md v5: gescannte und digitale PDFs, EPUB-Export, Tests
- Seitenklassifizierung (Text / Scan / OCR-Ebene / leer) mit OCR-Plan pro Seite:
Scans per Tesseract, gerade OCR-Ebenen übernehmen, schiefe neu erkennen,
Foto-/Coverseiten nur nach Probe-OCR; --ocr auto|always|never, --jobs
- Nachbearbeitung: Satzende-Erkennung mit « “ ‹, Silbentrennung, Überschriften-
ebenen aus PDF-Lesezeichen, Rückstufung von Satzresten, Inhaltsverzeichnis
trennen, Seitenzahlen/Kolumnentitel/Bildtext entfernen
- Patches für pymupdf4llm: Bindestrich-Komposita am Zeilenende, Absätze per
Einzug in Scan-Blöcken
- --epub über pandoc (Metadaten, Cover, Kapitelaufteilung)
- pytest-Suite mit synthetischen Test-PDFs (79 Tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:33:21 +02:00
|
|
|
|
import pymupdf
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
2026-10-02 13:12:36 +02:00
|
|
|
|
import pdf2md as p
|
pdf2md v5: gescannte und digitale PDFs, EPUB-Export, Tests
- Seitenklassifizierung (Text / Scan / OCR-Ebene / leer) mit OCR-Plan pro Seite:
Scans per Tesseract, gerade OCR-Ebenen übernehmen, schiefe neu erkennen,
Foto-/Coverseiten nur nach Probe-OCR; --ocr auto|always|never, --jobs
- Nachbearbeitung: Satzende-Erkennung mit « “ ‹, Silbentrennung, Überschriften-
ebenen aus PDF-Lesezeichen, Rückstufung von Satzresten, Inhaltsverzeichnis
trennen, Seitenzahlen/Kolumnentitel/Bildtext entfernen
- Patches für pymupdf4llm: Bindestrich-Komposita am Zeilenende, Absätze per
Einzug in Scan-Blöcken
- --epub über pandoc (Metadaten, Cover, Kapitelaufteilung)
- pytest-Suite mit synthetischen Test-PDFs (79 Tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:33:21 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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."
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-02 13:51:50 +02:00
|
|
|
|
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()`"
|
|
|
|
|
|
|
|
|
|
|
|
|
pdf2md v5: gescannte und digitale PDFs, EPUB-Export, Tests
- Seitenklassifizierung (Text / Scan / OCR-Ebene / leer) mit OCR-Plan pro Seite:
Scans per Tesseract, gerade OCR-Ebenen übernehmen, schiefe neu erkennen,
Foto-/Coverseiten nur nach Probe-OCR; --ocr auto|always|never, --jobs
- Nachbearbeitung: Satzende-Erkennung mit « “ ‹, Silbentrennung, Überschriften-
ebenen aus PDF-Lesezeichen, Rückstufung von Satzresten, Inhaltsverzeichnis
trennen, Seitenzahlen/Kolumnentitel/Bildtext entfernen
- Patches für pymupdf4llm: Bindestrich-Komposita am Zeilenende, Absätze per
Einzug in Scan-Blöcken
- --epub über pandoc (Metadaten, Cover, Kapitelaufteilung)
- pytest-Suite mit synthetischen Test-PDFs (79 Tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:33:21 +02:00
|
|
|
|
# --- Ü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"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-02 14:24:58 +02:00
|
|
|
|
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"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
pdf2md v5: gescannte und digitale PDFs, EPUB-Export, Tests
- Seitenklassifizierung (Text / Scan / OCR-Ebene / leer) mit OCR-Plan pro Seite:
Scans per Tesseract, gerade OCR-Ebenen übernehmen, schiefe neu erkennen,
Foto-/Coverseiten nur nach Probe-OCR; --ocr auto|always|never, --jobs
- Nachbearbeitung: Satzende-Erkennung mit « “ ‹, Silbentrennung, Überschriften-
ebenen aus PDF-Lesezeichen, Rückstufung von Satzresten, Inhaltsverzeichnis
trennen, Seitenzahlen/Kolumnentitel/Bildtext entfernen
- Patches für pymupdf4llm: Bindestrich-Komposita am Zeilenende, Absätze per
Einzug in Scan-Blöcken
- --epub über pandoc (Metadaten, Cover, Kapitelaufteilung)
- pytest-Suite mit synthetischen Test-PDFs (79 Tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:33:21 +02:00
|
|
|
|
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"
|
2026-10-02 01:23:36 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"\n\n\n\n"
|
|
|
|
|
|
"\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert p.make_image_links_relative(md, Path("Output/buch_images")) == (
|
|
|
|
|
|
"\n\n\n\n"
|
|
|
|
|
|
"\n"
|
|
|
|
|
|
)
|
2026-10-02 15:07:47 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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 = "\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/ "
|
2026-10-02 20:35:01 +02:00
|
|
|
|
"SharedDocs/schwerpunkte/artikel. html. Oder https://c.de/ und mehr.\n"
|
2026-10-02 15:07:47 +02:00
|
|
|
|
)
|
|
|
|
|
|
assert p.link_urls(md) == (
|
|
|
|
|
|
"Siehe <https://a.org/digital_dispatches/towards-x/>. und "
|
2026-10-02 20:35:01 +02:00
|
|
|
|
"<https://b.de/SharedDocs/schwerpunkte/artikel.html>. Oder <https://c.de/> und mehr.\n"
|
2026-10-02 15:07:47 +02:00
|
|
|
|
)
|
Markdown-Sonderzeichen maskieren, Verszeilen mit Satzende erhalten
- md_escape im Span-Patch: \ ` * [ ] < > $ ~ ^ und "_" an Wortgrenzen
werden maskiert (__add__ nicht mehr fett, <p> nicht mehr HTML); Code
bleibt wörtlich
- Folgeschritte maskierungsfest: norm_title, clean_inline, link_urls
(Positionstabelle inkl. Backslash), link_anchors, --split-Titel
- Kurze Zeile mit Satzende ist nur bei eingerückter Folgezeile ein
Absatzende, sonst eine Verszeile; Einrückung gegen den Spaltenrand der
Seite gemessen (trennt auch verklebte einzeilige Dialogabsätze)
- Tests, README, Bedienungsanleitung, TODO
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:28:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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
|
Zahl-Überschriften entfernen, Code-Nachbarzeilen in Codeblöcke
- "## 90", "## xxiv" (Fußnotenzeichen, römische Seitenzahlen ii–xcix)
wie Seitenzahlen entfernen, außer direkt vor einer Titel-Überschrift
oder in der PDF-Gliederung; "## IV." und Register-Buchstaben bleiben
- _mixed_block: zeilengetreue Blöcke zeilenweise prüfen; Konfiguration
([dependencies], name = "wert"), Befehle (cargo build, $ …) und
Programmausgaben (… ok, running n tests) als Codeblock, Anleitungen
als Text; kurze umbrochene Codezeilen erkennen
- Registereinträge ("match(), 247") nie als Code
- _raw_text: Leerzeichen bei Wortabstand ohne Leerzeichen-Zeichen
- Tests, README, Bedienungsanleitung, TODO
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:13:42 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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"
|