test_label_renderer_fields.py 6.9 KB

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