Jedes Kapitel endet mit „## Kern“. Die Montage sammelt alle Blöcke in buch/KERN.md, die API liefert sie unter kern.md. Stilregel, Kapitel- und Prüfprompt angepasst. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
212 lines
9.7 KiB
Python
212 lines
9.7 KiB
Python
"""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)
|
||
KERN_RE = re.compile(r"^## Kern[ \t]*\n(.*?)(?=^## |\Z)", re.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 kern(name: str) -> str:
|
||
"""Alle `## Kern`-Blöcke der Kapitel in einer Datei – zum Auffrischen eines gelesenen Buchs."""
|
||
g = guide(name); v = state.verzeichnis(name)
|
||
out = [f"# Kernerkenntnisse – {g['titel']}", ""]
|
||
for k in g["kapitel"]:
|
||
p = v / "buch" / k["datei"].split("/", 1)[1]
|
||
m = KERN_RE.search(p.read_text()) if p.exists() else None
|
||
if m:
|
||
out += [f"## Kapitel {k['ord']} – {k['titel']}", "", m.group(1).strip(), ""]
|
||
return "\n".join(out)
|
||
|
||
|
||
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) · Kernerkenntnisse: [KERN.md](KERN.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 = re.sub(r"border: ?1px solid #F(?:F0000|00)", "", h) # 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))
|
||
(v / "buch" / "KERN.md").write_text(kern(name))
|
||
return pdf(name)
|