operations_research/bilder_04/erzeuge_titelseite.py
dschlueter 81ddb02d5c Vorwort-Signatur (Köln, Datum + Dieter Schlüter) und Versionsnummer v<Std>.<Min> im Stand-Datum
- Vorwort endet mit „Köln, im <aktueller Monat> <Jahr>“ und rechtsbuendigem
  Autornamen (LaTeX flushright + HTML text-align:right)
- Stand-Datum auf Titelseite und Website um Versionsnummer erweitert:
  „Stand: 10. September 2026  v16.09“ — v<Stunde>.<Minute> macht jeden
  Uebersetzungslauf unterscheidbar
- erzeuge_titelseite.py: datum_string() um Versionsnummer erweitert
- build_version_04.py: aktuelles_datum() um Versionsnummer erweitert,
  README_RE-Regex fuer Versionsnummer angepasst
- neues Platzhalter-System {vorwort_datum} analog zu {datum}
2026-09-10 16:12:42 +02:00

350 lines
16 KiB
Python

#!/usr/bin/env python3
# erzeuge_titelseite.py
"""
Baut die Titelseite bilder_04/titelseite.svg (und die HTML-Kopie
OR_HTML_04/bilder_04/titelseite.svg) als SVG mit matplotlib. Das Datum im Fußband
('Stand: <datum> v<Std>.<Min>') wird dynamisch aus datetime.now() gesetzt,
damit es nach jedem Build den aktuellen Stand zeigt.
Die Koordinaten unten sind SVG-Punkte (595.44 x 841.68, A4 @ 72 dpi). Die
y-Achse wird invertiert (set_ylim(841.68, 0)), sodass y von oben zählt und
die Werte direkt mit der alten, von Hand erzeugten titelseite.svg
übereinstimmen.
Design (aus der Vorgängerversion abgeleitet):
- weißer Hintergrund
- 5 farbige Akzentbalken oben
- 7 konzentrische Ringe oben rechts (indigo, transparent)
- violette Trennlinie unter dem Untertitel
- grüne, konkave 'Optimierungskurve' mit rotem Stern-Marker (Optimum)
- große Solver-Karte (hell, violetter Rand) mit vier kleinen farbigen
Solver-Karten (HiGHS indigo, SciPy grün, Google OR-Tools cyan, CVXPY violett)
- indigo Fußband unten mit Autor (weiß) und Stand-Datum (hellviolett)
Aufruf:
python3 bilder_04/erzeuge_titelseite.py
(wird vom Build-Skript build_version_03.py vor dem PDF-/HTML-Bau aufgerufen).
"""
import os
import sys
from datetime import datetime
# Der Titeltext kommt aus NEUER_TITEL.md, gelesen von titeltexte_04.py. Das
# Modul liegt im Quellverzeichnis des Buchs, dieses Skript in bilder_04/ -
# daher der Pfadeintrag. Ein zweiter Parser hier waere genau die Dopplung,
# die dieses Modul beseitigt.
_BASIS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(_BASIS,
"Operations_Research_mit_Python_Version_04"))
from titeltexte_04 import lies_titeltexte
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, FancyBboxPatch, Rectangle
# Reproduzierbare SVG-Ausgabe. Ohne diese beiden Vorkehrungen unterscheiden sich
# zwei Laeufe - und sogar die zwei Kopien AUS DEMSELBEN Lauf - in zwei Punkten:
# * svg.hashsalt: matplotlib wuerfelt sonst die clip-path-IDs je Figure neu.
# * metadata={"Date": None} beim Speichern: sonst steht der Zeitstempel des
# Schreibvorgangs (auf die Mikrosekunde) in den SVG-Metadaten.
# Beides ist unsichtbar, macht aber jeden git-diff unlesbar und die beiden
# Kopien bilder_04/ und OR_HTML_04/bilder_04/ dauerhaft verschieden. Die Datei
# aendert sich jetzt bei jedem Lauf mit neuem Datum oder neuer Uhrzeit.
plt.rcParams["svg.hashsalt"] = "or-mit-python-v04"
# Hardcodierte deutsche Monatsnamen statt strftime("%B") - so ist das Datum
# unabhängig von der gesetzten Locale (de_DE.UTF-8) immer korrekt geschrieben,
# auch in CI-/Docker-/Headless-Umgebungen ohne deutsche Locale.
MONATE = ["Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember"]
# A4 bei 72 dpi -> 595.44 x 841.68 pt (matplotlib figure-Koordinaten).
BREITE, HOEHE = 595.44, 841.68
# Geometrie des Mittelteils. _design() zeichnet danach, _texte() setzt danach -
# beide lesen aus DIESEN Werten, damit Kasten und Text nie auseinanderlaufen.
# Trennlinie und Kartenoberkante sind gegenueber dem urspruenglichen Entwurf
# (336.7 / 368.7) nach oben gerueckt: Der Untertitel braucht seit der
# Titelaenderung nur noch zwei statt vier Zeilen, und der gewonnene Platz kommt
# der Solver-Karte zugute, die jetzt zwei Absaetze traegt.
TRENNLINIE_Y = 308.0
KARTE_X0, KARTE_BREITE = 64.30752, 466.82496
KARTE_Y0, KARTE_Y1 = 340.0, 481.44096
# Solver-Karten (x0, Breite, Farbe, Label) - bewusst NICHT gleich breit:
# "Google OR-Tools" ist deutlich laenger als die anderen Labels und liefe bei
# einheitlicher Breite ueber die Karte hinaus - da das Label weiss ist, wuerde
# der ueberstehende Teil auf dem weissen Seitenhintergrund unsichtbar. Reihe
# beginnt bei x=71.4528 (linker Rand wie Titel/Untertitel) und endet bei
# x=523.9872 (wie die violette Trennlinie), dazwischen 3 Luecken von je 10pt.
KARTEN_Y0, KARTEN_Y1 = 494.06616, 541.20024
KARTEN = [
(71.4528, 94.0, "#4338ca", "HiGHS"),
(175.4528, 94.0, "#059669", "SciPy"),
(279.4528, 140.5344, "#0891b2", "Google OR-Tools"),
(429.9872, 94.0, "#7c3aed", "CVXPY"),
]
def datum_string() -> str:
"""Heutiges Datum+Version als '5. September 2026 v11.55' (locale-unabhängig).
Die Versionsnummer v< Stunde>.<Minute> (24er Format, zweistellig) macht
jeden Übersetzungslauf unterscheidbar — nicht nur einen pro Tag.
"""
n = datetime.now()
return f"{n.day}. {MONATE[n.month - 1]} {n.year} v{n.hour:02d}.{n.minute:02d}"
def _ring(ax, x, y, r):
ax.add_patch(Circle((x, y), r, fill=False, ec="#4338ca", alpha=0.1,
lw=1.4, clip_on=True))
def _design(ax):
# Weißer Hintergrund.
ax.add_patch(Rectangle((0, 0), BREITE, HOEHE, facecolor="white",
edgecolor="none", zorder=0))
# 5 farbige Akzentbalken oben (Leiste), y 34.51..46.29, je 90.51 breit.
balken_farben = ["#4338ca", "#0891b2", "#7c3aed", "#059669", "#d97706"]
for i, farbe in enumerate(balken_farben):
ax.add_patch(Rectangle((71.4528 + i * 90.50688, 34.50888),
90.50688, 11.78352, facecolor=farbe,
edgecolor="none", zorder=1))
# 7 konzentrische Ringe oben rechts, Zentrum (553.76, 84.17).
for r in (42.084, 77.154, 112.224, 147.294, 182.364, 217.434, 252.504):
_ring(ax, 553.7592, 84.168, r)
# Große Solver-Karte (abgerundet): (64.31, 368.66) bis (531.13, 481.44),
# Radius ~11.91 (Q-Offset in der Original-SVG).
ax.add_patch(FancyBboxPatch(
(KARTE_X0, KARTE_Y0), KARTE_BREITE, KARTE_Y1 - KARTE_Y0,
boxstyle="round,pad=0,rounding_size=11.9098",
facecolor="#f5f3ff", edgecolor="#7c3aed", lw=1.6, zorder=1))
# Vier farbige Solver-Karten (HiGHS, SciPy, Google OR-Tools, CVXPY),
# individuelle Breiten (siehe KARTEN), y 494.07..541.20.
for x0, breite, farbe, _label in KARTEN:
ax.add_patch(FancyBboxPatch(
(x0, KARTEN_Y0), breite, KARTEN_Y1 - KARTEN_Y0,
boxstyle="round,pad=0,rounding_size=11.9098",
facecolor=farbe, edgecolor="none", zorder=2))
# Violette Trennlinie unter dem Untertitel, x 71.45..523.99.
ax.plot([71.4528, 523.9872], [TRENNLINIE_Y, TRENNLINIE_Y], color="#7c3aed",
lw=2.5, solid_capstyle="butt", zorder=3)
# Indigo Fußband unten: (0, 757.51) bis (595.44, 841.68).
ax.add_patch(Rectangle((0, 757.512), BREITE, HOEHE - 757.512,
facecolor="#4338ca", edgecolor="none", zorder=1))
# Grüne 'Optimierungskurve' (konkav, abflachend) von (71.45, 673.34) nach
# (523.99, 639.68) - approximiert die dekorative Kurve der Vorgängerversion
# (dort 99 geglättete Stützpunkte, hier eine konkave Wurzelfunktion, die
# denselben visuellen Eindruck erzeugt).
import numpy as np
xs = np.linspace(71.4528, 523.9872, 100)
t = (xs - 71.4528) / (523.9872 - 71.4528)
ys = 673.344 - 33.664 * np.sqrt(t) # konkav: steiler Start, flaches Ende
ax.plot(xs, ys, color="#059669", lw=3, solid_capstyle="butt", zorder=3)
# Roter Stern-Marker ('Optimum') bei (446.28, 641.67) auf der Kurve.
ax.plot([446.279273], [641.670502], marker="*", color="#e11d48",
markersize=12, mec="#e11d48", mfc="#e11d48", zorder=4)
# --- Textumbruch: gemessen statt geschaetzt -------------------------------
# Bis zur Titelaenderung standen alle Textzeilen mit von Hand vermessenen
# Koordinaten hier - uebernommen aus der urspruenglichen, handgezeichneten SVG.
# Das traegt genau so lange, wie sich der Text nicht aendert: Der neue Titel ist
# knapp dreimal so lang wie der alte, der Untertitel mehr als doppelt so lang.
# Deshalb werden Umbruch und Schriftgroesse jetzt GEMESSEN. Wer den Titel das
# naechste Mal aendert, aendert nur noch die Zeichenketten weiter unten.
def _textbreite(ax, text, groesse, fett=False) -> float:
"""Breite eines Textes in Seitenpunkten - ueber den echten Renderer."""
marke = ax.text(0, 0, text, fontsize=groesse,
fontweight="bold" if fett else "normal")
kasten = marke.get_window_extent(
renderer=ax.figure.canvas.get_renderer()).transformed(
ax.transData.inverted())
marke.remove()
return abs(kasten.width)
def _umbrechen(ax, text, groesse, maxbreite, fett=False) -> list[str]:
"""Bricht 'text' so um, dass keine Zeile breiter als 'maxbreite' wird."""
zeilen, zeile = [], ""
for wort in text.split():
probe = f"{zeile} {wort}".strip()
if not zeile or _textbreite(ax, probe, groesse, fett) <= maxbreite:
zeile = probe
else:
zeilen.append(zeile)
zeile = wort
if zeile:
zeilen.append(zeile)
return zeilen
def _setze_block(ax, zeilen, x, y0, groesse, durchschuss, **stil) -> float:
"""Setzt die Zeilen ab Grundlinie y0 und liefert die Grundlinie danach."""
for i, zeile in enumerate(zeilen):
ax.text(x, y0 + i * groesse * durchschuss, zeile,
fontsize=groesse, zorder=5, **stil)
return y0 + (len(zeilen) - 1) * groesse * durchschuss
# --- Die Texte der Titelseite ----------------------------------------------
# Nur diese Zeichenketten aendern, wenn das Buch anders heissen soll. Der Text
# stammt aus Operations_Research_mit_Python_Version_04/NEUER_TITEL.md; die beiden
# Absaetze KOMPENDIUM und ANWENDUNGEN
# stehen gemeinsam in der grossen Solver-Karte.
#
# Sie stehen NICHT hier, sondern in NEUER_TITEL.md - derselben Datei, aus der
# auch die Startseite der Website ihren Hero-Block speist. Vorher trug dieses
# Skript eigene Konstanten, und die Startseite noch einmal eigene; drei
# Titelueberarbeitungen zogen diese hier nach und liessen jene stehen.
#
# Der TITEL kommt einzeilig an und wird von _umbrechen() auf den Satzspiegel
# verteilt. Das ergibt dieselben drei Zeilen wie die frueher handgesetzten
# ("Optimierte" / "Entscheidungsfindung" / "mit Python") - und das ist kein
# Zufall: "Entscheidungsfindung" ist bei 34 pt schon 429 pt breit und damit
# fast so breit wie der ganze Satzspiegel (453 pt). Jede zweizeilige Aufteilung
# braeuchte mindestens 649 pt und passt bei keiner lesbaren Schriftgroesse.
_TEXTE = lies_titeltexte()
TITEL = _TEXTE["titel"]
UNTERTITEL = _TEXTE["untertitel"]
KOMPENDIUM = _TEXTE["kompendium"]
ANWENDUNGEN = _TEXTE["anwendungen"]
# Der Autor steht nicht in der Titelvorlage - sie beschreibt das Buch, nicht
# sein Impressum.
AUTOR = "Autor / Herausgeber: Dieter Schlüter"
# Satzspiegel: linker Rand und rechte Kante wie Trennlinie und Kartenreihe.
LINKS, RECHTS = 71.4528, 523.9872
SATZBREITE = RECHTS - LINKS
# Schriftgroessen und Durchschuss (Faktor auf die Schriftgroesse). Nachgemessen,
# nicht geraten: Bei 34 pt ist "Entscheidungsfindung" 429 pt breit und bleibt
# damit im Satzspiegel von 453 pt; bei 36 pt waeren es 454 pt - eines zu viel.
TITEL_GROESSE, TITEL_DURCHSCHUSS = 34, 1.40
# Untertitel 14 statt 15 pt: Bei 15 pt braucht er drei Zeilen, und die dritte
# traegt nur noch das Wort "Finanzmärkte" - eine Hurenkind-Zeile mitten auf dem
# Titelblatt. Bei 14 pt sind es zwei ausgeglichene Zeilen.
UNTER_GROESSE, UNTER_DURCHSCHUSS = 14, 1.23
# Kartentext 11.5 statt 12 pt: Der Text ist auf neun Zeilen gewachsen; bei 12 pt
# blieben nur noch 9 pt Luft ueber und unter dem Block, und der Text geriete in
# die abgerundeten Kartenecken (Radius 11.9 pt).
KOMP_GROESSE, KOMP_DURCHSCHUSS = 11.5, 1.27
KOMP_BREITE = 440.0 # nutzbare Textbreite in der Karte
# Anker des Satzes.
TITEL_START = 135.0 # erste Grundlinie des Haupttitels
ABSTAND_TITEL_UNTER = 42.0 # Luft zwischen Titel- und Untertitelblock
def _texte(ax, datum):
# Haupttitel: die letzte Zeile ("mit Python") in Anthrazit statt Indigo -
# so bleibt die Zweifarbigkeit des urspruenglichen Entwurfs erhalten, in
# dem "Operations Research" indigo und "mit Python" dunkel gesetzt war.
# Ein \n im TITEL heisst: hier wird umbrochen. Sonst automatisch.
if "\n" in TITEL:
titelzeilen = TITEL.split("\n")
zu_breit = [z for z in titelzeilen
if _textbreite(ax, z, TITEL_GROESSE, True) > SATZBREITE]
if zu_breit:
raise SystemExit(
f"Titelblatt: Diese Titelzeile passt bei {TITEL_GROESSE} pt "
f"nicht in den Satzspiegel ({SATZBREITE:.0f} pt): {zu_breit[0]!r}")
else:
titelzeilen = _umbrechen(ax, TITEL, TITEL_GROESSE, SATZBREITE, fett=True)
for i, zeile in enumerate(titelzeilen):
ax.text(LINKS, TITEL_START + i * TITEL_GROESSE * TITEL_DURCHSCHUSS,
zeile, fontsize=TITEL_GROESSE, fontweight="bold", zorder=5,
color="#1a1a2e" if i == len(titelzeilen) - 1 else "#4338ca")
titel_ende = TITEL_START + (len(titelzeilen) - 1) * TITEL_GROESSE * TITEL_DURCHSCHUSS
# Untertitel direkt darunter; die Zeilenzahl ergibt sich aus dem Umbruch.
unterzeilen = _umbrechen(ax, UNTERTITEL, UNTER_GROESSE, SATZBREITE)
unter_ende = _setze_block(ax, unterzeilen, LINKS,
titel_ende + ABSTAND_TITEL_UNTER,
UNTER_GROESSE, UNTER_DURCHSCHUSS, color="#334155")
# Sicherung: Der Untertitel darf die Trennlinie nicht beruehren. Lieber ein
# Abbruch beim Erzeugen als ein Titelblatt, auf dem Text auf der Linie liegt.
if unter_ende > TRENNLINIE_Y - 8:
raise SystemExit(
f"Titelblatt: Der Untertitel reicht bis y={unter_ende:.1f} und "
f"kollidiert mit der Trennlinie bei y={TRENNLINIE_Y}. "
f"Schriftgroesse oder Startpunkt anpassen.")
# KOMPENDIUM-Label
ax.text(LINKS, 84.168, "K O M P E N D I U M",
fontsize=13, color="#0891b2", zorder=5)
# Die beiden Absaetze in der grossen Solver-Karte, durch eine Leerzeile
# getrennt und als Block vertikal zentriert. Zentriert gesetzt statt links,
# weil die Karte breiter ist als der Satzspiegel.
kartenmitte_x = KARTE_X0 + KARTE_BREITE / 2
kartenzeilen = (_umbrechen(ax, KOMPENDIUM, KOMP_GROESSE, KOMP_BREITE)
+ [""]
+ _umbrechen(ax, ANWENDUNGEN, KOMP_GROESSE, KOMP_BREITE))
hoehe = (len(kartenzeilen) - 1) * KOMP_GROESSE * KOMP_DURCHSCHUSS
start = (KARTE_Y0 + KARTE_Y1) / 2 - hoehe / 2 + KOMP_GROESSE * 0.35
# Sicherung: Der Block darf nicht ueber den Kartenrand hinauslaufen.
if start - KOMP_GROESSE < KARTE_Y0 or start + hoehe > KARTE_Y1 - 4:
raise SystemExit(
f"Titelblatt: Der Kartentext ({len(kartenzeilen)} Zeilen) passt "
f"nicht in die Karte ({KARTE_Y1 - KARTE_Y0:.0f} pt hoch). "
f"Schriftgroesse verkleinern oder Karte vergroessern.")
_setze_block(ax, kartenzeilen, kartenmitte_x, start,
KOMP_GROESSE, KOMP_DURCHSCHUSS, color="#1a1a2e", ha="center")
# Autor (im Fußband, weiß)
ax.text(LINKS, 793.402715, AUTOR,
fontsize=14, color="#ffffff", zorder=5)
# text_7: Stand-Datum (im Fußband, hellviolett) - DYNAMISCH
ax.text(71.4528, 818.05749, f"Stand: {datum}",
fontsize=12, color="#c7d2fe", zorder=5)
# Solver-Namen, zentriert in ihrer Karte (weiß). ha/va="center"
# macht die Zentrierung unabhängig von Schriftmetriken - robuster als
# von Hand vermessene x/y-Offsets.
mitte_y = (KARTEN_Y0 + KARTEN_Y1) / 2
for x0, breite, _farbe, label in KARTEN:
ax.text(x0 + breite / 2, mitte_y, label,
fontsize=13, color="#ffffff", ha="center", va="center",
zorder=5)
def main() -> int:
basis = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ausgaben = [
os.path.join(basis, "bilder_04", "titelseite.svg"),
os.path.join(basis, "OR_HTML_04", "bilder_04", "titelseite.svg"),
]
d = datum_string()
fig = plt.figure(figsize=(8.27, 11.69))
ax = fig.add_axes([0, 0, 1, 1])
ax.set_xlim(0, BREITE)
ax.set_ylim(HOEHE, 0) # invertiert: y zählt von oben (wie SVG)
ax.axis("off")
_design(ax)
_texte(ax, d)
for pfad in ausgaben:
os.makedirs(os.path.dirname(pfad), exist_ok=True)
fig.savefig(pfad, format="svg", metadata={"Date": None})
plt.close(fig)
print(f"Titelseite erzeugt ({d}): {', '.join(ausgaben)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())