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
+184
View File
@@ -0,0 +1,184 @@
"""Geometry and layout validation for the PT-E560BT 63x20 mm label."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from reportlab.lib.units import mm
from pypdf import PdfReader
from ptouch_label import (
Box,
LabelConfig,
PtouchLabelRenderer,
compute_geometry,
code_density_warning,
)
# Label content size, and the driver's own media size.
TARGET_W = 63.0
TARGET_H = 20.0
DRIVER_MEDIA = (65.0, 24.0)
def main() -> int:
cfg = LabelConfig()
cfg.static_lines = ["MOTO ADV SRL", "Art: CABLU X", "Ver: 2026"]
cfg.qr_prefix = "INV-"
renderer = PtouchLabelRenderer(cfg)
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "test_ptouch.pdf")
path = renderer.render_pdf("INV-00123", out)
# With match_driver_media the page is the driver's media size and the label
# content sits centred inside it. Without it the page equals the content.
# NB: page_size_mm is the PAGE; page_width_mm/page_height_mm are the CONTENT.
content_w, content_h = cfg.page_width_mm, cfg.page_height_mm
ox, oy = cfg.origin_mm
expected_page = DRIVER_MEDIA if cfg.match_driver_media else (TARGET_W, TARGET_H)
page = PdfReader(path).pages[0]
w_mm = float(page.mediabox.width) / mm
h_mm = float(page.mediabox.height) / mm
print(f"PDF page : {w_mm:.3f} x {h_mm:.3f} mm (expected {expected_page[0]} x {expected_page[1]})")
ok_size = (
abs(w_mm - expected_page[0]) < 0.01 and abs(h_mm - expected_page[1]) < 0.01
)
print(f"page size : {'PASS' if ok_size else 'FAIL'}")
# The label content itself must still measure 63 x 20 mm.
content_ok = abs(content_w - TARGET_W) < 0.01 and abs(content_h - TARGET_H) < 0.01
print(f"content : {content_w:.2f} x {content_h:.2f} mm at ({ox:.2f}, {oy:.2f})"
f" {'PASS' if content_ok else 'FAIL'}")
# Content must fit wholly inside the page when offset.
fits = (ox >= -0.01 and oy >= -0.01
and ox + content_w <= w_mm + 0.01 and oy + content_h <= h_mm + 0.01)
print(f"content fits: {'PASS' if fits else 'FAIL'}")
ok_size = ok_size and content_ok and fits
geo = compute_geometry(cfg)
print("\nBoxes (mm, origin bottom-left):")
for key in ("printable", "logo", "text", "code"):
b = geo[key]
print(f" {key:10s} x={b.x:6.2f} y={b.y:6.2f} w={b.w:6.2f} h={b.h:6.2f}")
print(f"\ncol_code_mm : {cfg.col_code_mm:.2f}")
print(f"payload : {cfg.build_code_payload('INV-00123')!r}")
print("\nText lines (printable band = %.2f mm):" % cfg.printable_height_mm)
for ln in geo["lines"]:
print(
f" line {ln['index']} baseline={ln['baseline_mm']:6.2f} "
f"font={ln['font_pt']:4.1f} variable={ln['is_variable']}"
)
# Every line must stay inside the printable band
band_top = cfg.gutter_mm + cfg.printable_height_mm
band_bottom = cfg.gutter_mm
inside = all(
band_bottom - 0.01 <= ln["box"].y
and ln["box"].y + ln["box"].h <= band_top + 0.01
for ln in geo["lines"]
)
print(f"lines in band: {'PASS' if inside else 'FAIL'}")
# Columns must tile the full width without overlap
tiled = abs((cfg.col_logo_mm + cfg.col_text_mm + cfg.col_code_mm) - cfg.page_width_mm) < 1e-9
print(f"columns tile : {'PASS' if tiled else 'FAIL'}")
print("\ndensity :", code_density_warning(cfg, "INV-00123") or "OK (scannable)")
preview = renderer.render_preview("INV-00123", dpi=300)
print(f"preview px : {preview.size} @300dpi")
preview.save(os.path.join(os.path.dirname(out), "test_ptouch_preview.png"))
ok_code = verify_qr_roundtrip(cfg, "INV-00123")
ok_ink = verify_column_ink(preview, cfg)
return 0 if (ok_size and inside and tiled and ok_code and ok_ink) else 1
def verify_qr_roundtrip(cfg, inventory: str) -> bool:
"""Re-read the QR out of the rendered preview and compare to the source.
Downsampling the rendered image back to one pixel per module proves the
symbol survived the dot-aligned scaling and is geometrically intact.
"""
import qrcode
payload = cfg.build_code_payload(inventory)
qr = qrcode.QRCode(border=cfg.qr_border_modules, box_size=1)
qr.add_data(payload)
qr.make(fit=True)
expected = qr.get_matrix()
renderer = PtouchLabelRenderer(cfg)
bitmap = renderer.code_bitmap(inventory)
# Inspect the rendered preview at preview DPI and sample each module centre.
preview_dpi = 300
img = renderer.render_preview(inventory, dpi=preview_dpi)
gray = img.convert("L")
px_per_mm = preview_dpi / 25.4
box = renderer._code_box(cfg, bitmap)
module_mm = box.w / bitmap.modules
ox, oy = cfg.origin_mm
page_h = cfg.page_size_mm[1]
mismatches = 0
for row in range(bitmap.modules):
for col in range(bitmap.modules):
cx = int((ox + box.x + (col + 0.5) * module_mm) * px_per_mm)
# Layout y is measured from the bottom; preview y grows downward.
mm_y = oy + box.y + (bitmap.modules - row - 0.5) * module_mm
cy = int((page_h - mm_y) * px_per_mm)
if cx >= gray.width or cy >= gray.height or cx < 0 or cy < 0:
mismatches += 1
continue
dark = gray.getpixel((cx, cy)) < 128
if dark != bool(expected[row][col]):
mismatches += 1
total = bitmap.modules * bitmap.modules
ok = mismatches == 0
print(
f"qr roundtrip: {'PASS' if ok else 'FAIL'} "
f"({total - mismatches}/{total} modules match, {bitmap.modules} modules, "
f"{bitmap.dots_per_module:.0f} dots/module)"
)
return ok
def verify_column_ink(preview, cfg) -> bool:
"""Confirm each column actually received content, and none is empty."""
dpi = 300
px_per_mm = dpi / 25.4
gray = preview.convert("L")
ox, oy = cfg.origin_mm
def has_ink(x0, x1) -> bool:
# Coordinates are relative to the label content, so shift by the origin.
depth = cfg.page_size_mm[1]
region = gray.crop(
(
int(ox + x0 * px_per_mm),
int((depth - oy - cfg.page_height_mm + cfg.gutter_mm) * px_per_mm),
int(ox + x1 * px_per_mm),
int((depth - oy - cfg.gutter_mm) * px_per_mm),
)
)
# Any pixel meaningfully darker than paper
return region.getextrema()[0] < 200
text_ink = has_ink(cfg.col_logo_mm, cfg.col_logo_mm + cfg.col_text_mm)
code_ink = has_ink(cfg.col_logo_mm + cfg.col_text_mm, cfg.page_width_mm)
print(f"ink in text col: {'PASS' if text_ink else 'FAIL'}")
print(f"ink in code col: {'PASS' if code_ink else 'FAIL'}")
return text_ink and code_ink
if __name__ == "__main__":
raise SystemExit(main())