operations_research/bilder_04/erzeuge_titelseite.py
dschlueter b7af2f1d9a Version 04 als eigenes Repository
Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python"
(Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im
uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch
Version_03 (eingefroren) verwaltet und Version_04/ ignoriert.

Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit
der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen
Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist.

Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise,
325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren,
25 Notebooks, PDF mit 715 Seiten.

Zusaetzlich in diesem Commit:

* pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api,
  figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt
  unveraendert daneben bestehen. ortools steht in der Grundausstattung,
  highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der
  schlanken Installation gar nicht erst auftreten.

* Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py
  importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo
  steht in requirements.txt, wird aber von keinem Programm importiert,
  sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in
  [empfehlungen]).

* NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es
  ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden
  Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen.

* PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den
  tatsaechlichen aus erzeuge_titelseite.py korrigiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:20:09 +02:00

338 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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>') 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
from datetime import datetime
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 nur noch, wenn sich das SICHTBARE Datum im Fussband
# aendert - also einmal pro Tag.
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 als '5. September 2026' (locale-unabhängig)."""
n = datetime.now()
return f"{n.day}. {MONATE[n.month - 1]} {n.year}"
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 Kritik_und_Verbesserungsvorschlaege/NEUER_TITEL.md; die beiden
# Absaetze KOMPENDIUM und ANWENDUNGEN
# stehen gemeinsam in der grossen Solver-Karte.
#
# Im TITEL steht ein \n: An dieser Stelle wird bewusst umbrochen. Ohne ihn
# wuerde automatisch umbrochen und "mit Python" auseinandergerissen. Die Breite
# jeder Zeile wird in beiden Faellen geprueft.
#
# Warum drei Zeilen und nicht zwei: "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 - das passt bei keiner
# lesbaren Schriftgroesse.
TITEL = "Optimierte\nEntscheidungsfindung\nmit Python"
UNTERTITEL = ("Von der mathematischen Modellierung zu praktischen "
"intelligenten Lösungen für Betrieb, Technik und Finanzmärkte")
KOMPENDIUM = ("Ein praxisorientiertes Kompendium für Entscheider mit "
"Constraint Programming, Vektor- und Matrixmethoden sowie "
"HiGHS, SciPy, Google OR-Tools und CVXPY.")
ANWENDUNGEN = ("Von Personaleinsatz-, Schicht- und Vertretungsplänen, "
"Fahrtrouten, Logistik und Maschinen- und Netzwerkauslastung "
"über Energieverteilung "
"und Ressourcenplanung bis zur Portfoliooptimierung und besseren "
"Entscheidungen an den Finanzmärkten mit Python modellieren, "
"optimieren und belegbar fundierte Entscheidungen treffen.")
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())