feat: epub2md – EPUB nach kapitelweisem Markdown

Kommandozeilenwerkzeug, das DRM-freie EPUB-2/3-Dateien offline in
kapitelweise Markdown-Dateien umwandelt. Erzeugt werden außerdem Medien,
metadata.yaml, manifest.json, conversion-report.json und Diagnose-Dateien.

- Spine-basierte Kapitelbildung mit --chapter-level, epub:type-Abschnitten,
  Fortsetzungsdateien und Erkennung von Layout-Überschriften
- Code mit exakter Einrückung, sicheren Fences und konservativer
  Spracherkennung
- MathML->TeX (konservativ), TeX aus alt-Texten, SVG-/Bildformeln
  bleiben Bilder
- Fußnoten-Modi end-of-chapter, end-of-book, inline und keep-links
- Interne Links/Anker werden umgeschrieben, Linkcheck über die Ausgabe
- Sicher: kein Entpacken, Schutz vor Path Traversal und XXE,
  DRM-Erkennung, atomares Schreiben, --force nur für frühere Ausgaben
- 82 pytest-Tests mit synthetischen EPUBs und Invariantentests für Beispiele/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-05 19:33:42 +02:00
commit 02449734a2

129
epub2md/cli.py Normal file
View file

@ -0,0 +1,129 @@
"""Command line interface."""
from __future__ import annotations
import argparse
import sys
import traceback
from pathlib import Path
from typing import NoReturn
from . import __version__
from .errors import EXIT_INPUT, EXIT_INTERNAL, Epub2MdError
from .pipeline import Options, convert, print_summary
DESCRIPTION = """\
Konvertiert eine DRM-freie EPUB-Datei (EPUB 2/3) in eine strukturierte Sammlung
kapitelweiser Markdown-Dateien mit Medien, Metadaten und Konvertierungsbericht.
"""
EPILOG = """\
Rückgabecodes:
0 Erfolgreich (ggf. mit nicht-kritischen Warnungen)
1 Ungültige Parameter, Eingabedatei nicht lesbar oder Ausgabeverzeichnis belegt
2 EPUB-/ZIP-/Containerfehler (auch DRM, unsichere ZIP-Pfade mit --strict)
3 OPF-/Spine-/XHTML-Verarbeitungsfehler
4 Unerwarteter interner Fehler
Beispiel:
python3 epub2md.py Beispiele/buch.epub output/buch --math auto --footnotes end-of-chapter
"""
class _Parser(argparse.ArgumentParser):
def error(self, message: str) -> NoReturn: # argparse would exit with 2, which we reserve for EPUB errors
self.print_usage(sys.stderr)
print(f"{self.prog}: Fehler: {message}", file=sys.stderr)
sys.exit(EXIT_INPUT)
def build_parser() -> argparse.ArgumentParser:
p = _Parser(
prog="epub2md.py",
description=DESCRIPTION,
epilog=EPILOG,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("input", metavar="INPUT.epub", help="DRM-freie EPUB-Datei")
p.add_argument("output", metavar="OUTPUT_VERZEICHNIS", help="Zielverzeichnis (darf nicht existieren oder muss leer sein)")
p.add_argument("--format", choices=["gfm", "commonmark"], default="gfm",
help="Markdown-Variante (Standard: gfm). commonmark: Tabellen als HTML, Fußnoten als Anker-Links")
p.add_argument("--extract-media", action=argparse.BooleanOptionalAction, default=True,
help="Bilder/Medien nach OUTPUT/media/ extrahieren (Standard: an)")
p.add_argument("--copy-original", action=argparse.BooleanOptionalAction, default=False,
help="Original-EPUB nach OUTPUT/source/ kopieren (Standard: aus)")
p.add_argument("--chapter-level", type=int, default=1, metavar="N",
help="Überschriftenebene 1–6, ab der neue Kapiteldateien entstehen (Standard: 1). "
"Für Bücher mit Teilen (h1) und Kapiteln (h2) ist 2 sinnvoll")
p.add_argument("--toc", action=argparse.BooleanOptionalAction, default=True,
help="README.md mit Inhaltsverzeichnis erzeugen (Standard: an)")
p.add_argument("--frontmatter", action=argparse.BooleanOptionalAction, default=True,
help="YAML-Frontmatter in jeder Kapiteldatei (Standard: an)")
p.add_argument("--footnotes", choices=["keep-links", "end-of-chapter", "end-of-book", "inline"],
default="end-of-chapter", help="Strategie für Fuß-/Endnoten (Standard: end-of-chapter)")
p.add_argument("--math", choices=["auto", "tex", "mathml", "image"], default="auto",
help="Strategie für Formeln (Standard: auto)")
p.add_argument("--images", choices=["relative", "flat", "original-path"], default="relative",
help="Ablage der Medien: relative = media/images/0001-name.ext, flat = media/name.ext, "
"original-path = media/<Pfad im EPUB> (Standard: relative)")
p.add_argument("--slug-language", choices=["de", "en", "none"], default="de",
help="Dateinamen-Regeln: de (ä→ae), en (ä→a), none (Unicode behalten) (Standard: de)")
p.add_argument("--keep-html", action="store_true",
help="Nicht zuverlässig konvertierbare Elemente als bereinigtes HTML erhalten")
p.add_argument("--strict", action="store_true",
help="Bei kritischen Fehlern (fehlende Spine-Dateien, unsichere Pfade, kaputtes XHTML) abbrechen")
p.add_argument("--force", action="store_true",
help="Bestehendes Ausgabeverzeichnis ersetzen – nur wenn es eine frühere epub2md-Ausgabe ist")
p.add_argument("--verbose", "-v", action="store_true", help="Verarbeitungsschritte und Warnungen ausgeben")
p.add_argument("--dry-run", action="store_true", help="Nur analysieren und geplante Dateien anzeigen, nichts schreiben")
p.add_argument("--report", metavar="DATEI.json",
help="Konvertierungsbericht zusätzlich in diese Datei schreiben "
"(OUTPUT/conversion-report.json wird immer erzeugt)")
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
return p
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
try:
args = build_parser().parse_args(argv)
except SystemExit as exc: # --help/--version (0) or invalid arguments (1)
return exc.code if isinstance(exc.code, int) else EXIT_INPUT
opts = Options(
format=args.format,
extract_media=args.extract_media,
copy_original=args.copy_original,
chapter_level=args.chapter_level,
toc=args.toc,
frontmatter=args.frontmatter,
footnotes=args.footnotes,
math=args.math,
images=args.images,
slug_language=args.slug_language,
keep_html=args.keep_html,
strict=args.strict,
verbose=args.verbose,
dry_run=args.dry_run,
force=args.force,
report=args.report,
command=["python3", "epub2md.py"] + argv,
)
try:
result = convert(Path(args.input), Path(args.output), opts)
except Epub2MdError as exc:
print(f"epub2md: Fehler: {exc}", file=sys.stderr)
if args.verbose:
traceback.print_exc()
return exc.exit_code
except KeyboardInterrupt:
print("epub2md: abgebrochen", file=sys.stderr)
return EXIT_INTERNAL
except Exception as exc: # noqa: BLE001 – last line of defense, mapped to exit code 4
print(f"epub2md: Interner Fehler: {type(exc).__name__}: {exc}", file=sys.stderr)
if args.verbose:
traceback.print_exc()
else:
print("Mit --verbose wird der vollständige Traceback angezeigt.", file=sys.stderr)
return EXIT_INTERNAL
print_summary(result, opts)
return 0