Compare commits

..
34 changed files with 24 additions and 5136 deletions

View file

@ -1,20 +0,0 @@
# 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,8 +22,3 @@ svg-inkscape/
Operations_Research_mit_Python_Version_04_Programme/output/
produktionsmix.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,9 +25,6 @@ Dieses Verzeichnis ist die Wurzel; alle Skripte leiten ihre Pfade daraus ab
```
Operations_Research_mit_Python_Version_04/ Quelle: 37 Kapiteldateien + Build-Skripte
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.pdf generiert: PDF (xelatex)
OR_HTML_04/ generiert: Mehrseiten-Website
@ -42,11 +39,7 @@ LICENSE, LICENSE-TEXT.md MIT fuer Code, CC BY-SA 4.0 fuer
pandoc-defaults-basis.yaml, pandoc/, pandoc-defaults-buch.yaml PDF-Konfiguration
```
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.
Nur die als **Quelle** markierten Verzeichnisse werden von Hand bearbeitet.
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

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,6 @@ import functools
import json
import os
import re
import shutil
import subprocess
import sys
import zipfile
@ -72,19 +71,6 @@ 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)
@ -388,49 +374,6 @@ 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
@ -1421,7 +1364,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(WEB_VERZ, "katex")):
if os.path.exists(os.path.join(html_verz, "katex")):
befehl.insert(-2, "--katex=katex/")
if os.path.exists(stil):
befehl.extend(["-H", stil])
@ -1488,14 +1431,10 @@ def icon(name: str, klasse: str = "icon") -> str:
@functools.lru_cache(maxsize=1)
def _lade_icon_sprite_inline() -> str:
"""Liest web_04/assets/icons.svg und liefert nur die <symbol>-Definitionen
"""Liest assets/icons.svg und liefert nur die <symbol>-Definitionen
(ohne die aeussere <svg>-Huelle) zum Einbetten in jede Seite - siehe
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")
icon()."""
pfad = os.path.join(HTML_VERZ, "assets", "icons.svg")
with open(pfad, encoding="utf-8") as f:
inhalt = f.read()
innen = re.search(r"<svg[^>]*>(.*)</svg>", inhalt, re.DOTALL)
@ -1551,50 +1490,6 @@ 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.
@ -1756,7 +1651,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(WEB_VERZ, "katex")):
if os.path.exists(os.path.join(html_verz, "katex")):
befehl.append("--katex=katex/")
ergebnis = subprocess.run(befehl, cwd=BASIS, input=text,
capture_output=True, text=True, check=True)
@ -1830,20 +1725,14 @@ def resolve_plotly(text: str, fuer_html: bool) -> tuple[str, bool]:
return PLOTLY_RE.sub(ersetze, text), eingebettet
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.
def kopiere_plotly_bibliothek(html_verz: str) -> bool:
"""Kopiert die von plotly mitgelieferte plotly.min.js nach assets/.
Bewusst lokal statt per CDN: Die Website soll komplett offline
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")
funktionieren - genau wie die bereits lokal eingebundenen KaTeX-Assets."""
ziel = os.path.join(html_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__),
@ -2376,33 +2265,18 @@ 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": {}, "unter": {}})
knoten = haupt.setdefault(haupt_name, {"ziele": set(), "unter": {}})
if unter_name:
unter_name = unter_name.rsplit("@", 1)[-1].strip()
knoten["unter"].setdefault(unter_name, {})[ziel] = None
knoten["unter"].setdefault(unter_name, set()).add(ziel)
else:
knoten["ziele"][ziel] = None
knoten["ziele"].add(ziel)
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)
def ziel_links(ziele: set) -> str:
eintraege = sorted(ziele, key=lambda z: (z[0], z[2]))
return ", ".join(
f'<a href="{seite}{"#" + anker if anker else ""}">{kontext}</a>'
for (seite, kontext), anker in sorted(je_kapitel.items()))
for seite, anker, kontext in eintraege)
namen = sorted(haupt.keys(), key=_sortierschluessel)
sprungleiste = " ".join(
@ -2472,13 +2346,12 @@ 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)

View file

@ -1,342 +0,0 @@
#!/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,99 +2024,14 @@ 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.
**Nebenbefund: `OR_HTML_04/assets/site.css` ist eine Quelle, keine erzeugte Datei.** Der
Build schreibt sie nicht. Dasselbe gilt für `site.js`, `icons.svg`, `plotly.min.js` und
`katex/`. Sie liegen im als „generiert" beschriebenen Verzeichnis, sind aber von Hand
Build schreibt sie nicht (Zeitstempel vom 06.09., während `programme.js` bei jedem Lauf
neu entsteht). Dasselbe gilt für `site.js`, `icons.svg`, `highlight.css` und
`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.
(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
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

View file

@ -115,7 +115,6 @@ 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 |
| `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 |
| `pyproject.toml`, `Dockerfile`, `.dockerignore` | **Quelle**: Abhängigkeitsgruppen und Kurs-Image |
| `pandoc-defaults-*.yaml`, `pandoc/` | **Quelle**: Konfiguration des PDF-Baus |
@ -167,9 +166,8 @@ Prüfung hinterlassen.
| `inkscape` | SVG-Grafiken im PDF | LaTeX bricht bei `\includesvg` ab |
| `dot` (Graphviz) | 5 der 16 Bildgeneratoren | nur beim Neuerzeugen dieser Diagramme nötig |
**`web_04/katex/`** ist externes Material und wird von keinem Skript erzeugt — es liegt
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.
**`OR_HTML_04/katex/`** ist externes Material und wird von keinem Skript erzeugt. Fehlt es,
bleiben die Formeln auf der Website ungesetzt.
**Warum die PDF-Konfiguration doppelt vorliegt:** `pandoc-defaults-basis.yaml` ist eine Kopie
der Benutzerdatei `~/.config/pandoc/defaults.yaml` — mit **relativem** Pfad auf
@ -179,46 +177,6 @@ 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
Jedes Kapitel hat ein Notebook in `Notebooks_04/`, und die Website verlinkt es zum

View file

@ -1,41 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3 KiB

File diff suppressed because one or more lines are too long

View file

@ -1,348 +0,0 @@
/* 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; }
}

View file

@ -1,211 +0,0 @@
// 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" });
});
})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long