#!/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: ') 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 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 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())