Compare commits

..
Author SHA1 Message Date
dschlueter
d566cf8dac veroeffentliche_04.py: Upload mit rsync --delete und Nachpruefung
Der Anlass war messbar. Der Live-Stand vor dem Skript:

  /OR/                              200
  /OR/anhang-glossar-literatur.html 200   <- Leiche des vorigen Stands
  /OR/anhang-glossar.html           404
  /OR/notebooks.html                404
  /OR/Notebooks_04.zip              404
  /OR/Notebooks_04/                 403

scp -r ueberschreibt nur, es loescht nie - die alte Glossar-Seite bliebe
dauerhaft erreichbar. Das Zielverzeichnis vorher zu leeren waere die grobe
Antwort: Bricht die Uebertragung von 23 MB in 213 Dateien ab, ist die Website
tot. rsync --delete uebertraegt nur Geaendertes und entfernt genau das, was
verschwunden ist.

Der zweite Grund wiegt schwerer: Die Website hing wochenlang einen Bau
zurueck, ohne dass es auffiel. Vier Schritte:

1. Vorpruefung - ist eine Quelldatei neuer als die gebaute Website, Abbruch.
   Ebenso: PDF der Website byte-genau wie das gebaute, Verzeichnis-
   Weiterleitungen vorhanden (der Grund fuer den 403).
2. Probelauf - zaehlt Uebertragungen und Loeschungen. Mehr als 30 Loeschungen
   brechen ab; das ist der Schutz gegen einen falschen Zielpfad, mit dem
   --delete sonst ein fremdes Verzeichnis ausraeumt. --loeschgrenze hebt an.
3. Uebertragung (rsync -rltz --chmod=D755,F644 ueber ssh).
4. Nachpruefung - 14 Adressen per HTTP, Statuscode gegen Erwartung. Darunter
   die Probe, dass die entfernte Seite wirklich 404 liefert.

Anmeldung per SSH-Schluessel, nicht per Passwort in der .env: rsync und scp
haben keine Passwort-Option, ein Passwort braeuchte sshpass und staende
waehrend der Uebertragung in der Prozessliste. Die .env (in .gitignore,
Vorlage .env.beispiel) enthaelt nur Host, Benutzer, Zielpfad und URL.

