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
+193
View File
@@ -0,0 +1,193 @@
"""
Read or change the PT-E560BT driver's print resolution.
Why this exists: the Brother driver offers two resolution modes
180 x 180 dpi balanced, matches the 128-dot head in both axes
180 x 360 dpi anisotropic - 2x the resolution along the tape feed
In the anisotropic mode the driver reports the media as 24 x 65 mm but rasters
along the tape at 360 dpi, so a page authored for the media dimensions can be
scaled by the wrong factor and printed short. This tool lets the mode be switched
from the command line so the effect can be tested without clicking through the
printer properties dialog.
Changing the resolution modifies a system printer default, so it is reversible:
use --restore, or simply re-run with the other value. Administrator rights are
required; without them the script reports what to change by hand.
Usage:
python set_printer_resolution.py # show current setting
python set_printer_resolution.py --mode 180 # symmetric 180x180
python set_printer_resolution.py --mode 360 # anisotropic 180x360
python set_printer_resolution.py --restore # put back the old value
"""
from __future__ import annotations
import argparse
import json
import os
import sys
PRINTER = "Brother PT-E560BT"
STATE_FILE = ".printer_resolution_backup.json"
# DEVMODE field flags
DM_PRINTQUALITY = 0x00000400
DM_YRESOLUTION = 0x00040000
DM_MODIFY = 0x00000008
DM_IN_BUFFER = 0x00000008
DM_OUT_BUFFER = 0x00000002
def _devmode_field_bits():
return DM_PRINTQUALITY | DM_YRESOLUTION
def current_settings():
"""Return the printer's current resolution-related DEVMODE values."""
import win32print
handle = win32print.OpenPrinter(PRINTER)
try:
info = win32print.GetPrinter(handle, 2)
dm = info["pDevMode"]
return {
"port": info.get("pPortName"),
"paper_size": dm.PaperSize,
"paper_width": dm.PaperWidth,
"paper_length": dm.PaperLength,
"orientation": dm.Orientation,
"print_quality": dm.PrintQuality,
"y_resolution": dm.YResolution,
"fields": dm.Fields,
}
finally:
win32print.ClosePrinter(handle)
def measure_dc():
"""Read the resolution and paper dimensions the driver reports via a DC."""
try:
import win32ui
except ImportError:
return None
try:
dc = win32ui.CreateDC()
dc.CreatePrinterDC(PRINTER)
except Exception as exc: # noqa: BLE001
print(f" Could not create DC: {exc}")
return None
try:
return {
"logpixelsx": dc.GetDeviceCaps(88),
"logpixelsy": dc.GetDeviceCaps(90),
"physicalwidth": dc.GetDeviceCaps(110),
"physicalheight": dc.GetDeviceCaps(111),
"horzres": dc.GetDeviceCaps(8),
"vertres": dc.GetDeviceCaps(10),
}
finally:
try:
dc.DeleteDC()
except Exception: # noqa: BLE001
pass
def show(label=""):
settings = current_settings()
caps = measure_dc()
print(f"\n{label}")
print(f" DEVMODE PrintQuality : {settings['print_quality']}")
print(f" DEVMODE YResolution : {settings['y_resolution']}")
print(f" DEVMODE PaperSize : {settings['paper_size']} "
f"({settings['paper_width']/10:.1f} x {settings['paper_length']/10:.1f} mm)")
if caps:
x, y = caps["logpixelsx"], caps["logpixelsy"]
if x and y:
print(f" DC resolution : {x} x {y} dpi", end="")
if max(x, y) / min(x, y) >= 1.9:
print(" <-- ANISOTROPIC (2x mismatch between axes)")
else:
print(" (symmetric)")
print(f" Physical page : {caps['physicalwidth']} x "
f"{caps['physicalheight']} dots = "
f"{caps['physicalwidth']/max(x,1)*25.4:.2f} x "
f"{caps['physicalheight']/max(y,1)*25.4:.2f} mm")
print(f" Printable area : {caps['horzres']} x {caps['vertres']} dots")
return settings
def apply_mode(mode: str) -> bool:
"""Set the driver resolution mode. mode is '180' or '360'."""
import win32print
y_res = 360 if mode == "360" else 0
try:
handle = win32print.OpenPrinter(
PRINTER, {"DesiredAccess": win32print.PRINTER_ALL_ACCESS}
)
except Exception as exc: # noqa: BLE001
print(f"Cannot open printer for writing: {exc}")
print("Re-run this from an elevated (Administrator) terminal.")
return False
try:
info = win32print.GetPrinter(handle, 2)
dm = info["pDevMode"]
dm.PrintQuality = 180
dm.YResolution = y_res
dm.Fields |= _devmode_field_bits()
info["pDevMode"] = dm
win32print.SetPrinter(handle, 2, info, 0)
print(f"Applied resolution mode {mode} (YResolution={y_res}).")
return True
except Exception as exc: # noqa: BLE001
print(f"Failed to set resolution: {exc}")
print("This normally needs Administrator rights.")
return False
finally:
win32print.ClosePrinter(handle)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices=["180", "360"], help="resolution mode to set")
parser.add_argument("--restore", action="store_true", help="restore the saved value")
args = parser.parse_args()
before = show("BEFORE")
if args.restore:
if not os.path.exists(STATE_FILE):
print(f"\nNo backup found at {STATE_FILE}; nothing to restore.")
return 1
with open(STATE_FILE) as handle:
saved = json.load(handle)
mode = "360" if saved.get("y_resolution") else "180"
print(f"\nRestoring YResolution={saved.get('y_resolution')} (mode {mode})")
if apply_mode(mode):
show("AFTER")
return 0
if not args.mode:
print("\n(no --mode given; nothing changed)")
return 0
# Save the previous value so the change can be undone.
with open(STATE_FILE, "w") as handle:
json.dump(before, handle, indent=2)
print(f"\nSaved previous setting to {STATE_FILE}")
if apply_mode(args.mode):
show("AFTER")
return 0
if __name__ == "__main__":
raise SystemExit(main())