Inhaltsverzeichnis als Tabelle erhalten, Doku nach Probeläufen ergänzt

- normalize_markdown: Tabellenzeilen im Inhaltsverzeichnis unverändert
  übernehmen; der Verzeichnis-Modus endet mit der Tabelle (sonst zerfiel
  die Tabelle und die folgende Überschrift "Vorwort" ging verloren)
- Test für Verzeichnis als Tabelle
- README/Bedienungsanleitung: weiche Trennstriche, Courier-OCR-Ebenen,
  englische Bücher, --ocr always für alte Scans, Grenzen aus den
  Probeläufen mit fremden PDFs
- TODO.md: bekannte Schwächen (Zahlen als Überschrift, römische
  Seitenzahlen, gesperrte Überschriften)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-02 14:24:58 +02:00
commit f9dfa3a52e
5 changed files with 87 additions and 16 deletions

View file

@ -167,6 +167,7 @@ python3 pdf2md.py --epub scan.pdf Output/scan.md
| 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) |
| schneller auf vielen Kernen | `--jobs 8` (Standard 4) | | schneller auf vielen Kernen | `--jobs 8` (Standard 4) |
| OCR erzwingen, auch für vorhandene OCR-Ebenen | `--ocr always` | | OCR erzwingen, auch für vorhandene OCR-Ebenen | `--ocr always` |
| englisches Buch | `--ocr-language eng --lang en` (Sprachpaket `tesseract-ocr-eng` nötig) |
| keine OCR | `--ocr never` oder `--no-ocr` | | keine OCR | `--ocr never` oder `--no-ocr` |
So entscheidet das Programm von selbst (`--ocr auto`): So entscheidet das Programm von selbst (`--ocr auto`):
@ -175,7 +176,9 @@ So entscheidet das Programm von selbst (`--ocr auto`):
also ca. 5 Minuten für 300 Seiten. also ca. 5 Minuten für 300 Seiten.
- **Scans mit vorhandener OCR-Ebene** (z. B. von `ocrmypdf`): Eine gerade Ebene wird - **Scans mit vorhandener OCR-Ebene** (z. B. von `ocrmypdf`): Eine gerade Ebene wird
ü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. weil ihre Lesereihenfolge oft falsch ist. Alte OCR-Ebenen (z. B. aus Digitalisaten älterer
Bücher) sind oft schlecht: Kolumnentitel werden zu Überschriften, Buchstaben sind falsch
erkannt. Dann mit `--ocr always` neu erkennen lassen.
- **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“.
@ -213,7 +216,9 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g
grep -n "^#" Output/band1.md grep -n "^#" Output/band1.md
``` ```
Erwartet: `## Teil …` und darunter `### 1 …`, `### 2 …` usw. Ganze Sätze als Überschrift Erwartet: `## Teil …` und darunter `### 1 …`, `### 2 …` usw. Ganze Sätze als Überschrift
(endend mit `.` oder `«`) sind Erkennungsfehler, die du in Weg B korrigierst. (endend mit `.` oder `«`) sind Erkennungsfehler, die du in Weg B korrigierst. Die Ebenen
kommen aus der Gliederung (Lesezeichen) der PDF; ist sie flach, stehen alle Kapitel auf
einer Ebene.
2. **Stichproben** am Anfang, in der Mitte und am Ende des Markdowns mit der PDF vergleichen: 2. **Stichproben** am Anfang, in der Mitte und am Ende des Markdowns mit der PDF vergleichen:
Fehlen Absätze, sind Absätze zusammengeklebt, stehen Bilder an der richtigen Stelle? Fehlen Absätze, sind Absätze zusammengeklebt, stehen Bilder an der richtigen Stelle?
3. **EPUB auf dem PocketBook testen:** Inhaltsverzeichnis, Kapitelsprünge, Bilder, Cover. 3. **EPUB auf dem PocketBook testen:** Inhaltsverzeichnis, Kapitelsprünge, Bilder, Cover.
@ -231,6 +236,11 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g
| Bilder fehlen im EPUB (Weg B) | pandoc im Ordner der `.md` starten (`cd Output`) bzw. `--resource-path` auf diesen Ordner setzen; der `_images`-Ordner muss daneben liegen | | Bilder fehlen im EPUB (Weg B) | pandoc im Ordner der `.md` starten (`cd Output`) bzw. `--resource-path` auf diesen Ordner setzen; der `_images`-Ordner muss daneben liegen |
| Gescannter Text fehlt, Meldung „Hinweis: … Bildseite(n)“ | die Seite wurde für ein Foto gehalten → `--ocr always` | | Gescannter Text fehlt, Meldung „Hinweis: … Bildseite(n)“ | die Seite wurde für ein Foto gehalten → `--ocr always` |
| Satz als Überschrift oder Kapitel ohne Überschrift | in Weg B von Hand korrigieren (Abschnitt 3) | | Satz als Überschrift oder Kapitel ohne Überschrift | in Weg B von Hand korrigieren (Abschnitt 3) |
| Überschrift besteht nur aus einer Zahl (`## 90`, `## XX`) | meist Fußnotenzeichen oder Seitenzahl → in Weg B löschen |
| Buchstaben fehlen („Offzielle“, „treff ch“) | Ligatur (fi, fl) ohne Textzuordnung in der PDF; von Hand korrigieren |
| Überschrift gesperrt („M e i n k l e i nes Stück“) | Buchstaben einzeln gesetzt in der PDF; von Hand korrigieren |
| 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) |
| 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 | | EPUB zu groß | `--image-format jpg` (Standard) verwenden; PNG ist ca. 3× größer |
| Meine Änderungen in der `.md` sind weg | Schritt 1 wurde erneut ausgeführt → vorher Kopie anlegen | | Meine Änderungen in der `.md` sind weg | Schritt 1 wurde erneut ausgeführt → vorher Kopie anlegen |

