Zeilengetreue Übernahme von Code, Registern, Versen und Adressen
Ein Zeilenumbruch gilt als weich (vom Satzprogramm), wenn das erste Wort
der nächsten Zeile nicht mehr gepasst hätte (Toleranz 5 % der Spalte);
sonst als hart, und die Zeile bleibt samt Einrückung erhalten.
- lines_to_md (Patch von text_to_md): harte/weiche Umbrüche aus der
Geometrie; Blöcke mit überwiegend harten Umbrüchen zeilengetreu als
Codeblock (auch Proportionalschrift, Einrückung rekonstruiert) oder als
Zeilen mit "\" und geschützten Leerzeichen
- Blockgrenzen: freie Zeichen der letzten Zeile werden mitgegeben,
normalize_markdown prüft damit das erste Wort des nächsten Blocks
- normalize_markdown: harte Umbrüche, Absatzenden ohne Satzzeichen,
Codeblöcke über Seitengrenzen zusammenführen, wörtliches ``` maskieren
- EPUB-CSS: lange Codezeilen umbrechen (white-space: pre-wrap)
- Tests (tests/test_verbatim.py), README, Bedienungsanleitung, CLAUDE.md,
TODO.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:39:06 +02:00
|
|
|
|
"""Zeilengetreue Abschnitte: Register, Code, Verse – Fließtext bleibt Fließtext."""
|
|
|
|
|
|
|
|
|
|
|
|
import zipfile
|
|
|
|
|
|
|
|
|
|
|
|
import pymupdf
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
import pdf2md as p
|
Gedicht-Scans: Zeilen direkt aus Tesseract, Fehler aus pymupdf4llm 1.28
- --keep-lines auf Scanseiten: Zeilen direkt aus Tesseract statt über die
Layout-Analyse (die verschränkte eng stehende Verse und gab Zeilen
überlappender Blöcke doppelt aus); Stücke auf gleicher Höhe verbinden,
Rauschen außerhalb der Textspalte verwerfen, Strophen über den
Grundlinienabstand, Einrückung aus der x-Position, Gedichttitel aus der
PDF-Gliederung (richtige Schreibweise statt OCR-Lesart)
- Bildtext im neuen Format von pymupdf4llm 1.28 ("<!-- Start of picture
text -->") wieder entfernen
- Zeilen überlappender Layout-Blöcke nur einmal ausgeben
- OCR-Seitenzahlen ("= 58 —", "— VIII —") erkennen, auch am Strophenende
- Fraktur: deu_latf aus tessdata_best empfohlen (besser als frk)
- Tests, README, Bedienungsanleitung (Gedicht-/Fraktur-Abschnitt), TODO
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:39:12 +02:00
|
|
|
|
from conftest import needs_pandoc, needs_tesseract
|
Zeilengetreue Übernahme von Code, Registern, Versen und Adressen
Ein Zeilenumbruch gilt als weich (vom Satzprogramm), wenn das erste Wort
der nächsten Zeile nicht mehr gepasst hätte (Toleranz 5 % der Spalte);
sonst als hart, und die Zeile bleibt samt Einrückung erhalten.
- lines_to_md (Patch von text_to_md): harte/weiche Umbrüche aus der
Geometrie; Blöcke mit überwiegend harten Umbrüchen zeilengetreu als
Codeblock (auch Proportionalschrift, Einrückung rekonstruiert) oder als
Zeilen mit "\" und geschützten Leerzeichen
- Blockgrenzen: freie Zeichen der letzten Zeile werden mitgegeben,
normalize_markdown prüft damit das erste Wort des nächsten Blocks
- normalize_markdown: harte Umbrüche, Absatzenden ohne Satzzeichen,
Codeblöcke über Seitengrenzen zusammenführen, wörtliches ``` maskieren
- EPUB-CSS: lange Codezeilen umbrechen (white-space: pre-wrap)
- Tests (tests/test_verbatim.py), README, Bedienungsanleitung, CLAUDE.md,
TODO.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:39:06 +02:00
|
|
|
|
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)
|
2026-10-02 17:32:20 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
)
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_config_and_command_lines_become_code_between_text(tmp_path):
|
|
|
|
|
|
md = convert(tmp_path, [
|
|
|
|
|
|
(50, "Add to Cargo.toml:"),
|
|
|
|
|
|
(50, "[dependencies]"),
|
|
|
|
|
|
(50, 'serde = "1.0"'),
|
|
|
|
|
|
(50, "Then build it:"),
|
|
|
|
|
|
(50, "cargo build"),
|
|
|
|
|
|
])
|
|
|
|
|
|
assert "Add to Cargo.toml:\n\n```\n[dependencies]\nserde = \"1.0\"\n```\n\nThen build it:" in md
|
|
|
|
|
|
assert "```\ncargo build\n```" in md
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_single_code_like_register_entry_stays_text(tmp_path):
|
|
|
|
|
|
md = convert(tmp_path, [
|
|
|
|
|
|
(50, "case-sensitive, 48"),
|
|
|
|
|
|
(50, "@classmethod, 190"),
|
|
|
|
|
|
(50, "match(), 247"),
|
|
|
|
|
|
(50, "match-case, 110"),
|
|
|
|
|
|
(50, "clear(), 154, 158"),
|
|
|
|
|
|
(50, "Closure, 128"),
|
|
|
|
|
|
])
|
|
|
|
|
|
assert "```" not in md
|
|
|
|
|
|
assert "@classmethod, 190\\" in md
|
--keep-lines für Gedichtbände, OCR-Neuerkennung repariert, OCR-Rauschen entfernen
- --ocr always / schiefe OCR-Ebenen: alte unsichtbare Textebene vor der
OCR entfernen (nur in der Kopie im Speicher); vorher blieb der alte
Text erhalten bzw. stand doppelt da ("bliht" statt "blüht")
- --keep-lines: jede PDF-Zeile bleibt eine Zeile, Strophen als Absätze,
Einrückungen bleiben, nichts wird zusammengefügt (auch keine Trennung)
- OCR-Text selbst erkennen (unsichtbar, wie pymupdf4llm 1.28 intern);
Buchkante "|" entfernen, Rauschzeilen und Fetzen neben dem Textbereich
verwerfen, Zeilen mit verschiedenen Grundlinien trennen, Überschriften
ohne Buchstaben/Ziffern verwerfen
- Fraktur: frk+deu dokumentiert, Grenzen in TODO
- Tests: Scan mit falscher OCR-Ebene, Gedicht in schmaler Spalte,
OCR-Rauschen; README, Bedienungsanleitung, TODO
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:13:20 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- --keep-lines (Gedichtbände) ----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
POEM = [
|
|
|
|
|
|
"O wie blüht mein Leib aus jeder Ader",
|
|
|
|
|
|
"duftender, seitdem ich dich erkenn;",
|
|
|
|
|
|
"sieh, ich gehe schlanker und gerader,",
|
|
|
|
|
|
"und du wartest nur: wer bist du denn?",
|
|
|
|
|
|
None, # Strophenabstand
|
|
|
|
|
|
"Sieh: ich fühle, wie ich mich entferne,",
|
|
|
|
|
|
"wie ich Altes, Blatt um Blatt, verlier. Lei-",
|
|
|
|
|
|
"be und Seele sind wie lauter Sterne.",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def make_poem_pdf(path):
|
|
|
|
|
|
"""Schmale Seite, Verse füllen die Zeile fast (wie im Rilke-Band)."""
|
|
|
|
|
|
doc = pymupdf.open()
|
|
|
|
|
|
page = doc.new_page(width=272, height=400)
|
|
|
|
|
|
y = 60
|
|
|
|
|
|
for line in POEM:
|
|
|
|
|
|
if line is None:
|
|
|
|
|
|
y += 14
|
|
|
|
|
|
continue
|
|
|
|
|
|
page.insert_text((44, y), line, fontname="tiro", fontsize=10)
|
|
|
|
|
|
y += 13
|
|
|
|
|
|
doc.save(path)
|
|
|
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_poem_without_keep_lines_becomes_prose(tmp_path):
|
|
|
|
|
|
out = tmp_path / "gedicht.md"
|
|
|
|
|
|
run(make_poem_pdf(tmp_path / "gedicht.pdf"), out)
|
|
|
|
|
|
assert "aus jeder Ader duftender" in out.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_keep_lines_keeps_every_line_and_stanza(tmp_path):
|
|
|
|
|
|
out = tmp_path / "gedicht.md"
|
|
|
|
|
|
run(make_poem_pdf(tmp_path / "gedicht.pdf"), out, "--keep-lines")
|
|
|
|
|
|
md = out.read_text(encoding="utf-8")
|
|
|
|
|
|
assert (
|
|
|
|
|
|
"O wie blüht mein Leib aus jeder Ader\\\n"
|
|
|
|
|
|
"duftender, seitdem ich dich erkenn;\\\n"
|
|
|
|
|
|
"sieh, ich gehe schlanker und gerader,\\\n"
|
|
|
|
|
|
"und du wartest nur: wer bist du denn?\n\n"
|
|
|
|
|
|
"Sieh: ich fühle, wie ich mich entferne,\\\n"
|
|
|
|
|
|
"wie ich Altes, Blatt um Blatt, verlier. Lei-\\\n"
|
|
|
|
|
|
"be und Seele sind wie lauter Sterne."
|
|
|
|
|
|
) in md
|
|
|
|
|
|
assert "```" not in md # Zeilen auf ";" sind kein Code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ocr_noise_outside_text_area_is_dropped():
|
|
|
|
|
|
line = {"bbox": (0, 10, 12, 20), "spans": [{"text": "aoe", "size": 10}]}
|
|
|
|
|
|
p._page_text[0] = (44, 212)
|
|
|
|
|
|
try:
|
|
|
|
|
|
assert p._outside_text_area([line], 10)
|
|
|
|
|
|
assert not p._outside_text_area([{**line, "bbox": (44, 10, 200, 20)}], 10)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
p._page_text[0] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ocr_span(text, x, y, size=10):
|
|
|
|
|
|
return {"text": text, "size": size, "flags": 0, "char_flags": 0, "font": "GlyphLessFont",
|
|
|
|
|
|
"origin": (x, y), "bbox": (x, y - size, x + 6 * len(text), y)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_clean_ocr_lines_removes_edge_noise_and_splits_baselines():
|
|
|
|
|
|
lines = [
|
|
|
|
|
|
{"bbox": (0, 0, 400, 20), "spans": [_ocr_span("||", 10, 20), _ocr_span("Und", 60, 20),
|
|
|
|
|
|
_ocr_span("Don", 62, 36), _ocr_span("tauchte", 90, 20),
|
|
|
|
|
|
_ocr_span("|", 390, 20)]},
|
|
|
|
|
|
{"bbox": (0, 40, 30, 50), "spans": [_ocr_span("„+|", 5, 50)]}, # nur Rauschen
|
|
|
|
|
|
]
|
|
|
|
|
|
cleaned = p._clean_ocr_lines(lines)
|
|
|
|
|
|
assert ["".join(s["text"] for s in line["spans"]) for line in cleaned] == ["Undtauchte", "Don"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_heading_without_letters_is_dropped():
|
|
|
|
|
|
assert p.normalize_markdown("# ||\n\nText.\n") == "Text.\n"
|
Gedicht-Scans: Zeilen direkt aus Tesseract, Fehler aus pymupdf4llm 1.28
- --keep-lines auf Scanseiten: Zeilen direkt aus Tesseract statt über die
Layout-Analyse (die verschränkte eng stehende Verse und gab Zeilen
überlappender Blöcke doppelt aus); Stücke auf gleicher Höhe verbinden,
Rauschen außerhalb der Textspalte verwerfen, Strophen über den
Grundlinienabstand, Einrückung aus der x-Position, Gedichttitel aus der
PDF-Gliederung (richtige Schreibweise statt OCR-Lesart)
- Bildtext im neuen Format von pymupdf4llm 1.28 ("<!-- Start of picture
text -->") wieder entfernen
- Zeilen überlappender Layout-Blöcke nur einmal ausgeben
- OCR-Seitenzahlen ("= 58 —", "— VIII —") erkennen, auch am Strophenende
- Fraktur: deu_latf aus tessdata_best empfohlen (besser als frk)
- Tests, README, Bedienungsanleitung (Gedicht-/Fraktur-Abschnitt), TODO
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:39:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ocr_rows_join_pieces_but_not_neighbour_lines():
|
|
|
|
|
|
segments = [ # [x0, y0, x1, y1, text, grundlinie]
|
|
|
|
|
|
[120, 227, 256, 239, "Sie kam vom Markte, und", 237],
|
|
|
|
|
|
[264, 227, 364, 239, "sie ging zum Markt,", 237],
|
|
|
|
|
|
[123, 245, 320, 256, "Ein bildhübsch, übermütig junges Ding.", 254],
|
|
|
|
|
|
[208, 414, 366, 436, "Gieb uns ein Lied zum besten", 430], # höherer Kasten
|
|
|
|
|
|
[123, 424, 198, 436, "Bis einer ruft:", 433],
|
|
|
|
|
|
]
|
|
|
|
|
|
rows = [row[4] for row in p._ocr_rows(segments)]
|
|
|
|
|
|
assert rows == [
|
|
|
|
|
|
"Sie kam vom Markte, und sie ging zum Markt,",
|
|
|
|
|
|
"Ein bildhübsch, übermütig junges Ding.",
|
|
|
|
|
|
"Bis einer ruft: Gieb uns ein Lied zum besten",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@needs_tesseract
|
|
|
|
|
|
def test_keep_lines_on_scan_uses_ocr_lines_and_outline_title(tmp_path):
|
|
|
|
|
|
doc = pymupdf.open()
|
|
|
|
|
|
page = doc.new_page(width=420, height=595)
|
|
|
|
|
|
y = 90
|
|
|
|
|
|
page.insert_text((150, y), "Unterwegs.", fontname="tiro", fontsize=16)
|
|
|
|
|
|
for line in ["Sie kam vom Markte, und sie ging zum Markt,",
|
|
|
|
|
|
"Ein bildhübsches, übermütig junges Ding.", None,
|
|
|
|
|
|
"Die Laute ihr von der Schulter hing,",
|
|
|
|
|
|
"Ein rotes Tuch sie überm Arme trug."]:
|
|
|
|
|
|
y += 30 if line is None else 16
|
|
|
|
|
|
if line:
|
|
|
|
|
|
page.insert_text((80, y), line, fontname="tiro", fontsize=12)
|
|
|
|
|
|
page.insert_text((10, 300), "|", fontsize=14) # Buchkante
|
|
|
|
|
|
scan = pymupdf.open()
|
|
|
|
|
|
scan.new_page(width=420, height=595).insert_image(
|
|
|
|
|
|
scan[0].rect, pixmap=page.get_pixmap(dpi=300))
|
|
|
|
|
|
scan.set_toc([[1, "Unterwegs", 1]])
|
|
|
|
|
|
scan.save(tmp_path / "gedicht.pdf")
|
|
|
|
|
|
|
|
|
|
|
|
out = tmp_path / "gedicht.md"
|
|
|
|
|
|
run(tmp_path / "gedicht.pdf", out, "--keep-lines")
|
|
|
|
|
|
md = out.read_text(encoding="utf-8")
|
|
|
|
|
|
assert "# Unterwegs\n\n" in md # Titel aus der Gliederung
|
|
|
|
|
|
assert "Markt,\\\nEin bildh" in md # Verse als Zeilen
|
|
|
|
|
|
assert "Ding.\n\nDie Laute" in md # Strophengrenze
|
|
|
|
|
|
assert "|" not in md.split("---", 2)[2]
|