label_renderer.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794
  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. Beyond these, a sheet of any grid can be described at run time as a
  16. ``SheetLayout`` (#3296): page size, label size, columns and rows, margins and
  17. gaps. The built-in sheets are ``SheetLayout`` values too.
  18. The legacy ``ams_30x15`` preset (#809) was incorrect — the original 30×15 mm
  19. dimension didn't fit any documented variant of model 752566. Replaced by the
  20. two ``ams_holder_*`` presets above (#1426).
  21. The renderer is decoupled from the Spool model: callers build a ``LabelData``
  22. list from whatever source (local DB, Spoolman, future) so the same code path
  23. works in both modes.
  24. Layout principle, taken from the issue's user need (`#809`): the **spool ID**
  25. is the most-recognisable field at arm's length and dominates the layout. Other
  26. fields (brand, material, name, storage location) fill remaining space; the QR
  27. code provides the round-trip back to ``/inventory?spool=<id>``.
  28. Which of those appear is the caller's choice (#2981): ``fields`` names the
  29. lines to print, and ``DEFAULT_LABEL_FIELDS`` is the set every label carried
  30. before the choice existed. The label text is data only (numbers, units,
  31. dates), never words, because the PDF is not translated.
  32. ``render_labels`` returns a PDF; ``pdf_to_pngs`` rasterises it page by page
  33. for label-printer software that takes images.
  34. """
  35. from __future__ import annotations
  36. import io
  37. from dataclasses import dataclass
  38. from datetime import date
  39. from typing import Literal
  40. import qrcode
  41. from reportlab.lib.colors import Color, HexColor, black, white
  42. from reportlab.lib.pagesizes import A4, letter
  43. from reportlab.lib.units import mm
  44. from reportlab.pdfgen import canvas as rl_canvas
  45. TemplateName = Literal[
  46. "ams_holder_74x33",
  47. "ams_holder_75x55",
  48. "box_40x30",
  49. "box_62x29",
  50. "avery_5160",
  51. "avery_l7160",
  52. ]
  53. LabelField = Literal[
  54. "brand",
  55. "material",
  56. "hex",
  57. "name",
  58. "location",
  59. "material_number",
  60. "temps",
  61. "weight",
  62. "note",
  63. "added",
  64. "qr",
  65. "spool_id",
  66. ]
  67. # Top-to-bottom order of the text lines, and the order the picker lists them.
  68. ALL_LABEL_FIELDS: tuple[LabelField, ...] = (
  69. "brand",
  70. "material",
  71. "hex",
  72. "name",
  73. "location",
  74. "material_number",
  75. "temps",
  76. "weight",
  77. "note",
  78. "added",
  79. "qr",
  80. "spool_id",
  81. )
  82. # What a label carried before the fields were selectable, so a request that
  83. # names none prints exactly what it always did.
  84. DEFAULT_LABEL_FIELDS: frozenset[LabelField] = frozenset(
  85. {"brand", "material", "hex", "name", "location", "qr", "spool_id"}
  86. )
  87. @dataclass
  88. class LabelData:
  89. """Per-spool data needed to render a label.
  90. Decoupled from the SQLAlchemy model so the same renderer serves the local
  91. inventory and the Spoolman-backed inventory.
  92. """
  93. spool_id: int
  94. name: str
  95. material: str
  96. brand: str | None = None
  97. subtype: str | None = None
  98. rgba: str | None = None # "RRGGBB" or "RRGGBBAA"; None → neutral grey
  99. extra_colors: list[str] | None = None # additional hex colours (no '#')
  100. storage_location: str | None = None
  101. deeplink_url: str = "" # what the QR encodes; caller composes it
  102. material_number: str | None = None
  103. nozzle_temp_min: int | None = None
  104. nozzle_temp_max: int | None = None
  105. label_weight: int | None = None # advertised net weight, grams
  106. note: str | None = None
  107. added: date | None = None
  108. def _temps_text(data: LabelData) -> str:
  109. """``220–240 °C``, or the one bound that is set."""
  110. lo, hi = data.nozzle_temp_min, data.nozzle_temp_max
  111. if lo and hi and lo != hi:
  112. return f"{lo}–{hi} °C"
  113. if lo or hi:
  114. return f"{lo or hi} °C"
  115. return ""
  116. def _weight_text(data: LabelData) -> str:
  117. return f"{data.label_weight} g" if data.label_weight else ""
  118. # ── Colour helpers ───────────────────────────────────────────────────────────
  119. def _color_from_hex(hex_str: str | None, fallback: Color = HexColor(0x808080)) -> Color:
  120. """Parse an RRGGBB or RRGGBBAA string (no '#') into a ReportLab Color.
  121. Alpha is honoured so multi-colour spools with translucent overlays render
  122. correctly. Falls back to ``fallback`` for None / malformed input rather
  123. than raising — labels should always print.
  124. """
  125. if not hex_str:
  126. return fallback
  127. h = hex_str.lstrip("#").strip()
  128. if len(h) not in (6, 8):
  129. return fallback
  130. try:
  131. r = int(h[0:2], 16) / 255.0
  132. g = int(h[2:4], 16) / 255.0
  133. b = int(h[4:6], 16) / 255.0
  134. a = int(h[6:8], 16) / 255.0 if len(h) == 8 else 1.0
  135. return Color(r, g, b, alpha=a)
  136. except ValueError:
  137. return fallback
  138. def _hex_code_label(rgba: str | None) -> str:
  139. """Format ``data.rgba`` as a printable ``#RRGGBB`` string for the label.
  140. Drops the alpha channel (printed labels can't show transparency) and
  141. upper-cases the hex digits to match the colour-picker convention used in
  142. the inventory UI. Returns an empty string for None / malformed input so
  143. the caller can ``if hex_code:`` skip drawing without an exception.
  144. """
  145. if not rgba:
  146. return ""
  147. h = rgba.lstrip("#").strip()
  148. if len(h) not in (6, 8):
  149. return ""
  150. rgb = h[:6]
  151. if not all(c in "0123456789abcdefABCDEF" for c in rgb):
  152. return ""
  153. return f"#{rgb.upper()}"
  154. # ── QR generation ────────────────────────────────────────────────────────────
  155. def _qr_png_bytes(payload: str, *, box_size: int = 4, border: int = 2) -> bytes:
  156. """Render ``payload`` as a tight QR PNG. Empty payload returns empty bytes
  157. so callers can skip drawing without checking ahead of time.
  158. """
  159. if not payload:
  160. return b""
  161. qr = qrcode.QRCode(
  162. version=None,
  163. # ERROR_CORRECT_L (7% recovery) rather than M (15%): a label QR only
  164. # needs to survive being scanned off clean stock, not physical damage,
  165. # and L encodes the same payload in a lower version (fewer, chunkier
  166. # modules). That extra module size is what makes the code printable on
  167. # low-resolution 203 dpi thermal printers, where M-level density bled
  168. # the modules together on small labels (#1870).
  169. error_correction=qrcode.constants.ERROR_CORRECT_L,
  170. box_size=box_size,
  171. border=border,
  172. )
  173. qr.add_data(payload)
  174. qr.make(fit=True)
  175. img = qr.make_image(fill_color="black", back_color="white")
  176. buf = io.BytesIO()
  177. img.save(buf, format="PNG")
  178. return buf.getvalue()
  179. # ── Single-label drawing ─────────────────────────────────────────────────────
  180. def _draw_swatch(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None:
  181. """Draw the colour swatch. Multi-colour spools use vertical stripes
  182. (matching the FilamentSwatch convention in the frontend)."""
  183. c.saveState()
  184. try:
  185. primary = _color_from_hex(data.rgba)
  186. extras = [_color_from_hex(h) for h in (data.extra_colors or []) if h]
  187. colors = [primary, *extras]
  188. if not colors:
  189. c.setFillColor(HexColor(0x808080))
  190. c.rect(x, y, w, h, stroke=0, fill=1)
  191. return
  192. stripe_w = w / len(colors)
  193. for i, col in enumerate(colors):
  194. c.setFillColor(col)
  195. c.rect(x + i * stripe_w, y, stripe_w, h, stroke=0, fill=1)
  196. # Thin black border so light-colour swatches stay visible on white labels.
  197. c.setStrokeColor(black)
  198. c.setLineWidth(0.3)
  199. c.rect(x, y, w, h, stroke=1, fill=0)
  200. finally:
  201. c.restoreState()
  202. def _roomy_qr_size(inner_w: float, inner_h: float) -> float:
  203. """QR edge length (points) for the roomy layout.
  204. Historically a flat 20% of inner width, which on the narrowest label
  205. (box_40x30, ~37.6 mm inner) rendered a ~7.5 mm QR — at 203 dpi each module
  206. fell below ~2 dots and the code bled into itself on thermal printers
  207. (#1870). A 12 mm floor keeps small labels scannable; the code is still
  208. capped by the inner height, an 18 mm absolute max, and ~45% of inner width
  209. so it can't crowd out the text column on an ultra-narrow label.
  210. """
  211. return min(max(inner_w * 0.20, 12 * mm), inner_h, 18 * mm, inner_w * 0.45)
  212. def _draw_qr(c: rl_canvas.Canvas, x: float, y: float, size: float, payload: str) -> None:
  213. """Embed a square QR at (x, y) with edge length ``size`` (in points)."""
  214. png = _qr_png_bytes(payload)
  215. if not png:
  216. return
  217. from reportlab.lib.utils import ImageReader
  218. img = ImageReader(io.BytesIO(png))
  219. c.drawImage(img, x, y, width=size, height=size, mask="auto")
  220. def _truncate_to_width(c: rl_canvas.Canvas, text: str, font: str, size: float, max_w: float) -> str:
  221. """Truncate ``text`` with an ellipsis so it fits within ``max_w`` points."""
  222. if c.stringWidth(text, font, size) <= max_w:
  223. return text
  224. ell = "…"
  225. while text and c.stringWidth(text + ell, font, size) > max_w:
  226. text = text[:-1]
  227. return text + ell if text else ell
  228. def _draw_label(
  229. c: rl_canvas.Canvas,
  230. x: float,
  231. y: float,
  232. w: float,
  233. h: float,
  234. data: LabelData,
  235. monochrome: bool = False,
  236. fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
  237. ) -> None:
  238. """Render one label inside the box (x, y, w, h). Origin is bottom-left.
  239. Two layouts, picked by available height:
  240. - **Tight** (h < 20 mm): swatch on the left, three lines of text on the
  241. right (brand, material+subtype, big spool ID). No QR — at very small
  242. heights there is not enough horizontal room for swatch + text + QR
  243. without truncating away the user-need fields. Kept as the safety
  244. branch for any future ultra-small preset; the shipped templates all
  245. land in the roomy layout below.
  246. - **Roomy** (h >= 20 mm — AMS holder, box label, Avery sheets): swatch
  247. on the left, QR on the right, multi-line text in the middle column.
  248. Large spool ID anchored at bottom-left under the swatch so it stays
  249. readable at arm's length.
  250. """
  251. pad = 1.2 * mm
  252. inner_x, inner_y = x + pad, y + pad
  253. inner_w = w - 2 * pad
  254. inner_h = h - 2 * pad
  255. # Outer hairline border so labels are easy to cut out from blank stock.
  256. c.setStrokeColor(HexColor(0xCCCCCC))
  257. c.setLineWidth(0.4)
  258. c.rect(x, y, w, h, stroke=1, fill=0)
  259. is_tight = h < 20 * mm
  260. if is_tight:
  261. _draw_label_tight(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome, fields)
  262. else:
  263. _draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome, fields)
  264. def _draw_label_tight(
  265. c: rl_canvas.Canvas,
  266. x: float,
  267. y: float,
  268. w: float,
  269. h: float,
  270. inner_x: float,
  271. inner_y: float,
  272. inner_w: float,
  273. inner_h: float,
  274. pad: float,
  275. data: LabelData,
  276. monochrome: bool = False,
  277. fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
  278. ) -> None:
  279. """Tight layout (h < 20 mm). Swatch + brand/material/hex/ID, no QR.
  280. Only those four lines fit, so they are the only ``fields`` it honours.
  281. """
  282. # Monochrome: drop the colour swatch (see _draw_label_roomy) and give the
  283. # width to the text column (#1870).
  284. if monochrome:
  285. swatch_w = 0.0
  286. else:
  287. swatch_w = min(inner_h, inner_w * 0.35)
  288. swatch_y = inner_y + (inner_h - swatch_w) / 2
  289. _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
  290. text_x = inner_x + swatch_w + pad
  291. text_w = inner_w - swatch_w - pad
  292. if text_w < 5 * mm:
  293. return # Pathological — even the swatch barely fits.
  294. c.setFillColor(black)
  295. # Top: brand — bumped to bold + larger per the #809 follow-up so it's the
  296. # easiest thing to read on a small AMS holder at arm's length.
  297. brand_size = 6.5
  298. if data.brand and "brand" in fields:
  299. c.setFont("Helvetica-Bold", brand_size)
  300. brand = _truncate_to_width(c, data.brand, "Helvetica-Bold", brand_size, text_w)
  301. c.drawString(text_x, y + h - pad - brand_size, brand)
  302. # Second line: material + subtype, small
  303. sub_size = 5
  304. sub_line = " ".join(filter(None, [data.material, data.subtype])) if "material" in fields else ""
  305. sub_y_baseline = y + h - pad - brand_size - 0.6 - sub_size
  306. if sub_line:
  307. c.setFont("Helvetica", sub_size)
  308. sub_line = _truncate_to_width(c, sub_line, "Helvetica", sub_size, text_w)
  309. c.drawString(text_x, sub_y_baseline, sub_line)
  310. # Third line (when there's room): hex code, tiny — useful when the user
  311. # has multiple near-identical colours in the same material family.
  312. hex_code = _hex_code_label(data.rgba) if "hex" in fields else ""
  313. if hex_code:
  314. hex_size = 4.5
  315. hex_y = sub_y_baseline - 0.4 - hex_size
  316. # Don't render if it'd collide with the spool ID at the bottom.
  317. if hex_y > inner_y + 13:
  318. c.setFont("Helvetica", hex_size)
  319. c.drawString(text_x, hex_y, hex_code)
  320. # Bottom: BIG spool ID — the killer field at-a-glance.
  321. if "spool_id" not in fields:
  322. return
  323. id_size = 13
  324. c.setFont("Helvetica-Bold", id_size)
  325. id_text = _truncate_to_width(c, f"#{data.spool_id}", "Helvetica-Bold", id_size, text_w)
  326. c.drawString(text_x, inner_y + 0.5, id_text)
  327. def _draw_label_roomy(
  328. c: rl_canvas.Canvas,
  329. x: float,
  330. y: float,
  331. w: float,
  332. h: float,
  333. inner_x: float,
  334. inner_y: float,
  335. inner_w: float,
  336. inner_h: float,
  337. pad: float,
  338. data: LabelData,
  339. monochrome: bool = False,
  340. fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
  341. ) -> None:
  342. """Box-label / Avery layout. Swatch left, QR right, text middle."""
  343. # Swatch: full inner height, ~18% of inner width but capped so we never
  344. # eat the text column on extreme aspect ratios. Omitted entirely in
  345. # monochrome mode — on a B&W thermal printer a colour block prints as a
  346. # muddy grey that conveys nothing, so we reclaim the space for text and
  347. # rely on the hex-code line to carry the colour (#1870, requested by
  348. # @Geoff-S). The hex code already renders below whenever rgba is set.
  349. if monochrome:
  350. swatch_w = 0.0
  351. else:
  352. swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
  353. _draw_swatch(c, inner_x, inner_y, swatch_w, inner_h, data)
  354. # Without the QR the text column runs to the right-hand padding.
  355. if "qr" in fields:
  356. qr_size = _roomy_qr_size(inner_w, inner_h)
  357. qr_x = x + w - pad - qr_size
  358. qr_y = inner_y + (inner_h - qr_size) / 2
  359. _draw_qr(c, qr_x, qr_y, qr_size, data.deeplink_url)
  360. text_right = qr_x - 1.5 * mm
  361. else:
  362. text_right = inner_x + inner_w
  363. text_x = inner_x + swatch_w + 1.5 * mm
  364. text_w = text_right - text_x
  365. if text_w < 8 * mm:
  366. return
  367. c.setFillColor(black)
  368. # Spool ID — anchored at the bottom of the text column, big and bold. The
  369. # lines above stop short of it rather than print over it.
  370. id_size = 16
  371. show_id = "spool_id" in fields
  372. floor_y = inner_y + 0.5 + id_size if show_id else inner_y
  373. name = data.name or ""
  374. # The name line is dropped when it only repeats the brand, or the subtype
  375. # already printed beside the material (a Spoolman filament named after
  376. # its colour yields both).
  377. repeats = {data.brand or ""}
  378. if "material" in fields and data.subtype:
  379. repeats.add(data.subtype)
  380. # (field, text, font, size, gap below). Sizes and gaps of the first five
  381. # are what the label has always used; the rest share the location's.
  382. rows: list[tuple[LabelField, str, str, float, float]] = [
  383. # Brand — bumped to bold + larger per the #809 follow-up.
  384. ("brand", data.brand or "", "Helvetica-Bold", 8, 1.2),
  385. ("material", " · ".join(filter(None, [data.material, data.subtype])), "Helvetica", 7, 1.5),
  386. # Hex colour code — useful for telling near-identical material+colour
  387. # spools apart when the swatch is small or the user is colour-blind.
  388. ("hex", _hex_code_label(data.rgba), "Helvetica", 6.5, 1.2),
  389. ("name", name if name not in repeats else "", "Helvetica-Bold", 9, 1.2),
  390. ("location", data.storage_location or "", "Helvetica-Oblique", 6.5, 1.2),
  391. ("material_number", data.material_number or "", "Helvetica", 6.5, 1.2),
  392. ("temps", _temps_text(data), "Helvetica", 6.5, 1.2),
  393. ("weight", _weight_text(data), "Helvetica", 6.5, 1.2),
  394. ("note", data.note or "", "Helvetica-Oblique", 6.5, 1.2),
  395. ("added", data.added.isoformat() if data.added else "", "Helvetica", 6.5, 1.2),
  396. ]
  397. cursor_y = y + h - pad
  398. for field, text, font, size, gap in rows:
  399. if field not in fields or not text:
  400. continue
  401. if cursor_y - size < floor_y:
  402. break
  403. c.setFont(font, size)
  404. cursor_y -= size
  405. c.drawString(text_x, cursor_y, _truncate_to_width(c, text, font, size, text_w))
  406. cursor_y -= gap
  407. if show_id:
  408. c.setFont("Helvetica-Bold", id_size)
  409. id_text = _truncate_to_width(c, f"#{data.spool_id}", "Helvetica-Bold", id_size, text_w)
  410. c.drawString(text_x, inner_y + 0.5, id_text)
  411. # ── Template entry points ────────────────────────────────────────────────────
  412. PageSize = Literal["a4", "letter"]
  413. _PAGE_SIZES: dict[str, tuple[float, float]] = {"a4": A4, "letter": letter}
  414. # The same pages in millimetres, for the fit check. Written out rather than
  415. # derived from the points above so the dialog (labelSheetLayout.ts) can use the
  416. # very same numbers: letter[0] / mm is 215.8999…, not 215.9.
  417. PAGE_SIZES_MM: dict[str, tuple[float, float]] = {"a4": (210.0, 297.0), "letter": (215.9, 279.4)}
  418. # Bounds on a sheet layout (#3296). The minimum label is what the roomy layout
  419. # can fill: from 20 mm tall a label leaves the tight layout (which drops the QR
  420. # and most lines), and from 40 mm wide the text column beside the colour swatch
  421. # and the QR holds a four-digit spool ID at full size, at any height — the same
  422. # width as the smallest built-in label. The rest stops a typo asking for a
  423. # 10 000-label page.
  424. MIN_LABEL_W_MM = 40.0
  425. MIN_LABEL_H_MM = 20.0
  426. MAX_LENGTH_MM = 300.0
  427. MAX_COLS = 20
  428. MAX_ROWS = 40
  429. # Rounding slack when checking that the grid fits the page: a sheet measured in
  430. # inches converts to millimetres with a few hundredths to spare either way.
  431. _FIT_TOLERANCE_MM = 0.01
  432. @dataclass(frozen=True)
  433. class SheetLayout:
  434. """A sheet of identical labels in a grid: the built-in Avery templates and
  435. any custom sheet a user describes (#3296). Lengths are in millimetres."""
  436. page: PageSize
  437. label_w_mm: float
  438. label_h_mm: float
  439. cols: int
  440. rows: int
  441. top_mm: float
  442. left_mm: float
  443. col_gap_mm: float = 0.0
  444. row_gap_mm: float = 0.0
  445. @property
  446. def capacity(self) -> int:
  447. return self.cols * self.rows
  448. @property
  449. def page_size(self) -> tuple[float, float]:
  450. """Page size in points, as ReportLab takes it."""
  451. return _PAGE_SIZES[self.page]
  452. def problem(self) -> str | None:
  453. """Why this layout can't be printed, or ``None`` when it can."""
  454. if self.page not in PAGE_SIZES_MM:
  455. return f"Unknown page size: {self.page!r}"
  456. if self.label_w_mm < MIN_LABEL_W_MM or self.label_h_mm < MIN_LABEL_H_MM:
  457. return f"Labels must be at least {MIN_LABEL_W_MM:g} mm wide and {MIN_LABEL_H_MM:g} mm tall"
  458. lengths = (self.label_w_mm, self.label_h_mm, self.top_mm, self.left_mm, self.col_gap_mm, self.row_gap_mm)
  459. if max(lengths) > MAX_LENGTH_MM:
  460. return f"Sizes, margins and gaps can be at most {MAX_LENGTH_MM:g} mm"
  461. if not 1 <= self.cols <= MAX_COLS:
  462. return f"Columns must be between 1 and {MAX_COLS}"
  463. if not 1 <= self.rows <= MAX_ROWS:
  464. return f"Rows must be between 1 and {MAX_ROWS}"
  465. if min(self.top_mm, self.left_mm, self.col_gap_mm, self.row_gap_mm) < 0:
  466. return "Margins and gaps must not be negative"
  467. page_w_mm, page_h_mm = PAGE_SIZES_MM[self.page]
  468. grid_w = self.left_mm + self.cols * self.label_w_mm + (self.cols - 1) * self.col_gap_mm
  469. grid_h = self.top_mm + self.rows * self.label_h_mm + (self.rows - 1) * self.row_gap_mm
  470. if grid_w > page_w_mm + _FIT_TOLERANCE_MM:
  471. return f"The labels are {grid_w:.2f} mm wide with the left margin, but the page is {page_w_mm:.1f} mm"
  472. if grid_h > page_h_mm + _FIT_TOLERANCE_MM:
  473. return f"The labels are {grid_h:.2f} mm tall with the top margin, but the page is {page_h_mm:.1f} mm"
  474. return None
  475. # A built-in template name, or a sheet layout supplied at run time.
  476. Template = TemplateName | SheetLayout
  477. # (label_w_mm, label_h_mm) for single-label-per-page templates.
  478. _SINGLE_LABEL_SIZES_MM: dict[str, tuple[float, float]] = {
  479. "ams_holder_74x33": (74.0, 33.0),
  480. "ams_holder_75x55": (75.0, 55.0),
  481. "box_40x30": (40.0, 30.0),
  482. "box_62x29": (62.0, 29.0),
  483. }
  484. _SHEET_TEMPLATES: dict[str, SheetLayout] = {
  485. "avery_5160": SheetLayout("letter", 66.675, 25.4, 3, 10, top_mm=12.7, left_mm=4.76, col_gap_mm=3.175),
  486. "avery_l7160": SheetLayout("a4", 63.5, 38.1, 3, 7, top_mm=15.15, left_mm=7.0, col_gap_mm=2.5),
  487. }
  488. def _sheet_layout(template: Template) -> SheetLayout | None:
  489. if isinstance(template, SheetLayout):
  490. return template
  491. return _SHEET_TEMPLATES.get(template)
  492. def _template_title(template: Template) -> str:
  493. return "custom sheet" if isinstance(template, SheetLayout) else template
  494. def _render_single_label_pdf(
  495. template: TemplateName,
  496. data_list: list[LabelData],
  497. monochrome: bool = False,
  498. fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
  499. ) -> bytes:
  500. w_mm, h_mm = label_size_mm(template)
  501. page_w, page_h = w_mm * mm, h_mm * mm
  502. buf = io.BytesIO()
  503. c = rl_canvas.Canvas(buf, pagesize=(page_w, page_h))
  504. c.setTitle(f"Bambuddy spool labels ({template})")
  505. for data in data_list:
  506. _draw_label(c, 0, 0, page_w, page_h, data, monochrome, fields)
  507. c.showPage()
  508. c.save()
  509. return buf.getvalue()
  510. def label_size_mm(template: Template) -> tuple[float, float]:
  511. """(width, height) of one label of ``template`` — a sheet's cell size."""
  512. if isinstance(template, SheetLayout):
  513. return template.label_w_mm, template.label_h_mm
  514. if template in _SHEET_TEMPLATES:
  515. layout = _SHEET_TEMPLATES[template]
  516. return layout.label_w_mm, layout.label_h_mm
  517. if template in _SINGLE_LABEL_SIZES_MM:
  518. return _SINGLE_LABEL_SIZES_MM[template]
  519. raise ValueError(f"Unknown label template: {template!r}")
  520. def get_sheet_capacity(template: Template) -> int | None:
  521. """Return the number of slots on a sheet template, or ``None`` for roll labels."""
  522. layout = _sheet_layout(template)
  523. return None if layout is None else layout.capacity
  524. def sheet_page_count(template: Template, label_count: int, starting_position: int = 1) -> int | None:
  525. """Pages a sheet template prints ``label_count`` labels on, or ``None`` for
  526. roll labels (one label-sized page each)."""
  527. capacity = get_sheet_capacity(template)
  528. if capacity is None:
  529. return None
  530. return -(-(starting_position - 1 + label_count) // capacity)
  531. def _render_sheet_pdf(
  532. layout: SheetLayout,
  533. title: str,
  534. data_list: list[LabelData],
  535. monochrome: bool,
  536. starting_position: int,
  537. fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
  538. ) -> bytes:
  539. problem = layout.problem()
  540. if problem:
  541. raise ValueError(problem)
  542. page_size = layout.page_size
  543. page_w, page_h = page_size
  544. cols = layout.cols
  545. label_w = layout.label_w_mm * mm
  546. label_h = layout.label_h_mm * mm
  547. top_margin = layout.top_mm * mm
  548. left_margin = layout.left_mm * mm
  549. col_gap = layout.col_gap_mm * mm
  550. row_gap = layout.row_gap_mm * mm
  551. buf = io.BytesIO()
  552. c = rl_canvas.Canvas(buf, pagesize=page_size)
  553. c.setTitle(f"Bambuddy spool labels ({title})")
  554. per_page = layout.capacity
  555. if starting_position < 1 or starting_position > per_page:
  556. raise ValueError(f"Starting position must be between 1 and {per_page} for {title}")
  557. data_index = 0
  558. page_number = 0
  559. while data_index < len(data_list):
  560. slot_offset = starting_position - 1 if page_number == 0 else 0
  561. page_capacity = per_page - slot_offset
  562. chunk = data_list[data_index : data_index + page_capacity]
  563. for idx, data in enumerate(chunk):
  564. slot_index = slot_offset + idx
  565. row = slot_index // cols
  566. col = slot_index % cols
  567. x = left_margin + col * (label_w + col_gap)
  568. y = page_h - top_margin - (row + 1) * label_h - row * row_gap
  569. _draw_label(c, x, y, label_w, label_h, data, monochrome, fields)
  570. c.showPage()
  571. data_index += len(chunk)
  572. page_number += 1
  573. c.save()
  574. return buf.getvalue()
  575. def render_labels(
  576. template: Template,
  577. data_list: list[LabelData],
  578. *,
  579. monochrome: bool = False,
  580. starting_position: int = 1,
  581. fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
  582. ) -> bytes:
  583. """Render ``data_list`` to a PDF using ``template``: a built-in template
  584. name, or a ``SheetLayout`` for a custom sheet (#3296). Returns bytes.
  585. Empty ``data_list`` still produces a valid (empty) PDF — callers should
  586. short-circuit beforehand if that's not desired.
  587. ``monochrome`` drops the colour swatch (which prints as a useless grey block
  588. on black-and-white thermal printers) and reclaims the space for text; the
  589. hex-code line still carries the colour. See #1870.
  590. ``starting_position`` is one-based and applies only to the first page of a
  591. sheet template. Later pages always begin at the first slot.
  592. ``fields`` names the lines to print (#2981); see ``ALL_LABEL_FIELDS``.
  593. """
  594. if isinstance(template, SheetLayout):
  595. return _render_sheet_pdf(template, _template_title(template), data_list, monochrome, starting_position, fields)
  596. if template in _SHEET_TEMPLATES:
  597. return _render_sheet_pdf(_SHEET_TEMPLATES[template], template, data_list, monochrome, starting_position, fields)
  598. if template in _SINGLE_LABEL_SIZES_MM:
  599. if starting_position != 1:
  600. raise ValueError("Starting position is only supported for sheet label templates")
  601. return _render_single_label_pdf(template, data_list, monochrome, fields)
  602. raise ValueError(f"Unknown label template: {template!r}")
  603. def render_label_preview_pdf(
  604. template: Template,
  605. data: LabelData,
  606. *,
  607. monochrome: bool = False,
  608. fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
  609. ) -> bytes:
  610. """One label of ``template`` on a page of its own size.
  611. For a roll template that is the label as printed; for a sheet it is one
  612. cell, which is all the preview needs to show.
  613. """
  614. w_mm, h_mm = label_size_mm(template)
  615. page_w, page_h = w_mm * mm, h_mm * mm
  616. buf = io.BytesIO()
  617. c = rl_canvas.Canvas(buf, pagesize=(page_w, page_h))
  618. _draw_label(c, 0, 0, page_w, page_h, data, monochrome, fields)
  619. c.showPage()
  620. c.save()
  621. return buf.getvalue()
  622. def pdf_to_pngs(pdf: bytes, dpi: int) -> list[bytes]:
  623. """Rasterise every page of ``pdf`` to a PNG at ``dpi``.
  624. Images are drawn without smoothing: the QR is embedded as a bitmap, and
  625. resampling it with interpolation greys the module edges, which is what
  626. makes a small code unreadable on a 203 dpi thermal printer (#1870). The
  627. PNG carries its dpi so label software prints it at the right size.
  628. """
  629. import pypdfium2 as pdfium
  630. from backend.app.services.pdf_thumbnail import _PDFIUM_LOCK
  631. pages: list[bytes] = []
  632. with _PDFIUM_LOCK:
  633. doc = pdfium.PdfDocument(pdf)
  634. try:
  635. for index in range(len(doc)):
  636. page = doc[index]
  637. try:
  638. image = page.render(scale=dpi / 72, no_smoothimage=True).to_pil()
  639. finally:
  640. page.close()
  641. out = io.BytesIO()
  642. image.convert("RGB").save(out, "PNG", dpi=(dpi, dpi), optimize=True)
  643. pages.append(out.getvalue())
  644. finally:
  645. doc.close()
  646. return pages
  647. __all__ = [
  648. "ALL_LABEL_FIELDS",
  649. "DEFAULT_LABEL_FIELDS",
  650. "LabelData",
  651. "LabelField",
  652. "PageSize",
  653. "sheet_page_count",
  654. "SheetLayout",
  655. "Template",
  656. "TemplateName",
  657. "get_sheet_capacity",
  658. "label_size_mm",
  659. "pdf_to_pngs",
  660. "render_label_preview_pdf",
  661. "render_labels",
  662. ]
  663. # white re-exported for completeness; future templates may need a paper-tone variant.
  664. _ = white