View file

@ -1,10 +1,13 @@
# ljutsch – PDF → Markdown → EPUB # ljutsch – PDF → Markdown → EPUB
Werkzeug, um PDF-Bücher (hier: *Operation Ljutsch*, Band 1 und 2) in sauberes Markdown Werkzeug, um PDF-Bücher in sauberes Markdown und anschließend in ein EPUB für den
und anschließend in ein EPUB für den PocketBook-Reader umzuwandeln – mit Überschriften, PocketBook-Reader umzuwandeln – mit Überschriften, Bildern und echten Absätzen. Funktioniert
Bildern und echten Absätzen. Funktioniert für **digitale** PDFs (Textebene vorhanden) für **digitale** PDFs (Textebene vorhanden) ebenso wie für **gescannte** PDFs (nur Bilder,
ebenso wie für **gescannte** PDFs (nur Bilder, per OCR) und für Scans, die bereits eine per OCR) und für Scans, die bereits eine OCR-Ebene haben (z. B. von `ocrmypdf`).
OCR-Ebene haben (z. B. von `ocrmypdf`).
Entwickelt an *Operation Ljutsch* (Band 1 und 2), außerdem erprobt an einem deutschen
Sachbuch, einem englischen Handbuch, einem Mathematikbuch, einem Bericht mit Fußnoten und
einem englischen Scan von 1893 (Ergebnisse siehe [Grenzen](#grenzen)).
## Installation (Ubuntu 24.04) ## Installation (Ubuntu 24.04)
@ -29,6 +32,9 @@ python3 pdf2md.py --epub buch.pdf buch.md
# Scan in Deutsch und Englisch, 8 parallele OCR-Prozesse # Scan in Deutsch und Englisch, 8 parallele OCR-Prozesse
python3 pdf2md.py --ocr-language deu+eng --jobs 8 scan.pdf scan.md python3 pdf2md.py --ocr-language deu+eng --jobs 8 scan.pdf scan.md
# englisches Buch; alter Scan mit schlechter OCR-Ebene neu erkennen
python3 pdf2md.py --epub --lang en --ocr-language eng --ocr always scan.pdf scan.md
``` ```
| Option | Bedeutung | | Option | Bedeutung |
@ -49,10 +55,13 @@ python3 pdf2md.py --ocr-language deu+eng --jobs 8 scan.pdf scan.md
(`.` `!` `?` `…`, auch mit `«` `“` `»` usw. dahinter). Auf Scans werden Absätze am (`.` `!` `?` `…`, auch mit `«` `“` `»` usw. dahinter). Auf Scans werden Absätze am
Einzug erkannt. Einzug erkannt.
- **Silbentrennung:** `Geheim-|dienst` → `Geheimdienst`, `Militär-|Geheimdienst` bleibt mit - **Silbentrennung:** `Geheim-|dienst` → `Geheimdienst`, `Militär-|Geheimdienst` bleibt mit
Bindestrich, `Arbeits- und` bleibt. Bindestrich, `Arbeits- und` bleibt. Weiche Trennstriche (U+00AD) und `¬` am Zeilenende
werden ebenfalls aufgelöst (`wach­|senden` → `wachsenden`).
- **Überschriften:** Ebenen aus den PDF-Lesezeichen (Teile `##`, Kapitel `###`); - **Überschriften:** Ebenen aus den PDF-Lesezeichen (Teile `##`, Kapitel `###`);
Satzreste, die fälschlich als Überschrift erkannt wurden, kommen zurück in den Text. Satzreste, die fälschlich als Überschrift erkannt wurden, kommen zurück in den Text.
- **Inhaltsverzeichnis:** zusammengeklebte Einträge werden getrennt. - **Inhaltsverzeichnis:** zusammengeklebte Einträge werden getrennt; liegt das Verzeichnis
als Tabelle vor, bleibt die Tabelle erhalten.
- **OCR-Ebenen** in Courier werden nicht als Programmcode (`` `…` ``) gesetzt.
- **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt. - **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt.
## Tests ## Tests
@ -66,12 +75,21 @@ synthetisch erzeugt.
## Grenzen ## Grenzen
- Die Ergebnisqualität hängt von pymupdf4llm/pymupdf-layout ab. Zwei Schwächen der - Die Ergebnisqualität hängt von pymupdf4llm/pymupdf-layout ab. Einige Schwächen der
Bibliothek gleicht `pdf2md.py` per Patch aus (Bindestriche am Zeilenende, Absätze in Bibliothek gleicht `pdf2md.py` per Patch aus (Bindestriche und weiche Trennstriche am
Scan-Blöcken). Eine weitere ist bekannt: Bei Schriften mit Ligaturen (ff, fi) mit Zeilenende, Absätze in Scan-Blöcken, OCR-Ebenen in Courier). Eine weitere ist bekannt:
übergroßer Glyphenbox kann pymupdf4llm einzelne Zeilen verlieren. Bei den Ljutsch-Bänden Bei Schriften mit Ligaturen (ff, fi) mit übergroßer Glyphenbox kann pymupdf4llm einzelne
tritt das nicht auf (geprüft per Wortabgleich PDF ↔ Markdown). Zeilen verlieren. Bei den Ljutsch-Bänden tritt das nicht auf (geprüft per Wortabgleich
- OCR-Fehler (z. B. fehlende Leerzeichen) werden nicht korrigiert. PDF ↔ Markdown).
- OCR-Fehler (z. B. fehlende Leerzeichen) werden nicht korrigiert. Bei alten Scans mit
mitgelieferter OCR-Ebene liefert `--ocr always` meist deutlich besseren Text.
- **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“),
gesperrt gesetzte Überschriften („M e i n k l e i nes …“), flache PDF-Gliederungen
(alle Kapitel auf einer Ebene).
- Reine Zahlen können als Überschrift erscheinen (Fußnotenzeichen, römische Seitenzahlen).
Weitere bekannte Schwächen: [TODO.md](TODO.md).
- Bilder werden als JPEG (Qualität 95) gespeichert; EPUB Band 1 ca. 5 MB. - Bilder werden als JPEG (Qualität 95) gespeichert; EPUB Band 1 ca. 5 MB.
## Dateien ## Dateien

11
TODO.md
View file

@ -61,3 +61,14 @@ wieder zusammengesetzt = Gesamt-Markdown.
ausführen; Titel, Autor und Cover automatisch übernehmen. ausführen; Titel, Autor und Cover automatisch übernehmen.
- JPEG-Qualität einstellbar machen (z. B. `--jpeg-quality 85`), damit das EPUB noch kleiner wird. - JPEG-Qualität einstellbar machen (z. B. `--jpeg-quality 85`), damit das EPUB noch kleiner wird.
- Remote für das Git-Repo einrichten (z. B. GitHub), damit die Commits gesichert sind. - Remote für das Git-Repo einrichten (z. B. GitHub), damit die Commits gesichert sind.
## 3. Bekannte Schwächen (aus Probeläufen mit fremden PDFs)
- Reine Zahlen als Überschrift: pymupdf4llm macht Fußnotenzeichen zu Überschriften
(„## 90“ im Zensur-Report). Nicht pauschal entfernen – im DDR-Buch sind „## 1“, „## 2“
echte Kapitelnummern. Möglicher Ansatz: Zahl-Überschrift nur behalten, wenn direkt eine
Überschrift folgt.
- Römische Seitenzahlen („## XX“, „## XXIV“) werden nicht als Seitenzahl erkannt; Vorsicht
wegen echter Kapitel „I.“, „II.“.
- Gesperrte Überschriften („M e i n k l e i nes Stück D D R“) bleiben zerrissen.
- Alte Scans mit eigener OCR-Ebene: `--ocr always` liefert meist besseren Text.

View file

@ -303,6 +303,7 @@ def normalize_markdown(
fence_length = 0 fence_length = 0
in_contents = False in_contents = False
contents_seen: set[str] = set() # Titel, die im Inhaltsverzeichnis standen contents_seen: set[str] = set() # Titel, die im Inhaltsverzeichnis standen
contents_table = False # Inhaltsverzeichnis liegt als Markdown-Tabelle vor
headings_seen: set[str] = set() headings_seen: set[str] = set()
def paragraph_open() -> bool: def paragraph_open() -> bool:
@ -375,6 +376,7 @@ def normalize_markdown(
if CONTENTS_TITLE.match(text): if CONTENTS_TITLE.match(text):
heading(len(match.group(1)) if match else 2, text) heading(len(match.group(1)) if match else 2, text)
in_contents = True in_contents = True
contents_table = False
continue continue
if match: if match:
@ -408,7 +410,21 @@ def normalize_markdown(
continue continue
if in_contents: if in_contents:
if ends_sentence(text) and len(text) > 80: if stripped.startswith("|"):
# Verzeichnis als Tabelle: Zeilen unverändert übernehmen,
# sonst zerfällt die Tabelle in einzelne Absätze.
if not contents_table:
flush_paragraph()
blank_line()
contents_table = True
for cell in stripped.strip("|").split("|"):
contents_seen.add(norm_title(cell))
result.append(line)
continue
if contents_table:
in_contents = False # Tabelle zu Ende: Fließtext folgt
blank_line()
elif ends_sentence(text) and len(text) > 80:
in_contents = False # erster Fließtext nach dem Verzeichnis in_contents = False # erster Fließtext nach dem Verzeichnis
else: else:
for entry in split_contents_line(stripped, titles): for entry in split_contents_line(stripped, titles):

View file

@ -217,6 +217,22 @@ def test_contents_block_keeps_entries_and_headings_follow():
) )
def test_contents_table_stays_table_and_contents_end_after_it():
outline = {"vorwort": 1, "der staat": 1}
md = (
"## Inhaltsverzeichnis\n\n"
"|Vorwort|3|\n|---|---|\n|Der Staat|19|\n\n"
"Dieser Bericht steht unter CC BY-SA 4.0 (Lizenz)\n\n"
"Vorwort\n\nDer Text beginnt hier.\n"
)
assert norm(md, outline) == (
"## Inhaltsverzeichnis\n\n"
"|Vorwort|3|\n|---|---|\n|Der Staat|19|\n\n"
"Dieser Bericht steht unter CC BY-SA 4.0 (Lizenz)\n\n"
"# Vorwort\n\nDer Text beginnt hier.\n"
)
def test_heading_formatted_contents_entries_stay_plain(): def test_heading_formatted_contents_entries_stay_plain():
outline = {"1 der notar": 3} outline = {"1 der notar": 3}
md = "## Inhaltsverzeichnis\n\n## 1 Der Notar\n\n## 1 Der Notar\n\nText.\n" md = "## Inhaltsverzeichnis\n\n## 1 Der Notar\n\n## 1 Der Notar\n\nText.\n"