chore: initial import

This commit is contained in:
2026-07-24 21:37:03 +02:00
commit 6b4ed9bc36
111 changed files with 95131 additions and 0 deletions
+399
View File
@@ -0,0 +1,399 @@
# -*- coding: utf-8 -*-
"""
recipe_builder.py
Rendert eine Rezeptsammlung als A4-PDF im Design der Original-Editionen:
farbiges Kategorie-Band, farbiges Rezept-Titelband (weiße Schrift), getönter
Untertitel, NÄHRWERTE-Strip, zweispaltig ZUTATEN | SO GEHT'S, getönte
VARIATIONEN- und Tipp-Box, dünn umrandete Karte.
Regeln (wie im Original-Generator):
* jedes Rezept ist KeepTogether -> wird nie durch einen Seitenumbruch geteilt
* vor jeder Kategorie ein PageBreak -> jede Kategorie beginnt auf neuer Seite
build_pdf(cfg) -> bytes ; default_config() -> dict
"""
import re
import copy
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,
KeepTogether, PageBreak)
DEFAULTS = {
"type": "recipe",
"meta": {
"title": "Neue Rezeptsammlung",
"header_left": "REZEPTE · Edition",
"header_right": "Begleitend zum 8-Wochen-Plan",
"footer_left": "Teller = Portionsgrenze · Trend über die Woche zählt",
"output_name": "Rezepte.pdf",
"doc_title": "Rezeptsammlung",
"brand_color": "#6D28D9",
"tip_label": "Tipp",
"steps_label": "SO GEHT'S",
},
"intro": {
"headline": "Neue Rezeptsammlung",
"subtitle": "Kurzbeschreibung der Edition.",
"paragraphs": ["Einleitender Absatz."],
"rule_head": "Die eine Grundregel",
"rule_text": "Teller fertig machen, Rest aus dem Sichtfeld.",
"categories_head": "1 Kategorie",
},
"categories": [{"name": "Cremig & süß", "color": "#7C3AED"}],
"recipes": [
{"num": "01", "category": "Cremig & süß", "title": "Beispiel-Rezept",
"tagline": "Kurzer Untertitel.", "nutrition": "", "badge": "", "yield": "",
"ingredients": "200 g Magerquark · etwas Süße",
"steps": "Alles verrühren, fertig.",
"variations": ["Schoko: + 1 TL Kakao"], "tip": "Tipp Eiweiß-reich."},
],
}
def default_config():
return copy.deepcopy(DEFAULTS)
# ---------------------------------------------------------------- helpers
_ENT = re.compile(r"&(?!(?:amp|lt|gt|quot|apos|nbsp|#\d+|#x[0-9a-fA-F]+);)")
def esc(s):
if s is None:
return ""
s = str(s).replace("<", "&lt;").replace(">", "&gt;")
return _ENT.sub("&amp;", s)
def _hx(c):
return "#" + c.hexval()[2:]
def lighten(hexstr, w=0.92):
"""Mischt eine Farbe mit Weiß (w = Weiß-Anteil) -> heller Ton."""
try:
c = colors.HexColor(hexstr)
except Exception:
c = colors.HexColor("#334155")
r = c.red * 255 * (1 - w) + 255 * w
g = c.green * 255 * (1 - w) + 255 * w
b = c.blue * 255 * (1 - w) + 255 * w
return colors.Color(r / 255, g / 255, b / 255)
TIP_LABEL_RE = re.compile(r"^\s*([A-Za-zÄÖÜäöü]+-Tipp|Tipp)\b[:\s]*(.*)$", re.S)
def split_tip(text, default_label):
"""Zerlegt 'Morgen-Tipp Text' -> ('Morgen-Tipp', 'Text')."""
if not text:
return default_label, ""
m = TIP_LABEL_RE.match(text)
if m:
return m.group(1), m.group(2).strip()
return default_label, text.strip()
def bold_lead(v):
"""Erste Bezeichnung einer Variation fett (bis Doppelpunkt bzw. erstes Wort)."""
v = v.strip()
m = re.match(r"^([^:]{2,40}):\s*(.*)$", v, re.S)
if m:
return "<b>%s:</b> %s" % (esc(m.group(1)), esc(m.group(2)))
m = re.match(r"^(\S+)\s+(.*)$", v, re.S)
if m:
return "<b>%s</b> %s" % (esc(m.group(1)), esc(m.group(2)))
return esc(v)
NOSPLIT = {"bis", "damit", "sodass", "bevor", "nachdem", "während", "wenn", "falls", "sobald",
"weil", "sowie", "oder", "aber", "bei", "je", "ohne", "mit", "in", "im", "am", "zum",
"zur", "auf", "für", "über", "unter", "dabei", "dazu", "nach", "vor", "als"}
def split_steps(text):
t = (text or "").strip().replace("z. B.", "z.B.")
raw = re.split(r"(?<=[.!?])\s+|,\s+", t)
steps = []
for frag in raw:
frag = frag.strip()
if not frag:
continue
first = frag.split()[0].lower().strip("(„\"'.:;»«")
if steps and first in NOSPLIT:
steps[-1] = steps[-1].rstrip(".") + ", " + frag
else:
steps.append(frag)
steps = [s if s.endswith((".", "!", "?")) else s + "." for s in steps]
return [s[:1].upper() + s[1:] for s in steps]
def numbered_steps(steps_txt):
"""Bereits nummerierte (mehrzeilige) Schritte -> Zeilen; sonst Prosa splitten."""
if steps_txt and "\n" in steps_txt:
out = []
for ln in steps_txt.split("\n"):
ln = re.sub(r"^\s*\d+[\.\)]?\s+", "", ln.strip())
if ln:
out.append(ln)
return out
return split_steps(steps_txt)
# ---------------------------------------------------------------- renderer
def build_pdf(cfg):
_D = DEFAULTS
meta = dict(_D["meta"]); meta.update(cfg.get("meta") or {})
intro = dict(_D["intro"]); intro.update(cfg.get("intro") or {})
cats = cfg.get("categories") or _D["categories"]
recipes = cfg.get("recipes") or []
INK = colors.HexColor("#23272F")
SUB = colors.HexColor("#5B6270")
HAIR = colors.HexColor("#E3E1EA")
NUTBG = colors.HexColor("#F7F7F9")
BRAND = colors.HexColor(meta.get("brand_color", "#6D28D9"))
TIPBG = lighten(_hx(BRAND), 0.93)
def catcolor(name):
for c in cats:
if c.get("name") == name:
try:
return colors.HexColor(c.get("color", "#334155"))
except Exception:
return colors.HexColor("#334155")
return colors.HexColor("#334155")
styles = getSampleStyleSheet()
def S(name, **kw):
base = kw.pop("parent", styles["Normal"])
return ParagraphStyle(name, parent=base, **kw)
st_title = S("t", fontName="Helvetica-Bold", fontSize=22, leading=25, textColor=INK)
st_sub = S("s", fontName="Helvetica", fontSize=10.5, leading=14, textColor=SUB)
st_intro = S("i", fontName="Helvetica", fontSize=10, leading=15, textColor=INK)
st_callh = S("ch", fontName="Helvetica-Bold", fontSize=10.5, leading=13, textColor=INK)
st_legend= S("lg", fontName="Helvetica", fontSize=8.6, leading=12, textColor=INK)
st_cat = S("c", fontName="Helvetica-Bold", fontSize=12.5, leading=15, textColor=colors.white)
st_rname = S("rn", fontName="Helvetica-Bold", fontSize=11.5, leading=14, textColor=colors.white)
st_badge = S("bd", fontName="Helvetica-Bold", fontSize=7.5, leading=9)
st_tag = S("tg", fontName="Helvetica-Oblique", fontSize=9, leading=12, textColor=SUB)
st_lbl = S("lb", fontName="Helvetica-Bold", fontSize=7.5, leading=10, spaceAfter=1)
st_body = S("bd2", fontName="Helvetica", fontSize=8.8, leading=12, textColor=INK)
st_var = S("vr", fontName="Helvetica", fontSize=8.4, leading=11.6, textColor=INK)
st_tip = S("tp", fontName="Helvetica", fontSize=8.5, leading=12, textColor=INK)
st_yield = S("yl", fontName="Helvetica-Oblique", fontSize=8, leading=11, textColor=SUB)
PAGE_W, PAGE_H = A4
LM = RM = 1.5 * cm
CW = PAGE_W - LM - RM
def lbl(text, acc):
return Paragraph(text, S("lx", parent=st_lbl, textColor=acc))
# ---- category band ----
def category_band(name):
acc = catcolor(name)
t = Table([[Paragraph(esc(name), st_cat)]], colWidths=[CW])
t.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), acc),
("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6)]))
return t
# ---- one recipe card ----
def recipe_card(r):
acc = catcolor(r.get("category", ""))
accx = _hx(acc)
tint = lighten(accx, 0.92)
# Titelband (+ optional AIRFRYER-Badge)
name_p = Paragraph("%s&nbsp;&nbsp;%s" % (esc(r.get("num", "")), esc(r.get("title", ""))), st_rname)
if r.get("badge"):
badge = Table([[Paragraph(esc(r["badge"]), S("bx", parent=st_badge, textColor=acc))]])
badge.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), colors.white),
("LEFTPADDING", (0, 0), (-1, -1), 5), ("RIGHTPADDING", (0, 0), (-1, -1), 5),
("TOPPADDING", (0, 0), (-1, -1), 2), ("BOTTOMPADDING", (0, 0), (-1, -1), 2)]))
badge.hAlign = "RIGHT"
head = Table([[name_p, badge]], colWidths=[CW - 82, 82])
head.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), acc), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (0, 0), 9), ("RIGHTPADDING", (0, 0), (0, 0), 4),
("LEFTPADDING", (1, 0), (1, 0), 0), ("RIGHTPADDING", (1, 0), (1, 0), 9),
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5)]))
else:
head = Table([[name_p]], colWidths=[CW])
head.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), acc),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5)]))
rows = [[head]]
# Untertitel (getönt)
if r.get("tagline"):
tg = Table([[Paragraph(esc(r["tagline"]), st_tag)]], colWidths=[CW])
tg.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), tint),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4)]))
rows.append([tg])
# NÄHRWERTE-Strip
if r.get("nutrition"):
ntext = '<b><font color="%s">NÄHRWERTE</font></b>&nbsp;&nbsp;%s' % (accx, esc(r["nutrition"]))
n = Table([[Paragraph(ntext, st_var)]], colWidths=[CW])
n.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), NUTBG),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LINEBELOW", (0, 0), (-1, 0), 0.6, HAIR)]))
rows.append([n])
# ZUTATEN volle Breite
zut_cell = [lbl("ZUTATEN", acc), Paragraph(esc(r.get("ingredients", "")), st_body)]
if r.get("yield"):
zut_cell.append(Spacer(1, 2))
zut_cell.append(Paragraph("<i>%s</i>" % esc(r["yield"]), st_yield))
zut = Table([[zut_cell]], colWidths=[CW])
zut.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("BACKGROUND", (0, 0), (-1, -1), colors.white)]))
rows.append([zut])
# Schritte nummeriert, volle Breite
steps = numbered_steps(r.get("steps", ""))
srows = [[Paragraph('<b><font color="%s">%d</font></b>' % (accx, i), st_body),
Paragraph(esc(s), st_body)] for i, s in enumerate(steps, 1)]
if srows:
steps_inner = Table(srows, colWidths=[15, CW - 18 - 15])
steps_inner.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (0, 0), 4), ("RIGHTPADDING", (1, 0), (1, 0), 0),
("TOPPADDING", (0, 0), (-1, 0), 0), ("TOPPADDING", (0, 1), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 0)]))
arb_cell = [lbl(meta.get("steps_label", "SO GEHT'S"), acc), Spacer(1, 2), steps_inner]
else:
arb_cell = [lbl(meta.get("steps_label", "SO GEHT'S"), acc)]
arb = Table([[arb_cell]], colWidths=[CW])
arb.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 7),
("LINEABOVE", (0, 0), (-1, 0), 0.6, HAIR), ("BACKGROUND", (0, 0), (-1, -1), colors.white)]))
rows.append([arb])
# ZUBEREITUNG (Airfryer/Ofen), optional
if r.get("methods"):
m_cell = [lbl("ZUBEREITUNG · AIRFRYER / OFEN", acc), Paragraph(esc(r["methods"]), st_var)]
mt = Table([[m_cell]], colWidths=[CW])
mt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F3F4F6")),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LINEABOVE", (0, 0), (-1, 0), 0.6, HAIR)]))
rows.append([mt])
# VARIATIONEN (getönt)
if r.get("variations"):
vhtml = "<br/>".join(bold_lead(v) for v in r["variations"] if v)
v = Table([[[lbl("VARIATIONEN", acc), Paragraph(vhtml, st_var)]]], colWidths=[CW])
v.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), tint),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LINEABOVE", (0, 0), (-1, 0), 0.6, HAIR)]))
rows.append([v])
# Tipp (Marken-Tint)
if r.get("tip"):
lab, txt = split_tip(r["tip"], meta.get("tip_label", "Tipp"))
tip_html = '<b><font color="%s">%s</font></b>&nbsp;&nbsp;%s' % (accx, esc(lab), esc(txt))
tp = Table([[Paragraph(tip_html, st_tip)]], colWidths=[CW])
tp.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), TIPBG),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5)]))
rows.append([tp])
card = Table(rows, colWidths=[CW])
card.setStyle(TableStyle([("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0), ("BOTTOMPADDING", (0, 0), (-1, -1), 0),
("BOX", (0, 0), (-1, -1), 0.8, HAIR)]))
return KeepTogether([card, Spacer(1, 7)])
# ---- story ----
story = []
story.append(Spacer(1, 0.3 * cm))
story.append(Paragraph(esc(intro.get("headline", meta["title"])), st_title))
story.append(Spacer(1, 3))
if intro.get("subtitle"):
story.append(Paragraph(esc(intro["subtitle"]), st_sub))
story.append(Spacer(1, 10))
for p in intro.get("paragraphs", []):
story.append(Paragraph(esc(p), st_intro))
story.append(Spacer(1, 6))
if intro.get("rule_head") or intro.get("rule_text"):
gr = Table([[[Paragraph(esc(intro.get("rule_head", "")), st_callh), Spacer(1, 3),
Paragraph(esc(intro.get("rule_text", "")), st_intro)]]], colWidths=[CW])
gr.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), TIPBG),
("LEFTPADDING", (0, 0), (-1, -1), 12), ("RIGHTPADDING", (0, 0), (-1, -1), 12),
("TOPPADDING", (0, 0), (-1, -1), 9), ("BOTTOMPADDING", (0, 0), (-1, -1), 9),
("LINEBEFORE", (0, 0), (0, 0), 3, BRAND)]))
story.append(Spacer(1, 4))
story.append(gr)
story.append(Spacer(1, 12))
story.append(Paragraph(esc(intro.get("categories_head", "%d Kategorien" % len(cats))),
S("lh", parent=st_callh, fontSize=10)))
story.append(Spacer(1, 4))
leg_rows = []
for c in cats:
sw = Table([[""]], colWidths=[10], rowHeights=[10])
sw.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), catcolor(c["name"])),
("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0), ("BOTTOMPADDING", (0, 0), (-1, -1), 0)]))
leg_rows.append([sw, Paragraph(esc(c["name"]), st_legend)])
if leg_rows:
leg = Table(leg_rows, colWidths=[16, CW - 16])
leg.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (0, 0), 6),
("TOPPADDING", (0, 0), (-1, -1), 2), ("BOTTOMPADDING", (0, 0), (-1, -1), 2)]))
story.append(leg)
# Rezepte, gruppiert je Kategorie -> neue Seite pro Kategorie, Rezept nie geteilt
cur = None
for r in recipes:
c = r.get("category", "")
if c != cur:
story.append(PageBreak())
story.append(category_band(c))
story.append(Spacer(1, 7))
cur = c
story.append(recipe_card(r))
# ---- Kopf-/Fußzeile ----
header_left = meta.get("header_left", "")
header_right = meta.get("header_right", "Begleitend zum 8-Wochen-Plan")
footer_left = meta.get("footer_left", "")
def deco(canvas, doc):
canvas.saveState()
canvas.setFillColor(BRAND); canvas.setFont("Helvetica-Bold", 8)
canvas.drawString(LM, PAGE_H - 1.05 * cm, header_left)
canvas.setFont("Helvetica", 8); canvas.setFillColor(SUB)
canvas.drawRightString(PAGE_W - RM, PAGE_H - 1.05 * cm, header_right)
canvas.setStrokeColor(HAIR); canvas.setLineWidth(0.7)
canvas.line(LM, PAGE_H - 1.2 * cm, PAGE_W - RM, PAGE_H - 1.2 * cm)
canvas.line(LM, 1.15 * cm, PAGE_W - RM, 1.15 * cm)
canvas.setFont("Helvetica", 7.5); canvas.setFillColor(SUB)
canvas.drawString(LM, 0.8 * cm, footer_left)
canvas.drawRightString(PAGE_W - RM, 0.8 * cm, "Seite %d" % doc.page)
canvas.restoreState()
buf = BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=LM, rightMargin=RM,
topMargin=1.6 * cm, bottomMargin=1.5 * cm,
title=meta.get("doc_title", meta.get("title", "Rezepte")))
doc.build(story, onFirstPage=deco, onLaterPages=deco)
return buf.getvalue()
if __name__ == "__main__":
open("/tmp/recipe_test.pdf", "wb").write(build_pdf(default_config()))
print("OK")