Files
foodlinkk-command-center/tools-api/app/packaging/generator.py
T

433 lines
17 KiB
Python
Raw Normal View History

2026-06-09 10:41:13 +00:00
"""SVG packaging generator for Foodlinkk — rich design options."""
from __future__ import annotations
import base64
from io import BytesIO
from typing import Any
import svgwrite
from barcode import Code128
from barcode.writer import SVGWriter
2026-06-09 10:41:13 +00:00
MM_TO_PX = 3.7795275591
DEFAULT_BARCODE_VALUE = "8710000000012"
FOODLINKK_BRAND = {
"bg": "#0b1220",
"panel": "#101a2d",
"primary": "#00e5ff",
"secondary": "#ffd700",
2026-06-09 10:41:13 +00:00
"accent": "#b8ff3c",
"text": "#e2e8f0",
2026-06-09 10:41:13 +00:00
"muted": "#94a3b8",
"cut_line": "#ef4444",
"fold_line": "#60a5fa",
2026-06-09 10:41:13 +00:00
"bleed": "#f97316",
"halal": "#22c55e",
}
2026-06-09 10:41:13 +00:00
PACKAGING_TYPES = (
"folding_box",
"wrap",
"round_label",
"sleeve",
"pouch",
"tray",
)
def _mm(mm: float) -> float:
return round(float(mm) * MM_TO_PX, 2)
def _elements_enabled(elements: Any, name: str) -> bool:
if isinstance(elements, dict):
return bool(elements.get(name))
if isinstance(elements, list):
return name in elements
return False
def _barcode_data_uri(value: str) -> str:
barcode = Code128(value, writer=SVGWriter())
stream = BytesIO()
barcode.write(stream)
encoded = base64.b64encode(stream.getvalue()).decode("ascii")
return f"data:image/svg+xml;base64,{encoded}"
2026-06-09 10:41:13 +00:00
def _qr_placeholder_svg(size: int = 120) -> str:
"""Simple QR-style grid when qrcode lib unavailable."""
cell = max(size // 10, 8)
parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}">',
f'<rect width="{size}" height="{size}" fill="#fff"/>',
]
for y in range(0, size, cell):
for x in range(0, size, cell):
if (x + y) % (cell * 2) == 0 or x < cell * 3 and y < cell * 3 or x > size - cell * 4 and y < cell * 3:
parts.append(f'<rect x="{x}" y="{y}" width="{cell}" height="{cell}" fill="#111"/>')
parts.append("</svg>")
raw = "".join(parts).encode("utf-8")
return f"data:image/svg+xml;base64,{base64.b64encode(raw).decode('ascii')}"
def _spec_text(spec: dict[str, Any]) -> dict[str, str]:
text = spec.get("text") or {}
if not isinstance(text, dict):
text = {}
brand = {**FOODLINKK_BRAND, **(spec.get("brand") or {})}
return {
"product_name": str(text.get("product_name") or brand.get("product_name") or "FOODLINKK"),
"tagline": str(text.get("tagline") or brand.get("tagline") or "Premium food solutions"),
"subtitle": str(text.get("subtitle") or text.get("weight") or ""),
"ingredients": str(text.get("ingredients") or ""),
"best_before": str(text.get("best_before") or "Ten minste houdbaar tot: zie verpakking"),
"origin": str(text.get("origin") or "Geproduceerd in NL"),
}
def _nutrition_rows(spec: dict[str, Any]) -> list[tuple[str, str]]:
text = spec.get("text") or {}
rows = text.get("nutrition") if isinstance(text, dict) else None
if isinstance(rows, list) and rows:
out: list[tuple[str, str]] = []
for row in rows[:12]:
if isinstance(row, dict):
out.append((str(row.get("k") or row.get("label") or ""), str(row.get("v") or row.get("value") or "")))
return out
return [
("Energie", "450 kJ / 107 kcal"),
("Vetten", "4.2 g"),
("waarvan verzadigd", "1.1 g"),
("Koolhydraten", "12 g"),
("waarvan suikers", "2.8 g"),
("Eiwitten", "6.5 g"),
("Zout", "0.85 g"),
]
def _draw_bleed(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, brand: dict[str, str], bleed_mm: float) -> None:
if bleed_mm <= 0:
return
b = _mm(bleed_mm)
dwg.add(
dwg.rect(
insert=(x - b, y - b),
size=(w + 2 * b, h + 2 * b),
fill="none",
stroke=brand["bleed"],
stroke_dasharray="6,4",
stroke_width=1.0,
stroke_opacity=0.7,
)
)
def _draw_logo_block(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, brand: dict[str, str], txt: dict[str, str]) -> None:
dwg.add(
dwg.rect(insert=(x, y), size=(w, h), rx=10, ry=10, fill=brand["panel"], stroke=brand["secondary"], stroke_width=2)
)
dwg.add(
dwg.text(
txt["product_name"][:42],
insert=(x + 14, y + h * 0.42),
fill=brand["text"],
font_size=min(22, max(12, w * 0.045)),
font_family="Arial, Helvetica, sans-serif",
font_weight="bold",
)
)
if txt["tagline"]:
dwg.add(
dwg.text(
txt["tagline"][:60],
insert=(x + 14, y + h * 0.62),
fill=brand["primary"],
font_size=min(14, max(9, w * 0.028)),
font_family="Arial, Helvetica, sans-serif",
)
)
if txt["subtitle"]:
dwg.add(
dwg.text(
txt["subtitle"][:24],
insert=(x + 14, y + h * 0.82),
fill=brand["muted"],
font_size=11,
font_family="Arial, Helvetica, sans-serif",
)
)
def _draw_nutrition_panel(
dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, brand: dict[str, str], rows: list[tuple[str, str]]
) -> None:
dwg.add(dwg.rect(insert=(x, y), size=(w, h), fill="#ffffff", stroke=brand["text"], stroke_width=1.2, rx=4))
dwg.add(
dwg.text(
"Voedingswaarden per 100g",
insert=(x + 8, y + 16),
fill="#111827",
font_size=11,
font_weight="bold",
font_family="Arial, sans-serif",
)
)
line_y = y + 26
for label, value in rows:
dwg.add(dwg.text(label[:28], insert=(x + 8, line_y), fill="#374151", font_size=9, font_family="Arial, sans-serif"))
dwg.add(dwg.text(value[:16], insert=(x + w - 8, line_y), fill="#111827", font_size=9, font_family="Arial, sans-serif", text_anchor="end"))
line_y += 13
if line_y > y + h - 6:
break
def _draw_ingredients(dwg: svgwrite.Drawing, x: float, y: float, w: float, text: str, brand: dict[str, str]) -> None:
dwg.add(
dwg.text(
"Ingrediënten:",
insert=(x, y),
fill=brand["text"],
font_size=10,
font_weight="bold",
font_family="Arial, sans-serif",
)
)
chunk = text[:220] or "Ingrediënten volgens recept — vul aan in Packaging Studio."
words, line, line_y, line_h = chunk.split(), "", y + 14, 12
for word in words:
test = (line + " " + word).strip()
if len(test) > 42:
dwg.add(dwg.text(line, insert=(x, line_y), fill=brand["muted"], font_size=9, font_family="Arial, sans-serif"))
line, line_y = word, line_y + line_h
else:
line = test
if line:
dwg.add(dwg.text(line, insert=(x, line_y), fill=brand["muted"], font_size=9, font_family="Arial, sans-serif"))
def _draw_halal_badge(dwg: svgwrite.Drawing, x: float, y: float, brand: dict[str, str]) -> None:
r = 28
dwg.add(dwg.circle(center=(x + r, y + r), r=r, fill=brand["halal"], stroke="#ffffff", stroke_width=2))
dwg.add(
dwg.text(
"HALAL",
insert=(x + r, y + r + 5),
fill="#052e16",
font_size=11,
font_weight="bold",
font_family="Arial, sans-serif",
text_anchor="middle",
)
)
def _draw_window(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float) -> None:
dwg.add(dwg.rect(insert=(x, y), size=(w, h), fill="#bae6fd", fill_opacity=0.35, stroke="#38bdf8", stroke_width=1.5, rx=6))
dwg.add(dwg.text("WINDOW", insert=(x + w / 2, y + h / 2 + 4), fill="#0c4a6e", font_size=10, text_anchor="middle", font_family="Arial, sans-serif"))
def _draw_dimensions(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, spec: dict[str, Any], brand: dict[str, str]) -> None:
label = f"{spec.get('width_mm')} × {spec.get('height_mm')} × {spec.get('depth_mm')} mm"
dwg.add(
dwg.text(
label,
insert=(x + w / 2, y + h + 18),
fill=brand["muted"],
font_size=10,
text_anchor="middle",
font_family="Arial, sans-serif",
)
)
def _canvas_size(ptype: str, width_mm: float, height_mm: float, depth_mm: float) -> tuple[float, float]:
if ptype == "folding_box":
return _mm((width_mm * 2) + (depth_mm * 2) + 20), _mm(height_mm + depth_mm + 20)
if ptype == "wrap":
return _mm(width_mm + 20), _mm(height_mm + 20)
if ptype == "round_label":
d = max(min(width_mm, height_mm), 20)
return _mm(d + 20), _mm(d + 20)
if ptype == "sleeve":
return _mm((width_mm * 2) + depth_mm + 20), _mm(height_mm + 20)
if ptype == "pouch":
return _mm(width_mm + 20), _mm(height_mm + depth_mm + 20)
if ptype == "tray":
return _mm(width_mm + 20), _mm(height_mm + depth_mm + 20)
raise ValueError(f"Unsupported packaging type: {ptype}")
def generate_packaging(spec: dict[str, Any]) -> str:
2026-06-09 10:41:13 +00:00
"""Create an SVG packaging design from a rich spec."""
ptype = (spec.get("type") or "folding_box").strip().lower()
2026-06-09 10:41:13 +00:00
if ptype not in PACKAGING_TYPES:
raise ValueError(f"Unsupported packaging type: {ptype}")
width_mm = float(spec.get("width_mm", 120))
height_mm = float(spec.get("height_mm", 80))
depth_mm = float(spec.get("depth_mm", 40))
2026-06-09 10:41:13 +00:00
bleed_mm = float(spec.get("bleed_mm", 3))
elements = spec.get("elements", {})
brand = {**FOODLINKK_BRAND, **(spec.get("brand") or {})}
2026-06-09 10:41:13 +00:00
txt = _spec_text(spec)
2026-06-09 10:41:13 +00:00
canvas_w, canvas_h = _canvas_size(ptype, width_mm, height_mm, depth_mm)
dwg = svgwrite.Drawing(size=(canvas_w, canvas_h))
dwg.viewbox(0, 0, canvas_w, canvas_h)
dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=brand["bg"]))
dwg.add(
dwg.rect(
insert=(4, 4),
size=(canvas_w - 8, canvas_h - 8),
fill=brand["panel"],
rx=10,
ry=10,
stroke=brand["primary"],
stroke_opacity=0.25,
stroke_width=2,
)
)
margin = 24
2026-06-09 10:41:13 +00:00
body_w = body_h = x0 = y0 = 0.0
if ptype == "folding_box":
body_w = _mm(width_mm)
body_h = _mm(height_mm)
depth_w = _mm(depth_mm)
2026-06-09 10:41:13 +00:00
x0, y0 = margin, margin
panels = [depth_w, body_w, depth_w, body_w]
x = x0
for idx, panel_w in enumerate(panels):
2026-06-09 10:41:13 +00:00
fill = brand["primary"] if idx % 2 else brand["secondary"]
dwg.add(dwg.rect(insert=(x, y0), size=(panel_w, body_h), fill="none", stroke=fill, stroke_opacity=0.45, stroke_width=1.6))
x += panel_w
if _elements_enabled(elements, "fold_lines"):
x = x0 + panels[0]
for panel_w in panels[1:]:
2026-06-09 10:41:13 +00:00
dwg.add(dwg.line(start=(x, y0), end=(x, y0 + body_h), stroke=brand["fold_line"], stroke_dasharray="8,6", stroke_width=1.2))
x += panel_w
if _elements_enabled(elements, "cut_lines"):
2026-06-09 10:41:13 +00:00
dwg.add(dwg.rect(insert=(x0, y0), size=(sum(panels), body_h), fill="none", stroke=brand["cut_line"], stroke_dasharray="5,4", stroke_width=1.1))
if _elements_enabled(elements, "glue_tabs"):
tab_w, tab_h = _mm(12), _mm(8)
dwg.add(dwg.rect(insert=(x0 - tab_w, y0 + body_h * 0.4), size=(tab_w, tab_h), fill=brand["accent"], fill_opacity=0.35, stroke=brand["accent"]))
logo_x = x0 + panels[0] + (_mm(width_mm) * 0.1)
logo_y = y0 + (_mm(height_mm) * 0.12)
logo_w = _mm(width_mm) * 0.8
logo_h = _mm(height_mm) * 0.38
elif ptype == "wrap":
2026-06-09 10:41:13 +00:00
body_w, body_h = _mm(width_mm), _mm(height_mm)
x0, y0 = margin, margin
dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["primary"], stroke_width=2.2))
if _elements_enabled(elements, "fold_lines"):
dwg.add(dwg.line(start=(x0 + body_w / 2, y0), end=(x0 + body_w / 2, y0 + body_h), stroke=brand["fold_line"], stroke_dasharray="8,6", stroke_width=1.2))
if _elements_enabled(elements, "cut_lines"):
dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["cut_line"], stroke_dasharray="6,4", stroke_width=1.1))
logo_x, logo_y = x0 + body_w * 0.1, y0 + body_h * 0.12
logo_w, logo_h = body_w * 0.8, body_h * 0.35
elif ptype == "sleeve":
body_w, body_h = _mm(width_mm), _mm(height_mm)
depth_w = _mm(depth_mm)
x0, y0 = margin, margin
panels = [body_w, depth_w, body_w]
x = x0
for panel_w in panels:
dwg.add(dwg.rect(insert=(x, y0), size=(panel_w, body_h), fill="none", stroke=brand["primary"], stroke_width=1.8))
x += panel_w
if _elements_enabled(elements, "fold_lines"):
x = x0 + panels[0]
for panel_w in panels[1:]:
dwg.add(dwg.line(start=(x, y0), end=(x, y0 + body_h), stroke=brand["fold_line"], stroke_dasharray="8,6", stroke_width=1.2))
x += panel_w
logo_x, logo_y = x0 + body_w * 0.12, y0 + body_h * 0.15
logo_w, logo_h = body_w * 0.76, body_h * 0.4
elif ptype == "pouch":
body_w = _mm(width_mm)
body_h = _mm(height_mm)
2026-06-09 10:41:13 +00:00
seal_h = _mm(max(depth_mm, 15))
x0, y0 = margin, margin + seal_h
dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["primary"], stroke_width=2))
dwg.add(dwg.rect(insert=(x0, y0 - seal_h), size=(body_w, seal_h), fill=brand["secondary"], fill_opacity=0.2, stroke=brand["secondary"]))
dwg.add(dwg.text("SEAL", insert=(x0 + body_w / 2, y0 - seal_h / 2 + 4), fill=brand["text"], font_size=10, text_anchor="middle", font_family="Arial, sans-serif"))
logo_x, logo_y = x0 + body_w * 0.12, y0 + body_h * 0.18
logo_w, logo_h = body_w * 0.76, body_h * 0.42
elif ptype == "tray":
body_w, body_h = _mm(width_mm), _mm(height_mm)
lip = _mm(max(depth_mm, 8))
x0, y0 = margin, margin
dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["primary"], stroke_width=2.4))
dwg.add(dwg.rect(insert=(x0 + lip, y0 + lip), size=(body_w - 2 * lip, body_h - 2 * lip), fill="none", stroke=brand["fold_line"], stroke_dasharray="5,4", stroke_width=1))
logo_x, logo_y = x0 + lip + 8, y0 + lip + 8
logo_w, logo_h = body_w - 2 * lip - 16, (body_h - 2 * lip) * 0.45
else: # round_label
diameter = min(canvas_w, canvas_h) - (margin * 2)
2026-06-09 10:41:13 +00:00
cx, cy = canvas_w / 2, canvas_h / 2
dwg.add(dwg.circle(center=(cx, cy), r=diameter / 2, fill="none", stroke=brand["primary"], stroke_width=2.4))
if _elements_enabled(elements, "cut_lines"):
2026-06-09 10:41:13 +00:00
dwg.add(dwg.circle(center=(cx, cy), r=(diameter / 2) - 4, fill="none", stroke=brand["cut_line"], stroke_dasharray="4,4", stroke_width=1.0))
logo_w, logo_h = diameter * 0.64, diameter * 0.22
logo_x, logo_y = cx - (logo_w / 2), cy - (logo_h / 2) - 8
body_w = body_h = diameter
x0, y0 = cx - (diameter / 2), cy - (diameter / 2)
if _elements_enabled(elements, "bleed"):
_draw_bleed(dwg, x0, y0, body_w if ptype != "folding_box" else sum([_mm(depth_mm), _mm(width_mm), _mm(depth_mm), _mm(width_mm)]), body_h, brand, bleed_mm)
if _elements_enabled(elements, "logo_area"):
2026-06-09 10:41:13 +00:00
_draw_logo_block(dwg, logo_x, logo_y, logo_w, logo_h, brand, txt)
if _elements_enabled(elements, "window"):
wx = x0 + body_w * 0.55 if ptype != "round_label" else logo_x + logo_w * 0.1
wy = y0 + body_h * 0.55 if ptype != "round_label" else logo_y + logo_h + 8
_draw_window(dwg, wx, wy, max(60, body_w * 0.32), max(40, body_h * 0.22))
if _elements_enabled(elements, "halal_badge"):
_draw_halal_badge(dwg, x0 + 8, y0 + 8, brand)
if _elements_enabled(elements, "nutrition_panel"):
nx = x0 + 8
ny = y0 + body_h - min(120, body_h * 0.45)
_draw_nutrition_panel(dwg, nx, ny, min(150, body_w * 0.42), min(110, body_h * 0.4), brand, _nutrition_rows(spec))
if _elements_enabled(elements, "ingredients") and txt["ingredients"]:
_draw_ingredients(dwg, x0 + 8, y0 + body_h - 28, body_w - 16, txt["ingredients"], brand)
if _elements_enabled(elements, "barcode"):
barcode_uri = _barcode_data_uri(str(spec.get("barcode_value") or DEFAULT_BARCODE_VALUE))
bar_w = max(140, body_w * 0.35)
bar_h = max(50, body_h * 0.16)
bar_x = x0 + body_w - bar_w - 14
bar_y = y0 + body_h - bar_h - 14
dwg.add(dwg.rect(insert=(bar_x - 4, bar_y - 4), size=(bar_w + 8, bar_h + 8), fill="#ffffff"))
dwg.add(dwg.image(href=barcode_uri, insert=(bar_x, bar_y), size=(bar_w, bar_h)))
2026-06-09 10:41:13 +00:00
if _elements_enabled(elements, "qr_code"):
qr_uri = _qr_placeholder_svg(100)
qr_s = min(90, body_w * 0.22)
dwg.add(dwg.image(href=qr_uri, insert=(x0 + 10, y0 + body_h - qr_s - 10), size=(qr_s, qr_s)))
if _elements_enabled(elements, "dimensions"):
_draw_dimensions(dwg, x0, y0, body_w, body_h, spec, brand)
design_name = spec.get("design_name") or txt["product_name"]
dwg.add(
dwg.text(
f"Foodlinkk Packaging Studio · {design_name}"[:80],
insert=(12, canvas_h - 10),
fill=brand["muted"],
font_size=9,
font_family="Arial, sans-serif",
)
)
return dwg.tostring()