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,177 @@
|
||||
"""
|
||||
Capture the raw print job bytes sent to the PT-E560BT.
|
||||
|
||||
This is ground truth: it shows exactly what reached the printer, independent of
|
||||
SumatraPDF and of what our PDF claims. Comparing our intended geometry against
|
||||
the actual raster tells us whether a discrepancy originates in our PDF, in
|
||||
SumatraPDF, or in the driver/device.
|
||||
|
||||
How it works: the printer is redirected to a FILE port, the job is printed, and
|
||||
the resulting file is inspected. The original port is then restored.
|
||||
|
||||
Usage:
|
||||
python capture_print_job.py # capture one job to ./captures
|
||||
python capture_print_job.py --inspect FILE
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import win32print
|
||||
|
||||
PRINTER = "Brother PT-E560BT"
|
||||
CAPTURE_DIR = "captures"
|
||||
|
||||
|
||||
def get_port(printer: str) -> str:
|
||||
handle = win32print.OpenPrinter(printer)
|
||||
try:
|
||||
return win32print.GetPrinter(handle, 2)["pPortName"]
|
||||
finally:
|
||||
win32print.ClosePrinter(handle)
|
||||
|
||||
|
||||
def set_port(printer: str, port: str) -> None:
|
||||
"""Repoint a printer at a different port. Needs admin rights."""
|
||||
handle = win32print.OpenPrinter(printer, {"DesiredAccess": win32print.PRINTER_ALL_ACCESS})
|
||||
try:
|
||||
info = win32print.GetPrinter(handle, 2)
|
||||
info["pPortName"] = port
|
||||
win32print.SetPrinter(handle, 2, info, 0)
|
||||
finally:
|
||||
win32print.ClosePrinter(handle)
|
||||
|
||||
|
||||
def dump_structure(path: str) -> None:
|
||||
"""Summarise a captured job: size, and any recognisable P-touch commands."""
|
||||
size = os.path.getsize(path)
|
||||
with open(path, "rb") as handle:
|
||||
data = handle.read()
|
||||
|
||||
print(f"\nFile : {path}")
|
||||
print(f"Size : {size} bytes")
|
||||
print(f"First bytes: {data[:32].hex(' ')}")
|
||||
|
||||
# Brother raster printers use an ESC/P-style command set. Recognise the
|
||||
# common ones so the page setup the device received can be read back.
|
||||
known = {
|
||||
b"\x1b@": "ESC @ (initialise)",
|
||||
b"\x1bia": "ESC i a (set media/tape type)",
|
||||
b"\x1biM": "ESC i M (set print mode)",
|
||||
b"\x1biA": "ESC i A (set auto-cut)",
|
||||
b"\x1biK": "ESC i K (set halftone)",
|
||||
b"\x1biD": "ESC i D (set margin)",
|
||||
b"\x1biz": "ESC i z (set page size)",
|
||||
b"\x1biE": "ESC i E (set compression)",
|
||||
b"g": "g (raster graphics transfer)",
|
||||
b"\x1a": "SUB (cut/end)",
|
||||
}
|
||||
print("\nRecognised commands present:")
|
||||
found_any = False
|
||||
for token, label in known.items():
|
||||
if token in data:
|
||||
count = data.count(token)
|
||||
print(f" {label:<32} x{count}")
|
||||
found_any = True
|
||||
if not found_any:
|
||||
print(" (none recognised - job may be compressed or in a different format)")
|
||||
|
||||
# IF the device received an explicit page size (ESC i z), decode it:
|
||||
# ESC i z <len> <valid> <width> <length> <raster lines> ...
|
||||
idx = data.find(b"\x1biz")
|
||||
if idx >= 0:
|
||||
print("\nESC i z page-size command found:")
|
||||
chunk = data[idx: idx + 24]
|
||||
print(f" raw: {chunk.hex(' ')}")
|
||||
if len(chunk) >= 10:
|
||||
# Byte layout: 1b 69 7a <len=10> <valid> <width(2)> <length(2)> <lines(4)>
|
||||
valid = chunk[4]
|
||||
width = int.from_bytes(chunk[5:7], "little")
|
||||
length = int.from_bytes(chunk[7:9], "little")
|
||||
lines = int.from_bytes(chunk[9:13], "little")
|
||||
print(f" valid flags : 0x{valid:02x}")
|
||||
print(f" width : {width} dots = {width / 180 * 25.4:.2f} mm")
|
||||
print(f" length : {length} dots = {length / 180 * 25.4:.2f} mm")
|
||||
print(f" raster lines: {lines} = {lines / 180 * 25.4:.2f} mm")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--inspect", help="inspect an existing capture instead of printing")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.inspect:
|
||||
dump_structure(args.inspect)
|
||||
return 0
|
||||
|
||||
os.makedirs(CAPTURE_DIR, exist_ok=True)
|
||||
target = os.path.abspath(os.path.join(CAPTURE_DIR, "job.prn"))
|
||||
if os.path.exists(target):
|
||||
os.remove(target)
|
||||
|
||||
# Find our most recent label PDF.
|
||||
import glob
|
||||
|
||||
pdfs = sorted(glob.glob("pdf_backup/ptouch_*.pdf"), key=os.path.getmtime)
|
||||
if not pdfs:
|
||||
print("No label PDF found in pdf_backup/")
|
||||
return 1
|
||||
pdf = pdfs[-1]
|
||||
|
||||
original_port = get_port(PRINTER)
|
||||
print(f"Printer : {PRINTER}")
|
||||
print(f"Original : {original_port}")
|
||||
print(f"PDF : {pdf}")
|
||||
print(f"Capture to : {target}")
|
||||
|
||||
if original_port.upper().startswith("FILE:"):
|
||||
print("\nPort is already a FILE: port; capturing directly.")
|
||||
print(f"Check the file it writes to. Set port to FILE:{target} manually if needed.")
|
||||
return 1
|
||||
|
||||
# Create a local port that writes to our file.
|
||||
port_name = rf"FILE:{target}"
|
||||
try:
|
||||
win32print.AddPort(None, port_name)
|
||||
print(f"Created port: {port_name}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"AddPort failed (may already exist): {exc}")
|
||||
|
||||
try:
|
||||
set_port(PRINTER, port_name)
|
||||
print("Redirected printer to capture port.")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"\nCould not redirect the printer: {exc}")
|
||||
print("This step needs administrator rights and a local printer port.")
|
||||
return 1
|
||||
|
||||
try:
|
||||
from print_label import _sumatra_print
|
||||
|
||||
sumatra = os.path.join("SumatraPDF", "SumatraPDF.exe")
|
||||
ok, msg = _sumatra_print(sumatra, PRINTER, pdf, "noscale,paperkind=261")
|
||||
print(f"Print : {'sent' if ok else 'failed'} - {msg}")
|
||||
|
||||
# Give the spooler time to flush to the file.
|
||||
for _ in range(20):
|
||||
time.sleep(0.5)
|
||||
if os.path.exists(target) and os.path.getsize(target) > 0:
|
||||
break
|
||||
finally:
|
||||
set_port(PRINTER, original_port)
|
||||
print(f"Restored port: {original_port}")
|
||||
|
||||
if os.path.exists(target):
|
||||
dump_structure(target)
|
||||
else:
|
||||
print("\nNo capture file was produced.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user