Statische Website-Assets in die Quelle web_04/ holen

site.css, site.js, icons.svg, plotly.min.js und die 22 KaTeX-Dateien lagen in
OR_HTML_04/, obwohl kein Skript sie je geschrieben hat. Die Falle daran: Wer
eine CSS-Regel suchte, suchte sie in den Quellen und fand nichts - genau das
ist beim Einruecken des ZIP-Menuepunkts passiert. Und wer OR_HTML_04/ geloescht
und neu gebaut haette, haette eine Website ohne Stil, ohne Symbole und ohne
Formelsatz bekommen.

Jetzt liegen sie in web_04/, dessen Aufbau (assets/, katex/) das Ziel spiegelt.
spiegle_statische_assets() kopiert sie bei JEDEM Lauf.
kopiere_plotly_bibliothek() fuellt die Quelle statt des Ziels.
_lade_icon_sprite_inline() und die beiden KaTeX-Pruefungen lesen die Quelle,
haengen also nicht mehr vom eigenen Ergebnis ab.

Zwei Waechter, weil genau diese Verwechslung schon vorgekommen ist:

* Wurde die Kopie in OR_HTML_04/ von Hand geaendert (Inhalt weicht ab UND
  Zeitstempel ist neuer), bricht der Bau ab und nennt den mv-Befehl, der es
  richtigstellt - kein stilles Ueberschreiben.
* pruefe_assets() liest die href=/src=-Literale aus dem Quelltext des
  Bauskripts und verlangt fuer jedes einen Erzeuger: entweder web_04/ oder die
  Liste ERZEUGTE_ASSETS (highlight.css, search-index.js, programme.js).

Probe: rm -rf OR_HTML_04 && --html baut alle 213 Dateien wieder auf,
Dateiliste identisch zur Sicherung.

Fund dabei: Das Stichwortverzeichnis war nicht byte-reproduzierbar

ziel_links() sortierte nach (seite, kontext) - und kontext ist der
Kapiteltitel, fuer alle Marken einer Datei also derselbe. Bei zwei Fundstellen
im selben Kapitel war der Schluessel gleich, und die Reihenfolge fiel auf die
eines set() zurueck, also auf den je Prozess zufaelligen PYTHONHASHSEED. Zwei
Laeufe erzeugten unterschiedliche Bytes ohne Quellaenderung.

Derselbe Fehler war auch sichtbar: vier {idx:Branch-and-Bound} in Kapitel 6
ergaben vier optisch identische Links nebeneinander; zehn Registereintraege
waren betroffen. Behoben durch einen Link je Kapitel (erste Fundstelle in
Dokumentreihenfolge, dict statt set) - das macht die Sortierung zugleich
eindeutig.

Gegenprobe: drei Laeufe mit PYTHONHASHSEED=random liefern dieselbe Pruefsumme.
219 Fachbegriffe und 328 Indexmarken unveraendert.

Nachtrag zum vorigen Commit: highlight.css gehoert NICHT zu den Handdateien -
erzeuge_highlight_css() erzeugt sie aus pandoc --print-highlight-style. Die
Notiz in PROGRESS.md ist korrigiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dschlueter 2026-09-08 16:24:56 +02:00
commit 567bfa799a
30 changed files with 4686 additions and 22 deletions

View file

