Skip to content

feat(label-renderer): Template schema + Pillow/qrcode renderer for 1-bit label bitmaps#54

Merged
strausmann merged 3 commits into
mainfrom
feat/label-renderer
May 11, 2026
Merged

feat(label-renderer): Template schema + Pillow/qrcode renderer for 1-bit label bitmaps#54
strausmann merged 3 commits into
mainfrom
feat/label-renderer

Conversation

@strausmann
Copy link
Copy Markdown
Owner

Summary

Phase 4 PR D1 of 2. Adds the Template schema (Pydantic, frozen) and the LabelRenderer (Pillow + qrcode) that converts a (LabelData, TemplateSchema) pair into a 1-bit PIL Image ready for the printer.

This is the rendering core. PR D2 seeds 6 predefined templates on top.

What's in this PR

backend/app/schemas/template.py

  • LayoutElement(BaseModel, frozen=True) — discriminated by type: Literal["qr", "text"].
  • @model_validator(mode="after") enforces per-type required fields:
    • qr → needs data_field AND positive size
    • text → needs field AND positive font_size
  • Rejects size=0 and font_size=0 with clear "positive ..." messages (not just None).
  • TemplateSchema(BaseModel, frozen=True) — id, name, app (Literal), tape_mm, elements.

backend/app/services/label_renderer.py

  • LabelRenderer.render(data, template) -> Image.Image — stateless, 1-bit ("1") mode.
  • TAPE_HEIGHT_PX: Final[dict[int, int]] — 300 DPI heights for tapes 12mm/18mm/24mm/62mm. Extend as new tapes are supported.
  • DEFAULT_LABEL_WIDTH_PX: Final = 600 — ~50.8mm at 300 DPI, suitable for asset/product labels. Real width is set per-job downstream.
  • QR via qrcode lib with ERROR_CORRECT_M, box_size=4, border=1, resized to element.size.
  • Text via ImageDraw.text using DejaVuSans.ttf with graceful fallback to PIL default.
  • _resolve_field joins list/tuple values with | (separator policy documented inline for Phase 6 multi-line).
  • Missing fields render as empty string — no crash on template/data mismatch.
  • Unsupported tape_mmValueError with the offending value and the list of supported widths.

Tests — 16 tests, 127 total

  • 9 schema tests (test_template_schema.py): qr+text construction, qr requires data_field, qr requires positive size, qr rejects zero size, text requires field, text requires positive font_size, text rejects zero font_size, app must be known, schema is frozen.
  • 7 renderer tests (test_label_renderer.py): correct height for 24mm and 12mm, rejects unsupported tape, QR produces black pixels, secondary tuple joined into text, missing field renders empty, empty template produces blank white image.

Pillow 14 forward-compat

Test pixel-iteration uses get_flattened_data() (the documented replacement for the deprecated getdata()). Zero DeprecationWarnings in pytest output.

What's NOT in this PR

  • Seed templates — PR D2.
  • FastAPI endpoint, label persistence — Phases 5/6.
  • Hardware-specific raster encoding — Phase 2 plugins.

Test plan

  • pytest -q -W error::DeprecationWarning → 127/127, zero warnings.
  • ruff format --check . clean.
  • ruff check . clean.
  • mypy app/ (strict) clean — 24 source files.

Review history (subagent-driven)

  1. Implementer ce8cc00 — initial commit with both schema and renderer.
  2. Spec compliance: ✅ — all field/method/test requirements present.
  3. Code quality: APPROVED_WITH_NITS — flagged Pillow 14 deprecation, loose validator for zero values, missing empty-template test, undocumented separator + width constants.
  4. Fix commit c54b48aget_flattened_data() migration, positive-int validator with regression tests, empty-template test, three clarifying inline comments.

Linked plan

docs/superpowers/plans/2026-05-11-label-printer-hub.md Tasks 4.1 + 4.2.

PR D2 (Seed-Templates — 6 vordefinierte) builds on this branch.

