chore: initial import
This commit is contained in:
@@ -0,0 +1,669 @@
|
||||
# -*- 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: +6–7 kg) – dein größter Mehrwert auf dem Weg zu Klimmzug & Dip.<br/>"
|
||||
"• <b>Power / ballistische Hüftstreckung</b> (Swing) – explosive Basis für spätere Skills.<br/>"
|
||||
"• <b>Kondition & 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 2–3 Liegestützen und 0 Dips. Realistisch und ehrlich nach 8 Wochen sauberer Arbeit:<br/>"
|
||||
"• <b>Liegestütze:</b> von 2–3 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 ~45–60 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 & 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 1–2 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 & Eiweiß.</b> 7–9 h Schlaf, ~1,6 g Eiweiß pro kg. Stechender Schmerz = stoppen.",
|
||||
],
|
||||
"sheets_note": (
|
||||
"Ab der nächsten Seite: 1 Druckblatt pro Session (Woche 1–8). <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 & 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 & 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). 2–3 Reps Reserve. Pausen bis ~60s. Leichte Glocke. Woche 1 mit 2 Sätzen pro Rotation.",
|
||||
"Sauber bleiben, letzter Satz fordernd. Pausen ~45–60s. Etwas schwerere Glocke / mehr Reps. 2–3 Sätze.",
|
||||
"Fordernd, aber 1 Rep Reserve. Pausen ~45s. Schwerere Glocke / einarmig wo angegeben. 3 Sätze.",
|
||||
"Fordernd & 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': ['15–25 s', '25–40 s', '40–55 s', '55–70 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 15–20 s', 'voll 20–30 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 10–15s', '15–20s, 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 (60–90s) · Box-Atmung (4–5 Runden)",
|
||||
"stretch": "Türrahmen (Brust/Schulter, je 30–40s) · Trizeps (je 30s) · Seitheben/Lat-Seitdehnung (je 30s) · Quad im Stand (je 30s) · Knieender Hüftbeuger (je 30–40s) · Wade an der Wand (je 30s)"},
|
||||
"2": {"warmup": "Handflächen-Wippen · Handtuch-Dislocates (8–10×) · Cat-Cow · Hüftkreisen + Beinschwünge",
|
||||
"cooldown": "Ausschütteln + Gehen (60–90s) · Box-Atmung (4–5 Runden)",
|
||||
"stretch": "Lat-/Seitdehnung (je 30s) · Türrahmen (je 30–40s) · BWS über die Couch (30–40s) · <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 (60–90s) · Box-Atmung (4–5 Runden)",
|
||||
"stretch": "<b>Hamstring-Dehnung</b> (Couch, je 30–40s) · Quad im Stand (je 30s) · Knieender Hüftbeuger (je 30–40s) · Figure-4/Taube (je 30–40s) · <b>Cobra + liegende Wirbelsäulen-Rotation</b> (je 30s) · Wade an der Wand (je 30s)"},
|
||||
"4": {"warmup": "Handflächen-Wippen · Handtuch-Dislocates (8–10×) · Armkreisen/Wand-Slides · Cat-Cow",
|
||||
"cooldown": "Ausschütteln + Gehen (60–90s) · Box-Atmung (4–5 Runden)",
|
||||
"stretch": "Türrahmen (je 30–40s) · Trizeps (je 30s) · Lat-/Seitdehnung (je 30s) · BWS über die Couch (30–40s) · Unterarm (je 20s)"},
|
||||
"5": {"warmup": "Cat-Cow · Hüftkreisen + Beinschwünge · Armkreisen/Wand-Slides · Knie-über-Zeh an der Wand",
|
||||
"cooldown": "Ausschütteln + Gehen (60–90s) · Box-Atmung (4–5 Runden)",
|
||||
"stretch": "Türrahmen (je 30s) · Knieender Hüftbeuger (je 30s) · BWS über die Couch (30–40s) · 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 (60–90s) · Box-Atmung (4–5 Runden)",
|
||||
"stretch": "Hamstring-Dehnung (Couch, je 30–40s) · Knieender Hüftbeuger (je 30–40s) · Figure-4/Taube (je 30–40s) · Unterarm (je 20s) · BWS über die Couch (30–40s) · Lat-/Seitdehnung (je 30s)"},
|
||||
},
|
||||
"days": [
|
||||
{"num": 1, "focus": "Drücken + Beine",
|
||||
"rotA": [{"name": "Liegestütze", "key": "ls", "cue": "Brust kontrolliert senken & 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 & 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> (~1–2 Min): " + cd_txt +
|
||||
"<br/><b>DEHNEN</b> (warm & statisch, bis mildes Ziehen, ~4–6 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 (1–10): ______ 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")
|
||||
Reference in New Issue
Block a user