433 lines
17 KiB
Python
433 lines
17 KiB
Python
"""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()
|