label_renderer.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. """PDF spool label rendering.
  2. Four fixed templates:
  3. - ``ams_30x15`` — 30×15 mm single label, fits the popular Makerworld AMS
  4. Filament Label Holder (model 752566). One label per page.
  5. - ``box_62x29`` — 62×29 mm single label, sized for Brother PT/QL and Dymo
  6. generic small labels. One label per page.
  7. - ``avery_5160`` — US Letter sheet, 25.4×66.7 mm × 30 per sheet.
  8. - ``avery_l7160`` — A4 sheet, 38.1×63.5 mm × 21 per sheet.
  9. The renderer is decoupled from the Spool model: callers build a ``LabelData``
  10. list from whatever source (local DB, Spoolman, future) so the same code path
  11. works in both modes.
  12. Layout principle, taken from the issue's user need (`#809`): the **spool ID**
  13. is the most-recognisable field at arm's length and dominates the layout. Other
  14. fields (brand, material, name, storage location) fill remaining space; the QR
  15. code provides the round-trip back to ``/inventory?spool=<id>``.
  16. """
  17. from __future__ import annotations
  18. import io
  19. from dataclasses import dataclass
  20. from typing import Literal
  21. import qrcode
  22. from reportlab.lib.colors import Color, HexColor, black, white
  23. from reportlab.lib.pagesizes import A4, letter
  24. from reportlab.lib.units import mm
  25. from reportlab.pdfgen import canvas as rl_canvas
  26. TemplateName = Literal["ams_30x15", "box_62x29", "avery_5160", "avery_l7160"]
  27. @dataclass
  28. class LabelData:
  29. """Per-spool data needed to render a label.
  30. Decoupled from the SQLAlchemy model so the same renderer serves the local
  31. inventory and the Spoolman-backed inventory.
  32. """
  33. spool_id: int
  34. name: str
  35. material: str
  36. brand: str | None = None
  37. subtype: str | None = None
  38. rgba: str | None = None # "RRGGBB" or "RRGGBBAA"; None → neutral grey
  39. extra_colors: list[str] | None = None # additional hex colours (no '#')
  40. storage_location: str | None = None
  41. deeplink_url: str = "" # what the QR encodes; caller composes it
  42. # ── Colour helpers ───────────────────────────────────────────────────────────
  43. def _color_from_hex(hex_str: str | None, fallback: Color = HexColor(0x808080)) -> Color:
  44. """Parse an RRGGBB or RRGGBBAA string (no '#') into a ReportLab Color.
  45. Alpha is honoured so multi-colour spools with translucent overlays render
  46. correctly. Falls back to ``fallback`` for None / malformed input rather
  47. than raising — labels should always print.
  48. """
  49. if not hex_str:
  50. return fallback
  51. h = hex_str.lstrip("#").strip()
  52. if len(h) not in (6, 8):
  53. return fallback
  54. try:
  55. r = int(h[0:2], 16) / 255.0
  56. g = int(h[2:4], 16) / 255.0
  57. b = int(h[4:6], 16) / 255.0
  58. a = int(h[6:8], 16) / 255.0 if len(h) == 8 else 1.0
  59. return Color(r, g, b, alpha=a)
  60. except ValueError:
  61. return fallback
  62. def _luminance(color: Color) -> float:
  63. """Perceived luminance of a ReportLab Color (0–1, WCAG-style approximation)."""
  64. return 0.299 * color.red + 0.587 * color.green + 0.114 * color.blue
  65. # ── QR generation ────────────────────────────────────────────────────────────
  66. def _qr_png_bytes(payload: str, *, box_size: int = 4, border: int = 2) -> bytes:
  67. """Render ``payload`` as a tight QR PNG. Empty payload returns empty bytes
  68. so callers can skip drawing without checking ahead of time.
  69. """
  70. if not payload:
  71. return b""
  72. qr = qrcode.QRCode(
  73. version=None,
  74. error_correction=qrcode.constants.ERROR_CORRECT_M,
  75. box_size=box_size,
  76. border=border,
  77. )
  78. qr.add_data(payload)
  79. qr.make(fit=True)
  80. img = qr.make_image(fill_color="black", back_color="white")
  81. buf = io.BytesIO()
  82. img.save(buf, format="PNG")
  83. return buf.getvalue()
  84. # ── Single-label drawing ─────────────────────────────────────────────────────
  85. def _draw_swatch(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None:
  86. """Draw the colour swatch. Multi-colour spools use vertical stripes
  87. (matching the FilamentSwatch convention in the frontend)."""
  88. primary = _color_from_hex(data.rgba)
  89. extras = [_color_from_hex(h) for h in (data.extra_colors or []) if h]
  90. colors = [primary, *extras]
  91. if not colors:
  92. c.setFillColor(HexColor(0x808080))
  93. c.rect(x, y, w, h, stroke=0, fill=1)
  94. return
  95. stripe_w = w / len(colors)
  96. for i, col in enumerate(colors):
  97. c.setFillColor(col)
  98. c.rect(x + i * stripe_w, y, stripe_w, h, stroke=0, fill=1)
  99. # Thin black border so light-colour swatches stay visible on white labels.
  100. c.setStrokeColor(black)
  101. c.setLineWidth(0.3)
  102. c.rect(x, y, w, h, stroke=1, fill=0)
  103. def _draw_qr(c: rl_canvas.Canvas, x: float, y: float, size: float, payload: str) -> None:
  104. """Embed a square QR at (x, y) with edge length ``size`` (in points)."""
  105. png = _qr_png_bytes(payload)
  106. if not png:
  107. return
  108. from reportlab.lib.utils import ImageReader
  109. img = ImageReader(io.BytesIO(png))
  110. c.drawImage(img, x, y, width=size, height=size, mask="auto")
  111. def _truncate_to_width(c: rl_canvas.Canvas, text: str, font: str, size: float, max_w: float) -> str:
  112. """Truncate ``text`` with an ellipsis so it fits within ``max_w`` points."""
  113. if c.stringWidth(text, font, size) <= max_w:
  114. return text
  115. ell = "…"
  116. while text and c.stringWidth(text + ell, font, size) > max_w:
  117. text = text[:-1]
  118. return text + ell if text else ell
  119. def _draw_label(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None:
  120. """Render one label inside the box (x, y, w, h). Origin is bottom-left.
  121. Two layouts, picked by available height:
  122. - **Tight** (h < 20 mm — AMS holder): swatch on the left, three lines of
  123. text on the right (brand, material+subtype, big spool ID). No QR — at
  124. 30×15 mm there is not enough horizontal room for swatch + text + QR
  125. without truncating away the user-need fields, and the AMS holder is an
  126. at-a-glance identifier where the spool ID is the killer field. The
  127. box-label and Avery templates carry the QR for the other use cases.
  128. - **Roomy** (h >= 20 mm — box label, Avery sheets): swatch on the left,
  129. QR on the right, multi-line text in the middle column. Large spool ID
  130. anchored at bottom-left under the swatch so it stays readable when the
  131. label is on a box on a shelf at arm's length.
  132. """
  133. pad = 1.2 * mm
  134. inner_x, inner_y = x + pad, y + pad
  135. inner_w = w - 2 * pad
  136. inner_h = h - 2 * pad
  137. # Outer hairline border so labels are easy to cut out from blank stock.
  138. c.setStrokeColor(HexColor(0xCCCCCC))
  139. c.setLineWidth(0.4)
  140. c.rect(x, y, w, h, stroke=1, fill=0)
  141. is_tight = h < 20 * mm
  142. if is_tight:
  143. _draw_label_tight(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data)
  144. else:
  145. _draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data)
  146. def _draw_label_tight(
  147. c: rl_canvas.Canvas,
  148. x: float,
  149. y: float,
  150. w: float,
  151. h: float,
  152. inner_x: float,
  153. inner_y: float,
  154. inner_w: float,
  155. inner_h: float,
  156. pad: float,
  157. data: LabelData,
  158. ) -> None:
  159. """AMS-holder layout (e.g. 30×15 mm). Swatch + 3-line text, no QR."""
  160. swatch_w = min(inner_h, inner_w * 0.35)
  161. swatch_y = inner_y + (inner_h - swatch_w) / 2
  162. _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
  163. text_x = inner_x + swatch_w + pad
  164. text_w = inner_w - swatch_w - pad
  165. if text_w < 5 * mm:
  166. return # Pathological — even the swatch barely fits.
  167. c.setFillColor(black)
  168. # Top: brand (small)
  169. brand_size = 5.5
  170. if data.brand:
  171. c.setFont("Helvetica", brand_size)
  172. brand = _truncate_to_width(c, data.brand, "Helvetica", brand_size, text_w)
  173. c.drawString(text_x, y + h - pad - brand_size, brand)
  174. # Second line: material + subtype, small
  175. sub_size = 5.5
  176. sub_line = " ".join(filter(None, [data.material, data.subtype]))
  177. if sub_line:
  178. c.setFont("Helvetica", sub_size)
  179. sub_line = _truncate_to_width(c, sub_line, "Helvetica", sub_size, text_w)
  180. sub_y = y + h - pad - brand_size - 0.6 - sub_size
  181. c.drawString(text_x, sub_y, sub_line)
  182. # Bottom: BIG spool ID — the killer field at-a-glance.
  183. id_size = 13
  184. c.setFont("Helvetica-Bold", id_size)
  185. id_text = _truncate_to_width(c, f"#{data.spool_id}", "Helvetica-Bold", id_size, text_w)
  186. c.drawString(text_x, inner_y + 0.5, id_text)
  187. def _draw_label_roomy(
  188. c: rl_canvas.Canvas,
  189. x: float,
  190. y: float,
  191. w: float,
  192. h: float,
  193. inner_x: float,
  194. inner_y: float,
  195. inner_w: float,
  196. inner_h: float,
  197. pad: float,
  198. data: LabelData,
  199. ) -> None:
  200. """Box-label / Avery layout. Swatch left, QR right, text middle."""
  201. # Swatch: full inner height, ~18% of inner width but capped so we never
  202. # eat the text column on extreme aspect ratios.
  203. swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
  204. swatch_h = inner_h
  205. _draw_swatch(c, inner_x, inner_y, swatch_w, swatch_h, data)
  206. # QR: square, capped at the smaller of (a fraction of width, the inner
  207. # height, or 18 mm — beyond that the QR is overkill for the print size).
  208. qr_size = min(inner_w * 0.20, inner_h, 18 * mm)
  209. qr_x = x + w - pad - qr_size
  210. qr_y = inner_y + (inner_h - qr_size) / 2
  211. _draw_qr(c, qr_x, qr_y, qr_size, data.deeplink_url)
  212. text_x = inner_x + swatch_w + 1.5 * mm
  213. text_w = qr_x - text_x - 1.5 * mm
  214. if text_w < 8 * mm:
  215. return
  216. c.setFillColor(black)
  217. # Build the text rows we want to render, in top→bottom order.
  218. line1 = data.brand or ""
  219. line2 = " · ".join(filter(None, [data.material, data.subtype]))
  220. name = data.name or ""
  221. # Layout from the top of the text column.
  222. cursor_y = y + h - pad
  223. if line1:
  224. size = 7
  225. c.setFont("Helvetica", size)
  226. text = _truncate_to_width(c, line1, "Helvetica", size, text_w)
  227. cursor_y -= size
  228. c.drawString(text_x, cursor_y, text)
  229. cursor_y -= 1.2
  230. if line2:
  231. size = 7
  232. c.setFont("Helvetica", size)
  233. text = _truncate_to_width(c, line2, "Helvetica", size, text_w)
  234. cursor_y -= size
  235. c.drawString(text_x, cursor_y, text)
  236. cursor_y -= 1.5
  237. if name and name != line1:
  238. size = 9
  239. c.setFont("Helvetica-Bold", size)
  240. text = _truncate_to_width(c, name, "Helvetica-Bold", size, text_w)
  241. cursor_y -= size
  242. c.drawString(text_x, cursor_y, text)
  243. cursor_y -= 1.2
  244. if data.storage_location:
  245. size = 6.5
  246. c.setFont("Helvetica-Oblique", size)
  247. text = _truncate_to_width(c, data.storage_location, "Helvetica-Oblique", size, text_w)
  248. cursor_y -= size
  249. c.drawString(text_x, cursor_y, text)
  250. # Spool ID — anchored at the bottom of the text column, big and bold.
  251. id_size = 16
  252. c.setFont("Helvetica-Bold", id_size)
  253. id_text = _truncate_to_width(c, f"#{data.spool_id}", "Helvetica-Bold", id_size, text_w)
  254. c.drawString(text_x, inner_y + 0.5, id_text)
  255. # ── Template entry points ────────────────────────────────────────────────────
  256. # (label_w_mm, label_h_mm) for single-label-per-page templates.
  257. _SINGLE_LABEL_SIZES_MM: dict[str, tuple[float, float]] = {
  258. "ams_30x15": (30.0, 15.0),
  259. "box_62x29": (62.0, 29.0),
  260. }
  261. # Sheet template parameters: (page_size, label_w_mm, label_h_mm,
  262. # cols, rows, top_margin_mm, left_margin_mm,
  263. # col_gap_mm, row_gap_mm)
  264. _SHEET_TEMPLATES: dict[str, tuple] = {
  265. "avery_5160": (letter, 66.675, 25.4, 3, 10, 12.7, 4.76, 3.175, 0.0),
  266. "avery_l7160": (A4, 63.5, 38.1, 3, 7, 15.15, 7.0, 2.5, 0.0),
  267. }
  268. def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData]) -> bytes:
  269. w_mm, h_mm = _SINGLE_LABEL_SIZES_MM[template]
  270. page_w, page_h = w_mm * mm, h_mm * mm
  271. buf = io.BytesIO()
  272. c = rl_canvas.Canvas(buf, pagesize=(page_w, page_h))
  273. c.setTitle(f"Bambuddy spool labels ({template})")
  274. for data in data_list:
  275. _draw_label(c, 0, 0, page_w, page_h, data)
  276. c.showPage()
  277. c.save()
  278. return buf.getvalue()
  279. def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData]) -> bytes:
  280. page_size, w_mm, h_mm, cols, rows, top_mm, left_mm, col_gap_mm, row_gap_mm = _SHEET_TEMPLATES[template]
  281. page_w, page_h = page_size
  282. label_w = w_mm * mm
  283. label_h = h_mm * mm
  284. top_margin = top_mm * mm
  285. left_margin = left_mm * mm
  286. col_gap = col_gap_mm * mm
  287. row_gap = row_gap_mm * mm
  288. buf = io.BytesIO()
  289. c = rl_canvas.Canvas(buf, pagesize=page_size)
  290. c.setTitle(f"Bambuddy spool labels ({template})")
  291. per_page = cols * rows
  292. for page_start in range(0, len(data_list), per_page):
  293. chunk = data_list[page_start : page_start + per_page]
  294. for idx, data in enumerate(chunk):
  295. row = idx // cols
  296. col = idx % cols
  297. x = left_margin + col * (label_w + col_gap)
  298. y = page_h - top_margin - (row + 1) * label_h - row * row_gap
  299. _draw_label(c, x, y, label_w, label_h, data)
  300. c.showPage()
  301. c.save()
  302. return buf.getvalue()
  303. def render_labels(template: TemplateName, data_list: list[LabelData]) -> bytes:
  304. """Render ``data_list`` to a PDF using the named template. Returns bytes.
  305. Empty ``data_list`` still produces a valid (empty) PDF — callers should
  306. short-circuit beforehand if that's not desired.
  307. """
  308. if template in _SINGLE_LABEL_SIZES_MM:
  309. return _render_single_label_pdf(template, data_list)
  310. if template in _SHEET_TEMPLATES:
  311. return _render_sheet_pdf(template, data_list)
  312. raise ValueError(f"Unknown label template: {template!r}")
  313. __all__ = ["LabelData", "TemplateName", "render_labels"]
  314. # white re-exported for completeness; future templates may need a paper-tone variant.
  315. _ = white