diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md
index 733b205..bb7d24b 100644
--- a/BEDIENUNGSANLEITUNG.md
+++ b/BEDIENUNGSANLEITUNG.md
@@ -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) |
| 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) |
+| 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`) |
| 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.
- **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
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:
+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
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“.
+
+**Fraktur:** Das Modell `frk` aus dem Ubuntu-Paket ist alt und verwechselt oft ſ/f/k/l
+(„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
(„Geheim-|dienst“) zusammengefügt.
diff --git a/README.md b/README.md
index 12b3019..1605393 100644
--- a/README.md
+++ b/README.md
@@ -16,8 +16,10 @@ 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`. Für Fraktur-Scans
-zusätzlich `sudo apt install tesseract-ocr-frk` und `--ocr-language frk+deu`.
+`tesseract-ocr` wird nur für Scans gebraucht, `pandoc` nur für `--epub`. Für Fraktur-Scans das
+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
@@ -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 |
| `--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-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 |
## Was die Nachbearbeitung macht
@@ -131,9 +133,10 @@ synthetisch erzeugt.
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 …“).
+- Fraktur: Das lange s bleibt als „ſ“ erhalten; auch `deu_latf` liest alte Scans nicht fehlerfrei.
+- Gedicht-Scans ohne `--keep-lines`: Die Layout-Analyse von pymupdf4llm kann eng stehende Verse
+ 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
Werkzeug besser geeignet (z. B. Marker, Nougat).
- **Was am PDF liegt, bleibt:** fehlende Ligaturen ohne Textzuordnung („Offzielle“),
diff --git a/TODO.md b/TODO.md
index b83a313..6ffd8be 100644
--- a/TODO.md
+++ b/TODO.md
@@ -8,8 +8,9 @@
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.
+- Gedicht-Scans ohne `--keep-lines`: pymupdf-layout verschränkt eng stehende OCR-Zeilen
+ („UndDon tauchteweißen“). Mit `--keep-lines` umgangen (Zeilen direkt aus Tesseract).
+- `--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 “, „5
“) werden
nicht immer als ein Codeblock erkannt; Programmausgaben ohne typische Merkmale bleiben Text.
diff --git a/pdf2md.py b/pdf2md.py
index bd5e066..ad483e5 100644
--- a/pdf2md.py
+++ b/pdf2md.py
@@ -14,6 +14,7 @@ Ablauf:
import argparse
import contextlib
+import difflib
import io
import json
import multiprocessing
@@ -75,9 +76,10 @@ OMITTED_IMAGE = re.compile(
)
# Text, den pymupdf4llm innerhalb von Bildern findet (Fotos, OCR-Reste).
-PICTURE_TEXT = re.compile(
- r"\*\*----- Start of picture text -----\*\*
.*?"
- r"\*\*----- End of picture text -----\*\*
\n?",
+PICTURE_TEXT = re.compile( # Format bis pymupdf4llm 1.27 bzw. ab 1.28
+ r"(?:\*\*----- Start of picture text -----\*\*
|)"
+ r".*?"
+ r"(?:\*\*----- End of picture text -----\*\*
|)\n?",
re.DOTALL,
)
@@ -103,7 +105,8 @@ HTML_BLOCK = re.compile(
# Seitenzahl allein in einer Zeile: "12", "- 12 -", "Seite 12"
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,
)
@@ -409,6 +412,11 @@ def normalize_markdown(
standalone(clean_inline(line.rstrip(HARD_BREAK + PARAGRAPH_END)))
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):
content = line.removesuffix(PARAGRAPH_END)
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,}")
+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:
+ _dedupe_box_lines(boxes)
_page_lines.clear()
real: list[tuple[float, float]] = []
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()):
if run != RUN_OCR:
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
# alter und neuer Text doppelt da. Nur die geöffnete Kopie wird geändert.
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)
+# --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:
"""Unsichtbaren Text (vorhandene OCR-Ebene) entfernen; Bild und Grafik bleiben."""
page.add_redact_annot(page.rect, fill=False)
@@ -2392,6 +2530,10 @@ def main() -> int:
with pymupdf.open(source) as doc:
kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi)
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)
anchors = load_link_anchors(doc)
page_count = doc.page_count
@@ -2438,6 +2580,7 @@ def main() -> int:
ocr_dpi=args.ocr_dpi,
jpeg_quality=args.jpeg_quality,
keep_lines=args.keep_lines,
+ titles=titles,
)
markdown = extract_markdown(source, plan, options, args.jobs)
diff --git a/tests/test_verbatim.py b/tests/test_verbatim.py
index a2139d7..f803e16 100644
--- a/tests/test_verbatim.py
+++ b/tests/test_verbatim.py
@@ -6,7 +6,7 @@ import pymupdf
import pytest
import pdf2md as p
-from conftest import needs_pandoc
+from conftest import needs_pandoc, needs_tesseract
from test_e2e import run
NBSP = " "
@@ -285,3 +285,48 @@ def test_clean_ocr_lines_removes_edge_noise_and_splits_baselines():
def test_heading_without_letters_is_dropped():
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]