- 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>
169 lines
9.1 KiB
Python
169 lines
9.1 KiB
Python
"""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
|