label_renderer.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. """PDF spool label rendering.
  2. Six fixed templates:
  3. - ``ams_holder_74x33`` — 74×33 mm single label, matches the printable label
  4. STL bundled with the Makerworld AMS Filament Label Holder (model 752566).
  5. Smaller variant — the visible window in the holder. One label per page.
  6. - ``ams_holder_75x55`` — 75×55 mm single label, fits the cardstock-insert
  7. variant of the same holder. Roomier — swatch + QR + full text column.
  8. - ``box_40x30`` — 40×30 mm single label, common DK/Brother roll size and a
  9. good fit for filament-bag/storage-bin labels (#809 follow-up). Roomy
  10. layout — swatch, QR, full text column with hex code.
  11. - ``box_62x29`` — 62×29 mm single label, sized for Brother PT/QL and Dymo
  12. generic small labels. One label per page.
  13. - ``avery_5160`` — US Letter sheet, 25.4×66.7 mm × 30 per sheet.
  14. - ``avery_l7160`` — A4 sheet, 38.1×63.5 mm × 21 per sheet.
  15. The legacy ``ams_30x15`` preset (#809) was incorrect — the original 30×15 mm
  16. dimension didn't fit any documented variant of model 752566. Replaced by the
  17. two ``ams_holder_*`` presets above (#1426).
  18. The renderer is decoupled from the Spool model: callers build a ``LabelData``
  19. list from whatever source (local DB, Spoolman, future) so the same code path
  20. works in both modes.
  21. Layout principle, taken from the issue's user need (`#809`): the **spool ID**
  22. is the most-recognisable field at arm's length and dominates the layout. Other
  23. fields (brand, material, name, storage location) fill remaining space; the QR
  24. code provides the round-trip back to ``/inventory?spool=<id>``.
  25. """
  26. from __future__ import annotations
  27. import io
  28. from dataclasses import dataclass
  29. from typing import Literal
  30. import qrcode
  31. from reportlab.lib.colors import Color, HexColor, black, white
  32. from reportlab.lib.pagesizes import A4, letter
  33. from reportlab.lib.units import mm
  34. from reportlab.pdfgen import canvas as rl_canvas
  35. TemplateName = Literal[
  36. "ams_holder_74x33",
  37. "ams_holder_75x55",
  38. "box_40x30",
  39. "box_62x29",
  40. "avery_5160",
  41. "avery_l7160",
  42. ]
  43. @dataclass
  44. class LabelData:
  45. """Per-spool data needed to render a label.
  46. Decoupled from the SQLAlchemy model so the same renderer serves the local
  47. inventory and the Spoolman-backed inventory.
  48. """
  49. spool_id: int
  50. name: str
  51. material: str
  52. brand: str | None = None
  53. subtype: str | None = None
  54. rgba: str | None = None # "RRGGBB" or "RRGGBBAA"; None → neutral grey
  55. extra_colors: list[str] | None = None # additional hex colours (no '#')
  56. storage_location: str | None = None
  57. deeplink_url: str = "" # what the QR encodes; caller composes it
  58. # ── Colour helpers ───────────────────────────────────────────────────────────
  59. def _color_from_hex(hex_str: str | None, fallback: Color = HexColor(0x808080)) -> Color:
  60. """Parse an RRGGBB or RRGGBBAA string (no '#') into a ReportLab Color.
  61. Alpha is honoured so multi-colour spools with translucent overlays render
  62. correctly. Falls back to ``fallback`` for None / malformed input rather
  63. than raising — labels should always print.
  64. """
  65. if not hex_str:
  66. return fallback
  67. h = hex_str.lstrip("#").strip()
  68. if len(h) not in (6, 8):
  69. return fallback
  70. try:
  71. r = int(h[0:2], 16) / 255.0
  72. g = int(h[2:4], 16) / 255.0
  73. b = int(h[4:6], 16) / 255.0
  74. a = int(h[6:8], 16) / 255.0 if len(h) == 8 else 1.0
  75. return Color(r, g, b, alpha=a)
  76. except ValueError:
  77. return fallback
  78. def _luminance(color: Color) -> float:
  79. """Perceived luminance of a ReportLab Color (0–1, WCAG-style approximation)."""
  80. return 0.299 * color.red + 0.587 * color.green + 0.114 * color.blue
  81. def _hex_code_label(rgba: str | None) -> str:
  82. """Format ``data.rgba`` as a printable ``#RRGGBB`` string for the label.
  83. Drops the alpha channel (printed labels can't show transparency) and
  84. upper-cases the hex digits to match the colour-picker convention used in
  85. the inventory UI. Returns an empty string for None / malformed input so
  86. the caller can ``if hex_code:`` skip drawing without an exception.
  87. """
  88. if not rgba:
  89. return ""
  90. h = rgba.lstrip("#").strip()
  91. if len(h) not in (6, 8):
  92. return ""
  93. rgb = h[:6]
  94. if not all(c in "0123456789abcdefABCDEF" for c in rgb):
  95. return ""
  96. return f"#{rgb.upper()}"
  97. # ── QR generation ────────────────────────────────────────────────────────────
  98. def _qr_png_bytes(payload: str, *, box_size: int = 4, border: int = 2) -> bytes:
  99. """Render ``payload`` as a tight QR PNG. Empty payload returns empty bytes
  100. so callers can skip drawing without checking ahead of time.
  101. """
  102. if not payload:
  103. return b""
  104. qr = qrcode.QRCode(
  105. version=None,
  106. # ERROR_CORRECT_L (7% recovery) rather than M (15%): a label QR only
  107. # needs to survive being scanned off clean stock, not physical damage,
  108. # and L encodes the same payload in a lower version (fewer, chunkier
  109. # modules). That extra module size is what makes the code printable on
  110. # low-resolution 203 dpi thermal printers, where M-level density bled
  111. # the modules together on small labels (#1870).
  112. error_correction=qrcode.constants.ERROR_CORRECT_L,
  113. box_size=box_size,
  114. border=border,
  115. )
  116. qr.add_data(payload)
  117. qr.make(fit=True)
  118. img = qr.make_image(fill_color="black", back_color="white")
  119. buf = io.BytesIO()
  120. img.save(buf, format="PNG")
  121. return buf.getvalue()
  122. # ── Single-label drawing ─────────────────────────────────────────────────────
  123. def _draw_swatch(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None:
  124. """Draw the colour swatch. Multi-colour spools use vertical stripes
  125. (matching the FilamentSwatch convention in the frontend)."""
  126. primary = _color_from_hex(data.rgba)
  127. extras = [_color_from_hex(h) for h in (data.extra_colors or []) if h]
  128. colors = [primary, *extras]
  129. if not colors:
  130. c.setFillColor(HexColor(0x808080))
  131. c.rect(x, y, w, h, stroke=0, fill=1)
  132. return
  133. stripe_w = w / len(colors)
  134. for i, col in enumerate(colors):
  135. c.setFillColor(col)
  136. c.rect(x + i * stripe_w, y, stripe_w, h, stroke=0, fill=1)
  137. # Thin black border so light-colour swatches stay visible on white labels.
  138. c.setStrokeColor(black)
  139. c.setLineWidth(0.3)
  140. c.rect(x, y, w, h, stroke=1, fill=0)
  141. def _roomy_qr_size(inner_w: float, inner_h: float) -> float:
  142. """QR edge length (points) for the roomy layout.
  143. Historically a flat 20% of inner width, which on the narrowest label
  144. (box_40x30, ~37.6 mm inner) rendered a ~7.5 mm QR — at 203 dpi each module
  145. fell below ~2 dots and the code bled into itself on thermal printers
  146. (#1870). A 12 mm floor keeps small labels scannable; the code is still
  147. capped by the inner height, an 18 mm absolute max, and ~45% of inner width
  148. so it can't crowd out the text column on an ultra-narrow label.
  149. """
  150. return min(max(inner_w * 0.20, 12 * mm), inner_h, 18 * mm, inner_w * 0.45)
  151. def _draw_qr(c: rl_canvas.Canvas, x: float, y: float, size: float, payload: str) -> None:
  152. """Embed a square QR at (x, y) with edge length ``size`` (in points)."""
  153. png = _qr_png_bytes(payload)
  154. if not png:
  155. return
  156. from reportlab.lib.utils import ImageReader
  157. img = ImageReader(io.BytesIO(png))
  158. c.drawImage(img, x, y, width=size, height=size, mask="auto")
  159. def _truncate_to_width(c: rl_canvas.Canvas, text: str, font: str, size: float, max_w: float) -> str:
  160. """Truncate ``text`` with an ellipsis so it fits within ``max_w`` points."""
  161. if c.stringWidth(text, font, size) <= max_w:
  162. return text
  163. ell = "…"
  164. while text and c.stringWidth(text + ell, font, size) > max_w:
  165. text = text[:-1]
  166. return text + ell if text else ell
  167. def _draw_label(
  168. c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData, monochrome: bool = False
  169. ) -> None:
  170. """Render one label inside the box (x, y, w, h). Origin is bottom-left.
  171. Two layouts, picked by available height:
  172. - **Tight** (h < 20 mm): swatch on the left, three lines of text on the
  173. right (brand, material+subtype, big spool ID). No QR — at very small
  174. heights there is not enough horizontal room for swatch + text + QR
  175. without truncating away the user-need fields. Kept as the safety
  176. branch for any future ultra-small preset; the shipped templates all
  177. land in the roomy layout below.
  178. - **Roomy** (h >= 20 mm — AMS holder, box label, Avery sheets): swatch
  179. on the left, QR on the right, multi-line text in the middle column.
  180. Large spool ID anchored at bottom-left under the swatch so it stays
  181. readable at arm's length.
  182. """
  183. pad = 1.2 * mm
  184. inner_x, inner_y = x + pad, y + pad
  185. inner_w = w - 2 * pad
  186. inner_h = h - 2 * pad
  187. # Outer hairline border so labels are easy to cut out from blank stock.
  188. c.setStrokeColor(HexColor(0xCCCCCC))
  189. c.setLineWidth(0.4)
  190. c.rect(x, y, w, h, stroke=1, fill=0)
  191. is_tight = h < 20 * mm
  192. if is_tight:
  193. _draw_label_tight(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome)
  194. else:
  195. _draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome)
  196. def _draw_label_tight(
  197. c: rl_canvas.Canvas,
  198. x: float,
  199. y: float,
  200. w: float,
  201. h: float,
  202. inner_x: float,
  203. inner_y: float,
  204. inner_w: float,
  205. inner_h: float,
  206. pad: float,
  207. data: LabelData,
  208. monochrome: bool = False,
  209. ) -> None:
  210. """Tight layout (h < 20 mm). Swatch + brand/material/hex/ID, no QR."""
  211. # Monochrome: drop the colour swatch (see _draw_label_roomy) and give the
  212. # width to the text column (#1870).
  213. if monochrome:
  214. swatch_w = 0.0
  215. else:
  216. swatch_w = min(inner_h, inner_w * 0.35)
  217. swatch_y = inner_y + (inner_h - swatch_w) / 2
  218. _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
  219. text_x = inner_x + swatch_w + pad
  220. text_w = inner_w - swatch_w - pad
  221. if text_w < 5 * mm:
  222. return # Pathological — even the swatch barely fits.
  223. c.setFillColor(black)
  224. # Top: brand — bumped to bold + larger per the #809 follow-up so it's the
  225. # easiest thing to read on a small AMS holder at arm's length.
  226. brand_size = 6.5
  227. if data.brand:
  228. c.setFont("Helvetica-Bold", brand_size)
  229. brand = _truncate_to_width(c, data.brand, "Helvetica-Bold", brand_size, text_w)
  230. c.drawString(text_x, y + h - pad - brand_size, brand)
  231. # Second line: material + subtype, small
  232. sub_size = 5
  233. sub_line = " ".join(filter(None, [data.material, data.subtype]))
  234. sub_y_baseline = y + h - pad - brand_size - 0.6 - sub_size
  235. if sub_line:
  236. c.setFont("Helvetica", sub_size)
  237. sub_line = _truncate_to_width(c, sub_line, "Helvetica", sub_size, text_w)
  238. c.drawString(text_x, sub_y_baseline, sub_line)
  239. # Third line (when there's room): hex code, tiny — useful when the user
  240. # has multiple near-identical colours in the same material family.
  241. hex_code = _hex_code_label(data.rgba)
  242. if hex_code:
  243. hex_size = 4.5
  244. hex_y = sub_y_baseline - 0.4 - hex_size
  245. # Don't render if it'd collide with the spool ID at the bottom.
  246. if hex_y > inner_y + 13:
  247. c.setFont("Helvetica", hex_size)
  248. c.drawString(text_x, hex_y, hex_code)
  249. # Bottom: BIG spool ID — the killer field at-a-glance.
  250. id_size = 13
  251. c.setFont("Helvetica-Bold", id_size)
  252. id_text = _truncate_to_width(c, f"#{data.spool_id}", "Helvetica-Bold", id_size, text_w)
  253. c.drawString(text_x, inner_y + 0.5, id_text)
  254. def _draw_label_roomy(
  255. c: rl_canvas.Canvas,
  256. x: float,
  257. y: float,
  258. w: float,
  259. h: float,
  260. inner_x: float,
  261. inner_y: float,
  262. inner_w: float,
  263. inner_h: float,
  264. pad: float,
  265. data: LabelData,
  266. monochrome: bool = False,
  267. ) -> None:
  268. """Box-label / Avery layout. Swatch left, QR right, text middle."""
  269. # Swatch: full inner height, ~18% of inner width but capped so we never
  270. # eat the text column on extreme aspect ratios. Omitted entirely in
  271. # monochrome mode — on a B&W thermal printer a colour block prints as a
  272. # muddy grey that conveys nothing, so we reclaim the space for text and
  273. # rely on the hex-code line to carry the colour (#1870, requested by
  274. # @Geoff-S). The hex code already renders below whenever rgba is set.
  275. if monochrome:
  276. swatch_w = 0.0
  277. else:
  278. swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
  279. _draw_swatch(c, inner_x, inner_y, swatch_w, inner_h, data)
  280. qr_size = _roomy_qr_size(inner_w, inner_h)
  281. qr_x = x + w - pad - qr_size
  282. qr_y = inner_y + (inner_h - qr_size) / 2
  283. _draw_qr(c, qr_x, qr_y, qr_size, data.deeplink_url)
  284. text_x = inner_x + swatch_w + 1.5 * mm
  285. text_w = qr_x - text_x - 1.5 * mm
  286. if text_w < 8 * mm:
  287. return
  288. c.setFillColor(black)
  289. # Build the text rows we want to render, in top→bottom order.
  290. line1 = data.brand or ""
  291. line2 = " · ".join(filter(None, [data.material, data.subtype]))
  292. name = data.name or ""
  293. hex_code = _hex_code_label(data.rgba)
  294. # Layout from the top of the text column.
  295. cursor_y = y + h - pad
  296. # Brand — bumped to bold + larger per the #809 follow-up.
  297. if line1:
  298. size = 8
  299. c.setFont("Helvetica-Bold", size)
  300. text = _truncate_to_width(c, line1, "Helvetica-Bold", size, text_w)
  301. cursor_y -= size
  302. c.drawString(text_x, cursor_y, text)
  303. cursor_y -= 1.2
  304. if line2:
  305. size = 7
  306. c.setFont("Helvetica", size)
  307. text = _truncate_to_width(c, line2, "Helvetica", size, text_w)
  308. cursor_y -= size
  309. c.drawString(text_x, cursor_y, text)
  310. cursor_y -= 1.5
  311. # Hex colour code — useful for telling near-identical material+colour
  312. # spools apart when the swatch is small or the user is colour-blind.
  313. if hex_code:
  314. size = 6.5
  315. c.setFont("Helvetica", size)
  316. cursor_y -= size
  317. c.drawString(text_x, cursor_y, hex_code)
  318. cursor_y -= 1.2
  319. if name and name != line1:
  320. size = 9
  321. c.setFont("Helvetica-Bold", size)
  322. text = _truncate_to_width(c, name, "Helvetica-Bold", size, text_w)
  323. cursor_y -= size
  324. c.drawString(text_x, cursor_y, text)
  325. cursor_y -= 1.2
  326. if data.storage_location:
  327. size = 6.5
  328. c.setFont("Helvetica-Oblique", size)
  329. text = _truncate_to_width(c, data.storage_location, "Helvetica-Oblique", size, text_w)
  330. cursor_y -= size
  331. c.drawString(text_x, cursor_y, text)
  332. # Spool ID — anchored at the bottom of the text column, big and bold.
  333. id_size = 16
  334. c.setFont("Helvetica-Bold", id_size)
  335. id_text = _truncate_to_width(c, f"#{data.spool_id}", "Helvetica-Bold", id_size, text_w)
  336. c.drawString(text_x, inner_y + 0.5, id_text)
  337. # ── Template entry points ────────────────────────────────────────────────────
  338. # (label_w_mm, label_h_mm) for single-label-per-page templates.
  339. _SINGLE_LABEL_SIZES_MM: dict[str, tuple[float, float]] = {
  340. "ams_holder_74x33": (74.0, 33.0),
  341. "ams_holder_75x55": (75.0, 55.0),
  342. "box_40x30": (40.0, 30.0),
  343. "box_62x29": (62.0, 29.0),
  344. }
  345. # Sheet template parameters: (page_size, label_w_mm, label_h_mm,
  346. # cols, rows, top_margin_mm, left_margin_mm,
  347. # col_gap_mm, row_gap_mm)
  348. _SHEET_TEMPLATES: dict[str, tuple] = {
  349. "avery_5160": (letter, 66.675, 25.4, 3, 10, 12.7, 4.76, 3.175, 0.0),
  350. "avery_l7160": (A4, 63.5, 38.1, 3, 7, 15.15, 7.0, 2.5, 0.0),
  351. }
  352. def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData], monochrome: bool = False) -> bytes:
  353. w_mm, h_mm = _SINGLE_LABEL_SIZES_MM[template]
  354. page_w, page_h = w_mm * mm, h_mm * mm
  355. buf = io.BytesIO()
  356. c = rl_canvas.Canvas(buf, pagesize=(page_w, page_h))
  357. c.setTitle(f"Bambuddy spool labels ({template})")
  358. for data in data_list:
  359. _draw_label(c, 0, 0, page_w, page_h, data, monochrome)
  360. c.showPage()
  361. c.save()
  362. return buf.getvalue()
  363. def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData], monochrome: bool = False) -> bytes:
  364. page_size, w_mm, h_mm, cols, rows, top_mm, left_mm, col_gap_mm, row_gap_mm = _SHEET_TEMPLATES[template]
  365. page_w, page_h = page_size
  366. label_w = w_mm * mm
  367. label_h = h_mm * mm
  368. top_margin = top_mm * mm
  369. left_margin = left_mm * mm
  370. col_gap = col_gap_mm * mm
  371. row_gap = row_gap_mm * mm
  372. buf = io.BytesIO()
  373. c = rl_canvas.Canvas(buf, pagesize=page_size)
  374. c.setTitle(f"Bambuddy spool labels ({template})")
  375. per_page = cols * rows
  376. for page_start in range(0, len(data_list), per_page):
  377. chunk = data_list[page_start : page_start + per_page]
  378. for idx, data in enumerate(chunk):
  379. row = idx // cols
  380. col = idx % cols
  381. x = left_margin + col * (label_w + col_gap)
  382. y = page_h - top_margin - (row + 1) * label_h - row * row_gap
  383. _draw_label(c, x, y, label_w, label_h, data, monochrome)
  384. c.showPage()
  385. c.save()
  386. return buf.getvalue()
  387. def render_labels(template: TemplateName, data_list: list[LabelData], *, monochrome: bool = False) -> bytes:
  388. """Render ``data_list`` to a PDF using the named template. Returns bytes.
  389. Empty ``data_list`` still produces a valid (empty) PDF — callers should
  390. short-circuit beforehand if that's not desired.
  391. ``monochrome`` drops the colour swatch (which prints as a useless grey block
  392. on black-and-white thermal printers) and reclaims the space for text; the
  393. hex-code line still carries the colour. See #1870.
  394. """
  395. if template in _SINGLE_LABEL_SIZES_MM:
  396. return _render_single_label_pdf(template, data_list, monochrome)
  397. if template in _SHEET_TEMPLATES:
  398. return _render_sheet_pdf(template, data_list, monochrome)
  399. raise ValueError(f"Unknown label template: {template!r}")
  400. __all__ = ["LabelData", "TemplateName", "render_labels"]
  401. # white re-exported for completeness; future templates may need a paper-tone variant.
  402. _ = white