epub2md/epub2md/cli.py
dschlueter 16e2d186a4 feat: Seitenzahlen der Druckausgabe per --page-markers (P5)
- Erkennung: epub:type/role pagebreak (aria-label, title, Text) sowie
  Seitenanker-IDs page_47, page37, seite-12 und p17 (nur leere a/span)
- Ausgabe: visible = \[S. 123\], comment = <!-- S. 123 -->, none (Standard)
- keine Marker in Code; Marker in Überschriften als Absatz davor;
  Layout-Tabellen-Überschriften behalten ihre Seitenmarke
- Bericht: layout.page_markers; interne data-e2m-*-Attribute nie im HTML

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

169 lines
9.1 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("--boxes", choices=["callout", "quote", "html", "plain"], default="callout",
help="Infokästen (Tipp, Hinweis, Warnung, Exkurs …): callout = '> [!TIP]' (GitHub/Obsidian), "
"quote = Zitatblock mit fettem Titel, html = <div class=\"box …\">, plain = normaler Text "
"(Standard: callout)")
p.add_argument("--styles", choices=["semantic", "html"], default="semantic",
help="semantic: nur Bedeutung übernehmen (Hintergrundmarkierung → <mark>); html: zusätzlich Farbe, "
"Hintergrund, Schriftgröße/-art, Kapitälchen, Ausrichtung und Fremdsprache als <span style>/"
"<span lang> (wo sie vom normalen Text abweichen; GitHub zeigt das nicht an) (Standard: semantic)")
p.add_argument("--page-markers", choices=["none", "visible", "comment"], default="none",
help="Seitenzahlen der Druckausgabe: visible = '[S. 123]' im Text, comment = '<!-- S. 123 -->' "
"(unsichtbar, durchsuchbar), none = weglassen (Standard: none)")
p.add_argument("--css-emphasis", action=argparse.BooleanOptionalAction, default=True,
help="Fett/kursiv/unterstrichen/hoch-/tiefgestellt auch übernehmen, wenn das Buch sie nur per CSS "
"setzt; per CSS ausgeblendete Elemente weglassen (Standard: an)")
p.add_argument("--keep-invisible-chars", action="store_true",
help="Nullbreiten-Zeichen und weiche Trennstriche (U+00AD) im Fließtext behalten; Standard: entfernen "
"(stören Suche und Weiterverarbeitung). Code bleibt immer unverändert")
p.add_argument("--strict", action="store_true",
help="Bei kritischen Fehlern abbrechen: fehlende/unlesbare Spine-Dokumente, nicht dekodierbares "
"XHTML, unsichere ZIP-Pfade, übersprungene (verdächtige) Medien")
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,
keep_invisible_chars=args.keep_invisible_chars,
css_emphasis=args.css_emphasis,
boxes=args.boxes,
styles=args.styles,
page_markers=args.page_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