Add P-touch Labels for Asset: 63x20 mm label format for PT-E560BT

New three-column label format for Brother P-touch EDGE PT-E560BT asset tags:
logo | three static lines + variable field | QR code.

Layout and rendering
- ptouch_label.py: geometry engine with PDF and preview renderers. Both
  consume the same geometry, so the preview matches the print exactly.
- Code symbols are snapped to whole printer dots per module. Fractional
  modules are the usual reason a small QR code will not scan.
- QR is maximised to the printable height by default (16.4 mm, 4 dots per
  module) instead of a fixed 16 mm box (12.3 mm, 3 dots per module).
- Code payload is composed as prefix + inventory + suffix. A legacy
  {inventory} template is migrated on first save.

Hardware handling
- print_area.py reads the driver's real printable area via GetDeviceCaps.
  The PT-E560BT head is 128 dots at 180 dpi, so the maximum print height is
  18.06 mm even on 24 mm tape; the driver reports media as 65x24 mm but only
  about 61x18 mm is reachable. Content is fitted to the reachable area,
  otherwise the driver silently crops it.
- set_printer_resolution.py reads/switches the driver's resolution mode. The
  driver offers 180x180 (symmetric) and 180x360 (anisotropic, 2x across the
  tape); the anisotropic mode scaled output incorrectly.

GUI
- label_printer_ptouch_gui.py: 600x300 two-column panel - controls on the
  left, live preview on the right. Advanced settings for logo, static lines,
  code prefix/suffix and error correction.
- native_file_dialog.py: the standard Windows file picker, run in a
  subprocess because Tk and Kivy both want the main event loop.

Printing
- Print failures are reported honestly. print_to_printer previously returned
  True unconditionally, so a failed print claimed success and was logged as
  SUCCESS.
- SumatraPDF exit codes are now checked (4 = no printer, 5 = driver error).
- The PDF is sent with noscale so driver-side scaling cannot distort the QR
  module pitch.

Tests
- validate_ptouch_layout.py decodes the QR back out of the rendered preview
  module by module, proving the preview and PDF are the same layout.
- test_ptouch_pipeline.py writes to a temp config so it cannot overwrite
  operator settings.
- test_gui_layout.py exercises the real app class headlessly.
- test_print_reporting.py covers the failure paths.

