--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

295
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,10 +1875,11 @@ 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}"
doc[0].get_pixmap(dpi=150).save(cover)
cover = image_dir / f"cover.{args.image_format}"
_jpeg_quality[0] = args.jpeg_quality
doc[0].get_pixmap(dpi=150).save(cover)
options = dict(
keep_headers=args.keep_headers,
@ -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,24 +1904,30 @@ 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
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:
print(f"Bilder gespeichert in: {image_dir}")
if epub_path:
@ -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())