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:
@@ -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())
|
||||
Reference in New Issue
Block a user