This commit is contained in:
Team3
2026-09-27 21:36:33 +02:00
commit 498fe2220a
34 changed files with 3608 additions and 0 deletions

186
backend/montage.py Normal file
View File

@@ -0,0 +1,186 @@
"""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}
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}
"""
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(p.read_text()).replace("<h1>", f'<h1 id="{aid}" class="chap">', 1)
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)