test_label_renderer_fields.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. """Choosing what a spool label shows, and PNG output (#2981).
  2. Text is checked in the content stream of an uncompressed PDF, where ReportLab
  3. writes each string as it was drawn.
  4. """
  5. from __future__ import annotations
  6. import io
  7. import re
  8. from datetime import date
  9. from unittest.mock import patch
  10. import pytest
  11. from PIL import Image
  12. from reportlab.lib.units import mm
  13. from reportlab.pdfgen import canvas as rl_canvas
  14. from backend.app.services.label_renderer import (
  15. ALL_LABEL_FIELDS,
  16. DEFAULT_LABEL_FIELDS,
  17. LabelData,
  18. _draw_label,
  19. label_size_mm,
  20. pdf_to_pngs,
  21. render_label_preview_pdf,
  22. render_labels,
  23. )
  24. ALL = frozenset(ALL_LABEL_FIELDS)
  25. def _data(**overrides) -> LabelData:
  26. values = {
  27. "spool_id": 128,
  28. "name": "Jade White",
  29. "material": "PLA",
  30. "brand": "Bambu Lab",
  31. "subtype": "Matte",
  32. "rgba": "E8F0E0FF",
  33. "storage_location": "Shelf 2",
  34. "deeplink_url": "https://example.test/inventory?spool=128",
  35. "material_number": "MN-15",
  36. "nozzle_temp_min": 190,
  37. "nozzle_temp_max": 230,
  38. "label_weight": 1000,
  39. "note": "Dry first",
  40. "added": date(2026, 9, 30),
  41. }
  42. values.update(overrides)
  43. return LabelData(**values)
  44. def _text(template: str, data: LabelData, fields=DEFAULT_LABEL_FIELDS) -> bytes:
  45. """The page's content stream, i.e. only the text and shapes drawn.
  46. Not the whole file: its creation timestamp, document ID and xref offsets
  47. change from run to run, so a "not in" check against them fails whenever
  48. they happen to contain the digits (a run at 14:32:30 writes ``143230``).
  49. The QR code's image stream is left out for the same reason.
  50. """
  51. w_mm, h_mm = label_size_mm(template)
  52. buf = io.BytesIO()
  53. c = rl_canvas.Canvas(buf, pagesize=(w_mm * mm, h_mm * mm), pageCompression=0)
  54. _draw_label(c, 0, 0, w_mm * mm, h_mm * mm, data, False, fields)
  55. c.showPage()
  56. c.save()
  57. pdf = buf.getvalue()
  58. streams = []
  59. for m in re.finditer(rb"stream\r?\n(.*?)endstream", pdf, re.S):
  60. # The object's own dictionary sits between its "N 0 obj" and "stream".
  61. head = pdf[pdf.rfind(b" obj", 0, m.start()) : m.start()]
  62. if b"/Subtype /Image" not in head:
  63. streams.append(m.group(1))
  64. assert len(streams) == 1, f"expected one page content stream, got {len(streams)}"
  65. return streams[0]
  66. def test_default_fields_are_what_labels_always_carried():
  67. assert {"brand", "material", "hex", "name", "location", "qr", "spool_id"} == DEFAULT_LABEL_FIELDS
  68. def test_default_fields_leave_the_new_lines_off():
  69. pdf = _text("ams_holder_75x55", _data())
  70. for present in (b"Bambu Lab", b"Jade White", b"Shelf 2", b"#E8F0E0", b"#128"):
  71. assert present in pdf
  72. for absent in (b"MN-15", b"190", b"1000 g", b"Dry first", b"2026-09-30"):
  73. assert absent not in pdf
  74. def test_every_field_prints_on_a_label_with_room():
  75. pdf = _text("ams_holder_75x55", _data(), ALL)
  76. for present in (b"MN-15", b"190", b"230", b"1000 g", b"Dry first", b"2026-09-30", b"#128"):
  77. assert present in pdf
  78. def test_a_single_temperature_prints_alone():
  79. pdf = _text("ams_holder_75x55", _data(nozzle_temp_max=None), frozenset({"temps"}))
  80. assert b"190 " in pdf
  81. assert b"230" not in pdf
  82. def test_unticked_lines_are_left_out():
  83. pdf = _text("ams_holder_75x55", _data(), ALL - {"brand", "spool_id", "hex"})
  84. assert b"Bambu Lab" not in pdf
  85. assert b"#128" not in pdf
  86. assert b"#E8F0E0" not in pdf
  87. assert b"Jade White" in pdf
  88. def test_no_qr_without_the_qr_field():
  89. with patch("backend.app.services.label_renderer._draw_qr") as draw_qr:
  90. render_labels("box_40x30", [_data()], fields=ALL - {"qr"})
  91. draw_qr.assert_not_called()
  92. with patch("backend.app.services.label_renderer._draw_qr") as draw_qr:
  93. render_labels("box_40x30", [_data()])
  94. draw_qr.assert_called_once()
  95. def test_lines_that_do_not_fit_stop_above_the_spool_id():
  96. """A 40 x 30 label has no room for all ten lines. The ones that don't fit
  97. are dropped rather than drawn over the spool ID at the bottom."""
  98. drawn: list[tuple[float, str]] = []
  99. real = rl_canvas.Canvas.drawString
  100. def record(self, x, y, text, *args, **kwargs):
  101. drawn.append((y, text))
  102. return real(self, x, y, text, *args, **kwargs)
  103. with patch.object(rl_canvas.Canvas, "drawString", record):
  104. _text("box_40x30", _data(), ALL)
  105. id_y = next(y for y, text in drawn if text == "#128")
  106. other = [y for y, text in drawn if text != "#128"]
  107. assert other, "some lines must still print"
  108. # The ID is 16 pt tall from its baseline; every other baseline sits above it.
  109. assert min(other) >= id_y + 16
  110. assert "2026-09-30" not in [text for _, text in drawn]
  111. def test_name_is_not_repeated_when_it_is_the_subtype():
  112. """A Spoolman filament named after its colour gives subtype == name."""
  113. pdf = _text("ams_holder_75x55", _data(name="Matte"))
  114. assert pdf.count(b"Matte") == 1
  115. def test_name_prints_when_the_material_line_is_off():
  116. pdf = _text("ams_holder_75x55", _data(name="Matte"), DEFAULT_LABEL_FIELDS - {"material"})
  117. assert b"Matte" in pdf
  118. def test_tight_layout_honours_the_fields():
  119. """The small layout only has brand, material, hex and ID; they obey the choice too."""
  120. w, h = 40 * mm, 15 * mm
  121. buf = io.BytesIO()
  122. c = rl_canvas.Canvas(buf, pagesize=(w, h), pageCompression=0)
  123. _draw_label(c, 0, 0, w, h, _data(), False, frozenset({"hex"}))
  124. c.showPage()
  125. c.save()
  126. pdf = buf.getvalue()
  127. assert b"#E8F0E0" in pdf
  128. assert b"Bambu Lab" not in pdf
  129. assert b"#128" not in pdf
  130. def _assert_size(image: Image.Image, w_mm: float, h_mm: float, dpi: int) -> None:
  131. """PDFium rounds a fractional pixel up, so allow one either way."""
  132. expected = (w_mm / 25.4 * dpi, h_mm / 25.4 * dpi)
  133. assert all(abs(got - want) <= 1 for got, want in zip(image.size, expected, strict=True)), (image.size, expected)
  134. @pytest.mark.parametrize(
  135. ("template", "size_mm"),
  136. [("box_40x30", (40.0, 30.0)), ("avery_l7160", (63.5, 38.1)), ("avery_5160", (66.675, 25.4))],
  137. )
  138. def test_label_size_is_the_cell_for_a_sheet(template, size_mm):
  139. assert label_size_mm(template) == size_mm
  140. def test_preview_is_one_label_of_the_templates_size():
  141. """A sheet's preview is one cell, not a whole A4 page."""
  142. pngs = pdf_to_pngs(render_label_preview_pdf("avery_l7160", _data(), fields=ALL), 300)
  143. assert len(pngs) == 1
  144. image = Image.open(io.BytesIO(pngs[0]))
  145. _assert_size(image, 63.5, 38.1, 300)
  146. @pytest.mark.parametrize("dpi", [203, 300, 600])
  147. def test_png_is_printed_at_the_requested_resolution(dpi):
  148. pngs = pdf_to_pngs(render_labels("box_40x30", [_data()]), dpi)
  149. image = Image.open(io.BytesIO(pngs[0]))
  150. assert image.format == "PNG"
  151. _assert_size(image, 40, 30, dpi)
  152. assert tuple(round(v) for v in image.info["dpi"]) == (dpi, dpi)
  153. def test_one_png_per_page():
  154. roll = pdf_to_pngs(render_labels("box_40x30", [_data(spool_id=i) for i in range(1, 4)]), 203)
  155. assert len(roll) == 3
  156. sheets = pdf_to_pngs(render_labels("avery_l7160", [_data(spool_id=i) for i in range(1, 23)]), 72)
  157. assert len(sheets) == 2
  158. def test_qr_stays_black_and_white_in_the_png():
  159. """Rasterised without image smoothing, so the QR's modules keep hard edges
  160. instead of grey fringes a 203 dpi head can't print (#1870)."""
  161. png = pdf_to_pngs(render_label_preview_pdf("box_40x30", _data(), fields=frozenset({"qr"})), 203)[0]
  162. image = Image.open(io.BytesIO(png)).convert("L")
  163. # Only the QR is drawn besides the swatch and hairline border; crop to it.
  164. w, h = image.size
  165. qr_region = image.crop((w // 2, 0, w, h))
  166. greys = sum(1 for v in qr_region.getdata() if 40 < v < 215)
  167. assert greys / (qr_region.width * qr_region.height) < 0.02