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>
This commit is contained in:
Dieter Schlüter 2026-10-02 22:39:12 +02:00
commit 180d98ae04
5 changed files with 225 additions and 19 deletions

View file

@ -230,7 +230,7 @@ python3 pdf2md.py --epub scan.pdf Output/scan.md
| mehr oder weniger parallele OCR-Prozesse | `--jobs N` (Standard: halbe Kernzahl, höchstens 8) | | mehr oder weniger parallele OCR-Prozesse | `--jobs N` (Standard: halbe Kernzahl, höchstens 8) |
| OCR erzwingen, auch für vorhandene OCR-Ebenen | `--ocr always` (alte Ebene wird verworfen) | | OCR erzwingen, auch für vorhandene OCR-Ebenen | `--ocr always` (alte Ebene wird verworfen) |
| Gedichtband, Liederbuch | zusätzlich `--keep-lines` (siehe unten) | | Gedichtband, Liederbuch | zusätzlich `--keep-lines` (siehe unten) |
| Fraktur | `--ocr-language frk+deu` (Paket `tesseract-ocr-frk` nötig; „ſ“ bleibt erhalten) | | Fraktur | `--ocr-language deu_latf` (Modell nachladen, siehe unten) oder `frk+deu` (Paket `tesseract-ocr-frk`, schwächer) |
| englisches Buch | `--ocr-language eng` (Sprachpaket `tesseract-ocr-eng` nötig; EPUB-Sprache wird automatisch `en`) | | englisches Buch | `--ocr-language eng` (Sprachpaket `tesseract-ocr-eng` nötig; EPUB-Sprache wird automatisch `en`) |
| keine OCR | `--ocr never` oder `--no-ocr` | | keine OCR | `--ocr never` oder `--no-ocr` |
@ -245,17 +245,31 @@ So entscheidet das Programm von selbst (`--ocr auto`):
erkannt. Dann mit `--ocr always` neu erkennen lassen; die alte Ebene wird dabei verworfen. erkannt. Dann mit `--ocr always` neu erkennen lassen; die alte Ebene wird dabei verworfen.
- **Rauschen vom Scanrand** (Buchstabenfetzen neben dem Textbereich) wird entfernt. - **Rauschen vom Scanrand** (Buchstabenfetzen neben dem Textbereich) wird entfernt.
- **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“.
**Gedichtbände und Liederbücher:** Füllen die Verse die Zeile fast, kann das Programm sie nicht **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 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 Zeile, Strophen werden durch Leerzeilen getrennt, Einrückungen bleiben; nichts wird
zusammengefügt, auch keine Silbentrennung: zusammengefügt, auch keine Silbentrennung. Gescannte Seiten liest das Programm dann direkt aus
Tesseract (ohne Layout-Analyse, die bei Gedichten Zeilen verdoppelt oder verschränkt);
Gedichttitel kommen aus der PDF-Gliederung – in richtiger Schreibweise statt OCR-Lesart.
```bash ```bash
python3 pdf2md.py --epub --ocr always --keep-lines Neue_Gedichte_-_Rainer_Maria_Rilke.pdf Output/Neue_Gedichte_-_Rainer_Maria_Rilke.md 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 **Fraktur:** Das Modell `frk` aus dem Ubuntu-Paket ist alt und verwechselt oft ſ/f/k/l
„Hinweis“. („ftarf“ statt „ſtark“). Deutlich besser liest das Modell `deu_latf` aus tessdata_best:
```bash
sudo wget -P /usr/share/tesseract-ocr/5/tessdata https://github.com/tesseract-ocr/tessdata_best/raw/main/deu_latf.traineddata
python3 pdf2md.py --epub --ocr-language deu_latf --keep-lines gedichte.pdf Output/gedichte.md
```
Das lange s bleibt als „ſ“ erhalten. Auch mit `deu_latf` bleiben Lesefehler; alte Scans muss
man nacharbeiten (Weg B).
Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, getrennte Wörter Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, getrennte Wörter
(„Geheim-|dienst“) zusammengefügt. („Geheim-|dienst“) zusammengefügt.

View file

