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:
+15
@@ -2,3 +2,18 @@ label/
|
||||
build/
|
||||
logs/
|
||||
pdf_backup/
|
||||
|
||||
# Python caches
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Machine-specific printer state (per-workstation)
|
||||
.printer_resolution_backup.json
|
||||
|
||||
# Operator's own layout settings - holds a local logo path, not portable
|
||||
label_config.json
|
||||
|
||||
# Generated / calibration output
|
||||
captures/
|
||||
*.prn
|
||||
SumatraPDF/SumatraPDF-settings.txt
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# P-touch Labels for Asset
|
||||
|
||||
Barcode label printing for **Brother P-touch EDGE PT-E560BT** asset tags.
|
||||
|
||||
Generates a three-column label sized to the printer's real printable area, with a
|
||||
logo, three lines of fixed text plus a variable field, and a QR code that encodes
|
||||
a composed string. Includes a preview that matches the print exactly, and a
|
||||
Kivy GUI for operators.
|
||||
|
||||
```
|
||||
0 20 mm 45 mm 63 mm
|
||||
+---------+------------+--------+
|
||||
| | static 1 | QR |
|
||||
| LOGO | static 2 | CODE |
|
||||
| 16x16 | static 3 | |
|
||||
| | VARIABLE | |
|
||||
+---------+------------+--------+
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install -r requirements_ptouch.txt
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python label_printer_ptouch_gui.py # operator GUI (Kivy)
|
||||
```
|
||||
|
||||
The original three-row format is still available via `label_printer_gui.py` and
|
||||
is unchanged.
|
||||
|
||||
## Configuration
|
||||
|
||||
Operator settings live in `label_config.json` (not committed - it holds a local
|
||||
logo path). Edit them in **Advanced settings** in the GUI:
|
||||
|
||||
| Setting | Purpose |
|
||||
|---|---|
|
||||
| Logo image | PNG/JPG for the first column, with a thumbnail preview |
|
||||
| Static text lines | The three fixed lines |
|
||||
| Code prefix / suffix | Code payload is `prefix + inventory + suffix` |
|
||||
| QR error correction | `L` (shortest) to `H` (most robust) |
|
||||
|
||||
## Hardware notes
|
||||
|
||||
The PT-E560BT has a **128-dot print head at 180 dpi**, so the maximum print height
|
||||
is **18.06 mm** even on 24 mm tape. The label is therefore fitted to the driver's
|
||||
*printable area* (~61 x 18 mm), read at runtime, rather than to the nominal media
|
||||
size (65 x 24 mm).
|
||||
|
||||
The driver exposes two resolution modes; use the symmetric one:
|
||||
|
||||
```bash
|
||||
python set_printer_resolution.py --mode 180
|
||||
```
|
||||
|
||||
See [documentation/PTOUCH_63x20.md](documentation/PTOUCH_63x20.md) for the full
|
||||
layout, code-composition and dot-alignment details.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `check_print_area.py` | Driver page and printable area (authoritative `GetDeviceCaps`) |
|
||||
| `check_print_geometry.py` | Compares our PDF page against the driver's media |
|
||||
| `check_devmode.py` | Dumps the full driver DEVMODE |
|
||||
| `set_printer_resolution.py` | Read/switch the driver resolution mode |
|
||||
| `print_calibration.py` | Label with ruler marks, for measuring on tape |
|
||||
| `print_measure_scale.py` | Measures the actual X/Y scale factors |
|
||||
| `capture_print_job.py` | Captures raw bytes sent to the printer |
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python validate_ptouch_layout.py # geometry + QR round-trip from rendered pixels
|
||||
python test_ptouch_pipeline.py # config, PDF, print path (uses a temp config)
|
||||
python test_gui_layout.py # GUI wiring, headless
|
||||
python test_print_reporting.py # print failures are reported honestly
|
||||
```
|
||||
|
||||
`validate_ptouch_layout.py` decodes the QR back out of the rendered preview,
|
||||
module by module, which is what proves the preview and the PDF are the same
|
||||
layout and that the symbol survives dot-alignment.
|
||||
@@ -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())
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Dump the full DEVMODE of the PT-E560BT driver.
|
||||
|
||||
The printed length is far shorter than the page we send, which suggests the
|
||||
driver is cropped or scaled by a setting of its own. This prints every field
|
||||
that could cause that, so the behaviour can be explained rather than guessed.
|
||||
"""
|
||||
|
||||
import win32print
|
||||
|
||||
PRINTER = "Brother PT-E560BT"
|
||||
|
||||
ORIENTATION = {1: "portrait", 2: "landscape"}
|
||||
|
||||
FIELDS_OF_INTEREST = [
|
||||
"PaperSize",
|
||||
"PaperLength",
|
||||
"PaperWidth",
|
||||
"Orientation",
|
||||
"Scale",
|
||||
"Copies",
|
||||
"DefaultSource",
|
||||
"PrintQuality",
|
||||
"Color",
|
||||
"Duplex",
|
||||
"TTOption",
|
||||
"Collate",
|
||||
"FormName",
|
||||
"LogPixels",
|
||||
"BitsPerPel",
|
||||
"Nup",
|
||||
"DisplayFlags",
|
||||
"DisplayFrequency",
|
||||
"ICMMethod",
|
||||
"ICMIntent",
|
||||
"MediaType",
|
||||
"DitherType",
|
||||
"Reserved1",
|
||||
"Reserved2",
|
||||
"PanningWidth",
|
||||
"PanningHeight",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
info = win32print.GetPrinter(win32print.OpenPrinter(PRINTER), 2)
|
||||
dm = info.get("pDevMode")
|
||||
|
||||
print(f"Printer : {PRINTER}")
|
||||
print(f"Driver : {info.get('pDriverName')}")
|
||||
print(f"Port : {info.get('pPortName')}")
|
||||
|
||||
print(f"\nDEVMODE size : {dm.Size} bytes")
|
||||
print(f"DriverExtra : {dm.DriverExtra} bytes")
|
||||
print(f"Fields set (bitset): 0x{dm.Fields:x}")
|
||||
|
||||
print("\nFields of interest:")
|
||||
for name in FIELDS_OF_INTEREST:
|
||||
if not hasattr(dm, name):
|
||||
continue
|
||||
try:
|
||||
value = getattr(dm, name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" {name:<18}: <error {exc}>")
|
||||
continue
|
||||
extra = ""
|
||||
if name == "Orientation":
|
||||
extra = f" ({ORIENTATION.get(value, '?')})"
|
||||
elif name in ("PaperLength", "PaperWidth") and isinstance(value, int):
|
||||
extra = f" ({value / 10:.1f} mm)"
|
||||
print(f" {name:<18}: {value}{extra}")
|
||||
|
||||
# Any other integer attributes the struct exposes.
|
||||
print("\nAll remaining numeric fields:")
|
||||
shown = set(FIELDS_OF_INTEREST)
|
||||
for name in sorted(dir(type(dm))):
|
||||
if name.startswith("_") or name in shown:
|
||||
continue
|
||||
try:
|
||||
value = getattr(dm, name)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if isinstance(value, int) and value != 0:
|
||||
print(f" {name:<20}: {value}")
|
||||
|
||||
# Explain the relationship between page, printable area and paper length.
|
||||
print("\nInterpretation:")
|
||||
pl = dm.PaperLength / 10
|
||||
pw = dm.PaperWidth / 10
|
||||
print(f" paper wxh as stored : {pw:.1f} x {pl:.1f} mm "
|
||||
f"(orientation {ORIENTATION.get(dm.Orientation, '?')})")
|
||||
if dm.Orientation == 2:
|
||||
print(f" rotated for printing: {pl:.1f} mm along tape x {pw:.1f} mm across")
|
||||
print(f" dmScale : {dm.Scale}% (100 = no driver scaling)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Measure the PT-E560BT driver's page and printable area.
|
||||
|
||||
Context: the PDF we send is verified to be exactly 65 x 24 mm, but the label
|
||||
prints at roughly 25 x 17 mm. That points at the driver rather than our PDF, so
|
||||
this reads the driver's own coordinate system instead of guessing.
|
||||
|
||||
GetDeviceCaps() is authoritative:
|
||||
HORZSIZE / VERTSIZE -> physical page size, in millimetres
|
||||
HORZRES / VERTRES -> printable area, in device dots
|
||||
PHYSICALWIDTH / HEIGHT -> full page, in device dots
|
||||
PHYSICALOFFSETX / Y -> where the printable area starts (unprintable margin)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import win32ui
|
||||
|
||||
PRINTER = "Brother PT-E560BT"
|
||||
CONTROL = "Microsoft Print to PDF" # control: a normal printer, to sanity-check the method
|
||||
|
||||
HORZSIZE, VERTSIZE = 4, 6
|
||||
HORZRES, VERTRES = 8, 10
|
||||
LOGPIXELSX, LOGPIXELSY = 88, 90
|
||||
PHYSICALWIDTH, PHYSICALHEIGHT = 110, 111
|
||||
PHYSICALOFFSETX, PHYSICALOFFSETY = 112, 113
|
||||
|
||||
CAP_INDICES = {
|
||||
"HORZSIZE": HORZSIZE,
|
||||
"VERTSIZE": VERTSIZE,
|
||||
"HORZRES": HORZRES,
|
||||
"VERTRES": VERTRES,
|
||||
"LOGPIXELSX": LOGPIXELSX,
|
||||
"LOGPIXELSY": LOGPIXELSY,
|
||||
"PHYSICALWIDTH": PHYSICALWIDTH,
|
||||
"PHYSICALHEIGHT": PHYSICALHEIGHT,
|
||||
"PHYSICALOFFSETX": PHYSICALOFFSETX,
|
||||
"PHYSICALOFFSETY": PHYSICALOFFSETY,
|
||||
}
|
||||
|
||||
# What we intend to print (our PDF page, and the label content inside it).
|
||||
PAGE_MM = (65.0, 24.0)
|
||||
CONTENT_MM = (63.0, 20.0)
|
||||
|
||||
|
||||
def dots_to_mm(dots, dpi):
|
||||
return dots / dpi * 25.4 if dpi else 0.0
|
||||
|
||||
|
||||
def measure(printer, devmode=None):
|
||||
"""Return the driver's caps dict, or None if the DC could not be created."""
|
||||
try:
|
||||
hdc = win32ui.CreateDC()
|
||||
hdc.CreatePrinterDC(printer)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" CreatePrinterDC failed: {exc}")
|
||||
return None
|
||||
|
||||
try:
|
||||
caps = {name: hdc.GetDeviceCaps(idx) for name, idx in CAP_INDICES.items()}
|
||||
finally:
|
||||
try:
|
||||
hdc.DeleteDC()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
dpix = caps["LOGPIXELSX"] or 1
|
||||
dpiy = caps["LOGPIXELSY"] or 1
|
||||
|
||||
print(f"\n{printer}")
|
||||
print(f" resolution : {dpix} x {dpiy} dpi")
|
||||
print(f" physical page : {caps['HORZSIZE']} x {caps['VERTSIZE']} mm")
|
||||
print(
|
||||
f" printable area : {caps['HORZRES']} x {caps['VERTRES']} dots = "
|
||||
f"{dots_to_mm(caps['HORZRES'], dpix):.2f} x {dots_to_mm(caps['VERTRES'], dpiy):.2f} mm"
|
||||
)
|
||||
print(
|
||||
f" physical (dots) : {caps['PHYSICALWIDTH']} x {caps['PHYSICALHEIGHT']} = "
|
||||
f"{dots_to_mm(caps['PHYSICALWIDTH'], dpix):.2f} x "
|
||||
f"{dots_to_mm(caps['PHYSICALHEIGHT'], dpiy):.2f} mm"
|
||||
)
|
||||
print(
|
||||
f" printable origin : {caps['PHYSICALOFFSETX']} x {caps['PHYSICALOFFSETY']} dots = "
|
||||
f"{dots_to_mm(caps['PHYSICALOFFSETX'], dpix):.2f} x "
|
||||
f"{dots_to_mm(caps['PHYSICALOFFSETY'], dpiy):.2f} mm"
|
||||
)
|
||||
return caps
|
||||
|
||||
|
||||
def verdict(caps):
|
||||
"""Explain whether the driver's page matches what we send."""
|
||||
dpix = caps["LOGPIXELSX"] or 1
|
||||
dpiy = caps["LOGPIXELSY"] or 1
|
||||
w = dots_to_mm(caps["PHYSICALWIDTH"], dpix)
|
||||
h = dots_to_mm(caps["PHYSICALHEIGHT"], dpiy)
|
||||
|
||||
print(f"\n VERDICT for {PAGE_MM[0]:.0f} x {PAGE_MM[1]:.0f} mm page:")
|
||||
if abs(w - PAGE_MM[0]) < 1.0 and abs(h - PAGE_MM[1]) < 1.0:
|
||||
print(" driver page matches our PDF -> driver has nothing to rescale")
|
||||
else:
|
||||
print(f" driver page is {w:.2f} x {h:.2f} mm, NOT {PAGE_MM[0]} x {PAGE_MM[1]} mm")
|
||||
print(f" implied scale: x{PAGE_MM[0] / w:.3f} by y{PAGE_MM[1] / h:.3f}")
|
||||
|
||||
# Distinguish 'fit' (uniform) from 'stretch' (non-uniform).
|
||||
sx, sy = PAGE_MM[0] / w, PAGE_MM[1] / h
|
||||
if abs(sx - sy) < 0.02:
|
||||
print(" uniform scale -> driver is using 'fit'")
|
||||
else:
|
||||
print(" NON-UNIFORM scale -> driver is using 'stretch'; this distorts")
|
||||
|
||||
# Does the reported printable area match the 25 x 17 mm that actually printed?
|
||||
pw = dots_to_mm(caps["HORZRES"], dpix)
|
||||
ph = dots_to_mm(caps["VERTRES"], dpiy)
|
||||
print(f" printable area {pw:.2f} x {ph:.2f} mm")
|
||||
if abs(pw - 25.0) < 3.0 or abs(ph - 17.0) < 3.0:
|
||||
print(" ^ this is close to the 25 x 17 mm actually observed on tape")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 72)
|
||||
print("DRIVER PRINT AREA")
|
||||
print("=" * 72)
|
||||
print(f"Our PDF page : {PAGE_MM[0]:.1f} x {PAGE_MM[1]:.1f} mm")
|
||||
print(f"Label content : {CONTENT_MM[0]:.1f} x {CONTENT_MM[1]:.1f} mm")
|
||||
|
||||
caps = measure(PRINTER)
|
||||
if caps is None:
|
||||
return 1
|
||||
verdict(caps)
|
||||
|
||||
print("\n" + "-" * 72)
|
||||
print("CONTROL (method sanity check)")
|
||||
print("-" * 72)
|
||||
measure(CONTROL)
|
||||
|
||||
print("\n" + "=" * 72)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Compare the old and new page geometry against the driver's real media.
|
||||
|
||||
The PT-E560BT driver reports 24 x 65 mm (landscape). This shows what the driver
|
||||
receives in each configuration and whether it has to rescale or rotate.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from label_config import load_config
|
||||
from ptouch_label import LabelConfig, PtouchLabelRenderer, compute_geometry
|
||||
from reportlab.lib.units import mm
|
||||
from pypdf import PdfReader
|
||||
|
||||
# What the installed driver advertises (queried earlier from the Windows spooler).
|
||||
DRIVER_W, DRIVER_H = 65.0, 24.0
|
||||
|
||||
|
||||
def describe(label, cfg, inventory="INV-1"):
|
||||
renderer = PtouchLabelRenderer(cfg)
|
||||
path = os.path.join("pdf_backup", f"_geom_{label}.pdf")
|
||||
os.makedirs("pdf_backup", exist_ok=True)
|
||||
renderer.render_pdf(inventory, path)
|
||||
|
||||
page = PdfReader(path).pages[0]
|
||||
pw = float(page.mediabox.width) / mm
|
||||
ph = float(page.mediabox.height) / mm
|
||||
ox, oy = cfg.origin_mm
|
||||
|
||||
print(f"\n--- {label} ---")
|
||||
print(f" match_driver_media : {cfg.match_driver_media}")
|
||||
print(f" PDF page sent : {pw:.2f} x {ph:.2f} mm")
|
||||
print(f" label content : {cfg.page_width_mm:.2f} x {cfg.page_height_mm:.2f} mm")
|
||||
print(f" content origin : ({ox:.2f}, {oy:.2f}) mm")
|
||||
|
||||
page_rotated = abs(pw - DRIVER_H) < 0.5 and abs(ph - DRIVER_W) < 0.5
|
||||
exact_match = abs(pw - DRIVER_W) < 0.5 and abs(ph - DRIVER_H) < 0.5
|
||||
|
||||
print(f" driver media : {DRIVER_W:.2f} x {DRIVER_H:.2f} mm")
|
||||
if exact_match:
|
||||
print(" driver action : NONE (exact size match)")
|
||||
elif page_rotated:
|
||||
print(" driver action : rotate 90 deg, then fit")
|
||||
else:
|
||||
print(" driver action : RESCALE + reposition (distorts geometry)")
|
||||
|
||||
x_scale = DRIVER_W / pw if not exact_match else 1.0
|
||||
y_scale = DRIVER_H / ph if not exact_match else 1.0
|
||||
if not exact_match:
|
||||
print(f" implied scale : x{x_scale:.3f} by y{y_scale:.3f}")
|
||||
|
||||
os.remove(path)
|
||||
return exact_match
|
||||
|
||||
|
||||
def main() -> int:
|
||||
saved = load_config()
|
||||
# Use the saved settings so this reflects what will really be printed.
|
||||
print("=" * 70)
|
||||
print("PRINT GEOMETRY CHECK")
|
||||
print("=" * 70)
|
||||
print(f"Saved settings: match_driver_media={saved.match_driver_media}")
|
||||
print(f" logo_path={saved.logo_path or '(none)'}")
|
||||
print(f" static_lines={saved.static_lines}")
|
||||
|
||||
old = LabelConfig()
|
||||
old.match_driver_media = False
|
||||
old_ok = describe("OLD (page 63x20, no media match)", old)
|
||||
|
||||
new = saved
|
||||
new.match_driver_media = True
|
||||
new_ok = describe("NEW (page 65x24 matching driver media)", new)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(f"OLD config needs driver rescaling : {not old_ok}")
|
||||
print(f"NEW config is an exact size match : {new_ok}")
|
||||
print("=" * 70)
|
||||
|
||||
if new_ok:
|
||||
cfg = saved
|
||||
geo = compute_geometry(cfg)
|
||||
ox, oy = cfg.origin_mm
|
||||
print("\nContent placement on the 65 x 24 mm page:")
|
||||
print(f" label occupies x = {ox:.2f} .. {ox + cfg.page_width_mm:.2f} mm")
|
||||
print(f" y = {oy:.2f} .. {oy + cfg.page_height_mm:.2f} mm")
|
||||
print(f" slack left/right : {ox:.2f} mm each side")
|
||||
print(f" slack top/bottom : {oy:.2f} mm each side")
|
||||
print(f" expected printed : {cfg.page_width_mm:.0f} wide x "
|
||||
f"{cfg.page_height_mm:.0f} mm long")
|
||||
|
||||
return 0 if new_ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,160 @@
|
||||
# PT-E560BT Label Format (63 x 20 mm)
|
||||
|
||||
Three-column label layout for the Brother P-touch EDGE **PT-E560BT**.
|
||||
|
||||
```
|
||||
0 20 mm 45 mm 63 mm
|
||||
+---------+------------+--------+ 0
|
||||
| | static 1 | Q R |
|
||||
| LOGO | static 2 | C O | 1 mm non-printable gutter
|
||||
| 16x16 | static 3 | D E |
|
||||
| | VARIABLE | |
|
||||
+---------+------------+--------+ 20 mm
|
||||
20 mm 25 mm 18 mm
|
||||
```
|
||||
|
||||
## Interface
|
||||
|
||||
Run the new GUI:
|
||||
|
||||
```bash
|
||||
python label_printer_ptouch_gui.py
|
||||
```
|
||||
|
||||
| Element | Purpose |
|
||||
|---|---|
|
||||
| **Inventory number** | The single variable value per label. Becomes the 4th text line **and** is embedded in the QR code. |
|
||||
| **PRINT LABEL** | Generates the PDF and sends it to the selected printer. |
|
||||
| **Preview area** | Renders the label at true print proportions. Updates as you type. |
|
||||
| **Advanced settings** | Logo image, the three static text lines, QR prefix/suffix, QR error correction. Saved to `label_config.json`. |
|
||||
|
||||
The old three-row format is still available via `label_printer_gui.py` and is
|
||||
unchanged.
|
||||
|
||||
## Hardware constraints
|
||||
|
||||
Verified against Brother's published specifications *and* the installed Windows
|
||||
driver (via `GetDeviceCaps`), which agree:
|
||||
|
||||
| Spec | Value | Consequence |
|
||||
|---|---|---|
|
||||
| Print resolution | **180 dpi**, 128-dot head | 128 / 180 x 25.4 = **18.06 mm** is the hard height limit |
|
||||
| Max print height | **18.0 mm**, only with 24 mm tape | A 20 mm design cannot fit; it is not a margin setting |
|
||||
| Max tape width | 24 mm | **24 mm tape required** |
|
||||
|
||||
Because the head reaches only ~18 mm of the 24 mm tape, ~3 mm is unused on each
|
||||
side. The label content is sized to the driver's **printable area**, read at
|
||||
runtime by `print_area.py`, rather than to the nominal media size.
|
||||
|
||||
### Driver resolution modes
|
||||
|
||||
The Brother driver offers two modes, and the choice matters:
|
||||
|
||||
| Mode | xdpi | ydpi | Notes |
|
||||
|---|---|---|---|
|
||||
| 1 | 180 | 180 | symmetric - use this |
|
||||
| 2 | 180 | 360 | **anisotropic**, 2x across the tape |
|
||||
|
||||
In the anisotropic mode the driver reports twice the horizontal dots, and a page
|
||||
authored in millimetres can be scaled by the wrong factor and print short.
|
||||
`set_printer_resolution.py` reads and switches this setting:
|
||||
|
||||
```bash
|
||||
python set_printer_resolution.py # show current mode
|
||||
python set_printer_resolution.py --mode 180 # symmetric (recommended)
|
||||
python set_printer_resolution.py --restore # undo
|
||||
```
|
||||
|
||||
## Why the code is dot-aligned
|
||||
|
||||
A thermal head cannot print a fraction of a dot. If a QR symbol is scaled to an
|
||||
arbitrary size, its modules end up unevenly sized (some 3 dots wide, some 4),
|
||||
which is the usual reason a small QR code will not scan.
|
||||
|
||||
`build_code_bitmap()` snaps the symbol to an integer number of printer dots per
|
||||
module and returns the resulting exact physical size. At 16 mm available and 29
|
||||
modules (a typical short payload), that is **3 dots per module = 12.28 mm**,
|
||||
which scans comfortably.
|
||||
|
||||
`code_density_warning()` estimates density for the current payload and the GUI
|
||||
shows a warning when the code becomes too dense to be reliable, instead of
|
||||
silently printing an unscannable label.
|
||||
|
||||
## Code payload
|
||||
|
||||
The code is composed as **fixed prefix + inventory + fixed suffix**, configured in
|
||||
Advanced settings:
|
||||
|
||||
| Prefix | Inventory | Suffix | Encoded |
|
||||
|---|---|---|---|
|
||||
| `INV-` | `1234` | *(empty)* | `INV-1234` |
|
||||
| `INV-` | `1234` | `-A` | `INV-1234-A` |
|
||||
| *(empty)* | `1234` | *(empty)* | `1234` |
|
||||
|
||||
The `Encodes:` line in Advanced settings shows the exact payload live as you type,
|
||||
so the result can be confirmed before printing.
|
||||
|
||||
A legacy `{inventory}` template (e.g. `INV:{inventory}`) is still accepted and is
|
||||
migrated to prefix/suffix on first save, so older config files keep working.
|
||||
|
||||
**Keep the payload short.** The QR is maximised to fill the printable height
|
||||
(~16.4 mm at 29 modules), which gives **4 printer dots per module** for a short
|
||||
payload. Longer strings raise the QR version, reducing dots per module; the GUI
|
||||
warns before printing a code that would be too dense to scan reliably. Error
|
||||
correction `L` allows a longer string than `M`/`Q`/`H` at the same physical size.
|
||||
|
||||
## Maximising the QR
|
||||
|
||||
By default `qr_maximize` is enabled: the code column takes the full printable
|
||||
height so the symbol is as large as the hardware allows, and the text column
|
||||
shrinks to make room (never below `MIN_TEXT_COL_MM`, so the three static lines
|
||||
keep fitting).
|
||||
|
||||
| Setting | QR size | Dots/module |
|
||||
|---|---|---|
|
||||
| `qr_maximize: false` (16 mm box) | 12.28 mm | 3 |
|
||||
| `qr_maximize: true` (default) | 16.37 mm | 4 |
|
||||
|
||||
The symbol is flush to the right edge of the printable area when maximised. If
|
||||
your unit feeds the tape slightly off-centre, reduce `qr_box_mm` and set
|
||||
`qr_maximize: false` to leave clearance.
|
||||
|
||||
## Logo
|
||||
|
||||
- Formats: PNG, JPG, JPEG, BMP, GIF. Greyscale and alpha are handled (transparent
|
||||
areas are flattened to white).
|
||||
- The logo is scaled to fit the 16 x 16 mm cell preserving aspect ratio.
|
||||
- Thermal transfer printing is 1-bit, so logos should be **black-and-white line
|
||||
art**. Photographs dither poorly. `logo_dither` controls this.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `ptouch_label.py` | Layout engine: geometry, code generation, PDF + preview renderers |
|
||||
| `label_config.py` | Settings persistence (`label_config.json`) |
|
||||
| `label_printer_ptouch_gui.py` | Kivy GUI (this format) |
|
||||
| `print_label.py` | Printer discovery and silent printing (extended) |
|
||||
| `validate_ptouch_layout.py` | Geometry + QR round-trip validation |
|
||||
| `test_ptouch_pipeline.py` | End-to-end test (no hardware needed) |
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python validate_ptouch_layout.py # page size, boxes, QR round-trip
|
||||
python test_ptouch_pipeline.py # config, PDF, printing, QR scaling
|
||||
```
|
||||
|
||||
`validate_ptouch_layout.py` re-reads the rendered QR symbol out of the preview
|
||||
image, module by module, and compares it against the source matrix. That is what
|
||||
proves the preview and the PDF are the same layout, and that the symbol is
|
||||
geometrically intact after dot-alignment.
|
||||
|
||||
## Printing notes
|
||||
|
||||
Only SumatraPDF printing honours the scale setting. The PDF is sent with
|
||||
`-print-settings noscale` because driver-side scaling ("fit to page") would
|
||||
distort the QR module pitch and break scanning.
|
||||
|
||||
In the printer driver, set the media to the **63 x 20 mm** continuous tape and
|
||||
disable any auto-rotate. The TZe tape must be **24 mm**.
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Persistence for the PT-E560BT label layout settings.
|
||||
|
||||
Stored as JSON next to the application so the operator's logo, static text and
|
||||
QR template survive restarts. Kept deliberately tolerant: a missing or corrupt
|
||||
file silently yields defaults rather than blocking printing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from ptouch_label import LabelConfig
|
||||
|
||||
CONFIG_FILENAME = "label_config.json"
|
||||
|
||||
|
||||
def config_path(base_dir: str | None = None) -> str:
|
||||
"""Absolute path to the settings file."""
|
||||
directory = base_dir or os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(directory, CONFIG_FILENAME)
|
||||
|
||||
|
||||
def load_config(base_dir: str | None = None) -> LabelConfig:
|
||||
"""Load settings, falling back to defaults when absent or unreadable."""
|
||||
path = config_path(base_dir)
|
||||
if not os.path.exists(path):
|
||||
return LabelConfig()
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
return LabelConfig.from_dict(json.load(handle))
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f"Could not read {CONFIG_FILENAME} ({exc}); using defaults.")
|
||||
return LabelConfig()
|
||||
|
||||
|
||||
def save_config(config: LabelConfig, base_dir: str | None = None) -> str:
|
||||
"""Write settings to disk and return the path written."""
|
||||
path = config_path(base_dir)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(config.to_dict(), handle, indent=2, ensure_ascii=False)
|
||||
return path
|
||||
@@ -0,0 +1,850 @@
|
||||
"""
|
||||
Kivy GUI for the Brother P-touch EDGE PT-E560BT (63 x 20 mm labels).
|
||||
|
||||
This is a new entry point alongside the original `label_printer_gui.py`, which
|
||||
implements the older three-row format and is left untouched.
|
||||
|
||||
Window is a landscape 600 x 300 panel split into two columns:
|
||||
|
||||
+-----------------------------+---------------------------------+
|
||||
| CONTROLS | LABEL PREVIEW |
|
||||
| Inventory number | (63 x 20 mm true proportions) |
|
||||
| [___________________] | |
|
||||
| Printer | |
|
||||
| [Brother PT-E560BT v] | |
|
||||
| [ PRINT LABEL ] | |
|
||||
| [ Advanced settings ] | |
|
||||
+-----------------------------+---------------------------------+
|
||||
|
||||
The preview is produced by the same layout engine as the PDF, so the panel is a
|
||||
faithful representation of the printed result rather than an approximation.
|
||||
"""
|
||||
|
||||
from kivy.config import Config
|
||||
|
||||
# Window geometry must be set through Config before the window is created;
|
||||
# assigning Window.size afterwards is ignored by the SDL2 backend on Windows.
|
||||
WINDOW_SIZE = (600, 300)
|
||||
WINDOW_MIN = (520, 280)
|
||||
Config.set("graphics", "width", str(WINDOW_SIZE[0]))
|
||||
Config.set("graphics", "height", str(WINDOW_SIZE[1]))
|
||||
Config.set("graphics", "minimum_width", str(WINDOW_MIN[0]))
|
||||
Config.set("graphics", "minimum_height", str(WINDOW_MIN[1]))
|
||||
|
||||
from kivy.app import App
|
||||
from kivy.clock import Clock
|
||||
from kivy.core.image import Image as CoreImage
|
||||
from kivy.core.window import Window
|
||||
from kivy.graphics import Color, Line, Rectangle
|
||||
from kivy.metrics import dp
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.scrollview import ScrollView
|
||||
from kivy.uix.spinner import Spinner
|
||||
from kivy.uix.textinput import TextInput
|
||||
|
||||
import datetime
|
||||
import io
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from label_config import load_config, save_config
|
||||
from print_area import apply_to_config as apply_print_area, query_print_area
|
||||
from print_label import get_available_printers, print_ptouch_label
|
||||
from ptouch_label import PtouchLabelRenderer, code_density_warning
|
||||
|
||||
|
||||
def _printed_logo_size(config, source_size) -> tuple[float, float]:
|
||||
"""Size the logo will occupy on the label, in mm, preserving aspect ratio.
|
||||
|
||||
The logo is fitted inside the logo cell, so this reports the real printed
|
||||
footprint rather than the source pixel dimensions.
|
||||
"""
|
||||
src_w, src_h = source_size
|
||||
box_w = config.logo_box_mm
|
||||
box_h = config.logo_box_mm
|
||||
if not src_w or not src_h:
|
||||
return (0.0, 0.0)
|
||||
ratio = min(box_w / src_w, box_h / src_h)
|
||||
return (src_w * ratio, src_h * ratio)
|
||||
|
||||
|
||||
class LogoThumb(BoxLayout):
|
||||
"""Small preview of the selected logo, or a placeholder when none is set."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.size_hint_x = None
|
||||
self.width = dp(48)
|
||||
self._texture = None
|
||||
with self.canvas.before:
|
||||
Color(0.9, 0.9, 0.92, 1)
|
||||
self._bg = Rectangle(pos=self.pos, size=self.size)
|
||||
self.bind(pos=self._redraw, size=self._redraw)
|
||||
|
||||
def set_path(self, path):
|
||||
"""Load a thumbnail from a file, or clear it when the path is empty."""
|
||||
self._texture = None
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(path)
|
||||
img.load()
|
||||
img = img.convert("RGBA")
|
||||
background = Image.new("RGBA", img.size, (255, 255, 255, 255))
|
||||
img = Image.alpha_composite(background, img).convert("RGB")
|
||||
img.thumbnail((128, 128))
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
self._texture = CoreImage(buffer, ext="png").texture
|
||||
except Exception as exc: # noqa: BLE001 - a bad file must not break the UI
|
||||
print(f"Logo thumbnail error: {exc}")
|
||||
self._redraw()
|
||||
|
||||
def _redraw(self, *_args):
|
||||
self._bg.pos = self.pos
|
||||
self._bg.size = self.size
|
||||
self.canvas.remove_group("thumb")
|
||||
if self._texture is None:
|
||||
return
|
||||
|
||||
available_w = max(1, self.width - dp(8))
|
||||
available_h = max(1, self.height - dp(8))
|
||||
scale = min(
|
||||
available_w / self._texture.width, available_h / self._texture.height
|
||||
)
|
||||
draw_w = max(1, self._texture.width * scale)
|
||||
draw_h = max(1, self._texture.height * scale)
|
||||
|
||||
with self.canvas:
|
||||
Color(1, 1, 1, 1, group="thumb")
|
||||
Rectangle(
|
||||
texture=self._texture,
|
||||
pos=(self.center_x - draw_w / 2, self.center_y - draw_h / 2),
|
||||
size=(draw_w, draw_h),
|
||||
group="thumb",
|
||||
)
|
||||
|
||||
|
||||
class PreviewPanel(BoxLayout):
|
||||
"""Displays the rendered label on a dark card, scaled to fit."""
|
||||
|
||||
CARD_BG = (0.13, 0.13, 0.15, 1)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._texture = None
|
||||
with self.canvas.before:
|
||||
Color(*self.CARD_BG)
|
||||
self._bg = Rectangle(pos=self.pos, size=self.size)
|
||||
self.bind(pos=self._on_geometry, size=self._on_geometry)
|
||||
|
||||
def _on_geometry(self, *_args):
|
||||
self._bg.pos = self.pos
|
||||
self._bg.size = self.size
|
||||
# Re-place the image so it stays centred when the window is resized.
|
||||
self._redraw()
|
||||
|
||||
def show_image(self, pil_image):
|
||||
"""Store a PIL image as a texture and draw it."""
|
||||
buffer = io.BytesIO()
|
||||
pil_image.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
self._texture = CoreImage(buffer, ext="png").texture
|
||||
self._redraw()
|
||||
|
||||
def _redraw(self):
|
||||
"""Draw the stored texture centred and fitted inside the panel."""
|
||||
self.canvas.remove_group("preview")
|
||||
if self._texture is None:
|
||||
return
|
||||
|
||||
available_w = max(1, self.width - dp(16))
|
||||
available_h = max(1, self.height - dp(16))
|
||||
scale = min(
|
||||
available_w / self._texture.width, available_h / self._texture.height
|
||||
)
|
||||
draw_w = max(1, self._texture.width * scale)
|
||||
draw_h = max(1, self._texture.height * scale)
|
||||
x = self.center_x - draw_w / 2
|
||||
y = self.center_y - draw_h / 2
|
||||
|
||||
with self.canvas:
|
||||
Color(1, 1, 1, 1, group="preview")
|
||||
Rectangle(
|
||||
texture=self._texture,
|
||||
pos=(x, y),
|
||||
size=(draw_w, draw_h),
|
||||
group="preview",
|
||||
)
|
||||
# Hairline outline makes the label edge visible against the card.
|
||||
Color(0.45, 0.45, 0.5, 1, group="preview")
|
||||
Line(
|
||||
rectangle=(x, y, draw_w, draw_h),
|
||||
width=dp(1),
|
||||
group="preview",
|
||||
)
|
||||
|
||||
|
||||
class PtouchPrinterApp(App):
|
||||
"""P-touch label printing front-end."""
|
||||
|
||||
PREVIEW_DPI = 300
|
||||
PREVIEW_DEBOUNCE_S = 0.35
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.label_cfg = load_config()
|
||||
self._preview_event = None
|
||||
|
||||
full_printers = get_available_printers()
|
||||
self.printer_display_map = {}
|
||||
self.available_printers = []
|
||||
for full_name in full_printers:
|
||||
display_name = self._shorten_printer_name(full_name)
|
||||
if display_name in self.printer_display_map:
|
||||
display_name = full_name[:20]
|
||||
self.printer_display_map[display_name] = full_name
|
||||
self.available_printers.append(display_name)
|
||||
|
||||
self.cleanup_old_pdfs()
|
||||
self.cleanup_old_logs()
|
||||
|
||||
# -- helpers ----------------------------------------------------------
|
||||
|
||||
def _shorten_printer_name(self, name, max_len=22):
|
||||
"""Shorten a printer name for the dropdown, keeping it unique."""
|
||||
if name.startswith("\\\\"):
|
||||
parts = name.strip("\\").split("\\")
|
||||
short = parts[-1] if len(parts) >= 2 else name
|
||||
else:
|
||||
short = name
|
||||
return short[:max_len]
|
||||
|
||||
def _get_full_printer_name(self, display_name):
|
||||
return self.printer_display_map.get(display_name, display_name)
|
||||
|
||||
def cleanup_old_pdfs(self, days=5):
|
||||
self._cleanup_dir("pdf_backup", (".pdf",), days)
|
||||
|
||||
def cleanup_old_logs(self, days=5):
|
||||
self._cleanup_dir("logs", (".log", ".csv"), days)
|
||||
|
||||
def _cleanup_dir(self, folder, suffixes, days):
|
||||
"""Delete files older than `days` from a folder, ignoring errors."""
|
||||
if not os.path.exists(folder):
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
return
|
||||
cutoff = datetime.datetime.now().timestamp() - days * 86400
|
||||
for name in os.listdir(folder):
|
||||
if not name.endswith(suffixes):
|
||||
continue
|
||||
path = os.path.join(folder, name)
|
||||
try:
|
||||
if os.path.isfile(path) and os.path.getmtime(path) < cutoff:
|
||||
os.remove(path)
|
||||
except OSError as exc:
|
||||
print(f"Cleanup skipped {name}: {exc}")
|
||||
|
||||
def log_print_action(self, inventory, printer, pdf_path, success, warning=""):
|
||||
"""Append a print event to the daily CSV log."""
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
log_filename = os.path.join(
|
||||
"logs", f"print_log_{datetime.datetime.now().strftime('%Y%m%d')}.csv"
|
||||
)
|
||||
|
||||
def escape(value):
|
||||
value = str(value)
|
||||
if any(ch in value for ch in (",", '"', "\n")):
|
||||
value = '"' + value.replace('"', '""') + '"'
|
||||
return value
|
||||
|
||||
line = (
|
||||
f"{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')},"
|
||||
f"{'SUCCESS' if success else 'FAILED'},"
|
||||
f"{escape(inventory)},{escape(printer)},{escape(pdf_path)},"
|
||||
f"{escape(warning)}\n"
|
||||
)
|
||||
try:
|
||||
exists = os.path.isfile(log_filename)
|
||||
with open(log_filename, "a", encoding="utf-8") as handle:
|
||||
if not exists:
|
||||
handle.write("Timestamp,Status,Inventory,Printer,PDF File,Warning\n")
|
||||
handle.write(line)
|
||||
except OSError as exc:
|
||||
print(f"Error saving log entry: {exc}")
|
||||
|
||||
# -- layout -----------------------------------------------------------
|
||||
|
||||
def build(self):
|
||||
"""Assemble the two-column panel: controls on the left, preview right."""
|
||||
self.title = "P-touch Label Printing"
|
||||
|
||||
root = BoxLayout(orientation="horizontal", spacing=dp(8), padding=dp(10))
|
||||
|
||||
# ---- column 1: controls ------------------------------------------
|
||||
controls = BoxLayout(orientation="vertical", spacing=dp(6), size_hint_x=0.42)
|
||||
|
||||
controls.add_widget(
|
||||
Label(
|
||||
text="[b]P-touch Label Printing[/b]",
|
||||
markup=True,
|
||||
size_hint_y=None,
|
||||
height=dp(22),
|
||||
font_size="15sp",
|
||||
halign="left",
|
||||
valign="middle",
|
||||
color=(1, 1, 1, 1),
|
||||
text_size=(None, dp(22)),
|
||||
)
|
||||
)
|
||||
|
||||
controls.add_widget(self._field_label("Inventory number"))
|
||||
self.inventory_input = TextInput(
|
||||
multiline=False,
|
||||
size_hint_y=None,
|
||||
height=dp(42),
|
||||
font_size="16sp",
|
||||
padding=(dp(8), dp(8)),
|
||||
write_tab=False,
|
||||
)
|
||||
self.inventory_input.bind(text=self._on_inventory_change)
|
||||
self.inventory_input.bind(on_text_validate=self._on_enter)
|
||||
controls.add_widget(self.inventory_input)
|
||||
|
||||
controls.add_widget(self._field_label("Printer"))
|
||||
self.printer_spinner = Spinner(
|
||||
text=self.available_printers[0] if self.available_printers else "PDF",
|
||||
values=self.available_printers or ["PDF"],
|
||||
size_hint_y=None,
|
||||
height=dp(38),
|
||||
font_size="13sp",
|
||||
)
|
||||
# The printable area differs per printer, so re-fit the preview on change.
|
||||
self.printer_spinner.bind(text=lambda *_: self.update_preview())
|
||||
controls.add_widget(self.printer_spinner)
|
||||
|
||||
# Push the buttons to the bottom of the column.
|
||||
controls.add_widget(BoxLayout())
|
||||
|
||||
self.print_button = Button(
|
||||
text="PRINT LABEL",
|
||||
size_hint_y=None,
|
||||
height=dp(46),
|
||||
font_size="15sp",
|
||||
bold=True,
|
||||
background_color=(0.16, 0.62, 0.28, 1),
|
||||
background_normal="",
|
||||
)
|
||||
self.print_button.bind(on_press=self.print_label)
|
||||
controls.add_widget(self.print_button)
|
||||
|
||||
settings_button = Button(
|
||||
text="Advanced settings",
|
||||
size_hint_y=None,
|
||||
height=dp(36),
|
||||
font_size="13sp",
|
||||
background_color=(0.24, 0.30, 0.45, 1),
|
||||
background_normal="",
|
||||
)
|
||||
settings_button.bind(on_press=lambda *_: self.open_settings())
|
||||
controls.add_widget(settings_button)
|
||||
|
||||
root.add_widget(controls)
|
||||
|
||||
# ---- column 2: preview ------------------------------------------
|
||||
preview_col = BoxLayout(orientation="vertical", spacing=dp(4))
|
||||
|
||||
preview_col.add_widget(
|
||||
self._field_label("Label preview (63 x 20 mm, true proportions)")
|
||||
)
|
||||
self.preview = PreviewPanel()
|
||||
preview_col.add_widget(self.preview)
|
||||
|
||||
self.status_label = Label(
|
||||
text="",
|
||||
size_hint_y=None,
|
||||
height=dp(34),
|
||||
font_size="11sp",
|
||||
markup=True,
|
||||
halign="left",
|
||||
valign="middle",
|
||||
)
|
||||
self.status_label.bind(
|
||||
size=lambda widget, size: setattr(widget, "text_size", (size[0], None))
|
||||
)
|
||||
preview_col.add_widget(self.status_label)
|
||||
|
||||
root.add_widget(preview_col)
|
||||
|
||||
Clock.schedule_once(lambda *_: self.update_preview(), 0.1)
|
||||
Clock.schedule_once(lambda *_: self.inventory_input.focus, 0.35)
|
||||
return root
|
||||
|
||||
def _field_label(self, text):
|
||||
"""A small caption label with a fixed height."""
|
||||
label = Label(
|
||||
text=text,
|
||||
size_hint_y=None,
|
||||
height=dp(18),
|
||||
font_size="11sp",
|
||||
halign="left",
|
||||
valign="middle",
|
||||
opacity=0.8,
|
||||
)
|
||||
label.bind(size=lambda w, s: setattr(w, "text_size", (s[0], None)))
|
||||
return label
|
||||
|
||||
# -- preview ----------------------------------------------------------
|
||||
|
||||
def _on_inventory_change(self, *_args):
|
||||
"""Debounce regeneration so typing does not re-render on every key."""
|
||||
if self._preview_event is not None:
|
||||
self._preview_event.cancel()
|
||||
self._preview_event = Clock.schedule_once(
|
||||
lambda *_: self.update_preview(), self.PREVIEW_DEBOUNCE_S
|
||||
)
|
||||
if hasattr(self, "payload_label") and self.payload_label is not None:
|
||||
self._refresh_payload_label()
|
||||
|
||||
def _on_enter(self, *_args):
|
||||
if self._preview_event is not None:
|
||||
self._preview_event.cancel()
|
||||
self.update_preview()
|
||||
|
||||
def _effective_config(self):
|
||||
"""The config as it will actually be printed, fitted to the printer.
|
||||
|
||||
The preview must use this rather than the raw settings, otherwise the
|
||||
preview would show the unfitted layout while the printer receives a
|
||||
fitted one - which is precisely the mismatch that caused an earlier
|
||||
'preview looks fine but prints wrong' report.
|
||||
"""
|
||||
cfg = self.label_cfg.copy()
|
||||
printer = self._get_full_printer_name(self.printer_spinner.text)
|
||||
if printer and printer != "PDF":
|
||||
apply_print_area(cfg, printer)
|
||||
return cfg
|
||||
|
||||
def _printer_area_note(self):
|
||||
"""Short description of the printer's printable limits, for the status line."""
|
||||
printer = self._get_full_printer_name(self.printer_spinner.text)
|
||||
if not printer or printer == "PDF":
|
||||
return ""
|
||||
area = query_print_area(printer)
|
||||
if area is None:
|
||||
return ""
|
||||
return (
|
||||
f"printable {area.printable_width_mm:.1f} x {area.printable_height_mm:.1f} mm"
|
||||
)
|
||||
|
||||
def update_preview(self):
|
||||
"""Render the preview and surface any code-density warning."""
|
||||
self._preview_event = None
|
||||
inventory = self.inventory_input.text.strip()
|
||||
cfg = self._effective_config()
|
||||
try:
|
||||
renderer = PtouchLabelRenderer(cfg)
|
||||
image = renderer.render_preview(inventory, dpi=self.PREVIEW_DPI)
|
||||
self.preview.show_image(image)
|
||||
except Exception as exc: # noqa: BLE001 - preview must never crash the app
|
||||
# Log the traceback too: a silent except here previously hid a real
|
||||
# bug (config shadowed by Kivy's App.config) for an entire session.
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
self._set_status(f"[color=ff6666]Preview error: {exc}[/color]")
|
||||
return
|
||||
|
||||
warning = code_density_warning(cfg, inventory)
|
||||
if warning:
|
||||
self._set_status(f"[color=ffb347]{warning}[/color]")
|
||||
else:
|
||||
payload = cfg.build_code_payload(inventory)
|
||||
shown = payload if len(payload) <= 34 else payload[:31] + "..."
|
||||
bitmap = renderer.code_bitmap(inventory)
|
||||
note = self._printer_area_note()
|
||||
size = f"label {cfg.page_width_mm:.0f}x{cfg.page_height_mm:.0f} mm"
|
||||
if bitmap is not None:
|
||||
size += (
|
||||
f" | QR {bitmap.width_mm:.1f} mm, "
|
||||
f"{bitmap.dots_per_module:.0f} dots/mod"
|
||||
)
|
||||
tail = f" | {note}" if note else ""
|
||||
self._set_status(f"QR: [b]{shown}[/b]\n{size}{tail}")
|
||||
|
||||
def _set_status(self, markup):
|
||||
self.status_label.text = markup
|
||||
|
||||
# -- settings ---------------------------------------------------------
|
||||
|
||||
def open_settings(self):
|
||||
"""Advanced settings: logo, three static lines, QR string.
|
||||
|
||||
Sized to fill most of the window, since the window is only 300 px tall;
|
||||
the form scrolls when it does not all fit.
|
||||
"""
|
||||
content = BoxLayout(orientation="vertical", spacing=dp(6), padding=dp(8))
|
||||
scroll = ScrollView()
|
||||
form = GridLayout(cols=1, size_hint_y=None, spacing=dp(4))
|
||||
form.bind(minimum_height=form.setter("height"))
|
||||
|
||||
form.add_widget(self._field_label("Logo image (PNG / JPG)"))
|
||||
logo_row = BoxLayout(size_hint_y=None, height=dp(34), spacing=dp(6))
|
||||
self.logo_input = TextInput(
|
||||
text=self.label_cfg.logo_path, multiline=False, font_size="11sp"
|
||||
)
|
||||
logo_row.add_widget(self.logo_input)
|
||||
browse = Button(text="Browse...", size_hint_x=0.28, font_size="12sp")
|
||||
browse.bind(on_press=lambda *_: self.browse_logo())
|
||||
logo_row.add_widget(browse)
|
||||
clear = Button(text="Clear", size_hint_x=0.18, font_size="12sp")
|
||||
clear.bind(on_press=lambda *_: self.clear_logo())
|
||||
logo_row.add_widget(clear)
|
||||
form.add_widget(logo_row)
|
||||
|
||||
# A thumbnail so the operator can confirm the right file was picked.
|
||||
logo_preview_row = BoxLayout(size_hint_y=None, height=dp(52), spacing=dp(8))
|
||||
self.logo_thumb = LogoThumb()
|
||||
logo_preview_row.add_widget(self.logo_thumb)
|
||||
self.logo_status = Label(
|
||||
text="",
|
||||
font_size="11sp",
|
||||
halign="left",
|
||||
valign="middle",
|
||||
markup=True,
|
||||
)
|
||||
self.logo_status.bind(
|
||||
size=lambda w, s: setattr(w, "text_size", (s[0], None))
|
||||
)
|
||||
logo_preview_row.add_widget(self.logo_status)
|
||||
form.add_widget(logo_preview_row)
|
||||
self._refresh_logo_thumb()
|
||||
|
||||
form.add_widget(self._field_label("Static text lines (3)"))
|
||||
self.static_inputs = []
|
||||
for index in range(3):
|
||||
current = (
|
||||
self.label_cfg.static_lines[index]
|
||||
if index < len(self.label_cfg.static_lines)
|
||||
else ""
|
||||
)
|
||||
field = TextInput(
|
||||
text=current,
|
||||
multiline=False,
|
||||
size_hint_y=None,
|
||||
height=dp(32),
|
||||
font_size="13sp",
|
||||
)
|
||||
self.static_inputs.append(field)
|
||||
form.add_widget(field)
|
||||
|
||||
form.add_widget(
|
||||
self._field_label("Code composition: fixed prefix + inventory + fixed suffix")
|
||||
)
|
||||
code_row = BoxLayout(size_hint_y=None, height=dp(32), spacing=dp(6))
|
||||
|
||||
self.prefix_input = TextInput(
|
||||
text=self.label_cfg.qr_prefix,
|
||||
multiline=False,
|
||||
font_size="13sp",
|
||||
hint_text="prefix",
|
||||
)
|
||||
self.prefix_input.bind(text=lambda *_: self.preview_with_template())
|
||||
code_row.add_widget(self.prefix_input)
|
||||
|
||||
inv_tag = Label(
|
||||
text="+ [inventory] +",
|
||||
size_hint_x=None,
|
||||
width=dp(96),
|
||||
font_size="11sp",
|
||||
halign="center",
|
||||
valign="middle",
|
||||
opacity=0.75,
|
||||
)
|
||||
inv_tag.bind(size=lambda w, s: setattr(w, "text_size", (s[0], None)))
|
||||
code_row.add_widget(inv_tag)
|
||||
|
||||
self.suffix_input = TextInput(
|
||||
text=self.label_cfg.qr_suffix,
|
||||
multiline=False,
|
||||
font_size="13sp",
|
||||
hint_text="suffix",
|
||||
)
|
||||
self.suffix_input.bind(text=lambda *_: self.preview_with_template())
|
||||
code_row.add_widget(self.suffix_input)
|
||||
form.add_widget(code_row)
|
||||
|
||||
# Live preview of the composed payload, so the operator can see exactly
|
||||
# what will be encoded before printing anything.
|
||||
self.payload_label = Label(
|
||||
text="",
|
||||
size_hint_y=None,
|
||||
height=dp(30),
|
||||
font_size="11sp",
|
||||
halign="left",
|
||||
valign="middle",
|
||||
markup=True,
|
||||
)
|
||||
self.payload_label.bind(
|
||||
size=lambda w, s: setattr(w, "text_size", (s[0], None))
|
||||
)
|
||||
form.add_widget(self.payload_label)
|
||||
|
||||
form.add_widget(
|
||||
self._field_label("QR error correction (L = shortest, H = most robust)")
|
||||
)
|
||||
self.ec_spinner = Spinner(
|
||||
text=self.label_cfg.qr_error_correction,
|
||||
values=["L", "M", "Q", "H"],
|
||||
size_hint_y=None,
|
||||
height=dp(32),
|
||||
font_size="13sp",
|
||||
)
|
||||
self.ec_spinner.bind(text=lambda *_: self.preview_with_template())
|
||||
form.add_widget(self.ec_spinner)
|
||||
self._refresh_payload_label()
|
||||
|
||||
scroll.add_widget(form)
|
||||
content.add_widget(scroll)
|
||||
|
||||
buttons = BoxLayout(size_hint_y=None, height=dp(40), spacing=dp(6))
|
||||
save = Button(
|
||||
text="Save",
|
||||
background_color=(0.16, 0.62, 0.28, 1),
|
||||
background_normal="",
|
||||
)
|
||||
cancel = Button(text="Cancel")
|
||||
buttons.add_widget(save)
|
||||
buttons.add_widget(cancel)
|
||||
content.add_widget(buttons)
|
||||
|
||||
self._settings_popup = Popup(
|
||||
title="Advanced settings", content=content, size_hint=(0.98, 1.0)
|
||||
)
|
||||
save.bind(on_press=lambda *_: self.apply_settings())
|
||||
cancel.bind(on_press=lambda *_: self._settings_popup.dismiss())
|
||||
self._settings_popup.bind(on_dismiss=self._on_settings_dismissed)
|
||||
self._settings_popup.open()
|
||||
|
||||
def preview_with_template(self):
|
||||
"""Reflect in-dialog code edits in the preview immediately."""
|
||||
self.label_cfg.qr_prefix = self.prefix_input.text
|
||||
self.label_cfg.qr_suffix = self.suffix_input.text
|
||||
self.label_cfg.qr_error_correction = self.ec_spinner.text
|
||||
self._refresh_payload_label()
|
||||
self.update_preview()
|
||||
|
||||
def _refresh_payload_label(self):
|
||||
"""Show exactly what the code will encode, using the current field value."""
|
||||
self.label_cfg.qr_prefix = self.prefix_input.text
|
||||
self.label_cfg.qr_suffix = self.suffix_input.text
|
||||
sample = self.inventory_input.text.strip()
|
||||
payload = self.label_cfg.build_code_payload(sample)
|
||||
shown = payload if payload else "[i](empty - enter an inventory number)[/i]"
|
||||
self.payload_label.text = f"Encodes: [b]{shown}[/b]"
|
||||
|
||||
def _on_settings_dismissed(self, *_args):
|
||||
"""On close, reload from disk so unsaved edits are discarded."""
|
||||
self._settings_popup = None
|
||||
self.label_cfg = load_config()
|
||||
self.update_preview()
|
||||
|
||||
def browse_logo(self):
|
||||
"""Open the real Windows file dialog to choose a logo.
|
||||
|
||||
The dialog runs in a subprocess because Tk and Kivy both want the main
|
||||
event loop; giving Tk its own process avoids that conflict.
|
||||
"""
|
||||
script = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"native_file_dialog.py")
|
||||
if not os.path.exists(script):
|
||||
self.show_popup(
|
||||
"Missing helper",
|
||||
"native_file_dialog.py was not found next to the application.",
|
||||
)
|
||||
return
|
||||
|
||||
args = [
|
||||
sys.executable,
|
||||
script,
|
||||
"--dir", self.label_cfg.logo_path or "",
|
||||
"--title", "Choose logo image",
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
except OSError as exc:
|
||||
self.show_popup("Error", f"Could not open the file dialog:\n{exc}")
|
||||
return
|
||||
|
||||
output = (result.stdout or "").strip().splitlines()
|
||||
line = output[-1] if output else ""
|
||||
print(f"Logo dialog result: {line or '(no output)'}")
|
||||
|
||||
if line.startswith("SELECTED:"):
|
||||
path = line[len("SELECTED:"):]
|
||||
self.logo_input.text = path
|
||||
self.label_cfg.logo_path = path
|
||||
save_config(self.label_cfg)
|
||||
self.update_preview()
|
||||
elif line == "CANCELLED":
|
||||
pass
|
||||
else:
|
||||
detail = (result.stderr or "").strip().splitlines()
|
||||
detail = detail[-1] if detail else "no output from the dialog"
|
||||
self.show_popup("Error", f"File dialog failed:\n{detail}")
|
||||
|
||||
def clear_logo(self):
|
||||
"""Remove the logo from the label."""
|
||||
self.logo_input.text = ""
|
||||
self.label_cfg.logo_path = ""
|
||||
save_config(self.label_cfg)
|
||||
self._refresh_logo_thumb()
|
||||
self.update_preview()
|
||||
|
||||
def _refresh_logo_thumb(self):
|
||||
"""Update the logo thumbnail and its status caption."""
|
||||
path = self.label_cfg.logo_path
|
||||
self.logo_thumb.set_path(path)
|
||||
|
||||
if not path:
|
||||
self.logo_status.text = "[i]No logo set - the first column stays empty[/i]"
|
||||
return
|
||||
if not os.path.exists(path):
|
||||
self.logo_status.text = f"[color=ff8888]File not found:[/color]\n{os.path.basename(path)}"
|
||||
return
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(path) as img:
|
||||
mode = img.mode
|
||||
size = img.size
|
||||
print_dim = _printed_logo_size(self.label_cfg, size)
|
||||
self.logo_status.text = (
|
||||
f"{os.path.basename(path)}\n"
|
||||
f"{size[0]}x{size[1]} {mode} -> prints {print_dim[0]:.1f}x{print_dim[1]:.1f} mm"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.logo_status.text = f"[color=ff8888]Unreadable image:[/color]\n{exc}"
|
||||
|
||||
def apply_settings(self):
|
||||
"""Persist settings to disk and refresh the preview."""
|
||||
self.label_cfg.logo_path = self.logo_input.text.strip()
|
||||
self.label_cfg.static_lines = [field.text for field in self.static_inputs]
|
||||
self.label_cfg.qr_prefix = self.prefix_input.text
|
||||
self.label_cfg.qr_suffix = self.suffix_input.text
|
||||
# The composed prefix/suffix form supersedes any legacy template.
|
||||
self.label_cfg.qr_template = ""
|
||||
self.label_cfg.qr_error_correction = self.ec_spinner.text
|
||||
|
||||
try:
|
||||
print(f"Settings saved to {save_config(self.label_cfg)}")
|
||||
except OSError as exc:
|
||||
self.show_popup("Error", f"Could not save settings: {exc}")
|
||||
|
||||
popup, self._settings_popup = self._settings_popup, None
|
||||
if popup is not None:
|
||||
popup.unbind(on_dismiss=self._on_settings_dismissed)
|
||||
popup.dismiss()
|
||||
self.update_preview()
|
||||
|
||||
# -- printing ---------------------------------------------------------
|
||||
|
||||
def print_label(self, _instance):
|
||||
inventory = self.inventory_input.text.strip()
|
||||
if not inventory:
|
||||
self.show_popup("Missing value", "Enter an inventory number first.")
|
||||
return
|
||||
|
||||
printer = self._get_full_printer_name(self.printer_spinner.text)
|
||||
popup = self._busy_popup("Generating label...\nPlease wait")
|
||||
|
||||
def worker():
|
||||
try:
|
||||
success, pdf_path, warning = print_ptouch_label(
|
||||
inventory, printer, self.label_cfg
|
||||
)
|
||||
self.log_print_action(
|
||||
inventory, printer, pdf_path, success, warning or ""
|
||||
)
|
||||
Clock.schedule_once(lambda *_: popup.dismiss(), 0)
|
||||
if success:
|
||||
message = "Label sent to printer."
|
||||
if warning:
|
||||
message += f"\n\nNote: {warning}"
|
||||
Clock.schedule_once(
|
||||
lambda *_: self.show_popup("Success", message, auto_dismiss=not warning),
|
||||
0.1,
|
||||
)
|
||||
Clock.schedule_once(lambda *_: self.reset_inventory(), 0.2)
|
||||
else:
|
||||
Clock.schedule_once(
|
||||
lambda *_: self.show_popup("Error", "Printing failed."), 0.1
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.log_print_action(inventory, printer, "", False, str(exc))
|
||||
Clock.schedule_once(lambda *_: popup.dismiss(), 0)
|
||||
Clock.schedule_once(
|
||||
lambda *_: self.show_popup("Error", f"Print error: {exc}"), 0.1
|
||||
)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _busy_popup(self, message):
|
||||
"""A small modal shown while a print job runs."""
|
||||
popup = Popup(
|
||||
title="Please wait",
|
||||
content=Label(text=message),
|
||||
size_hint=(None, None),
|
||||
size=(dp(260), dp(120)),
|
||||
auto_dismiss=False,
|
||||
)
|
||||
popup.open()
|
||||
return popup
|
||||
|
||||
def reset_inventory(self):
|
||||
"""Clear the variable field, keeping printer selection and settings."""
|
||||
self.inventory_input.text = ""
|
||||
self.update_preview()
|
||||
self.inventory_input.focus = True
|
||||
|
||||
def show_popup(self, title, message, auto_dismiss=False):
|
||||
"""A fixed-size modal that fits the small landscape window."""
|
||||
content = BoxLayout(orientation="vertical", padding=dp(8), spacing=dp(8))
|
||||
body = Label(text=message, halign="center", valign="middle")
|
||||
body.bind(size=lambda w, s: setattr(w, "text_size", (s[0], None)))
|
||||
content.add_widget(body)
|
||||
|
||||
close = Button(text="OK", size_hint_y=None, height=dp(34))
|
||||
content.add_widget(close)
|
||||
|
||||
popup = Popup(
|
||||
title=title,
|
||||
content=content,
|
||||
size_hint=(None, None),
|
||||
size=(dp(320), dp(180)),
|
||||
title_size="14sp",
|
||||
)
|
||||
close.bind(on_press=popup.dismiss)
|
||||
popup.open()
|
||||
if auto_dismiss:
|
||||
Clock.schedule_once(lambda *_: popup.dismiss(), 2.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
PtouchPrinterApp().run()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Native Windows file/folder picker, used by the Kivy GUI.
|
||||
|
||||
Kivy's built-in FileChooserListView has no address bar, no Quick Access and
|
||||
awkward keyboard navigation, which makes finding a file on a real PC painful.
|
||||
This shells out to the standard Windows dialog instead.
|
||||
|
||||
It runs in a **separate process** on purpose: Tk wants to own the main event
|
||||
loop, and Kivy's SDL2 loop is already running in the parent. Isolating Tk in a
|
||||
child process avoids that conflict entirely, and the result comes back on stdout.
|
||||
|
||||
Usage:
|
||||
python native_file_dialog.py [--dir START_DIR] [--save] [--folder] [--title T]
|
||||
|
||||
Output (one line on stdout):
|
||||
SELECTED:<abs path> on success
|
||||
CANCELLED if the user dismissed the dialog
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
IMAGE_TYPES = [
|
||||
("Image files", "*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.tif;*.tiff;*.webp"),
|
||||
("PNG", "*.png"),
|
||||
("JPEG", "*.jpg;*.jpeg"),
|
||||
("Bitmap", "*.bmp"),
|
||||
("All files", "*.*"),
|
||||
]
|
||||
|
||||
|
||||
def _default_dir(start: str | None) -> str:
|
||||
"""Pick a sensible starting folder."""
|
||||
if start and os.path.isdir(start):
|
||||
return start
|
||||
if start and os.path.isfile(start):
|
||||
return os.path.dirname(start)
|
||||
|
||||
# Prefer the user's Pictures folder, then their home directory.
|
||||
home = os.path.expanduser("~")
|
||||
pictures = os.path.join(home, "Pictures")
|
||||
if os.path.isdir(pictures):
|
||||
return pictures
|
||||
return home if os.path.isdir(home) else os.getcwd()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Native file picker")
|
||||
parser.add_argument("--dir", default=None, help="initial directory or file")
|
||||
parser.add_argument("--title", default="Select a file")
|
||||
parser.add_argument(
|
||||
"--folder", action="store_true", help="pick a folder instead of a file"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--save", action="store_true", help="use the save-as dialog"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
import tkinter
|
||||
from tkinter import filedialog
|
||||
except ImportError:
|
||||
print("ERROR:tkinter is not available in this Python installation", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
# Keep the dialog on top of the Kivy window rather than behind it.
|
||||
root.attributes("-topmost", True)
|
||||
root.update()
|
||||
|
||||
start = _default_dir(args.dir)
|
||||
try:
|
||||
if args.folder:
|
||||
result = filedialog.askdirectory(
|
||||
title=args.title, initialdir=start, mustexist=True
|
||||
)
|
||||
elif args.save:
|
||||
result = filedialog.asksaveasfilename(
|
||||
title=args.title,
|
||||
initialdir=start,
|
||||
defaultextension=".png",
|
||||
filetypes=IMAGE_TYPES,
|
||||
)
|
||||
else:
|
||||
result = filedialog.askopenfilename(
|
||||
title=args.title, initialdir=start, filetypes=IMAGE_TYPES
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
root.destroy()
|
||||
except tkinter.TclError:
|
||||
pass
|
||||
|
||||
if result:
|
||||
print(f"SELECTED:{os.path.normpath(result)}")
|
||||
else:
|
||||
print("CANCELLED")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Query a Windows printer driver for its page and printable area.
|
||||
|
||||
Why this matters for the P-touch: the PT-E560BT driver reports its media as
|
||||
65 x 24 mm, but its *printable* area is only about 61.1 x 18.06 mm, because the
|
||||
print head cannot reach the edges. Anything drawn outside that window is cropped
|
||||
by the driver, which is why a 63 x 20 mm design printed short.
|
||||
|
||||
Reading the values from the driver (rather than hardcoding them) means the layout
|
||||
adapts automatically if the tape width or media size changes.
|
||||
|
||||
The 18 mm printable height is a hardware limit: the PT-E560BT has a 128 dot print
|
||||
head at 180 dpi, and 128 / 180 * 25.4 = 18.06 mm. No layout can exceed it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
# GetDeviceCaps indices (wingdi.h)
|
||||
HORZSIZE, VERTSIZE = 4, 6
|
||||
HORZRES, VERTRES = 8, 10
|
||||
LOGPIXELSX, LOGPIXELSY = 88, 90
|
||||
PHYSICALWIDTH, PHYSICALHEIGHT = 110, 111
|
||||
PHYSICALOFFSETX, PHYSICALOFFSETY = 112, 113
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrintArea:
|
||||
"""Page and printable-area geometry of a printer driver, in millimetres."""
|
||||
|
||||
printer: str
|
||||
dpi_x: int
|
||||
dpi_y: int
|
||||
# Physical media
|
||||
media_width_mm: float
|
||||
media_height_mm: float
|
||||
# Area the head can actually reach
|
||||
printable_width_mm: float
|
||||
printable_height_mm: float
|
||||
# Where the printable area starts, relative to the physical page corner
|
||||
offset_x_mm: float
|
||||
offset_y_mm: float
|
||||
|
||||
def describe(self) -> str:
|
||||
return (
|
||||
f"{self.printer}: media {self.media_width_mm:.2f} x {self.media_height_mm:.2f} mm, "
|
||||
f"printable {self.printable_width_mm:.2f} x {self.printable_height_mm:.2f} mm "
|
||||
f"at ({self.offset_x_mm:.2f}, {self.offset_y_mm:.2f}) mm, "
|
||||
f"{self.dpi_x}x{self.dpi_y} dpi"
|
||||
)
|
||||
|
||||
|
||||
def _mm(dots: float, dpi: float) -> float:
|
||||
return dots / dpi * 25.4 if dpi else 0.0
|
||||
|
||||
|
||||
def query_print_area(printer_name: str) -> Optional[PrintArea]:
|
||||
"""Read a printer's page and printable geometry.
|
||||
|
||||
Returns None when the information is unavailable (non-Windows platform, the
|
||||
printer is missing, or pywin32 is not installed). Callers must fall back to
|
||||
configured defaults in that case.
|
||||
"""
|
||||
try:
|
||||
import win32ui
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
dc = win32ui.CreateDC()
|
||||
dc.CreatePrinterDC(printer_name)
|
||||
except Exception as exc: # noqa: BLE001 - caller falls back to defaults
|
||||
print(f"Could not query print area for '{printer_name}': {exc}")
|
||||
return None
|
||||
|
||||
try:
|
||||
dpi_x = dc.GetDeviceCaps(LOGPIXELSX) or 180
|
||||
dpi_y = dc.GetDeviceCaps(LOGPIXELSY) or 180
|
||||
return PrintArea(
|
||||
printer=printer_name,
|
||||
dpi_x=dpi_x,
|
||||
dpi_y=dpi_y,
|
||||
media_width_mm=_mm(dc.GetDeviceCaps(PHYSICALWIDTH), dpi_x),
|
||||
media_height_mm=_mm(dc.GetDeviceCaps(PHYSICALHEIGHT), dpi_y),
|
||||
printable_width_mm=_mm(dc.GetDeviceCaps(HORZRES), dpi_x),
|
||||
printable_height_mm=_mm(dc.GetDeviceCaps(VERTRES), dpi_y),
|
||||
offset_x_mm=_mm(dc.GetDeviceCaps(PHYSICALOFFSETX), dpi_x),
|
||||
offset_y_mm=_mm(dc.GetDeviceCaps(PHYSICALOFFSETY), dpi_y),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"GetDeviceCaps failed for '{printer_name}': {exc}")
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
dc.DeleteDC()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def apply_to_config(config, printer_name: str, margin_mm: float = 0.1) -> bool:
|
||||
"""Resize a LabelConfig to the printer's printable area.
|
||||
|
||||
The label content is set to the printable area (minus a small safety margin)
|
||||
so nothing the driver cannot reach is ever drawn.
|
||||
|
||||
Args:
|
||||
config: a LabelConfig instance, modified in place
|
||||
printer_name: printer to query
|
||||
margin_mm: shrink each dimension by this much to stay clear of rounding
|
||||
|
||||
Returns:
|
||||
bool: True if the config was updated
|
||||
"""
|
||||
area = query_print_area(printer_name)
|
||||
if area is None:
|
||||
return False
|
||||
|
||||
config.media_width_mm = round(area.media_width_mm, 2)
|
||||
config.media_height_mm = round(area.media_height_mm, 2)
|
||||
config.page_width_mm = round(area.printable_width_mm - margin_mm, 2)
|
||||
config.page_height_mm = round(area.printable_height_mm - margin_mm, 2)
|
||||
config.gutter_mm = 0.0 # the content box is now the printable area itself
|
||||
config.match_driver_media = True
|
||||
|
||||
# Keep the three columns tiling the new width, preserving their proportions.
|
||||
total = config.col_logo_mm + config.col_text_mm + config.col_code_mm
|
||||
if total > 0:
|
||||
scale = config.page_width_mm / total
|
||||
config.col_logo_mm = round(config.col_logo_mm * scale, 2)
|
||||
config.col_text_mm = round(config.col_text_mm * scale, 2)
|
||||
return True
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Print a calibration label to verify physical geometry on the PT-E560BT.
|
||||
|
||||
The label carries four things that can be measured with a ruler on the tape:
|
||||
|
||||
1. A hairline box exactly at the 63 x 20 mm content bounds
|
||||
2. Corner ticks at 10 mm from each corner, for checking scale (a 10 mm tick
|
||||
measures 10 mm only if the driver did not rescale)
|
||||
3. A QR symbol with a known 12.28 mm size and 3 dots per module
|
||||
4. Ruler marks every 10 mm along the bottom edge
|
||||
|
||||
Whether the driver is scaling, rotating or offsetting the page can be read
|
||||
directly off the print, instead of being inferred.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from label_config import load_config, save_config
|
||||
from ptouch_label import PRINTER_DPI, LabelConfig, build_code_bitmap
|
||||
|
||||
PRINTER = "Brother PT-E560BT"
|
||||
|
||||
# One module of the QR at 3 dots/module, i.e. what 0.4233 mm should measure.
|
||||
MODULE_MM = 3.0 / PRINTER_DPI * 25.4
|
||||
|
||||
|
||||
def build_calibration_pdf(cfg: LabelConfig, path: str) -> str:
|
||||
page_w, page_h = cfg.page_size_mm
|
||||
ox, oy = cfg.origin_mm
|
||||
c = canvas.Canvas(path, pagesize=(page_w * mm, page_h * mm))
|
||||
c.setTitle("PT-E560BT calibration")
|
||||
|
||||
def X(label_mm: float) -> float:
|
||||
return (ox + label_mm) * mm
|
||||
|
||||
def Y(label_mm: float) -> float:
|
||||
return (oy + label_mm) * mm
|
||||
|
||||
# 1. Content bounds, hairline
|
||||
c.setStrokeColorRGB(0, 0, 0)
|
||||
c.setLineWidth(0.25)
|
||||
c.rect(X(0), Y(0), cfg.page_width_mm * mm, cfg.page_height_mm * mm)
|
||||
|
||||
# 2. Corner ticks: 10 mm legs just inside the box
|
||||
c.setLineWidth(0.5)
|
||||
for cx, cy, dx, dy in [
|
||||
(0, 0, 1, 1),
|
||||
(cfg.page_width_mm, 0, -1, 1),
|
||||
(0, 20, 1, -1),
|
||||
(cfg.page_width_mm, 20, -1, -1),
|
||||
]:
|
||||
c.line(X(cx), Y(cy), X(cx + 10 * dx), Y(cy))
|
||||
c.line(X(cx), Y(cy), X(cx), Y(cy + 10 * dy))
|
||||
|
||||
# 3. Ruler marks every 10 mm along the bottom, labelled
|
||||
c.setFont("Helvetica", 4)
|
||||
for i in range(0, 61, 10):
|
||||
tick_h = 2.0 if i % 20 == 0 else 1.0
|
||||
c.setLineWidth(0.4)
|
||||
c.line(X(i + 1), Y(1), X(i + 1), Y(1 + tick_h))
|
||||
if i % 20 == 0:
|
||||
c.drawString(X(i + 2), Y(1.2), str(i))
|
||||
|
||||
# 4. Column guides at the real boundaries: 20 mm and 45 mm
|
||||
c.setLineWidth(0.25)
|
||||
for x in (cfg.col_logo_mm, cfg.col_logo_mm + cfg.col_text_mm):
|
||||
c.line(X(x), Y(1), X(x), Y(cfg.page_height_mm - 1))
|
||||
|
||||
# 5. Labels so the print can be interpreted without this file
|
||||
c.setFont("Helvetica-Bold", 5)
|
||||
c.drawString(X(20.8), Y(16.2), "CALIBRATION")
|
||||
c.setFont("Helvetica", 4)
|
||||
c.drawString(X(20.8), Y(13.0), "box = 63 x 20 mm")
|
||||
c.drawString(X(20.8), Y(9.5), "ticks = 10 mm")
|
||||
c.drawString(X(20.8), Y(6.5), "columns at")
|
||||
c.drawString(X(20.8), Y(3.5), "20 / 45 mm")
|
||||
c.drawString(X(20.8), Y(1.6), f"qr mod {MODULE_MM:.3f}mm")
|
||||
|
||||
# 6. The QR itself, with a known module size
|
||||
bitmap = build_code_bitmap(cfg, "CAL", 112)
|
||||
if bitmap is not None:
|
||||
temp = os.path.join(os.path.dirname(path), "_cal_qr.png")
|
||||
bitmap.image.save(temp, "PNG")
|
||||
geo_box_w = bitmap.width_mm
|
||||
c.drawImage(
|
||||
temp,
|
||||
X(cfg.page_width_mm - 1 - geo_box_w),
|
||||
Y((cfg.page_height_mm - geo_box_w) / 2),
|
||||
width=geo_box_w * mm,
|
||||
height=geo_box_w * mm,
|
||||
mask=None,
|
||||
)
|
||||
os.remove(temp)
|
||||
print(f"QR: {bitmap.modules} modules, {bitmap.dots_per_module:.0f} dots/module, "
|
||||
f"{bitmap.width_mm:.2f} mm across")
|
||||
|
||||
c.showPage()
|
||||
c.save()
|
||||
return path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cfg = load_config()
|
||||
if not os.path.exists("label_config.json"):
|
||||
save_config(cfg)
|
||||
|
||||
out = os.path.join("pdf_backup", "calibration_63x20.pdf")
|
||||
os.makedirs("pdf_backup", exist_ok=True)
|
||||
build_calibration_pdf(cfg, out)
|
||||
|
||||
print(f"Page : {cfg.page_size_mm[0]:.1f} x {cfg.page_size_mm[1]:.1f} mm")
|
||||
print(f"Content : {cfg.page_width_mm:.1f} x {cfg.page_height_mm:.1f} mm "
|
||||
f"at origin {cfg.origin_mm}")
|
||||
print(f"Written : {out}")
|
||||
|
||||
if "--no-print" in sys.argv:
|
||||
print("(--no-print given, not sending to printer)")
|
||||
return 0
|
||||
|
||||
from print_label import print_to_printer
|
||||
ok = print_to_printer(PRINTER, out, settings=cfg.sumatra_settings)
|
||||
print(f"Printed : {'SUCCESS' if ok else 'FAILED'}")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+175
-20
@@ -8,6 +8,16 @@ import datetime
|
||||
import platform
|
||||
import subprocess
|
||||
from print_label_pdf import PDFLabelGenerator
|
||||
from ptouch_label import (
|
||||
LabelConfig,
|
||||
PtouchLabelRenderer,
|
||||
code_density_warning,
|
||||
)
|
||||
|
||||
try:
|
||||
from print_area import apply_to_config as apply_print_area
|
||||
except ImportError: # pragma: no cover - print_area is optional
|
||||
apply_print_area = None
|
||||
|
||||
# Cross-platform printer support
|
||||
try:
|
||||
@@ -118,12 +128,25 @@ def create_label_image(text):
|
||||
left_margin = 15
|
||||
row_spacing = 3
|
||||
|
||||
# Fonts
|
||||
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||
# Fonts - use whatever bold/regular TrueType pair exists on this platform.
|
||||
# The previous hardcoded Linux path always failed on Windows, silently
|
||||
# falling back to a tiny bitmap font.
|
||||
font_candidates = [
|
||||
(r"C:\Windows\Fonts\arialbd.ttf", r"C:\Windows\Fonts\arial.ttf"),
|
||||
(r"C:\Windows\Fonts\segoeuib.ttf", r"C:\Windows\Fonts\segoeui.ttf"),
|
||||
("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
|
||||
]
|
||||
label_font = value_font = None
|
||||
for bold_path, regular_path in font_candidates:
|
||||
if os.path.exists(bold_path) and os.path.exists(regular_path):
|
||||
try:
|
||||
label_font = ImageFont.truetype(font_path, 16)
|
||||
value_font = ImageFont.truetype(font_path, 14)
|
||||
except IOError:
|
||||
label_font = ImageFont.truetype(bold_path, 16)
|
||||
value_font = ImageFont.truetype(regular_path, 14)
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
if label_font is None:
|
||||
label_font = ImageFont.load_default()
|
||||
value_font = ImageFont.load_default()
|
||||
|
||||
@@ -225,7 +248,131 @@ def create_label_pdf(text):
|
||||
return generator.create_label_pdf(sap_nr, cantitate, lot_number, pdf_filename)
|
||||
|
||||
|
||||
def print_to_printer(printer_name, file_path):
|
||||
def create_ptouch_label_pdf(inventory, config=None, filename=None):
|
||||
"""
|
||||
Create a PT-E560BT formatted label PDF (63 x 20 mm, three columns).
|
||||
|
||||
Layout: logo | three static lines + variable inventory line | QR code.
|
||||
|
||||
Args:
|
||||
inventory (str): Value for the variable field and the QR payload
|
||||
config (LabelConfig): Layout settings (defaults used if None)
|
||||
filename (str): Output filename (auto-generated in pdf_backup if None)
|
||||
|
||||
Returns:
|
||||
str: Path to the generated PDF file
|
||||
"""
|
||||
cfg = config or LabelConfig()
|
||||
|
||||
pdf_backup_dir = 'pdf_backup'
|
||||
os.makedirs(pdf_backup_dir, exist_ok=True)
|
||||
|
||||
if not filename:
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
safe_value = "".join(c for c in (inventory or "empty") if c.isalnum() or c in "-_")[:20]
|
||||
filename = os.path.join(pdf_backup_dir, f"ptouch_{safe_value}_{timestamp}.pdf")
|
||||
|
||||
renderer = PtouchLabelRenderer(cfg)
|
||||
return renderer.render_pdf(inventory, filename)
|
||||
|
||||
|
||||
def print_ptouch_label(inventory, printer, config=None):
|
||||
"""
|
||||
Generate and print a PT-E560BT label.
|
||||
|
||||
Before rendering, the layout is fitted to the printer's actual printable
|
||||
area when that can be queried. The PT-E560BT driver rejects anything drawn
|
||||
outside roughly 61.1 x 18.06 mm, so fitting here prevents the driver from
|
||||
silently cropping the label.
|
||||
|
||||
Args:
|
||||
inventory (str): Variable field / QR payload value
|
||||
printer (str): Target printer name, or "PDF" to only save the file
|
||||
config (LabelConfig): Layout settings
|
||||
|
||||
Returns:
|
||||
tuple: (success (bool), pdf_path (str), warning (str or None))
|
||||
"""
|
||||
cfg = config or LabelConfig()
|
||||
|
||||
# Fit to the real printable area (no-op for the "PDF" pseudo-printer).
|
||||
if apply_print_area is not None and printer and printer != "PDF":
|
||||
if apply_print_area(cfg, printer):
|
||||
print(
|
||||
f"Fitted to printable area: content {cfg.page_width_mm} x "
|
||||
f"{cfg.page_height_mm} mm on {cfg.media_width_mm} x "
|
||||
f"{cfg.media_height_mm} mm media"
|
||||
)
|
||||
|
||||
warning = code_density_warning(cfg, inventory)
|
||||
|
||||
try:
|
||||
pdf_path = create_ptouch_label_pdf(inventory, cfg)
|
||||
print(f"P-touch label PDF created: {pdf_path}")
|
||||
except Exception as e:
|
||||
print(f"Error creating P-touch label: {e}")
|
||||
return False, "", warning
|
||||
|
||||
# The page must be sent at its true size. Any driver-side scaling
|
||||
# would distort the QR module pitch and break scanning.
|
||||
success = print_to_printer(
|
||||
printer, pdf_path, scale=cfg.printer_scale, settings=cfg.sumatra_settings
|
||||
)
|
||||
return success, pdf_path, warning
|
||||
|
||||
|
||||
# SumatraPDF exit codes when printing (from its documented behaviour).
|
||||
SUMATRA_EXIT_CODES = {
|
||||
0: "success",
|
||||
2: "could not open the file (not found or unsupported format)",
|
||||
3: "the document does not allow printing",
|
||||
4: "the printer does not exist",
|
||||
5: "the printer driver / device failed",
|
||||
6: "printing is disabled by restriction policy",
|
||||
}
|
||||
|
||||
|
||||
def _sumatra_print(sumatra_path, printer_name, file_path, settings):
|
||||
"""
|
||||
Send a PDF to a printer through SumatraPDF and report the real outcome.
|
||||
|
||||
Args:
|
||||
sumatra_path (str): Path to SumatraPDF.exe
|
||||
printer_name (str): Target printer
|
||||
file_path (str): PDF to print
|
||||
settings (str): Value for -print-settings, e.g. "noscale,center"
|
||||
|
||||
Returns:
|
||||
tuple: (success (bool), message (str))
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[
|
||||
sumatra_path,
|
||||
"-print-to", printer_name,
|
||||
file_path,
|
||||
"-print-settings", settings,
|
||||
"-silent",
|
||||
"-exit-when-done",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
|
||||
code = result.returncode
|
||||
if code == 0:
|
||||
return True, f"sent to '{printer_name}' (settings: {settings})"
|
||||
|
||||
reason = SUMATRA_EXIT_CODES.get(code, f"unknown exit code {code}")
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
message = f"SumatraPDF failed (exit {code}): {reason}"
|
||||
if detail:
|
||||
message += f" - {detail.splitlines()[-1]}"
|
||||
return False, message
|
||||
|
||||
|
||||
def print_to_printer(printer_name, file_path, scale="noscale", settings=None):
|
||||
"""
|
||||
Print file to printer (cross-platform).
|
||||
|
||||
@@ -294,19 +441,25 @@ def print_to_printer(printer_name, file_path):
|
||||
for sumatra_path in sumatra_paths:
|
||||
if os.path.exists(sumatra_path):
|
||||
try:
|
||||
subprocess.run([
|
||||
sumatra_path,
|
||||
"-print-to",
|
||||
printer_name,
|
||||
file_path,
|
||||
"-print-settings",
|
||||
"fit,landscape",
|
||||
"-silent",
|
||||
"-exit-when-done"
|
||||
], check=False, creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
print(f"Label sent to printer via SumatraPDF: {printer_name}")
|
||||
# "noscale" keeps the PDF at its true physical
|
||||
# size; the P-touch label layout depends on it.
|
||||
# "center" stops the label hugging one edge when
|
||||
# the page is shorter than the media.
|
||||
if settings:
|
||||
print_settings = settings
|
||||
elif scale == "noscale":
|
||||
print_settings = "noscale"
|
||||
else:
|
||||
print_settings = "fit,landscape"
|
||||
|
||||
ok, message = _sumatra_print(
|
||||
sumatra_path, printer_name, file_path,
|
||||
print_settings
|
||||
)
|
||||
print(f"SumatraPDF: {message}")
|
||||
if ok:
|
||||
printed = True
|
||||
break
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"SumatraPDF error: {e}")
|
||||
|
||||
@@ -370,7 +523,8 @@ def print_to_printer(printer_name, file_path):
|
||||
pass
|
||||
|
||||
if not printed:
|
||||
# Fallback: Let user know and save PDF
|
||||
# No silent-print backend worked. Report failure so the
|
||||
# caller does not claim a label was printed.
|
||||
print("=" * 60)
|
||||
print("NOTICE: Silent PDF printing requires SumatraPDF")
|
||||
print("SumatraPDF not found (should be bundled inside the app)")
|
||||
@@ -379,6 +533,7 @@ def print_to_printer(printer_name, file_path):
|
||||
print("=" * 60)
|
||||
print(f"PDF saved to: {file_path}")
|
||||
print("The PDF can be printed manually.")
|
||||
return False
|
||||
|
||||
return True
|
||||
else:
|
||||
@@ -394,7 +549,7 @@ def print_to_printer(printer_name, file_path):
|
||||
except Exception as e:
|
||||
print(f"Windows print error: {e}")
|
||||
print("PDF backup saved as fallback")
|
||||
return True
|
||||
return False
|
||||
|
||||
elif SYSTEM == "Darwin":
|
||||
# macOS: Use lp command
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Print a measuring label to determine the printer's actual scale factors.
|
||||
|
||||
The problem: we render a correct 61 x 18 mm label, but it prints around 25 x 17 mm.
|
||||
Two candidate causes predict different numbers, and both look similar at that size:
|
||||
|
||||
(a) Resolution mismatch - the driver reports LOGPIXELSX=360 but the device is
|
||||
180 dpi, so content is compressed 2x along the tape. Predicts marks 10 mm
|
||||
apart appearing ~5 mm apart in X only.
|
||||
(b) Driver shrink-to-fit across the 24 mm media width. Predicts an equal
|
||||
shrink in X and Y: scale 24/65.05 = 0.37 -> marks 10 mm apart appear 3.7 mm.
|
||||
|
||||
This label makes the answer unambiguous: it draws ruler ticks at known spacings
|
||||
in both axes, so reading one number off the tape identifies the cause.
|
||||
|
||||
Reading the print:
|
||||
* X ticks (along the tape) are laid out every 10 mm from 0 to 60.
|
||||
* Y ticks (across the tape) are laid out every 2 mm from 0 to 18.
|
||||
* A 10 x 10 mm reference square sits at a known position.
|
||||
|
||||
Measure the distance between tick "0" and tick "60" (intended 60 mm):
|
||||
~60 mm -> geometry correct; the problem lies elsewhere
|
||||
~30 mm -> case (a), 2x compression along the tape
|
||||
~22 mm -> case (b), shrink-to-fit on width
|
||||
other -> report the measured value
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from label_config import load_config
|
||||
from print_area import query_print_area
|
||||
from ptouch_label import LabelConfig
|
||||
|
||||
PRINTER = "Brother PT-E560BT"
|
||||
|
||||
|
||||
def build_measure_pdf(cfg: LabelConfig, path: str) -> str:
|
||||
"""Draw the measuring label at the configured content size."""
|
||||
page_w, page_h = cfg.page_size_mm
|
||||
ox, oy = cfg.origin_mm
|
||||
content_w, content_h = cfg.page_width_mm, cfg.page_height_mm
|
||||
|
||||
c = canvas.Canvas(path, pagesize=(page_w * mm, page_h * mm))
|
||||
c.setTitle("PT-E560BT scale measurement")
|
||||
|
||||
def X(v: float) -> float:
|
||||
return (ox + v) * mm
|
||||
|
||||
def Y(v: float) -> float:
|
||||
return (oy + v) * mm
|
||||
|
||||
# Outer frame exactly at the content bounds.
|
||||
c.setStrokeColorRGB(0, 0, 0)
|
||||
c.setLineWidth(0.4)
|
||||
c.rect(X(0), Y(0), content_w * mm, content_h * mm)
|
||||
|
||||
# X ruler: a tick every 10 mm along the bottom, labelled.
|
||||
c.setFont("Helvetica", 5)
|
||||
for i in range(0, int(content_w) + 1, 10):
|
||||
tall = i % 20 == 0
|
||||
c.setLineWidth(0.6 if tall else 0.3)
|
||||
c.line(X(i), Y(0), X(i), Y(3.5 if tall else 1.8))
|
||||
if tall:
|
||||
c.drawString(X(i + 0.4), Y(3.8), str(i))
|
||||
|
||||
# Y ruler: a tick every 2 mm up the left edge, labelled every 6 mm.
|
||||
for j in range(0, int(content_h) + 1, 2):
|
||||
tall = j % 6 == 0
|
||||
c.setLineWidth(0.6 if tall else 0.3)
|
||||
c.line(X(0), Y(j), X(2.5 if tall else 1.4), Y(j))
|
||||
if tall:
|
||||
c.drawString(X(2.8), Y(j + 0.3), str(j))
|
||||
|
||||
# 10 x 10 mm reference square, for an independent scale check.
|
||||
c.setLineWidth(0.6)
|
||||
c.rect(X(30), Y(4), 10 * mm, 10 * mm)
|
||||
c.setFont("Helvetica-Bold", 4)
|
||||
c.drawString(X(30.6), Y(8.4), "10x10")
|
||||
c.drawString(X(30.6), Y(6.0), "mm")
|
||||
|
||||
# Explanation printed on the label itself.
|
||||
c.setFont("Helvetica-Bold", 5)
|
||||
c.drawString(X(44), Y(15.4), "MEASURE:")
|
||||
c.setFont("Helvetica", 4)
|
||||
c.drawString(X(44), Y(13.4), "tick 0 to 60")
|
||||
c.drawString(X(44), Y(11.6), "should be 60 mm")
|
||||
c.drawString(X(44), Y(9.8), "square = 10 mm")
|
||||
c.drawString(X(44), Y(8.0), "frame =")
|
||||
c.drawString(X(44), Y(6.4), f"{content_w:.0f} x {content_h:.0f} mm")
|
||||
c.drawString(X(44), Y(4.8), "tape = 24 mm")
|
||||
|
||||
c.showPage()
|
||||
c.save()
|
||||
return path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cfg = load_config()
|
||||
|
||||
# Use the printer's real printable area for this measurement.
|
||||
area = query_print_area(PRINTER)
|
||||
if area:
|
||||
print(f"Printable area: {area.printable_width_mm:.2f} x "
|
||||
f"{area.printable_height_mm:.2f} mm")
|
||||
cfg.media_width_mm = round(area.media_width_mm, 2)
|
||||
cfg.media_height_mm = round(area.media_height_mm, 2)
|
||||
cfg.page_width_mm = round(area.printable_width_mm - 0.1, 2)
|
||||
cfg.page_height_mm = round(area.printable_height_mm - 0.1, 2)
|
||||
|
||||
os.makedirs("pdf_backup", exist_ok=True)
|
||||
out = os.path.join("pdf_backup", "measure_scale.pdf")
|
||||
build_measure_pdf(cfg, out)
|
||||
|
||||
print(f"Content bounds : {cfg.page_width_mm:.2f} x {cfg.page_height_mm:.2f} mm")
|
||||
print(f"PDF page : {cfg.page_size_mm[0]:.2f} x {cfg.page_size_mm[1]:.2f} mm")
|
||||
print(f"Origin : {cfg.origin_mm}")
|
||||
print(f"Written : {out}")
|
||||
|
||||
if "--no-print" in sys.argv:
|
||||
print("(--no-print: not sending to printer)")
|
||||
return 0
|
||||
|
||||
from print_label import print_to_printer
|
||||
|
||||
settings = "noscale,paperkind=261"
|
||||
print(f"\nPrinting with -print-settings '{settings}' ...")
|
||||
ok = print_to_printer(PRINTER, out, settings=settings)
|
||||
print(f"Result: {'SENT' if ok else 'FAILED'}")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Send one test label to the PT-E560BT.
|
||||
|
||||
Generates the 63 x 20 mm label and prints it through SumatraPDF with noscale so
|
||||
the QR module pitch reaches the tape unmangled. Reports the real printer exit
|
||||
status rather than assuming success.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from label_config import load_config, save_config
|
||||
from print_label import print_ptouch_label
|
||||
from ptouch_label import code_density_warning
|
||||
|
||||
PRINTER = "Brother PT-E560BT"
|
||||
TEST_INVENTORY = sys.argv[1] if len(sys.argv) > 1 else "INV-0001"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cfg = load_config()
|
||||
|
||||
# Only seed defaults on the very first run so operator settings are kept.
|
||||
if not os.path.exists("label_config.json"):
|
||||
print("No saved settings - writing defaults (edit them in Advanced settings).")
|
||||
cfg.static_lines = ["MOTO ADV SRL", "Articol: CABLU", "Rev: 2026-09"]
|
||||
cfg.qr_prefix = "INV-"
|
||||
save_config(cfg)
|
||||
|
||||
print(f"Printer : {PRINTER}")
|
||||
print(f"Inventory : {TEST_INVENTORY}")
|
||||
print(f"QR string : {cfg.build_code_payload(TEST_INVENTORY)!r}")
|
||||
print(f"Logo : {cfg.logo_path or '(none set)'}")
|
||||
print(f"Send opts : {cfg.sumatra_settings}")
|
||||
|
||||
warning = code_density_warning(cfg, TEST_INVENTORY)
|
||||
if warning:
|
||||
print(f"\nWARNING: {warning}")
|
||||
|
||||
print("\nGenerating and printing...")
|
||||
success, pdf_path, warning = print_ptouch_label(TEST_INVENTORY, PRINTER, cfg)
|
||||
|
||||
print("\n" + "=" * 62)
|
||||
print(f"PDF : {pdf_path}")
|
||||
print(f"Result : {'SUCCESS' if success else 'FAILED'}")
|
||||
if warning:
|
||||
print(f"Warning : {warning}")
|
||||
print("=" * 62)
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+812
@@ -0,0 +1,812 @@
|
||||
"""
|
||||
P-touch EDGE PT-E560BT label layout engine.
|
||||
|
||||
Produces an exact 63 x 20 mm label split into three columns:
|
||||
|
||||
0 20 mm 45 mm 63 mm
|
||||
+---------+------------+--------+ 0
|
||||
| | static 1 | Q R |
|
||||
| LOGO | static 2 | C O | 1 mm non-printable gutter
|
||||
| 16x16 | static 3 | D E |
|
||||
| | VARIABLE | |
|
||||
+---------+------------+--------+ 20 mm
|
||||
20 mm 25 mm 18 mm
|
||||
|
||||
Hardware constraints (Brother spec sheet for PT-E310BT / PT-E510 / PT-E560BT):
|
||||
* print resolution ....... 180 dpi (128 dot print head)
|
||||
* max print height ....... 18.0 mm, and only when using 24 mm tape
|
||||
* max tape width ......... 24 mm
|
||||
|
||||
Because the print head can only reach 18 mm, a 20 mm tall label keeps 1 mm of
|
||||
non-printable gutter at the top and bottom. All content is laid out inside the
|
||||
central 18 mm band; `gutter_mm` controls this and is applied automatically.
|
||||
|
||||
The same geometry is used for both the PDF and the on-screen preview, so what
|
||||
you see in the GUI is what the printer receives.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field, asdict
|
||||
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfgen import canvas
|
||||
from PIL import Image
|
||||
|
||||
MM_PER_PT = 25.4 / 72.0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: Font size that a QR / Code128 symbol must be at least, in dots, to stay
|
||||
#: scannable on a 180 dpi thermal print head.
|
||||
MIN_MODULE_DOTS = 2.5
|
||||
|
||||
|
||||
@dataclass
|
||||
class LabelConfig:
|
||||
"""All user-editable layout settings for the P-touch label."""
|
||||
|
||||
# Media
|
||||
page_width_mm: float = 63.0
|
||||
page_height_mm: float = 20.0
|
||||
# Vertical non-printable gutter (print head reaches 18 mm of 20 mm)
|
||||
gutter_mm: float = 1.0
|
||||
# Emit the page at the driver's own media size, with the 63 x 20 mm content
|
||||
# centred inside it.
|
||||
#
|
||||
# Why this defaults to True: the installed PT-E560BT driver reports its media
|
||||
# as 24 x 65 mm (landscape). Sending a 63 x 20 mm page means the driver has to
|
||||
# rescale and reposition it, and the measured result was a 25 x 17 mm label
|
||||
# clipped against the left edge. Matching the page to the media size removes
|
||||
# scaling and rotation from the equation, so the printed geometry is exactly
|
||||
# what was drawn.
|
||||
match_driver_media: bool = True
|
||||
media_width_mm: float = 65.0 # physical page, along the tape
|
||||
media_height_mm: float = 24.0 # physical page, across the tape
|
||||
# Where the printable area starts inside the physical page. The PT-E560BT
|
||||
# print head cannot reach the page edges: its reachable window measured
|
||||
# 61.10 x 18.06 mm starting at (1.98, 2.96) mm on 65 x 24 mm media.
|
||||
# Content drawn outside that window is cropped by the driver.
|
||||
media_offset_x_mm: float = 1.98
|
||||
media_offset_y_mm: float = 2.96
|
||||
center_in_media: bool = True # when False, use media_offset_* instead
|
||||
# Draw a hairline box at the content bounds. Used only for calibration.
|
||||
show_alignment_box: bool = False
|
||||
|
||||
# Column split
|
||||
col_logo_mm: float = 20.0
|
||||
col_text_mm: float = 25.0 # remainder goes to the code column
|
||||
|
||||
# Column 1 - logo
|
||||
logo_path: str = ""
|
||||
logo_box_mm: float = 16.0
|
||||
logo_dither: bool = True # thermal transfer is 1-bit; dither greyscale
|
||||
|
||||
# Column 2 - text
|
||||
static_lines: list[str] = field(
|
||||
default_factory=lambda: ["LINE 1", "LINE 2", "LINE 3"]
|
||||
)
|
||||
static_font_pt: float = 7.0
|
||||
variable_font_pt: float = 9.5
|
||||
text_padding_mm: float = 0.8
|
||||
|
||||
# Column 3 - code
|
||||
code_type: str = "qr" # "qr" or "code128"
|
||||
# Code payload is composed as: prefix + inventory + suffix.
|
||||
# Example: prefix "INV-" and suffix "-A" with inventory "1234" -> "INV-1234-A".
|
||||
qr_prefix: str = ""
|
||||
qr_suffix: str = ""
|
||||
# Legacy single-template form, kept only so older config files still load.
|
||||
# New settings should use qr_prefix / qr_suffix.
|
||||
qr_template: str = ""
|
||||
# Size the code column so the symbol fills the printable height, giving the
|
||||
# largest possible scannable code. When False, col_text_mm decides the split.
|
||||
qr_maximize: bool = True
|
||||
qr_box_mm: float = 16.0 # used only when qr_maximize is False
|
||||
qr_border_modules: int = 4
|
||||
qr_error_correction: str = "M"
|
||||
|
||||
#: Smallest text column worth keeping; stops a maximised code from starving
|
||||
#: the text so badly that the three static lines stop fitting.
|
||||
MIN_TEXT_COL_MM: float = 18.0
|
||||
|
||||
# Printing
|
||||
printer_scale: str = "noscale" # never let the driver rescale the page
|
||||
# SumatraPDF -print-settings.
|
||||
#
|
||||
# "paperkind" forces the driver's media explicitly (261 = 24 mm tape). Without
|
||||
# it the driver was observed squeezing content into roughly the first 25 mm.
|
||||
# Set to 0 to omit it and let the driver use its default.
|
||||
#
|
||||
# "disable-auto-rotation" is deliberately NOT used: SumatraPDF's default
|
||||
# auto-rotation is what orients our 65 x 24 mm page onto the 24 x 65 mm media.
|
||||
# Disabling it makes the geometry worse, not better.
|
||||
#
|
||||
# "center" centres the page on the paper once noscale is in effect.
|
||||
paperkind: int = 261
|
||||
|
||||
# -- helpers ----------------------------------------------------------
|
||||
|
||||
@property
|
||||
def sumatra_settings(self) -> str:
|
||||
"""SumatraPDF -print-settings string for this label.
|
||||
|
||||
Pins the scale and the media kind so the QR module pitch reaches the tape
|
||||
unmangled. Auto-rotation is intentionally left enabled: SumatraPDF uses it
|
||||
to place our 65 x 24 mm page onto the driver's 24 x 65 mm media.
|
||||
"""
|
||||
parts = ["noscale"]
|
||||
if self.paperkind:
|
||||
parts.append(f"paperkind={self.paperkind}")
|
||||
parts.append("center")
|
||||
return ",".join(parts)
|
||||
|
||||
@property
|
||||
def col_code_mm(self) -> float:
|
||||
"""Width of the code column.
|
||||
|
||||
When `qr_maximize` is set, the code column takes everything left after the
|
||||
logo column and a minimum usable text column, so the symbol is as large as
|
||||
the printable height allows. The code is square, so its width is bounded by
|
||||
the height; any excess here is simply not used.
|
||||
"""
|
||||
remaining = self.page_width_mm - self.col_logo_mm - self.col_text_mm
|
||||
if not self.qr_maximize:
|
||||
return remaining
|
||||
return max(remaining, self.printable_height_mm)
|
||||
|
||||
@property
|
||||
def effective_col_text_mm(self) -> float:
|
||||
"""Text column width actually used, after the code column is given its space."""
|
||||
if not self.qr_maximize:
|
||||
return self.col_text_mm
|
||||
# Code needs a square region as wide as the printable height.
|
||||
code_need = self.printable_height_mm
|
||||
available_for_text = self.page_width_mm - self.col_logo_mm - code_need
|
||||
return max(self.MIN_TEXT_COL_MM, available_for_text)
|
||||
|
||||
@property
|
||||
def printable_height_mm(self) -> float:
|
||||
return self.page_height_mm - 2 * self.gutter_mm
|
||||
|
||||
@property
|
||||
def page_size_mm(self) -> tuple[float, float]:
|
||||
"""Actual PDF page size, which may be larger than the label content."""
|
||||
if self.match_driver_media:
|
||||
return (self.media_width_mm, self.media_height_mm)
|
||||
return (self.page_width_mm, self.page_height_mm)
|
||||
|
||||
@property
|
||||
def origin_mm(self) -> tuple[float, float]:
|
||||
"""Bottom-left corner of the label content within the PDF page.
|
||||
|
||||
When `center_in_media` is False the content is placed at the driver's
|
||||
printable-area offset instead of being centred. That matters when the
|
||||
printable window is not centred on the page.
|
||||
"""
|
||||
page_w, page_h = self.page_size_mm
|
||||
if not self.match_driver_media:
|
||||
return (0.0, 0.0)
|
||||
if self.center_in_media:
|
||||
return (
|
||||
(page_w - self.page_width_mm) / 2,
|
||||
(page_h - self.page_height_mm) / 2,
|
||||
)
|
||||
return (self.media_offset_x_mm, self.media_offset_y_mm)
|
||||
|
||||
@property
|
||||
def line_count(self) -> int:
|
||||
return max(1, len(self.static_lines) + 1) # static lines + variable
|
||||
|
||||
def build_code_payload(self, inventory: str) -> str:
|
||||
"""Compose the code payload from prefix + inventory + suffix.
|
||||
|
||||
The composed form is used for both QR and Code128, so the operator can
|
||||
build e.g. `INV-1234-A` out of a fixed prefix, the variable field, and a
|
||||
fixed suffix.
|
||||
|
||||
A legacy `qr_template` (e.g. "INV:{inventory}") is still honoured when no
|
||||
prefix/suffix are set, so existing config files keep working.
|
||||
"""
|
||||
inventory = (inventory or "").strip()
|
||||
|
||||
# Legacy template support: only when the new fields are untouched.
|
||||
template = (self.qr_template or "").strip()
|
||||
if template and not self.qr_prefix and not self.qr_suffix:
|
||||
if "{inventory}" in template:
|
||||
return template.replace("{inventory}", inventory)
|
||||
if "{INVENTORY}" in template:
|
||||
return template.replace("{INVENTORY}", inventory)
|
||||
return f"{template}{inventory}" if inventory else template
|
||||
|
||||
return f"{self.qr_prefix}{inventory}{self.qr_suffix}"
|
||||
|
||||
def describe_code_payload(self) -> str:
|
||||
"""Human-readable form of the payload composition, for settings UIs."""
|
||||
prefix = self.qr_prefix or "(none)"
|
||||
suffix = self.qr_suffix or "(none)"
|
||||
return f"{prefix} + <inventory> + {suffix}"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
def copy(self) -> "LabelConfig":
|
||||
"""An independent copy, so fitting for one printer cannot mutate another."""
|
||||
clone = LabelConfig.from_dict(self.to_dict())
|
||||
clone.static_lines = list(self.static_lines)
|
||||
return clone
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "LabelConfig":
|
||||
"""Build a config, ignoring unknown keys so old files keep loading."""
|
||||
valid = {f for f in cls.__dataclass_fields__}
|
||||
clean = {k: v for k, v in (data or {}).items() if k in valid}
|
||||
cfg = cls(**clean)
|
||||
if not isinstance(cfg.static_lines, list):
|
||||
cfg.static_lines = list(cls().static_lines)
|
||||
return cfg
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Box:
|
||||
"""A rectangle in millimetres, measured from the bottom-left corner."""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
w: float
|
||||
h: float
|
||||
|
||||
@property
|
||||
def cx(self) -> float:
|
||||
return self.x + self.w / 2
|
||||
|
||||
@property
|
||||
def cy(self) -> float:
|
||||
return self.y + self.h / 2
|
||||
|
||||
|
||||
def compute_geometry(cfg: LabelConfig) -> dict:
|
||||
"""Return every box and text baseline in millimetres.
|
||||
|
||||
Coordinates are relative to the label content (origin at the label's
|
||||
bottom-left). `origin_mm` gives where that content sits on the PDF page,
|
||||
which matters when `match_driver_media` enlarges the page.
|
||||
|
||||
Both renderers (PDF and preview) consume this dict, which is what keeps
|
||||
the preview honest.
|
||||
"""
|
||||
g = cfg.gutter_mm
|
||||
inner_h = cfg.printable_height_mm
|
||||
|
||||
# Column 1 - logo, centred in its column and vertically centred
|
||||
logo = Box(
|
||||
x=(cfg.col_logo_mm - cfg.logo_box_mm) / 2,
|
||||
y=(cfg.page_height_mm - cfg.logo_box_mm) / 2,
|
||||
w=cfg.logo_box_mm,
|
||||
h=cfg.logo_box_mm,
|
||||
)
|
||||
|
||||
# Column 2 - text block, vertically filling the printable band
|
||||
text_w = cfg.effective_col_text_mm
|
||||
text = Box(
|
||||
x=cfg.col_logo_mm + cfg.text_padding_mm,
|
||||
y=g,
|
||||
w=text_w - 2 * cfg.text_padding_mm,
|
||||
h=inner_h,
|
||||
)
|
||||
|
||||
# Column 3 - code. A QR symbol is square, so when maximising we fill the
|
||||
# printable height and let the width follow; otherwise use the configured box.
|
||||
if cfg.qr_maximize:
|
||||
code_size = inner_h
|
||||
else:
|
||||
code_size = cfg.qr_box_mm
|
||||
code = Box(
|
||||
x=(
|
||||
cfg.col_logo_mm
|
||||
+ text_w
|
||||
+ (cfg.page_width_mm - cfg.col_logo_mm - text_w - code_size) / 2
|
||||
),
|
||||
y=(cfg.page_height_mm - code_size) / 2,
|
||||
w=code_size,
|
||||
h=code_size,
|
||||
)
|
||||
|
||||
# Text lines, distributed evenly through the printable band. Line 0 is the
|
||||
# topmost static line; the last line is the variable inventory value.
|
||||
leading = inner_h / cfg.line_count
|
||||
lines = []
|
||||
for i in range(cfg.line_count):
|
||||
line_top = g + inner_h - i * leading
|
||||
line_bottom = line_top - leading
|
||||
is_variable = i == cfg.line_count - 1
|
||||
font_pt = cfg.variable_font_pt if is_variable else cfg.static_font_pt
|
||||
cap_mm = 0.72 * font_pt * MM_PER_PT
|
||||
# Centre the cap height of the glyphs inside the line slot.
|
||||
baseline = line_bottom + leading / 2 - cap_mm / 2
|
||||
lines.append(
|
||||
{
|
||||
"index": i,
|
||||
"box": Box(text.x, line_bottom, text.w, leading),
|
||||
"baseline_mm": baseline,
|
||||
"font_pt": font_pt,
|
||||
"is_variable": is_variable,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"page": (cfg.page_width_mm, cfg.page_height_mm),
|
||||
"printable": Box(0, g, cfg.page_width_mm, inner_h),
|
||||
"logo": logo,
|
||||
"text": text,
|
||||
"code": code,
|
||||
"lines": lines,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fonts (preview only - the PDF uses built-in Helvetica)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_FONT_CANDIDATES = [
|
||||
(r"C:\Windows\Fonts\arialbd.ttf", r"C:\Windows\Fonts\arial.ttf"),
|
||||
(r"C:\Windows\Fonts\segoeuib.ttf", r"C:\Windows\Fonts\segoeui.ttf"),
|
||||
("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
|
||||
]
|
||||
|
||||
|
||||
def _find_fonts() -> tuple[str, str]:
|
||||
"""Locate a bold/regular TrueType pair, falling back to DejaVu."""
|
||||
for bold, regular in _FONT_CANDIDATES:
|
||||
if os.path.exists(bold) and os.path.exists(regular):
|
||||
return bold, regular
|
||||
return "", ""
|
||||
|
||||
|
||||
def _preview_font(size_pt: float, bold: bool, dpi: int):
|
||||
"""Return a PIL font at the given point size for the requested DPI."""
|
||||
from PIL import ImageFont
|
||||
|
||||
px = max(6, int(round(size_pt * dpi / 72.0)))
|
||||
bold_path, regular_path = _find_fonts()
|
||||
path = bold_path if bold else regular_path
|
||||
if path:
|
||||
try:
|
||||
return ImageFont.truetype(path, px)
|
||||
except OSError:
|
||||
pass
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Code (QR / barcode) generation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: Native resolution of the PT-E560BT print head.
|
||||
PRINTER_DPI = 180
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodeBitmap:
|
||||
"""A generated code symbol plus the exact size it should occupy."""
|
||||
|
||||
image: object # PIL.Image - 1-bit-friendly 'L' mode
|
||||
width_mm: float
|
||||
height_mm: float
|
||||
modules: int # module count across the symbol (QR) or bars (Code128)
|
||||
dots_per_module: float
|
||||
|
||||
|
||||
def build_code_bitmap(cfg: LabelConfig, payload: str, target_dots: int) -> CodeBitmap | None:
|
||||
"""Render the code so its modules land on whole printer dots.
|
||||
|
||||
Thermal print heads cannot render a fraction of a dot, so a symbol scaled to
|
||||
an arbitrary size gets uneven modules - some 3 dots wide, some 4 - which is
|
||||
the main reason small QR codes fail to scan. This snaps the symbol to an
|
||||
integer number of dots per module and returns the resulting exact size.
|
||||
"""
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
try:
|
||||
if cfg.code_type == "code128":
|
||||
return _build_code128(cfg, payload, target_dots)
|
||||
return _build_qr(cfg, payload, target_dots)
|
||||
except Exception as exc: # noqa: BLE001 - never crash a print job
|
||||
print(f"Code generation error for '{payload}': {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def _build_qr(cfg: LabelConfig, payload: str, target_dots: int) -> CodeBitmap:
|
||||
"""Build a dot-aligned QR symbol."""
|
||||
import qrcode
|
||||
from qrcode.constants import (
|
||||
ERROR_CORRECT_H,
|
||||
ERROR_CORRECT_L,
|
||||
ERROR_CORRECT_M,
|
||||
ERROR_CORRECT_Q,
|
||||
)
|
||||
|
||||
levels = {
|
||||
"L": ERROR_CORRECT_L,
|
||||
"M": ERROR_CORRECT_M,
|
||||
"Q": ERROR_CORRECT_Q,
|
||||
"H": ERROR_CORRECT_H,
|
||||
}
|
||||
qr = qrcode.QRCode(
|
||||
version=None,
|
||||
error_correction=levels.get(cfg.qr_error_correction.upper(), ERROR_CORRECT_M),
|
||||
box_size=1,
|
||||
border=cfg.qr_border_modules,
|
||||
)
|
||||
qr.add_data(payload)
|
||||
qr.make(fit=True)
|
||||
|
||||
modules = qr.modules_count + 2 * cfg.qr_border_modules
|
||||
dots_per_module = max(1, target_dots // modules)
|
||||
total_dots = modules * dots_per_module
|
||||
|
||||
# Render at one pixel per module, then scale by whole pixels with NEAREST so
|
||||
# every module stays perfectly square and edge-aligned.
|
||||
base = qr.make_image(fill_color="black", back_color="white").convert("L")
|
||||
img = base.resize((total_dots, total_dots), Image.NEAREST)
|
||||
|
||||
size_mm = total_dots / PRINTER_DPI * 25.4
|
||||
return CodeBitmap(
|
||||
image=img,
|
||||
width_mm=size_mm,
|
||||
height_mm=size_mm,
|
||||
modules=modules,
|
||||
dots_per_module=float(dots_per_module),
|
||||
)
|
||||
|
||||
|
||||
def _build_code128(cfg: LabelConfig, payload: str, target_dots: int) -> CodeBitmap:
|
||||
"""Build a Code128 barcode sized to whole dots per narrow bar."""
|
||||
from barcode import Code128
|
||||
from barcode.writer import ImageWriter
|
||||
|
||||
instance = Code128(payload, writer=ImageWriter())
|
||||
# Total symbol width in narrow modules (encoding bars + quiet zone).
|
||||
modules = len(instance.build()[0])
|
||||
|
||||
# Render one pixel per module at the printer's native resolution.
|
||||
temp_base = os.path.join(
|
||||
tempfile.gettempdir(),
|
||||
f"ptouch_code_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}",
|
||||
)
|
||||
instance.save(temp_base, options={"write_text": False, "module_height": 8, "font_size": 0})
|
||||
path = temp_base + ".png"
|
||||
try:
|
||||
raw = Image.open(path)
|
||||
raw.load()
|
||||
raw = raw.convert("L")
|
||||
finally:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
dots_per_module = max(1, target_dots // modules)
|
||||
total_dots = modules * dots_per_module
|
||||
img = raw.resize((total_dots, raw.height * dots_per_module), Image.NEAREST)
|
||||
|
||||
return CodeBitmap(
|
||||
image=img,
|
||||
width_mm=total_dots / PRINTER_DPI * 25.4,
|
||||
height_mm=img.height / PRINTER_DPI * 25.4,
|
||||
modules=modules,
|
||||
dots_per_module=float(dots_per_module),
|
||||
)
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Renderers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class PtouchLabelRenderer:
|
||||
"""Renders the three-column P-touch label to PDF and to a preview image."""
|
||||
|
||||
def __init__(self, config: LabelConfig | None = None):
|
||||
self.config = config or LabelConfig()
|
||||
|
||||
# -- shared -----------------------------------------------------------
|
||||
|
||||
def _load_logo(self):
|
||||
"""Open the logo, flatten transparency and mode for thermal printing."""
|
||||
from PIL import Image
|
||||
|
||||
path = self.config.logo_path
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
img = Image.open(path)
|
||||
img.load()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"Logo load error '{path}': {exc}")
|
||||
return None
|
||||
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
img = img.convert("RGBA")
|
||||
background = Image.new("RGBA", img.size, (255, 255, 255, 255))
|
||||
img = Image.alpha_composite(background, img)
|
||||
img = img.convert("L")
|
||||
|
||||
if self.config.logo_dither:
|
||||
# Thermal transfer is 1-bit; dither so greyscale logos survive.
|
||||
img = img.convert("1").convert("L")
|
||||
return img
|
||||
|
||||
def _fit_into(self, img, box: Box, dpi: int):
|
||||
"""Scale an image to fit inside a box, preserving aspect ratio."""
|
||||
target_w = max(1, int(round(box.w / 25.4 * dpi)))
|
||||
target_h = max(1, int(round(box.h / 25.4 * dpi)))
|
||||
ratio = min(target_w / img.width, target_h / img.height)
|
||||
new_size = (
|
||||
max(1, int(round(img.width * ratio))),
|
||||
max(1, int(round(img.height * ratio))),
|
||||
)
|
||||
return img.resize(new_size, Image.LANCZOS)
|
||||
|
||||
def _code_box(self, cfg: LabelConfig, bitmap: CodeBitmap | None) -> Box:
|
||||
"""Centre the code symbol in its column, at its dot-aligned size.
|
||||
|
||||
The symbol has a fixed physical size (whole dots per module), so it is
|
||||
placed centred rather than stretched to fill the column. If it is larger
|
||||
than the column it is clamped, which shrinks the module pitch slightly -
|
||||
`code_density_warning` reports that case.
|
||||
"""
|
||||
geo = compute_geometry(cfg)["code"]
|
||||
if bitmap is None:
|
||||
return geo
|
||||
w = min(bitmap.width_mm, geo.w)
|
||||
h = min(bitmap.height_mm, geo.h)
|
||||
return Box(x=geo.cx - w / 2, y=geo.cy - h / 2, w=w, h=h)
|
||||
|
||||
def code_bitmap(self, inventory: str) -> CodeBitmap | None:
|
||||
"""The dot-aligned code symbol for this label, ready to embed."""
|
||||
cfg = self.config
|
||||
return build_code_bitmap(cfg, cfg.build_code_payload(inventory), self._code_target_dots(cfg))
|
||||
|
||||
def _line_text(self, index: int, inventory: str) -> str:
|
||||
"""Text for a given line index; the last line is the inventory value."""
|
||||
lines = self.config.static_lines
|
||||
if index >= len(lines):
|
||||
return (inventory or "").strip()
|
||||
return lines[index] if index < len(lines) else ""
|
||||
|
||||
# -- PDF --------------------------------------------------------------
|
||||
|
||||
def render_pdf(self, inventory: str, filename: str | None = None) -> str:
|
||||
"""Write the label to a PDF at exactly the configured page size.
|
||||
|
||||
Returns the filename. Passing None writes to a temp file.
|
||||
"""
|
||||
cfg = self.config
|
||||
geo = compute_geometry(cfg)
|
||||
payload = cfg.build_code_payload(inventory)
|
||||
|
||||
if not filename:
|
||||
filename = os.path.join(
|
||||
tempfile.gettempdir(),
|
||||
f"ptouch_label_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.pdf",
|
||||
)
|
||||
|
||||
page_w, page_h = cfg.page_size_mm
|
||||
ox, oy = cfg.origin_mm
|
||||
c = canvas.Canvas(filename, pagesize=(page_w * mm, page_h * mm))
|
||||
c.setTitle(f"P-touch label {inventory}".strip())
|
||||
|
||||
# Optional hairline at the label content bounds, for calibration prints.
|
||||
if cfg.show_alignment_box:
|
||||
c.setStrokeColorRGB(0, 0, 0)
|
||||
c.setLineWidth(0.25)
|
||||
c.rect(ox * mm, oy * mm, cfg.page_width_mm * mm, cfg.page_height_mm * mm)
|
||||
|
||||
# Column 1 - logo
|
||||
logo = self._load_logo()
|
||||
if logo is not None:
|
||||
box = geo["logo"]
|
||||
temp_logo = os.path.join(
|
||||
tempfile.gettempdir(),
|
||||
f"ptouch_logo_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.png",
|
||||
)
|
||||
logo.save(temp_logo, "PNG")
|
||||
try:
|
||||
c.drawImage(
|
||||
temp_logo,
|
||||
(ox + box.x) * mm,
|
||||
(oy + box.y) * mm,
|
||||
width=box.w * mm,
|
||||
height=box.h * mm,
|
||||
preserveAspectRatio=True,
|
||||
anchor="c",
|
||||
mask=None,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
os.remove(temp_logo)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Column 2 - three static lines plus the variable line
|
||||
for line in geo["lines"]:
|
||||
text = self._line_text(line["index"], inventory)
|
||||
if not text:
|
||||
continue
|
||||
c.setFont("Helvetica-Bold", line["font_pt"])
|
||||
baseline_y = (oy + line["baseline_mm"]) * mm
|
||||
# Shrink to fit rather than letting text run into the code column.
|
||||
max_w = line["box"].w * mm
|
||||
size = line["font_pt"]
|
||||
while size > 4 and c.stringWidth(text, "Helvetica-Bold", size) > max_w:
|
||||
size -= 0.25
|
||||
c.setFont("Helvetica-Bold", size)
|
||||
c.drawString((ox + line["box"].x) * mm, baseline_y, text)
|
||||
|
||||
# Column 3 - QR / barcode
|
||||
bitmap = build_code_bitmap(cfg, payload, self._code_target_dots(cfg))
|
||||
if bitmap is not None:
|
||||
box = self._code_box(cfg, bitmap)
|
||||
temp_code = os.path.join(
|
||||
tempfile.gettempdir(),
|
||||
f"ptouch_code_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.png",
|
||||
)
|
||||
bitmap.image.save(temp_code, "PNG")
|
||||
try:
|
||||
c.drawImage(
|
||||
temp_code,
|
||||
(ox + box.x) * mm,
|
||||
(oy + box.y) * mm,
|
||||
width=box.w * mm,
|
||||
height=box.h * mm,
|
||||
mask=None,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
os.remove(temp_code)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
c.showPage()
|
||||
c.save()
|
||||
return filename
|
||||
|
||||
# -- preview ----------------------------------------------------------
|
||||
|
||||
def render_preview(self, inventory: str, dpi: int = 300):
|
||||
"""Render the label to a PIL image using the identical geometry."""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
cfg = self.config
|
||||
geo = compute_geometry(cfg)
|
||||
payload = cfg.build_code_payload(inventory)
|
||||
|
||||
page_w_mm, page_h_mm = cfg.page_size_mm
|
||||
ox, oy = cfg.origin_mm
|
||||
|
||||
def px(mm_value: float) -> float:
|
||||
return mm_value / 25.4 * dpi
|
||||
|
||||
def py(mm_from_bottom: float) -> float:
|
||||
# Layout is measured from the bottom-left; PIL's origin is top-left.
|
||||
return (page_h_mm - mm_from_bottom) / 25.4 * dpi
|
||||
|
||||
width = int(round(px(page_w_mm)))
|
||||
height = int(round(px(page_h_mm)))
|
||||
img = Image.new("L", (width, height), "white")
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# When the page is larger than the label content (driver media match),
|
||||
# shade the slack so the operator can see where the label sits.
|
||||
if cfg.match_driver_media:
|
||||
draw.rectangle(
|
||||
[
|
||||
int(round(px(ox))),
|
||||
int(round(py(oy + cfg.page_height_mm))),
|
||||
int(round(px(ox + cfg.page_width_mm))),
|
||||
int(round(py(oy))),
|
||||
],
|
||||
outline=0,
|
||||
width=1,
|
||||
)
|
||||
|
||||
# Column 1 - logo, centred in its box
|
||||
logo = self._load_logo()
|
||||
box = geo["logo"]
|
||||
if logo is not None:
|
||||
fitted = self._fit_into(logo, box, dpi)
|
||||
img.paste(
|
||||
fitted,
|
||||
(
|
||||
int(round(px(ox + box.x + (box.w - fitted.width / dpi * 25.4) / 2))),
|
||||
int(round(py(oy + box.y + (box.h + fitted.height / dpi * 25.4) / 2))),
|
||||
),
|
||||
)
|
||||
|
||||
# Column 2 - text lines, drawn on their baselines
|
||||
for line in geo["lines"]:
|
||||
text = self._line_text(line["index"], inventory)
|
||||
if not text:
|
||||
continue
|
||||
font = _preview_font(line["font_pt"], bold=True, dpi=dpi)
|
||||
draw.text(
|
||||
(px(ox + line["box"].x), py(oy + line["baseline_mm"])),
|
||||
text,
|
||||
fill=0,
|
||||
font=font,
|
||||
anchor="ls",
|
||||
)
|
||||
|
||||
# Column 3 - code
|
||||
bitmap = build_code_bitmap(cfg, payload, self._code_target_dots(cfg))
|
||||
if bitmap is not None:
|
||||
box = self._code_box(cfg, bitmap)
|
||||
# The bitmap is authored at the printer's native 180 dpi; rescale it
|
||||
# to the preview resolution with NEAREST so modules stay crisp and
|
||||
# the on-screen size matches the printed size exactly.
|
||||
factor = dpi / PRINTER_DPI
|
||||
scaled = bitmap.image.resize(
|
||||
(
|
||||
max(1, int(round(bitmap.image.width * factor))),
|
||||
max(1, int(round(bitmap.image.height * factor))),
|
||||
),
|
||||
Image.NEAREST,
|
||||
)
|
||||
img.paste(
|
||||
scaled,
|
||||
(int(round(px(ox + box.x))), int(round(py(oy + box.y + box.h)))),
|
||||
)
|
||||
|
||||
return img.convert("RGB")
|
||||
|
||||
# -- diagnostics ------------------------------------------------------
|
||||
|
||||
def _code_target_dots(self, cfg: LabelConfig) -> int:
|
||||
"""Dots available for the code symbol along its smaller axis."""
|
||||
geo = compute_geometry(cfg)["code"]
|
||||
return max(1, int(round(min(geo.w, geo.h) / 25.4 * PRINTER_DPI)))
|
||||
|
||||
def print_resolution_dpi(self) -> int:
|
||||
"""Native resolution of the PT-E560BT print head."""
|
||||
return PRINTER_DPI
|
||||
|
||||
|
||||
def code_density_warning(config: LabelConfig, inventory: str) -> str | None:
|
||||
"""Return a warning string when the code would print too densely to scan."""
|
||||
payload = config.build_code_payload(inventory)
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
geo = compute_geometry(config)["code"]
|
||||
target_dots = max(1, int(round(min(geo.w, geo.h) / 25.4 * PRINTER_DPI)))
|
||||
bitmap = build_code_bitmap(config, payload, target_dots)
|
||||
if bitmap is None:
|
||||
return None
|
||||
|
||||
if bitmap.width_mm > geo.w + 0.01 or bitmap.height_mm > geo.h + 0.01:
|
||||
return (
|
||||
f"Code does not fit: needs {bitmap.width_mm:.1f} x {bitmap.height_mm:.1f} mm "
|
||||
f"but the column is {geo.w:.1f} x {geo.h:.1f} mm even at one dot per "
|
||||
f"module ({bitmap.modules} modules). Shorten the QR string."
|
||||
)
|
||||
|
||||
if bitmap.dots_per_module < MIN_MODULE_DOTS:
|
||||
return (
|
||||
f"Code is dense: {bitmap.dots_per_module:.1f} printer dots per module at "
|
||||
f"{PRINTER_DPI} dpi ({bitmap.modules} modules). Scanning may be "
|
||||
f"unreliable - shorten the QR string or lower error correction."
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,7 @@
|
||||
python-barcode
|
||||
pillow
|
||||
qrcode[pil]
|
||||
kivy
|
||||
reportlab
|
||||
pywin32
|
||||
pypdf
|
||||
@@ -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())
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Headless test of the two-column GUI.
|
||||
|
||||
Instantiates the real app class and drives its handlers without opening a window,
|
||||
so layout wiring and settings logic are verified automatically rather than by
|
||||
looking at the screen.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Keep Kivy from parsing our argv; the real window backend is used but the app
|
||||
# event loop is never started, so nothing is displayed.
|
||||
os.environ.setdefault("KIVY_NO_ARGS", "1")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import label_printer_ptouch_gui as gui
|
||||
|
||||
print("1. Window is a 600 x 300 landscape panel")
|
||||
print(f" WINDOW_SIZE : {gui.WINDOW_SIZE}")
|
||||
landscape = gui.WINDOW_SIZE[0] > gui.WINDOW_SIZE[1]
|
||||
print(f" landscape : {landscape} {'PASS' if landscape else 'FAIL'}")
|
||||
|
||||
print("\n2. App.config is not shadowed (the bug that crashed the settings panel)")
|
||||
app = gui.PtouchPrinterApp()
|
||||
has_collision = "config" in vars(app) and not hasattr(vars(app)["config"], "get")
|
||||
print(f" app.config : {type(app.config).__name__}")
|
||||
print(f" label_cfg : {type(app.label_cfg).__name__}")
|
||||
cfg_ok = type(app.label_cfg).__name__ == "LabelConfig"
|
||||
print(f" config valid: {cfg_ok} {'PASS' if cfg_ok else 'FAIL'}")
|
||||
|
||||
print("\n3. Two-column root layout")
|
||||
root = app.build()
|
||||
print(f" orientation : {root.orientation}")
|
||||
print(f" columns : {len(root.children)}")
|
||||
cols_ok = root.orientation == "horizontal" and len(root.children) == 2
|
||||
print(f" {'PASS' if cols_ok else 'FAIL'}")
|
||||
|
||||
# Children are in reverse order of addition: preview column first, then controls.
|
||||
preview_col, controls = root.children
|
||||
print(f" controls col: {controls.orientation}, {len(controls.children)} items")
|
||||
print(f" preview col : {preview_col.orientation}, {len(preview_col.children)} items")
|
||||
|
||||
print("\n4. Required widgets exist")
|
||||
for name in (
|
||||
"inventory_input",
|
||||
"printer_spinner",
|
||||
"print_button",
|
||||
"preview",
|
||||
"status_label",
|
||||
):
|
||||
present = hasattr(app, name)
|
||||
print(f" {name:<18}: {'yes' if present else 'NO'}")
|
||||
if not present:
|
||||
return 1
|
||||
|
||||
print("\n5. Settings handlers are reachable (would have raised before)")
|
||||
# Accessing these properties is what failed previously.
|
||||
_ = app.label_cfg.logo_path
|
||||
_ = app.label_cfg.static_lines
|
||||
_ = app.label_cfg.qr_prefix
|
||||
_ = app.label_cfg.qr_suffix
|
||||
_ = app.label_cfg.qr_error_correction
|
||||
print(" loaded without error: PASS")
|
||||
|
||||
print("\n6. Preview render works headless")
|
||||
app.update_preview()
|
||||
print(f" status text : {app.status_label.text!r}")
|
||||
print(f" texture set : {app.preview._texture is not None}")
|
||||
|
||||
print("\n7. QR payload is built from the inventory field")
|
||||
app.inventory_input.text = "INV-42"
|
||||
app.update_preview()
|
||||
print(f" status text : {app.status_label.text!r}")
|
||||
payload_ok = "INV-42" in app.status_label.text
|
||||
print(f" reflects field: {payload_ok} {'PASS' if payload_ok else 'FAIL'}")
|
||||
|
||||
print("\n" + "=" * 62)
|
||||
passed = landscape and cfg_ok and cols_ok and payload_ok
|
||||
print("RESULT:", "PASS" if passed else "FAIL")
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Test that the print layer reports failure honestly instead of always True."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from print_label import print_to_printer, _sumatra_print, SUMATRA_EXIT_CODES
|
||||
|
||||
SUMATRA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "SumatraPDF", "SumatraPDF.exe")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("1. SumatraPDF exit-code handling")
|
||||
# Nonexistent printer must fail, not silently succeed.
|
||||
ok, message = _sumatra_print(SUMATRA, "NoSuchPrinter_XYZ", "nope.pdf", "noscale")
|
||||
print(f" missing file + printer -> success={ok}")
|
||||
print(f" message: {message}")
|
||||
file_missing_ok = not ok
|
||||
|
||||
print("\n2. Documented exit codes are mapped")
|
||||
for code in sorted(SUMATRA_EXIT_CODES):
|
||||
print(f" {code}: {SUMATRA_EXIT_CODES[code]}")
|
||||
|
||||
print("\n3. print_to_printer on a nonexistent PDF must return False")
|
||||
result = print_to_printer("Brother PT-E560BT", "does_not_exist.pdf", settings="noscale")
|
||||
print(f" returned: {result} {'PASS' if result is False else 'FAIL'}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
passed = file_missing_ok and result is False
|
||||
print("RESULT:", "PASS" if passed else "FAIL")
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,135 @@
|
||||
"""End-to-end test of the P-touch print pipeline (no hardware required).
|
||||
|
||||
Exercises config persistence, PDF generation, the "PDF" pseudo-printer path, and
|
||||
variable-length QR payloads, then verifies the output dimensions.
|
||||
|
||||
IMPORTANT: this test writes to a temporary config file. It must never call
|
||||
save_config() with the default path, because that overwrites the operator's real
|
||||
settings in label_config.json.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from reportlab.lib.units import mm
|
||||
from pypdf import PdfReader
|
||||
|
||||
import label_config
|
||||
from print_label import print_ptouch_label
|
||||
from ptouch_label import LabelConfig, PtouchLabelRenderer, code_density_warning
|
||||
|
||||
#: Isolated directory for config written by this test.
|
||||
TEMP_DIR = tempfile.mkdtemp(prefix="ptouch_test_")
|
||||
|
||||
|
||||
def make_test_logo(path):
|
||||
"""Create a greyscale logo with transparency to exercise the flatten path."""
|
||||
img = Image.new("RGBA", (400, 400), (255, 255, 255, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.ellipse((20, 20, 380, 380), fill=(0, 0, 0, 255))
|
||||
draw.rectangle((150, 150, 250, 250), fill=(255, 255, 255, 255))
|
||||
img.save(path)
|
||||
return path
|
||||
|
||||
|
||||
def main():
|
||||
base = os.path.dirname(os.path.abspath(__file__))
|
||||
logo_path = os.path.join(TEMP_DIR, "test_logo.png")
|
||||
make_test_logo(logo_path)
|
||||
|
||||
cfg = LabelConfig()
|
||||
cfg.logo_path = logo_path
|
||||
cfg.static_lines = ["MOTO ADV SRL", "Articol: CABLU", "Rev: 2026-09"]
|
||||
cfg.qr_prefix = "INV-"
|
||||
cfg.qr_suffix = "-A"
|
||||
cfg.qr_error_correction = "M"
|
||||
|
||||
print("=" * 68)
|
||||
print("1. Config persistence round-trip (in a temp dir, real config untouched)")
|
||||
real_cfg_path = label_config.config_path()
|
||||
real_before = open(real_cfg_path).read() if os.path.exists(real_cfg_path) else None
|
||||
|
||||
path = label_config.save_config(cfg, base_dir=TEMP_DIR)
|
||||
reloaded = label_config.load_config(base_dir=TEMP_DIR)
|
||||
print(f" saved to : {path}")
|
||||
print(f" logo preserved: {reloaded.logo_path == cfg.logo_path}")
|
||||
print(f" lines preserved: {reloaded.static_lines == cfg.static_lines}")
|
||||
print(f" prefix/suffix kept: "
|
||||
f"{reloaded.qr_prefix == cfg.qr_prefix and reloaded.qr_suffix == cfg.qr_suffix}")
|
||||
composed = reloaded.build_code_payload("1234")
|
||||
print(f" composed payload : {composed!r} "
|
||||
f"{'PASS' if composed == 'INV-1234-A' else 'FAIL'}")
|
||||
|
||||
real_after = open(real_cfg_path).read() if os.path.exists(real_cfg_path) else None
|
||||
untouched = real_before == real_after
|
||||
print(f" real config untouched: {'PASS' if untouched else 'FAIL'}")
|
||||
|
||||
print("\n2. Unknown/corrupt keys are tolerated")
|
||||
weird = LabelConfig.from_dict({"static_lines": "not-a-list", "bogus_key": 1})
|
||||
print(f" static_lines fell back: {weird.static_lines == LabelConfig().static_lines}")
|
||||
|
||||
print("\n3. Print pipeline via the 'PDF' pseudo-printer")
|
||||
success, pdf_path, warning = print_ptouch_label("INV-00042", "PDF", cfg)
|
||||
print(f" success : {success}")
|
||||
print(f" pdf : {pdf_path}")
|
||||
print(f" warning : {warning or 'none'}")
|
||||
|
||||
page = PdfReader(pdf_path).pages[0]
|
||||
w_mm = float(page.mediabox.width) / mm
|
||||
h_mm = float(page.mediabox.height) / mm
|
||||
# With match_driver_media the page is the driver's media size and the label
|
||||
# content is centred inside it; otherwise page size == content size.
|
||||
want_w, want_h = cfg.page_size_mm
|
||||
exact = abs(w_mm - want_w) < 0.01 and abs(h_mm - want_h) < 0.01
|
||||
print(f" page size : {w_mm:.2f} x {h_mm:.2f} mm {'PASS' if exact else 'FAIL'}")
|
||||
print(f" label content : {cfg.page_width_mm:.1f} x {cfg.page_height_mm:.1f} mm "
|
||||
f"at origin {cfg.origin_mm}")
|
||||
|
||||
print("\n4. QR payload scaling with the inventory value")
|
||||
renderer = PtouchLabelRenderer(cfg)
|
||||
for value in ["A", "INV-00042", "MOTO-2026-0001234567"]:
|
||||
bitmap = renderer.code_bitmap(value)
|
||||
warn = code_density_warning(cfg, value)
|
||||
payload = cfg.build_code_payload(value)
|
||||
print(
|
||||
f" {value:<22} modules={bitmap.modules:>3} "
|
||||
f"size={bitmap.width_mm:5.2f}mm dots/mod={bitmap.dots_per_module:.1f} "
|
||||
f"{'OK' if not warn else 'WARN'}"
|
||||
)
|
||||
print(f" payload: {payload!r}")
|
||||
if warn:
|
||||
print(f" -> {warn}")
|
||||
|
||||
print("\n5. Oversized payload is reported, not silently misprinted")
|
||||
huge = "X" * 300
|
||||
warn = code_density_warning(cfg, huge)
|
||||
print(f" warning present: {warn is not None}")
|
||||
print(f" -> {warn}")
|
||||
|
||||
print("\n6. Preview render (what the GUI shows)")
|
||||
preview = renderer.render_preview("INV-00042", dpi=300)
|
||||
print(f" preview size : {preview.size} px @300dpi")
|
||||
preview.save(os.path.join(base, "test_ptouch_preview.png"))
|
||||
print(f" saved : test_ptouch_preview.png")
|
||||
|
||||
# Tidy up the test artifacts
|
||||
for temp in (logo_path, pdf_path):
|
||||
try:
|
||||
if os.path.exists(temp):
|
||||
os.remove(temp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
print("\n" + "=" * 68)
|
||||
passed = exact and success and untouched
|
||||
print("RESULT:", "PASS" if passed else "FAIL")
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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