Files
cantarire-etichetare/cantarire/designer.py
T
ske087 bff6d70b17 Initial commit: aplicatie de cantarire si etichetare
Aplicatie Flask + SQLite pentru productie alimentara: cantarirea
componentelor unui produs finit si tiparirea etichetei prin QZ Tray.

- produse finite (retete) formate din componente cantarite
- designer vizual de etichete (mm/px, zona sigura, campuri variabile,
  cod QR cu continut din mai multe bucati)
- legare produs finit -> sablon de eticheta
- navigare pe doua taburi: Cantarire / Setari
- administrare: componente, produse finite, sabloane, campuri, istoric
- backup, resetare baza de date, checklist de pregatire, /healthz
- CLI de administrare si serviciu systemd pentru productie
- vendor/syncpulse-scale-bridge (GPL-3.0-or-later) pentru citirea cantarului
2026-10-18 23:38:45 +03:00

682 lines
25 KiB
Python

"""Designer de etichete: model, validare si randare HTML.
Un sablon este descris prin `design_json` — blocuri pozitionate in milimetri,
editate vizual in browser. Randarea se face integral pe server.
Modelul de design (v1):
{
"version": 1,
"width_mm": 100, "height_mm": 60,
"blocks": [
{"id": "b1", "type": "text", "x": 4, "y": 4, "w": 60, "h": 6,
"text": "PRODUS FINIT", "style": {"size": 3.2, "bold": true}},
{"id": "b2", "type": "field", "x": 4, "y": 10, "w": 60, "h": 12,
"field": "package.total_weight", "style": {"size": 14, "bold": true}},
{"id": "b3", "type": "items", "x": 4, "y": 26, "w": 60, "h": 20,
"style": {"size": 3.4}},
{"id": "b4", "type": "qr", "x": 72, "y": 30, "w": 22, "h": 22},
{"id": "b5", "type": "line", "x": 4, "y": 50, "w": 92, "h": 0.4}
]
}
Randarea este facuta integral pe server, in HTML cu pozitionare absoluta in
mm, ca eticheta sa fie identica in previzualizare si la tiparirea QZ Tray.
"""
from __future__ import annotations
import base64
import html
import io
import json
import math
import re
from datetime import datetime
from functools import lru_cache
from typing import Any, Iterable, Optional
from .repositories import ValidationError
from .units import format_weight
DESIGN_VERSION = 1
MAX_BLOCKS = 60
MIN_SIZE_MM = 0.3
# Marja pe care imprimantele termice nu o pot acoperi, afisata in editor ca
# contur rosu. Nu este impusa la validare, doar recomandata.
SAFE_MARGIN_MM = 3.0
MAX_COLOR = re.compile(r"^#[0-9A-Fa-f]{3,8}$")
# Campuri disponibile in designer. `sample` este folosit pentru previzualizare.
FIELD_CATALOG: list[tuple[str, str, str]] = [
("recipe.name", "Produs finit — denumire", "Fasole cu carne"),
("recipe.code", "Produs finit — cod", "PF-001"),
("package.total_weight", "Greutate totală", "450"),
("package.weight_unit", "Greutate cu unitate", "450 g"),
("package.unit", "Unitate de măsură", "g"),
("package.lot", "Lot", "LOT-2610-A"),
("package.operator", "Operator", "Maria"),
("package.completed_at", "Data finalizării", "18.10.2026 20:15"),
("package.id", "Număr pachet", "124"),
("company.name", "Firmă — denumire", "Moto Adv SRL"),
("company.address", "Firmă — adresă", "Str. Exemplu 1"),
("company.phone", "Firmă — telefon", "0700 000 000"),
]
FIELD_KEYS = {key for key, _label, _sample in FIELD_CATALOG}
# Campuri proprii (definite din pagina "Campuri"): `custom.<slug>`.
CUSTOM_PREFIX = "custom."
CUSTOM_KEY = re.compile(r"^custom\.[a-z0-9_]{1,48}$")
PLACEHOLDER = re.compile(r"\{\{\s*([A-Za-z0-9_.]+)\s*\}\}")
MAX_CUSTOM_TEMPLATE = 300
MAX_CUSTOM_LABEL = 60
BLOCK_TYPES = ("text", "field", "items", "qr", "line", "image")
# Continutul codului QR: lista ordonata de campuri si/sau text liber.
MAX_QR_PARTS = 12
MAX_QR_TEXT = 60
QR_SEPARATORS: dict[str, str] = {
"pipe": " | ",
"dash": " - ",
"slash": "/",
"space": " ",
"newline": "\n",
"none": "",
}
# Chei acceptate doar in QR (nu sunt campuri de afisat ca text).
QR_EXTRA_KEYS = {"package.qr"}
DEFAULT_STYLES: dict[str, dict[str, Any]] = {
"text": {"size": 3.2, "bold": False, "italic": False, "align": "left",
"uppercase": False, "color": "#000000"},
"field": {"size": 4.0, "bold": False, "italic": False, "align": "left",
"uppercase": False, "color": "#000000"},
"items": {"size": 3.4, "bold": False, "italic": False, "align": "left",
"uppercase": False, "color": "#000000", "separator": "stack"},
}
# ---------------------------------------------------------------------------
# Validare
# ---------------------------------------------------------------------------
def _number(value: Any, *, default: float, low: float, high: float) -> float:
try:
number = float(value)
except (TypeError, ValueError):
return default
if not math.isfinite(number):
return default
return round(min(max(number, low), high), 3)
def _style(raw: Any, kind: str) -> dict[str, Any]:
base = dict(DEFAULT_STYLES.get(kind, DEFAULT_STYLES["text"]))
if isinstance(raw, dict):
base["size"] = _number(raw.get("size"), default=base["size"], low=1.5, high=40)
for flag in ("bold", "italic", "uppercase"):
if flag in raw:
base[flag] = bool(raw[flag])
align = str(raw.get("align") or base["align"]).lower()
base["align"] = align if align in {"left", "center", "right"} else "left"
color = str(raw.get("color") or base["color"])
base["color"] = color if MAX_COLOR.match(color) else "#000000"
if kind == "items":
separator = str(raw.get("separator") or base.get("separator", "stack"))
base["separator"] = separator if separator in {"stack", "inline"} else "stack"
return base
def _clean_block(raw: Any, index: int, width: float, height: float) -> Optional[dict[str, Any]]:
if not isinstance(raw, dict):
return None
kind = str(raw.get("type") or "").lower()
if kind not in BLOCK_TYPES:
return None
block = {
"id": str(raw.get("id") or f"b{index + 1}")[:40],
"type": kind,
"x": _number(raw.get("x"), default=0, low=0, high=width),
"y": _number(raw.get("y"), default=0, low=0, high=height),
"w": _number(raw.get("w"), default=max(10.0, width / 2), low=1, high=width),
"h": _number(raw.get("h"), default=6, low=MIN_SIZE_MM, high=height),
"style": _style(raw.get("style"), kind),
}
# Sa nu iasa din eticheta.
block["w"] = min(block["w"], max(1.0, width - block["x"]))
block["h"] = min(block["h"], max(MIN_SIZE_MM, height - block["y"]))
if kind == "text":
block["text"] = str(raw.get("text") or "")[:200]
elif kind == "field":
field = str(raw.get("field") or "recipe.name")[:60]
if field in FIELD_KEYS or CUSTOM_KEY.match(field):
block["field"] = field
else:
block["field"] = "recipe.name"
block["prefix"] = str(raw.get("prefix") or "")[:40]
block["suffix"] = str(raw.get("suffix") or "")[:40]
elif kind == "qr":
block["separator"], block["parts"] = _clean_qr_parts(raw)
elif kind == "image":
src = str(raw.get("src") or "")[:2000]
if not _image_src_ok(src):
src = ""
block["src"] = src
return block
def _image_src_ok(src: str) -> bool:
if not src:
return False
if src.startswith("data:image/"):
return len(src) <= 400_000
return src.startswith(("http://", "https://", "/static/"))
def qr_key_ok(key: str) -> bool:
return key in FIELD_KEYS or key in QR_EXTRA_KEYS or bool(CUSTOM_KEY.match(key))
def _clean_qr_parts(raw: dict[str, Any]) -> tuple[str, list[dict[str, str]]]:
"""Continutul QR: separator + lista de `{field}` / `{text}`."""
separator = str(raw.get("separator") or "pipe")
if separator not in QR_SEPARATORS:
separator = "pipe"
parts: list[dict[str, str]] = []
raw_parts = raw.get("parts")
if isinstance(raw_parts, list):
for item in raw_parts:
if len(parts) >= MAX_QR_PARTS:
break
if not isinstance(item, dict):
continue
if item.get("field"):
key = str(item.get("field"))[:60]
if qr_key_ok(key):
parts.append({"field": key})
elif "text" in item:
text = str(item.get("text") or "")[:MAX_QR_TEXT]
if text:
parts.append({"text": text})
# Designuri vechi: doar `field`; il pastram ca o singura parte.
if not parts:
legacy = str(raw.get("field") or "package.qr")[:60]
parts = [{"field": legacy if qr_key_ok(legacy) else "package.qr"}]
return separator, parts
def qr_payload(block: dict[str, Any], fields: dict[str, Any]) -> str:
"""Textul codat in QR, din partile configurate (goale sunt sarite)."""
parts = block.get("parts") or []
if not parts:
return str(fields.get(block.get("field") or "package.qr") or "")
separator = QR_SEPARATORS.get(str(block.get("separator") or "pipe"), " | ")
pieces: list[str] = []
for part in parts:
if "text" in part:
piece = str(part.get("text") or "")
else:
piece = str(fields.get(part.get("field") or "") or "")
if piece:
pieces.append(piece)
return separator.join(pieces)
def validate_design(raw: Any, width_mm: float, height_mm: float) -> dict[str, Any]:
"""Curata si limiteaza un design primit din browser."""
if isinstance(raw, str):
try:
raw = json.loads(raw)
except ValueError as exc:
raise ValidationError("Designul nu este un JSON valid.") from exc
if not isinstance(raw, dict):
raise ValidationError("Designul trebuie sa fie un obiect JSON.")
width = _number(raw.get("width_mm"), default=width_mm, low=10, high=500)
height = _number(raw.get("height_mm"), default=height_mm, low=10, high=500)
blocks_raw = raw.get("blocks")
if not isinstance(blocks_raw, list):
blocks_raw = []
if len(blocks_raw) > MAX_BLOCKS:
raise ValidationError(f"Maxim {MAX_BLOCKS} elemente pe eticheta.")
blocks = []
for index, item in enumerate(blocks_raw):
block = _clean_block(item, index, width, height)
if block is not None:
blocks.append(block)
return {
"version": DESIGN_VERSION,
"width_mm": width,
"height_mm": height,
"blocks": blocks,
}
def dump_design(design: dict[str, Any]) -> str:
return json.dumps(design, ensure_ascii=False, separators=(",", ":"))
def load_design(text: str, width_mm: float, height_mm: float) -> Optional[dict[str, Any]]:
if not text:
return None
try:
return validate_design(json.loads(text), width_mm, height_mm)
except (ValueError, ValidationError):
return None
# ---------------------------------------------------------------------------
# Valori
# ---------------------------------------------------------------------------
def _date(value: Any, fmt: str = "%d.%m.%Y %H:%M") -> str:
if not value:
return ""
try:
return datetime.fromisoformat(str(value)).strftime(fmt)
except (TypeError, ValueError):
return str(value)
def resolve_fields(
package: dict[str, Any],
items: Iterable[dict[str, Any]],
recipe: Optional[dict[str, Any]],
settings: dict[str, str],
) -> dict[str, str]:
"""Valorile afisate pe eticheta (deja formatate)."""
recipe = recipe or {}
item_list = list(items)
unit = package.get("unit") or recipe.get("unit") or ""
return {
"recipe.name": str(recipe.get("name") or package.get("recipe_name") or ""),
"recipe.code": str(recipe.get("code") or package.get("recipe_code") or ""),
"package.total_weight": format_weight(package.get("total_weight")),
"package.weight_unit": (
f"{format_weight(package.get('total_weight'))} {unit}".strip()
),
"package.unit": str(unit),
"package.lot": str(package.get("lot") or ""),
"package.operator": str(package.get("operator") or ""),
"package.completed_at": _date(package.get("completed_at") or package.get("created_at")),
"package.id": str(package.get("id") or ""),
"company.name": str(settings.get("company_name") or ""),
"company.address": str(settings.get("company_address") or ""),
"company.phone": str(settings.get("company_phone") or ""),
# Text folosit in codul QR: produs, lot, greutate, data.
"package.qr": " | ".join(part for part in (
str(recipe.get("code") or package.get("recipe_code") or ""),
str(package.get("lot") or ""),
f"{format_weight(package.get('total_weight'))} {unit}".strip(),
_date(package.get("completed_at") or package.get("created_at"), "%Y-%m-%d"),
) if part),
"_items": item_list, # type: ignore[dict-item]
}
def demo_fields() -> dict[str, Any]:
"""Aceleasi valori ca in designer, ca previzualizarea sa fie fidela."""
demo_package = {
"id": 124,
"recipe_code": "PF-001",
"recipe_name": "Fasole cu carne",
"unit": "g",
"total_weight": 450,
"lot": "LOT-2610-A",
"operator": "Maria",
"completed_at": "2026-10-18T20:15:00+03:00",
}
demo_items = [
{"position": 1, "component_name": "Fasole", "weight": 250, "unit": "g"},
{"position": 2, "component_name": "Carne", "weight": 200, "unit": "g"},
]
demo_recipe = {"code": "PF-001", "name": "Fasole cu carne", "unit": "g"}
demo_settings = {
"company_name": "Moto Adv SRL",
"company_address": "Str. Exemplu 1",
"company_phone": "0700 000 000",
}
return resolve_fields(demo_package, demo_items, demo_recipe, demo_settings)
def demo_items() -> list[dict[str, Any]]:
return [
{"position": 1, "component_name": "Fasole", "weight": 250, "unit": "g"},
{"position": 2, "component_name": "Carne", "weight": 200, "unit": "g"},
]
# ---------------------------------------------------------------------------
# Campuri proprii (custom.*)
# ---------------------------------------------------------------------------
def custom_placeholders(text: str) -> list[str]:
"""Numele de campuri referite prin {{...}} intr-un sablon de camp propriu."""
return PLACEHOLDER.findall(str(text or ""))
def custom_template_errors(text: str) -> list[str]:
"""Mesaje pentru sablonul unui camp propriu (gol, prea lung, campuri necunoscute)."""
value = str(text or "").strip()
if not value:
return ["Textul campului este obligatoriu."]
if len(value) > MAX_CUSTOM_TEMPLATE:
return [f"Textul campului poate avea maxim {MAX_CUSTOM_TEMPLATE} caractere."]
unknown = sorted({name for name in custom_placeholders(value) if name not in FIELD_KEYS})
if unknown:
return [
"Campuri necunoscute: "
+ ", ".join(f"{{{{{name}}}}}" for name in unknown)
+ ". Sunt acceptate doar campurile fixe din lista."
]
return []
def validate_custom_field(data: dict[str, Any]) -> None:
"""Verifica un camp propriu inainte de salvare."""
label = str(data.get("label") or "").strip()
if not label:
raise ValidationError("Denumirea campului este obligatorie.")
if len(label) > MAX_CUSTOM_LABEL:
raise ValidationError(f"Denumirea poate avea maxim {MAX_CUSTOM_LABEL} caractere.")
errors = custom_template_errors(str(data.get("template") or ""))
if errors:
raise ValidationError(errors[0])
def expand_custom(text: str, values: dict[str, Any]) -> str:
"""Inlocuieste {{camp}} cu valoarea deja calculata; necunoscutele devin gol."""
def replace(match: "re.Match[str]") -> str:
value = values.get(match.group(1))
if value is None or isinstance(value, (list, dict)):
return ""
return str(value)
return PLACEHOLDER.sub(replace, str(text or ""))
def apply_custom_fields(
values: dict[str, Any],
custom: Optional[Iterable[dict[str, Any]]] = None,
) -> dict[str, Any]:
"""Adauga valorile campurilor proprii peste cele fixe.
Campurile proprii se pot referi doar la campuri fixe, deci nu pot intra in bucla.
"""
result = dict(values)
for row in custom or []:
key = str(row.get("key") or "")
if not CUSTOM_KEY.match(key):
continue
result[key] = expand_custom(row.get("template") or "", result)
return result
def custom_field_catalog(
custom: Optional[Iterable[dict[str, Any]]] = None,
demo: Optional[dict[str, Any]] = None,
) -> list[dict[str, Any]]:
"""Intrarile pentru popup-ul de alegere a campului, inclusiv cele proprii."""
samples = demo if demo is not None else demo_fields()
entries: list[dict[str, Any]] = []
for row in custom or []:
key = str(row.get("key") or "")
if not CUSTOM_KEY.match(key):
continue
entries.append({
"key": key,
"label": row.get("label") or key,
"sample": str(samples.get(key) or ""),
"group": "Câmpuri proprii",
"custom": True,
})
return entries
# ---------------------------------------------------------------------------
# Randare
# ---------------------------------------------------------------------------
@lru_cache(maxsize=64)
def qr_svg(payload: str) -> str:
"""Cod QR inline (SVG), fara dependinte de imagini externe."""
import qrcode
import qrcode.image.svg
image = qrcode.make(
payload or " ",
image_factory=qrcode.image.svg.SvgPathImage,
box_size=10,
border=2,
)
buffer = io.BytesIO()
image.save(buffer)
svg = buffer.getvalue().decode("utf-8")
# Dimensiunea o da CSS-ul; pastram doar viewBox-ul.
return re.sub(r'<svg([^>]*?)\swidth="[^"]*"\sheight="[^"]*"',
r"<svg\1", svg, count=1)
def _font_size(style: dict[str, Any]) -> float:
return float(style.get("size") or 3.2)
def _text_style(style: dict[str, Any], *, height: float) -> str:
parts = [
f"font-size:{_font_size(style):.2f}mm",
f"font-weight:{'700' if style.get('bold') else '400'}",
f"font-style:{'italic' if style.get('italic') else 'normal'}",
f"text-align:{style.get('align', 'left')}",
f"color:{style.get('color', '#000000')}",
"line-height:1.15",
f"height:{height:.2f}mm",
"overflow:hidden",
"white-space:pre-wrap",
"word-break:break-word",
]
if style.get("uppercase"):
parts.append("text-transform:uppercase")
return ";".join(parts)
def _block_position(block: dict[str, Any]) -> str:
return (
f"left:{block['x']:.2f}mm;top:{block['y']:.2f}mm;"
f"width:{block['w']:.2f}mm;height:{block['h']:.2f}mm"
)
def render_block(
block: dict[str, Any],
fields: dict[str, Any],
items: list[dict[str, Any]],
) -> str:
kind = block["type"]
style = block.get("style") or {}
position = _block_position(block)
if kind == "line":
return (
f'<div class="d-block d-line" style="{position};background:'
f'{style.get("color", "#000000")}"></div>'
)
if kind == "text":
text = html.escape(block.get("text") or "")
if style.get("uppercase"):
text = text.upper()
return (f'<div class="d-block d-text" style="{position};'
f'{_text_style(style, height=block["h"])}">{text}</div>')
if kind == "field":
value = str(fields.get(block.get("field") or "") or "")
prefix = block.get("prefix") or ""
suffix = block.get("suffix") or ""
text = html.escape(f"{prefix}{value}{suffix}")
if style.get("uppercase"):
text = text.upper()
return (f'<div class="d-block d-field" style="{position};'
f'{_text_style(style, height=block["h"])}">{text}</div>')
if kind == "items":
if not items:
return f'<div class="d-block d-items" style="{position}"></div>'
lines = []
for item in items:
weight = format_weight(item.get("weight"))
unit = html.escape(str(item.get("unit") or ""))
name = html.escape(str(item.get("component_name") or ""))
if style.get("separator") == "inline":
lines.append(
f'<span class="d-item"><span class="d-item-w">{weight} {unit}</span> {name}</span>'
)
else:
lines.append(
f'<div class="d-item"><span class="d-item-w">{weight} {unit}</span> {name}</div>'
)
separator = " &middot; " if style.get("separator") == "inline" else ""
body = separator.join(lines)
return (f'<div class="d-block d-items" style="{position};'
f'{_text_style(style, height=block["h"])}">{body}</div>')
if kind == "qr":
value = qr_payload(block, fields)
svg = qr_svg(value)
return (f'<div class="d-block d-qr" style="{position}">'
f'<div class="d-qr-inner">{svg}</div></div>')
if kind == "image":
src = block.get("src") or ""
if not src:
return (f'<div class="d-block d-image d-image-empty" style="{position}"></div>')
return (f'<div class="d-block d-image" style="{position}">'
f'<img src="{html.escape(src, quote=True)}" alt=""></div>')
return ""
def render_design(
design: dict[str, Any],
fields: dict[str, Any],
items: list[dict[str, Any]],
) -> str:
width = float(design.get("width_mm") or 100)
height = float(design.get("height_mm") or 60)
blocks = "".join(render_block(block, fields, items)
for block in design.get("blocks") or [])
return (
f'<div class="design-label" style="position:relative;width:{width:.2f}mm;'
f'height:{height:.2f}mm;overflow:hidden;background:#fff;color:#000">'
f"{blocks}</div>"
)
DESIGN_CSS = """\
.design-label { font-family: Arial, Helvetica, sans-serif; }
.d-block { position: absolute; box-sizing: border-box; }
.d-text, .d-field, .d-items { overflow: hidden; }
.d-item { white-space: nowrap; }
.d-item-w { font-weight: 700; }
.d-qr-inner { width: 100%; height: 100%; }
.d-qr-inner svg { width: 100%; height: 100%; display: block; }
.d-image img { width: 100%; height: 100%; object-fit: contain; display: block; }
.d-image-empty { border: 0.3mm dashed #94a3b8; }
"""
# ---------------------------------------------------------------------------
# Sablon nou
# ---------------------------------------------------------------------------
def default_design(width_mm: float = 100.0, height_mm: float = 60.0) -> dict[str, Any]:
"""Un design de pornire, cu elementele deja asezate in zona sigura.
Proportiile se adapteaza la inaltimea etichetei, ca elementele sa nu iasa
din margini nici pe formate mici (ex. 60 x 40 mm).
"""
width = float(width_mm)
height = float(height_mm)
margin = SAFE_MARGIN_MM
k = min(1.0, max(0.55, height / 60.0))
def r(value: float) -> float:
return round(value, 2)
qr = min(22.0, width * 0.28, max(8.0, height - 2 * margin - 2))
qr_x = r(width - margin - qr)
column = r(max(6.0, qr_x - 2 - margin))
company_h = 4 * k
name_h = 8 * k
weight_h = 15 * k
meta_h = 4 * k
meta_step = 4.5 * k
company_y = margin
line_y = company_y + company_h
name_y = line_y + 1
weight_y = name_y + name_h + 1
items_y = weight_y + weight_h + 1
operator_y = height - margin - meta_h
lot_y = operator_y - meta_step
items_h = max(3 * k, lot_y - 1 - items_y)
return {
"version": DESIGN_VERSION,
"width_mm": width,
"height_mm": height,
"blocks": [
{"id": "b1", "type": "field", "x": r(margin), "y": r(company_y),
"w": column, "h": r(company_h),
"field": "company.name",
"style": _style({"size": 3.0 * k, "bold": True, "uppercase": True}, "field")},
{"id": "b2", "type": "field", "x": r(margin), "y": r(name_y),
"w": column, "h": r(name_h),
"field": "recipe.name", "style": _style({"size": 5.0 * k, "bold": True}, "field")},
{"id": "b3", "type": "field", "x": r(margin), "y": r(weight_y),
"w": column, "h": r(weight_h),
"field": "package.weight_unit", "suffix": "",
"style": _style({"size": 14.0 * k, "bold": True}, "field")},
{"id": "b4", "type": "items", "x": r(margin), "y": r(items_y),
"w": column, "h": r(items_h), "style": _style({"size": 3.4 * k}, "items")},
{"id": "b5", "type": "field", "x": r(margin), "y": r(lot_y),
"w": column, "h": r(meta_h),
"field": "package.lot", "prefix": "Lot: ",
"style": _style({"size": 3.0 * k}, "field")},
{"id": "b6", "type": "field", "x": r(margin), "y": r(operator_y),
"w": column, "h": r(meta_h),
"field": "package.operator", "prefix": "Operator: ",
"style": _style({"size": 3.0 * k}, "field")},
{"id": "b7", "type": "qr", "x": qr_x, "y": r(margin + 1),
"w": r(qr), "h": r(qr),
"field": "package.qr", "separator": "pipe",
"parts": [{"field": "package.qr"}],
"style": _style({}, "qr")},
{"id": "b8", "type": "line", "x": r(margin), "y": r(line_y),
"w": column, "h": 0.3,
"style": _style({"color": "#000000"}, "line")},
],
}
def render_preview(
design: dict[str, Any],
custom: Optional[Iterable[dict[str, Any]]] = None,
) -> tuple[str, str]:
"""(html, css) randate cu date demo — pentru previzualizarea din editor."""
fields = apply_custom_fields(demo_fields(), custom)
items = demo_items()
return render_design(design, fields, items), DESIGN_CSS
def design_to_base64(design: dict[str, Any]) -> str:
return base64.b64encode(dump_design(design).encode("utf-8")).decode("ascii")