bff6d70b17
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
682 lines
25 KiB
Python
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 = " · " 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")
|