Beim ersten Test lief die Pruefung in eine irrefuehrende Meldung ("kein rsync
installiert"), waehrend in Wahrheit schon die Anmeldung fehlschlug. Die
Pruefung unterscheidet die Faelle jetzt am ssh-Rueckgabewert 255 und nennt
ssh-copy-id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:28:22 +02:00
dschlueter
567bfa799a 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>
2026-09-08 16:24:56 +02:00
34 changed files with 5136 additions and 24 deletions

20
.env.beispiel Normal file
View file

@ -0,0 +1,20 @@
# Vorlage fuer .env - kopieren, ausfuellen, absichern:
#
# cp .env.beispiel .env && chmod 600 .env
#
# Gelesen von veroeffentliche_04.py. Hier steht KEIN Geheimnis: rsync und scp
# haben keine Passwort-Option, die Anmeldung laeuft ueber den SSH-Schluessel.
# Einmalig einrichten mit
#
# ssh-copy-id dschlueter@jamulix.de
#
# Danach fragt nichts mehr nach einem Passwort.
OR_HOST=jamulix.de
OR_BENUTZER=dschlueter
OR_ZIEL=/var/www/jamulix.de/html/OR/
OR_URL=https://jamulix.de/OR/
# Optional - nur setzen, wenn vom Ueblichen abweichend:
# OR_SSH_PORT=22
# OR_SSH_KEY=~/.ssh/id_ed25519

5
.gitignore vendored
View file

@ -22,3 +22,8 @@ svg-inkscape/
Operations_Research_mit_Python_Version_04_Programme/output/ Operations_Research_mit_Python_Version_04_Programme/output/
produktionsmix.xlsx produktionsmix.xlsx
produktionsmix_ergebnis.xlsx produktionsmix_ergebnis.xlsx
# Zugangsdaten fuers Veroeffentlichen (veroeffentliche_04.py). Enthaelt zwar
# kein Passwort - die Anmeldung laeuft ueber den SSH-Schluessel -, aber Host,
# Benutzer und Serverpfad gehen niemanden etwas an. Vorlage: .env.beispiel
.env

View file

@ -25,6 +25,9 @@ Dieses Verzeichnis ist die Wurzel; alle Skripte leiten ihre Pfade daraus ab
``` ```
Operations_Research_mit_Python_Version_04/ Quelle: 37 Kapiteldateien + Build-Skripte Operations_Research_mit_Python_Version_04/ Quelle: 37 Kapiteldateien + Build-Skripte
bilder_04/ Quelle: Diagramme + erzeuge_*.py-Generatoren bilder_04/ Quelle: Diagramme + erzeuge_*.py-Generatoren
web_04/ Quelle: site.css, site.js, icons.svg,
plotly.min.js, katex/ - die statischen
Bestandteile der Website
Operations_Research_mit_Python_Version_04.md generiert: Gesamtdokument Operations_Research_mit_Python_Version_04.md generiert: Gesamtdokument
Operations_Research_mit_Python_Version_04.pdf generiert: PDF (xelatex) Operations_Research_mit_Python_Version_04.pdf generiert: PDF (xelatex)
OR_HTML_04/ generiert: Mehrseiten-Website OR_HTML_04/ generiert: Mehrseiten-Website
@ -39,7 +42,11 @@ LICENSE, LICENSE-TEXT.md MIT fuer Code, CC BY-SA 4.0 fuer
pandoc-defaults-basis.yaml, pandoc/, pandoc-defaults-buch.yaml PDF-Konfiguration pandoc-defaults-basis.yaml, pandoc/, pandoc-defaults-buch.yaml PDF-Konfiguration
``` ```
Nur die als **Quelle** markierten Verzeichnisse werden von Hand bearbeitet. Nur die als **Quelle** markierten Verzeichnisse werden von Hand bearbeitet. Seit dem
Umzug nach `web_04/` stimmt dieser Satz auch: Vorher lagen `site.css`, `site.js`,
`icons.svg` und `katex/` mitten im erzeugten `OR_HTML_04/`. `--check` bewacht das jetzt
(`pruefe_assets()`), und der Bau bricht ab, wenn jemand die Kopie statt der Quelle
bearbeitet hat.
Dieses Verzeichnis ist seit dem 08.09.2026 ein **eigenes Git-Repository** (`main`). Das Dieses Verzeichnis ist seit dem 08.09.2026 ein **eigenes Git-Repository** (`main`). Das
übergeordnete `OR_mit_Python/` ist nur noch das Archiv der Historie bis zur Trennung und übergeordnete `OR_mit_Python/` ist nur noch das Archiv der Historie bis zur Trennung und

File diff suppressed because one or more lines are too long

View file

@ -32,6 +32,7 @@ import functools
import json import json
import os import os
import re import re
import shutil
import subprocess import subprocess
import sys import sys
import zipfile import zipfile
@ -71,6 +72,19 @@ BILDER_VERZ = os.path.join(BASIS, BILDER_NAME)
HTML_NAME = f"OR_HTML_{VERSION}" HTML_NAME = f"OR_HTML_{VERSION}"
HTML_VERZ = os.path.join(BASIS, HTML_NAME) 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). # Generierte Jupyter-Notebooks (eines je Kapitel).
NOTEBOOK_NAME = f"Notebooks_{VERSION}" NOTEBOOK_NAME = f"Notebooks_{VERSION}"
NOTEBOOK_VERZ = os.path.join(BASIS, NOTEBOOK_NAME) 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.") f"folgt aber '{soll}' - die Lesekette ueberspringt eine Datei.")
fehlend.append(name) 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 return fehlend
@ -1364,7 +1421,7 @@ def baue_gesamtdokument(html_verz: str) -> None:
"--standalone", "--wrap=none", "--toc", "--toc-depth=3", "--standalone", "--wrap=none", "--toc", "--toc-depth=3",
"-M", f"pagetitle={SITE_TITEL} - Version {VERSION}", "-M", f"pagetitle={SITE_TITEL} - Version {VERSION}",
"-M", "lang=de", "--highlight-style=breezedark", "-o", ziel] "-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/") befehl.insert(-2, "--katex=katex/")
if os.path.exists(stil): if os.path.exists(stil):
befehl.extend(["-H", stil]) befehl.extend(["-H", stil])
@ -1431,10 +1488,14 @@ def icon(name: str, klasse: str = "icon") -> str:
@functools.lru_cache(maxsize=1) @functools.lru_cache(maxsize=1)
def _lade_icon_sprite_inline() -> str: 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 (ohne die aeussere <svg>-Huelle) zum Einbetten in jede Seite - siehe
icon().""" icon().
pfad = os.path.join(HTML_VERZ, "assets", "icons.svg")
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: with open(pfad, encoding="utf-8") as f:
inhalt = f.read() inhalt = f.read()
innen = re.search(r"<svg[^>]*>(.*)</svg>", inhalt, re.DOTALL) 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}/") 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: def kopiere_pdf(html_verz: str) -> bool:
"""Kopiert das gebaute PDF nach OR_HTML_04/, damit der Download-Link in der """Kopiert das gebaute PDF nach OR_HTML_04/, damit der Download-Link in der
Sidebar funktioniert, sobald der Ordner auf einen Webserver kopiert wird. 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: def _pandoc_fragment(text: str, html_verz: str) -> str:
befehl = ["pandoc"] + PANDOC_HTML_BASIS 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/") befehl.append("--katex=katex/")
ergebnis = subprocess.run(befehl, cwd=BASIS, input=text, ergebnis = subprocess.run(befehl, cwd=BASIS, input=text,
capture_output=True, text=True, check=True) 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 return PLOTLY_RE.sub(ersetze, text), eingebettet
def kopiere_plotly_bibliothek(html_verz: str) -> bool: def kopiere_plotly_bibliothek() -> bool:
"""Kopiert die von plotly mitgelieferte plotly.min.js nach assets/. """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 Bewusst lokal statt per CDN: Die Website soll komplett offline
funktionieren - genau wie die bereits lokal eingebundenen KaTeX-Assets.""" funktionieren - genau wie die bereits lokal eingebundenen KaTeX-Assets.
ziel = os.path.join(html_verz, "assets", "plotly.min.js")
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): if os.path.exists(ziel):
return True return True
os.makedirs(os.path.dirname(ziel), exist_ok=True)
try: try:
import plotly import plotly
quelle = os.path.join(os.path.dirname(plotly.__file__), 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, _, unter_name = eintrag["roh"].partition("!")
haupt_name = haupt_name.rsplit("@", 1)[-1].strip() haupt_name = haupt_name.rsplit("@", 1)[-1].strip()
ziel = (eintrag["seite"], eintrag["anker"], eintrag["kontext"]) 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: if unter_name:
unter_name = unter_name.rsplit("@", 1)[-1].strip() unter_name = unter_name.rsplit("@", 1)[-1].strip()
knoten["unter"].setdefault(unter_name, set()).add(ziel) knoten["unter"].setdefault(unter_name, {})[ziel] = None
else: else:
knoten["ziele"].add(ziel) knoten["ziele"][ziel] = None
def ziel_links(ziele: set) -> str: def ziel_links(ziele: dict) -> str:
eintraege = sorted(ziele, key=lambda z: (z[0], z[2])) # 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( return ", ".join(
f'<a href="{seite}{"#" + anker if anker else ""}">{kontext}</a>' 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) namen = sorted(haupt.keys(), key=_sortierschluessel)
sprungleiste = " ".join( sprungleiste = " ".join(
@ -2346,12 +2472,13 @@ def baue_html_website() -> None:
os.makedirs(html_verz, exist_ok=True) os.makedirs(html_verz, exist_ok=True)
datei_seite, labels, label_seite, struktur, stichwortregister = baue_seiten_registry() datei_seite, labels, label_seite, struktur, stichwortregister = baue_seiten_registry()
kopiere_plotly_bibliothek()
spiegle_statische_assets(html_verz)
spiegle_bilder(html_verz) spiegle_bilder(html_verz)
erzeuge_highlight_css(html_verz) erzeuge_highlight_css(html_verz)
pdf_verfuegbar = kopiere_pdf(html_verz) pdf_verfuegbar = kopiere_pdf(html_verz)
notizbuecher = baue_notebooks(struktur) notizbuecher = baue_notebooks(struktur)
spiegle_notebooks(html_verz, notizbuecher) spiegle_notebooks(html_verz, notizbuecher)
kopiere_plotly_bibliothek(html_verz)
suchindex: list[dict] = [] suchindex: list[dict] = []
baue_kapitel_seiten(html_verz, datei_seite, labels, label_seite, struktur, baue_kapitel_seiten(html_verz, datei_seite, labels, label_seite, struktur,
suchindex, pdf_verfuegbar, notizbuecher) suchindex, pdf_verfuegbar, notizbuecher)

View file

@ -0,0 +1,342 @@
#!/usr/bin/env python3
# veroeffentliche_04.py
#
# Laedt OR_HTML_04/ auf den Webserver - mit rsync, nicht mit scp.
#
# Warum das der Unterschied ist: scp ueberschreibt nur, es loescht nie. Nach
# dem Teilen von Anhang E/F blieb 'anhang-glossar-literatur.html' online
# erreichbar, obwohl die Datei aus dem Buch verschwunden war - verlinkt von
# nirgendwo, aber auffindbar. Das Zielverzeichnis vorher zu leeren waere die
# grobe Antwort darauf: Bricht die Uebertragung von 23 MB in 213 Dateien ab,
# ist die Website tot. rsync --delete uebertraegt nur Geaendertes und entfernt
# genau das, was verschwunden ist.
#
# Der zweite Grund fuer dieses Skript wiegt schwerer: Die Website hing
# wochenlang einen Bau zurueck, ohne dass es auffiel. Deshalb prueft Schritt 1,
# ob der Stand ueberhaupt aktuell ist, und Schritt 4 prueft hinterher per HTTP
# nach, ob das Ergebnis stimmt.
#
# Anmeldung per SSH-Schluessel. rsync und scp haben keine Passwort-Option; ein
# Passwort braeuchte sshpass und staende waehrend der Uebertragung in der
# Prozessliste. Einmalig:
#
# ssh-copy-id dschlueter@jamulix.de
#
# Aufruf:
# python3 Operations_Research_mit_Python_Version_04/veroeffentliche_04.py
# ... --probelauf
#
# Konfiguration in Version_04/.env (chmod 600, nicht im Repository) - siehe
# .env.beispiel. Ein Geheimnis steht nicht darin.
import argparse
import os
import subprocess
import sys
import urllib.error
import urllib.request
HIER = os.path.dirname(os.path.abspath(__file__))
BASIS = os.path.dirname(HIER)
VERSION = "04"
HTML_NAME = f"OR_HTML_{VERSION}"
HTML_VERZ = os.path.join(BASIS, HTML_NAME)
WEB_NAME = f"web_{VERSION}"
PDF_DATEINAME = f"Operations_Research_mit_Python_Version_{VERSION}.pdf"
ENV_DATEI = os.path.join(BASIS, ".env")
# Verzeichnisse, deren Aenderung einen neuen Bau noetig macht. Ist eine Datei
# darin neuer als die juengste erzeugte Seite, ist die Website veraltet.
QUELLEN = [
os.path.join(BASIS, f"Operations_Research_mit_Python_Version_{VERSION}"),
os.path.join(BASIS, f"bilder_{VERSION}"),
os.path.join(BASIS, WEB_NAME),
]
# Was nach dem Hochladen erreichbar sein MUSS bzw. NICHT MEHR erreichbar sein
# darf. Die 404-Zeile ist die eigentliche Probe darauf, dass --delete gewirkt
# hat: Sie war der Anlass fuer dieses Skript.
NACHPRUEFUNG = [
("", 200),
("index.html", 200),
("notebooks.html", 200),
("programme.html", 200),
("anhang-glossar.html", 200),
("anhang-literatur.html", 200),
("stichwortverzeichnis.html", 200),
("Notebooks_04.zip", 200),
("Notebooks_04/", 200),
("programme/", 200),
("assets/site.css", 200),
("katex/katex.min.js", 200),
(PDF_DATEINAME, 200),
("anhang-glossar-literatur.html", 404),
]
LOESCHGRENZE = 30
def lies_env() -> dict[str, str]:
"""Liest .env - Zeilen der Form SCHLUESSEL=wert, '#' leitet einen
Kommentar ein. Bewusst kein python-dotenv: fuenf Schluessel rechtfertigen
keine Abhaengigkeit."""
if not os.path.exists(ENV_DATEI):
raise SystemExit(
f"FEHLER: {ENV_DATEI} fehlt.\n"
f" Vorlage kopieren und ausfuellen:\n"
f" cp .env.beispiel .env && chmod 600 .env")
werte = {}
with open(ENV_DATEI, encoding="utf-8") as f:
for zeile in f:
zeile = zeile.split("#", 1)[0].strip()
if not zeile or "=" not in zeile:
continue
schluessel, _, wert = zeile.partition("=")
werte[schluessel.strip()] = wert.strip().strip('"').strip("'")
fehlend = [s for s in ("OR_HOST", "OR_BENUTZER", "OR_ZIEL", "OR_URL")
if not werte.get(s)]
if fehlend:
raise SystemExit(f"FEHLER: In .env fehlen: {', '.join(fehlend)}")
return werte
def juengste_datei(verzeichnisse: list[str]) -> tuple[float, str]:
"""Liefert (Zeitstempel, Pfad) der zuletzt geaenderten Datei.
Dieses Skript selbst zaehlt nicht mit: Es aendert die Website nicht, sein
Bearbeiten duerfte den Bau also nicht als veraltet erscheinen lassen."""
neuste, pfad = 0.0, ""
for verzeichnis in verzeichnisse:
for wurzel, verz, dateien in os.walk(verzeichnis):
verz[:] = [v for v in verz
if v != "__pycache__" and not v.startswith(".")]
for name in dateien:
voll = os.path.join(wurzel, name)
if os.path.abspath(voll) == os.path.abspath(__file__):
continue
try:
zeit = os.path.getmtime(voll)
except OSError:
continue
if zeit > neuste:
neuste, pfad = zeit, voll
return neuste, pfad
def vorpruefung() -> None:
"""Jede Verletzung bricht ab. Ein veralteter oder unvollstaendiger Stand
soll gar nicht erst auf den Server kommen."""
def scheitern(text: str) -> None:
raise SystemExit(f"FEHLER: {text}")
bau_aufruf = (f"python3 Operations_Research_mit_Python_Version_{VERSION}/"
f"build_version_{VERSION}.py --pdf --html")
if not os.path.exists(os.path.join(HTML_VERZ, "index.html")):
scheitern(f"{HTML_NAME}/index.html fehlt - erst bauen:\n"
f" {bau_aufruf}")
# Ist der Bau aktuell? Verglichen wird gegen die juengste erzeugte Seite,
# nicht gegen die aelteste: Der Bau schreibt alle Seiten in einem Zug.
quell_zeit, quell_datei = juengste_datei(QUELLEN)
bau_zeit, _ = juengste_datei([HTML_VERZ])
if quell_zeit > bau_zeit:
scheitern(
f"Die Quelle ist neuer als die Website:\n"
f" {os.path.relpath(quell_datei, BASIS)}\n"
f" Erst bauen, dann hochladen - sonst geht ein alter Stand "
f"online.\n"
f" (Bewusst trotzdem hochladen: --ohne-vorpruefung)")
# Das PDF wurde schon einmal aus der Wurzel kopiert, ohne dass die Kopie
# nachgezogen wurde - die Website bot ein aelteres PDF an als das Buch.
pdf_wurzel = os.path.join(BASIS, PDF_DATEINAME)
pdf_kopie = os.path.join(HTML_VERZ, PDF_DATEINAME)
if not os.path.exists(pdf_kopie):
scheitern(f"{HTML_NAME}/{PDF_DATEINAME} fehlt - mit --pdf --html bauen.")
if os.path.exists(pdf_wurzel):
gross_wurzel = os.path.getsize(pdf_wurzel)
gross_kopie = os.path.getsize(pdf_kopie)
if gross_wurzel != gross_kopie:
scheitern(
f"Das PDF der Website weicht vom gebauten ab "
f"({gross_kopie:n} statt {gross_wurzel:n} Bytes).\n"
f" Nochmal mit --html bauen, das kopiert es nach.")
# Ohne die beiden Weiterleitungen antwortet der Webserver auf die
# Verzeichnisse mit 403 - genau das war der Fehlerbericht.
pflicht = ["notebooks.html", "programme.html", f"Notebooks_{VERSION}.zip",
f"Notebooks_{VERSION}/index.html", "programme/index.html"]
fehlend = [d for d in pflicht
if not os.path.exists(os.path.join(HTML_VERZ, d))]
if fehlend:
scheitern(f"In {HTML_NAME}/ fehlen: {', '.join(fehlend)}\n"
f" Erst mit --html bauen.")
print("Vorpruefung bestanden: Bau ist aktuell und vollstaendig.")
def ssh_optionen(env: dict[str, str]) -> str:
teile = ["ssh"]
if env.get("OR_SSH_PORT"):
teile += ["-p", env["OR_SSH_PORT"]]
if env.get("OR_SSH_KEY"):
teile += ["-i", os.path.expanduser(env["OR_SSH_KEY"])]
return " ".join(teile)
def rsync_befehl(env: dict[str, str], probelauf: bool) -> list[str]:
# -rlt statt -a: Eigentuemer und Gruppe lassen sich als normaler Nutzer
# ohnehin nicht setzen, -a erzeugte dafuer nur Warnungen. --chmod sorgt
# dafuer, dass der Webserver alles lesen kann, unabhaengig von der lokalen
# umask.
befehl = ["rsync", "-rltz", "--delete", "--chmod=D755,F644",
"--skip-compress=pdf/zip/woff2/png/jpg/svg",
"--itemize-changes",
"-e", ssh_optionen(env)]
if probelauf:
befehl.append("--dry-run")
ziel = env["OR_ZIEL"]
befehl += [HTML_VERZ + os.sep,
f'{env["OR_BENUTZER"]}@{env["OR_HOST"]}:{ziel}']
return befehl
def pruefe_ziel_erreichbar(env: dict[str, str]) -> None:
"""Ein Handschlag, bevor 23 MB losgeschickt werden - und er unterscheidet
die beiden Fehlerursachen, die sonst leicht verwechselt werden: Anmeldung
scheitert (ssh liefert 255) gegen rsync fehlt am Ziel."""
kennung = f'{env["OR_BENUTZER"]}@{env["OR_HOST"]}'
# BatchMode: keine Passwortabfrage, damit der Aufruf nicht haengt. Der
# ssh-agent funktioniert weiterhin. stderr laeuft absichtlich durch, damit
# ssh seine eigene Diagnose zeigen kann.
ergebnis = subprocess.run(
ssh_optionen(env).split() + ["-o", "BatchMode=yes", kennung,
"command -v rsync || echo KEIN_RSYNC"],
stdout=subprocess.PIPE, text=True)
if ergebnis.returncode == 255:
raise SystemExit(
f"FEHLER: Keine SSH-Verbindung zu {kennung} (siehe Meldung oben).\n"
f" Haeufigste Ursache: Der oeffentliche Schluessel liegt noch\n"
f" nicht auf dem Server. Einmalig:\n"
f" ssh-copy-id {kennung}\n"
f" Bei passwortgeschuetztem Schluessel: ssh-add einmal "
f"ausfuehren.")
if ergebnis.returncode != 0:
raise SystemExit(f"FEHLER: ssh {kennung} endete mit Code "
f"{ergebnis.returncode}.")
if "KEIN_RSYNC" in ergebnis.stdout or not ergebnis.stdout.strip():
raise SystemExit(
"FEHLER: Auf dem Server ist kein rsync installiert.\n"
" Ausweg ohne rsync - nach OR.neu/ hochladen und tauschen;\n"
" das entfernt ebenfalls Altes und ist fuer Besucher fast\n"
" unterbrechungsfrei:\n"
f" scp -r {HTML_NAME}/* {kennung}:{env['OR_ZIEL']}../OR.neu/\n"
f' ssh {kennung} '
f'"mv OR OR.alt && mv OR.neu OR && rm -rf OR.alt"')
def zaehle_aenderungen(ausgabe: str) -> tuple[list[str], list[str]]:
"""Trennt die --itemize-changes-Ausgabe in Uebertragungen und Loeschungen."""
uebertragen, geloescht = [], []
for zeile in ausgabe.splitlines():
if zeile.startswith("*deleting"):
geloescht.append(zeile.split(None, 1)[-1])
elif zeile[:1] in ("<", ">", "c", "h") and " " in zeile:
uebertragen.append(zeile.split(None, 1)[-1])
return uebertragen, geloescht
def nachpruefung(basis_url: str) -> bool:
"""Holt jede Adresse und vergleicht den Statuscode mit der Erwartung."""
print("\nNachpruefung:")
alles_gut = True
for pfad, erwartet in NACHPRUEFUNG:
url = basis_url.rstrip("/") + "/" + pfad
anfrage = urllib.request.Request(url, method="HEAD")
try:
with urllib.request.urlopen(anfrage, timeout=20) as antwort:
code = antwort.status
except urllib.error.HTTPError as fehler:
code = fehler.code
except OSError as fehler:
code = f"{type(fehler).__name__}"
passt = code == erwartet
alles_gut = alles_gut and passt
print(f" {'OK ' if passt else 'FEHLER'} {code!s:>6} "
f"(erwartet {erwartet}) /{pfad}")
return alles_gut
def main() -> int:
zerleger = argparse.ArgumentParser(
description="Laedt OR_HTML_04/ auf den Webserver (rsync ueber SSH).")
zerleger.add_argument("--probelauf", action="store_true",
help="alles ausser der echten Uebertragung")
zerleger.add_argument("--loeschgrenze", type=int, default=LOESCHGRENZE,
help=f"Abbruch, wenn mehr Dateien geloescht wuerden "
f"(Vorgabe: {LOESCHGRENZE})")
zerleger.add_argument("--ohne-vorpruefung", action="store_true",
help="Schritt 1 ueberspringen (bewusst alter Stand)")
zerleger.add_argument("--ohne-nachpruefung", action="store_true",
help="Schritt 4 ueberspringen (kein Netz zum Server)")
argumente = zerleger.parse_args()
env = lies_env()
ziel = f'{env["OR_BENUTZER"]}@{env["OR_HOST"]}:{env["OR_ZIEL"]}'
print(f"Ziel: {ziel}")
if not argumente.ohne_vorpruefung:
vorpruefung()
pruefe_ziel_erreichbar(env)
# Schritt 2: Immer erst zaehlen. Der Probelauf kostet Sekunden und ist die
# einzige Gelegenheit, einen falschen Zielpfad zu bemerken, BEVOR --delete
# eine fremde Website ausraeumt.
probe = subprocess.run(rsync_befehl(env, probelauf=True),
capture_output=True, text=True)
if probe.returncode != 0:
print(probe.stderr, file=sys.stderr)
return probe.returncode
uebertragen, geloescht = zaehle_aenderungen(probe.stdout)
print(f"\nZu uebertragen: {len(uebertragen)} Datei(en)")
for name in uebertragen[:15]:
print(f" + {name}")
if len(uebertragen) > 15:
print(f" ... und {len(uebertragen) - 15} weitere")
print(f"Zu loeschen: {len(geloescht)} Datei(en)")
for name in geloescht:
print(f" - {name}")
if len(geloescht) > argumente.loeschgrenze:
print(f"\nFEHLER: {len(geloescht)} Loeschungen ueberschreiten die Grenze "
f"von {argumente.loeschgrenze}.\n"
f" Das ist meist ein falscher Zielpfad oder ein halb "
f"gebautes {HTML_NAME}/.\n"
f" Wenn es stimmt: --loeschgrenze {len(geloescht)}",
file=sys.stderr)
return 1
if argumente.probelauf:
print("\nProbelauf - es wurde nichts uebertragen.")
return 0
if not uebertragen and not geloescht:
print("\nNichts zu tun - der Server ist auf dem Stand des Baus.")
else:
print("\nUebertrage ...")
lauf = subprocess.run(rsync_befehl(env, probelauf=False))
if lauf.returncode != 0:
return lauf.returncode
print(f"Fertig: {len(uebertragen)} uebertragen, "
f"{len(geloescht)} geloescht.")
if argumente.ohne_nachpruefung:
return 0
return 0 if nachpruefung(env["OR_URL"]) else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -2024,14 +2024,99 @@ Archivs — so können Link und Datei nicht auseinanderlaufen. Das Glossar ersch
dasselbe leisten, und unten in der Gruppe „Anhänge", weil es ein Anhang ist. dasselbe leisten, und unten in der Gruppe „Anhänge", weil es ein Anhang ist.
**Nebenbefund: `OR_HTML_04/assets/site.css` ist eine Quelle, keine erzeugte Datei.** Der **Nebenbefund: `OR_HTML_04/assets/site.css` ist eine Quelle, keine erzeugte Datei.** Der
Build schreibt sie nicht (Zeitstempel vom 06.09., während `programme.js` bei jedem Lauf Build schreibt sie nicht. Dasselbe gilt für `site.js`, `icons.svg`, `plotly.min.js` und
neu entsteht). Dasselbe gilt für `site.js`, `icons.svg`, `highlight.css` und `katex/`. Sie liegen im als „generiert" beschriebenen Verzeichnis, sind aber von Hand
`plotly.min.js`. Sie liegen im als „generiert" beschriebenen Verzeichnis, sind aber von Hand
gepflegt bzw. mitgeliefert — wer `OR_HTML_04/` löscht und neu baut, verliert sie. gepflegt bzw. mitgeliefert — wer `OR_HTML_04/` löscht und neu baut, verliert sie.
(Nachtrag: Der nächste Abschnitt räumt das auf. **Und `highlight.css` gehört nicht in diese
Liste** — die erzeugt `erzeuge_highlight_css()` aus `pandoc --print-highlight-style`; die
erste Fassung dieses Absatzes zählte sie fälschlich mit.)
Die alte `anhang-glossar-literatur.html` musste von Hand entfernt werden; der Build räumt Die alte `anhang-glossar-literatur.html` musste von Hand entfernt werden; der Build räumt
verwaiste Seiten nicht ab. verwaiste Seiten nicht ab.
### ✅ `web_04/` — die statischen Assets bekommen eine Quelle
Vier Dateien und die 22 KaTeX-Dateien lagen im erzeugten Verzeichnis, 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 Einrücken des ZIP-Menüpunkts passiert. Und wer `OR_HTML_04/`
gelöscht und neu gebaut hätte, hätte 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()`
füllt die Quelle statt des Ziels; `_lade_icon_sprite_inline()` liest die Quelle, hängt also
nicht mehr vom eigenen Ergebnis ab.
**Zwei Wächter**, weil genau diese Verwechslung schon vorgekommen ist:
* Wurde die Kopie in `OR_HTML_04/` von Hand geändert (Inhalt weicht ab *und* Zeitstempel ist
neuer), **bricht der Bau ab** und nennt den `mv`-Befehl, der es richtigstellt. Kein
stilles Überschreiben.
* `pruefe_assets()` liest die `href=`/`src=`-Literale aus dem Quelltext des Bauskripts und
verlangt für jedes einen Erzeuger — entweder `web_04/` oder die Liste `ERZEUGTE_ASSETS`
(`highlight.css`, `search-index.js`, `programme.js`). Ein künftiger Eintrag im Seitenkopf
ohne Datei dahinter fällt sofort auf.
Die Probe aufs Exempel: `rm -rf OR_HTML_04 && --html` baut alle 213 Dateien wieder auf,
Dateiliste identisch zur Sicherung.
### 🐛 Fund: Das Stichwortverzeichnis war nicht byte-reproduzierbar
Beim Gegenprüfen der Neubau-Probe unterschied sich `stichwortverzeichnis.html` zwischen zwei
Läufen, ohne dass sich eine Quelle geändert hatte. Ursache in `ziel_links()`: sortiert wurde
nach `(seite, kontext)` — und `kontext` ist der **Kapiteltitel**, für alle Marken einer Datei
also derselbe. Bei zwei Fundstellen im selben Kapitel war der Schlüssel gleich, und die
Reihenfolge fiel auf die eines `set()` zurück, also auf den je Prozess zufälligen
`PYTHONHASHSEED`.
Derselbe Konstruktionsfehler war auch sichtbar: Vier `{idx:Branch-and-Bound}` in Kapitel 6
ergaben **vier optisch identische Links** nebeneinander — zehn Einträge im Register waren
davon betroffen. Behoben durch einen Link je Kapitel (die erste Fundstelle in
Dokumentreihenfolge, `dict` statt `set`). Damit ist die Sortierung zugleich eindeutig.
Gegenprobe: drei Läufe mit `PYTHONHASHSEED=random` liefern dieselbe Prüfsumme
`3da5b9b2…`. Keine Hauptzeile trägt mehr einen doppelten Link; die 219 Fachbegriffe und 328
Indexmarken bleiben unverändert.
### ✅ `veroeffentliche_04.py` — der Upload wird nachprüfbar
Der Anlass war messbar. Der Live-Stand vor dem Skript:
```
/OR/ 200
/OR/anhang-glossar-literatur.html 200 <- Leiche des vorigen Stands
/OR/anhang-glossar.html 404
/OR/notebooks.html 404
/OR/Notebooks_04.zip 404
/OR/Notebooks_04/ 403
```
**`scp -r` überschreibt nur, es löscht nie.** Die alte Glossar-Seite bliebe nach einem
`scp`-Upload dauerhaft erreichbar. Das Zielverzeichnis vorher zu leeren wäre die grobe
Antwort: Bricht die Übertragung von 23 MB in 213 Dateien ab, ist die Website tot.
`rsync --delete` überträgt nur Geändertes und entfernt genau das, was verschwunden ist.
Der zweite Grund wiegt schwerer: **Die Website hing wochenlang einen Bau zurück**, ohne dass
es auffiel. Deshalb prüft das Skript vorher, ob eine Quelldatei neuer ist als die gebaute
Website, ob das PDF der Website byte-genau dem gebauten entspricht und ob die
Verzeichnis-Weiterleitungen vorhanden sind (der Grund für den 403). Und es prüft
**hinterher per HTTP** vierzehn Adressen nach — einschließlich der Probe, dass die entfernte
Seite wirklich 404 liefert.
Dazwischen ein Probelauf, der zählt: Mehr als 30 Löschungen brechen ab. Das ist der Schutz
gegen einen falschen Zielpfad — mit `--delete` räumt ein Tippfehler sonst ein fremdes
Verzeichnis aus.
**Anmeldung per SSH-Schlüssel, nicht per Passwort in der `.env`.** `rsync` und `scp` haben
keine Passwort-Option; ein Passwort bräuchte `sshpass` und stünde während der Übertragung in
der Prozessliste. Die `.env` (in `.gitignore`, Vorlage `.env.beispiel`) enthält deshalb nur
Host, Benutzer, Zielpfad und URL.
**Beim ersten Test lief die Prüfung in eine irreführende Meldung:** „Auf dem Server ist kein
rsync installiert" — tatsächlich schlug schon die Anmeldung fehl, weil der öffentliche
Schlüssel noch nicht auf `jamulix.de` liegt. Die Prüfung unterscheidet die Fälle jetzt am
ssh-Rückgabewert 255 und nennt `ssh-copy-id`.
--- ---
## 8. Commit-Historie des V04-Strangs ## 8. Commit-Historie des V04-Strangs

View file

@ -115,6 +115,7 @@ pip install -r Operations_Research_mit_Python_Version_04_Programme/requirements.
| --- | --- | | --- | --- |
| `Operations_Research_mit_Python_Version_04/` | **Quelle**: 37 Kapiteldateien (inkl. 5 Teil-Synthesen) + Build-Skripte | | `Operations_Research_mit_Python_Version_04/` | **Quelle**: 37 Kapiteldateien (inkl. 5 Teil-Synthesen) + Build-Skripte |
| `bilder_04/` | **Quelle**: 33 Diagramme (SVG) + 16 `erzeuge_*.py`-Generatoren | | `bilder_04/` | **Quelle**: 33 Diagramme (SVG) + 16 `erzeuge_*.py`-Generatoren |
| `web_04/` | **Quelle**: `site.css`, `site.js`, `icons.svg`, `plotly.min.js`, `katex/` — die statischen Bestandteile der Website |
| `Kritik_und_Verbesserungsvorschlaege/` | **Quelle**: die Rezensionen und Vorschläge, die den Umbau ausgelöst haben | | `Kritik_und_Verbesserungsvorschlaege/` | **Quelle**: die Rezensionen und Vorschläge, die den Umbau ausgelöst haben |
| `pyproject.toml`, `Dockerfile`, `.dockerignore` | **Quelle**: Abhängigkeitsgruppen und Kurs-Image | | `pyproject.toml`, `Dockerfile`, `.dockerignore` | **Quelle**: Abhängigkeitsgruppen und Kurs-Image |
| `pandoc-defaults-*.yaml`, `pandoc/` | **Quelle**: Konfiguration des PDF-Baus | | `pandoc-defaults-*.yaml`, `pandoc/` | **Quelle**: Konfiguration des PDF-Baus |
@ -166,8 +167,9 @@ Prüfung hinterlassen.
| `inkscape` | SVG-Grafiken im PDF | LaTeX bricht bei `\includesvg` ab | | `inkscape` | SVG-Grafiken im PDF | LaTeX bricht bei `\includesvg` ab |
| `dot` (Graphviz) | 5 der 16 Bildgeneratoren | nur beim Neuerzeugen dieser Diagramme nötig | | `dot` (Graphviz) | 5 der 16 Bildgeneratoren | nur beim Neuerzeugen dieser Diagramme nötig |
**`OR_HTML_04/katex/`** ist externes Material und wird von keinem Skript erzeugt. Fehlt es, **`web_04/katex/`** ist externes Material und wird von keinem Skript erzeugt — es liegt
bleiben die Formeln auf der Website ungesetzt. deshalb als Quelle im Repository und wird bei jedem Bau nach `OR_HTML_04/katex/` kopiert.
Fehlt es, bleiben die Formeln auf der Website ungesetzt.
**Warum die PDF-Konfiguration doppelt vorliegt:** `pandoc-defaults-basis.yaml` ist eine Kopie **Warum die PDF-Konfiguration doppelt vorliegt:** `pandoc-defaults-basis.yaml` ist eine Kopie
der Benutzerdatei `~/.config/pandoc/defaults.yaml` — mit **relativem** Pfad auf der Benutzerdatei `~/.config/pandoc/defaults.yaml` — mit **relativem** Pfad auf
@ -177,6 +179,46 @@ Benutzerdatei zurück, falls sie fehlt.
--- ---
## Veröffentlichen
`OR_HTML_04/` ist selbstgenügsam: Der Ordner lässt sich direkt auf einen Webserver kopieren.
Für den regelmäßigen Fall gibt es ein Skript.
```bash
# einmalig: den eigenen SSH-Schlüssel auf den Server legen
ssh-copy-id benutzer@example.org
# einmalig: Ziel eintragen
cp .env.beispiel .env && chmod 600 .env # dann .env ausfüllen
# jedes Mal
python3 Operations_Research_mit_Python_Version_04/veroeffentliche_04.py
```
Das Skript benutzt **`rsync --delete`, nicht `scp`** — und das ist der entscheidende
Unterschied: `scp` überschreibt nur, es entfernt nie. Eine Seite, die aus dem Buch
verschwindet, bliebe sonst dauerhaft online erreichbar. `rsync` überträgt zudem nur, was sich
geändert hat; ein zweiter Lauf direkt danach überträgt nichts.
Vier Schritte, jeder mit einer eigenen Aufgabe:
1. **Vorprüfung.** Ist eine Quelldatei neuer als die gebaute Website, bricht das Skript ab —
ein alter Stand soll gar nicht erst hochgeladen werden. Ebenso geprüft: dass das PDF der
Website byte-genau dem gebauten entspricht und dass die Verzeichnis-Weiterleitungen da
sind.
2. **Probelauf.** Zählt und zeigt, was übertragen und was gelöscht würde. Über 30 Löschungen
ist der Lauf verdächtig (falscher Zielpfad, halb gebautes Verzeichnis) und bricht ab;
`--loeschgrenze N` hebt die Grenze an.
3. **Übertragung.**
4. **Nachprüfung per HTTP.** Vierzehn Adressen werden abgerufen und ihr Statuscode mit der
Erwartung verglichen — einschließlich der Probe, dass eine entfernte Seite wirklich 404
liefert.
`--probelauf` führt nur die Schritte 1 und 2 aus. In der `.env` steht **kein Geheimnis**:
`rsync` und `scp` haben keine Passwort-Option, die Anmeldung läuft über den SSH-Schlüssel.
---
## Notebooks und Google Colab ## Notebooks und Google Colab
Jedes Kapitel hat ein Notebook in `Notebooks_04/`, und die Website verlinkt es zum Jedes Kapitel hat ein Notebook in `Notebooks_04/`, und die Website verlinkt es zum

41
web_04/assets/icons.svg Normal file
View file

@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</symbol>
<symbol id="icon-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.2" y2="16.2"/>
</symbol>
<symbol id="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="4.5"/>
<line x1="12" y1="1.5" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22.5"/>
<line x1="1.5" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22.5" y2="12"/>
<line x1="4.5" y1="4.5" x2="6.2" y2="6.2"/><line x1="17.8" y1="17.8" x2="19.5" y2="19.5"/>
<line x1="19.5" y1="4.5" x2="17.8" y2="6.2"/><line x1="6.2" y1="17.8" x2="4.5" y2="19.5"/>
</symbol>
<symbol id="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.8 6.8 0 0 0 10.5 10.5z"/>
</symbol>
<symbol id="icon-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 4 7 12 15 20"/>
</symbol>
<symbol id="icon-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 4 17 12 9 20"/>
</symbol>
<symbol id="icon-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 13 9.5 18.5 20 6"/>
</symbol>
<symbol id="icon-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13.5V19a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 19V7A1.5 1.5 0 0 1 5 5.5h5.5"/>
<polyline points="14.5 3.5 20.5 3.5 20.5 9.5"/><line x1="11" y1="13" x2="20" y2="4"/>
</symbol>
<symbol id="icon-book" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 5.5A2 2 0 0 1 6 4h6v16H6a2 2 0 0 0-2 1.5z"/>
<path d="M20 5.5A2 2 0 0 0 18 4h-6v16h6a2 2 0 0 1 2 1.5z"/>
</symbol>
<symbol id="icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</symbol>
<symbol id="icon-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3v12"/><polyline points="7 10 12 15 17 10"/><path d="M4 19.5h16"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 3 KiB

3882
web_04/assets/plotly.min.js vendored Normal file

File diff suppressed because one or more lines are too long

348
web_04/assets/site.css Normal file
View file

@ -0,0 +1,348 @@
/* site.css Design-System der Mehrseiten-Website "Operations Research mit
Python". Struktur (CSS-Variablen, .dark-Umschaltung, kuehle Graustufen)
nach dem Vorbild https://jamulix.de/sorting/, Farben aus der bestehenden
Buchpalette (Titelseite/PDF: Indigo/Cyan/Violett/Gruen/Amber). */
:root {
--bg: #f5f6fa;
--surface: #ffffff;
--surface2: #eef0f5;
--border: #d9dce6;
--text: #1a1a2e;
--muted: #5c6380;
--accent: #4338ca;
--accent-text: #ffffff;
--cyan: #0891b2;
--violett: #7c3aed;
--emerald: #059669;
--amber: #b45309;
--rose: #e11d48;
--tint-indigo: #eef2ff;
--tint-violett: #f5f3ff;
--tint-cyan: #ecfeff;
--tint-emerald: #ecfdf5;
--tint-amber: #fffbeb;
--code-bg: #1e293b;
--code-text: #e6edf3;
--shadow: 0 2px 10px rgba(26, 26, 46, .07);
--shadow-lg: 0 8px 30px rgba(26, 26, 46, .12);
--radius: 12px;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0e0f1a;
--surface: #161826;
--surface2: #1e2133;
--border: #2b2f45;
--text: #e7e9f5;
--muted: #9aa1c4;
--accent: #818cf8;
--accent-text: #0e0f1a;
--cyan: #22d3ee;
--violett: #c4b5fd;
--emerald: #34d399;
--amber: #fbbf24;
--rose: #fb7185;
--tint-indigo: #1b1f3a;
--tint-violett: #241f3d;
--tint-cyan: #132833;
--tint-emerald: #10291f;
--tint-amber: #2c2410;
--code-bg: #0b0d14;
--code-text: #dbe4f0;
--shadow: 0 2px 10px rgba(0, 0, 0, .35);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, .5);
}
}
:root[data-theme="dark"] {
--bg: #0e0f1a;
--surface: #161826;
--surface2: #1e2133;
--border: #2b2f45;
--text: #e7e9f5;
--muted: #9aa1c4;
--accent: #818cf8;
--accent-text: #0e0f1a;
--cyan: #22d3ee;
--violett: #c4b5fd;
--emerald: #34d399;
--amber: #fbbf24;
--rose: #fb7185;
--tint-indigo: #1b1f3a;
--tint-violett: #241f3d;
--tint-cyan: #132833;
--tint-emerald: #10291f;
--tint-amber: #2c2410;
--code-bg: #0b0d14;
--code-text: #dbe4f0;
--shadow: 0 2px 10px rgba(0, 0, 0, .35);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, .5);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0; background: var(--bg); color: var(--text);
font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, "DejaVu Sans", Arial, sans-serif;
line-height: 1.7; font-size: 16px; -webkit-text-size-adjust: 100%;
}
.icon { width: 1.15em; height: 1.15em; flex: none; vertical-align: -.2em; }
/* --- Kopfzeile --- */
.site-header {
position: sticky; top: 0; z-index: 30; display: flex; align-items: center;
gap: .75rem; padding: .6rem 1rem; background: var(--surface);
border-bottom: 1px solid var(--border); box-shadow: var(--shadow);
}
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 2.2rem; height: 2.2rem; border-radius: 8px; border: 1px solid transparent;
background: transparent; color: var(--text); cursor: pointer;
}
.icon-btn:hover { background: var(--surface2); border-color: var(--border); }
.brand {
display: flex; align-items: center; gap: .5rem; color: var(--accent);
font-weight: 700; text-decoration: none; white-space: nowrap;
}
.brand span { display: none; }
@media (min-width: 900px) { .brand span { display: inline; } }
.site-search { position: relative; flex: 1; max-width: 420px; margin: 0 auto; }
.site-search input {
width: 100%; padding: .5rem .8rem .5rem 2.2rem; border-radius: 20px;
border: 1px solid var(--border); background: var(--surface2); color: var(--text);
font-size: .92rem;
}
.site-search input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.such-icon { position: absolute; left: .7rem; top: 50%; transform: translateY(-50%); color: var(--muted); }
.suche-ergebnisse {
position: absolute; top: calc(100% + .4rem); left: 0; right: 0; max-height: 60vh;
overflow-y: auto; background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-lg); z-index: 40;
}
.suche-ergebnisse a {
display: block; padding: .55rem .9rem; color: var(--text); text-decoration: none;
border-bottom: 1px solid var(--border);
}
.suche-ergebnisse a:last-child { border-bottom: none; }
.suche-ergebnisse a:hover, .suche-ergebnisse a.aktiv { background: var(--tint-indigo); }
.suche-ergebnisse small { display: block; color: var(--muted); font-weight: 400; }
.suche-leer { padding: .8rem .9rem; color: var(--muted); }
#theme-toggle .icon-moon { display: none; }
:root[data-theme="dark"] #theme-toggle .icon-sun { display: none; }
:root[data-theme="dark"] #theme-toggle .icon-moon { display: inline; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) #theme-toggle .icon-sun { display: none; }
:root:not([data-theme="light"]) #theme-toggle .icon-moon { display: inline; }
}
/* --- Layout: Sidebar + Inhalt --- */
.site-body { display: flex; align-items: flex-start; max-width: 1400px; margin: 0 auto; }
.sidebar {
position: sticky; top: 3.6rem; width: 290px; flex: none;
height: calc(100vh - 3.6rem); overflow-y: auto; border-right: 1px solid var(--border);
background: var(--surface); padding: 1rem 0;
}
.sidebar-gruppe { border-bottom: 1px solid var(--border); }
.sidebar-gruppe summary {
cursor: pointer; padding: .6rem 1rem; font-weight: 700; color: var(--accent);
list-style: none; display: flex; align-items: center; justify-content: space-between;
}
.sidebar-gruppe summary::-webkit-details-marker { display: none; }
.sidebar-gruppe summary::after { content: "▾"; color: var(--muted); font-size: .8em; }
.sidebar-gruppe[open] summary::after { content: "▴"; }
.sidebar ul { list-style: none; margin: 0; padding: 0 .5rem .5rem; }
.sidebar li a {
display: flex; align-items: center; gap: .5rem; padding: .4rem .6rem;
border-radius: 8px; color: var(--text); text-decoration: none; font-size: .92rem;
}
.sidebar li a:hover { background: var(--tint-indigo); }
.sidebar li.aktiv a { background: var(--accent); color: var(--accent-text); font-weight: 600; }
.fortschritt-haken { width: 1em; height: 1em; color: var(--emerald); visibility: hidden; }
.fortschritt-haken .icon { width: 100%; height: 100%; }
li[data-erledigt="1"] .fortschritt-haken { visibility: visible; }
.sidebar-extra { border-top: 2px solid var(--border); margin-top: .5rem; padding-top: .8rem !important; }
/* Unterpunkt in der festen Menueliste (Download-ZIP unter Notebooks): eingerueckt und etwas kleiner, ohne echte Listenverschachtelung. */
.sidebar-unterpunkt a { padding-left: 1.9rem !important; font-size: .92em; }
.sidebar-overlay { display: none; }
.content { flex: 1; min-width: 0; padding: 1.5rem 2rem 5rem; max-width: 900px; margin: 0 auto; }
/* --- Breadcrumb, Prev/Next --- */
.breadcrumb { font-size: .85rem; color: var(--muted); margin-bottom: .8rem; }
.breadcrumb a { color: var(--muted); }
.prev-next { display: flex; justify-content: space-between; gap: 1rem; margin: 1.2rem 0; }
.prev-next-knopf {
display: flex; align-items: center; gap: .6rem; flex: 1; max-width: 48%;
padding: .7rem 1rem; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface); color: var(--text); text-decoration: none; font-size: .9rem;
box-shadow: var(--shadow); transition: border-color .15s, transform .15s;
}
.prev-next-knopf:hover { border-color: var(--accent); transform: translateY(-1px); }
.prev-next-next { margin-left: auto; text-align: right; flex-direction: row-reverse; }
.prev-next-knopf small { display: block; color: var(--muted); font-weight: 400; }
.prev-next-leer { flex: 1; max-width: 48%; }
.fortschritt-knopf {
display: inline-flex; align-items: center; gap: .5rem; margin: 1rem 0;
padding: .55rem 1rem; border-radius: 20px; border: 1px solid var(--border);
background: var(--surface2); color: var(--text); cursor: pointer; font: inherit; font-size: .88rem;
}
.fortschritt-knopf .icon { color: var(--muted); }
.fortschritt-knopf.erledigt { background: var(--tint-emerald); border-color: var(--emerald); color: var(--emerald); }
.fortschritt-knopf.erledigt .icon { color: var(--emerald); }
.site-footer { text-align: center; padding: 1.5rem; color: var(--muted); font-size: .85rem; border-top: 1px solid var(--border); }
.site-footer a { color: var(--muted); }
/* --- Typografie im Artikel (angelehnt an bilder/or_html_style.html) --- */
article h1, article h2, article h3, article h4, article h5 {
line-height: 1.25; font-weight: 700; margin-top: 2.1em; margin-bottom: .6em; scroll-margin-top: 4.5rem;
}
article h1 { color: var(--accent); font-size: 1.9rem; border-bottom: 3px solid var(--accent); padding-bottom: .25em; }
article h2 { color: var(--accent); font-size: 1.45rem; border-bottom: 2px solid var(--border); padding-bottom: .2em; }
article h3 { color: var(--cyan); font-size: 1.18rem; }
article h4 { color: var(--violett); font-size: 1.05rem; }
article h2[id$="-lernziele"]::before { content: "🎯 "; }
article h2[id$="-uebungsaufgaben"]::before { content: "📝 "; }
article h2[id$="-selbsttest"]::before { content: "✅ "; }
article h2[id$="-zusammenfassung"]::before { content: "📌 "; }
article a { color: var(--accent); text-decoration: none; }
article a:hover { text-decoration: underline; }
article a[target="_blank"]::after { content: " \2197"; font-size: .82em; color: var(--cyan); text-decoration: none; }
article p { margin: .75em 0; }
article hr { border: 0; border-top: 1px solid var(--border); margin: 2.2em 0; }
article figure { margin: 2em 0; text-align: center; }
article img { max-width: 100%; height: auto; display: block; margin: 0 auto; border-radius: 10px; }
article figure img { box-shadow: var(--shadow-lg); }
article figcaption { color: var(--muted); font-size: .9rem; font-style: italic; margin-top: .6em; }
article table { border-collapse: collapse; width: 100%; margin: 1.4em 0; font-size: .94rem; box-shadow: var(--shadow); border-radius: 8px; overflow: hidden; }
article th { background: var(--accent); color: var(--accent-text); font-weight: 700; text-align: left; }
article th, article td { padding: .55em .75em; border: 1px solid var(--border); vertical-align: top; }
article tbody tr:nth-child(even) { background: var(--tint-indigo); }
article code { font-family: "DejaVu Sans Mono", Consolas, Menlo, monospace; font-size: .9em; background: var(--tint-violett); color: var(--violett); padding: .12em .4em; border-radius: 5px; }
article pre { background: var(--code-bg); color: var(--code-text); padding: 1.1em 1.2em; border-radius: 10px; overflow-x: auto; line-height: 1.5; font-size: .85rem; box-shadow: var(--shadow-lg); }
article pre code { background: none; color: inherit; padding: 0; font-size: inherit; }
article div.sourceCode { position: relative; }
article div.sourceCode pre.sourceCode { padding-top: 2.6em; }
article blockquote { border-left: 4px solid var(--violett); background: var(--tint-violett); margin: 1.4em 0; padding: .6em 1.2em; border-radius: 0 8px 8px 0; }
article ul, article ol { padding-left: 1.4em; }
article li { margin: .3em 0; }
.katex-display { overflow-x: auto; overflow-y: hidden; padding: .4em 0; }
/* --- Code-Buttons (Kopieren / Herunterladen) --- */
.code-btn-leiste { position: absolute; top: .55em; right: .55em; z-index: 2; display: flex; gap: .4em; }
.code-btn, .download-link {
display: inline-flex; align-items: center; gap: .35em; background: rgba(255,255,255,.08);
color: #cbd5e1; border: 1px solid rgba(255,255,255,.18); border-radius: 6px;
padding: .3em .65em; font: inherit; font-size: .78rem; cursor: pointer;
text-decoration: none !important; transition: background .15s, color .15s;
}
.code-btn:hover, .download-link:hover { background: rgba(255,255,255,.18); color: #fff; }
.code-btn.copied { background: var(--emerald); color: #fff; border-color: var(--emerald); }
.download-link { position: static; margin-left: .6em; }
/* --- Karten (Kapitel auf einen Blick, Lernpfade, Kapitelraster) --- */
.card { border-radius: var(--radius); padding: 1.1em 1.4em; margin: 1.6em 0; box-shadow: var(--shadow); }
.card p, .card blockquote { margin: .5em 0 !important; border: none !important; background: none !important; padding: 0 !important; }
.card-blick { background: var(--tint-indigo); border: 1px solid var(--accent); }
.card-blick blockquote { border-left: 3px solid var(--accent); padding-left: .8em !important; }
/* --- Didaktische Karten der Version 04 ---------------------------------
Jede Kartenart traegt eine eigene Leitfarbe aus der Buchpalette, damit
Kursteilnehmer beim Blaettern sofort erkennen, was sie vor sich haben:
gruen = sofort ausprobieren, cyan = Formel im Klartext, violett = Excel,
amber = Selbstkontrolle, rose = hier steckt ein Fehler drin. Die
Leitfarbe liegt links als 5px-Balken, damit sie auch im Ausdruck und in
der Druckvorschau erkennbar bleibt. */
.card-schnellstart,
.card-formel,
.card-excel,
.card-quiz,
.card-denkfehler { border: 1px solid var(--border); border-left-width: 5px; }
.card-schnellstart { background: var(--tint-emerald); border-left-color: var(--emerald); }
.card-formel { background: var(--tint-cyan); border-left-color: var(--cyan); }
.card-excel { background: var(--tint-violett); border-left-color: var(--violett); }
.card-quiz { background: var(--tint-amber); border-left-color: var(--amber); }
.card-denkfehler { background: var(--tint-amber); border-left-color: var(--rose); }
/* Die Karten enthalten haeufig Tabellen (Formel-Uebersetzer) und
Codebloecke (Schnellstart, Denkfehler). Beide sollen sich klar vom
getoenten Kartengrund abheben statt darin zu verschwimmen. */
.card-schnellstart pre,
.card-denkfehler pre { margin: .8em 0 !important; }
.card-formel table { margin: .6em 0; background: var(--surface); }
.card-formel td:first-child { white-space: nowrap; }
/* --- Interaktive Plotly-Figuren ---------------------------------------
Stehen im Text unmittelbar hinter der statischen Fassung (die im PDF
gedruckt wird). Auf schmalen Displays darf die Figur horizontal
scrollen, statt die Seite selbst breit zu machen. */
.plotly-figur { margin: 1.4em 0; background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: .6em; overflow-x: auto; }
.plotly-figur .plotly-graph-div { min-width: 320px; }
/* Der Formel-Uebersetzer ist zweispaltig gedacht: links die Mathematik,
rechts der Klartext. Auf schmalen Displays wuerde die Formelspalte die
Tabelle sonst aus dem Viewport druecken. */
@media (max-width: 640px) {
.card-formel td:first-child { white-space: normal; }
}
.kartenraster { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 1rem; margin: 1rem 0 2rem; }
.karte { display: block; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.1rem; box-shadow: var(--shadow); text-decoration: none; color: var(--text); }
.karte:hover { border-color: var(--accent); transform: translateY(-2px); transition: transform .15s, border-color .15s; }
.karte h3 { margin: .3em 0; color: var(--text); font-size: 1.05rem; }
.karte p { color: var(--muted); font-size: .88rem; margin: .4em 0; }
.karte-nummer { font-size: .78rem; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: .03em; }
.karte-zeit { font-size: .8rem; color: var(--muted); }
.karte-pfad { background: var(--tint-violett); border-color: var(--violett); }
.karte-einstieg { background: var(--tint-cyan); border-color: var(--cyan); }
.kapitelgruppe { margin: 2.5rem 0; }
.kapitelgruppe h2 { color: var(--accent); border-bottom: 2px solid var(--border); padding-bottom: .3em; }
.lernpfade h2 { color: var(--violett); }
/* --- Landing-Page Hero --- */
.hero { margin: 1rem 0 2.5rem; }
.hero-kicker { color: var(--cyan); letter-spacing: .12em; font-weight: 700; font-size: .85rem; }
.hero-text h1 { font-size: 2.1rem; color: var(--accent); margin: .2em 0; }
.hero-untertitel { color: var(--muted); font-size: 1.05rem; }
.hero-badges { display: flex; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
.hero-kompendium { color: var(--muted); margin-top: 1.4rem; max-width: 640px; }
.hero-autor { color: var(--muted); font-size: .92rem; line-height: 1.5; }
.badge { padding: .35em .9em; border-radius: 20px; color: #fff; font-weight: 600; font-size: .85rem; }
.badge-indigo { background: var(--accent); }
.badge-cyan { background: var(--cyan); }
.badge-violett { background: var(--violett); }
.badge-emerald { background: var(--emerald); }
/* --- Programme-Seite --- */
.programmgruppe { margin: 2rem 0; }
.programmliste { list-style: none; padding: 0; }
.programmliste li { display: flex; align-items: center; justify-content: space-between; padding: .5em .8em; border: 1px solid var(--border); border-radius: 8px; margin: .4em 0; background: var(--surface); }
/* --- Stichwortverzeichnis --- */
.register-sprungleiste { display: flex; flex-wrap: wrap; gap: .3rem; margin: 1.2rem 0 1.8rem; position: sticky; top: 3.6rem; background: var(--bg); padding: .5rem 0; z-index: 5; }
.register-sprungleiste a { display: inline-flex; align-items: center; justify-content: center; width: 1.8rem; height: 1.8rem; border-radius: 6px; background: var(--surface2); color: var(--accent); text-decoration: none; font-weight: 700; font-size: .85rem; }
.register-sprungleiste a:hover { background: var(--tint-indigo); }
.register-buchstabe { color: var(--accent); border-bottom: 2px solid var(--border); padding-bottom: .15em; margin-top: 2em; font-size: 1.3rem; }
.register-eintrag { padding: .55em 0; border-bottom: 1px solid var(--border); }
.register-begriff { font-weight: 700; }
.register-eintrag a { font-size: .92rem; }
.register-unter { list-style: none; margin: .4em 0 0; padding-left: 1.4em; }
.register-unter li { margin: .3em 0; color: var(--muted); font-size: .92rem; }
.register-unter a { font-size: inherit; }
/* --- Mobile --- */
@media (max-width: 899px) {
.sidebar {
position: fixed; top: 0; left: 0; height: 100vh; z-index: 50; transform: translateX(-105%);
transition: transform .2s ease; box-shadow: var(--shadow-lg); padding-top: 3.6rem;
}
.sidebar.offen { transform: translateX(0); }
.sidebar-overlay.offen { display: block; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 40; }
.content { padding: 1.2rem 1rem 4rem; }
.site-search { max-width: none; }
}

211
web_04/assets/site.js Normal file
View file

@ -0,0 +1,211 @@
// site.js — Interaktivität der Mehrseiten-Website "Operations Research mit
// Python": Dark-Mode-Toggle, mobile Sidebar, Volltextsuche, Lernfortschritt,
// Code-Kopieren/Herunterladen. Reines Vanilla-JS, keine Abhaengigkeiten.
(function () {
"use strict";
var LS_THEME = "or-theme";
var LS_FORTSCHRITT = "or-fortschritt";
function ladeFortschritt() {
try {
return JSON.parse(localStorage.getItem(LS_FORTSCHRITT) || "{}");
} catch (e) {
return {};
}
}
function speichereFortschritt(daten) {
try {
localStorage.setItem(LS_FORTSCHRITT, JSON.stringify(daten));
} catch (e) {}
}
// --- Dark-Mode-Toggle -------------------------------------------------
function initTheme() {
var btn = document.getElementById("theme-toggle");
if (!btn) return;
btn.addEventListener("click", function () {
var aktuell = document.documentElement.getAttribute("data-theme");
var dunkelSystem = window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
var istDunkel = aktuell ? aktuell === "dark" : dunkelSystem;
var neu = istDunkel ? "light" : "dark";
document.documentElement.setAttribute("data-theme", neu);
try { localStorage.setItem(LS_THEME, neu); } catch (e) {}
});
}
// --- Mobile Sidebar -----------------------------------------------------
function initSidebar() {
var sidebar = document.getElementById("sidebar");
var overlay = document.getElementById("sidebar-overlay");
var knopf = document.getElementById("sidebar-toggle");
if (!sidebar || !overlay || !knopf) return;
function schliessen() {
sidebar.classList.remove("offen");
overlay.classList.remove("offen");
overlay.hidden = true;
}
knopf.addEventListener("click", function () {
var offen = sidebar.classList.toggle("offen");
overlay.hidden = !offen;
overlay.classList.toggle("offen", offen);
});
overlay.addEventListener("click", schliessen);
sidebar.querySelectorAll("a").forEach(function (a) {
a.addEventListener("click", schliessen);
});
// Aktuelle Kapitelgruppe aufklappen, falls noch nicht offen.
var aktiv = sidebar.querySelector("li.aktiv");
if (aktiv) {
var details = aktiv.closest("details");
if (details) details.open = true;
// Nur den Sidebar-eigenen Scrollbereich verschieben, NICHT
// aktiv.scrollIntoView() verwenden: das wuerde auch das ganze Fenster
// scrollen und einen #anker-Sprung aus der URL (z.B. von einem
// Querverweis) wieder zunichtemachen.
sidebar.scrollTop = Math.max(0, aktiv.offsetTop - sidebar.clientHeight / 2);
}
}
// --- Lernfortschritt ------------------------------------------------
function initFortschritt() {
var daten = ladeFortschritt();
document.querySelectorAll('.sidebar li[data-kapitel]').forEach(function (li) {
if (daten[li.dataset.kapitel]) li.dataset.erledigt = "1";
});
var knopf = document.querySelector(".fortschritt-knopf");
if (!knopf) return;
var kapitel = knopf.dataset.kapitel;
function aktualisiereKnopf() {
var erledigt = !!daten[kapitel];
knopf.classList.toggle("erledigt", erledigt);
knopf.querySelector("span").textContent =
erledigt ? "Als gelesen markiert" : "Als gelesen markieren";
var eintrag = document.querySelector(
'.sidebar li[data-kapitel="' + kapitel + '"]');
if (eintrag) eintrag.dataset.erledigt = erledigt ? "1" : "0";
}
aktualisiereKnopf();
knopf.addEventListener("click", function () {
daten[kapitel] = !daten[kapitel];
speichereFortschritt(daten);
aktualisiereKnopf();
});
}
// --- Suche ------------------------------------------------------------
function initSuche() {
var eingabe = document.getElementById("suche-eingabe");
var ergebnisBox = document.getElementById("suche-ergebnisse");
if (!eingabe || !ergebnisBox) return;
// window.OR_SEARCH_INDEX kommt aus assets/search-index.js (als normales
// <script> geladen, nicht per fetch()) - fetch() auf lokale Dateien
// scheitert unter file:// (Seite ohne Webserver geoeffnet) an CORS.
var index = window.OR_SEARCH_INDEX || [];
function anzeigen(treffer) {
if (!treffer.length) {
ergebnisBox.innerHTML = '<p class="suche-leer">Keine Treffer.</p>';
ergebnisBox.hidden = false;
return;
}
ergebnisBox.innerHTML = treffer.slice(0, 20).map(function (t) {
var href = t.seite + (t.anker ? "#" + t.anker : "");
var snippet = t.text ? "<small>" + t.text + "</small>" : "";
return '<a href="' + href + '">' + t.titel + snippet + "</a>";
}).join("");
ergebnisBox.hidden = false;
}
eingabe.addEventListener("input", function () {
var q = eingabe.value.trim().toLowerCase();
if (q.length < 2) { ergebnisBox.hidden = true; return; }
var treffer = index.filter(function (e) {
return (e.titel && e.titel.toLowerCase().indexOf(q) !== -1) ||
(e.text && e.text.toLowerCase().indexOf(q) !== -1);
});
anzeigen(treffer);
});
document.addEventListener("click", function (ev) {
if (!ergebnisBox.contains(ev.target) && ev.target !== eingabe) {
ergebnisBox.hidden = true;
}
});
eingabe.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") { ergebnisBox.hidden = true; eingabe.blur(); }
});
}
// --- Code: Kopieren-Button (+ Herunterladen, falls Programmdatei bekannt) --
function initCodeButtons() {
// window.OR_PROGRAMME kommt aus assets/programme.js (normales <script>,
// kein fetch() - siehe initSuche()).
var programmListe = window.OR_PROGRAMME || [];
document.querySelectorAll("div.sourceCode").forEach(function (box) {
var code = box.querySelector("code");
if (!code) return;
var leiste = document.createElement("div");
leiste.className = "code-btn-leiste";
var kopieren = document.createElement("button");
kopieren.type = "button";
kopieren.className = "code-btn";
kopieren.setAttribute("aria-label", "Code kopieren");
kopieren.innerHTML =
'<svg class="icon"><use href="#icon-copy"></use></svg><span>Kopieren</span>';
kopieren.addEventListener("click", function () {
navigator.clipboard.writeText(code.innerText).then(function () {
var label = kopieren.querySelector("span");
var original = label.textContent;
kopieren.classList.add("copied");
label.textContent = "Kopiert!";
setTimeout(function () {
kopieren.classList.remove("copied");
label.textContent = original;
}, 1500);
});
});
leiste.appendChild(kopieren);
box.appendChild(leiste);
// Erste Codezeile "# Name.py" (nach Shebang) -> Download-Button, wenn
// eine passende Datei unter programme/ existiert (Programme.py werden
// beim Bau nach OR_HTML/programme/ kopiert, siehe build_version_03.py).
var text = code.innerText;
var treffer = text.match(/^#\s*([A-Za-z0-9_]+\.py)\s*$/m);
if (treffer && programmListe.indexOf(treffer[1]) !== -1) {
var link = document.createElement("a");
link.className = "code-btn";
link.href = "programme/" + treffer[1];
link.setAttribute("download", "");
link.innerHTML =
'<svg class="icon"><use href="#icon-download"></use></svg><span>Herunterladen</span>';
leiste.appendChild(link);
}
});
}
document.addEventListener("DOMContentLoaded", function () {
initTheme();
initSidebar();
initFortschritt();
initSuche();
initCodeButtons();
});
// Bilder ohne width/height-Attribut (siehe Kapitelseiten) reflowen den
// Text erst, wenn sie fertig geladen sind - das passiert erst NACH
// DOMContentLoaded. Ein #anker-Sprung aus der URL (Querverweis, Such-
// treffer, Stichwortverzeichnis) kann dadurch zu weit oben landen. Nach
// vollstaendigem Laden (inkl. Bilder) hier zur Sicherheit erneut hinspringen.
window.addEventListener("load", function () {
if (!location.hash) return;
var ziel = document.getElementById(decodeURIComponent(location.hash.slice(1)));
if (ziel) ziel.scrollIntoView({ behavior: "instant", block: "start" });
});
})();

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
web_04/katex/katex.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
web_04/katex/katex.min.js vendored Normal file

File diff suppressed because one or more lines are too long