Files

692 lines
25 KiB
Python
Raw Permalink 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.
Six fixed templates:
- ``ams_holder_74x33`` — 74×33 mm single label, matches the printable label
STL bundled with the Makerworld AMS Filament Label Holder (model 752566).
Smaller variant — the visible window in the holder. One label per page.
- ``ams_holder_75x55`` — 75×55 mm single label, fits the cardstock-insert
variant of the same holder. Roomier — swatch + QR + full text column.
- ``box_40x30`` — 40×30 mm single label, common DK/Brother roll size and a
good fit for filament-bag/storage-bin labels (#809 follow-up). Roomy
layout — swatch, QR, full text column with hex code.
- ``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 legacy ``ams_30x15`` preset (#809) was incorrect — the original 30×15 mm
dimension didn't fit any documented variant of model 752566. Replaced by the
two ``ams_holder_*`` presets above (#1426).
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>``.
Which of those appear is the caller's choice (#2981): ``fields`` names the
lines to print, and ``DEFAULT_LABEL_FIELDS`` is the set every label carried
before the choice existed. The label text is data only (numbers, units,
dates), never words, because the PDF is not translated.
``render_labels`` returns a PDF; ``pdf_to_pngs`` rasterises it page by page
for label-printer software that takes images.
"""
from __future__ import annotations
import io
from dataclasses import dataclass
from datetime import date
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_holder_74x33",
"ams_holder_75x55",
"box_40x30",
"box_62x29",
"avery_5160",
"avery_l7160",
]
LabelField = Literal[
"brand",
"material",
"hex",
"name",
"location",
"material_number",
"temps",
"weight",
"note",
"added",
"qr",
"spool_id",
]
# Top-to-bottom order of the text lines, and the order the picker lists them.
ALL_LABEL_FIELDS: tuple[LabelField, ...] = (
"brand",
"material",
"hex",
"name",
"location",
"material_number",
"temps",
"weight",
"note",
"added",
"qr",
"spool_id",
)
# What a label carried before the fields were selectable, so a request that
# names none prints exactly what it always did.
DEFAULT_LABEL_FIELDS: frozenset[LabelField] = frozenset(
{"brand", "material", "hex", "name", "location", "qr", "spool_id"}
)
@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
material_number: str | None = None
nozzle_temp_min: int | None = None
nozzle_temp_max: int | None = None
label_weight: int | None = None # advertised net weight, grams
note: str | None = None
added: date | None = None
def _temps_text(data: LabelData) -> str:
"""``220–240 °C``, or the one bound that is set."""
lo, hi = data.nozzle_temp_min, data.nozzle_temp_max
if lo and hi and lo != hi:
return f"{lo}–{hi} °C"
if lo or hi:
return f"{lo or hi} °C"
return ""
def _weight_text(data: LabelData) -> str:
return f"{data.label_weight} g" if data.label_weight else ""
# ── 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
def _hex_code_label(rgba: str | None) -> str:
"""Format ``data.rgba`` as a printable ``#RRGGBB`` string for the label.
Drops the alpha channel (printed labels can't show transparency) and
upper-cases the hex digits to match the colour-picker convention used in
the inventory UI. Returns an empty string for None / malformed input so
the caller can ``if hex_code:`` skip drawing without an exception.
"""
if not rgba:
return ""
h = rgba.lstrip("#").strip()
if len(h) not in (6, 8):
return ""
rgb = h[:6]
if not all(c in "0123456789abcdefABCDEF" for c in rgb):
return ""
return f"#{rgb.upper()}"
# ── 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_CORRECT_L (7% recovery) rather than M (15%): a label QR only
# needs to survive being scanned off clean stock, not physical damage,
# and L encodes the same payload in a lower version (fewer, chunkier
# modules). That extra module size is what makes the code printable on
# low-resolution 203 dpi thermal printers, where M-level density bled
# the modules together on small labels (#1870).
error_correction=qrcode.constants.ERROR_CORRECT_L,
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)."""
c.saveState()
try:
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)
finally:
c.restoreState()
def _roomy_qr_size(inner_w: float, inner_h: float) -> float:
"""QR edge length (points) for the roomy layout.
Historically a flat 20% of inner width, which on the narrowest label
(box_40x30, ~37.6 mm inner) rendered a ~7.5 mm QR — at 203 dpi each module
fell below ~2 dots and the code bled into itself on thermal printers
(#1870). A 12 mm floor keeps small labels scannable; the code is still
capped by the inner height, an 18 mm absolute max, and ~45% of inner width
so it can't crowd out the text column on an ultra-narrow label.
"""
return min(max(inner_w * 0.20, 12 * mm), inner_h, 18 * mm, inner_w * 0.45)
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,
monochrome: bool = False,
fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
) -> 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): swatch on the left, three lines of text on the
right (brand, material+subtype, big spool ID). No QR — at very small
heights there is not enough horizontal room for swatch + text + QR
without truncating away the user-need fields. Kept as the safety
branch for any future ultra-small preset; the shipped templates all
land in the roomy layout below.
- **Roomy** (h >= 20 mm — AMS holder, 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 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, monochrome, fields)
else:
_draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome, fields)
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,
monochrome: bool = False,
fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
) -> None:
"""Tight layout (h < 20 mm). Swatch + brand/material/hex/ID, no QR.
Only those four lines fit, so they are the only ``fields`` it honours.
"""
# Monochrome: drop the colour swatch (see _draw_label_roomy) and give the
# width to the text column (#1870).
if monochrome:
swatch_w = 0.0
else:
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 — bumped to bold + larger per the #809 follow-up so it's the
# easiest thing to read on a small AMS holder at arm's length.
brand_size = 6.5
if data.brand and "brand" in fields:
c.setFont("Helvetica-Bold", brand_size)
brand = _truncate_to_width(c, data.brand, "Helvetica-Bold", brand_size, text_w)
c.drawString(text_x, y + h - pad - brand_size, brand)
# Second line: material + subtype, small
sub_size = 5
sub_line = " ".join(filter(None, [data.material, data.subtype])) if "material" in fields else ""
sub_y_baseline = y + h - pad - brand_size - 0.6 - sub_size
if sub_line:
c.setFont("Helvetica", sub_size)
sub_line = _truncate_to_width(c, sub_line, "Helvetica", sub_size, text_w)
c.drawString(text_x, sub_y_baseline, sub_line)
# Third line (when there's room): hex code, tiny — useful when the user
# has multiple near-identical colours in the same material family.
hex_code = _hex_code_label(data.rgba) if "hex" in fields else ""
if hex_code:
hex_size = 4.5
hex_y = sub_y_baseline - 0.4 - hex_size
# Don't render if it'd collide with the spool ID at the bottom.
if hex_y > inner_y + 13:
c.setFont("Helvetica", hex_size)
c.drawString(text_x, hex_y, hex_code)
# Bottom: BIG spool ID — the killer field at-a-glance.
if "spool_id" not in fields:
return
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,
monochrome: bool = False,
fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
) -> 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. Omitted entirely in
# monochrome mode — on a B&W thermal printer a colour block prints as a
# muddy grey that conveys nothing, so we reclaim the space for text and
# rely on the hex-code line to carry the colour (#1870, requested by
# @Geoff-S). The hex code already renders below whenever rgba is set.
if monochrome:
swatch_w = 0.0
else:
swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
_draw_swatch(c, inner_x, inner_y, swatch_w, inner_h, data)
# Without the QR the text column runs to the right-hand padding.
if "qr" in fields:
qr_size = _roomy_qr_size(inner_w, inner_h)
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_right = qr_x - 1.5 * mm
else:
text_right = inner_x + inner_w
text_x = inner_x + swatch_w + 1.5 * mm
text_w = text_right - text_x
if text_w < 8 * mm:
return
c.setFillColor(black)
# Spool ID — anchored at the bottom of the text column, big and bold. The
# lines above stop short of it rather than print over it.
id_size = 16
show_id = "spool_id" in fields
floor_y = inner_y + 0.5 + id_size if show_id else inner_y
name = data.name or ""
# The name line is dropped when it only repeats the brand, or the subtype
# already printed beside the material (a Spoolman filament named after
# its colour yields both).
repeats = {data.brand or ""}
if "material" in fields and data.subtype:
repeats.add(data.subtype)
# (field, text, font, size, gap below). Sizes and gaps of the first five
# are what the label has always used; the rest share the location's.
rows: list[tuple[LabelField, str, str, float, float]] = [
# Brand — bumped to bold + larger per the #809 follow-up.
("brand", data.brand or "", "Helvetica-Bold", 8, 1.2),
("material", " · ".join(filter(None, [data.material, data.subtype])), "Helvetica", 7, 1.5),
# Hex colour code — useful for telling near-identical material+colour
# spools apart when the swatch is small or the user is colour-blind.
("hex", _hex_code_label(data.rgba), "Helvetica", 6.5, 1.2),
("name", name if name not in repeats else "", "Helvetica-Bold", 9, 1.2),
("location", data.storage_location or "", "Helvetica-Oblique", 6.5, 1.2),
("material_number", data.material_number or "", "Helvetica", 6.5, 1.2),
("temps", _temps_text(data), "Helvetica", 6.5, 1.2),
("weight", _weight_text(data), "Helvetica", 6.5, 1.2),
("note", data.note or "", "Helvetica-Oblique", 6.5, 1.2),
("added", data.added.isoformat() if data.added else "", "Helvetica", 6.5, 1.2),
]
cursor_y = y + h - pad
for field, text, font, size, gap in rows:
if field not in fields or not text:
continue
if cursor_y - size < floor_y:
break
c.setFont(font, size)
cursor_y -= size
c.drawString(text_x, cursor_y, _truncate_to_width(c, text, font, size, text_w))
cursor_y -= gap
if show_id:
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_holder_74x33": (74.0, 33.0),
"ams_holder_75x55": (75.0, 55.0),
"box_40x30": (40.0, 30.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],
monochrome: bool = False,
fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
) -> bytes:
w_mm, h_mm = label_size_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, monochrome, fields)
c.showPage()
c.save()
return buf.getvalue()
def label_size_mm(template: TemplateName) -> tuple[float, float]:
"""(width, height) of one label of ``template`` — a sheet's cell size."""
if template in _SINGLE_LABEL_SIZES_MM:
return _SINGLE_LABEL_SIZES_MM[template]
if template in _SHEET_TEMPLATES:
layout = _SHEET_TEMPLATES[template]
return layout[1], layout[2]
raise ValueError(f"Unknown label template: {template!r}")
def get_sheet_capacity(template: TemplateName) -> int | None:
"""Return the number of slots on a sheet template, or ``None`` for roll labels."""
layout = _SHEET_TEMPLATES.get(template)
if layout is None:
return None
return layout[3] * layout[4]
def _render_sheet_pdf(
template: TemplateName,
data_list: list[LabelData],
monochrome: bool,
starting_position: int,
fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
) -> 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
if starting_position < 1 or starting_position > per_page:
raise ValueError(f"Starting position must be between 1 and {per_page} for {template}")
data_index = 0
page_number = 0
while data_index < len(data_list):
slot_offset = starting_position - 1 if page_number == 0 else 0
page_capacity = per_page - slot_offset
chunk = data_list[data_index : data_index + page_capacity]
for idx, data in enumerate(chunk):
slot_index = slot_offset + idx
row = slot_index // cols
col = slot_index % 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, monochrome, fields)
c.showPage()
data_index += len(chunk)
page_number += 1
c.save()
return buf.getvalue()
def render_labels(
template: TemplateName,
data_list: list[LabelData],
*,
monochrome: bool = False,
starting_position: int = 1,
fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
) -> 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.
``monochrome`` drops the colour swatch (which prints as a useless grey block
on black-and-white thermal printers) and reclaims the space for text; the
hex-code line still carries the colour. See #1870.
``starting_position`` is one-based and applies only to the first page of a
sheet template. Later pages always begin at the first slot.
``fields`` names the lines to print (#2981); see ``ALL_LABEL_FIELDS``.
"""
if template in _SINGLE_LABEL_SIZES_MM:
if starting_position != 1:
raise ValueError("Starting position is only supported for sheet label templates")
return _render_single_label_pdf(template, data_list, monochrome, fields)
if template in _SHEET_TEMPLATES:
return _render_sheet_pdf(template, data_list, monochrome, starting_position, fields)
raise ValueError(f"Unknown label template: {template!r}")
def render_label_preview_pdf(
template: TemplateName,
data: LabelData,
*,
monochrome: bool = False,
fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
) -> bytes:
"""One label of ``template`` on a page of its own size.
For a roll template that is the label as printed; for a sheet it is one
cell, which is all the preview needs to show.
"""
w_mm, h_mm = label_size_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))
_draw_label(c, 0, 0, page_w, page_h, data, monochrome, fields)
c.showPage()
c.save()
return buf.getvalue()
def pdf_to_pngs(pdf: bytes, dpi: int) -> list[bytes]:
"""Rasterise every page of ``pdf`` to a PNG at ``dpi``.
Images are drawn without smoothing: the QR is embedded as a bitmap, and
resampling it with interpolation greys the module edges, which is what
makes a small code unreadable on a 203 dpi thermal printer (#1870). The
PNG carries its dpi so label software prints it at the right size.
"""
import pypdfium2 as pdfium
from backend.app.services.pdf_thumbnail import _PDFIUM_LOCK
pages: list[bytes] = []
with _PDFIUM_LOCK:
doc = pdfium.PdfDocument(pdf)
try:
for index in range(len(doc)):
page = doc[index]
try:
image = page.render(scale=dpi / 72, no_smoothimage=True).to_pil()
finally:
page.close()
out = io.BytesIO()
image.convert("RGB").save(out, "PNG", dpi=(dpi, dpi), optimize=True)
pages.append(out.getvalue())
finally:
doc.close()
return pages
__all__ = [
"ALL_LABEL_FIELDS",
"DEFAULT_LABEL_FIELDS",
"LabelData",
"LabelField",
"TemplateName",
"get_sheet_capacity",
"label_size_mm",
"pdf_to_pngs",
"render_label_preview_pdf",
"render_labels",
]
# white re-exported for completeness; future templates may need a paper-tone variant.
_ = white