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/
|
build/
|
||||||
logs/
|
logs/
|
||||||
pdf_backup/
|
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())
|
||||||
+178
-23
@@ -8,6 +8,16 @@ import datetime
|
|||||||
import platform
|
import platform
|
||||||
import subprocess
|
import subprocess
|
||||||
from print_label_pdf import PDFLabelGenerator
|
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
|
# Cross-platform printer support
|
||||||
try:
|
try:
|
||||||
@@ -118,12 +128,25 @@ def create_label_image(text):
|
|||||||
left_margin = 15
|
left_margin = 15
|
||||||
row_spacing = 3
|
row_spacing = 3
|
||||||
|
|
||||||
# Fonts
|
# Fonts - use whatever bold/regular TrueType pair exists on this platform.
|
||||||
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
# The previous hardcoded Linux path always failed on Windows, silently
|
||||||
try:
|
# falling back to a tiny bitmap font.
|
||||||
label_font = ImageFont.truetype(font_path, 16)
|
font_candidates = [
|
||||||
value_font = ImageFont.truetype(font_path, 14)
|
(r"C:\Windows\Fonts\arialbd.ttf", r"C:\Windows\Fonts\arial.ttf"),
|
||||||
except IOError:
|
(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(bold_path, 16)
|
||||||
|
value_font = ImageFont.truetype(regular_path, 14)
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if label_font is None:
|
||||||
label_font = ImageFont.load_default()
|
label_font = ImageFont.load_default()
|
||||||
value_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)
|
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).
|
Print file to printer (cross-platform).
|
||||||
|
|
||||||
@@ -294,19 +441,25 @@ def print_to_printer(printer_name, file_path):
|
|||||||
for sumatra_path in sumatra_paths:
|
for sumatra_path in sumatra_paths:
|
||||||
if os.path.exists(sumatra_path):
|
if os.path.exists(sumatra_path):
|
||||||
try:
|
try:
|
||||||
subprocess.run([
|
# "noscale" keeps the PDF at its true physical
|
||||||
sumatra_path,
|
# size; the P-touch label layout depends on it.
|
||||||
"-print-to",
|
# "center" stops the label hugging one edge when
|
||||||
printer_name,
|
# the page is shorter than the media.
|
||||||
file_path,
|
if settings:
|
||||||
"-print-settings",
|
print_settings = settings
|
||||||
"fit,landscape",
|
elif scale == "noscale":
|
||||||
"-silent",
|
print_settings = "noscale"
|
||||||
"-exit-when-done"
|
else:
|
||||||
], check=False, creationflags=subprocess.CREATE_NO_WINDOW)
|
print_settings = "fit,landscape"
|
||||||
print(f"Label sent to printer via SumatraPDF: {printer_name}")
|
|
||||||
printed = True
|
ok, message = _sumatra_print(
|
||||||
break
|
sumatra_path, printer_name, file_path,
|
||||||
|
print_settings
|
||||||
|
)
|
||||||
|
print(f"SumatraPDF: {message}")
|
||||||
|
if ok:
|
||||||
|
printed = True
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"SumatraPDF error: {e}")
|
print(f"SumatraPDF error: {e}")
|
||||||
|
|
||||||
@@ -370,7 +523,8 @@ def print_to_printer(printer_name, file_path):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if not printed:
|
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("=" * 60)
|
||||||
print("NOTICE: Silent PDF printing requires SumatraPDF")
|
print("NOTICE: Silent PDF printing requires SumatraPDF")
|
||||||
print("SumatraPDF not found (should be bundled inside the app)")
|
print("SumatraPDF not found (should be bundled inside the app)")
|
||||||
@@ -379,7 +533,8 @@ def print_to_printer(printer_name, file_path):
|
|||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print(f"PDF saved to: {file_path}")
|
print(f"PDF saved to: {file_path}")
|
||||||
print("The PDF can be printed manually.")
|
print("The PDF can be printed manually.")
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
# Non-PDF files
|
# Non-PDF files
|
||||||
@@ -394,7 +549,7 @@ def print_to_printer(printer_name, file_path):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Windows print error: {e}")
|
print(f"Windows print error: {e}")
|
||||||
print("PDF backup saved as fallback")
|
print("PDF backup saved as fallback")
|
||||||
return True
|
return False
|
||||||
|
|
||||||
elif SYSTEM == "Darwin":
|
elif SYSTEM == "Darwin":
|
||||||
# macOS: Use lp command
|
# 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