--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`) | | Buch in Deutsch (Standard) | – (`--ocr-language deu`) |
| Deutsch und Englisch gemischt | `--ocr-language deu+eng` (Sprachpaket `tesseract-ocr-eng` nötig) | | 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) | | 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`) | | 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` |
@ -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, ü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 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 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 - **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 Probe-OCR echten Fließtext liefert. Sonst bleiben sie Bild, und das Programm meldet einen
„Hinweis“. „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) | | `--from-md PFAD` | nur EPUB aus `.md`-Datei oder `--split`-Ordner (Abschnitt 3, 4) |
| `--title`, `--author` | Titel/Autor im EPUB vorgeben | | `--title`, `--author` | Titel/Autor im EPUB vorgeben |
| `--lang` | Sprache im EPUB (Standard: aus `--ocr-language` abgeleitet, `deu`→`de`, `eng`→`en`; sonst `de`) | | `--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-headers` | Kolumnentitel/Seitenköpfe nicht entfernen |
| `--keep-footers` | Seitenzahlen/Seitenfüße 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 | | 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 | | 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) | | 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 | | 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 | | 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 | | `--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 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 ## 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 | | `--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-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
@ -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 (Markdown: `\` am Zeilenende, Einrückung als geschütztes Leerzeichen). Codeblöcke über
eine Seitengrenze werden zusammengeführt; lange Codezeilen bricht der Reader um. 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 - **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 `«` `“` `»` - **Absätze:** eine Absatzgrenze gilt nach Satzende (`.` `!` `?` `…`, auch mit `«` `“` `»`
usw. dahinter) oder an einem harten Umbruch; auf Scans werden Absätze am Einzug erkannt. usw. dahinter) oder an einem harten Umbruch; auf Scans werden Absätze am Einzug erkannt.
- **Silbentrennung:** `Geheim-|dienst` → `Geheimdienst`, `Militär-|Geheimdienst` bleibt mit - **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 Zeilen verlieren. Bei den Ljutsch-Bänden tritt das nicht auf (geprüft per Wortabgleich
PDF ↔ Markdown). PDF ↔ Markdown).
- OCR-Fehler (z. B. fehlende Leerzeichen) werden nicht korrigiert. Bei alten Scans mit - 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 - **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“),
@ -134,7 +143,7 @@ synthetisch erzeugt.
Link (Zensur-Report: alle 76 gesetzt). Ohne Linkziel in der PDF wird eine URL nur über Link (Zensur-Report: alle 76 gesetzt). Ohne Linkziel in der PDF wird eine URL nur über
typische Umbruchstellen (`-`, `/`, `_`) mit ihrer Fortsetzung verbunden. typische Umbruchstellen (`-`, `/`, `_`) mit ihrer Fortsetzung verbunden.
- Zeilengetreue Erkennung: Ist ein Gedicht genauso eingerückt wie Absatzanfänge, werden - 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. Zeilennummern werden nicht immer als ein Codeblock erkannt.
Weitere bekannte Schwächen: [TODO.md](TODO.md). Weitere bekannte Schwächen: [TODO.md](TODO.md).
- Bilder werden als JPEG (Qualität 85) gespeichert; EPUB Band 1 ca. 3 MB. - 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 - 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 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`.
- 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 - 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.

131
pdf2md.py
View file

@ -32,7 +32,7 @@ from pathlib import Path
import pymupdf import pymupdf
import pymupdf4llm import pymupdf4llm
from pymupdf4llm.helpers import document_layout 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) standalone(text)
continue 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( if level is None and NUMBER_HEADING.match(text) and not heading_follows(
lines, index 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 nxt = spans[i + 1] if i + 1 < len(spans) else None
if not span.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT: if not span.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT:
span = {**span, "flags": span.get("flags", 0) & ~MONOSPACE_FLAG} 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 if not (span.get("flags", 0) & MONOSPACE_FLAG): # Code bleibt wörtlich
span = {**span, "text": md_escape(span["text"])} span = {**span, "text": md_escape(span["text"])}
text = span["text"].rstrip() 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_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: def _remember_page_lines(boxes) -> None:
_page_lines.clear() _page_lines.clear()
real: list[tuple[float, float]] = []
for box in boxes: for box in boxes:
if getattr(box, "boxclass", None) in TEXT_BOXES: if getattr(box, "boxclass", None) in TEXT_BOXES:
for line in getattr(box, "textlines", None) or []: for line in getattr(box, "textlines", None) or []:
_page_lines.append((line["bbox"][0], line["bbox"][2])) _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]: def _visual_lines(textlines: list[dict]) -> list[dict]:
@ -1258,16 +1271,20 @@ def _indent_levels(lines: list[dict], em: float) -> list[int]:
return levels 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: def _is_code(lines: list[dict], ignore_code: bool, short: bool = False) -> bool:
"""Ganzer Block Code? short: kurzer Block – dann zählen auch Konfiguration, """Ganzer Block Code? short: kurzer Block – dann zählen auch Konfiguration,
Befehle und Programmausgaben (sonst nur zeilenweise, siehe _mixed_block).""" Befehle und Programmausgaben (sonst nur zeilenweise, siehe _mixed_block)."""
if ignore_code: if ignore_code:
return False return False
texts = [_raw_text(line) for line in lines] texts = [_raw_text(line) for line in lines]
if all( if _is_monospace(lines):
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()
):
return True # Schreibmaschinenschrift return True # Schreibmaschinenschrift
hints = sum( hints = sum(
1 for t in texts 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}" 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: def lines_to_md(textlines: list[dict], original, ignore_code: bool = False) -> str:
"""Textblock ausgeben: Fließtext wie bisher, sonst zeilengetreu.""" """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) lines = _visual_lines(textlines)
sizes = sorted(s["size"] for line in lines for s in line["spans"]) sizes = sorted(s["size"] for line in lines for s in line["spans"])
em = sizes[len(sizes) // 2] if sizes else 10.0 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) right = _column_right(lines, em)
tol = _fit_tolerance(lines, right, em) tol = _fit_tolerance(lines, right, em)
end = _block_end(lines, right, em, tol) 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) 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: def patch_pymupdf4llm_paragraphs() -> None:
original = getattr(document_layout, "text_to_md", None) original = getattr(document_layout, "text_to_md", None)
if original is None or getattr(original, "pdf2md_patched", False): 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: 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 _jpeg_quality[0] = options.get("jpeg_quality", DEFAULT_JPEG_QUALITY) # auch im Worker
_keep_lines[0] = options.get("keep_lines", False)
kwargs = dict( kwargs = dict(
pages=pages, pages=pages,
header=options["keep_headers"], 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"], image_format=options["image_format"],
) )
if run == RUN_OCR: 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( kwargs.update(
force_ocr=True, use_ocr=OCRMode.FORCE_DROP_OLD,
ocr_function=tesseract_api.exec_ocr, ocr_function=tesseract_api.exec_ocr,
ocr_language=options["ocr_language"], ocr_language=options["ocr_language"],
ocr_dpi=options["ocr_dpi"], 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. # pymupdf4llm schreibt Statusmeldungen auf stdout; wir melden selbst.
with contextlib.redirect_stdout(io.StringIO()): with contextlib.redirect_stdout(io.StringIO()):
if run != RUN_OCR:
return pymupdf4llm.to_markdown(source, **kwargs) 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: def _init_worker() -> None:
@ -2232,6 +2344,12 @@ def main() -> int:
help="Sprache im EPUB, z. B. en (Standard: aus --ocr-language, " help="Sprache im EPUB, z. B. en (Standard: aus --ocr-language, "
"deu -> de, eng -> en)", "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( parser.add_argument(
"--keep-headers", "--keep-headers",
action="store_true", action="store_true",
@ -2319,6 +2437,7 @@ def main() -> int:
ocr_language=args.ocr_language, ocr_language=args.ocr_language,
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,
) )
markdown = extract_markdown(source, plan, options, args.jobs) 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" paths["blank"] = folder / "blank.pdf"
blank.save(paths["blank"]) 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(): if pymupdf.get_tessdata():
scanned_ocr = pymupdf.open() scanned_ocr = pymupdf.open()
for page in digital: 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 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 @needs_tesseract
def test_mixed_keeps_page_order(pdfs, tmp_path): def test_mixed_keeps_page_order(pdfs, tmp_path):
out = tmp_path / "mixed.md" 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 "```" not in md
assert "@classmethod, 190\\" 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"