"""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.`. 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']*?)\swidth="[^"]*"\sheight="[^"]*"', r" 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'
' ) if kind == "text": text = html.escape(block.get("text") or "") if style.get("uppercase"): text = text.upper() return (f'
{text}
') 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'
{text}
') if kind == "items": if not items: return f'
' 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'{weight} {unit} {name}' ) else: lines.append( f'
{weight} {unit} {name}
' ) separator = " · " if style.get("separator") == "inline" else "" body = separator.join(lines) return (f'
{body}
') if kind == "qr": value = qr_payload(block, fields) svg = qr_svg(value) return (f'
' f'
{svg}
') if kind == "image": src = block.get("src") or "" if not src: return (f'
') return (f'
' f'
') 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'
' f"{blocks}
" ) 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")