--split, --from-md, --jpeg-quality, YAML-Kopf, --title/--author

- --split teile|kapitel: Einzeldateien in einem Ordner mit index.md
  (Reihenfolge) und images/; Titelei und Inhaltsverzeichnis als Vorspann;
  YAML-Kopf je Datei (title, part, chapter, order, book, author)
- --from-md DATEI|ORDNER: EPUB aus bearbeitetem Markdown bzw. Split-Ordner,
  Angaben aus dem YAML-Kopf; ersetzt den pandoc-Befehl aus Weg B
- Markdown beginnt mit YAML-Kopf (title, author, lang, cover); Cover wird
  mit --images immer abgelegt
- --jpeg-quality (Standard 85 statt fest 95): EPUB Band 1 3 MB statt 5 MB
- --title/--author überschreiben PDF-Metadaten
- Tests (tests/test_split.py), README, Bedienungsanleitung (Weg C),
  CLAUDE.md, TODO.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-02 18:04:57 +02:00
commit d52ac597fc
7 changed files with 527 additions and 103 deletions

View file

@ -10,6 +10,8 @@ Es gibt zwei Arbeitswege:
- **Weg A:** PDF → EPUB in einem Schritt, wenn das Ergebnis ohne Nacharbeit passt. - **Weg A:** PDF → EPUB in einem Schritt, wenn das Ergebnis ohne Nacharbeit passt.
- **Weg B:** PDF → Markdown → von Hand bearbeiten → EPUB, wenn du Überschriften, Text oder - **Weg B:** PDF → Markdown → von Hand bearbeiten → EPUB, wenn du Überschriften, Text oder
Bilder noch korrigieren willst. Bilder noch korrigieren willst.
- **Weg C:** wie Weg B, aber jeder Teil bzw. jedes Kapitel in einer eigenen Datei – zum
Umsortieren, für RAG oder andere Formate.
--- ---
@ -88,7 +90,21 @@ cd ~/Downloads/ljutsch
python3 pdf2md.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf Output/band1.md python3 pdf2md.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf Output/band1.md
``` ```
Wenn du kein Cover und kein Probe-EPUB brauchst, reicht `--images` statt `--epub`. Wenn du kein Probe-EPUB brauchst, reicht `--images` statt `--epub` (das Cover wird trotzdem
abgelegt).
Am Anfang von `band1.md` steht ein **YAML-Kopf** mit den Angaben für das EPUB:
```yaml
---
title: "Operation Ljutsch"
author: "Reinhard Otto Kranz"
lang: "de"
cover: "band1_images/cover.jpg"
---
```
Hier kannst du Titel, Autor, Sprache oder Cover ändern. Werte immer in `"…"` schreiben.
### Schritt 2: `band1.md` bearbeiten ### Schritt 2: `band1.md` bearbeiten
@ -138,23 +154,14 @@ Am nächsten Morgen ...
### Schritt 3: EPUB aus dem bearbeiteten Markdown erzeugen ### Schritt 3: EPUB aus dem bearbeiteten Markdown erzeugen
Im Ordner der Markdown-Datei starten (wichtig für die Bilder):
```bash ```bash
cd ~/Downloads/ljutsch/Output cd ~/Downloads/ljutsch
pandoc band1.md \ python3 pdf2md.py --from-md Output/band1.md
--from markdown-yaml_metadata_block+autolink_bare_uris --to epub3 \
--output band1.epub \
--resource-path . \
--split-level=3 --toc-depth=3 \
--metadata title="Operation Ljutsch" \
--metadata author="Reinhard Otto Kranz" \
--metadata lang=de \
--epub-cover-image band1_images/cover.jpg
``` ```
Das sind dieselben Einstellungen, die `pdf2md.py --epub` verwendet. Titel und Autor trägst du Das erzeugt `Output/band1.epub` mit denselben Einstellungen wie `--epub`. Titel, Autor, Sprache
selbst ein. Ohne Cover lässt du die letzte Zeile weg und entfernst den `\` am Ende der Zeile davor. und Cover kommen aus dem YAML-Kopf; `--title`, `--author`, `--lang` oder `--epub-file PFAD`
überschreiben sie bei Bedarf.
Schritt 2 und 3 kannst du beliebig oft wiederholen. **Achtung:** Schritt 1 erneut auszuführen Schritt 2 und 3 kannst du beliebig oft wiederholen. **Achtung:** Schritt 1 erneut auszuführen
überschreibt deine Änderungen in `band1.md`. Leg dir vorher eine Kopie an oder nimm einen anderen überschreibt deine Änderungen in `band1.md`. Leg dir vorher eine Kopie an oder nimm einen anderen
@ -162,7 +169,48 @@ Dateinamen.
--- ---
## 4. Gescannte PDFs ## 4. Weg C – Teile oder Kapitel als Einzeldateien
```bash
cd ~/Downloads/ljutsch
python3 pdf2md.py --epub --split kapitel Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf Output/band1.md
```
Statt `band1.md` entsteht der Ordner `Output/band1/`:
```
band1/
index.md Reihenfolge der Dateien + YAML-Kopf des Buchs
00_vorspann.md Titelei und Inhaltsverzeichnis
01_vorwort-berlin-2015.md
02_teil-i-das-vermaechtnis.md Teil-Überschrift (und ggf. Einleitung)
03_1-der-notar.md
…
40_impressum.md
images/ Bilder und cover.jpg
```
- `--split kapitel` schneidet an Teilen **und** Kapiteln, `--split teile` nur an Teilen (ein Teil
mit all seinen Kapiteln pro Datei).
- Jede Datei beginnt mit einem YAML-Kopf, z. B.:
```yaml
---
title: "3 Die Grauen nehmen die Spur auf"
part: "Teil I: Das Vermächtnis"
chapter: 3
order: 5
book: "Operation Ljutsch"
author: "Reinhard Otto Kranz"
---
```
- **Umsortieren:** in `index.md` die Zeilen verschieben. **Weglassen:** Zeile löschen.
Dateien dürfen umbenannt werden, wenn der Link in `index.md` mitgeändert wird.
- **EPUB neu bauen:** `python3 pdf2md.py --from-md Output/band1/` → `Output/band1.epub`
(Kapitel in der Reihenfolge von `index.md`).
---
## 5. Gescannte PDFs
Bei Scans liest das Programm den Text per OCR (Tesseract). Der Aufruf ist derselbe: Bei Scans liest das Programm den Text per OCR (Tesseract). Der Aufruf ist derselbe:
@ -197,28 +245,32 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g
--- ---
## 5. Alle Optionen ## 6. Alle Optionen
| Option | Bedeutung | | Option | Bedeutung |
| :-- | :-- | | :-- | :-- |
| `pdf` | Eingabe-PDF | | `pdf` | Eingabe-PDF (entfällt bei `--from-md`) |
| `markdown` | Ausgabe-Datei; ohne Angabe: gleicher Name wie die PDF mit `.md` | | `markdown` | Ausgabe-Datei; ohne Angabe: gleicher Name wie die PDF mit `.md` |
| `--ocr auto\|always\|never` | OCR-Entscheidung (Standard `auto`, siehe Abschnitt 4) | | `--ocr auto\|always\|never` | OCR-Entscheidung (Standard `auto`, siehe Abschnitt 5) |
| `--no-ocr` | Kurzform für `--ocr never` | | `--no-ocr` | Kurzform für `--ocr never` |
| `--ocr-language` | Tesseract-Sprache(n), z. B. `deu+eng` (Standard `deu`) | | `--ocr-language` | Tesseract-Sprache(n), z. B. `deu+eng` (Standard `deu`) |
| `--ocr-dpi` | Auflösung für die OCR (Standard 300) | | `--ocr-dpi` | Auflösung für die OCR (Standard 300) |
| `--jobs N` | parallele OCR-Prozesse (Standard: halbe Kernzahl, höchstens 8) | | `--jobs N` | parallele OCR-Prozesse (Standard: halbe Kernzahl, höchstens 8) |
| `--images` | Bilder in `<name>_images/` speichern und im Markdown verlinken | | `--images` | Bilder in `<name>_images/` speichern und im Markdown verlinken |
| `--image-format jpg\|png` | Bildformat (Standard `jpg`; `png` verlustfrei, aber ca. 3× größer) | | `--image-format jpg\|png` | Bildformat (Standard `jpg`; `png` verlustfrei, aber ca. 3× größer) |
| `--jpeg-quality 1-100` | JPEG-Qualität (Standard 85; 95 fast verlustfrei, Bilder ca. 1,7× größer) |
| `--epub` | zusätzlich EPUB erzeugen (schaltet `--images` ein) | | `--epub` | zusätzlich EPUB erzeugen (schaltet `--images` ein) |
| `--epub-file PFAD` | anderer Pfad für das EPUB | | `--epub-file PFAD` | anderer Pfad für das EPUB |
| `--split teile\|kapitel` | Teile bzw. Kapitel als Einzeldateien (Abschnitt 4) |
| `--from-md PFAD` | nur EPUB aus `.md`-Datei oder `--split`-Ordner (Abschnitt 3, 4) |
| `--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-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 |
--- ---
## 6. Ergebnis prüfen ## 7. Ergebnis prüfen
1. **Überschriften auflisten.** So sieht man schnell, ob Teile und Kapitel stimmen: 1. **Überschriften auflisten.** So sieht man schnell, ob Teile und Kapitel stimmen:
```bash ```bash
@ -235,7 +287,7 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g
--- ---
## 7. Häufige Probleme ## 8. Häufige Probleme
| Meldung / Problem | Lösung | | Meldung / Problem | Lösung |
| :-- | :-- | | :-- | :-- |
@ -254,5 +306,6 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g
| 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) |
| 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; `--jpeg-quality 70` spart weiter |
| `--from-md`: „In index.md genannt, aber nicht vorhanden“ | Datei umbenannt oder gelöscht → Link in `index.md` anpassen |
| 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

