epub2md/epub2md/pipeline.py
dschlueter e8fd6c2fb9 fix: Kastentyp aus Verlagsklasse, kein Feld-Trenner vor ausgeblendeten Spans
- _box_type erkennt hinweis/note/notiz/anmerkung/info als NOTE; damit
  gewinnt die Klasse "box hinweis" gegen ein Typwort im Titel ("Wichtige …")
- _separate_field_spans setzt kein Leerzeichen, wenn einer der Spans per CSS
  display:none ist ("<u>x</u> ." → "<u>x</u>.")
- gefunden mit der neuen QA-Fixture 14-typography (QA-Matrix jetzt 85 Fälle,
  inkl. epub2md_merge.py und Pandoc-Filter)

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

595 lines
29 KiB
Python

"""Orchestration of the complete EPUB -> Markdown conversion."""
from __future__ import annotations
import os
import posixpath
import re
import shutil
import sys
import tempfile
from collections.abc import Callable
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from . import TOOL_NAME, __version__
from .chapters import ChapterBuilder
from .diagnostics import Diagnostics
from .css import StyleSheet, base_style, emphasis
from .epub_reader import EpubReader, decode_markup, parse_xml
from .errors import ContainerError, InputError, StructureError
from .footnotes import collect_notes
from .html_converter import RenderOptions, Renderer
from .linkcheck import check_links
from .media import MediaRegistry
from .models import Chapter
from .normalize import Doc, book_uses_title_tables, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail
from .references import LinkResolver
from .utils import is_element, is_inside, iter_elements, local, sha256_file, slugify, text_content
from . import writer
MARKER_FILES = ("conversion-report.json", "manifest.json")
LARGE_CHAPTER_WORDS = 30000 # above this a deeper --chapter-level is suggested (RAG/readability)
@dataclass
class Options:
format: str = "gfm"
extract_media: bool = True
copy_original: bool = False
chapter_level: int = 1
toc: bool = True
frontmatter: bool = True
footnotes: str = "end-of-chapter"
math: str = "auto"
images: str = "relative"
slug_language: str = "de"
keep_html: bool = False
code_language: str = ""
keep_wrap_markers: bool = False
keep_invisible_chars: bool = False
css_emphasis: bool = True
boxes: str = "callout"
styles: str = "semantic"
page_markers: str = "none"
strict: bool = False
verbose: bool = False
dry_run: bool = False
force: bool = False
report: str | None = None
command: list[str] = field(default_factory=list)
def public(self) -> dict[str, Any]:
data = asdict(self)
data.pop("command")
return data
@dataclass
class Result:
report: dict[str, Any]
chapters: list[Chapter]
files: dict[str, str]
output_dir: Path
def check_paths(input_path: Path, output_dir: Path, opts: Options) -> None:
if not input_path.is_file():
raise InputError(f"Eingabedatei nicht gefunden oder keine Datei: {input_path}")
if not os.access(input_path, os.R_OK):
raise InputError(f"Eingabedatei nicht lesbar: {input_path}")
if not 1 <= opts.chapter_level <= 6:
raise InputError("--chapter-level muss zwischen 1 und 6 liegen.")
if opts.dry_run:
return
if output_dir.exists() and not output_dir.is_dir():
raise InputError(f"Ausgabepfad existiert und ist kein Verzeichnis: {output_dir}")
if output_dir.exists() and any(output_dir.iterdir()):
if not opts.force:
raise InputError(
f"Ausgabeverzeichnis existiert bereits und ist nicht leer: {output_dir}\n"
"Mit --force wird es ersetzt (nur wenn es eine frühere epub2md-Ausgabe ist)."
)
resolved = output_dir.resolve()
protected = {Path("/").resolve(), Path.home().resolve(), Path.cwd().resolve()}
if resolved in protected or is_inside(Path.cwd(), resolved):
raise InputError(f"--force verweigert: {output_dir} ist ein geschütztes Verzeichnis.")
if is_inside(input_path, output_dir):
raise InputError("--force verweigert: die Eingabedatei liegt im Ausgabeverzeichnis.")
if not any((output_dir / m).is_file() for m in MARKER_FILES):
raise InputError(
f"--force verweigert: {output_dir} enthält keine frühere epub2md-Ausgabe "
"(conversion-report.json/manifest.json fehlen). Bitte manuell prüfen."
)
def convert(input_path: Path, output_dir: Path, opts: Options) -> Result:
diag = Diagnostics(verbose=opts.verbose, strict=opts.strict)
check_paths(input_path, output_dir, opts)
generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
sha = sha256_file(input_path)
diag.step(f"Öffne {input_path}")
reader = EpubReader(input_path, diag)
try:
return _convert(reader, input_path, output_dir, opts, diag, generated_at, sha)
finally:
reader.close()
def _load_docs(reader: EpubReader, package: Any, diag: Diagnostics) -> list[Doc]:
docs: list[Doc] = []
for item in package.spine:
try:
root = parse_xml(reader.read(item.path), item.path, diag, html_fallback=True)
except StructureError as exc:
diag.error("xhtml-unreadable", str(exc), item.path, critical=True)
continue
except KeyError:
diag.error("spine-file-missing", f"Spine-Dokument fehlt: {item.path}", item.path, critical=True)
continue
docs.append(make_doc(item.path, root, item.linear))
if not docs:
raise StructureError("Keines der Spine-Dokumente konnte gelesen werden.")
return docs
def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Options, diag: Diagnostics,
generated_at: str, sha: str) -> Result:
package = reader.load_package()
diag.step(f"EPUB {package.version}: {package.metadata.title!r}, {len(package.spine)} Spine-Dokumente")
docs = _load_docs(reader, package, diag)
stylesheet = _load_stylesheet(reader, package, docs, diag) # before normalization drops <style>
hidden = (lambda el: emphasis(stylesheet.computed(el))["hidden"]) if opts.css_emphasis else None
_prepare_docs(docs, package, diag, hidden)
notes, chapters, resolver = _build_structure(docs, package, opts, diag)
rendered = _render(reader, package, chapters, resolver, notes, opts, diag, stylesheet)
files, all_chapters, report = _assemble(input_path, output_dir, opts, diag, generated_at, sha, package, docs,
notes, chapters, resolver, rendered)
if opts.dry_run:
return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir)
if opts.report:
_check_report_path(Path(opts.report), output_dir, files, rendered.media, opts)
_write_output(reader, input_path, output_dir, opts, files, rendered.media)
if opts.report:
report_path = Path(opts.report)
try:
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(writer.to_json(report), encoding="utf-8")
except OSError as exc:
raise InputError(f"Bericht (--report) konnte nicht geschrieben werden: {report_path} "
f"({exc.strerror or exc}); die Ausgabe in {output_dir} ist vollständig.") from exc
return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir)
def _check_report_path(report_path: Path, output_dir: Path, files: dict[str, str], media: MediaRegistry,
opts: Options) -> None:
"""--report must not replace a file that epub2md itself writes into the output directory."""
if not is_inside(report_path, output_dir):
return
rel = report_path.resolve().relative_to(output_dir.resolve()).as_posix()
generated = set(files) | ({mf.output for mf in media.files.values()} if opts.extract_media else set())
if rel in generated:
raise InputError(f"--report {report_path} würde die erzeugte Datei {rel} überschreiben; "
"bitte einen anderen Dateinamen wählen.")
@dataclass
class _Rendered:
renderer: Renderer
media: MediaRegistry
cover_out: str
endnotes: Chapter | None
endnotes_md: str
skipped_formula_images: int
def _prepare_docs(docs: list[Doc], package: Any, diag: Diagnostics,
hidden: Callable[[Any], bool] | None = None) -> None:
"""Normalize publisher markup and drop HTML tables of contents that NAV/NCX already cover."""
title_tables = book_uses_title_tables(docs)
for doc in docs:
normalize_doc(doc, diag, title_tables, hidden)
landmark_toc_docs = {lm.path for lm in package.landmarks if lm.type == "toc" and lm.path and not lm.fragment}
for lm in package.landmarks:
if lm.type == "toc" and lm.fragment:
toc_doc = next((d for d in docs if d.path == lm.path), None)
el = next((e for e in iter_elements(toc_doc.body) if e.get("id") == lm.fragment), None) if toc_doc else None
if toc_doc is not None and el is not None and el is not toc_doc.body and _mostly_links(el):
toc_doc.toc_ids.update(e.get("id") for e in iter_elements(el) if e.get("id"))
remove_keep_tail(el)
if package.toc:
for doc in docs:
if is_html_toc_doc(doc, package.nav_path, landmark_toc_docs):
doc.skipped = "html-toc"
diag.info("html-toc-skipped", "HTML-Inhaltsverzeichnis übersprungen (ersetzt durch README.md/NAV).", doc.path)
if all(d.skipped for d in docs):
for d in docs:
d.skipped = ""
def _build_structure(docs: list[Doc], package: Any, opts: Options,
diag: Diagnostics) -> tuple[Any, list[Chapter], LinkResolver]:
"""Notes, chapter units with file names, and the link resolver (incl. NAV/NCX targets)."""
md = package.metadata
notes = collect_notes(docs, opts.footnotes, diag)
diag.step(f"{len(notes.notes)} Anmerkungen erkannt")
chapters = ChapterBuilder(opts.chapter_level, package.toc, diag, md.title, md.authors).build(docs)
_assign_filenames(chapters, opts.slug_language)
resolver = LinkResolver(docs, chapters, notes, "README.md" if opts.toc else None)
nav_targets: list[tuple[str, str]] = []
def walk(entries: list[Any]) -> None:
for e in entries:
if e.path and e.fragment:
nav_targets.append((e.path, e.fragment))
walk(e.children)
walk(package.toc)
resolver.add_targets(nav_targets)
diag.step(f"{len(chapters)} Kapitel gebildet")
return notes, chapters, resolver
def _load_stylesheet(reader: EpubReader, package: Any, docs: list[Doc], diag: Diagnostics) -> StyleSheet:
"""All CSS of the book: manifest stylesheets (manifest order) plus <style> elements of the documents."""
texts: list[str] = []
for item in package.manifest.values():
if item.media_type == "text/css" or item.path.lower().endswith(".css"):
try:
texts.append(decode_markup(reader.read(item.path), item.path, diag))
except (KeyError, ContainerError):
diag.warn("css-unreadable", f"Stylesheet {item.path} nicht lesbar; seine Formatierung entfällt.", item.path)
for doc in docs:
texts.extend(text_content(e) for e in iter_elements(doc.root) if local(e.tag) == "style")
return StyleSheet(texts)
def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver: LinkResolver, notes: Any,
opts: Options, diag: Diagnostics, stylesheet: StyleSheet | None = None) -> _Rendered:
"""Render all chapters (and the end-of-book notes file) to Markdown and register media."""
md = package.metadata
media = MediaRegistry(package, opts.images, opts.slug_language)
cover_out = ""
if md.cover_path and opts.extract_media and reader.exists(md.cover_path):
cover_out = media.register(md.cover_path, role="cover", hint="cover").output
language = (md.language or "de").lower()
renderer = Renderer(
RenderOptions(format=opts.format, footnotes=opts.footnotes, math=opts.math,
extract_media=opts.extract_media, keep_html=opts.keep_html, language=language,
code_language=opts.code_language, keep_wrap_markers=opts.keep_wrap_markers,
keep_invisible_chars=opts.keep_invisible_chars, css_emphasis=opts.css_emphasis,
boxes=opts.boxes, styles=opts.styles, page_markers=opts.page_markers),
resolver, media, notes, diag, set(reader.names), reader.entry_problem, stylesheet,
base_style(stylesheet, [p for c in chapters for _, it in c.items if is_element(it)
for p in iter_elements(it) if local(p.tag) == "p"][:20000]) if stylesheet else None,
)
width = max(3, len(str(len(chapters) + 1)))
notes_title = "Anmerkungen" if language.startswith("de") else "Notes"
endnotes_number = 999 if len(chapters) < 999 else len(chapters) + 1
renderer.endnotes_file = f"chapters/{endnotes_number:0{width}d}-{slugify(notes_title, opts.slug_language)}.md"
for ch in chapters:
diag.step(f"Rendere {ch.filename}")
ch.markdown = renderer.render_chapter(ch)
ch.stats.word_count = _word_count(ch.markdown)
_check_chapter_size(ch, opts.chapter_level, diag)
_report_wrap_markers(renderer, diag)
if not opts.code_language:
_suggest_code_language(renderer, diag)
endnotes: Chapter | None = None
endnotes_md = ""
if opts.footnotes == "end-of-book" and renderer.book_notes:
endnotes_md = renderer.render_endnotes_file(notes_title)
endnotes = Chapter(number=len(chapters) + 1, title=notes_title, items=[], docs=[], types=["endnotes"])
endnotes.filename = renderer.endnotes_file
endnotes.stats.footnotes = len(renderer.book_notes)
endnotes.stats.word_count = _word_count(endnotes_md)
skipped = _collect_remaining_media(reader, package, media, renderer, opts, diag)
return _Rendered(renderer, media, cover_out, endnotes, endnotes_md, skipped)
def _suggest_code_language(renderer: Renderer, diag: Diagnostics) -> None:
"""Book-level hint: many unlabeled blocks while every recognized block has the same language."""
blocks = renderer.code_blocks
detected = [b["language"] for b in blocks if b["language"]]
unlabeled = len(blocks) - len(detected)
if len(blocks) < 10 or len(detected) < 5 or unlabeled < 0.3 * len(blocks):
return
lang = max(set(detected), key=detected.count)
share = detected.count(lang) / len(detected)
if share >= 0.9:
diag.info("code-language-hint",
f"{unlabeled} von {len(blocks)} Codeblöcken ohne erkannte Sprache; {round(share * 100)} % der erkannten "
f"Blöcke sind {lang}. Bei einem einsprachigen Buch kennzeichnet --code-language {lang} die übrigen Blöcke.")
def _report_wrap_markers(renderer: Renderer, diag: Diagnostics) -> None:
wraps: dict[str, int] = {}
for block in renderer.code_blocks:
if block["wrap_lines_joined"]:
wraps[block["chapter"]] = wraps.get(block["chapter"], 0) + block["wrap_lines_joined"]
for chapter_file, joined in wraps.items():
diag.info("wrap-markers-joined", f"{joined} Druck-Zeilenumbrüche (↪) in Codeblöcken zusammengeführt.",
chapter_file, chapter_file)
def _collect_remaining_media(reader: EpubReader, package: Any, media: MediaRegistry, renderer: Renderer,
opts: Options, diag: Diagnostics) -> int:
"""Register fonts, CSS, audio and unreferenced images; returns the number of formula images replaced by TeX."""
skipped_formula_images = 0
if opts.extract_media:
for item in package.manifest.values():
if item.path in media.files or not reader.exists(item.path):
continue
if item.media_type in ("application/xhtml+xml", "text/html", "application/x-dtbncx+xml",
"application/oebps-package+xml") or item.path == package.opf_path:
continue
problem = reader.entry_problem(item.path)
if problem:
diag.error("media-skipped", f"Medium {item.path} nicht übernommen: {problem}", item.path,
critical=True, exc=ContainerError)
continue
if item.path in renderer.consumed_formula_images:
skipped_formula_images += 1
continue
mf = media.register(item.path, role="unreferenced")
mf.references = 0
for mf in media.files.values():
if mf.data is None and reader.exists(mf.source):
mf.size = reader.names[mf.source].file_size
return skipped_formula_images
def _assemble(input_path: Path, output_dir: Path, opts: Options, diag: Diagnostics, generated_at: str, sha: str,
package: Any, docs: list[Doc], notes: Any, chapters: list[Chapter], resolver: LinkResolver,
rendered: _Rendered) -> tuple[dict[str, str], list[Chapter], dict[str, Any]]:
"""Build all output files in memory (chapters, README, metadata, manifest, report, diagnostics)."""
md = package.metadata
renderer, media, endnotes = rendered.renderer, rendered.media, rendered.endnotes
for n in notes.notes.values():
if n.ref_count and not n.label and notes.extracted:
diag.warn("note-not-rendered", "Anmerkung wurde referenziert, aber nicht ausgegeben.", n.key)
opf_dir = posixpath.dirname(package.opf_path)
files: dict[str, str] = {}
for ch in chapters:
warn_count = sum(1 for d in diag.items if d.chapter == ch.filename and d.level != "info")
ch.warnings = warn_count
front = writer.chapter_frontmatter(ch, md, input_path.name, opf_dir, warn_count) if opts.frontmatter else ""
files[ch.filename] = front + ch.markdown
if endnotes is not None:
front = writer.chapter_frontmatter(endnotes, md, input_path.name, opf_dir, 0) if opts.frontmatter else ""
files[endnotes.filename] = front + rendered.endnotes_md
all_chapters = chapters + ([endnotes] if endnotes else [])
stats = _book_stats(all_chapters, media, renderer, diag)
if opts.toc:
files["README.md"] = writer.readme(md, chapters, endnotes, package.toc, resolver, stats, rendered.cover_out,
package.version, input_path.name, opts.command, str(output_dir),
renderer.boxes if opts.boxes == "callout" else 0)
existing = {mf.output for mf in media.files.values()} if opts.extract_media else set()
if opts.copy_original:
existing.add(f"source/{input_path.name}")
existing.update({"metadata.yaml", "manifest.json", "conversion-report.json",
"diagnostics/warnings.md", "diagnostics/unsupported-elements.md"})
broken = check_links(files, existing)
for b in broken:
diag.warn("broken-output-link", f"Link {b['link']} in der Ausgabe ist ungültig ({b['reason']}).", b["file"], b["file"])
stats["warnings"] = diag.count("warning")
report = _report(input_path, sha, generated_at, package, opts, all_chapters, media, renderer, notes, diag,
docs, broken, rendered.skipped_formula_images)
files["metadata.yaml"] = writer.metadata_yaml(md, input_path.name, sha, package.version, rendered.cover_out,
opts.public(), generated_at)
files["manifest.json"] = writer.to_json(_manifest(md, all_chapters, media, diag))
files["conversion-report.json"] = writer.to_json(report)
files["diagnostics/warnings.md"] = writer.warnings_md(diag)
files["diagnostics/unsupported-elements.md"] = writer.unsupported_md(diag)
return files, all_chapters, report
def _mostly_links(el: Any) -> bool:
from .utils import local, norm_ws, text_content
total = len(norm_ws(text_content(el)))
links = sum(len(norm_ws(text_content(a))) for a in iter_elements(el) if local(a.tag) == "a")
return total > 0 and links / total >= 0.5
def _assign_filenames(chapters: list[Chapter], slug_language: str) -> None:
width = max(3, len(str(len(chapters) + 1)))
used: set[str] = set()
for ch in chapters:
base = slugify(ch.title, slug_language, fallback=(ch.types[0] if ch.types else "kapitel"))
name = f"{ch.number:0{width}d}-{base}"
candidate, n = name, 2
while candidate in used:
candidate = f"{name}-{n}"
n += 1
used.add(candidate)
ch.filename = f"chapters/{candidate}.md"
def _check_chapter_size(ch: Chapter, chapter_level: int, diag: Diagnostics) -> None:
"""Suggest a deeper --chapter-level for very large chapters with enough sub-headings."""
if ch.stats.word_count <= LARGE_CHAPTER_WORDS or chapter_level >= 6:
return
sections = len(re.findall(r"^## ", ch.markdown, re.M))
if sections >= 2:
diag.warn(
"chapter-large",
f"Kapitel „{ch.title}“ hat {ch.stats.word_count} Wörter und {sections} Unterabschnitte; "
f"--chapter-level {chapter_level + 1} würde sie als eigene Dateien ausgeben.",
ch.docs[0] if ch.docs else "",
ch.filename,
)
def _word_count(markdown: str) -> int:
text = re.sub(r"^(`{3,}).*?^\1\s*$", " ", markdown, flags=re.M | re.S)
text = re.sub(r"<[^>]+>|\]\([^)]*\)", " ", text)
return len(re.findall(r"\w+", text))
def _book_stats(chapters: list[Chapter], media: MediaRegistry, renderer: Renderer, diag: Diagnostics) -> dict[str, int]:
def total(attr: str) -> int:
return sum(getattr(c.stats, attr) for c in chapters)
return {
"chapters": len(chapters),
"images": total("images"),
"svgs": total("svgs"),
"media_files": len(media.files),
"code_blocks": total("code_blocks"),
"code_blocks_unlabeled": total("code_blocks_unlabeled"),
"formulas": len(renderer.formulas),
"formulas_as_image": total("math_as_image"),
"formulas_as_mathml": total("math_as_mathml"),
"footnotes": len(renderer.book_notes),
"tables": total("tables"),
"tables_html": total("tables_html"),
"warnings": diag.count("warning"),
}
def _manifest(md: Any, chapters: list[Chapter], media: MediaRegistry, diag: Diagnostics) -> dict[str, Any]:
return {
"book": {"title": md.title, "subtitle": md.subtitle, "authors": md.authors, "language": md.language,
"identifier": md.identifier, "isbn": md.isbn, "publisher": md.publisher},
"chapters": [
{
"number": c.number,
"title": c.title,
"file": c.filename,
"source_href": c.docs[0] if c.docs else None,
"source_hrefs": c.docs,
"types": c.types,
"anchors": c.anchors,
"word_count": c.stats.word_count,
"code_blocks": c.stats.code_blocks,
"images": c.stats.images + c.stats.svgs,
"math_blocks": c.stats.math_block,
"math_inline": c.stats.math_inline,
"footnotes": c.stats.footnotes,
"tables": c.stats.tables,
"warnings": c.warnings,
}
for c in chapters
],
"media": [m.as_dict() for m in media.files.values()],
"warnings": [d.as_dict() for d in diag.items if d.level != "info"],
}
def _report(input_path: Path, sha: str, generated_at: str, package: Any, opts: Options, chapters: list[Chapter],
media: MediaRegistry, renderer: Renderer, notes: Any, diag: Diagnostics, docs: list[Doc],
broken: list[dict[str, str]], skipped_formula_images: int) -> dict[str, Any]:
from collections import Counter
reps = Counter(f["representation"] for f in renderer.formulas)
origins = Counter(f["origin"] for f in renderer.formulas)
langs = Counter(c["language"] or "(keine)" for c in renderer.code_blocks)
special = [{"type": t, "title": c.title, "file": c.filename} for c in chapters for t in c.types
if t in ("glossary", "bibliography", "index", "index-persons", "endnotes", "appendix")]
errors = [d.as_dict() for d in diag.items if d.level == "error"]
warnings = [d.as_dict() for d in diag.items if d.level == "warning"]
status = "success" if not errors and not warnings else ("success-with-errors" if errors else "success-with-warnings")
return {
"tool": TOOL_NAME,
"version": __version__,
"generated_at": generated_at,
"status": status,
"lossless": False if (warnings or errors) else None,
"input": {"file": str(input_path), "name": input_path.name, "sha256": sha,
"size": input_path.stat().st_size},
"epub": {"version": package.version, "opf": package.opf_path, "nav": package.nav_path,
"ncx": package.ncx_path, "spine_items": len(package.spine),
"manifest_items": len(package.manifest)},
"options": opts.public(),
"metadata": asdict(package.metadata),
"chapters": [
{"number": c.number, "title": c.title, "file": c.filename, "source_hrefs": c.docs, "types": c.types,
"stats": c.stats.as_dict(), "warnings": c.warnings}
for c in chapters
],
"skipped_documents": [{"href": d.path, "reason": d.skipped} for d in docs if d.skipped],
"media": {"count": len(media.files), "files": [m.as_dict() for m in media.files.values()],
"formula_images_replaced_by_tex": skipped_formula_images},
"code_blocks": {"count": len(renderer.code_blocks), "languages": dict(langs),
"items": renderer.code_blocks},
"formulas": {"count": len(renderer.formulas), "representations": dict(reps), "origins": dict(origins),
"items": renderer.formulas},
"footnotes": {"mode": opts.footnotes, "detected": len(notes.notes),
"referenced": sum(1 for n in notes.notes.values() if n.ref_count),
"rendered": len(renderer.book_notes)},
"special_sections": special,
"text_cleanup": {"invisible_chars_removed": renderer.invisible_removed},
"css": {"rules": len(renderer.css.rules) if renderer.css else 0, "applied": dict(renderer.css_applied),
"hidden_elements": renderer.hidden_elements},
"layout": {"scene_breaks": renderer.scene_breaks, "poems": renderer.poems, "boxes": renderer.boxes,
"page_markers": renderer.page_markers},
"warnings": warnings,
"errors": errors,
"infos": [d.as_dict() for d in diag.items if d.level == "info"],
"unresolved_links": diag.unresolved_links,
"unsupported_elements": dict(diag.unsupported),
"output_link_check": {"broken": broken, "broken_count": len(broken)},
}
def _write_output(reader: EpubReader, input_path: Path, output_dir: Path, opts: Options,
files: dict[str, str], media: MediaRegistry) -> None:
parent = output_dir.resolve().parent
try:
parent.mkdir(parents=True, exist_ok=True)
tmp = Path(tempfile.mkdtemp(prefix=".epub2md-", dir=parent))
except OSError as exc:
raise InputError(f"Ausgabeverzeichnis nicht beschreibbar: {parent} ({exc.strerror or exc})") from exc
try:
for rel, content in files.items():
_write_file(tmp, rel, content.encode("utf-8"))
if opts.extract_media:
for mf in media.files.values():
data = mf.data if mf.data is not None else reader.read(mf.source)
_write_file(tmp, mf.output, data)
if opts.copy_original:
dest = tmp / "source" / input_path.name
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(input_path, dest)
if output_dir.exists():
if any(output_dir.iterdir()):
shutil.rmtree(output_dir) # safety checks were done in check_paths()
else:
output_dir.rmdir()
os.replace(tmp, output_dir)
except OSError as exc:
shutil.rmtree(tmp, ignore_errors=True)
raise InputError(f"Schreibfehler im Ziel {output_dir}: {exc.strerror or exc}") from exc
except BaseException:
shutil.rmtree(tmp, ignore_errors=True)
raise
def _write_file(root: Path, rel: str, data: bytes) -> None:
target = (root / rel).resolve()
if not is_inside(target, root):
raise ContainerError(f"Ausgabepfad außerhalb des Zielverzeichnisses verweigert: {rel}")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
def print_summary(result: Result, opts: Options) -> None:
r = result.report
n_warn = len(r["warnings"])
n_err = len(r["errors"])
action = "Analyse (--dry-run, nichts geschrieben)" if opts.dry_run else f"Ausgabe: {result.output_dir}"
print(f"{TOOL_NAME}: {len(r['chapters'])} Kapitel, {r['media']['count']} Mediendateien, "
f"{r['code_blocks']['count']} Codeblöcke, {r['formulas']['count']} Formeln, "
f"{r['footnotes']['rendered']} Anmerkungen, {n_warn} Warnungen, {n_err} Fehler")
print(action)
if opts.dry_run:
for c in r["chapters"]:
print(f" {c['file']} ← {', '.join(c['source_hrefs'][:2])}{' …' if len(c['source_hrefs']) > 2 else ''}")
if opts.report:
print("Hinweis: --report wird bei --dry-run nicht geschrieben.", file=sys.stderr)