@ -16,8 +16,10 @@ sudo apt install tesseract-ocr tesseract-ocr-deu pandoc
pip install -r requirements.txt # pymupdf4llm, pymupdf-layout, pytest pip install -r requirements.txt # pymupdf4llm, pymupdf-layout, pytest
``` ```
`tesseract-ocr` wird nur für Scans gebraucht, `pandoc` nur für `--epub`. Für Fraktur-Scans `tesseract-ocr` wird nur für Scans gebraucht, `pandoc` nur für `--epub`. Für Fraktur-Scans das
zusätzlich `sudo apt install tesseract-ocr-frk` und `--ocr-language frk+deu`. Modell `deu_latf` aus [tessdata_best](https://github.com/tesseract-ocr/tessdata_best) nach
`/usr/share/tesseract-ocr/5/tessdata/` laden und `--ocr-language deu_latf` angeben (besser als
das Ubuntu-Paket `tesseract-ocr-frk`).
## Verwendung ## Verwendung
@ -60,7 +62,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 | | `--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) | | `--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`) | | `--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-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. Scans werden direkt aus Tesseract gelesen, Titel aus der PDF-Gliederung übernommen |
| `--keep-headers`, `--keep-footers` | Kolumnentitel bzw. Seitenzahlen nicht entfernen | | `--keep-headers`, `--keep-footers` | Kolumnentitel bzw. Seitenzahlen nicht entfernen |
## Was die Nachbearbeitung macht ## Was die Nachbearbeitung macht
@ -131,9 +133,10 @@ synthetisch erzeugt.
mitgelieferter OCR-Ebene liefert `--ocr always` meist deutlich besseren Text (die alte 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 Ebene wird dabei verworfen, z. B. „bliht“ → „blüht“). OCR-Rauschen vom Scanrand (Buchkante
als „|“, Buchstabenfetzen neben dem Textbereich) wird entfernt. als „|“, Buchstabenfetzen neben dem Textbereich) wird entfernt.
- Fraktur (`--ocr-language frk+deu`): Das lange s bleibt als „ſ“ erhalten; bei manchen Scans - Fraktur: Das lange s bleibt als „ſ“ erhalten; auch `deu_latf` liest alte Scans nicht fehlerfrei.
legt die Zeilenbildung von pymupdf4llm zwei eng stehende Verszeilen zusammen - Gedicht-Scans ohne `--keep-lines`: Die Layout-Analyse von pymupdf4llm kann eng stehende Verse
(„UndDon tauchteweißen …“). verschränken („UndDon tauchteweißen …“) – mit `--keep-lines` werden Scans direkt aus
Tesseract gelesen.
- **Mathematische Formeln** werden als Bilder übernommen; für Mathebücher ist ein anderes - **Mathematische Formeln** werden als Bilder übernommen; für Mathebücher ist ein anderes
Werkzeug besser geeignet (z. B. Marker, Nougat). Werkzeug besser geeignet (z. B. Marker, Nougat).
- **Was am PDF liegt, bleibt:** fehlende Ligaturen ohne Textzuordnung („Offzielle“), - **Was am PDF liegt, bleibt:** fehlende Ligaturen ohne Textzuordnung („Offzielle“),

View file

@ -8,8 +8,9 @@
Flecken“: alle Verse bei x = 94 wie Absatzanfänge), ist geometrisch nicht von mehreren 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. einzeiligen Dialogabsätzen zu unterscheiden; Verszeilen mit Satzende werden dort Absätze.
Für reine Gedichtbände: `--keep-lines`. Für reine Gedichtbände: `--keep-lines`.
- Fraktur-Scans (Löwenberg 1895): Zwei eng stehende OCR-Zeilen werden von der Zeilenbildung - Gedicht-Scans ohne `--keep-lines`: pymupdf-layout verschränkt eng stehende OCR-Zeilen
in pymupdf4llm/pymupdf-layout gelegentlich zu einer verschränkt („UndDon tauchteweißen“); („UndDon tauchteweißen“). Mit `--keep-lines` umgangen (Zeilen direkt aus Tesseract).
im OCR-PDF selbst sind sie getrennt. Idee: Option, „ſ“ in „s“ umzuwandeln. - `--keep-lines` auf Scans: Zierleisten/Ornamente werden manchmal als Wortsalat gelesen
(„amm Cem wen rn“); keine Bilder aus Scanseiten. Idee: Option „ſ“ → „s“.
- Listings mit eingedruckten Zeilennummern (Markdown Guide: „4 <html>“, „5 <head>“) werden - Listings mit eingedruckten Zeilennummern (Markdown Guide: „4 <html>“, „5 <head>“) werden
nicht immer als ein Codeblock erkannt; Programmausgaben ohne typische Merkmale bleiben Text. nicht immer als ein Codeblock erkannt; Programmausgaben ohne typische Merkmale bleiben Text.

