epub2md/epub2md/writer.py
dschlueter 109612db28 fix: restliche Befunde der unabhängigen QA (QA2-09 bis QA2-19)
- QA2-09: Formeln in HTML-Tabellen als MathML/Formelbild statt $…$; Inline-Noten
  dort als HTML-Verweis (Markdown wird in HTML-Blöcken nicht gerendert)
- QA2-10: PDF-Weg Markdown → HTML → PDF in README, Bedienungsanleitung und
  Buch-README. Beim direkten Weg Markdown → LaTeX verwirft Pandoc HTML-Tabellen
  und <a id>-Ziele. Geprüft: C-Buch 405 Seiten, 34 Tabellen, 728/755 Links.
- QA2-11: Hinweis code-language-hint bei einsprachigen Büchern
- QA2-12: --strict bricht bei nicht dekodierbarem XHTML ab (Exit 3);
  Hilfetext präzisiert
- QA2-13: verdächtig große Medien werden übersprungen (Fehler media-skipped,
  Platzhalter) statt die ganze Konvertierung abzubrechen
- QA2-14: Formelklasse am <img> selbst wird erkannt
- QA2-15: wiederholte Diagnosen werden gezählt (count, ×n); MathML in
  HTML-Tabellen zählt im Formelbericht mit
- QA2-16: Unicode-Buchstaben in Ankernamen bleiben erhalten
- QA2-17: keine leeren Links (#) auf den eigenen Kapitelanfang; keine
  namespaced Attribute (epub:type → type=) in HTML-Tabellen
- QA2-18: nicht definierte Entitäten werden gemeldet (undefined-entity)
- QA2-19: Bild-Platzhalter ohne Kursivschrift (benachbarte ergaben **)

Regressionstests: tests/test_qa2_open.py (11 Tests, vorher alle rot).

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

191 lines
8.5 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 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
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") -> 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 ""
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')} --toc -s{math} -o {shlex.quote(stem + '.html')}",
f"pandoc {shlex.quote(stem + '.md')} --toc -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} -o {shlex.quote(stem + '.html')} && "
f"pandoc {shlex.quote(stem + '.html')} --toc --pdf-engine=xelatex -o {shlex.quote(stem + '.pdf')}",
]
out.append("## Weiterverarbeitung")
out.append("```bash\n" + "\n".join(commands) + "\n```")
if math:
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)