pdf2md/tests/test_verbatim.py
Dieter Schlüter d6a8c11b0a 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

181 lines
6.1 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.

"""Zeilengetreue Abschnitte: Register, Code, Verse – Fließtext bleibt Fließtext."""
import zipfile
import pymupdf
import pytest
import pdf2md as p
from conftest import needs_pandoc
from test_e2e import run
NBSP = " "
PROSE = (
"<p>Dieser Absatz ist ganz normaler Fließtext im Flattersatz. Er wird vom "
"Satzprogramm umbrochen, weil die Zeilen voll sind, und muss deshalb in der "
"Ausgabe wieder zu einer einzigen Zeile zusammengefügt werden, ohne harte "
"Umbrüche und ohne Leerzeilen dazwischen.</p>"
)
def make_pdf(path, lines, html=PROSE):
"""Fließtext oben (legt die Spaltenbreite fest), darunter Zeilen an festen Positionen."""
doc = pymupdf.open()
page = doc.new_page(width=420, height=595)
page.insert_htmlbox(pymupdf.Rect(50, 40, 370, 200), html)
y = 240
for x, text in lines:
page.insert_text((x, y), text, fontname="tiro", fontsize=11)
y += 14
doc.save(path)
return path
def convert(tmp_path, lines, html=PROSE) -> str:
source = make_pdf(tmp_path / "probe.pdf", lines, html)
out = tmp_path / "probe.md"
run(source, out)
return out.read_text(encoding="utf-8")
def test_prose_stays_one_paragraph(tmp_path):
md = convert(tmp_path, [])
assert "Fließtext im Flattersatz. Er wird vom Satzprogramm umbrochen" in md
assert "\\" not in md
def test_register_keeps_lines_and_indentation(tmp_path):
md = convert(tmp_path, [
(50, "Backslash"),
(62, "Maskierung, 79"),
(50, "BaseException, 227"),
(50, "Basisklasse, 206"),
(62, "abstrakte, 212"),
])
# "Backslash" hält das Layout-Modell hier für eine Überschrift.
assert (
f"{NBSP * 4}Maskierung, 79\\\n"
"BaseException, 227\\\n"
"Basisklasse, 206\\\n"
f"{NBSP * 4}abstrakte, 212"
) in md
def test_code_in_proportional_font_becomes_code_block(tmp_path):
md = convert(tmp_path, [
(50, "def quadrat(x):"),
(62, "y = x * x"),
(62, "return y"),
(50, "print(quadrat(3))"),
])
assert "```\ndef quadrat(x):\n y = x * x\n return y\nprint(quadrat(3))\n```" in md
def test_poem_keeps_line_breaks(tmp_path):
html = PROSE + (
"<p>Der Mond ist aufgegangen,<br>die goldnen Sternlein prangen<br>"
"am Himmel hell und klar.</p>"
)
md = convert(tmp_path, [], html)
assert "Der Mond ist aufgegangen,\\\ndie goldnen Sternlein prangen\\\nam Himmel hell und klar." in md
# --- normalize_markdown mit Markierungen -------------------------------------------
H, P, B = p.HARD_BREAK, p.PARAGRAPH_END, p.BLOCK_END
def test_hard_breaks_become_backslashes_except_last():
md = f"Erste{H}\n\nZweite{H}\n\nDritte{H}\n\nDanach Fließtext.\n"
assert p.normalize_markdown(md) == "Erste\\\nZweite\\\nDritte\n\nDanach Fließtext.\n"
def test_block_end_continues_when_word_did_not_fit():
md = f"Er sprach mit dem{B}3{B}\n\nGeheimdienstchef über alles.\n"
assert p.normalize_markdown(md) == "Er sprach mit dem Geheimdienstchef über alles.\n"
def test_block_end_is_hard_when_word_would_have_fit():
md = f"Moskau, im Mai{B}40{B}\n\nDer Zug fuhr ab.\n"
assert p.normalize_markdown(md) == "Moskau, im Mai\n\nDer Zug fuhr ab.\n"
def test_multiline_block_without_sentence_end_ends_paragraph():
md = f"Ein Absatz mit Fußnote.[8 ]{B}30P{B}\n\nDer nächste Absatz.\n"
assert p.normalize_markdown(md) == "Ein Absatz mit Fußnote.[8 ]\n\nDer nächste Absatz.\n"
def test_paragraph_end_closes_verbatim_run_without_blank():
md = f"Die Wahrheit erzählen,{H}\ndas ist der größte Witz.{P}\n\nWeiter.\n"
assert p.normalize_markdown(md) == (
"Die Wahrheit erzählen,\\\ndas ist der größte Witz.\n\nWeiter.\n"
)
def test_code_blocks_across_page_break_are_merged():
md = "```\nfn main() {\n```\n\n17\n\n```\n}\n```\n"
assert p.normalize_markdown(md) == "```\nfn main() {\n}\n```\n"
@pytest.mark.parametrize("text", ["fn main() {", "use std::io;", "def f(x):", "print(x)"])
def test_code_hints(text):
assert p.CODE_HINT.search(text)
@pytest.mark.parametrize("text", ["Maskierung, 79", "Der Mond ist aufgegangen,", "Kapitel (1)"])
def test_no_code_hints(text):
assert not p.CODE_HINT.search(text)
@needs_pandoc
def test_epub_wraps_long_code_lines(pdfs, tmp_path):
run(pdfs["digital"], tmp_path / "buch.md", "--epub")
with zipfile.ZipFile(tmp_path / "buch.epub") as z:
css = "".join(z.read(n).decode() for n in z.namelist() if n.endswith(".css"))
assert "white-space: pre-wrap" in css
def test_literal_fence_text_is_escaped_and_markers_never_leak():
md = f"```{B}66{B}\n\nText danach.\n\nRest{H}\n"
out = p.normalize_markdown(md)
assert out.startswith("\\```")
assert not p.MARKERS.search(out)
def test_contents_entries_are_uniform_paragraphs():
outline = {"vorwort": 2, "1 der notar": 3, "2 ein brief": 3}
md = (
f"## Inhaltsverzeichnis\n\nTitel{H}\nVorwort{H}\n1 Der Notar{P}\n\n"
f"2 Ein Brief{B}40{B}\n\n## Vorwort\n\nText.\n"
)
assert p.normalize_markdown(md, outline) == (
"## Inhaltsverzeichnis\n\nTitel\n\nVorwort\n\n1 Der Notar\n\n2 Ein Brief\n\n"
"## Vorwort\n\nText.\n"
)
def test_outline_like_lines_outside_contents_become_paragraphs():
md = f"## Vorschau Band Zwei\n\n32 Oberst a.D.{H}\n33 Wolga-Attacke{P}\n\nTeil VI: Polen{H}\nOperation Morgenlicht{P}\n"
assert p.normalize_markdown(md) == (
"## Vorschau Band Zwei\n\n32 Oberst a.D.\n\n33 Wolga-Attacke\n\n"
"Teil VI: Polen\n\nOperation Morgenlicht\n"
)
def test_poem_lines_with_sentence_ends_stay_lines(tmp_path):
md = convert(tmp_path, [
(50, "Der Mond ist aufgegangen."),
(50, "Die Sterne prangen hell."),
(50, "Der Wald steht schwarz."),
])
assert "Der Mond ist aufgegangen.\\\nDie Sterne prangen hell.\\\nDer Wald steht schwarz." in md
def test_indented_dialogue_lines_stay_paragraphs(tmp_path):
md = convert(tmp_path, [
(62, "»Und sind es heute noch«, lachte er."),
(62, "»Und sind es heute noch«, echote sie."),
])
assert "lachte er.\n\n»Und sind es heute noch«, echote sie." in md
assert "lachte er.\\" not in md