Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions backend/app/schemas/template.py
Original file line number Diff line number Diff line change
@@ -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, ...]
127 changes: 127 additions & 0 deletions backend/app/services/label_renderer.py
Original file line number Diff line number Diff line change
@@ -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)
95 changes: 95 additions & 0 deletions backend/tests/unit/schemas/test_template_schema.py
Original file line number Diff line number Diff line change
@@ -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)
Loading