- 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}
350 lines
16 KiB
Python
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())
|