--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:
parent
1ba5b5005f
commit
3e5195efc2
7 changed files with 265 additions and 13 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
17
README.md
17
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
4
TODO.md
4
TODO.md
|
|
@ -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
131
pdf2md.py
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue