epub2md/epub2md/writer.py
dschlueter 99c3c609ca feat: Inhaltsverzeichnis nach Cover, Titelseite und Impressum (pandoc/book.lua)
Pandocs --toc setzt Inhaltsverzeichnis und Titelkopf immer vor den
Buchtext, also vor Cover, Titelseite und Impressum.

- epub2md_merge.py: Kapiteltypen aus manifest.json; Platzhalter
  <div id="epub2md-toc"> hinter dem Vorspann und, ohne eigene Titelseite,
  <div id="epub2md-titlepage"> hinter dem Cover; --toc-Liste an derselben Stelle
- pandoc/book.lua (HTML/weasyprint, statt --toc): baut dort das
  Inhaltsverzeichnis, lässt Pandocs Titelkopf bei eigener Titelseite weg,
  Cover ohne Bildunterschrift, Kapitelanker → div.chapter-start;
  andere Formate: nur Platzhalter entfernen
- print.css: jedes Kapitel (auch Cover/Titelseite/Impressum) und das
  Verzeichnis auf neuer Seite, Verzeichnis mit Punktlinie und Seitenzahl
- Buch-README, Bedienungsanleitung, README: Befehle mit book.lua

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

211 lines
9.9 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.

"""Builders for README, metadata.yaml, manifest.json, report and diagnostics files."""
from __future__ import annotations
import json
import posixpath
import shlex
from collections import Counter
from pathlib import Path
from typing import Any
from . import TOOL_NAME, __version__
from .diagnostics import Diagnostics
from .html_converter import md_escape
from .models import BookMetadata, Chapter, NavEntry
from .references import LinkResolver
from .utils import rel_link, to_yaml, url_path
CALLOUT_FILTER = Path(__file__).resolve().parent.parent / "pandoc" / "callouts.lua"
PRINT_CSS = Path(__file__).resolve().parent.parent / "pandoc" / "print.css"
BOOK_FILTER = Path(__file__).resolve().parent.parent / "pandoc" / "book.lua"
SPECIAL_LABELS = {
"glossary": "Glossar",
"bibliography": "Literatur / Quellen",
"index": "Sachregister",
"index-persons": "Personenregister",
"endnotes": "Anmerkungen",
"appendix": "Anhang",
}
def chapter_frontmatter(ch: Chapter, md: BookMetadata, source_epub: str, opf_dir: str, warnings: int) -> str:
def rel(p: str) -> str:
return p[len(opf_dir) + 1 :] if opf_dir and p.startswith(opf_dir + "/") else p
data: dict[str, Any] = {
"book_title": md.title,
"book_author": md.authors,
"language": md.language or None,
"chapter_number": ch.number,
"chapter_title": ch.title,
"source_epub": source_epub,
"source_href": rel(ch.docs[0]) if ch.docs else None,
}
if len(ch.docs) > 1:
data["source_hrefs"] = [rel(d) for d in ch.docs]
data["epub_type"] = ch.types
data["conversion_status"] = "converted-with-warnings" if warnings else "converted"
return "---\n" + to_yaml(data) + "---\n\n"
def metadata_yaml(md: BookMetadata, source_name: str, sha256: str, epub_version: str, cover: str,
options: dict[str, Any], generated_at: str) -> str:
data: dict[str, Any] = {}
def put(key: str, value: Any) -> None:
if value not in ("", None, []):
data[key] = value
put("title", md.title)
put("subtitle", md.subtitle)
put("authors", md.authors)
put("contributors", md.contributors)
put("language", md.language)
put("publisher", md.publisher)
put("published", md.published)
put("identifier", md.identifier)
put("isbn", md.isbn)
data["source_file"] = source_name
data["source_sha256"] = sha256
put("epub_version", epub_version)
put("description", md.description)
put("rights", md.rights)
put("subjects", md.subjects)
put("cover_image", cover)
data["conversion"] = {"tool": TOOL_NAME, "version": __version__, "generated_at": generated_at, "options": options}
return to_yaml(data)
def _nav_lines(entries: list[NavEntry], resolver: LinkResolver, depth: int = 0) -> list[str]:
lines = []
for e in entries:
label = md_escape(e.label or "(ohne Titel)")
url = None
if e.path:
url, _ = resolver.resolve("README.md", e.path, ("#" + e.fragment) if e.fragment else "")
lines.append(" " * depth + (f"- [{label}]({url})" if url else f"- {label}"))
lines.extend(_nav_lines(e.children, resolver, depth + 1))
return lines
def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, nav: list[NavEntry],
resolver: LinkResolver, stats: dict[str, int], cover: str, epub_version: str, source_name: str,
command: list[str], output_dir: str = "OUTPUT_VERZEICHNIS", callouts: int = 0) -> str:
out = [f"# {md_escape(md.title or source_name)}"]
if md.subtitle:
out.append(f"*{md_escape(md.subtitle)}*")
if md.authors:
out.append("**" + md_escape(", ".join(md.authors)) + "**")
if cover:
out.append(f"![Cover]({url_path(cover)})")
rows = [("Sprache", md.language), ("Verlag", md.publisher), ("Veröffentlicht", md.published),
("Identifier", md.identifier), ("ISBN", md.isbn), ("EPUB-Version", epub_version),
("Quelldatei", source_name)]
table = ["| Feld | Wert |", "| --- | --- |"] + [
f"| {k} | {md_escape(v, table=True)} |" for k, v in rows if v
]
out.append("\n".join(table))
out.append("## Inhaltsverzeichnis")
lines = [f"{c.number}. [{md_escape(c.title)}]({url_path(c.filename)})"
+ (" *(Zusatzseite, nicht in linearer Lesereihenfolge)*" if "auxiliary" in c.types else "")
for c in chapters]
if endnotes is not None:
lines.append(f"{len(chapters) + 1}. [{md_escape(endnotes.title)}]({url_path(endnotes.filename)})")
out.append("\n".join(lines) if lines else "*(keine Kapitel)*")
specials = [(c, t) for c in chapters for t in c.types if t in SPECIAL_LABELS]
if specials or endnotes is not None:
out.append("## Besondere Abschnitte")
sl = [f"- {SPECIAL_LABELS[t]}: [{md_escape(c.title)}]({url_path(c.filename)})" for c, t in specials]
if endnotes is not None:
sl.append(f"- Anmerkungen (gesammelt): [{md_escape(endnotes.title)}]({url_path(endnotes.filename)})")
out.append("\n".join(sl))
if nav:
out.append("## Original-Inhaltsverzeichnis (NAV/NCX)")
out.append("\n".join(_nav_lines(nav, resolver)))
out.append("## Statistik")
labels = [("chapters", "Kapitel"), ("images", "Bilder"), ("svgs", "SVG-Grafiken"),
("media_files", "Extrahierte Mediendateien"), ("code_blocks", "Codeblöcke"),
("formulas", "Erkannte Formeln"), ("formulas_as_image", "Formeln als Bild/SVG belassen"),
("formulas_as_mathml", "Formeln als MathML belassen"), ("footnotes", "Fußnoten/Anmerkungen"),
("warnings", "Warnungen")]
out.append("\n".join(["| Kennzahl | Anzahl |", "| --- | ---: |"] + [f"| {lbl} | {stats.get(k, 0)} |" for k, lbl in labels]))
out.append("Details: [conversion-report.json](conversion-report.json), "
"[manifest.json](manifest.json), [Warnungen](diagnostics/warnings.md)")
stem = source_name[:-5] if source_name.lower().endswith(".epub") else source_name
math = " --mathml" if stats.get("formulas") else ""
lua = ""
if callouts and CALLOUT_FILTER.is_file(): # '> [!TIP]' boxes: Pandoc needs the filter to draw real boxes
lua = f" --lua-filter {shlex.quote(str(CALLOUT_FILTER))}"
# book.lua: table of contents after cover/title page/imprint instead of Pandoc's --toc at the very top
toc = f" --lua-filter {shlex.quote(str(BOOK_FILTER))}" if BOOK_FILTER.is_file() else " --toc"
commands = [
"# Gesamtdatei erzeugen (im selben Arbeitsverzeichnis wie die Konvertierung)",
f"python3 epub2md_merge.py {shlex.quote(output_dir)}",
"# danach in diesem Ordner:",
f"pandoc {shlex.quote(stem + '.md')} -s{math}{toc}{lua} -o {shlex.quote(stem + '.html')}",
f"pandoc {shlex.quote(stem + '.md')} --toc{lua} -o {shlex.quote(stem + '.epub')}",
"# PDF über HTML: Pandoc übernimmt dann auch HTML-Tabellen und interne Sprungziele",
f"pandoc {shlex.quote(stem + '.md')} -s{math}{lua} -o {shlex.quote(stem + '.html')} && "
f"pandoc {shlex.quote(stem + '.html')} --toc{lua} --pdf-engine=xelatex -o {shlex.quote(stem + '.pdf')}",
]
if PRINT_CSS.is_file():
commands += [
"# PDF ohne LaTeX mit weasyprint: Farben, Kästen und HTML-Stile bleiben erhalten",
f"pandoc {shlex.quote(stem + '.md')} -s{math}{toc}{lua} --css {shlex.quote(str(PRINT_CSS))} "
f"--pdf-engine=weasyprint -o {shlex.quote(stem + '.pdf')}",
]
out.append("## Weiterverarbeitung")
out.append("```bash\n" + "\n".join(commands) + "\n```")
if callouts:
noun = "Infokasten" if callouts == 1 else "Infokästen"
out.append(f"Das Buch enthält {callouts} {noun} (`> [!TIP]` …). Der Filter `callouts.lua` macht daraus "
"in HTML, EPUB und PDF gerahmte Kästen mit Titel; ohne ihn zeigt Pandoc „[!TIP]“ als Text.")
if stats.get("formulas"):
out.append("Das Buch enthält Formeln: Für HTML `--mathml` (oder `--mathjax`) verwenden, sonst belässt "
"Pandoc Matrizen und ähnliche Konstrukte als TeX-Text.")
out.append("## Reproduktion")
out.append("```bash\n" + " ".join(shlex.quote(c) for c in command) + "\n```")
return "\n\n".join(out) + "\n"
def warnings_md(diag: Diagnostics) -> str:
out = ["# Warnungen und Hinweise", ""]
levels = Counter(d.level for d in diag.items)
out.append(f"Fehler: {levels.get('error', 0)} · Warnungen: {levels.get('warning', 0)} · Hinweise: {levels.get('info', 0)}")
if not diag.items:
out += ["", "Keine Auffälligkeiten."]
return "\n".join(out) + "\n"
by_code: dict[str, list[Any]] = {}
for d in diag.items:
by_code.setdefault(f"{d.level}: {d.code}", []).append(d)
order = {"error": 0, "warning": 1, "info": 2}
for key in sorted(by_code, key=lambda k: (order.get(k.split(":")[0], 3), k)):
items = by_code[key]
out += ["", f"## {key} ({len(items)})", ""]
for d in items:
where = " · ".join(x for x in (d.source, d.chapter) if x)
times = f" (×{d.count})" if d.count > 1 else ""
out.append(f"- {md_escape(d.message)}{times}" + (f" (`{where}`)" if where else ""))
return "\n".join(out) + "\n"
def unsupported_md(diag: Diagnostics) -> str:
out = ["# Nicht direkt unterstützte Elemente", ""]
if not diag.unsupported:
out.append("Keine.")
else:
out.append("Inhalte dieser Elemente wurden als Text übernommen (mit `--keep-html` als HTML).")
out += ["", "| Element | Anzahl | Beispiel |", "| --- | ---: | --- |"]
for tag, n in diag.unsupported.most_common():
out.append(f"| `{tag}` | {n} | `{diag.unsupported_examples.get(tag, '')}` |")
return "\n".join(out) + "\n"
def to_json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, indent=2) + "\n"
def rel(from_file: str, to: str) -> str:
return rel_link(from_file, to) or posixpath.basename(to)