Files
boehmitools-training/plugins/trainingsplan/plan_builder.py
T
2026-07-24 21:37:03 +02:00

670 lines
45 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
# -*- coding: utf-8 -*-
"""
plan_builder.py
Rendert den 8-Wochen-Calisthenics-Plan als A4-PDF.
Layout 1:1 wie im Original-Skript, aber alle Inhalte kommen aus einem Config-Dict.
build_pdf(config) -> bytes
default_config() -> dict (Standard-Inhalte)
"""
import copy
from schema_contract import migrate_training_config
from io import BytesIO
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
PageBreak, HRFlowable, KeepTogether)
from reportlab.lib.enums import TA_RIGHT
# =========================================================================
# STANDARD-KONFIGURATION (entspricht exakt dem Original-Skript)
# =========================================================================
DEFAULTS = {
"meta": {
"title": "Calisthenics-Basis 8-Wochen Komplettplan",
"subtitle": "Blutiger Anfänger · 1,90 m · 95 kg · Rucksack + Couch + Kettlebell · tägliche ~30-Min Tabata-Session",
"output_name": "Calisthenics-8-Wochen-Komplett.pdf",
"footer_left": "Calisthenics 8-Wochen Komplettplan \u00b7 mit Kettlebell",
"doc_title": "Calisthenics 8-Wochen Komplettplan (mit Kettlebell)",
"weeks": 8,
},
"colors": {
"INK": "#1A1A1A", "ACCENT": "#C2410C", "ACC2": "#0F766E", "KBCOL": "#6D28D9",
"LINK": "#1565C0", "LIGHT": "#F4F1EC", "GRID": "#CFC9BF", "MUTE": "#6B6256",
"DARK": "#23201C", "WARMBG": "#EAF1F0", "GOALBG": "#FBF1E9", "KBBG": "#F1ECFB",
},
"front": {
"session_how_head": "So funktioniert eine Session",
"session_how_body": (
"<b>Tabata-Block = 4 Min:</b> 10 Sek einzählen, dann 8× (20 Sek Arbeit / 10 Sek Pause). "
"Pro Block rotieren <b>4 Übungen</b>, je <b>2 Durchgänge</b> = 8 Intervalle = 1 Block = <b>1 Satz</b>. "
"Eine Rotation läuft <b>3 Sätze</b> (in Woche 1 ruhig mit 2 starten). Warm-up steht über, Cool-down unter den Übungen."
),
"flow_rows": [
["Teil", "Inhalt", "Zeit"],
["Warm-up", "Gelenk-Prep (dynamisch)", "~3 Min"],
["Rotation A", "4 Übungen, 3 Sätze (kurze Pausen)", "~14 Min"],
["Rotation B", "4 Übungen, 3 Sätze (kurze Pausen)", "~14 Min"],
["Cool-down", "Ausklingen + Dehnen", "~6 Min"],
["", "Gesamt", "~35 Min"],
],
"timer_note": "<b>Timer:</b> „Tabata / Interval Timer“ auf 10s Vorlauf, 8 Runden, 20/10.",
"kb_head": "Warum Kettlebell und warum es zu deinem Ziel passt",
"kb_box": (
"Du trainierst für <b>Calisthenics</b>, nicht für Maximalkraft. Genau da liegen die <b>belegten</b> Stärken der Kettlebell "
"ohne dass dich ihr dokumentierter Schwachpunkt (Maximalkraft/Hypertrophie wegen fixer Gewichtssprünge) trifft:<br/>"
"•  <b>Griffkraft</b> (Studie BELL: +67 kg) dein größter Mehrwert auf dem Weg zu Klimmzug &amp; Dip.<br/>"
"•  <b>Power / ballistische Hüftstreckung</b> (Swing) explosive Basis für spätere Skills.<br/>"
"•  <b>Kondition &amp; Körperfett</b> wirksam belegt; du startest mit wenig Ausdauer.<br/>"
"•  <b>Ganzkörper, ein Gerät, zeiteffizient</b> passt ideal ins 30-Min-Tabata-Format; niedriges Einstiegs-Verletzungsrisiko bei sauberer Technik.<br/>"
"<b>Ehrlich:</b> Reines KB ersetzt <u>kein</u> Maximalkraft-/Muskelaufbau-Programm. Für dein Ziel (Basis-Fundament) liegt das ohnehin unter der relevanten Schwelle."
),
"goals_head": "Realistische Ziele nach 8 Wochen",
"goals_box": (
"Du startest bei 23 Liegestützen und 0 Dips. Realistisch und ehrlich nach 8 Wochen sauberer Arbeit:<br/>"
"•  <b>Liegestütze:</b> von 23 auf erste saubere <b>volle</b> Reps (Rest in Knie-/Negativ-Form).<br/>"
"•  <b>Bank-Dips:</b> saubere Reps mit Füßen etwas weiter weg.   <b>Pike Push-up:</b> am Boden mit kleinem ROM.<br/>"
"•  <b>Plank ~4560 s</b>, <b>Wall Sit ~60 s</b>, viele saubere <b>Goblet-Kniebeugen</b>, sauberer <b>Swing</b> + spürbar bessere <b>Griffkraft</b>.<br/>"
"•  <b>Noch NICHT dran:</b> Diamond Push-ups, L-Sit, Handstand, schwerer Turkish Get-Up, Snatch Stoff für Woche 12+."
),
"goals_note": (
"<b>Wichtig:</b> Die Wochen-Stufen sind ein <b>Rahmen, kein Muss</b>. Bleib auf einer Stufe, solange sie fordert. "
"<b>Aufstiegs-Regel:</b> erst hoch (oder schwerere Glocke), wenn du eine Übung 2 Sessions in Folge in allen Sätzen sauber über die vollen 20s schaffst. "
"<font color='#6D28D9'><b>KB = Kettlebell bevorzugt; Rucksack bleibt als Alternative.</b></font>"
),
"lib_head": "Übungs-Bibliothek (mit Anleitungen)",
"lib_intro": (
"Für Übungen, die du noch nicht kennst, ist ein Link zu einer bebilderten Anleitung dabei. "
"<font color='#6D28D9'>Lila Gruppe = Kettlebell-Übungen.</font>"
),
"baseline_head": "Standortbestimmung &amp; Tracking",
"baseline_intro": "Mach die Tests <b>vor Tag 1</b> und in <b>Woche 8 identisch</b> nochmal dein Vorher/Nachher.",
"baseline_rows": [
["Test", "Tag 0", "Woche 8"],
["Liegestütze (max, saubere Reps)", "", ""],
["Bank-Dips an der Couch (max)", "", ""],
["Kniebeugen in 60 Sekunden", "", ""],
["Ausfallschritte (max gesamt)", "", ""],
["Plank (max Sekunden)", "", ""],
["Wall Sit (max Sekunden)", "", ""],
["KB Farmer-Hold (max Sekunden, Griff)", "", ""],
["Körpergewicht (kg)", "95", ""],
],
"reminder_head": "Kurz-Reminder",
"reminders": [
"<b>Form vor Reps.</b> Wähle die Stufe / Glocke, bei der du die 20s kontrolliert durcharbeitest mit 12 Reps Reserve.",
"<b>Swing-Technik first.</b> Der Swing ist ein Hüft-Schnapp, KEIN Kniebeuge und KEIN Armheben. Erst Dead-Stop-Swings, bis die Hüftstreckung sitzt.",
"<b>Einseitige Übungen (Tag 2):</b> Durchgang 1 = links, Durchgang 2 = rechts so ist jede Seite automatisch ausbalanciert.",
"<b>Täglich ist viel.</b> Tag 5 leicht, Tag 7 frei. Bei starkem Muskelkater einen Extra-Ruhetag gehört zum Aufbau.",
"<b>Schlaf &amp; Eiweiß.</b> 79 h Schlaf, ~1,6 g Eiweiß pro kg. Stechender Schmerz = stoppen.",
],
"sheets_note": (
"Ab der nächsten Seite: 1 Druckblatt pro Session (Woche 18). <font color='#6D28D9'>Tag 6 = reine Kettlebell-Session.</font> "
"Warm-up steht über, Cool-down + Dehnen unter den Übungen. Bei Vorerkrankungen/Verletzungen vorher ärztlich abklären."
),
},
"library": [
{"group": "DRÜCKEN (Brust / Schultern / Trizeps)", "color": "ACC2", "items": [
{"name": "Liegestütze", "desc": "Stütz, Körper gerade, Brust kontrolliert senken &amp; drücken. Stufen: Inkline (Couch), Knie, Negativ, Voll.", "url": "https://blog.calimove.com/2023/01/25/push-up-variations-beginner-expert/"},
{"name": "Bank-Dips (Couch)", "desc": "Rücken zur Couch, Hände auf der Kante, Po davor absenken (Ellbogen nach hinten), hochdrücken. Knie beugen = leichter.", "url": "https://www.healthline.com/health/fitness-exercise/bench-dips"},
{"name": "Pike Push-up / Hold", "desc": "Umgekehrtes V (Hintern hoch), Kopf Richtung Boden senken, hochdrücken. Schulter-Fokus. Hände erhöht = leichter.", "url": "https://www.hingehealth.com/resources/articles/pike-push-up/"},
{"name": "Seitheben (KB/Rucksack)", "desc": "Arme seitlich bis Schulterhöhe heben, Ellbogen leicht gebeugt, kontrolliert senken. Seitlicher Deltamuskel.", "url": ""},
]},
{"group": "ZIEHEN (Rücken / Bizeps KB oder Rucksack + Boden)", "color": "ACC2", "items": [
{"name": "Einarm-Rudern ENG", "desc": "Hüfte beugen, Rücken GERADE, abgestützt. Ellbogen an den Rippen, Zug zur Hüfte Lat.", "url": "https://www.puregym.com/exercises/back/rows/dumbbell-bent-over-row/"},
{"name": "Einarm-Rudern BREIT", "desc": "Gleiche Position, aber Ellbogen ~45° geöffnet, Zug zum Brustkorb oberer Rücken / hinterer Delta.", "url": "https://www.puregym.com/exercises/back/rows/dumbbell-bent-over-row/"},
{"name": "Bizeps-Curls (KB/Rucksack)", "desc": "Last unten greifen, Oberarme fix am Körper, kontrolliert zu den Schultern curlen.", "url": ""},
{"name": "Superman / Reverse Snow Angels / Y-T-W", "desc": "Bäuchlings Arme+Beine heben (Superman). Snow Angels = Arme am Boden auf/ab. Rücken ohne Stange.", "url": "https://www.calixpert.com/exercises/hollow-body-to-superman-hold"},
]},
{"group": "BEINE", "color": "ACC2", "items": [
{"name": "Kniebeuge / Goblet Squat (KB/Rucksack)", "desc": "Hüfte zurück, Oberschenkel parallel, Fersen am Boden. Goblet = Glocke/Rucksack vor der Brust umarmen.", "url": ""},
{"name": "Ausfallschritte", "desc": "Großer Schritt (rückwärts = knieschonend), hinteres Knie Richtung Boden. Anfangs ggf. festhalten.", "url": ""},
{"name": "RDL / Hip Hinge (KB/Rucksack)", "desc": "Knie leicht gebeugt, Hüfte nach hinten, Rücken gerade, Last entlang der Beine senken bis Dehnung hinten (Hamstrings).", "url": ""},
{"name": "Glute Bridge / Wadenheben / Wall Sit", "desc": "Bridge: Hüfte hochdrücken (1-beinig steigern). Waden: Zehenspitzen hoch/runter. Wall Sit: Rücken an die Wand, halten.", "url": ""},
]},
{"group": "KETTLEBELL (Power / Griff / Ganzkörper)", "color": "KBCOL", "items": [
{"name": "Swing (zweiarmig, Hardstyle)", "desc": "<b>Wichtigste KB-Übung.</b> Hüfthinge, Glocke aus der Hüfte explosiv auf Brusthöhe schnappen (kein Armheben), oben Po+Bauch fest. Dead-Stop zum Lernen.", "url": "https://www.onepeloton.com/blog/kettlebell-swing/"},
{"name": "Goblet Squat (KB)", "desc": "Glocke an den Hörnern vor der Brust umarmen, tief und aufrecht hocken. Beste Einsteiger-KB-Kniebeuge.", "url": "https://www.onepeloton.com/blog/goblet-squat/"},
{"name": "KB-Deadlift (beidbeinig)", "desc": "Glocke zwischen den Füßen, Hüfte hinge, Rücken gerade, vom Boden heben und Hüfte durchstrecken. Stabile Swing-Vorbereitung.", "url": "https://www.onepeloton.com/blog/kettlebell-deadlift/"},
{"name": "KB Floor Press", "desc": "Rücklings am Boden, Glocke vom Boden weg nach oben drücken. Schulterschonend, gut für Anfänger.", "url": "https://www.masterclass.com/articles/kettlebell-floor-press-guide"},
{"name": "Halo", "desc": "Glocke (oder Rucksack) vor der Brust, langsam eng um den Kopf kreisen. Schulter-Mobility + Core.", "url": "https://www.tomsguide.com/how-to/kettlebell-halo-exercise-how-to-do-it-and-the-benefits-for-your-core-and-shoulder-muscles"},
{"name": "Bottoms-up KB-Hold / Carry", "desc": "Glocke am Griff, Boden nach oben, Unterarm senkrecht, still halten. Schwierigkeit kommt aus der Balance fordert <b>Griff + Handgelenk</b> auch mit leichter Glocke.", "url": "https://www.onepeloton.com/blog/farmers-carry/"},
{"name": "Around-the-World + Uppercut", "desc": "Glocke kreist um den Körper (Around-the-World); danach diagonal nach oben zur gegenüberliegenden Schulter/Brust führen, die andere Hand fängt ab (wie ein Uppercut von unten). Rotatorischer Core + Koordination. Erst nur das Umkreisen lernen, Uppercut später.", "url": ""},
]},
{"group": "CORE", "color": "ACC2", "items": [
{"name": "Plank / Side Plank", "desc": "Unterarmstütz, Körper gerade. Anfangs auf den Knien. Side Plank seitlich auf einem Unterarm.", "url": ""},
{"name": "Hollow Hold", "desc": "Auf dem Rücken, unterer Rücken am Boden, Arme+Beine leicht angehoben. Anfänger: angewinkelt (Tuck).", "url": "https://www.calixpert.com/exercises/hollow-body-to-superman-hold"},
{"name": "Dead Bug", "desc": "Auf dem Rücken, Arme+Knie 90°, gegengleich Arm+Bein langsam ausstrecken, unterer Rücken bleibt am Boden.", "url": "https://dieringe.com/exercises/dead-bug"},
{"name": "Bird Dog", "desc": "Vierfüßler, gegengleich Arm+Bein ausstrecken, Rumpf stabil, kurz halten.", "url": "https://www.hingehealth.com/resources/articles/bird-dog/"},
{"name": "Mountain Climbers / Bicycle / Russian Twist / Beinheben", "desc": "Climbers: Knie zügig zur Brust. Bicycle: Ellbogen zum Gegenknie. Twist: sitzend drehen (KB). Beinheben: Knie gebeugt = leichter.", "url": ""},
]},
{"group": "WARM-UP &amp; MOBILITY", "color": "ACC2", "items": [
{"name": "Inchworm", "desc": "Aus dem Stand vorbeugen, Hände nach vorne in den Stütz wandern, dann zurück.", "url": "https://www.onepeloton.com/blog/inchworm-exercise"},
{"name": "Jacks / High Knees / Kreisen / Cat-Cow", "desc": "Klassische dynamische Aufwärm-Bewegungen locker, zum Warmwerden.", "url": ""},
]},
],
"phases": {
"names": ["Phase 1 · Fundament", "Phase 2 · Aufbau", "Phase 3 · Steigerung", "Phase 4 · Festigen"],
"params": [
"Fokus: Technik (v.a. Swing als Dead-Stop). 23 Reps Reserve. Pausen bis ~60s. Leichte Glocke. Woche 1 mit 2 Sätzen pro Rotation.",
"Sauber bleiben, letzter Satz fordernd. Pausen ~4560s. Etwas schwerere Glocke / mehr Reps. 23 Sätze.",
"Fordernd, aber 1 Rep Reserve. Pausen ~45s. Schwerere Glocke / einarmig wo angegeben. 3 Sätze.",
"Fordernd &amp; sauber (NICHT bis zum Muskelversagen). Pausen ~45s. Ende Woche 8 leichter (Deload). 3 Sätze.",
],
"param_light": "Bewusst locker aktive Erholung. Saubere, leichte Wiederholungen, keine Belastung bis zum Limit.",
},
"stages": {
'ls': ['Inkline (hoch)', 'Inkline tiefer / Knie', 'Knie / Negativ', 'Negativ + erste Volle'],
'dips': ['Knie stark gebeugt, klein', 'Knie gebeugt, voll', 'Füße etwas weiter', 'Füße weiter weg'],
'pike': ['Pike Hold', 'Hold + kleine Senkung', 'Push-up Hände erhöht', 'Push-up Boden, klein'],
'lseng': ['Inkline (hoch)', 'Inkline mittel', 'Inkline tief', 'Knie-Liegestütze'],
'load': ['leicht', 'etwas mehr', 'mehr', 'fordernd'],
'squat': ['Körpergewicht', 'Körpergewicht, mehr Reps', 'Goblet leicht', 'Goblet mittel'],
'tsquat': ['3s runter', '3s + Pause unten', 'Goblet 3s', 'Goblet 3s + Pause'],
'lunge': ['Körpergewicht (ggf. festhalten)', 'Körpergewicht', 'mehr Reps', '+ Last leicht'],
'bridge': ['beidbeinig', 'beidbeinig, 2s halten', 'einbeinig', 'einbeinig, 2s halten'],
'wall': ['1525 s', '2540 s', '4055 s', '5570 s'],
'calf': ['Körpergewicht', 'langsam', 'langsam, mehr Reps', '1-beinig'],
'sup': ['Superman Hold', 'Superman Hold länger', 'Reverse Snow Angels', 'Y-T-W Raises'],
'plank': ['auf Knien 1520 s', 'voll 2030 s', 'voll + Schulter-Taps', 'voll, länger'],
'mtn': ['moderat', 'moderat', 'zügig', 'zügig'],
'db': ['Arme ODER Beine einzeln', 'gegengleich', 'gegengleich + Pause', 'Beine weiter gestreckt'],
'lr': ['Knie gebeugt', 'Knie gebeugt, langsam', 'halb gestreckt', 'gestreckt'],
'holl': ['Tuck (Knie an)', 'Tuck, länger', 'halb gestreckt', 'halb/voll'],
'sp': ['auf Knien', 'auf Knien, länger', 'voll', 'voll, länger'],
'bic': ['langsam', 'moderat', 'moderat', 'zügig'],
'tw': ['ohne Gewicht', 'ohne Gewicht', 'KB leicht', 'KB'],
'swing': ['Dead-Stop, leicht (Technik)', 'Dead-Stop, mehr Reps', 'durchgehend, mittel', 'durchgehend, schwerer'],
'gobletkb': ['leicht, Tiefe lernen', 'leicht, mehr Reps', 'mittel', 'mittel, 3s runter'],
'press': ['leicht (Floor Press)', 'Floor Press mehr', 'Overhead leicht', 'Overhead mittel'],
'halo': ['leicht, langsam', 'leicht', 'mittel', 'mittel, langsam'],
'carry': ['im Stand 1015s', '1520s, ruhig', 'bis Schulterhöhe 15s', 'länger / ein paar Schritte'],
'kbdl': ['leicht, Technik', 'leicht, mehr Reps', 'mittel', 'schwerer, kontrolliert'],
'rowe': ['leicht', 'etwas mehr', 'mehr', 'fordernd'],
'row1': ['leicht', 'etwas mehr', 'einarmig leicht', 'einarmig mittel'],
'row2': ['leicht (Technik)', 'leicht, mehr Reps', 'mittel', 'mittel, Pause oben'],
'lraise': ['leicht (Technik)', 'leicht, mehr Reps', 'mittel', 'mittel, langsam'],
'atw': ['nur Around-the-World, leicht', 'Around-the-World, mehr Reps', '+ Uppercut, leicht', '+ Uppercut, flüssig'],
},
"prepost": {
"1": {"warmup": "Handflächen-Wippen · Armkreisen/Wand-Slides · Hüftkreisen + Beinschwünge · Knie-über-Zeh an der Wand",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "Türrahmen (Brust/Schulter, je 3040s) · Trizeps (je 30s) · Seitheben/Lat-Seitdehnung (je 30s) · Quad im Stand (je 30s) · Knieender Hüftbeuger (je 3040s) · Wade an der Wand (je 30s)"},
"2": {"warmup": "Handflächen-Wippen · Handtuch-Dislocates (810×) · Cat-Cow · Hüftkreisen + Beinschwünge",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "Lat-/Seitdehnung (je 30s) · Türrahmen (je 3040s) · BWS über die Couch (3040s) · <b>Cobra/Sphinx</b> (Bauch, 30s) · Unterarm (je 20s) · Knieender Hüftbeuger (je 30s)"},
"3": {"warmup": "Hüftkreisen + Beinschwünge · Knie-über-Zeh an der Wand · Cat-Cow · Handflächen-Wippen",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "<b>Hamstring-Dehnung</b> (Couch, je 3040s) · Quad im Stand (je 30s) · Knieender Hüftbeuger (je 3040s) · Figure-4/Taube (je 3040s) · <b>Cobra + liegende Wirbelsäulen-Rotation</b> (je 30s) · Wade an der Wand (je 30s)"},
"4": {"warmup": "Handflächen-Wippen · Handtuch-Dislocates (810×) · Armkreisen/Wand-Slides · Cat-Cow",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "Türrahmen (je 3040s) · Trizeps (je 30s) · Lat-/Seitdehnung (je 30s) · BWS über die Couch (3040s) · Unterarm (je 20s)"},
"5": {"warmup": "Cat-Cow · Hüftkreisen + Beinschwünge · Armkreisen/Wand-Slides · Knie-über-Zeh an der Wand",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "Türrahmen (je 30s) · Knieender Hüftbeuger (je 30s) · BWS über die Couch (3040s) · Figure-4/Taube (je 30s) · Wade an der Wand (je 30s)"},
"6": {"warmup": "Halo (leicht) · Hüftkreisen + Beinschwünge · Cat-Cow · Handflächen-Wippen",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "Hamstring-Dehnung (Couch, je 3040s) · Knieender Hüftbeuger (je 3040s) · Figure-4/Taube (je 3040s) · Unterarm (je 20s) · BWS über die Couch (3040s) · Lat-/Seitdehnung (je 30s)"},
},
"days": [
{"num": 1, "focus": "Drücken + Beine",
"rotA": [{"name": "Liegestütze", "key": "ls", "cue": "Brust kontrolliert senken &amp; drücken"},
{"name": "Bank-Dips (Couch)", "key": "dips", "cue": "Po vor der Couch senken, hochdrücken"},
{"name": "Pike", "key": "pike", "cue": "umgek. V, Kopf senken (Schultern)"},
{"name": "Seitheben (KB/Rucksack)", "key": "lraise", "cue": "Arme seitlich bis Schulterhöhe, kontrolliert"}],
"rotB": [{"name": "Goblet Squat (KB/Rucksack)", "key": "gobletkb", "cue": "Last umarmen, tief"},
{"name": "Ausfallschritt rückw.", "key": "lunge", "cue": "großer Schritt, Knie Richtung Boden"},
{"name": "Glute Bridge", "key": "bridge", "cue": "Hüfte hochdrücken, Po fest"},
{"name": "Wadenheben", "key": "calf", "cue": "auf Zehenspitzen hoch/runter"}]},
{"num": 2, "focus": "Ziehen + Core",
"rotA": [{"name": "Einarm-Rudern ENG (KB)", "key": "rowe", "cue": "1 Arm, Ellbogen an den Rippen, Zug zur Hüfte · Lat · DG1 links / DG2 rechts"},
{"name": "Bizeps-Curls (KB/Rucksack)", "key": "load", "cue": "Oberarme fix, zu den Schultern"},
{"name": "Einarm-Rudern BREIT (KB)", "key": "row2", "cue": "1 Arm, Ellbogen ~45° auf, Zug zum Brustkorb · oberer Rücken · DG1 links / DG2 rechts"},
{"name": "Superman / Y-T-W", "key": "sup", "cue": "bäuchlings Arme+Beine heben"}],
"rotB": [{"name": "Plank", "key": "plank", "cue": "gerade Linie halten"},
{"name": "Mountain Climbers", "key": "mtn", "cue": "Knie zügig zur Brust"},
{"name": "Dead Bug", "key": "db", "cue": "gegengleich, Rücken am Boden"},
{"name": "Beinheben", "key": "lr", "cue": "Beine heben/senken"}]},
{"num": 3, "focus": "Beine + Core",
"rotA": [{"name": "Goblet Squat (KB/Rucksack)", "key": "gobletkb", "cue": "Last umarmen, tief"},
{"name": "RDL / Hip Hinge (KB/Rucksack)", "key": "load", "cue": "Hüfte zurück, Rücken gerade, Hamstrings"},
{"name": "Glute Bridge 1-beinig", "key": "bridge", "cue": "ein Bein gestreckt"},
{"name": "Wall Sit", "key": "wall", "cue": "Rücken an die Wand, halten"}],
"rotB": [{"name": "Hollow Hold", "key": "holl", "cue": "unterer Rücken am Boden"},
{"name": "Side Plank (Wechsel)", "key": "sp", "cue": "seitlich auf einem Unterarm"},
{"name": "Bicycle Crunch", "key": "bic", "cue": "Ellbogen zum Gegenknie"},
{"name": "Russian Twist (KB)", "key": "tw", "cue": "sitzend Rumpf drehen"}]},
{"num": 4, "focus": "Oberkörper: Drücken + Ziehen",
"rotA": [{"name": "Liegestütze", "key": "ls", "cue": "Brust kontrolliert senken &amp; drücken"},
{"name": "Bank-Dips (Couch)", "key": "dips", "cue": "Po vor der Couch senken"},
{"name": "Pike", "key": "pike", "cue": "umgek. V, Kopf senken"},
{"name": "KB Floor Press", "key": "press", "cue": "Glocke vom Boden weg drücken"}],
"rotB": [{"name": "Einarm-Rudern ENG (KB)", "key": "rowe", "cue": "1 Arm, Ellbogen an den Rippen, Zug zur Hüfte · Lat · DG1 links / DG2 rechts"},
{"name": "Bizeps-Curls (KB/Rucksack)", "key": "load", "cue": "Oberarme fix, zu den Schultern"},
{"name": "Einarm-Rudern BREIT (KB)", "key": "row2", "cue": "1 Arm, Ellbogen ~45° auf, Zug zum Brustkorb · oberer Rücken · DG1 links / DG2 rechts"},
{"name": "Y-T-W / Superman", "key": "sup", "cue": "bäuchlings, Rücken"}]},
{"num": 5, "focus": "Ganzkörper LEICHT + Mobility",
"rotA": [{"name": "Kniebeuge (leicht)", "key": "", "cue": "locker, sauber"},
{"name": "Liegestütze (1 Stufe leichter)", "key": "", "cue": "leichtere Variante"},
{"name": "Rudern (leicht)", "key": "", "cue": "wenig Gewicht"},
{"name": "Mountain Climbers (locker)", "key": "", "cue": "ruhiges Tempo"}],
"rotB": [{"name": "Inchworm", "key": "", "cue": "in den Stütz wandern, zurück"},
{"name": "Cat-Cow", "key": "", "cue": "Rücken ründen/strecken"},
{"name": "Bird Dog", "key": "", "cue": "gegengleich Arm+Bein, kurz halten"},
{"name": "KB-Halo (leicht)", "key": "", "cue": "langsam um den Kopf kreisen"}]},
{"num": 6, "focus": "KETTLEBELL Ganzkörper",
"rotA": [{"name": "KB Swing (zweiarmig)", "key": "swing", "cue": "Hüft-Schnapp, kein Armheben"},
{"name": "Goblet Squat (KB)", "key": "gobletkb", "cue": "Glocke vor der Brust, tief"},
{"name": "KB-Deadlift (beidbeinig)", "key": "kbdl", "cue": "Glocke zwischen den Füßen, Hüfte hinge"},
{"name": "Halo", "key": "halo", "cue": "eng um den Kopf kreisen"}],
"rotB": [{"name": "KB Floor Press", "key": "press", "cue": "Glocke vom Boden weg drücken"},
{"name": "Einarmiges Rudern (KB)", "key": "row1", "cue": "Rücken gerade, zum Bauch"},
{"name": "Bottoms-up KB-Hold (Griff)", "key": "carry", "cue": "Glocke Boden nach oben, Unterarm senkrecht, still halten"},
{"name": "Around-the-World + Uppercut (KB)", "key": "atw", "cue": "Glocke um den Körper, dann diagonal zur Gegen-Schulter, andere Hand fängt"}]},
],
}
KB_NOTE = ("<b>Reine Kettlebell-Session.</b> Swing = Hüft-Schnapp, kein Armheben. "
"Glocke so wählen, dass alle Reps sauber sind.")
def _lighten(hexstr, w=0.90):
try:
c = colors.HexColor(hexstr)
except Exception:
c = colors.HexColor("#0F766E")
return colors.Color(c.red * (1 - w) + w, c.green * (1 - w) + w, c.blue * (1 - w) + w)
def migrate_config(cfg):
"""Führt alte Trainings-Configs in das neue, flexible Format über (idempotent).
Neu: phases.items[{name,params,weeks}], stages{key:{name,steps[]}},
days[{...,rotations:[{label,exercises[]}]}] statt rotA/rotB,
Tag-Farbe/Notiz/Badge statt fest verdrahtetem KB-Tag."""
cfg = copy.deepcopy(cfg or {})
meta = cfg.setdefault("meta", {})
days = cfg.get("days", []) or []
# phases: {names,params} -> {items:[{name,params,weeks}], param_light}
ph = cfg.get("phases", {}) or {}
if "items" not in ph:
names = ph.get("names", []) or []
params = ph.get("params", []) or []
n = max(len(names), len(params))
items = [{"name": names[i] if i < len(names) else ("Phase %d" % (i + 1)),
"params": params[i] if i < len(params) else "",
"weeks": 2} for i in range(n)]
cfg["phases"] = {"items": items, "param_light": ph.get("param_light", "")}
# Übungsnamen je Key sammeln (für Stufen-Namen)
name_by_key = {}
for d in days:
groups = [d.get("rotA", []) or [], d.get("rotB", []) or []]
for rot in d.get("rotations", []) or []:
groups.append(rot.get("exercises", []) or [])
for grp in groups:
for it in grp:
k = it.get("key")
if k and it.get("name") and k not in name_by_key:
name_by_key[k] = it["name"]
# stages: [..] -> {name, steps:[..], result_schemas:[..]}
# ``result_schemas`` liegt parallel zu ``steps``. Ein Eintrag kann ``None``
# sein und bedeutet dann: Übungsstandard bzw. Tracker-Fallback verwenden.
newstages = {}
for key, val in (cfg.get("stages", {}) or {}).items():
if isinstance(val, dict) and "steps" in val:
stage = copy.deepcopy(val)
steps = stage.get("steps") if isinstance(stage.get("steps"), list) else []
schemas = stage.get("result_schemas") if isinstance(stage.get("result_schemas"), list) else []
schemas = list(schemas[:len(steps)])
while len(schemas) < len(steps):
schemas.append(None)
stage["steps"] = steps
stage["result_schemas"] = schemas
newstages[key] = stage
else:
steps = val if isinstance(val, list) else []
nm = name_by_key.get(key) or next((s for s in steps if s), "") or key
newstages[key] = {"name": nm, "steps": steps, "result_schemas": [None] * len(steps)}
cfg["stages"] = newstages
# Explizites Trainingsformat. Alte Pläne bleiben auf ``auto`` und werden
# vom Tracker weiterhin aus den Plantexten erkannt. Neue Pläne können
# Tabata/Intervalle damit ohne Text-Heuristik eindeutig beschreiben.
fmt = cfg.get("training_format")
if not isinstance(fmt, dict):
fmt = {}
mode = str(fmt.get("mode") or "auto")
if mode not in {"auto", "sets_reps", "tabata", "fixed_interval"}:
mode = "auto"
normalized_fmt = {"mode": mode}
for field in ("work_seconds", "rest_seconds", "rounds"):
try:
value = int(fmt.get(field))
except (TypeError, ValueError):
value = 0
normalized_fmt[field] = value if value > 0 else None
normalized_fmt["fixed_interval"] = bool(
fmt.get("fixed_interval") or mode in {"tabata", "fixed_interval"}
)
normalized_fmt["rounds_scope"] = "block"
cfg["training_format"] = normalized_fmt
# days: rotA/rotB -> rotations (+ KB-Styling & light-Tag nur bei Alt-Konvertierung)
for d in days:
if "rotations" not in d:
rots = []
if d.get("rotA"):
rots.append({"label": "ROTATION A — 3 Sätze", "exercises": d.get("rotA")})
if d.get("rotB"):
rots.append({"label": "ROTATION B — 3 Sätze", "exercises": d.get("rotB")})
d["rotations"] = rots
d.pop("rotA", None)
d.pop("rotB", None)
if d.get("num") == 6:
d.setdefault("color", "#6D28D9")
d.setdefault("note", KB_NOTE)
d.setdefault("note_bg", "#F1ECFB")
if d.get("num") == 5:
d.setdefault("light", True)
cfg["days"] = days
meta.setdefault("days", len(days))
return migrate_training_config(cfg)
def default_config():
return migrate_config(copy.deepcopy(DEFAULTS))
# =========================================================================
# RENDERER
# =========================================================================
def build_pdf(cfg):
"""cfg: Trainings-Config (altes oder neues Format). Gibt PDF-Bytes zurück."""
_D = DEFAULTS
cfg = dict(cfg or {})
meta = dict(_D["meta"]); meta.update(cfg.get("meta") or {})
cols = dict(_D["colors"]); cols.update(cfg.get("colors") or {})
cfg["meta"], cfg["colors"] = meta, cols
for k in ("front", "library", "phases", "stages", "prepost", "days"):
if not cfg.get(k):
cfg[k] = copy.deepcopy(_D[k])
cfg = migrate_config(cfg) # auf neues Modell normalisieren
C = {k: colors.HexColor(v) for k, v in cfg["colors"].items()}
INK, ACCENT, ACC2, KBCOL = C["INK"], C["ACCENT"], C["ACC2"], C["KBCOL"]
LINK, LIGHT, GRID, MUTE = C["LINK"], C["LIGHT"], C["GRID"], C["MUTE"]
DARK, WARMBG, GOALBG, KBBG = C["DARK"], C["WARMBG"], C["GOALBG"], C["KBBG"]
link_hex = cfg["colors"]["LINK"]
styles = getSampleStyleSheet()
def Sty(name, **kw):
base = kw.pop("parent", styles["Normal"])
return ParagraphStyle(name, parent=base, **kw)
title_s = Sty("title", fontName="Helvetica-Bold", fontSize=23, textColor=INK, leading=26, spaceAfter=2)
sub_s = Sty("sub", fontName="Helvetica", fontSize=10.5, textColor=MUTE, leading=14, spaceAfter=2)
h2_s = Sty("h2", fontName="Helvetica-Bold", fontSize=14, textColor=ACCENT, leading=17, spaceBefore=13, spaceAfter=5)
body_s = Sty("body", fontName="Helvetica", fontSize=9.6, textColor=INK, leading=13.5, spaceAfter=4)
smallm_s = Sty("smallm", fontName="Helvetica", fontSize=8.2, textColor=MUTE, leading=10.5)
cell_s = Sty("cell", fontName="Helvetica", fontSize=8.3, textColor=INK, leading=10.6)
cellb_s = Sty("cellb", fontName="Helvetica-Bold", fontSize=8.3, textColor=INK, leading=10.6)
link_s = Sty("link", fontName="Helvetica", fontSize=8.2, textColor=LINK, leading=10.6)
th_s = Sty("th", fontName="Helvetica-Bold", fontSize=8.6, textColor=colors.white, leading=11)
tag_s = Sty("tag", fontName="Helvetica-Bold", fontSize=8.4, textColor=colors.white, leading=11)
note_s = Sty("note", fontName="Helvetica", fontSize=9, textColor=INK, leading=13)
goal_s = Sty("goal", fontName="Helvetica", fontSize=8.9, textColor=INK, leading=12.5)
kb_s = Sty("kb", fontName="Helvetica", fontSize=8.9, textColor=INK, leading=12.5)
bandL = Sty("bandL", fontName="Helvetica-Bold", fontSize=17, textColor=colors.white, leading=19)
bandR = Sty("bandR", fontName="Helvetica-Bold", fontSize=11.5, textColor=colors.white, leading=14, alignment=TA_RIGHT)
howto = Sty("howto", fontName="Helvetica", fontSize=8.6, textColor=MUTE, leading=11.5, spaceBefore=4, spaceAfter=2)
warm = Sty("warm", fontName="Helvetica", fontSize=9, textColor=INK, leading=12.5)
roth = Sty("roth", fontName="Helvetica-Bold", fontSize=11, textColor=colors.white, leading=13)
exn = Sty("exn", fontName="Helvetica-Bold", fontSize=9.6, textColor=INK, leading=11.5)
stage_s = Sty("stage", fontName="Helvetica-Bold", fontSize=9, textColor=ACCENT, leading=11)
param_s = Sty("param", fontName="Helvetica", fontSize=8.8, textColor=INK, leading=12)
logst = Sty("logst", fontName="Helvetica", fontSize=9.5, textColor=INK, leading=18)
USABLE = A4[0] - 2 * 1.3 * cm
def link(url, label="Anleitung (Bilder/Video)"):
return Paragraph('<a href="%s" color="%s"><u>%s</u></a>' % (url, link_hex, label), link_s)
front = cfg["front"]
story = []
# ===================== FRONT MATTER =====================
story.append(Paragraph(cfg["meta"]["title"], title_s))
story.append(Paragraph(cfg["meta"]["subtitle"], sub_s))
story.append(HRFlowable(width="100%", thickness=2, color=ACCENT, spaceBefore=6, spaceAfter=6))
story.append(Paragraph(front["session_how_head"], h2_s))
story.append(Paragraph(front["session_how_body"], body_s))
flow = front["flow_rows"]
fr = [[Paragraph(c, th_s if i == 0 else (cellb_s if (j == 1 and r[0] == '') else cell_s)) for j, c in enumerate(r)] for i, r in enumerate(flow)]
t = Table(fr, colWidths=[3.2 * cm, 9.6 * cm, 2.7 * cm])
t.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, 0), DARK), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT]),
("GRID", (0, 0), (-1, -1), 0.5, GRID), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4), ("LEFTPADDING", (0, 0), (-1, -1), 6)]))
story.append(t)
story.append(Spacer(1, 4))
story.append(Paragraph(front["timer_note"], smallm_s))
# KB evidence box
story.append(Paragraph(front["kb_head"], h2_s))
kb_rows = [[Paragraph(front["kb_box"], kb_s)]]
gt = Table(kb_rows, colWidths=[USABLE])
gt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), KBBG), ("BOX", (0, 0), (-1, -1), 0.8, KBCOL),
("TOPPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10)]))
story.append(gt)
story.append(Paragraph(front["goals_head"], h2_s))
goal_rows = [[Paragraph(front["goals_box"], goal_s)]]
gt2 = Table(goal_rows, colWidths=[USABLE])
gt2.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), GOALBG), ("BOX", (0, 0), (-1, -1), 0.8, ACCENT),
("TOPPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10)]))
story.append(gt2)
story.append(Spacer(1, 5))
story.append(Paragraph(front["goals_note"], note_s))
story.append(PageBreak())
# ---- exercise library ----
story.append(Paragraph(front["lib_head"], h2_s))
story.append(Paragraph(front["lib_intro"], smallm_s))
for grp in cfg["library"]:
gcol = C.get(grp["color"], ACC2)
rows = [[Paragraph(grp["group"], tag_s), "", ""],
[Paragraph("Übung", th_s), Paragraph("Ausführung (Kurz)", th_s), Paragraph("Anleitung", th_s)]]
for it in grp["items"]:
rows.append([Paragraph(it["name"], cellb_s), Paragraph(it["desc"], cell_s),
link(it["url"]) if it.get("url") else Paragraph("Standard-Bewegung", smallm_s)])
tstyle = [("SPAN", (0, 0), (-1, 0)), ("BACKGROUND", (0, 0), (-1, 0), gcol), ("BACKGROUND", (0, 1), (-1, 1), DARK),
("ROWBACKGROUNDS", (0, 2), (-1, -1), [colors.white, LIGHT]), ("GRID", (0, 1), (-1, -1), 0.5, GRID),
("VALIGN", (0, 0), (-1, -1), "TOP"), ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6)]
story.append(KeepTogether(Table(rows, colWidths=[4.0 * cm, 8.9 * cm, 3.5 * cm], style=TableStyle(tstyle))))
story.append(Spacer(1, 6))
story.append(PageBreak())
# ---- baseline + reminders ----
story.append(Paragraph(front["baseline_head"], h2_s))
story.append(Paragraph(front["baseline_intro"], body_s))
track = front["baseline_rows"]
tr = [[Paragraph(c, th_s if i == 0 else cell_s) for c in r] for i, r in enumerate(track)]
tt = Table(tr, colWidths=[9.5 * cm, 3.0 * cm, 3.0 * cm])
tt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, 0), DARK), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT]),
("GRID", (0, 0), (-1, -1), 0.5, GRID), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("LEFTPADDING", (0, 0), (-1, -1), 6)]))
story.append(tt)
story.append(Spacer(1, 8))
story.append(Paragraph(front["reminder_head"], h2_s))
for r in front["reminders"]:
story.append(Paragraph("•  " + r, body_s))
story.append(Spacer(1, 6))
story.append(HRFlowable(width="100%", thickness=1, color=GRID))
story.append(Paragraph(front["sheets_note"], smallm_s))
story.append(PageBreak())
# ===================== SESSION SHEETS =====================
stages = cfg["stages"]
phase_items = cfg["phases"]["items"]
PARAM_LIGHT = cfg["phases"].get("param_light", "")
prepost = cfg["prepost"]
def phase_index(week):
acc = 0
for i, ph in enumerate(phase_items):
try:
acc += int(ph.get("weeks", 2) or 0)
except Exception:
acc += 2
if week <= acc:
return i
return max(0, len(phase_items) - 1)
def phase_name(pi):
return phase_items[pi]["name"] if 0 <= pi < len(phase_items) else ""
def phase_params(pi):
return phase_items[pi].get("params", "") if 0 <= pi < len(phase_items) else ""
def stage_text(key, pi):
st = stages.get(key) if key else None
if st:
steps = st.get("steps", []) if isinstance(st, dict) else st
if 0 <= pi < len(steps) and steps[pi]:
step = steps[pi]
return step.get("name", "") if isinstance(step, dict) else step
return "leicht"
def rotation_table(rot, pi):
rows = [[Paragraph("Übung", th_s), Paragraph("Stufe heute", th_s),
Paragraph("Satz 1", th_s), Paragraph("Satz 2", th_s), Paragraph("Satz 3", th_s)]]
for item in rot:
name, key, cue = item.get("name", ""), item.get("key") or None, item.get("cue", "")
ex = Paragraph("%s<br/><font size=7.6 color='#6B6256'>%s</font>" % (name, cue), exn)
sc = Paragraph(stage_text(key, pi), stage_s)
rows.append([ex, sc, "", "", ""])
tb = Table(rows, colWidths=[7.2 * cm, 3.5 * cm, 1.95 * cm, 1.95 * cm, 1.95 * cm])
tb.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, 0), DARK), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT]),
("GRID", (0, 0), (-1, -1), 0.6, GRID), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 1), (-1, -1), 8), ("BOTTOMPADDING", (0, 1), (-1, -1), 8),
("TOPPADDING", (0, 0), (-1, 0), 4), ("BOTTOMPADDING", (0, 0), (-1, 0), 4),
("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6), ("ALIGN", (2, 0), (-1, -1), "CENTER")]))
return tb
def day_has_exercises(day):
return any((rot.get("exercises") or []) for rot in (day.get("rotations") or []))
def session_page(week, day):
num, focus = day.get("num"), day.get("focus", "")
pi = phase_index(week)
color = day.get("color")
band_col = colors.HexColor(color) if color else ACCENT
rot_col = colors.HexColor(color) if color else ACC2
pp = prepost.get(str(num), {"warmup": "", "cooldown": "", "stretch": ""})
pre_txt = pp.get("warmup", ""); cd_txt = pp.get("cooldown", ""); stretch_txt = pp.get("stretch", "")
flow = []
badge = day.get("badge") or {}
btext = badge.get("text", "").strip()
left_html = "WOCHE %d · TAG %d" % (week, num)
if btext:
left_html += " <font size=8 color='white' backColor='%s'> %s </font>" % (badge.get("color", "#6D28D9"), btext)
band = Table([[Paragraph(left_html, bandL),
Paragraph("%s<br/><font size=8 color='#EAD9F7'>%s</font>" % (focus, phase_name(pi)), bandR)]],
colWidths=[USABLE * 0.5, USABLE * 0.5])
band.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), band_col), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 9), ("BOTTOMPADDING", (0, 0), (-1, -1), 9),
("LEFTPADDING", (0, 0), (0, 0), 12), ("RIGHTPADDING", (-1, 0), (-1, 0), 12)]))
flow.append(band)
flow.append(Paragraph("Tabata: 20s an / 10s aus · pro Block 4 Übungen × 2 Durchgänge = 1 Satz · 10s Vorlauf", howto))
wb = Table([[Paragraph("<b>WARM-UP · Gelenk-Prep</b> (dynamisch, ~3 Min, kein Halten): " + pre_txt, warm)]], colWidths=[USABLE])
wb.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), WARMBG), ("BOX", (0, 0), (-1, -1), 0.6, ACC2),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9)]))
flow.append(Spacer(1, 5))
flow.append(wb)
note = (day.get("note") or "").strip()
if note:
nb = colors.HexColor(day["note_bg"]) if day.get("note_bg") else _lighten(color or "#0F766E")
nborder = colors.HexColor(color) if color else ACC2
flow.append(Spacer(1, 4))
nt = Table([[Paragraph(note, warm)]], colWidths=[USABLE])
nt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), nb), ("BOX", (0, 0), (-1, -1), 0.6, nborder),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9)]))
flow.append(nt)
flow.append(Spacer(1, 8))
for rot in (day.get("rotations") or []):
exs = rot.get("exercises") or []
if not exs:
continue
rh = Table([[Paragraph(rot.get("label", ""), roth)]], colWidths=[USABLE])
rh.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), rot_col), ("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4), ("LEFTPADDING", (0, 0), (-1, -1), 8)]))
flow.append(rh)
flow.append(Spacer(1, 2))
flow.append(rotation_table(exs, pi))
flow.append(Spacer(1, 8))
pbp = day.get("params_by_phase") or []
if 0 <= pi < len(pbp) and (pbp[pi] or "").strip():
ptxt = pbp[pi]
else:
ptxt = PARAM_LIGHT if day.get("light") else phase_params(pi)
pb = Table([[Paragraph(ptxt, param_s)]], colWidths=[USABLE])
pb.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), LIGHT), ("BOX", (0, 0), (-1, -1), 0.6, GRID),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6), ("LEFTPADDING", (0, 0), (-1, -1), 9)]))
flow.append(pb)
flow.append(Spacer(1, 7))
cd = Table([[Paragraph("<b>COOL-DOWN</b> (~12 Min): " + cd_txt +
"<br/><b>DEHNEN</b> (warm &amp; statisch, bis mildes Ziehen, ~46 Min): " + stretch_txt, warm)]], colWidths=[USABLE])
cd.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), WARMBG), ("BOX", (0, 0), (-1, -1), 0.6, ACC2),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9)]))
flow.append(cd)
flow.append(Spacer(1, 7))
flow.append(Paragraph("Gefühl heute (110): ______ Glocke (kg): ______ Notizen: ________________________________", logst))
return flow
weeks = int(cfg["meta"].get("weeks", 8) or 0)
for week in range(1, weeks + 1):
for day in cfg["days"]:
if not day_has_exercises(day):
continue
story += session_page(week, day)
story.append(PageBreak())
if story and isinstance(story[-1], PageBreak):
story.pop()
footer_left = cfg["meta"]["footer_left"]
def footer(canvas, doc):
canvas.saveState()
canvas.setFont("Helvetica", 7.5)
canvas.setFillColor(MUTE)
canvas.drawString(1.3 * cm, 1.0 * cm, footer_left)
canvas.drawRightString(A4[0] - 1.3 * cm, 1.0 * cm, "Seite %d" % doc.page)
canvas.restoreState()
buf = BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=1.3 * cm, rightMargin=1.3 * cm,
topMargin=1.2 * cm, bottomMargin=1.4 * cm, title=cfg["meta"]["doc_title"])
doc.build(story, onFirstPage=footer, onLaterPages=footer)
return buf.getvalue()
if __name__ == "__main__":
data = build_pdf(default_config())
with open("/tmp/test_plan.pdf", "wb") as f:
f.write(data)
print("OK", len(data), "bytes")