# -*- 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("<", "<").replace(">", ">") return _ENT.sub("&", 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 "%s: %s" % (esc(m.group(1)), esc(m.group(2))) m = re.match(r"^(\S+)\s+(.*)$", v, re.S) if m: return "%s %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  %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 = 'NÄHRWERTE  %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("%s" % 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('%d' % (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 = "
".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 = '%s  %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")