diff --git a/backend/app/schemas/template.py b/backend/app/schemas/template.py new file mode 100644 index 0000000..8b1d91e --- /dev/null +++ b/backend/app/schemas/template.py @@ -0,0 +1,64 @@ +"""Label-template schema describing the layout of a printable label. + +A `TemplateSchema` is a recipe for placing QR codes and text on the +printable area of a Brother tape. The renderer consumes a template plus a +`LabelData` payload and emits a 1-bit PIL Image ready for the printer. + +Templates are frozen at construction so they can be safely seeded as +module-level constants (see app/seed/templates.py in PR D2). +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, model_validator + + +class LayoutElement(BaseModel): + """A single drawable element — either a QR code or a text run. + + The `type` field discriminates which subset of the optional fields + is required. `model_validator(mode="after")` enforces the contract + at construction time so the renderer can trust the shape. + """ + + model_config = ConfigDict(frozen=True) + + type: Literal["qr", "text"] + x: int + y: int + # qr-specific + size: int | None = None + data_field: str | None = None + # text-specific + field: str | None = None + font_size: int | None = None + + @model_validator(mode="after") + def _validate_per_type(self) -> LayoutElement: + if self.type == "qr": + if not self.data_field: + raise ValueError("qr element requires data_field") + if self.size is None or self.size <= 0: + raise ValueError(f"qr element requires a positive size (got {self.size!r})") + else: # type == "text" + if not self.field: + raise ValueError("text element requires field") + if self.font_size is None or self.font_size <= 0: + raise ValueError( + f"text element requires a positive font_size (got {self.font_size!r})" + ) + return self + + +class TemplateSchema(BaseModel): + """A complete label template — identity, target app, tape size, and layout.""" + + model_config = ConfigDict(frozen=True) + + id: str + name: str + app: Literal["snipeit", "grocy", "spoolman"] + tape_mm: int + elements: tuple[LayoutElement, ...] diff --git a/backend/app/services/label_renderer.py b/backend/app/services/label_renderer.py new file mode 100644 index 0000000..da3fbab --- /dev/null +++ b/backend/app/services/label_renderer.py @@ -0,0 +1,127 @@ +"""Compose a 1-bit PIL Image from a TemplateSchema + LabelData. + +The renderer is stateless — one instance can serve concurrent requests. +It does not know the printer or the queue; it only produces the bitmap. +The printer-backend plug-in (Phase 2 hardware tasks) converts the bitmap +to raster bytes for the specific Brother model. + +Coordinate system: top-left origin, pixels at 300 DPI (brother_ql native). +The print area is constrained by Brother's per-tape geometry tables — +see `TAPE_HEIGHT_PX` for the supported widths. +""" + +from __future__ import annotations + +import functools +from typing import Final + +import qrcode +import qrcode.constants +from PIL import Image, ImageDraw, ImageFont + +from app.schemas.label_data import LabelData +from app.schemas.template import LayoutElement, TemplateSchema + +# Tape-mm to printable-area pixel-height at 300 DPI (brother_ql native). +# Source: Brother Raster Command Reference v1.02. Extend as new tape widths +# are supported by the printer-model plugins. +TAPE_HEIGHT_PX: Final[dict[int, int]] = { + 12: 106, + 18: 165, + 24: 256, + 62: 696, # endless QL tape +} + +# Default label width in pixels — 600 px at 300 DPI ≈ 50.8mm, suitable for +# typical asset/product label lengths. The actual width the printer receives +# is determined by the print job; this is just the canvas the renderer +# paints on. +DEFAULT_LABEL_WIDTH_PX: Final[int] = 600 + + +@functools.lru_cache(maxsize=32) +def _load_font_cached(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + """Load DejaVuSans at `size`px (cached), fall back to PIL's bitmap default if unavailable. + + The cache is bounded at 32 entries — far more than any realistic template uses. + Repeated calls with the same size return the same font instance without disk I/O. + """ + try: + return ImageFont.truetype("DejaVuSans.ttf", size) + except OSError: + return ImageFont.load_default() + + +class LabelRenderer: + """Render a (LabelData, TemplateSchema) pair into a 1-bit PIL Image.""" + + def render(self, data: LabelData, template: TemplateSchema) -> Image.Image: + """Return a 1-bit image sized for the template's tape width. + + Raises: + ValueError: if `template.tape_mm` is not in TAPE_HEIGHT_PX. + """ + height = TAPE_HEIGHT_PX.get(template.tape_mm) + if height is None: + raise ValueError( + f"Unsupported tape_mm: {template.tape_mm}. " + f"Supported widths: {sorted(TAPE_HEIGHT_PX)}" + ) + + img = Image.new("1", (DEFAULT_LABEL_WIDTH_PX, height), color=1) + draw = ImageDraw.Draw(img) + + for element in template.elements: + if element.type == "qr": + self._draw_qr(img, element, data) + else: # element.type == "text" + self._draw_text(draw, element, data) + + return img + + def _draw_qr(self, img: Image.Image, element: LayoutElement, data: LabelData) -> None: + # LayoutElement.model_validator guarantees these are non-None for type="qr". + # The asserts document the invariant for readers and mypy; they are NOT + # runtime guards (python -O strips them). + assert element.data_field is not None + assert element.size is not None + + payload = self._resolve_field(data, element.data_field) + qr = qrcode.QRCode( + version=None, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=4, + border=1, + ) + qr.add_data(payload) + qr.make(fit=True) + qr_pil: Image.Image = qr.make_image(fill_color="black", back_color="white").convert("1") + qr_pil = qr_pil.resize((element.size, element.size)) + img.paste(qr_pil, (element.x, element.y)) + + def _draw_text( + self, + draw: ImageDraw.ImageDraw, + element: LayoutElement, + data: LabelData, + ) -> None: + # LayoutElement.model_validator guarantees these are non-None for type="text". + # The asserts document the invariant for readers and mypy; they are NOT + # runtime guards (python -O strips them). + assert element.field is not None + assert element.font_size is not None + + text = self._resolve_field(data, element.field) + font = _load_font_cached(element.font_size) + draw.text((element.x, element.y), text, fill=0, font=font) + + @staticmethod + def _resolve_field(data: LabelData, field: str) -> str: + """Read `field` off `data`, coercing tuples/lists to a single ' | '-joined string.""" + value = getattr(data, field, "") + if isinstance(value, (list, tuple)): + # Separator " | " chosen for single-line tape labels. If a future phase + # adds multi-line text fields, this should become a per-element + # `separator` attribute on LayoutElement. + return " | ".join(str(v) for v in value) + return str(value) diff --git a/backend/tests/unit/schemas/test_template_schema.py b/backend/tests/unit/schemas/test_template_schema.py new file mode 100644 index 0000000..44f338f --- /dev/null +++ b/backend/tests/unit/schemas/test_template_schema.py @@ -0,0 +1,95 @@ +import pytest +from app.schemas.template import LayoutElement, TemplateSchema + + +def test_template_with_qr_and_text() -> None: + template = TemplateSchema( + id="snipeit-asset-24mm", + name="Snipe-IT 24mm", + app="snipeit", + tape_mm=24, + elements=[ + LayoutElement(type="qr", x=0, y=0, size=256, data_field="qr_payload"), + LayoutElement(type="text", x=270, y=10, field="title", font_size=24), + ], + ) + assert len(template.elements) == 2 + assert template.elements[0].type == "qr" + + +def test_template_qr_requires_data_field() -> None: + """QR element without data_field must fail validation.""" + with pytest.raises(ValueError, match="data_field"): + LayoutElement(type="qr", x=0, y=0, size=256) + + +def test_template_qr_requires_size() -> None: + """QR element without size must fail validation.""" + with pytest.raises(ValueError, match="size"): + LayoutElement(type="qr", x=0, y=0, data_field="qr_payload") + + +def test_template_text_requires_field() -> None: + with pytest.raises(ValueError, match="field"): + LayoutElement(type="text", x=0, y=0, font_size=24) + + +def test_template_text_requires_font_size() -> None: + with pytest.raises(ValueError, match="font_size"): + LayoutElement(type="text", x=0, y=0, field="title") + + +def test_template_qr_rejects_zero_size() -> None: + with pytest.raises(ValueError, match="positive size"): + LayoutElement(type="qr", x=0, y=0, size=0, data_field="qr_payload") + + +def test_template_text_rejects_zero_font_size() -> None: + with pytest.raises(ValueError, match="positive font_size"): + LayoutElement(type="text", x=0, y=0, field="title", font_size=0) + + +def test_template_app_must_be_one_of_known() -> None: + with pytest.raises(ValueError): + TemplateSchema( + id="t", + name="t", + app="unknown", # not in Literal + tape_mm=24, + elements=[], + ) + + +def test_template_schema_is_frozen() -> None: + """Templates are immutable after construction.""" + from pydantic_core import ValidationError + + template = TemplateSchema(id="t", name="t", app="snipeit", tape_mm=24, elements=[]) + with pytest.raises(ValidationError, match="frozen_instance"): + template.name = "different" # type: ignore[misc] + + +def test_template_elements_is_immutable() -> None: + """elements is a tuple — appending must raise AttributeError, not silently mutate.""" + template = TemplateSchema( + id="t", + name="t", + app="snipeit", + tape_mm=24, + elements=[LayoutElement(type="text", x=0, y=0, field="title", font_size=12)], + ) + with pytest.raises(AttributeError): + template.elements.append( # type: ignore[attr-defined] + LayoutElement(type="text", x=10, y=10, field="primary_id", font_size=12) + ) + assert isinstance(template.elements, tuple) + + +def test_template_qr_rejects_negative_size() -> None: + with pytest.raises(ValueError, match="positive size"): + LayoutElement(type="qr", x=0, y=0, size=-10, data_field="qr_payload") + + +def test_template_text_rejects_negative_font_size() -> None: + with pytest.raises(ValueError, match="positive font_size"): + LayoutElement(type="text", x=0, y=0, field="title", font_size=-12) diff --git a/backend/tests/unit/services/test_label_renderer.py b/backend/tests/unit/services/test_label_renderer.py new file mode 100644 index 0000000..0201421 --- /dev/null +++ b/backend/tests/unit/services/test_label_renderer.py @@ -0,0 +1,144 @@ +import pytest +from app.schemas.label_data import LabelData +from app.schemas.template import LayoutElement, TemplateSchema +from app.services.label_renderer import ( + DEFAULT_LABEL_WIDTH_PX, + TAPE_HEIGHT_PX, + LabelRenderer, +) +from PIL import Image + + +def test_render_produces_image_with_correct_height_24mm() -> None: + template = TemplateSchema( + id="t1", + name="Test", + app="snipeit", + tape_mm=24, + elements=[ + LayoutElement(type="text", x=10, y=10, field="title", font_size=24), + ], + ) + data = LabelData( + title="Hello", + primary_id="ID-1", + qr_payload="x", + source_app="snipeit", + ) + + img = LabelRenderer().render(data, template) + + assert isinstance(img, Image.Image) + assert img.height == TAPE_HEIGHT_PX[24] + assert img.mode == "1" + + +def test_render_produces_image_with_correct_height_12mm() -> None: + template = TemplateSchema( + id="t1", + name="Test", + app="snipeit", + tape_mm=12, + elements=[LayoutElement(type="text", x=5, y=5, field="title", font_size=16)], + ) + data = LabelData(title="x", primary_id="x", qr_payload="x", source_app="snipeit") + img = LabelRenderer().render(data, template) + assert img.height == TAPE_HEIGHT_PX[12] + + +def test_render_rejects_unsupported_tape_mm() -> None: + template = TemplateSchema( + id="t1", + name="Test", + app="snipeit", + tape_mm=99, + elements=[], + ) + data = LabelData(title="x", primary_id="x", qr_payload="x", source_app="snipeit") + with pytest.raises(ValueError, match="99"): + LabelRenderer().render(data, template) + + +def test_render_with_qr_element_includes_black_pixels() -> None: + """A QR element must produce a non-trivial number of black pixels in its bbox.""" + template = TemplateSchema( + id="t1", + name="Test", + app="snipeit", + tape_mm=24, + elements=[ + LayoutElement(type="qr", x=0, y=0, size=200, data_field="qr_payload"), + ], + ) + data = LabelData( + title="X", + primary_id="X", + qr_payload="https://example.com", + source_app="snipeit", + ) + + img = LabelRenderer().render(data, template) + qr_region = img.crop((0, 0, 200, 200)) + black_count = sum(1 for p in qr_region.get_flattened_data() if p == 0) + assert black_count > 100, f"Expected QR to produce many black pixels, got {black_count}" + + +def test_render_resolves_secondary_tuple_field() -> None: + """secondary is a tuple — renderer must join the entries when used as a text field.""" + template = TemplateSchema( + id="t1", + name="Test", + app="snipeit", + tape_mm=24, + elements=[ + LayoutElement(type="text", x=10, y=100, field="secondary", font_size=16), + ], + ) + data = LabelData( + title="X", + primary_id="X", + qr_payload="x", + source_app="snipeit", + secondary=("Color: Black", "Weight: 850g"), + ) + + img = LabelRenderer().render(data, template) + # The text region should not be entirely white (some pixels must be drawn). + region = img.crop((10, 100, DEFAULT_LABEL_WIDTH_PX, 120)) + black_count = sum(1 for p in region.get_flattened_data() if p == 0) + assert black_count > 0 + + +def test_render_empty_template_produces_blank_image() -> None: + """An empty template (no elements) must render a blank white canvas.""" + template = TemplateSchema(id="t", name="T", app="snipeit", tape_mm=24, elements=[]) + data = LabelData(title="X", primary_id="X", qr_payload="x", source_app="snipeit") + img = LabelRenderer().render(data, template) + # All pixels should be 1 (white background). + assert all(p == 1 for p in img.get_flattened_data()) + + +def test_render_with_missing_data_field_renders_empty_string() -> None: + """If a template references a field LabelData doesn't have, render empty (no crash).""" + template = TemplateSchema( + id="t1", + name="Test", + app="snipeit", + tape_mm=24, + elements=[ + LayoutElement(type="text", x=10, y=10, field="nonexistent_field", font_size=16), + ], + ) + data = LabelData(title="X", primary_id="X", qr_payload="x", source_app="snipeit") + # Must NOT raise — missing fields render as empty strings. + img = LabelRenderer().render(data, template) + assert img is not None + + +def test_font_loader_is_cached() -> None: + """Same font_size returns the same font instance (LRU-cached).""" + from app.services.label_renderer import _load_font_cached + + a = _load_font_cached(24) + b = _load_font_cached(24) + assert a is b