epub2md/epub2md/cli.py
dschlueter 6db834addd feat: Druck-Umbruchzeichen (↪/➥) in Codeblöcken auflösen (QA R6)
Fortsetzungszeilen werden wieder mit genau einem Leerzeichen zur
ursprünglichen Codezeile zusammengefügt. Im Python-Beispielbuch sind das
195 Stellen; danach lassen sich 247 statt 203 von 255 Python-Blöcken
parsen. Abschaltbar mit --keep-wrap-markers; der Bericht enthält
wrap_lines_joined pro Block und einen Hinweis pro Kapitel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:45:16 +02:00

146 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 .codeblocks import LANG_ALIASES
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, Ausgabeverzeichnis belegt/nicht beschreibbar
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 code_language(value: str) -> str:
lang = LANG_ALIASES.get(value.strip().lower())
if lang is None:
known = ", ".join(sorted(set(LANG_ALIASES.values())))
raise argparse.ArgumentTypeError(f"unbekannte Sprache {value!r} (bekannt: {known})")
return lang
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("--code-language", type=code_language, default=None, metavar="SPRACHE",
help="Sprachlabel für Codeblöcke ohne erkannte Sprache, z. B. python oder c "
"(erkannte Labels haben Vorrang; Standard: kein Label)")
p.add_argument("--keep-wrap-markers", action="store_true",
help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte "
"Zeilen werden wieder zur ursprünglichen Codezeile zusammengefügt")
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,
code_language=args.code_language or "",
keep_wrap_markers=args.keep_wrap_markers,
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