strausmann and others added 2 commits May 11, 2026 10:51
…bit label bitmaps

Implements Tasks 4.1 and 4.2 of Phase 4 (PR D1).

- TemplateSchema + LayoutElement: frozen Pydantic models with per-type
  model_validator enforcing required fields (qr→data_field+size,
  text→field+font_size). App constrained to Literal["snipeit","grocy","spoolman"].
- LabelRenderer.render(): produces a 1-bit PIL Image sized by TAPE_HEIGHT_PX
  (300 DPI, Brother Raster Command Reference v1.02). Raises ValueError on
  unknown tape_mm. Stateless — safe for concurrent use.
- _resolve_field: joins tuple/list secondary fields with ' | ' separator,
  returns "" for unknown fields (no crash on template/data mismatch).
- _load_font: tries DejaVuSans.ttf, falls back to PIL bitmap default.
- 13 new tests (7 schema + 6 renderer); total suite: 124 passed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-compat + empty-template test + clarifying comments

- Fix A: Replace both getdata() calls with get_flattened_data() (Pillow 14 forward-compat)
- Fix B: Validator now explicitly rejects size=0 and font_size=0 with clear error messages;
  add regression tests test_template_qr_rejects_zero_size and test_template_text_rejects_zero_font_size
- Fix C: Add test_render_empty_template_produces_blank_image to cover the zero-element path
- Fix D: Document DEFAULT_LABEL_WIDTH_PX with DPI/mm context
- Fix E: Comment on separator choice in _resolve_field, with future-phase guidance
- Fix F: Clarify assert intent in _draw_qr/_draw_text (invariant docs, not runtime guards)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 11, 2026 10:57
@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request establishes the core rendering infrastructure for label generation. It provides the necessary schema definitions to structure label templates and a service layer to convert those templates into 1-bit images suitable for printer output. The implementation focuses on type safety, immutability, and robust handling of rendering parameters.

Highlights

  • Template Schema Implementation: Introduced a frozen Pydantic schema (TemplateSchema and LayoutElement) to define label layouts, including strict validation for QR and text elements.
  • Label Rendering Engine: Added a stateless LabelRenderer service that uses Pillow and qrcode to generate 1-bit label bitmaps, supporting various tape widths and font rendering.
  • Pillow 14 Compatibility: Updated pixel data access to use get_flattened_data() to ensure forward compatibility with Pillow 14.
  • Robust Testing: Added 16 unit tests covering schema validation, rendering logic, edge cases, and field resolution.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a label-template schema and a rendering service for generating 1-bit PIL images for Brother tape printers. It includes Pydantic models for layout elements with validation and a renderer that handles QR codes and text fields. A bug was found in the unit tests where a hallucinated Pillow method get_flattened_data() is used instead of getdata(), which will cause test failures.


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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

get_flattened_data() is not a valid Pillow method. It appears to be a hallucination (Pillow is currently at version 10.x, and getdata() is not deprecated; version 14 does not exist). Use img.getdata() instead. This issue also occurs on lines 108 and 118.

Suggested change
black_count = sum(1 for p in qr_region.get_flattened_data() if p == 0)
black_count = sum(1 for p in qr_region.getdata() if p == 0)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disagreeing — get_flattened_data() is a real method, not a hallucination. The project pins pillow>=11.0 (see backend/pyproject.toml) and the installed version in CI + local is Pillow 12.2.0, not 10.x. Verified just now:

$ python -c "import PIL; from PIL import Image; print(PIL.__version__); img = Image.new('1', (10,10), 1); print(hasattr(img, 'get_flattened_data'))"
12.2.0
True

The deprecation notice for getdata() in Pillow's release notes points at get_flattened_data() as the replacement. Tests run clean with pytest -W error::DeprecationWarning (zero warnings, 131 passed).

Leaving as-is.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds the backend rendering core for printable labels by introducing an immutable-ish template schema and a Pillow/qrcode-based renderer that produces 1-bit label bitmaps sized to supported tape widths.