Diagnostics: check_print_area.py, check_print_geometry.py, check_devmode.py,
print_calibration.py, print_measure_scale.py, capture_print_job.py.
This commit is contained in:
2026-09-25 17:30:11 +03:00
parent 58082ed171
commit 93afa3c417
22 changed files with 3877 additions and 23 deletions
+135
View File
@@ -0,0 +1,135 @@
"""End-to-end test of the P-touch print pipeline (no hardware required).
Exercises config persistence, PDF generation, the "PDF" pseudo-printer path, and
variable-length QR payloads, then verifies the output dimensions.
IMPORTANT: this test writes to a temporary config file. It must never call
save_config() with the default path, because that overwrites the operator's real
settings in label_config.json.
"""
import os
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
os.chdir(os.path.dirname(os.path.abspath(__file__)))
from PIL import Image, ImageDraw
from reportlab.lib.units import mm
from pypdf import PdfReader
import label_config
from print_label import print_ptouch_label
from ptouch_label import LabelConfig, PtouchLabelRenderer, code_density_warning
#: Isolated directory for config written by this test.
TEMP_DIR = tempfile.mkdtemp(prefix="ptouch_test_")
def make_test_logo(path):
"""Create a greyscale logo with transparency to exercise the flatten path."""
img = Image.new("RGBA", (400, 400), (255, 255, 255, 0))
draw = ImageDraw.Draw(img)
draw.ellipse((20, 20, 380, 380), fill=(0, 0, 0, 255))
draw.rectangle((150, 150, 250, 250), fill=(255, 255, 255, 255))
img.save(path)
return path
def main():
base = os.path.dirname(os.path.abspath(__file__))
logo_path = os.path.join(TEMP_DIR, "test_logo.png")
make_test_logo(logo_path)
cfg = LabelConfig()
cfg.logo_path = logo_path
cfg.static_lines = ["MOTO ADV SRL", "Articol: CABLU", "Rev: 2026-09"]
cfg.qr_prefix = "INV-"
cfg.qr_suffix = "-A"
cfg.qr_error_correction = "M"
print("=" * 68)
print("1. Config persistence round-trip (in a temp dir, real config untouched)")
real_cfg_path = label_config.config_path()
real_before = open(real_cfg_path).read() if os.path.exists(real_cfg_path) else None
path = label_config.save_config(cfg, base_dir=TEMP_DIR)
reloaded = label_config.load_config(base_dir=TEMP_DIR)
print(f" saved to : {path}")
print(f" logo preserved: {reloaded.logo_path == cfg.logo_path}")
print(f" lines preserved: {reloaded.static_lines == cfg.static_lines}")
print(f" prefix/suffix kept: "
f"{reloaded.qr_prefix == cfg.qr_prefix and reloaded.qr_suffix == cfg.qr_suffix}")
composed = reloaded.build_code_payload("1234")
print(f" composed payload : {composed!r} "
f"{'PASS' if composed == 'INV-1234-A' else 'FAIL'}")
real_after = open(real_cfg_path).read() if os.path.exists(real_cfg_path) else None
untouched = real_before == real_after
print(f" real config untouched: {'PASS' if untouched else 'FAIL'}")
print("\n2. Unknown/corrupt keys are tolerated")
weird = LabelConfig.from_dict({"static_lines": "not-a-list", "bogus_key": 1})
print(f" static_lines fell back: {weird.static_lines == LabelConfig().static_lines}")
print("\n3. Print pipeline via the 'PDF' pseudo-printer")
success, pdf_path, warning = print_ptouch_label("INV-00042", "PDF", cfg)
print(f" success : {success}")
print(f" pdf : {pdf_path}")
print(f" warning : {warning or 'none'}")
page = PdfReader(pdf_path).pages[0]
w_mm = float(page.mediabox.width) / mm
h_mm = float(page.mediabox.height) / mm
# With match_driver_media the page is the driver's media size and the label
# content is centred inside it; otherwise page size == content size.
want_w, want_h = cfg.page_size_mm
exact = abs(w_mm - want_w) < 0.01 and abs(h_mm - want_h) < 0.01
print(f" page size : {w_mm:.2f} x {h_mm:.2f} mm {'PASS' if exact else 'FAIL'}")
print(f" label content : {cfg.page_width_mm:.1f} x {cfg.page_height_mm:.1f} mm "
f"at origin {cfg.origin_mm}")
print("\n4. QR payload scaling with the inventory value")
renderer = PtouchLabelRenderer(cfg)
for value in ["A", "INV-00042", "MOTO-2026-0001234567"]:
bitmap = renderer.code_bitmap(value)
warn = code_density_warning(cfg, value)
payload = cfg.build_code_payload(value)
print(
f" {value:<22} modules={bitmap.modules:>3} "
f"size={bitmap.width_mm:5.2f}mm dots/mod={bitmap.dots_per_module:.1f} "
f"{'OK' if not warn else 'WARN'}"
)
print(f" payload: {payload!r}")
if warn:
print(f" -> {warn}")
print("\n5. Oversized payload is reported, not silently misprinted")
huge = "X" * 300
warn = code_density_warning(cfg, huge)
print(f" warning present: {warn is not None}")
print(f" -> {warn}")
print("\n6. Preview render (what the GUI shows)")
preview = renderer.render_preview("INV-00042", dpi=300)
print(f" preview size : {preview.size} px @300dpi")
preview.save(os.path.join(base, "test_ptouch_preview.png"))
print(f" saved : test_ptouch_preview.png")
# Tidy up the test artifacts
for temp in (logo_path, pdf_path):
try:
if os.path.exists(temp):
os.remove(temp)
except OSError:
pass
print("\n" + "=" * 68)
passed = exact and success and untouched
print("RESULT:", "PASS" if passed else "FAIL")
return 0 if passed else 1
if __name__ == "__main__":
raise SystemExit(main())