@ -22,5 +22,8 @@ Aktuelles Programm: `pdf2md.py` (Einzeldatei; Name bleibt fest, keine Versionsnu
- Zwischen dem Patch (`lines_to_md`) und `normalize_markdown` laufen interne Markierungen - Zwischen dem Patch (`lines_to_md`) und `normalize_markdown` laufen interne Markierungen
aus dem Private-Use-Bereich (`HARD_BREAK`, `BLOCK_END`, `PARAGRAPH_END`, `STANZA_BREAK`, aus dem Private-Use-Bereich (`HARD_BREAK`, `BLOCK_END`, `PARAGRAPH_END`, `STANZA_BREAK`,
U+E002–U+E005); sie dürfen nie in der Ausgabe landen. U+E002–U+E005); sie dürfen nie in der Ausgabe landen.
- Ausgabe-Markdown beginnt mit einem YAML-Kopf, den `make_frontmatter`/`split_frontmatter`
selbst schreiben und lesen; pandoc bekommt nur den Text (`markdown-yaml_metadata_block`),
weil `---`-Zeilen im Buchtext sonst als YAML gelesen würden.
- Regressionsprüfung bei Änderungen am Umbruch: Band 1/2 vorher/nachher diffen; Fließtext - Regressionsprüfung bei Änderungen am Umbruch: Band 1/2 vorher/nachher diffen; Fließtext
darf sich nur an echten Absatz- oder Zeilenwechseln der PDF ändern. darf sich nur an echten Absatz- oder Zeilenwechseln der PDF ändern.

View file

@ -34,6 +34,13 @@ 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
# Kapitel als Einzeldateien (Ordner buch/ mit index.md), dazu EPUB
python3 pdf2md.py --epub --split kapitel buch.pdf buch.md
# EPUB aus bearbeitetem Markdown bzw. aus einem --split-Ordner (Reihenfolge: index.md)
python3 pdf2md.py --from-md buch.md
python3 pdf2md.py --from-md buch/
# englisches Buch; alter Scan mit schlechter OCR-Ebene neu erkennen # englisches Buch; alter Scan mit schlechter OCR-Ebene neu erkennen
python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EPUB-Sprache: en python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EPUB-Sprache: en
``` ```
@ -46,7 +53,11 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP
| `--jobs N` | parallele OCR-Prozesse (Standard: halbe Kernzahl, höchstens 8); reine Textdokumente laufen immer sequenziell | | `--jobs N` | parallele OCR-Prozesse (Standard: halbe Kernzahl, höchstens 8); reine Textdokumente laufen immer sequenziell |
| `--images` | Grafiken in `<name>_images/` speichern und verlinken | | `--images` | Grafiken in `<name>_images/` speichern und verlinken |
| `--image-format jpg\|png` | Bildformat (Standard jpg; png verlustfrei, aber ca. 3× größer) | | `--image-format jpg\|png` | Bildformat (Standard jpg; png verlustfrei, aber ca. 3× größer) |
| `--jpeg-quality 1-100` | JPEG-Qualität (Standard 85; 95 fast verlustfrei, Bilder ca. 1,7× größer) |
| `--epub`, `--epub-file PFAD` | EPUB 3 mit pandoc erzeugen (schaltet `--images` ein) | | `--epub`, `--epub-file PFAD` | EPUB 3 mit pandoc erzeugen (schaltet `--images` ein) |
| `--split teile\|kapitel` | Teile bzw. Kapitel als Einzeldateien in einem Ordner `<name>/` mit `index.md` (Reihenfolge) und `images/` |
| `--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`) | | `--lang` | Sprache im EPUB (Standard: aus `--ocr-language`, `deu`→`de`, `eng`→`en`) |
| `--keep-headers`, `--keep-footers` | Kolumnentitel bzw. Seitenzahlen nicht entfernen | | `--keep-headers`, `--keep-footers` | Kolumnentitel bzw. Seitenzahlen nicht entfernen |
@ -77,11 +88,14 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP
Tracking-Parameter (`utm_…`) entfallen. Ohne Linkziel wird die URL aus dem gedruckten Tracking-Parameter (`utm_…`) entfallen. Ohne Linkziel wird die URL aus dem gedruckten
Text zusammengesetzt. Text zusammengesetzt.
- **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt. - **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt.
- **YAML-Kopf:** Jede Markdown-Datei beginnt mit `title`, `author`, `lang`, `cover`
(Einzeldateien von `--split` mit `title`, `part`, `chapter`, `order`, `book`, `author`).
`--from-md` liest daraus die EPUB-Angaben.
## Tests ## Tests
```bash ```bash
python3 -m pytest # ca. 30 s; OCR-/EPUB-Tests werden ohne Tesseract/pandoc übersprungen python3 -m pytest # ca. 45 s; OCR-/EPUB-Tests werden ohne Tesseract/pandoc übersprungen
``` ```
Die Test-PDFs (digital, gescannt, mit OCR-Ebene, gemischt, Fotoseite) werden zur Laufzeit Die Test-PDFs (digital, gescannt, mit OCR-Ebene, gemischt, Fotoseite) werden zur Laufzeit
@ -108,7 +122,7 @@ synthetisch erzeugt.
stehen als Zeilen statt im Codeblock. stehen als Zeilen statt im Codeblock.
- Reine Zahlen können als Überschrift erscheinen (Fußnotenzeichen, römische Seitenzahlen). - Reine Zahlen können als Überschrift erscheinen (Fußnotenzeichen, römische Seitenzahlen).
Weitere bekannte Schwächen: [TODO.md](TODO.md). 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 85) gespeichert; EPUB Band 1 ca. 3 MB.
## Dateien ## Dateien

