mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 12:46:49 +02:00
198 lines
6.9 KiB
Python
198 lines
6.9 KiB
Python
"""Choosing what a spool label shows, and PNG output (#2981).
|
|
|
|
Text is checked in an uncompressed PDF, where ReportLab writes each string as
|
|
it was drawn.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from datetime import date
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
from reportlab.lib.units import mm
|
|
from reportlab.pdfgen import canvas as rl_canvas
|
|
|
|
from backend.app.services.label_renderer import (
|
|
ALL_LABEL_FIELDS,
|
|
DEFAULT_LABEL_FIELDS,
|
|
LabelData,
|
|
_draw_label,
|
|
label_size_mm,
|
|
pdf_to_pngs,
|
|
render_label_preview_pdf,
|
|
render_labels,
|
|
)
|
|
|
|
ALL = frozenset(ALL_LABEL_FIELDS)
|
|
|
|
|
|
def _data(**overrides) -> LabelData:
|
|
values = {
|
|
"spool_id": 128,
|
|
"name": "Jade White",
|
|
"material": "PLA",
|
|
"brand": "Bambu Lab",
|
|
"subtype": "Matte",
|
|
"rgba": "E8F0E0FF",
|
|
"storage_location": "Shelf 2",
|
|
"deeplink_url": "https://example.test/inventory?spool=128",
|
|
"material_number": "MN-15",
|
|
"nozzle_temp_min": 190,
|
|
"nozzle_temp_max": 230,
|
|
"label_weight": 1000,
|
|
"note": "Dry first",
|
|
"added": date(2026, 9, 30),
|
|
}
|
|
values.update(overrides)
|
|
return LabelData(**values)
|
|
|
|
|
|
def _text(template: str, data: LabelData, fields=DEFAULT_LABEL_FIELDS) -> bytes:
|
|
w_mm, h_mm = label_size_mm(template)
|
|
buf = io.BytesIO()
|
|
c = rl_canvas.Canvas(buf, pagesize=(w_mm * mm, h_mm * mm), pageCompression=0)
|
|
_draw_label(c, 0, 0, w_mm * mm, h_mm * mm, data, False, fields)
|
|
c.showPage()
|
|
c.save()
|
|
return buf.getvalue()
|
|
|
|
|
|
def test_default_fields_are_what_labels_always_carried():
|
|
assert {"brand", "material", "hex", "name", "location", "qr", "spool_id"} == DEFAULT_LABEL_FIELDS
|
|
|
|
|
|
def test_default_fields_leave_the_new_lines_off():
|
|
pdf = _text("ams_holder_75x55", _data())
|
|
for present in (b"Bambu Lab", b"Jade White", b"Shelf 2", b"#E8F0E0", b"#128"):
|
|
assert present in pdf
|
|
for absent in (b"MN-15", b"190", b"1000 g", b"Dry first", b"2026-09-30"):
|
|
assert absent not in pdf
|
|
|
|
|
|
def test_every_field_prints_on_a_label_with_room():
|
|
pdf = _text("ams_holder_75x55", _data(), ALL)
|
|
for present in (b"MN-15", b"190", b"230", b"1000 g", b"Dry first", b"2026-09-30", b"#128"):
|
|
assert present in pdf
|
|
|
|
|
|
def test_a_single_temperature_prints_alone():
|
|
pdf = _text("ams_holder_75x55", _data(nozzle_temp_max=None), frozenset({"temps"}))
|
|
assert b"190 " in pdf
|
|
assert b"230" not in pdf
|
|
|
|
|
|
def test_unticked_lines_are_left_out():
|
|
pdf = _text("ams_holder_75x55", _data(), ALL - {"brand", "spool_id", "hex"})
|
|
assert b"Bambu Lab" not in pdf
|
|
assert b"#128" not in pdf
|
|
assert b"#E8F0E0" not in pdf
|
|
assert b"Jade White" in pdf
|
|
|
|
|
|
def test_no_qr_without_the_qr_field():
|
|
with patch("backend.app.services.label_renderer._draw_qr") as draw_qr:
|
|
render_labels("box_40x30", [_data()], fields=ALL - {"qr"})
|
|
draw_qr.assert_not_called()
|
|
with patch("backend.app.services.label_renderer._draw_qr") as draw_qr:
|
|
render_labels("box_40x30", [_data()])
|
|
draw_qr.assert_called_once()
|
|
|
|
|
|
def test_lines_that_do_not_fit_stop_above_the_spool_id():
|
|
"""A 40 x 30 label has no room for all ten lines. The ones that don't fit
|
|
are dropped rather than drawn over the spool ID at the bottom."""
|
|
drawn: list[tuple[float, str]] = []
|
|
real = rl_canvas.Canvas.drawString
|
|
|
|
def record(self, x, y, text, *args, **kwargs):
|
|
drawn.append((y, text))
|
|
return real(self, x, y, text, *args, **kwargs)
|
|
|
|
with patch.object(rl_canvas.Canvas, "drawString", record):
|
|
_text("box_40x30", _data(), ALL)
|
|
|
|
id_y = next(y for y, text in drawn if text == "#128")
|
|
other = [y for y, text in drawn if text != "#128"]
|
|
assert other, "some lines must still print"
|
|
# The ID is 16 pt tall from its baseline; every other baseline sits above it.
|
|
assert min(other) >= id_y + 16
|
|
assert "2026-09-30" not in [text for _, text in drawn]
|
|
|
|
|
|
def test_name_is_not_repeated_when_it_is_the_subtype():
|
|
"""A Spoolman filament named after its colour gives subtype == name."""
|
|
pdf = _text("ams_holder_75x55", _data(name="Matte"))
|
|
assert pdf.count(b"Matte") == 1
|
|
|
|
|
|
def test_name_prints_when_the_material_line_is_off():
|
|
pdf = _text("ams_holder_75x55", _data(name="Matte"), DEFAULT_LABEL_FIELDS - {"material"})
|
|
assert b"Matte" in pdf
|
|
|
|
|
|
def test_tight_layout_honours_the_fields():
|
|
"""The small layout only has brand, material, hex and ID; they obey the choice too."""
|
|
w, h = 40 * mm, 15 * mm
|
|
buf = io.BytesIO()
|
|
c = rl_canvas.Canvas(buf, pagesize=(w, h), pageCompression=0)
|
|
_draw_label(c, 0, 0, w, h, _data(), False, frozenset({"hex"}))
|
|
c.showPage()
|
|
c.save()
|
|
pdf = buf.getvalue()
|
|
assert b"#E8F0E0" in pdf
|
|
assert b"Bambu Lab" not in pdf
|
|
assert b"#128" not in pdf
|
|
|
|
|
|
def _assert_size(image: Image.Image, w_mm: float, h_mm: float, dpi: int) -> None:
|
|
"""PDFium rounds a fractional pixel up, so allow one either way."""
|
|
expected = (w_mm / 25.4 * dpi, h_mm / 25.4 * dpi)
|
|
assert all(abs(got - want) <= 1 for got, want in zip(image.size, expected, strict=True)), (image.size, expected)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "size_mm"),
|
|
[("box_40x30", (40.0, 30.0)), ("avery_l7160", (63.5, 38.1)), ("avery_5160", (66.675, 25.4))],
|
|
)
|
|
def test_label_size_is_the_cell_for_a_sheet(template, size_mm):
|
|
assert label_size_mm(template) == size_mm
|
|
|
|
|
|
def test_preview_is_one_label_of_the_templates_size():
|
|
"""A sheet's preview is one cell, not a whole A4 page."""
|
|
pngs = pdf_to_pngs(render_label_preview_pdf("avery_l7160", _data(), fields=ALL), 300)
|
|
assert len(pngs) == 1
|
|
image = Image.open(io.BytesIO(pngs[0]))
|
|
_assert_size(image, 63.5, 38.1, 300)
|
|
|
|
|
|
@pytest.mark.parametrize("dpi", [203, 300, 600])
|
|
def test_png_is_printed_at_the_requested_resolution(dpi):
|
|
pngs = pdf_to_pngs(render_labels("box_40x30", [_data()]), dpi)
|
|
image = Image.open(io.BytesIO(pngs[0]))
|
|
assert image.format == "PNG"
|
|
_assert_size(image, 40, 30, dpi)
|
|
assert tuple(round(v) for v in image.info["dpi"]) == (dpi, dpi)
|
|
|
|
|
|
def test_one_png_per_page():
|
|
roll = pdf_to_pngs(render_labels("box_40x30", [_data(spool_id=i) for i in range(1, 4)]), 203)
|
|
assert len(roll) == 3
|
|
sheets = pdf_to_pngs(render_labels("avery_l7160", [_data(spool_id=i) for i in range(1, 23)]), 72)
|
|
assert len(sheets) == 2
|
|
|
|
|
|
def test_qr_stays_black_and_white_in_the_png():
|
|
"""Rasterised without image smoothing, so the QR's modules keep hard edges
|
|
instead of grey fringes a 203 dpi head can't print (#1870)."""
|
|
png = pdf_to_pngs(render_label_preview_pdf("box_40x30", _data(), fields=frozenset({"qr"})), 203)[0]
|
|
image = Image.open(io.BytesIO(png)).convert("L")
|
|
# Only the QR is drawn besides the swatch and hairline border; crop to it.
|
|
w, h = image.size
|
|
qr_region = image.crop((w // 2, 0, w, h))
|
|
greys = sum(1 for v in qr_region.getdata() if 40 < v < 215)
|
|
assert greys / (qr_region.width * qr_region.height) < 0.02
|