@ -32,6 +32,7 @@ import functools
import json
import os
import re
import shutil
import subprocess
import sys
import zipfile
@ -71,6 +72,19 @@ BILDER_VERZ = os.path.join(BASIS, BILDER_NAME)
HTML_NAME = f"OR_HTML_{VERSION}"
HTML_VERZ = os.path.join(BASIS, HTML_NAME)
# Statische Bestandteile der Website, die kein Skript erzeugt: das von Hand
# gepflegte Stylesheet und Skript, die Symbolsammlung, die mitgelieferte
# Plotly- und KaTeX-Bibliothek. Der Aufbau von web_04/ spiegelt das Ziel
# (assets/, katex/), damit spiegle_statische_assets() ein stumpfer
# Baumdurchlauf bleibt.
#
# Sie lagen frueher in OR_HTML_04/ selbst - also mitten im erzeugten
# Verzeichnis. Das war eine Falle: Wer eine CSS-Regel suchte, suchte sie in den
# Quellen und fand nichts; wer OR_HTML_04/ loeschte und neu baute, bekam eine
# Website ohne Stil.
WEB_NAME = f"web_{VERSION}"
WEB_VERZ = os.path.join(BASIS, WEB_NAME)
# Generierte Jupyter-Notebooks (eines je Kapitel).
NOTEBOOK_NAME = f"Notebooks_{VERSION}"
NOTEBOOK_VERZ = os.path.join(BASIS, NOTEBOOK_NAME)
@ -374,6 +388,49 @@ def pruefe_dateien() -> list[str]:
f"folgt aber '{soll}' - die Lesekette ueberspringt eine Datei.")
fehlend.append(name)
fehlend.extend(pruefe_assets())
return fehlend
# Dateien, die der Bau selbst in OR_HTML_04/assets/ erzeugt. Alles andere, was
# der Seitenkopf einbindet, muss aus der Quelle web_04/ kommen.
ERZEUGTE_ASSETS = {
"assets/highlight.css", # erzeuge_highlight_css() (pandoc)
"assets/search-index.js", # erzeuge_suchindex()
"assets/programme.js", # baue_programme_seite()
}
def pruefe_assets() -> list[str]:
"""Jede Datei, die der Seitenkopf per href=/src= einbindet, braucht einen
Erzeuger: entweder ein Skript (ERZEUGTE_ASSETS) oder die Quelle web_04/.
Ohne diese Pruefung entsteht beim naechsten Eintrag im Seitenkopf wieder
eine Datei ohne Herkunft - genau der Zustand, aus dem site.css, site.js,
icons.svg und katex/ gerade befreit wurden."""
# Gelesen wird der Quelltext dieses Skripts, nicht eine gerenderte Seite:
# Die Verweise stehen als Literale in mehreren Bausteinen (Seitenkopf,
# KATEX_KOPF, PLOTLY_SKRIPT), und eine Textsuche findet sie alle, ohne
# dass dafuer eine Seite gebaut werden muss.
with open(os.path.abspath(__file__), encoding="utf-8") as f:
eigener_quelltext = f.read()
verweise = sorted(set(re.findall(r'(?:href|src)="((?:assets|katex)/[^"]+)"',
eigener_quelltext)))
fehlend = []
for rel in verweise:
if rel in ERZEUGTE_ASSETS:
continue
if os.path.exists(os.path.join(WEB_VERZ, rel)):
continue
print(f"FEHLER: Der Seitenkopf bindet '{rel}' ein, aber niemand erzeugt "
f"die Datei - sie fehlt in {WEB_NAME}/ und in ERZEUGTE_ASSETS.")
fehlend.append(rel)
# icons.svg wird nicht verlinkt, sondern inline eingebettet (ICON_SPRITE_INLINE)
# und taucht deshalb nicht in `verweise` auf - hier separat geprueft.
if not os.path.exists(os.path.join(WEB_VERZ, "assets", "icons.svg")):
print(f"FEHLER: {WEB_NAME}/assets/icons.svg fehlt - die Seiten haetten "
"keine Symbole.")
fehlend.append("assets/icons.svg")
return fehlend
@ -1364,7 +1421,7 @@ def baue_gesamtdokument(html_verz: str) -> None:
"--standalone", "--wrap=none", "--toc", "--toc-depth=3",
"-M", f"pagetitle={SITE_TITEL} - Version {VERSION}",
"-M", "lang=de", "--highlight-style=breezedark", "-o", ziel]
if os.path.exists(os.path.join(html_verz, "katex")):
if os.path.exists(os.path.join(WEB_VERZ, "katex")):
befehl.insert(-2, "--katex=katex/")
if os.path.exists(stil):
befehl.extend(["-H", stil])
@ -1431,10 +1488,14 @@ def icon(name: str, klasse: str = "icon") -> str:
@functools.lru_cache(maxsize=1)
def _lade_icon_sprite_inline() -> str:
"""Liest assets/icons.svg und liefert nur die <symbol>-Definitionen
"""Liest web_04/assets/icons.svg und liefert nur die <symbol>-Definitionen
(ohne die aeussere <svg>-Huelle) zum Einbetten in jede Seite - siehe
icon()."""
pfad = os.path.join(HTML_VERZ, "assets", "icons.svg")
icon().
Gelesen wird die QUELLE, nicht die Kopie in OR_HTML_04/: Sonst haenge der
Bau von seinem eigenen Ergebnis ab und schluege bei leerem Zielverzeichnis
fehl."""
pfad = os.path.join(WEB_VERZ, "assets", "icons.svg")
with open(pfad, encoding="utf-8") as f:
inhalt = f.read()
innen = re.search(r"<svg[^>]*>(.*)</svg>", inhalt, re.DOTALL)
@ -1490,6 +1551,50 @@ def spiegle_bilder(html_verz: str) -> None:
print(f"Bilder gespiegelt: {kopiert} Datei(en) nach {HTML_NAME}/{BILDER_NAME}/")
def spiegle_statische_assets(html_verz: str) -> None:
"""Kopiert web_04/ nach OR_HTML_04/ - bei JEDEM Lauf, nicht nur wenn das
Ziel fehlt. Erst dadurch ist OR_HTML_04/ vollstaendig wiederherstellbar:
loeschen, neu bauen, fertig.
Der Waechter: Weicht die Zieldatei von der Quelle ab und ist sie neuer,
hat jemand die Kopie bearbeitet statt des Originals. Dann bricht der Bau
ab, statt die Arbeit still zu ueberschreiben - derselbe Fehler ist in
diesem Projekt schon vorgekommen."""
if not os.path.isdir(WEB_VERZ):
print(f"WARNUNG: Quellverzeichnis der Website-Assets fehlt: {WEB_VERZ}")
return
kopiert = 0
for wurzel, verzeichnisse, dateien in os.walk(WEB_VERZ):
verzeichnisse[:] = [v for v in verzeichnisse if not v.startswith(".")]
rel = os.path.relpath(wurzel, WEB_VERZ)
ziel_verz = html_verz if rel == "." else os.path.join(html_verz, rel)
os.makedirs(ziel_verz, exist_ok=True)
for name in sorted(dateien):
quelle = os.path.join(wurzel, name)
ziel = os.path.join(ziel_verz, name)
with open(quelle, "rb") as f:
daten = f.read()
if os.path.exists(ziel):
with open(ziel, "rb") as f:
vorhanden = f.read()
if vorhanden == daten:
continue
if os.path.getmtime(ziel) > os.path.getmtime(quelle):
rel_ziel = os.path.relpath(ziel, BASIS)
rel_quelle = os.path.relpath(quelle, BASIS)
raise SystemExit(
f"FEHLER: {rel_ziel} wurde geaendert, ist aber eine Kopie.\n"
f" Die Aenderung gehoert in die Quelle:\n"
f" mv {rel_ziel} {rel_quelle}")
with open(ziel, "wb") as f:
f.write(daten)
shutil.copystat(quelle, ziel)
kopiert += 1
gesamt = sum(len(d) for _, _, d in os.walk(WEB_VERZ))
print(f"Statische Assets: {gesamt} Datei(en) aus {WEB_NAME}/, "
f"davon {kopiert} neu geschrieben.")
def kopiere_pdf(html_verz: str) -> bool:
"""Kopiert das gebaute PDF nach OR_HTML_04/, damit der Download-Link in der
Sidebar funktioniert, sobald der Ordner auf einen Webserver kopiert wird.
@ -1651,7 +1756,7 @@ PANDOC_HTML_BASIS = ["-f", "markdown+autolink_bare_uris", "-t", "html5",
def _pandoc_fragment(text: str, html_verz: str) -> str:
befehl = ["pandoc"] + PANDOC_HTML_BASIS
if os.path.exists(os.path.join(html_verz, "katex")):
if os.path.exists(os.path.join(WEB_VERZ, "katex")):
befehl.append("--katex=katex/")
ergebnis = subprocess.run(befehl, cwd=BASIS, input=text,
capture_output=True, text=True, check=True)
@ -1725,14 +1830,20 @@ def resolve_plotly(text: str, fuer_html: bool) -> tuple[str, bool]:
return PLOTLY_RE.sub(ersetze, text), eingebettet
def kopiere_plotly_bibliothek(html_verz: str) -> bool:
"""Kopiert die von plotly mitgelieferte plotly.min.js nach assets/.
def kopiere_plotly_bibliothek() -> bool:
"""Holt die von plotly mitgelieferte plotly.min.js in die QUELLE
web_04/assets/ - von dort verteilt spiegle_statische_assets() sie.
Bewusst lokal statt per CDN: Die Website soll komplett offline
funktionieren - genau wie die bereits lokal eingebundenen KaTeX-Assets."""
ziel = os.path.join(html_verz, "assets", "plotly.min.js")
funktionieren - genau wie die bereits lokal eingebundenen KaTeX-Assets.
Nur wenn die Datei fehlt: Sie ist mit 4,8 MB versioniert und aendert sich
nur, wenn plotly aktualisiert wird. Wer sie auffrischen will, loescht sie
und baut neu."""
ziel = os.path.join(WEB_VERZ, "assets", "plotly.min.js")
if os.path.exists(ziel):
return True
os.makedirs(os.path.dirname(ziel), exist_ok=True)
try:
import plotly
quelle = os.path.join(os.path.dirname(plotly.__file__),
@ -2265,18 +2376,33 @@ def baue_stichwortverzeichnis(html_verz: str, struktur: list[dict],
haupt_name, _, unter_name = eintrag["roh"].partition("!")
haupt_name = haupt_name.rsplit("@", 1)[-1].strip()
ziel = (eintrag["seite"], eintrag["anker"], eintrag["kontext"])
knoten = haupt.setdefault(haupt_name, {"ziele": set(), "unter": {}})
knoten = haupt.setdefault(haupt_name, {"ziele": {}, "unter": {}})
if unter_name:
unter_name = unter_name.rsplit("@", 1)[-1].strip()
knoten["unter"].setdefault(unter_name, set()).add(ziel)
knoten["unter"].setdefault(unter_name, {})[ziel] = None
else:
knoten["ziele"].add(ziel)
knoten["ziele"][ziel] = None
def ziel_links(ziele: set) -> str:
eintraege = sorted(ziele, key=lambda z: (z[0], z[2]))
def ziel_links(ziele: dict) -> str:
# Ein Link je Kapitel, nicht je Fundstelle. 'kontext' ist der
# Kapiteltitel und damit fuer alle Marken einer Datei gleich - vier
# {idx:Branch-and-Bound} in Kapitel 6 ergaben vier optisch identische
# Links nebeneinander. Behalten wird die ERSTE Fundstelle; die Marken
# kommen in Dokumentreihenfolge an, weil finditer() den Text linear
# durchlaeuft und dict die Einfuegereihenfolge bewahrt.
#
# Die Deduplizierung macht die anschliessende Sortierung zugleich
# eindeutig. Vorher war sie es nicht: Der Schluessel (seite, kontext)
# war fuer zwei Fundstellen desselben Kapitels identisch, und die
# Reihenfolge fiel auf die eines set() zurueck - also auf den je Prozess
# zufaelligen PYTHONHASHSEED. Zwei Laeufe erzeugten unterschiedliche
# Bytes, ohne dass sich eine Quelle geaendert haette.
je_kapitel: dict[tuple[str, str], str] = {}
for seite, anker, kontext in ziele:
je_kapitel.setdefault((seite, kontext), anker)
return ", ".join(
f'<a href="{seite}{"#" + anker if anker else ""}">{kontext}</a>'
for seite, anker, kontext in eintraege)
for (seite, kontext), anker in sorted(je_kapitel.items()))
namen = sorted(haupt.keys(), key=_sortierschluessel)
sprungleiste = " ".join(
@ -2346,12 +2472,13 @@ def baue_html_website() -> None:
os.makedirs(html_verz, exist_ok=True)
datei_seite, labels, label_seite, struktur, stichwortregister = baue_seiten_registry()
kopiere_plotly_bibliothek()
spiegle_statische_assets(html_verz)
spiegle_bilder(html_verz)
erzeuge_highlight_css(html_verz)
pdf_verfuegbar = kopiere_pdf(html_verz)
notizbuecher = baue_notebooks(struktur)
spiegle_notebooks(html_verz, notizbuecher)
kopiere_plotly_bibliothek(html_verz)
suchindex: list[dict] = []
baue_kapitel_seiten(html_verz, datei_seite, labels, label_seite, struktur,
suchindex, pdf_verfuegbar, notizbuecher)