66
TODO.md
View file

@ -1,68 +1,14 @@
# TODO # TODO
## 1. Teile und Kapitel als separate Markdown-Dateien (`--split`) ## 1. Ideen
**Ziel:** Beim Konvertieren PDF → Markdown jeden Teil bzw. jedes Kapitel als eigene MD-Datei - `--split`: Seitenzahlen der PDF ins Frontmatter (`source_pages`); pymupdf4llm liefert sie
speichern. Damit lassen sich: mit `page_chunks=True`, müsste aber durch die ganze Nachbearbeitung getragen werden.
- `--split`: Bücher ohne erkennbare Gliederung werden nicht geteilt (nur `00_vorspann.md`);
- Teile/Kapitel einzeln ändern, denkbar wäre ein Schnitt nach Seitenzahl oder Länge.
- Teile/Kapitel vertauschen oder umsortieren,
- die MD-Dateien für RAG verarbeiten (ein Kapitel = ein Dokument),
- daraus HTML-Dateien oder andere Formate erzeugen.
**Skizze:**
- Option `--split teile|kapitel`; Ausgabe als Ordner `<name>/` statt einer einzelnen `.md`.
- Dateinamen mit Nummer und Titel, z. B.
```
band1/
index.md # Reihenfolge der Dateien (bzw. book.yaml)
00_vorspann.md # Titelei, Inhaltsverzeichnis, Vorwort
01_teil-i_das-vermaechtnis.md
02_kapitel-01_der-notar.md
…
99_nachspann.md # Der Autor, Impressum
images/
```
- YAML-Frontmatter pro Datei (für RAG und Umsortieren):
```yaml
---
title: "1 Der Notar"
part: "Teil I: Das Vermächtnis"
chapter: 1
source_pages: [12, 25]
book: "Operation Ljutsch"
author: "Reinhard Otto Kranz"
---
```
- EPUB aus den Einzeldateien: pandoc nimmt mehrere Eingabedateien in der Reihenfolge von
`index.md`; Umsortieren = Reihenfolge im Index ändern.
**Ansatzpunkte im Code (`pdf2md.py`):**
- Schnitt **nach** `normalize_markdown()` an den Überschriften `##` (Teil) und `###` (Kapitel);
die Ebenen kommen aus `load_outline()`.
- Bildpfade pro Datei anpassen (`make_image_links_relative()`).
- Vorspann (alles vor dem ersten Teil/Kapitel) und Nachspann als eigene Dateien.
**Offene Fragen:**
- Ein Ordner pro Teil oder alles flach in einem Ordner?
- Seitenzahlen ins Frontmatter? (pymupdf4llm liefert sie mit `page_chunks=True`.)
- Bücher ohne PDF-Gliederung: auf die erkannten Überschriften zurückfallen?
- Umkehrweg „Einzeldateien → ein MD/EPUB“ als eigene Option oder eigenes Skript?
**Tests:** Aufteilung des synthetischen Test-PDFs (Teil I, Kapitel 1–2) prüfen; Einzeldateien
wieder zusammengesetzt = Gesamt-Markdown.
## 2. Weitere Ideen
- `--from-md buch.md`: nur den EPUB-Schritt aus einem (von Hand bearbeiteten) Markdown
ausführen; Titel, Autor und Cover automatisch übernehmen.
- 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) ## 2. Bekannte Schwächen (aus Probeläufen mit fremden PDFs)
- Reine Zahlen als Überschrift: pymupdf4llm macht Fußnotenzeichen zu Überschriften - Reine Zahlen als Überschrift: pymupdf4llm macht Fußnotenzeichen zu Überschriften
(„## 90“ im Zensur-Report). Nicht pauschal entfernen – im DDR-Buch sind „## 1“, „## 2“ („## 90“ im Zensur-Report). Nicht pauschal entfernen – im DDR-Buch sind „## 1“, „## 2“

295
pdf2md.py
View file

@ -15,6 +15,7 @@ Ablauf:
import argparse import argparse
import contextlib import contextlib
import io import io
import json
import multiprocessing import multiprocessing
import os import os
import re import re
@ -22,6 +23,7 @@ import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import unicodedata
import urllib.parse import urllib.parse
from collections import Counter from collections import Counter
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
@ -1098,6 +1100,20 @@ class _DiscardMessages(io.StringIO):
return len(text) return len(text)
# JPEG-Qualität: pymupdf4llm speichert Bilder mit Pixmap.save() ohne Angabe
# der Qualität (Standard 95); wir setzen sie über --jpeg-quality.
DEFAULT_JPEG_QUALITY = 85
_jpeg_quality = [DEFAULT_JPEG_QUALITY]
_pixmap_save = pymupdf.Pixmap.save
def _save_pixmap(self, filename, output=None, jpg_quality=None):
quality = _jpeg_quality[0] if jpg_quality is None else jpg_quality
return _pixmap_save(self, filename, output, quality)
pymupdf.Pixmap.save = _save_pixmap
patch_pymupdf4llm_hyphens() patch_pymupdf4llm_hyphens()
patch_pymupdf4llm_paragraphs() patch_pymupdf4llm_paragraphs()
document_layout.INFO_MESSAGES = _DiscardMessages() document_layout.INFO_MESSAGES = _DiscardMessages()
@ -1314,6 +1330,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
kwargs = dict( kwargs = dict(
pages=pages, pages=pages,
header=options["keep_headers"], header=options["keep_headers"],
@ -1504,6 +1521,187 @@ def write_epub(
raise RuntimeError(f"pandoc fehlgeschlagen: {done.stderr.strip()}") raise RuntimeError(f"pandoc fehlgeschlagen: {done.stderr.strip()}")
# ===========================================================================
# Metadaten-Kopf, Aufteilen in Einzeldateien, EPUB aus Markdown
# ===========================================================================
# Jede erzeugte Markdown-Datei beginnt mit einem YAML-Kopf (title, author, lang,
# cover …). Wir schreiben und lesen ihn selbst; pandoc bekommt nur den Text,
# weil "---"-Zeilen im Buchtext sonst als YAML missverstanden werden könnten.
FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n+", re.DOTALL)
INDEX_LINK = re.compile(r"\]\(([^)\s]+\.md)\)")
UMLAUTS = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
def make_frontmatter(meta: dict) -> str:
lines = ["---"]
for key, value in meta.items():
if value not in (None, ""):
lines.append(f"{key}: {json.dumps(value, ensure_ascii=False)}")
return "\n".join(lines + ["---"]) + "\n\n"
def split_frontmatter(text: str) -> tuple[dict, str]:
"""YAML-Kopf (nur "schlüssel: wert") abtrennen und auswerten."""
match = FRONTMATTER.match(text)
if not match:
return {}, text
meta = {}
for line in match.group(1).splitlines():
key, sep, value = line.partition(":")
if sep:
value = value.strip()
try:
meta[key.strip()] = json.loads(value)
except ValueError:
meta[key.strip()] = value.strip("'\"")
return meta, text[match.end():]
def slugify(text: str, limit: int = 50) -> str:
text = clean_inline(text).lower().translate(UMLAUTS)
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
return slug[:limit].rstrip("-") or "abschnitt"
def iter_headings(lines: list[str]):
"""(Zeilennummer, Ebene, Text) aller Überschriften außerhalb von Codeblöcken."""
fence = None
for index, line in enumerate(lines):
marker = FENCE.match(line)
if fence:
if marker and marker.group(1)[0] == fence:
fence = None
continue
if marker:
fence = marker.group(1)[0]
elif match := re.match(r"^(#{1,6}) (.+)$", line):
yield index, len(match.group(1)), match.group(2).strip()
def split_markdown(markdown: str, mode: str) -> list[dict]:
"""Buch in Abschnitte teilen: "teile" (oberste Ebene) oder "kapitel".
Titelei und Inhaltsverzeichnis bilden den Vorspann.
"""
lines = markdown.split("\n")
headings = list(iter_headings(lines))
counts = Counter(level for _, level, _ in headings)
levels = sorted(level for level, n in counts.items() if n >= 2 and level <= 3)
if not levels:
return [{"title": "Vorspann", "body": markdown}]
part_level, chapter_level = levels[0], levels[-1]
cut_levels = {part_level} if mode == "teile" else {part_level, chapter_level}
# Bis einschließlich Inhaltsverzeichnis (falls am Anfang) alles Vorspann.
start = 0
for index, _level, text in headings:
if CONTENTS_TITLE.match(clean_inline(text)) and index < len(lines) // 5:
start = index + 1
break
cuts = [(i, lvl, t) for i, lvl, t in headings if lvl in cut_levels and i >= start]
sections = []
first = cuts[0][0] if cuts else len(lines)
front = "\n".join(lines[:first]).strip()
if front:
sections.append({"title": "Vorspann", "body": front + "\n"})
part = ""
for n, (index, level, text) in enumerate(cuts):
end = cuts[n + 1][0] if n + 1 < len(cuts) else len(lines)
title = clean_inline(text)
if level == part_level:
part = title
number = re.match(r"(\d+)\b", title) if level == chapter_level != part_level else None
sections.append({
"title": title,
"part": part if part != title else "",
"chapter": int(number.group(1)) if number else None,
"body": "\n".join(lines[index:end]).strip() + "\n",
})
return sections
def write_split(folder: Path, sections: list[dict], meta: dict) -> list[Path]:
"""Abschnitte als nummerierte Dateien plus index.md (Reihenfolge) schreiben."""
folder.mkdir(parents=True, exist_ok=True)
width = max(2, len(str(len(sections))))
files = []
for order, section in enumerate(sections):
name = f"{order:0{width}d}_{slugify(section['title'])}.md"
head = {
"title": section["title"],
"part": section.get("part"),
"chapter": section.get("chapter"),
"order": order,
"book": meta.get("title"),
"author": meta.get("author"),
}
(folder / name).write_text(make_frontmatter(head) + section["body"], encoding="utf-8")
files.append(folder / name)
entries = "\n".join(
f"- [{s['title']}]({f.name})" for s, f in zip(sections, files)
)
index = (
make_frontmatter(meta)
+ "<!-- Reihenfolge der Dateien im Buch; zum Umsortieren Zeilen verschieben. -->\n\n"
+ entries + "\n"
)
(folder / "index.md").write_text(index, encoding="utf-8")
return files
def read_markdown_source(path: Path) -> tuple[dict, str, Path]:
"""Metadaten, Text und Basisordner aus einer .md-Datei oder einem Split-Ordner."""
if path.is_dir():
index = path / "index.md"
if not index.is_file():
raise RuntimeError(f"index.md fehlt in {path}")
meta, listing = split_frontmatter(index.read_text(encoding="utf-8"))
parts = []
for name in INDEX_LINK.findall(listing):
file = path / name
if not file.is_file():
raise RuntimeError(f"In index.md genannt, aber nicht vorhanden: {file}")
parts.append(split_frontmatter(file.read_text(encoding="utf-8"))[1].strip())
if not parts:
raise RuntimeError(f"index.md nennt keine Dateien: {index}")
return meta, "\n\n".join(parts) + "\n", path
meta, body = split_frontmatter(path.read_text(encoding="utf-8"))
return meta, body, path.parent
def markdown_to_epub(
source: Path,
epub_path: Path,
*,
title: str | None = None,
author: str | None = None,
language: str | None = None,
) -> None:
"""EPUB aus Markdown-Datei oder Split-Ordner; Angaben aus dem YAML-Kopf."""
meta, body, base = read_markdown_source(source)
cover = base / meta["cover"] if meta.get("cover") else None
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", suffix=".md", dir=base, delete=False
) as tmp:
tmp.write(body)
try:
write_epub(
Path(tmp.name),
epub_path,
title=title or meta.get("title") or source.stem,
author=author or meta.get("author") or "",
language=language or meta.get("lang") or "de",
cover=cover if cover and cover.is_file() else None,
split_level=heading_depth(body),
)
finally:
Path(tmp.name).unlink(missing_ok=True)
# =========================================================================== # ===========================================================================
# Kommandozeile # Kommandozeile
# =========================================================================== # ===========================================================================
@ -1515,7 +1713,7 @@ def main() -> int:
"Satzenden gliedern, optional Grafiken speichern und EPUB erzeugen." "Satzenden gliedern, optional Grafiken speichern und EPUB erzeugen."
) )
) )
parser.add_argument("pdf", type=Path, help="Eingabe-PDF") parser.add_argument("pdf", type=Path, nargs="?", help="Eingabe-PDF")
parser.add_argument( parser.add_argument(
"markdown", "markdown",
type=Path, type=Path,
@ -1579,6 +1777,28 @@ def main() -> int:
metavar="PFAD", metavar="PFAD",
help="Pfad des EPUB; Standard: gleicher Name wie Markdown mit .epub", help="Pfad des EPUB; Standard: gleicher Name wie Markdown mit .epub",
) )
parser.add_argument(
"--jpeg-quality",
type=int,
default=DEFAULT_JPEG_QUALITY,
metavar="1-100",
help="JPEG-Qualität der Bilder (Standard: %(default)s; 95 = fast verlustfrei, größer)",
)
parser.add_argument(
"--split",
choices=["teile", "kapitel"],
help="Teile bzw. Kapitel als einzelne Markdown-Dateien in einem Ordner "
"(mit index.md für die Reihenfolge) statt einer Datei",
)
parser.add_argument(
"--from-md",
type=Path,
metavar="PFAD",
help="nur EPUB erzeugen: aus einer (bearbeiteten) Markdown-Datei oder einem "
"--split-Ordner; Titel, Autor, Cover aus dem YAML-Kopf",
)
parser.add_argument("--title", help="Titel im EPUB (statt PDF-Metadaten/Dateiname)")
parser.add_argument("--author", help="Autor im EPUB (statt PDF-Metadaten/Dateiname)")
parser.add_argument( parser.add_argument(
"--lang", "--lang",
help="Sprache im EPUB, z. B. en (Standard: aus --ocr-language, " help="Sprache im EPUB, z. B. en (Standard: aus --ocr-language, "
@ -1595,6 +1815,13 @@ def main() -> int:
help="Seitenfüße/Seitenzahlen übernehmen", help="Seitenfüße/Seitenzahlen übernehmen",
) )
args = parser.parse_args() args = parser.parse_args()
if not 1 <= args.jpeg_quality <= 100:
parser.error("--jpeg-quality muss zwischen 1 und 100 liegen.")
if args.from_md:
return epub_from_markdown(args, parser)
if args.pdf is None:
parser.error("Eingabe-PDF fehlt (oder --from-md PFAD angeben).")
args.lang = args.lang or epub_language(args.ocr_language) args.lang = args.lang or epub_language(args.ocr_language)
source = args.pdf.expanduser() source = args.pdf.expanduser()
@ -1611,7 +1838,9 @@ def main() -> int:
args.images = True # EPUB ohne Bilder wäre unvollständig args.images = True # EPUB ohne Bilder wäre unvollständig
epub_path = (args.epub_file or target.with_suffix(".epub")).expanduser() epub_path = (args.epub_file or target.with_suffix(".epub")).expanduser()
image_dir = target.parent / f"{target.stem}_images" # --split: Ordner "<name>/" mit Einzeldateien und "images/" statt einer Datei
folder = target.with_suffix("") if args.split else None
image_dir = folder / "images" if folder else target.parent / f"{target.stem}_images"
try: try:
with pymupdf.open(source) as doc: with pymupdf.open(source) as doc:
@ -1620,8 +1849,12 @@ def main() -> int:
uris = load_link_uris(doc) uris = load_link_uris(doc)
page_count = doc.page_count page_count = doc.page_count
file_title, file_author = title_author_from_filename(source.stem) file_title, file_author = title_author_from_filename(source.stem)
title = ((doc.metadata or {}).get("title") or "").strip() or file_title title = (
author = ((doc.metadata or {}).get("author") or "").strip() or file_author args.title or ((doc.metadata or {}).get("title") or "").strip() or file_title
)
author = (
args.author or ((doc.metadata or {}).get("author") or "").strip() or file_author
)
counts = Counter(kinds) counts = Counter(kinds)
print( print(
@ -1642,10 +1875,11 @@ def main() -> int:
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
cover = None cover = None
if args.images: if args.images:
# Cover immer mit ablegen, damit --from-md es später findet.
image_dir.mkdir(parents=True, exist_ok=True) image_dir.mkdir(parents=True, exist_ok=True)
if epub_path: cover = image_dir / f"cover.{args.image_format}"
cover = image_dir / f"cover.{args.image_format}" _jpeg_quality[0] = args.jpeg_quality
doc[0].get_pixmap(dpi=150).save(cover) doc[0].get_pixmap(dpi=150).save(cover)
options = dict( options = dict(
keep_headers=args.keep_headers, keep_headers=args.keep_headers,
@ -1655,6 +1889,7 @@ def main() -> int:
image_format=args.image_format, image_format=args.image_format,
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,
) )
markdown = extract_markdown(source, plan, options, args.jobs) markdown = extract_markdown(source, plan, options, args.jobs)
@ -1669,24 +1904,30 @@ def main() -> int:
running_head_min=max(3, page_count // 10), running_head_min=max(3, page_count // 10),
) )
markdown = link_urls(markdown, uris) markdown = link_urls(markdown, uris)
target.write_text(markdown, encoding="utf-8")
base = folder or target.parent
meta = {
"title": title,
"author": author,
"lang": args.lang,
"cover": cover.relative_to(base).as_posix() if cover else None,
}
if folder:
files = write_split(folder, split_markdown(markdown, args.split), meta)
else:
target.write_text(make_frontmatter(meta) + markdown, encoding="utf-8")
if epub_path: if epub_path:
write_epub( markdown_to_epub(folder or target, epub_path)
target,
epub_path,
title=title,
author=author,
language=args.lang,
cover=cover,
split_level=heading_depth(markdown),
)
except Exception as exc: except Exception as exc:
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr) print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
return 1 return 1
print(f"Markdown geschrieben: {target}") if folder:
print(f"Markdown geschrieben: {len(files)} Dateien in {folder} (Reihenfolge: index.md)")
else:
print(f"Markdown geschrieben: {target}")
if args.images: if args.images:
print(f"Bilder gespeichert in: {image_dir}") print(f"Bilder gespeichert in: {image_dir}")
if epub_path: if epub_path:
@ -1695,5 +1936,23 @@ def main() -> int:
return 0 return 0
def epub_from_markdown(args, parser) -> int:
"""--from-md: EPUB aus bearbeitetem Markdown bzw. Split-Ordner erzeugen."""
source = args.from_md.expanduser()
if not source.exists():
parser.error(f"Nicht gefunden: {source}")
base = source if source.is_dir() else source.with_suffix("")
epub_path = (args.epub_file or base.with_suffix(".epub")).expanduser()
try:
markdown_to_epub(
source, epub_path, title=args.title, author=args.author, language=args.lang
)
except Exception as exc:
print(f"Fehler beim EPUB-Erzeugen: {exc}", file=sys.stderr)
return 1
print(f"EPUB geschrieben: {epub_path}")
return 0
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())