151
pdf2md.py
View file

@ -14,6 +14,7 @@ Ablauf:
import argparse import argparse
import contextlib import contextlib
import difflib
import io import io
import json import json
import multiprocessing import multiprocessing
@ -75,9 +76,10 @@ OMITTED_IMAGE = re.compile(
) )
# Text, den pymupdf4llm innerhalb von Bildern findet (Fotos, OCR-Reste). # Text, den pymupdf4llm innerhalb von Bildern findet (Fotos, OCR-Reste).
PICTURE_TEXT = re.compile( PICTURE_TEXT = re.compile( # Format bis pymupdf4llm 1.27 bzw. ab 1.28
r"\*\*----- Start of picture text -----\*\*<br>.*?" r"(?:\*\*----- Start of picture text -----\*\*<br>|<!-- Start of picture text -->)"
r"\*\*----- End of picture text -----\*\*<br>\n?", r".*?"
r"(?:\*\*----- End of picture text -----\*\*<br>|<!-- End of picture text -->)\n?",
re.DOTALL, re.DOTALL,
) )
@ -103,7 +105,8 @@ HTML_BLOCK = re.compile(
# Seitenzahl allein in einer Zeile: "12", "- 12 -", "Seite 12" # Seitenzahl allein in einer Zeile: "12", "- 12 -", "Seite 12"
PAGE_NUMBER = re.compile( PAGE_NUMBER = re.compile(
r"^(?:[-–—]\s*)?(?:(?:seite|page|s\.)\s*)?\d{1,4}(?:\s*[-–—])?$", r"^(?:[-–—=]\s*)?(?:(?:seite|page|s\.)\s*)?\d{1,4}(?:\s*[-–—=])?$"
r"|^[-–—=]\s*[ivxlc]{1,7}\s*[-–—=]$", # "— VIII —" (OCR: "= 58 —")
re.IGNORECASE, re.IGNORECASE,
) )
@ -409,6 +412,11 @@ def normalize_markdown(
standalone(clean_inline(line.rstrip(HARD_BREAK + PARAGRAPH_END))) standalone(clean_inline(line.rstrip(HARD_BREAK + PARAGRAPH_END)))
continue continue
if line.endswith(PARAGRAPH_END) and drop_page_numbers and PAGE_NUMBER.match(
clean_inline(line.removesuffix(PARAGRAPH_END))
):
continue # Seitenzahl am Strophen-/Seitenende
if line.endswith(PARAGRAPH_END): if line.endswith(PARAGRAPH_END):
content = line.removesuffix(PARAGRAPH_END) content = line.removesuffix(PARAGRAPH_END)
if not paragraph and result and result[-1].endswith(HARD_BREAK): if not paragraph and result and result[-1].endswith(HARD_BREAK):
@ -1171,7 +1179,26 @@ _keep_lines = [False] # --keep-lines: jede PDF-Zeile bleibt eine Zeile
REAL_WORD = re.compile(r"[^\W\d_]{3,}") REAL_WORD = re.compile(r"[^\W\d_]{3,}")
def _dedupe_box_lines(boxes) -> None:
"""Überlappende Layout-Blöcke enthalten dieselben Zeilen doppelt
(Gedichtseiten in Scans) – jede Zeile nur im ersten Block behalten."""
seen: set = set()
for box in boxes:
lines = getattr(box, "textlines", None)
if not lines or getattr(box, "boxclass", None) not in TEXT_BOXES:
continue
kept = []
for line in lines:
key = (tuple(round(v) for v in line["bbox"]),
"".join(span["text"] for span in line["spans"]))
if key not in seen:
seen.add(key)
kept.append(line)
box.textlines = kept
def _remember_page_lines(boxes) -> None: def _remember_page_lines(boxes) -> None:
_dedupe_box_lines(boxes)
_page_lines.clear() _page_lines.clear()
real: list[tuple[float, float]] = [] real: list[tuple[float, float]] = []
for box in boxes: for box in boxes:
@ -1805,6 +1832,15 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str
with contextlib.redirect_stdout(io.StringIO()): with contextlib.redirect_stdout(io.StringIO()):
if run != RUN_OCR: if run != RUN_OCR:
return pymupdf4llm.to_markdown(source, **kwargs) return pymupdf4llm.to_markdown(source, **kwargs)
if options.get("keep_lines"):
with pymupdf.open(source) as doc:
return "".join(
ocr_lines_md(
doc[pno], options["ocr_language"], options["ocr_dpi"],
options.get("titles", {}).get(pno, []),
)
for pno in pages
)
# Fremde OCR-Ebenen entfernt pymupdf4llm nicht (nur eigene); sonst stünde # 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. # alter und neuer Text doppelt da. Nur die geöffnete Kopie wird geändert.
with pymupdf.open(source) as doc: with pymupdf.open(source) as doc:
@ -1813,6 +1849,108 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str
return pymupdf4llm.to_markdown(doc, **kwargs) return pymupdf4llm.to_markdown(doc, **kwargs)
# --keep-lines auf Scanseiten: Zeilen direkt aus Tesseract, ohne Layout-Modell.
# Dieses legt bei Gedicht-Scans eng stehende Verse zusammen ("UndDon tauchte…")
# und gibt Zeilen sich überlappender Blöcke doppelt aus.
OCR_TITLE_LINES = 8 # Titel suchen wir unter den ersten Zeilen der Seite
OCR_STANZA_GAP = 1.4 # Grundlinienabstand (× üblichem) für eine Strophengrenze
PAGE_LABEL = re.compile(r"^seite\s+\d+$", re.IGNORECASE) # Gliederungseintrag "Seite 4"
def _ocr_segments(page: pymupdf.Page, language: str, dpi: int) -> list[list]:
"""Textzeilen-Stücke [x0, y0, x1, y1, text] aus Tesseract, ohne Rauschen."""
pix = page.get_pixmap(dpi=dpi) # RGB: MuPDFs OCR liest Graustufen nicht
segments = []
with pymupdf.open("pdf", pix.pdfocr_tobytes(language=language)) as ocr:
for block in ocr[0].get_text("dict")["blocks"]:
for line in block.get("lines", []):
text = OCR_NOISE.sub("", "".join(s["text"] for s in line["spans"]))
text = re.sub(r"\s+", " ", text).strip()
if HAS_ALNUM.search(text): # Grundlinie für Zeilen- und Strophenabstand
segments.append([*line["bbox"], text, line["spans"][0]["origin"][1]])
real = [seg for seg in segments if len(REAL_WORD.findall(seg[4])) >= 2]
if real: # Spalte aus echten Zeilen; Fetzen am Scanrand liegen außerhalb
left = min(seg[0] for seg in real)
right = max(seg[2] for seg in real)
height = sorted(seg[3] - seg[1] for seg in real)[len(real) // 2]
segments = [
seg for seg in segments
if seg[2] > left - height and seg[0] < right + height
and (len(REAL_WORD.findall(seg[4])) >= 1 or PAGE_NUMBER.match(seg[4]))
]
return segments
def _ocr_rows(segments: list[list]) -> list[list]:
"""Stücke auf gleicher Höhe zu Zeilen verbinden: [x0, y0, x1, y1, text].
Verglichen wird mit dem Mittelpunkt des ersten Stücks einer Zeile – ein
wachsender Bereich würde sonst die Nachbarzeilen mit einsammeln."""
if not segments:
return []
heights = sorted(seg[3] - seg[1] for seg in segments)
height = heights[len(heights) // 2]
rows: list[list] = [] # [x0, y0, x1, y1, stücke, mittelpunkt]
for seg in sorted(segments, key=lambda s: (s[1] + s[3]) / 2):
center = (seg[1] + seg[3]) / 2
row = next((r for r in rows if abs(r[5] - center) < 0.5 * height
and all(seg[0] >= o[2] or seg[2] <= o[0] for o in r[4])), None)
if row is None:
rows.append([*seg[:4], [seg], center])
else:
row[0], row[1] = min(row[0], seg[0]), min(row[1], seg[1])
row[2], row[3] = max(row[2], seg[2]), max(row[3], seg[3])
row[4].append(seg)
rows.sort(key=lambda r: r[5])
return [
[*r[:4], " ".join(seg[4] for seg in sorted(r[4], key=lambda s: s[0])),
max(seg[5] for seg in r[4])]
for r in rows
]
def _title_key(text: str) -> str:
return re.sub(r"[\W\d_]+", "", text.lower())
def ocr_lines_md(page: pymupdf.Page, language: str, dpi: int, titles: list) -> str:
"""Scanseite zeilengetreu: Verse, Strophen (Abstand), Einrückung, Titel aus
der PDF-Gliederung (richtige Schreibweise statt OCR-Lesart)."""
rows = _ocr_rows(_ocr_segments(page, language, dpi))
if not rows:
return ""
heights = sorted(r[3] - r[1] for r in rows)
height = heights[len(heights) // 2]
left = min(r[0] for r in rows if not PAGE_NUMBER.match(r[4]))
pitches = sorted(b[5] - a[5] for a, b in zip(rows, rows[1:])) or [height]
pitch = pitches[len(pitches) // 2] # Abstand der Grundlinien
headings: dict[int, str] = {}
for level, title in titles: # Gliederungstitel dieser Seite wiederfinden
key = _title_key(title)
scored = [
(difflib.SequenceMatcher(None, key, _title_key(r[4])).ratio(), i)
for i, r in enumerate(rows[:OCR_TITLE_LINES]) if i not in headings
]
if scored and (best := max(scored))[0] >= 0.6:
headings[best[1]] = "#" * min(level, 6) + " " + md_escape(title)
out: list[str] = []
for i, row in enumerate(rows):
if i in headings:
out += ["", headings[i], ""]
continue
indent = int(max(0.0, row[0] - left) / (1.2 * height))
text = TEXT_INDENT * indent + md_escape(row[4])
gap = i + 1 < len(rows) and rows[i + 1][5] - row[5] > OCR_STANZA_GAP * pitch
last = i + 1 == len(rows) or (i + 1) in headings
out.append(text + (PARAGRAPH_END if gap or (last and i + 1 < len(rows)) else HARD_BREAK))
if gap:
out.append("")
return "\n".join(out).strip("\n") + "\n\n"
def drop_invisible_text(page: pymupdf.Page) -> None: def drop_invisible_text(page: pymupdf.Page) -> None:
"""Unsichtbaren Text (vorhandene OCR-Ebene) entfernen; Bild und Grafik bleiben.""" """Unsichtbaren Text (vorhandene OCR-Ebene) entfernen; Bild und Grafik bleiben."""
page.add_redact_annot(page.rect, fill=False) page.add_redact_annot(page.rect, fill=False)
@ -2392,6 +2530,10 @@ def main() -> int:
with pymupdf.open(source) as doc: with pymupdf.open(source) as doc:
kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi) kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi)
outline = load_outline(doc) outline = load_outline(doc)
titles: dict[int, list[tuple[int, str]]] = {} # Seite → Gliederungstitel
for level, title, page_no in doc.get_toc():
if page_no > 0 and title.strip() and not PAGE_LABEL.match(title.strip()):
titles.setdefault(page_no - 1, []).append((level, title.strip()))
uris = load_link_uris(doc) uris = load_link_uris(doc)
anchors = load_link_anchors(doc) anchors = load_link_anchors(doc)
page_count = doc.page_count page_count = doc.page_count
@ -2438,6 +2580,7 @@ def main() -> int:
ocr_dpi=args.ocr_dpi, ocr_dpi=args.ocr_dpi,
jpeg_quality=args.jpeg_quality, jpeg_quality=args.jpeg_quality,
keep_lines=args.keep_lines, keep_lines=args.keep_lines,
titles=titles,
) )
markdown = extract_markdown(source, plan, options, args.jobs) markdown = extract_markdown(source, plan, options, args.jobs)

View file

@ -6,7 +6,7 @@ import pymupdf
import pytest import pytest
import pdf2md as p import pdf2md as p
from conftest import needs_pandoc from conftest import needs_pandoc, needs_tesseract
from test_e2e import run from test_e2e import run
NBSP = " " NBSP = " "
@ -285,3 +285,48 @@ def test_clean_ocr_lines_removes_edge_noise_and_splits_baselines():
def test_heading_without_letters_is_dropped(): def test_heading_without_letters_is_dropped():
assert p.normalize_markdown("# ||\n\nText.\n") == "Text.\n" assert p.normalize_markdown("# ||\n\nText.\n") == "Text.\n"
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]