Files
foodlinkk-command-center/tools-api/app/packaging/generator.py
T
2026-06-09 10:41:13 +00:00

433 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
MM_TO_PX = 3.7795275591
DEFAULT_BARCODE_VALUE = "8710000000012"
FOODLINKK_BRAND = {
"bg": "#0b1220",
"panel": "#101a2d",
"primary": "#00e5ff",
"secondary": "#ffd700",
"accent": "#b8ff3c",
"text": "#e2e8f0",
"muted": "#94a3b8",
"cut_line": "#ef4444",
"fold_line": "#60a5fa",
"bleed": "#f97316",
"halal": "#22c55e",
}
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}"
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:
"""Create an SVG packaging design from a rich spec."""
ptype = (spec.get("type") or "folding_box").strip().lower()
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))
bleed_mm = float(spec.get("bleed_mm", 3))
elements = spec.get("elements", {})
brand = {**FOODLINKK_BRAND, **(spec.get("brand") or {})}
txt = _spec_text(spec)
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
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)
x0, y0 = margin, margin
panels = [depth_w, body_w, depth_w, body_w]
x = x0
for idx, panel_w in enumerate(panels):
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:]:
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"):
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":
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)
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)
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"):
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"):
_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)))
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()