--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>
This commit is contained in:
Dieter Schlüter 2026-10-02 22:13:20 +02:00
commit 3e5195efc2
7 changed files with 265 additions and 13 deletions

View file

@ -228,7 +228,9 @@ python3 pdf2md.py --epub scan.pdf Output/scan.md
| Buch in Deutsch (Standard) | – (`--ocr-language deu`) |
| Deutsch und Englisch gemischt | `--ocr-language deu+eng` (Sprachpaket `tesseract-ocr-eng` nötig) |
| mehr oder weniger parallele OCR-Prozesse | `--jobs N` (Standard: halbe Kernzahl, höchstens 8) |
| OCR erzwingen, auch für vorhandene OCR-Ebenen | `--ocr always` |
| OCR erzwingen, auch für vorhandene OCR-Ebenen | `--ocr always` (alte Ebene wird verworfen) |
| Gedichtband, Liederbuch | zusätzlich `--keep-lines` (siehe unten) |
| Fraktur | `--ocr-language frk+deu` (Paket `tesseract-ocr-frk` nötig; „ſ“ bleibt erhalten) |
| englisches Buch | `--ocr-language eng` (Sprachpaket `tesseract-ocr-eng` nötig; EPUB-Sprache wird automatisch `en`) |
| keine OCR | `--ocr never` oder `--no-ocr` |
@ -240,7 +242,17 @@ So entscheidet das Programm von selbst (`--ocr auto`):
übernommen, das geht sehr schnell. Eine schiefe Ebene (Scan nicht entzerrt) wird neu erkannt,
weil ihre Lesereihenfolge oft falsch ist. Alte OCR-Ebenen (z. B. aus Digitalisaten älterer
Bücher) sind oft schlecht: Kolumnentitel werden zu Überschriften, Buchstaben sind falsch
erkannt. Dann mit `--ocr always` neu erkennen lassen.
erkannt. Dann mit `--ocr always` neu erkennen lassen; die alte Ebene wird dabei verworfen.
- **Rauschen vom Scanrand** (Buchstabenfetzen neben dem Textbereich) wird entfernt.
**Gedichtbände und Liederbücher:** Füllen die Verse die Zeile fast, kann das Programm sie nicht
von Fließtext unterscheiden und fügt sie zusammen. Mit `--keep-lines` bleibt jede Zeile eine
Zeile, Strophen werden durch Leerzeilen getrennt, Einrückungen bleiben; nichts wird
zusammengefügt, auch keine Silbentrennung:
```bash
python3 pdf2md.py --epub --ocr always --keep-lines Neue_Gedichte_-_Rainer_Maria_Rilke.pdf Output/Neue_Gedichte_-_Rainer_Maria_Rilke.md
```
- **Reine Bildseiten in einem sonst digitalen PDF** (Fotos, Cover) werden nur übernommen, wenn eine
Probe-OCR echten Fließtext liefert. Sonst bleiben sie Bild, und das Programm meldet einen
„Hinweis“.
@ -270,6 +282,7 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g
| `--from-md PFAD` | nur EPUB aus `.md`-Datei oder `--split`-Ordner (Abschnitt 3, 4) |
| `--title`, `--author` | Titel/Autor im EPUB vorgeben |
| `--lang` | Sprache im EPUB (Standard: aus `--ocr-language` abgeleitet, `deu`→`de`, `eng`→`en`; sonst `de`) |
| `--keep-lines` | jede Zeile der PDF bleibt eine Zeile (Gedichte, Liedtexte); nichts wird zusammengefügt |
| `--keep-headers` | Kolumnentitel/Seitenköpfe nicht entfernen |
| `--keep-footers` | Seitenzahlen/Seitenfüße nicht entfernen |
@ -310,6 +323,7 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g
| Fließtext zerfällt in einzelne Zeilen mit `\` | in Weg B die `\` am Zeilenende entfernen |
| Formeln erscheinen als Bilder | bei Mathebüchern normal; dafür ist das Programm nicht gebaut |
| Alter Scan: falsche Überschriften, viele Lesefehler | `--ocr always` (vorhandene OCR-Ebene ignorieren) |
| Gedicht wird zu Fließtext (Verse zusammengefügt) | `--keep-lines` |
| Auf Scans fehlen Leerzeichen („deutetin“) | OCR-Fehler; höhere Auflösung versuchen (`--ocr-dpi 400`) oder von Hand korrigieren |
| EPUB zu groß | `--image-format jpg` (Standard) verwenden; PNG ist ca. 3× größer; `--jpeg-quality 70` spart weiter |
| `--from-md`: „In index.md genannt, aber nicht vorhanden“ | Datei umbenannt oder gelöscht → Link in `index.md` anpassen |

View file

@ -16,7 +16,8 @@ sudo apt install tesseract-ocr tesseract-ocr-deu pandoc
pip install -r requirements.txt # pymupdf4llm, pymupdf-layout, pytest
```
`tesseract-ocr` wird nur für Scans gebraucht, `pandoc` nur für `--epub`.
`tesseract-ocr` wird nur für Scans gebraucht, `pandoc` nur für `--epub`. Für Fraktur-Scans
zusätzlich `sudo apt install tesseract-ocr-frk` und `--ocr-language frk+deu`.
## Verwendung
@ -59,6 +60,7 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP
| `--from-md PFAD` | nur EPUB erzeugen – aus einer (bearbeiteten) `.md` oder einem `--split`-Ordner |
| `--title`, `--author` | Titel/Autor im EPUB vorgeben (statt PDF-Metadaten bzw. YAML-Kopf) |
| `--lang` | Sprache im EPUB (Standard: aus `--ocr-language`, `deu`→`de`, `eng`→`en`) |
| `--keep-lines` | jede Zeile der PDF bleibt eine Zeile (Gedichtbände, Liedtexte): Strophen als Absätze, Einrückungen bleiben; nichts wird zusammengefügt, auch keine Silbentrennung |
| `--keep-headers`, `--keep-footers` | Kolumnentitel bzw. Seitenzahlen nicht entfernen |
## Was die Nachbearbeitung macht
@ -72,7 +74,9 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP
(Markdown: `\` am Zeilenende, Einrückung als geschütztes Leerzeichen). Codeblöcke über
eine Seitengrenze werden zusammengeführt; lange Codezeilen bricht der Reader um.
- **Verse:** Eine kurze Zeile mit Satzende gilt nur dann als Absatzende, wenn die nächste
Zeile eingerückt ist (Roman); sonst ist sie eine Verszeile und bleibt eine Zeile.
Zeile eingerückt ist (Roman); sonst ist sie eine Verszeile und bleibt eine Zeile. Füllen
Verse die Zeile fast (schmale Gedichtbände), ist das geometrisch nicht von Fließtext zu
unterscheiden – dafür gibt es `--keep-lines`.
- **Absätze:** eine Absatzgrenze gilt nach Satzende (`.` `!` `?` `…`, auch mit `«` `“` `»`
usw. dahinter) oder an einem harten Umbruch; auf Scans werden Absätze am Einzug erkannt.
- **Silbentrennung:** `Geheim-|dienst` → `Geheimdienst`, `Militär-|Geheimdienst` bleibt mit
@ -124,7 +128,12 @@ synthetisch erzeugt.
Zeilen verlieren. Bei den Ljutsch-Bänden tritt das nicht auf (geprüft per Wortabgleich
PDF ↔ Markdown).
- OCR-Fehler (z. B. fehlende Leerzeichen) werden nicht korrigiert. Bei alten Scans mit
mitgelieferter OCR-Ebene liefert `--ocr always` meist deutlich besseren Text.
mitgelieferter OCR-Ebene liefert `--ocr always` meist deutlich besseren Text (die alte
Ebene wird dabei verworfen, z. B. „bliht“ → „blüht“). OCR-Rauschen vom Scanrand (Buchkante
als „|“, Buchstabenfetzen neben dem Textbereich) wird entfernt.
- Fraktur (`--ocr-language frk+deu`): Das lange s bleibt als „ſ“ erhalten; bei manchen Scans
legt die Zeilenbildung von pymupdf4llm zwei eng stehende Verszeilen zusammen
(„UndDon tauchteweißen …“).
- **Mathematische Formeln** werden als Bilder übernommen; für Mathebücher ist ein anderes
Werkzeug besser geeignet (z. B. Marker, Nougat).
- **Was am PDF liegt, bleibt:** fehlende Ligaturen ohne Textzuordnung („Offzielle“),
@ -134,7 +143,7 @@ synthetisch erzeugt.
Link (Zensur-Report: alle 76 gesetzt). Ohne Linkziel in der PDF wird eine URL nur über
typische Umbruchstellen (`-`, `/`, `_`) mit ihrer Fortsetzung verbunden.
- Zeilengetreue Erkennung: Ist ein Gedicht genauso eingerückt wie Absatzanfänge, werden
Verszeilen mit Satzzeichen am Ende zu eigenen Absätzen. Listings mit eingedruckten
Verszeilen mit Satzzeichen am Ende zu eigenen Absätzen (Abhilfe: `--keep-lines`). Listings mit eingedruckten
Zeilennummern werden nicht immer als ein Codeblock erkannt.
Weitere bekannte Schwächen: [TODO.md](TODO.md).
- Bilder werden als JPEG (Qualität 85) gespeichert; EPUB Band 1 ca. 3 MB.

View file

@ -7,5 +7,9 @@
- Zeilengetreu: Ein Gedicht, das genauso eingerückt ist wie Absatzanfänge (Band 2, „Blinde
Flecken“: alle Verse bei x = 94 wie Absatzanfänge), ist geometrisch nicht von mehreren
einzeiligen Dialogabsätzen zu unterscheiden; Verszeilen mit Satzende werden dort Absätze.
Für reine Gedichtbände: `--keep-lines`.
- Fraktur-Scans (Löwenberg 1895): Zwei eng stehende OCR-Zeilen werden von der Zeilenbildung
in pymupdf4llm/pymupdf-layout gelegentlich zu einer verschränkt („UndDon tauchteweißen“);
im OCR-PDF selbst sind sie getrennt. Idee: Option, „ſ“ in „s“ umzuwandeln.
- Listings mit eingedruckten Zeilennummern (Markdown Guide: „4 <html>“, „5 <head>“) werden
nicht immer als ein Codeblock erkannt; Programmausgaben ohne typische Merkmale bleiben Text.

133
pdf2md.py
View file

@ -32,7 +32,7 @@ from pathlib import Path
import pymupdf
import pymupdf4llm
from pymupdf4llm.helpers import document_layout
from pymupdf4llm.ocr import tesseract_api
from pymupdf4llm.ocr import OCRMode, tesseract_api
# ===========================================================================
@ -499,6 +499,9 @@ def normalize_markdown(
standalone(text)
continue
if not HAS_ALNUM.search(text):
continue # Überschrift nur aus Zeichen ("# ||", OCR-Rauschen)
if level is None and NUMBER_HEADING.match(text) and not heading_follows(
lines, index
):
@ -1023,6 +1026,8 @@ def _mark_line_end_hyphens(spans: list[dict]) -> list[dict]:
nxt = spans[i + 1] if i + 1 < len(spans) else None
if not span.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT:
span = {**span, "flags": span.get("flags", 0) & ~MONOSPACE_FLAG}
if not span.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT: # OCR-Text
span = {**span, "text": OCR_NOISE.sub("", span["text"])} # Scanrand-"|"
if not (span.get("flags", 0) & MONOSPACE_FLAG): # Code bleibt wörtlich
span = {**span, "text": md_escape(span["text"])}
text = span["text"].rstrip()
@ -1161,14 +1166,22 @@ COMMAND_LINE = re.compile(
)
_page_lines: list[tuple[float, float]] = [] # (x0, x1) der Textzeilen der Seite
_page_text: list[tuple[float, float] | None] = [None] # Textbereich aus echten Zeilen
_keep_lines = [False] # --keep-lines: jede PDF-Zeile bleibt eine Zeile
REAL_WORD = re.compile(r"[^\W\d_]{3,}")
def _remember_page_lines(boxes) -> None:
_page_lines.clear()
real: list[tuple[float, float]] = []
for box in boxes:
if getattr(box, "boxclass", None) in TEXT_BOXES:
for line in getattr(box, "textlines", None) or []:
_page_lines.append((line["bbox"][0], line["bbox"][2]))
text = "".join(span["text"] for span in line["spans"])
if len(REAL_WORD.findall(text)) >= 2: # keine Seitenzahl, kein Scan-Rauschen
real.append((line["bbox"][0], line["bbox"][2]))
_page_text[0] = (min(x0 for x0, _ in real), max(x1 for _, x1 in real)) if real else None
def _visual_lines(textlines: list[dict]) -> list[dict]:
@ -1258,16 +1271,20 @@ def _indent_levels(lines: list[dict], em: float) -> list[int]:
return levels
def _is_monospace(lines: list[dict]) -> bool:
return all(
s["flags"] & 8 and s.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT
for line in lines for s in line["spans"] if s["text"].strip()
)
def _is_code(lines: list[dict], ignore_code: bool, short: bool = False) -> bool:
"""Ganzer Block Code? short: kurzer Block – dann zählen auch Konfiguration,
Befehle und Programmausgaben (sonst nur zeilenweise, siehe _mixed_block)."""
if ignore_code:
return False
texts = [_raw_text(line) for line in lines]
if all(
s["flags"] & 8 and s.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT
for line in lines for s in line["spans"] if s["text"].strip()
):
if _is_monospace(lines):
return True # Schreibmaschinenschrift
hints = sum(
1 for t in texts
@ -1366,11 +1383,68 @@ def _block_end(lines: list[dict], right: float, em: float, tol: float) -> str:
return f"{BLOCK_END}{max(0, int(free))}{paragraph}{BLOCK_END}"
OCR_NOISE = re.compile(r"[|¦]") # Buchkante/Seitenrand als "|" gelesen
HAS_ALNUM = re.compile(r"[^\W_]")
def _clean_ocr_lines(textlines: list[dict]) -> list[dict]:
"""OCR-Seiten: Zeilen mit Wörtern auf verschiedenen Grundlinien trennen
(schiefer Scan: "UndDon tauchteweißen") und Rauschen entfernen."""
result = []
for line in textlines:
groups: list[list[dict]] = []
for span in sorted(line["spans"], key=lambda s: s["origin"][1]):
text = OCR_NOISE.sub("", span["text"])
if not text.strip():
continue
span = {**span, "text": text}
if groups and abs(span["origin"][1] - groups[-1][0]["origin"][1]) <= 0.4 * span["size"]:
groups[-1].append(span)
else:
groups.append([span])
for spans in groups:
spans.sort(key=lambda s: s["bbox"][0])
if not HAS_ALNUM.search("".join(s["text"] for s in spans)):
continue # nur Rauschen ("„+", "%*")
bbox = (
min(s["bbox"][0] for s in spans), min(s["bbox"][1] for s in spans),
max(s["bbox"][2] for s in spans), max(s["bbox"][3] for s in spans),
)
result.append({**line, "bbox": bbox, "spans": spans})
result.sort(key=lambda line: (line["bbox"][1], line["bbox"][0]))
return result
def _is_ocr_text(textlines: list[dict]) -> bool:
"""OCR-Text ist unsichtbar (Render-Modus 3) – wie utils.is_ocr_text in pymupdf4llm."""
spans = [s for line in textlines for s in line["spans"] if s["text"].strip()]
return bool(spans) and not any(s.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT for s in spans)
def _outside_text_area(lines: list[dict], em: float) -> bool:
"""Block ganz links/rechts neben dem Textbereich (OCR-Rauschen vom Scanrand)."""
if _page_text[0] is None:
return False
left, right = _page_text[0]
bx0 = min(line["bbox"][0] for line in lines)
bx1 = max(line["bbox"][2] for line in lines)
return bx1 < left - 0.5 * em or bx0 > right + 0.5 * em
def lines_to_md(textlines: list[dict], original, ignore_code: bool = False) -> str:
"""Textblock ausgeben: Fließtext wie bisher, sonst zeilengetreu."""
ocr = _is_ocr_text(textlines)
if ocr:
textlines = _clean_ocr_lines(textlines)
if not textlines:
return ""
lines = _visual_lines(textlines)
sizes = sorted(s["size"] for line in lines for s in line["spans"])
em = sizes[len(sizes) // 2] if sizes else 10.0
if ocr and _outside_text_area(lines, em):
return "" # OCR-Seite: Rauschen am Rand verwerfen
if _keep_lines[0]:
return _keep_lines_md(lines, em, ignore_code)
right = _column_right(lines, em)
tol = _fit_tolerance(lines, right, em)
end = _block_end(lines, right, em, tol)
@ -1421,6 +1495,24 @@ def lines_to_md(textlines: list[dict], original, ignore_code: bool = False) -> s
return _verbatim_block(logical, levels, gaps, end)
def _keep_lines_md(lines: list[dict], em: float, ignore_code: bool) -> str:
"""--keep-lines: jede Zeile bleibt eine Zeile, nichts wird zusammengefügt.
Strophen-/Absatzabstände werden Leerzeilen, Einrückungen bleiben. Ein Block
mit mehreren Zeilen endet mit Absatzende (Strophe), eine Einzelzeile mit
hartem Umbruch (sie kann zur nächsten Zeile gehören).
"""
logical = [[line] for line in lines]
levels = _indent_levels(lines, em)
if len(lines) > 1 and not ignore_code and _is_monospace(lines):
return _code_block(logical, levels)
pitches = [b["bbox"][1] - a["bbox"][1] for a, b in zip(lines, lines[1:])]
usual = sorted(pitches)[len(pitches) // 2] if pitches else 0
gaps = [usual > 0 and pitch > STANZA_GAP * usual for pitch in pitches]
end = PARAGRAPH_END if len(lines) > 1 else HARD_BREAK
return _verbatim_block(logical, levels, gaps, end)
def patch_pymupdf4llm_paragraphs() -> None:
original = getattr(document_layout, "text_to_md", None)
if original is None or getattr(original, "pdf2md_patched", False):
@ -1686,6 +1778,7 @@ def keep_existing_ocr(page, **kwargs) -> None:
def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str:
_jpeg_quality[0] = options.get("jpeg_quality", DEFAULT_JPEG_QUALITY) # auch im Worker
_keep_lines[0] = options.get("keep_lines", False)
kwargs = dict(
pages=pages,
header=options["keep_headers"],
@ -1695,8 +1788,10 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str
image_format=options["image_format"],
)
if run == RUN_OCR:
# FORCE_DROP_OLD: vorhandene (schlechte) OCR-Ebene verwerfen und neu
# erkennen; force_ocr=True hieße FORCE_KEEP_OLD und behielte den alten Text.
kwargs.update(
force_ocr=True,
use_ocr=OCRMode.FORCE_DROP_OLD,
ocr_function=tesseract_api.exec_ocr,
ocr_language=options["ocr_language"],
ocr_dpi=options["ocr_dpi"],
@ -1708,7 +1803,24 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str
# pymupdf4llm schreibt Statusmeldungen auf stdout; wir melden selbst.
with contextlib.redirect_stdout(io.StringIO()):
return pymupdf4llm.to_markdown(source, **kwargs)
if run != RUN_OCR:
return pymupdf4llm.to_markdown(source, **kwargs)
# Fremde OCR-Ebenen entfernt pymupdf4llm nicht (nur eigene); sonst stünde
# alter und neuer Text doppelt da. Nur die geöffnete Kopie wird geändert.
with pymupdf.open(source) as doc:
for pno in pages:
drop_invisible_text(doc[pno])
return pymupdf4llm.to_markdown(doc, **kwargs)
def drop_invisible_text(page: pymupdf.Page) -> None:
"""Unsichtbaren Text (vorhandene OCR-Ebene) entfernen; Bild und Grafik bleiben."""
page.add_redact_annot(page.rect, fill=False)
page.apply_redactions(
images=pymupdf.PDF_REDACT_IMAGE_NONE,
graphics=pymupdf.PDF_REDACT_LINE_ART_NONE,
text=pymupdf.PDF_REDACT_TEXT_REMOVE_INVISIBLE,
)
def _init_worker() -> None:
@ -2232,6 +2344,12 @@ def main() -> int:
help="Sprache im EPUB, z. B. en (Standard: aus --ocr-language, "
"deu -> de, eng -> en)",
)
parser.add_argument(
"--keep-lines",
action="store_true",
help="jede Zeile der PDF bleibt eine Zeile (Gedichte, Liedtexte); kein "
"Zusammenfügen, auch keine Silbentrennung",
)
parser.add_argument(
"--keep-headers",
action="store_true",
@ -2319,6 +2437,7 @@ def main() -> int:
ocr_language=args.ocr_language,
ocr_dpi=args.ocr_dpi,
jpeg_quality=args.jpeg_quality,
keep_lines=args.keep_lines,
)
markdown = extract_markdown(source, plan, options, args.jobs)

View file

@ -141,6 +141,15 @@ def pdfs(tmp_path_factory) -> dict[str, Path]:
paths["blank"] = folder / "blank.pdf"
blank.save(paths["blank"])
# Scan mit falscher (unsichtbarer) OCR-Ebene, wie von einem schlechten OCR-Lauf
bad_ocr = pymupdf.open()
for page in digital:
_scan_page(bad_ocr, page)
for y in range(80, 500, 20):
bad_ocr[-1].insert_text((60, y), "Falscher Text " * 4, fontsize=10, render_mode=3)
paths["bad_ocr"] = folder / "bad_ocr.pdf"
bad_ocr.save(paths["bad_ocr"])
if pymupdf.get_tessdata():
scanned_ocr = pymupdf.open()
for page in digital:

View file

@ -81,6 +81,22 @@ def test_existing_ocr_layer_is_reused(pdfs, tmp_path, digital_md):
assert ratio >= 0.9, ratio
@needs_tesseract
def test_ocr_always_replaces_bad_ocr_layer(pdfs, tmp_path, digital_md):
out = tmp_path / "bad_ocr.md"
run(pdfs["bad_ocr"], out, "--ocr", "always")
md = out.read_text(encoding="utf-8")
assert "Falscher" not in md # alte Ebene verworfen, nicht verdoppelt
ratio = difflib.SequenceMatcher(None, words(digital_md), words(md), autojunk=False).ratio()
assert ratio >= 0.9, ratio
def test_auto_keeps_existing_ocr_layer(pdfs, tmp_path):
out = tmp_path / "bad_ocr_auto.md"
run(pdfs["bad_ocr"], out)
assert "Falscher Text" in out.read_text(encoding="utf-8")
@needs_tesseract
def test_mixed_keeps_page_order(pdfs, tmp_path):
out = tmp_path / "mixed.md"

View file

@ -204,3 +204,84 @@ def test_single_code_like_register_entry_stays_text(tmp_path):
])
assert "```" not in md
assert "@classmethod, 190\\" in md
# --- --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"