Files
buchwerk/backend/montage.py
2026-10-05 16:06:06 +02:00

198 lines
9.0 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.
"""Buch aus den Kapiteldateien zusammensetzen: Guide-JSON, README, Gesamt-Markdown, PDF."""
import html
import logging
import re
import shutil
import subprocess
from . import config, gliederung, state
log = logging.getLogger("buchwerk")
ABSCHNITT_RE = re.compile(r"^## (\d+\.\d+) (.+?)\s*$", re.M)
def _woerter(text: str) -> int:
return len(re.sub(r"```.*?```", "", text, flags=re.S).split())
def _kapitel_lesen(v, k: dict) -> dict:
p = v / "buch" / k["datei"]
aus = {"id": f"{k['n']:02d}", "ord": k["n"], "titel": k["titel"], "teil": k["teil"], "datei": f"buch/{k['datei']}",
"status": "fehlt", "intro": "", "abschnitte": [], "woerter": 0, "minuten": 0,
"berichte": {"autor": state.bericht_lesen(v.name, f"kapitel-{k['n']:02d}"),
"pruefer": state.bericht_lesen(v.name, f"pruefung-{k['n']:02d}")}}
if not p.exists():
return aus
text = p.read_text()
text = re.sub(r"^# .*\n", "", text, count=1)
treffer = list(ABSCHNITT_RE.finditer(text))
aus["intro"] = text[:treffer[0].start()].strip() if treffer else text.strip()
for i, m in enumerate(treffer):
ende = treffer[i + 1].start() if i + 1 < len(treffer) else len(text)
inhalt = text[m.end():ende].strip()
w = _woerter(inhalt)
aus["abschnitte"].append({"id": f"{k['n']:02d}-{m.group(1).split('.')[1]}", "nr": m.group(1),
"titel": m.group(2), "text": inhalt, "woerter": w,
"minuten": round(w / config.LESE_WPM)})
aus["woerter"] = _woerter(text)
aus["minuten"] = round(aus["woerter"] / config.LESE_WPM)
st = state.zustand(v.name)
if state.stufe(v.name, "konsistenz")["status"] == "fertig":
aus["status"] = "konsistent"
elif aus["berichte"]["pruefer"]:
aus["status"] = "geprueft"
else:
aus["status"] = "geschrieben"
return aus
def guide(name: str) -> dict:
v = state.verzeichnis(name)
t = state.thema(name) or {}
d = state.deutung(name) or {}
gp = v / "buch" / "GLIEDERUNG.md"
g = gliederung.parsen(gp.read_text()) if gp.exists() else {"titel": "", "untertitel": "", "kapitel": []}
kapitel = [_kapitel_lesen(v, k) for k in g["kapitel"]]
vorwort = ""
vp = v / "buch" / "00-vorwort.md"
if vp.exists():
vorwort = re.sub(r"^# .*\n", "", vp.read_text(), count=1).strip()
qp = v / "quellen" / "QUELLEN.md"
woerter = sum(k["woerter"] for k in kapitel)
return {"titel": d.get("titel") or g["titel"] or t.get("titel") or name,
"untertitel": d.get("untertitel") or g["untertitel"] or "",
"status": state.zustand(name)["status"], "vorwort": vorwort, "kapitel": kapitel,
"woerter": woerter, "minuten": round(woerter / config.LESE_WPM),
"gliederung": gp.read_text() if gp.exists() else "",
"quellen": qp.read_text() if qp.exists() else "",
"pdf": (v / "buch" / "buch.pdf").exists()}
def markdown(name: str) -> str:
g = guide(name)
v = state.verzeichnis(name)
zeilen = [f"# {g['titel']}", ""]
if g["untertitel"]:
zeilen += [f"*{g['untertitel']}*", ""]
if g["vorwort"]:
zeilen += ["## Vorwort", "", g["vorwort"], ""]
teil = None
for k in g["kapitel"]:
p = v / "buch" / k["datei"].split("/", 1)[1]
if not p.exists():
continue
if k["teil"] and k["teil"] != teil:
teil = k["teil"]
zeilen += [f"# {teil}", ""]
zeilen += [p.read_text().strip(), ""]
return "\n".join(zeilen)
def readme(name: str) -> str:
g = guide(name)
out = [f"# {g['titel']}", ""]
if g["untertitel"]:
out += [f"*{g['untertitel']}*", ""]
out += ["PDF: [buch.pdf](buch.pdf) · Gesamttext: [buch.md](buch.md)", "", "## Inhalt", "", "[Vorwort](00-vorwort.md)", ""]
teil = None
for k in g["kapitel"]:
if k["teil"] and k["teil"] != teil:
teil = k["teil"]
out += [f"### {teil}", ""]
d = k["datei"].split("/", 1)[1]
out += [f"**[Kapitel {k['ord']} – {k['titel']}]({d})** ",
" · ".join(f"{a['nr']} {a['titel']}" for a in k["abschnitte"]), ""]
out += [f"*{len(g['kapitel'])} Kapitel, rund {round(g['woerter'], -3):,} Wörter.*".replace(",", "."), ""]
return "\n".join(out)
# ---- PDF ----------------------------------------------------------------------
CSS = """
@page{size:A5;margin:17mm 14mm 19mm 14mm;@bottom-center{content:counter(page);font:9pt "DejaVu Serif"}}
@page:first{@bottom-center{content:none}}
body{font-family:"DejaVu Serif",serif;font-size:9.3pt;line-height:1.42;text-align:left;hyphens:auto}
h1.chap{font-size:16pt;margin:0 0 9mm;page-break-before:always}
h2{font-size:11.8pt;margin:7mm 0 2mm;page-break-after:avoid}
h3{font-size:10pt;margin:4mm 0 1mm;page-break-after:avoid}
p{margin:0 0 2.2mm;orphans:2;widows:2}
code{font-family:"DejaVu Sans Mono",monospace;font-size:7.9pt;hyphens:none}
.codehilite,pre{background:#f5f5f2!important;border:0.4pt solid #ccc;padding:2mm;margin:2mm 0 3mm;border-radius:1mm}
pre{white-space:pre-wrap;word-wrap:break-word;font-size:7.3pt;line-height:1.3;margin:0;border:none;padding:0}
pre.diagramm{white-space:pre;font-size:6.8pt;line-height:1.15;page-break-inside:avoid;width:max-content;max-width:100%;margin:2mm auto 3mm;padding:2mm;border:0.4pt solid #ccc;border-radius:1mm;background:#f5f5f2}
table{border-collapse:collapse;font-size:8pt;margin:3mm 0;width:100%}
td,th{border:0.4pt solid #888;padding:1.2mm;vertical-align:top;text-align:left}
ul,ol{padding-left:5mm} li{margin-bottom:1.2mm}
.title{page-break-after:always;text-align:center;padding-top:50mm}
.title h1{font-size:28pt;margin:0}.title p{font-size:12pt}
.part{page-break-before:always;page-break-after:always;text-align:center;padding-top:60mm}
.parttitle{font-size:19pt}
.toc{page-break-before:always}.toc ul{list-style:none;padding:0}
.tpart{font-weight:bold;margin-top:4mm}.tch a{color:black;text-decoration:none}
.tch a::after{content:leader(".") target-counter(attr(href),page)}
a{color:black}
"""
DIAGRAMM_RE = re.compile(r"^```diagramm[ \t]*\n(.*?)^```[ \t]*$", re.S | re.M)
def _vorbereiten(text: str) -> str:
"""Textdiagramme als unformatierte <pre> durchreichen; gdshader mit dem GLSL-Lexer hervorheben."""
text = DIAGRAMM_RE.sub(lambda m: '<pre class="diagramm">' + html.escape(m.group(1).rstrip("\n")) + "</pre>", text)
return re.sub(r"^```gdshader[ \t]*$", "```glsl", text, flags=re.M)
def pdf(name: str) -> bool:
exe = shutil.which("weasyprint")
if not exe:
log.warning("weasyprint fehlt – kein PDF")
return False
import markdown as md_mod
g = guide(name)
v = state.verzeichnis(name)
md = md_mod.Markdown(extensions=["tables", "sane_lists", "fenced_code", "codehilite"],
extension_configs={"codehilite": {"noclasses": True, "pygments_style": "friendly", "guess_lang": False}})
body, toc, teil = [], [], None
if g["vorwort"]:
body.append(f'<section class="chapter"><h1 id="k0" class="chap">Vorwort</h1>{md.convert(g["vorwort"])}</section>')
toc.append('<li class="tch"><a href="#k0">Vorwort</a></li>')
md.reset()
for k in g["kapitel"]:
p = v / "buch" / k["datei"].split("/", 1)[1]
if not p.exists():
continue
if k["teil"] and k["teil"] != teil:
teil = k["teil"]
tt = html.escape(teil).replace(" – ", "<br>", 1)
body.append(f'<section class="part"><h1 class="parttitle">{tt}</h1></section>')
toc.append(f'<li class="tpart">{html.escape(teil)}</li>')
aid = f"k{k['ord']}"
t = f"Kapitel {k['ord']} – {k['titel']}"
toc.append(f'<li class="tch"><a href="#{aid}">{html.escape(t)}</a></li>')
h = md.convert(_vorbereiten(p.read_text())).replace("<h1>", f'<h1 id="{aid}" class="chap">', 1)
h = h.replace("border:1px solid #FF0000", "") # Pygments markiert @-Annotationen in GDScript als Fehler
h = h.replace("border: 1px solid #FF0000", "border: none")
body.append(f'<section class="chapter">{h}</section>')
md.reset()
doc = (f'<!doctype html><html lang="de"><head><meta charset="utf-8"><title>{html.escape(g["titel"])}</title>'
f'<style>{CSS}</style></head><body><div class="title"><h1>{html.escape(g["titel"])}</h1>'
f'<p>{html.escape(g["untertitel"])}</p></div><div class="toc"><h1 style="font-size:16pt">Inhalt</h1>'
f'<ul>{"".join(toc)}</ul></div>{"".join(body)}</body></html>')
hp, pp = v / "arbeit" / "buch.html", v / "buch" / "buch.pdf"
hp.parent.mkdir(exist_ok=True)
hp.write_text(doc)
r = subprocess.run([exe, str(hp), str(pp)], capture_output=True, text=True, timeout=900)
if r.returncode != 0:
log.error("weasyprint: %s", r.stderr[-500:])
return False
return True
def bauen(name: str) -> bool:
"""README, Gesamt-Markdown, PDF. Läuft in einem Thread – keine Zustandsänderungen hier."""
v = state.verzeichnis(name)
(v / "buch" / "README.md").write_text(readme(name))
(v / "buch" / "buch.md").write_text(markdown(name))
return pdf(name)