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:
commit
02449734a2
1 changed files with 7726 additions and 0 deletions
129
epub2md/cli.py
Normal file
129
epub2md/cli.py
Normal 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
|
||||
Loading…
Reference in a new issue