Files
bambuddy/backend/app/services/label_renderer.py
T
maziggy 864e5c990e feat(inventory): printable PDF spool labels in 4 sizes (#809)
Closes the longest-standing inventory gap — finding a specific spool
  in a closet of 50 partials. Per-spool icon button on every inventory
  card and table row, plus a "Print labels..." header action that opens
  a multi-select picker pre-loaded with the currently filtered spools.

  Four pre-built templates: AMS holder (30 x 15 mm) for the popular
  Makerworld AMS Filament Label Holder, single box label (62 x 29 mm)
  for Brother PT/QL or Dymo small labels, Avery L7160 (A4, 21 per
  sheet), and Avery 5160 (US Letter, 30 per sheet). Each label carries
  the colour swatch (with multi-colour gradient stripes for spools
  with extra_colors set), brand, material, name, the *spool ID*
  (bsaunder's articulated user-need: telling 8 spools of "PLA White"
  apart, especially partials), and a QR code that deep-links to
  /inventory?spool=<id> for phone-scan round-trips. Box-label adds
  storage location; AMS-holder drops the QR — at 30 x 15 mm there is
  no room for swatch + text + QR without truncating away the spool ID,
  and AMS-bay identification is at arm's length where the swatch and
  ID are enough.

  Server-side rendering via ReportLab + qrcode (already a dep). Pure
  Python, no headless browser, no system libs. Output is byte-identical
  across browsers, Avery sheets align to <0.1 mm, and bulk export is
  one click for one PDF. Two endpoints — POST /inventory/labels (local
  DB) and POST /spoolman/labels (Spoolman-backed) — gated on
  INVENTORY_READ, capped at 500 spools per request, returning
  application/pdf via StreamingResponse. The renderer is decoupled
  from the SQLAlchemy model via a LabelData dataclass so the same code
  path serves both modes.

  Modal picker scales to large libraries: search (substring match
  across name / brand / #ID), material filter chips derived from the
  visible spools, additive Select-all-visible / Deselect-visible /
  Clear-all actions so selections survive filter changes. Restyled
  twice in development — first cut used generic Tailwind which clashed
  with the inventory's bambu-dark palette; second cut switched to
  bambu-dark-secondary / bambu-green / bambu-gray to match.

  Two render bugs found during visual inspection of generated PDFs and
  fixed before commit:

    1. AMS-30x15 template originally produced labels with only swatch
       + QR and no text at all — the side-by-side layout left <5 mm
       for the text column, so the renderer bailed without drawing
       anything. Layout split into tight (h<20mm) and roomy (h>=20mm)
       regimes; tight regime drops the QR and gives the right column
       to brand + material + a 13pt-bold spool ID.

    2. Box-62x29 template aggressively truncated text — swatch + QR
       each at ~14 mm on a 26mm-tall label squeezed the text column
       to ~16 mm, turning "Polymaker Ivory" into "Polymak..." and
       "Polymaker . PLA . Matte" into "Polymaker ...". Swatch capped
       at 16 mm, QR capped at 18 mm and constrained to ~20% of width,
       leaving the text column ~30 mm — full names render without
       truncation.

  Both bugs pinned by regression tests in test_label_renderer.py that
  render with pageCompression=0 so the resulting PDF bytes contain the
  text as ASCII and `assert b"Polymaker" in pdf` works.
2026-05-05 12:29:57 +02:00

398 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PDF spool label rendering.
Four fixed templates:
- ``ams_30x15`` — 30×15 mm single label, fits the popular Makerworld AMS
Filament Label Holder (model 752566). One label per page.
- ``box_62x29`` — 62×29 mm single label, sized for Brother PT/QL and Dymo
generic small labels. One label per page.
- ``avery_5160`` — US Letter sheet, 25.4×66.7 mm × 30 per sheet.
- ``avery_l7160`` — A4 sheet, 38.1×63.5 mm × 21 per sheet.
The renderer is decoupled from the Spool model: callers build a ``LabelData``
list from whatever source (local DB, Spoolman, future) so the same code path
works in both modes.
Layout principle, taken from the issue's user need (`#809`): the **spool ID**
is the most-recognisable field at arm's length and dominates the layout. Other
fields (brand, material, name, storage location) fill remaining space; the QR
code provides the round-trip back to ``/inventory?spool=<id>``.
"""
from __future__ import annotations
import io
from dataclasses import dataclass
from typing import Literal
import qrcode
from reportlab.lib.colors import Color, HexColor, black, white
from reportlab.lib.pagesizes import A4, letter
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas as rl_canvas
TemplateName = Literal["ams_30x15", "box_62x29", "avery_5160", "avery_l7160"]
@dataclass
class LabelData:
"""Per-spool data needed to render a label.
Decoupled from the SQLAlchemy model so the same renderer serves the local
inventory and the Spoolman-backed inventory.
"""
spool_id: int
name: str
material: str
brand: str | None = None
subtype: str | None = None
rgba: str | None = None # "RRGGBB" or "RRGGBBAA"; None → neutral grey
extra_colors: list[str] | None = None # additional hex colours (no '#')
storage_location: str | None = None
deeplink_url: str = "" # what the QR encodes; caller composes it
# ── Colour helpers ───────────────────────────────────────────────────────────
def _color_from_hex(hex_str: str | None, fallback: Color = HexColor(0x808080)) -> Color:
"""Parse an RRGGBB or RRGGBBAA string (no '#') into a ReportLab Color.
Alpha is honoured so multi-colour spools with translucent overlays render
correctly. Falls back to ``fallback`` for None / malformed input rather
than raising — labels should always print.
"""
if not hex_str:
return fallback
h = hex_str.lstrip("#").strip()
if len(h) not in (6, 8):
return fallback
try:
r = int(h[0:2], 16) / 255.0
g = int(h[2:4], 16) / 255.0
b = int(h[4:6], 16) / 255.0
a = int(h[6:8], 16) / 255.0 if len(h) == 8 else 1.0
return Color(r, g, b, alpha=a)
except ValueError:
return fallback
def _luminance(color: Color) -> float:
"""Perceived luminance of a ReportLab Color (0–1, WCAG-style approximation)."""
return 0.299 * color.red + 0.587 * color.green + 0.114 * color.blue
# ── QR generation ────────────────────────────────────────────────────────────
def _qr_png_bytes(payload: str, *, box_size: int = 4, border: int = 2) -> bytes:
"""Render ``payload`` as a tight QR PNG. Empty payload returns empty bytes
so callers can skip drawing without checking ahead of time.
"""
if not payload:
return b""
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=box_size,
border=border,
)
qr.add_data(payload)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
# ── Single-label drawing ─────────────────────────────────────────────────────
def _draw_swatch(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None:
"""Draw the colour swatch. Multi-colour spools use vertical stripes
(matching the FilamentSwatch convention in the frontend)."""
primary = _color_from_hex(data.rgba)
extras = [_color_from_hex(h) for h in (data.extra_colors or []) if h]
colors = [primary, *extras]
if not colors:
c.setFillColor(HexColor(0x808080))
c.rect(x, y, w, h, stroke=0, fill=1)
return
stripe_w = w / len(colors)
for i, col in enumerate(colors):
c.setFillColor(col)
c.rect(x + i * stripe_w, y, stripe_w, h, stroke=0, fill=1)
# Thin black border so light-colour swatches stay visible on white labels.
c.setStrokeColor(black)
c.setLineWidth(0.3)
c.rect(x, y, w, h, stroke=1, fill=0)
def _draw_qr(c: rl_canvas.Canvas, x: float, y: float, size: float, payload: str) -> None:
"""Embed a square QR at (x, y) with edge length ``size`` (in points)."""
png = _qr_png_bytes(payload)
if not png:
return
from reportlab.lib.utils import ImageReader
img = ImageReader(io.BytesIO(png))
c.drawImage(img, x, y, width=size, height=size, mask="auto")
def _truncate_to_width(c: rl_canvas.Canvas, text: str, font: str, size: float, max_w: float) -> str:
"""Truncate ``text`` with an ellipsis so it fits within ``max_w`` points."""
if c.stringWidth(text, font, size) <= max_w:
return text
ell = "…"
while text and c.stringWidth(text + ell, font, size) > max_w:
text = text[:-1]
return text + ell if text else ell
def _draw_label(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None:
"""Render one label inside the box (x, y, w, h). Origin is bottom-left.
Two layouts, picked by available height:
- **Tight** (h < 20 mm — AMS holder): swatch on the left, three lines of
text on the right (brand, material+subtype, big spool ID). No QR — at
30×15 mm there is not enough horizontal room for swatch + text + QR
without truncating away the user-need fields, and the AMS holder is an
at-a-glance identifier where the spool ID is the killer field. The
box-label and Avery templates carry the QR for the other use cases.
- **Roomy** (h >= 20 mm — box label, Avery sheets): swatch on the left,
QR on the right, multi-line text in the middle column. Large spool ID
anchored at bottom-left under the swatch so it stays readable when the
label is on a box on a shelf at arm's length.
"""
pad = 1.2 * mm
inner_x, inner_y = x + pad, y + pad
inner_w = w - 2 * pad
inner_h = h - 2 * pad
# Outer hairline border so labels are easy to cut out from blank stock.
c.setStrokeColor(HexColor(0xCCCCCC))
c.setLineWidth(0.4)
c.rect(x, y, w, h, stroke=1, fill=0)
is_tight = h < 20 * mm
if is_tight:
_draw_label_tight(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data)
else:
_draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data)
def _draw_label_tight(
c: rl_canvas.Canvas,
x: float,
y: float,
w: float,
h: float,
inner_x: float,
inner_y: float,
inner_w: float,
inner_h: float,
pad: float,
data: LabelData,
) -> None:
"""AMS-holder layout (e.g. 30×15 mm). Swatch + 3-line text, no QR."""
swatch_w = min(inner_h, inner_w * 0.35)
swatch_y = inner_y + (inner_h - swatch_w) / 2
_draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
text_x = inner_x + swatch_w + pad
text_w = inner_w - swatch_w - pad
if text_w < 5 * mm:
return # Pathological — even the swatch barely fits.
c.setFillColor(black)
# Top: brand (small)
brand_size = 5.5
if data.brand:
c.setFont("Helvetica", brand_size)
brand = _truncate_to_width(c, data.brand, "Helvetica", brand_size, text_w)
c.drawString(text_x, y + h - pad - brand_size, brand)
# Second line: material + subtype, small
sub_size = 5.5
sub_line = " ".join(filter(None, [data.material, data.subtype]))
if sub_line:
c.setFont("Helvetica", sub_size)
sub_line = _truncate_to_width(c, sub_line, "Helvetica", sub_size, text_w)
sub_y = y + h - pad - brand_size - 0.6 - sub_size
c.drawString(text_x, sub_y, sub_line)
# Bottom: BIG spool ID — the killer field at-a-glance.
id_size = 13
c.setFont("Helvetica-Bold", id_size)
id_text = _truncate_to_width(c, f"#{data.spool_id}", "Helvetica-Bold", id_size, text_w)
c.drawString(text_x, inner_y + 0.5, id_text)
def _draw_label_roomy(
c: rl_canvas.Canvas,
x: float,
y: float,
w: float,
h: float,
inner_x: float,
inner_y: float,
inner_w: float,
inner_h: float,
pad: float,
data: LabelData,
) -> None:
"""Box-label / Avery layout. Swatch left, QR right, text middle."""
# Swatch: full inner height, ~18% of inner width but capped so we never
# eat the text column on extreme aspect ratios.
swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
swatch_h = inner_h
_draw_swatch(c, inner_x, inner_y, swatch_w, swatch_h, data)
# QR: square, capped at the smaller of (a fraction of width, the inner
# height, or 18 mm — beyond that the QR is overkill for the print size).
qr_size = min(inner_w * 0.20, inner_h, 18 * mm)
qr_x = x + w - pad - qr_size
qr_y = inner_y + (inner_h - qr_size) / 2
_draw_qr(c, qr_x, qr_y, qr_size, data.deeplink_url)
text_x = inner_x + swatch_w + 1.5 * mm
text_w = qr_x - text_x - 1.5 * mm
if text_w < 8 * mm:
return
c.setFillColor(black)
# Build the text rows we want to render, in top→bottom order.
line1 = data.brand or ""
line2 = " · ".join(filter(None, [data.material, data.subtype]))
name = data.name or ""
# Layout from the top of the text column.
cursor_y = y + h - pad
if line1:
size = 7
c.setFont("Helvetica", size)
text = _truncate_to_width(c, line1, "Helvetica", size, text_w)
cursor_y -= size
c.drawString(text_x, cursor_y, text)
cursor_y -= 1.2
if line2:
size = 7
c.setFont("Helvetica", size)
text = _truncate_to_width(c, line2, "Helvetica", size, text_w)
cursor_y -= size
c.drawString(text_x, cursor_y, text)
cursor_y -= 1.5
if name and name != line1:
size = 9
c.setFont("Helvetica-Bold", size)
text = _truncate_to_width(c, name, "Helvetica-Bold", size, text_w)
cursor_y -= size
c.drawString(text_x, cursor_y, text)
cursor_y -= 1.2
if data.storage_location:
size = 6.5
c.setFont("Helvetica-Oblique", size)
text = _truncate_to_width(c, data.storage_location, "Helvetica-Oblique", size, text_w)
cursor_y -= size
c.drawString(text_x, cursor_y, text)
# Spool ID — anchored at the bottom of the text column, big and bold.
id_size = 16
c.setFont("Helvetica-Bold", id_size)
id_text = _truncate_to_width(c, f"#{data.spool_id}", "Helvetica-Bold", id_size, text_w)
c.drawString(text_x, inner_y + 0.5, id_text)
# ── Template entry points ────────────────────────────────────────────────────
# (label_w_mm, label_h_mm) for single-label-per-page templates.
_SINGLE_LABEL_SIZES_MM: dict[str, tuple[float, float]] = {
"ams_30x15": (30.0, 15.0),
"box_62x29": (62.0, 29.0),
}
# Sheet template parameters: (page_size, label_w_mm, label_h_mm,
# cols, rows, top_margin_mm, left_margin_mm,
# col_gap_mm, row_gap_mm)
_SHEET_TEMPLATES: dict[str, tuple] = {
"avery_5160": (letter, 66.675, 25.4, 3, 10, 12.7, 4.76, 3.175, 0.0),
"avery_l7160": (A4, 63.5, 38.1, 3, 7, 15.15, 7.0, 2.5, 0.0),
}
def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData]) -> bytes:
w_mm, h_mm = _SINGLE_LABEL_SIZES_MM[template]
page_w, page_h = w_mm * mm, h_mm * mm
buf = io.BytesIO()
c = rl_canvas.Canvas(buf, pagesize=(page_w, page_h))
c.setTitle(f"Bambuddy spool labels ({template})")
for data in data_list:
_draw_label(c, 0, 0, page_w, page_h, data)
c.showPage()
c.save()
return buf.getvalue()
def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData]) -> bytes:
page_size, w_mm, h_mm, cols, rows, top_mm, left_mm, col_gap_mm, row_gap_mm = _SHEET_TEMPLATES[template]
page_w, page_h = page_size
label_w = w_mm * mm
label_h = h_mm * mm
top_margin = top_mm * mm
left_margin = left_mm * mm
col_gap = col_gap_mm * mm
row_gap = row_gap_mm * mm
buf = io.BytesIO()
c = rl_canvas.Canvas(buf, pagesize=page_size)
c.setTitle(f"Bambuddy spool labels ({template})")
per_page = cols * rows
for page_start in range(0, len(data_list), per_page):
chunk = data_list[page_start : page_start + per_page]
for idx, data in enumerate(chunk):
row = idx // cols
col = idx % cols
x = left_margin + col * (label_w + col_gap)
y = page_h - top_margin - (row + 1) * label_h - row * row_gap
_draw_label(c, x, y, label_w, label_h, data)
c.showPage()
c.save()
return buf.getvalue()
def render_labels(template: TemplateName, data_list: list[LabelData]) -> bytes:
"""Render ``data_list`` to a PDF using the named template. Returns bytes.
Empty ``data_list`` still produces a valid (empty) PDF — callers should
short-circuit beforehand if that's not desired.
"""
if template in _SINGLE_LABEL_SIZES_MM:
return _render_single_label_pdf(template, data_list)
if template in _SHEET_TEMPLATES:
return _render_sheet_pdf(template, data_list)
raise ValueError(f"Unknown label template: {template!r}")
__all__ = ["LabelData", "TemplateName", "render_labels"]
# white re-exported for completeness; future templates may need a paper-tone variant.
_ = white