Changes:

  • Introduces TemplateSchema / LayoutElement Pydantic models with per-element-type validation.
  • Adds LabelRenderer service that renders QR + text elements into a 1-bit PIL.Image.
  • Adds unit tests covering schema validation and rendering behavior (QR pixels, tape height, unsupported tape, missing fields, blank templates).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
backend/app/schemas/template.py Adds template/layout Pydantic schema and per-type validation rules.
backend/app/services/label_renderer.py Implements the Pillow/qrcode label rendering pipeline and tape geometry constants.
backend/tests/unit/schemas/test_template_schema.py Adds schema validation and immutability tests.
backend/tests/unit/services/test_label_renderer.py Adds renderer tests for sizing, QR/text drawing, and mismatch handling.

Comment thread backend/app/schemas/template.py Outdated
name: str
app: Literal["snipeit", "grocy", "spoolman"]
tape_mm: int
elements: list[LayoutElement]
Comment thread backend/app/schemas/template.py Outdated
Comment on lines +44 to +49
raise ValueError("qr element requires a positive size (got None or 0)")
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("text element requires a positive font_size (got None or 0)")
Comment thread backend/app/services/label_renderer.py Outdated
Comment on lines +100 to +121
text = self._resolve_field(data, element.field)
font = self._load_font(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)

@staticmethod
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
"""Load DejaVuSans at `size`px, fall back to PIL's bitmap default if unavailable."""
try:
return ImageFont.truetype("DejaVuSans.ttf", size)
except OSError:
return ImageFont.load_default()
…ssages + LRU-cached font loader

- Fix A: TemplateSchema.elements changed from list to tuple[LayoutElement, ...] so
  frozen=True actually prevents mutation (same pattern as LabelData.secondary fix in PR #51).
  Pydantic v2 coerces list inputs automatically; callers are unaffected.
- Fix B: LayoutElement validator messages now use !r to show the actual rejected value
  (None, 0, -5 etc.) instead of the generic "got None or 0" string.
- Fix C: _load_font promoted from a per-call static method to a module-level
  @functools.lru_cache(maxsize=32) function, eliminating repeated disk I/O for
  multi-element templates.
- Add 4 regression tests: immutability, negative size, negative font_size, cache identity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@strausmann strausmann merged commit fb77028 into main May 11, 2026
9 checks passed
github-actions Bot pushed a commit that referenced this pull request May 12, 2026
## 0.3.0 (2026-05-12)

* feat(config): pydantic-settings module with env-driven runtime configuration (#45) ([878e9e0](878e9e0)), closes [#45](#45)
* feat(integrations): AppLookupService aggregator — Phase 3 complete (#53) ([222bef4](222bef4)), closes [#53](#53)
* feat(integrations): Grocy + Spoolman lookup clients with shared NotFoundError base (#52) ([b1c9c3c](b1c9c3c)), closes [#52](#52)
* feat(integrations): LabelData schema + Snipe-IT lookup client (#51) ([3bc180f](3bc180f)), closes [#51](#51)
* feat(label-renderer): Template schema + Pillow/qrcode renderer for 1-bit label bitmaps (#54) ([fb77028](fb77028)), closes [#54](#54)
* feat(printer-models): Brother PT-Series TapeRegistry with TZe and heat-shrink specs (#47) ([7526019](7526019)), closes [#47](#47)
* feat(printer-models): Job lifecycle FSM with explicit state machine (#49) ([1a8c40e](1a8c40e)), closes [#49](#49)
* feat(printer-models): PrinterModel Protocol + ModelRegistry for plugin discovery (#48) ([2ae0e09](2ae0e09)), closes [#48](#48)
* feat(printer-models): PrintQueue worker with pause/resume/cancel/retry (#50) ([dfdf6fe](dfdf6fe)), closes [#50](#50)

[skip ci]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants