--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 B:** PDF → Markdown → von Hand bearbeiten → EPUB, wenn du Überschriften, Text oder
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
```
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
@ -138,23 +154,14 @@ Am nächsten Morgen ...
### Schritt 3: EPUB aus dem bearbeiteten Markdown erzeugen
Im Ordner der Markdown-Datei starten (wichtig für die Bilder):
```bash
cd ~/Downloads/ljutsch/Output
pandoc 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
cd ~/Downloads/ljutsch
python3 pdf2md.py --from-md Output/band1.md
```
Das sind dieselben Einstellungen, die `pdf2md.py --epub` verwendet. Titel und Autor trägst du
selbst ein. Ohne Cover lässt du die letzte Zeile weg und entfernst den `\` am Ende der Zeile davor.
Das erzeugt `Output/band1.epub` mit denselben Einstellungen wie `--epub`. Titel, Autor, Sprache
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
ü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:
@ -197,28 +245,32 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g
---
## 5. Alle Optionen
## 6. Alle Optionen
| 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` |
| `--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` |
| `--ocr-language` | Tesseract-Sprache(n), z. B. `deu+eng` (Standard `deu`) |
| `--ocr-dpi` | Auflösung für die OCR (Standard 300) |
| `--jobs N` | parallele OCR-Prozesse (Standard: halbe Kernzahl, höchstens 8) |
| `--images` | Bilder in `<name>_images/` speichern und im Markdown verlinken |
| `--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-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`) |
| `--keep-headers` | Kolumnentitel/Seitenköpfe 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:
```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 |
| :-- | :-- |
@ -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 |
| 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 |
| 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 |

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
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.
- 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
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
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
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 |
| `--images` | Grafiken in `<name>_images/` speichern und verlinken |
| `--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) |
| `--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`) |
| `--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
Text zusammengesetzt.
- **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
```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
@ -108,7 +122,7 @@ synthetisch erzeugt.
stehen als Zeilen statt im Codeblock.
- 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 85) gespeichert; EPUB Band 1 ca. 3 MB.
## Dateien

66
TODO.md
View file

@ -1,68 +1,14 @@
# 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
speichern. Damit lassen sich:
- Teile/Kapitel einzeln ändern,
- 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.
- `--split`: Seitenzahlen der PDF ins Frontmatter (`source_pages`); pymupdf4llm liefert sie
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`);
denkbar wäre ein Schnitt nach Seitenzahl oder Länge.
- 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
(„## 90“ im Zensur-Report). Nicht pauschal entfernen – im DDR-Buch sind „## 1“, „## 2“

289
pdf2md.py
View file

@ -15,6 +15,7 @@ Ablauf:
import argparse
import contextlib
import io
import json
import multiprocessing
import os
import re
@ -22,6 +23,7 @@ import shutil
import subprocess
import sys
import tempfile
import unicodedata
import urllib.parse
from collections import Counter
from concurrent.futures import ProcessPoolExecutor
@ -1098,6 +1100,20 @@ class _DiscardMessages(io.StringIO):
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_paragraphs()
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:
_jpeg_quality[0] = options.get("jpeg_quality", DEFAULT_JPEG_QUALITY) # auch im Worker
kwargs = dict(
pages=pages,
header=options["keep_headers"],
@ -1504,6 +1521,187 @@ def write_epub(
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
# ===========================================================================
@ -1515,7 +1713,7 @@ def main() -> int:
"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(
"markdown",
type=Path,
@ -1579,6 +1777,28 @@ def main() -> int:
metavar="PFAD",
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(
"--lang",
help="Sprache im EPUB, z. B. en (Standard: aus --ocr-language, "
@ -1595,6 +1815,13 @@ def main() -> int:
help="Seitenfüße/Seitenzahlen übernehmen",
)
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)
source = args.pdf.expanduser()
@ -1611,7 +1838,9 @@ def main() -> int:
args.images = True # EPUB ohne Bilder wäre unvollständig
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:
with pymupdf.open(source) as doc:
@ -1620,8 +1849,12 @@ def main() -> int:
uris = load_link_uris(doc)
page_count = doc.page_count
file_title, file_author = title_author_from_filename(source.stem)
title = ((doc.metadata or {}).get("title") or "").strip() or file_title
author = ((doc.metadata or {}).get("author") or "").strip() or file_author
title = (
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)
print(
@ -1642,9 +1875,10 @@ def main() -> int:
target.parent.mkdir(parents=True, exist_ok=True)
cover = None
if args.images:
# Cover immer mit ablegen, damit --from-md es später findet.
image_dir.mkdir(parents=True, exist_ok=True)
if epub_path:
cover = image_dir / f"cover.{args.image_format}"
_jpeg_quality[0] = args.jpeg_quality
doc[0].get_pixmap(dpi=150).save(cover)
options = dict(
@ -1655,6 +1889,7 @@ def main() -> int:
image_format=args.image_format,
ocr_language=args.ocr_language,
ocr_dpi=args.ocr_dpi,
jpeg_quality=args.jpeg_quality,
)
markdown = extract_markdown(source, plan, options, args.jobs)
@ -1669,23 +1904,29 @@ def main() -> int:
running_head_min=max(3, page_count // 10),
)
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:
write_epub(
target,
epub_path,
title=title,
author=author,
language=args.lang,
cover=cover,
split_level=heading_depth(markdown),
)
markdown_to_epub(folder or target, epub_path)
except Exception as exc:
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
return 1
if folder:
print(f"Markdown geschrieben: {len(files)} Dateien in {folder} (Reihenfolge: index.md)")
else:
print(f"Markdown geschrieben: {target}")
if args.images:
print(f"Bilder gespeichert in: {image_dir}")
@ -1695,5 +1936,23 @@ def main() -> int:
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__":
raise SystemExit(main())

View file

@ -9,6 +9,7 @@ import zipfile
import pytest
from conftest import ROOT, TITLE, needs_pandoc, needs_tesseract
from pdf2md import split_frontmatter
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
# keine Seitenzahlen, keine Kolumnentitel außer der Titelüberschrift
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

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)