93afa3c417
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.
185 lines
6.7 KiB
Python
185 lines
6.7 KiB
Python
"""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())
|