400 lines
18 KiB
Python
400 lines
18 KiB
Python
# -*- 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 "<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 %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> %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> %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")
|