epub2md/epub2md/writer.py
dschlueter 86aa93c12c feat: Buchanfang Cover → Titel → Impressum → Inhaltsverzeichnis für alle Bücher
- Kapitelerkennung: erstes Kapitel nur aus einem Bild = Cover ("[Titelinfo]",
  "Deckblatt"), Titel "Titel" = Titelseite
- epub2md_merge: Vorspann reicht bis vor den Hauptteil (Teil, Vorwort,
  Einleitung, nummerierter Titel, > 1000 Wörter), kurze Zwischenseiten
  (Auflage, Verlagsbrief, "Über dieses Buch") bleiben davor
- book.lua: Cover am Inhalt erkannt; LaTeX-Weg (Filter in beiden Schritten):
  \tableofcontents hinter dem Vorspann, \clearpage je Kapitel, kein \maketitle
  vor dem Cover, A4, seitengroßes Cover, DejaVu-Schriften und Mathe-Symbole
  für fehlende Glyphen (Mathebuch: >10.000 fehlende Zeichen → 0)
- Buch-README: bei Formeln LaTeX-Weg empfohlen, sonst weasyprint
- Alle sechs Beispielbücher geprüft: Seite 1 Cover, Verzeichnis nach dem Vorspann

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

214 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
book = f" --lua-filter {shlex.quote(str(BOOK_FILTER))}" if BOOK_FILTER.is_file() else ""
toc = book or " --toc"
md_, html_, pdf_ = (shlex.quote(stem + ext) for ext in (".md", ".html", ".pdf"))
latex = [
"# PDF mit LaTeX über HTML: Pandoc übernimmt dann auch HTML-Tabellen und interne Sprungziele",
f"pandoc {md_} -s{math}{book}{lua} -o {html_} && pandoc {html_}{toc}{lua} --pdf-engine=xelatex -o {pdf_}",
]
weasy = [
"# PDF mit weasyprint: Farben, Kästen und HTML-Stile bleiben erhalten" + ("" if math else " (empfohlen)"),
f"pandoc {md_} -s{math}{toc}{lua} --css {shlex.quote(str(PRINT_CSS))} --pdf-engine=weasyprint -o {pdf_}",
] if PRINT_CSS.is_file() else []
if math:
latex[0] += " (empfohlen: weasyprint kann keine Formeln setzen)"
commands = [
"# Gesamtdatei erzeugen (im selben Arbeitsverzeichnis wie die Konvertierung)",
f"python3 epub2md_merge.py {shlex.quote(output_dir)}",
"# danach in diesem Ordner:",
f"pandoc {md_} -s{math}{toc}{lua} -o {html_}",
f"pandoc {md_} --toc{lua} -o {shlex.quote(stem + '.epub')}",
] + (latex + weasy if math else weasy + latex)
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)