View file

@ -9,6 +9,7 @@ import zipfile
import pytest import pytest
from conftest import ROOT, TITLE, needs_pandoc, needs_tesseract from conftest import ROOT, TITLE, needs_pandoc, needs_tesseract
from pdf2md import split_frontmatter
SCRIPT = ROOT / "pdf2md.py" SCRIPT = ROOT / "pdf2md.py"
@ -49,7 +50,9 @@ def test_digital_structure(digital_md):
assert "Dort sprach er lange mit dem Geheimdienst über die Lage" in digital_md assert "Dort sprach er lange mit dem Geheimdienst über die Lage" in digital_md
# keine Seitenzahlen, keine Kolumnentitel außer der Titelüberschrift # keine Seitenzahlen, keine Kolumnentitel außer der Titelüberschrift
assert not re.search(r"^\d+$", digital_md, re.MULTILINE) assert not re.search(r"^\d+$", digital_md, re.MULTILINE)
assert digital_md.count(TITLE) == 1 meta, body = split_frontmatter(digital_md)
assert meta["title"] == TITLE and meta["lang"] == "de"
assert body.count(TITLE) == 1
@needs_tesseract @needs_tesseract

146
tests/test_split.py Normal file
View file

@ -0,0 +1,146 @@
"""--split, --from-md, YAML-Kopf und --jpeg-quality."""
import zipfile
import pytest
import pdf2md as p
from conftest import needs_pandoc
from test_e2e import run
BOOK = """# Das Buch
## Inhaltsverzeichnis
Teil I
1 Anfang
## Teil I: Der Anfang
Einleitung zum Teil.
### 1 Anfang
Text eins.
```
# kein Kapitel, nur ein Kommentar
```
### 2 Weiter
Text zwei.
## Impressum
Alle Rechte vorbehalten.
"""
def test_frontmatter_round_trip():
meta = {"title": "Ein \"Buch\": Teil 1", "author": "Max Muster", "chapter": 3, "leer": ""}
text = p.make_frontmatter(meta) + "# Text\n"
parsed, body = p.split_frontmatter(text)
assert parsed == {"title": "Ein \"Buch\": Teil 1", "author": "Max Muster", "chapter": 3}
assert body == "# Text\n"
def test_text_without_frontmatter_is_unchanged():
assert p.split_frontmatter("---\n\nText\n") == ({}, "---\n\nText\n")
def test_slugify():
assert p.slugify("2 Ein Brief – eine Liste – ein Vermächtnis") == "2-ein-brief-eine-liste-ein-vermaechtnis"
assert p.slugify("Größe & Maß") == "groesse-mass"
assert p.slugify("!!!") == "abschnitt"
def test_split_by_chapters():
sections = p.split_markdown(BOOK, "kapitel")
assert [s["title"] for s in sections] == [
"Vorspann", "Teil I: Der Anfang", "1 Anfang", "2 Weiter", "Impressum",
]
assert sections[0]["body"].startswith("# Das Buch") and "## Inhaltsverzeichnis" in sections[0]["body"]
assert sections[2]["part"] == "Teil I: Der Anfang" and sections[2]["chapter"] == 1
assert "# kein Kapitel" in sections[2]["body"] # Codeblock wird nicht geschnitten
assert "".join(s["body"] for s in sections).replace("\n", "") == BOOK.replace("\n", "")
def test_split_by_parts():
sections = p.split_markdown(BOOK, "teile")
assert [s["title"] for s in sections] == ["Vorspann", "Teil I: Der Anfang", "Impressum"]
assert "### 2 Weiter" in sections[1]["body"]
def test_write_split_and_read_back(tmp_path):
sections = p.split_markdown(BOOK, "kapitel")
files = p.write_split(tmp_path / "buch", sections, {"title": "Das Buch", "author": "Max"})
assert [f.name for f in files][:3] == ["00_vorspann.md", "01_teil-i-der-anfang.md", "02_1-anfang.md"]
meta, body, base = p.read_markdown_source(tmp_path / "buch")
assert meta["title"] == "Das Buch" and base == tmp_path / "buch"
assert body.replace("\n", "") == BOOK.replace("\n", "")
head, _ = p.split_frontmatter(files[2].read_text())
assert head == {"title": "1 Anfang", "part": "Teil I: Der Anfang", "chapter": 1,
"order": 2, "book": "Das Buch", "author": "Max"}
def test_missing_index_entry_is_reported(tmp_path):
folder = tmp_path / "buch"
p.write_split(folder, p.split_markdown(BOOK, "teile"), {"title": "X"})
(folder / "01_teil-i-der-anfang.md").unlink()
with pytest.raises(RuntimeError, match="nicht vorhanden"):
p.read_markdown_source(folder)
# --- Ende zu Ende ------------------------------------------------------------------
def test_cli_split_writes_folder(pdfs, tmp_path):
run(pdfs["digital"], tmp_path / "buch.md", "--split", "kapitel", "--images")
folder = tmp_path / "buch"
names = sorted(f.name for f in folder.glob("*.md"))
assert names[0] == "00_vorspann.md" and "index.md" in names
assert any(n.endswith("_1-der-notar.md") for n in names)
assert (folder / "images" / "cover.jpg").is_file()
text = "".join(f.read_text() for f in folder.glob("*.md"))
assert "](images/" in text and "_images/" not in text
@needs_pandoc
def test_from_md_folder_follows_index_order(pdfs, tmp_path):
run(pdfs["digital"], tmp_path / "buch.md", "--split", "kapitel", "--epub")
index = tmp_path / "buch" / "index.md"
lines = index.read_text().split("\n")
i = next(n for n, line in enumerate(lines) if "1-der-notar" in line)
j = next(n for n, line in enumerate(lines) if "2-ein-brief" in line)
lines[i], lines[j] = lines[j], lines[i]
index.write_text("\n".join(lines))
run("--from-md", tmp_path / "buch", "--epub-file", tmp_path / "neu.epub")
with zipfile.ZipFile(tmp_path / "neu.epub") as z:
nav = z.read("EPUB/nav.xhtml").decode()
opf = z.read("EPUB/content.opf").decode()
assert nav.index("2 Ein Brief") < nav.index("1 Der Notar")
assert "Erika Muster" in opf and "cover" in opf
@needs_pandoc
def test_from_md_file_uses_frontmatter_and_overrides(pdfs, tmp_path):
run(pdfs["digital"], tmp_path / "buch.md", "--images")
md = tmp_path / "buch.md"
md.write_text(md.read_text().replace("Der Regen", "Der starke Regen"))
run("--from-md", md, "--title", "Neuer Titel")
with zipfile.ZipFile(tmp_path / "buch.epub") as z:
opf = z.read("EPUB/content.opf").decode()
text = "".join(z.read(n).decode() for n in z.namelist() if n.endswith(".xhtml"))
assert ">Neuer Titel<" in opf and "Erika Muster" in opf and "cover" in opf
assert "Der starke Regen" in text and "title:" not in text
def test_jpeg_quality_changes_image_size(pdfs, tmp_path):
def size(quality):
out = tmp_path / f"q{quality}" / "buch.md"
run(pdfs["digital"], out, "--images", "--jpeg-quality", str(quality))
return sum(f.stat().st_size for f in (out.parent / "buch_images").glob("*.jpg"))
assert size(30) < size(95)