Add P-touch Labels for Asset: 63x20 mm label format for PT-E560BT

New three-column label format for Brother P-touch EDGE PT-E560BT asset tags:
logo | three static lines + variable field | QR code.

Layout and rendering
- ptouch_label.py: geometry engine with PDF and preview renderers. Both
  consume the same geometry, so the preview matches the print exactly.
- Code symbols are snapped to whole printer dots per module. Fractional
  modules are the usual reason a small QR code will not scan.
- QR is maximised to the printable height by default (16.4 mm, 4 dots per
  module) instead of a fixed 16 mm box (12.3 mm, 3 dots per module).
- Code payload is composed as prefix + inventory + suffix. A legacy
  {inventory} template is migrated on first save.

Hardware handling
- print_area.py reads the driver's real printable area via GetDeviceCaps.
  The PT-E560BT head is 128 dots at 180 dpi, so the maximum print height is
  18.06 mm even on 24 mm tape; the driver reports media as 65x24 mm but only
  about 61x18 mm is reachable. Content is fitted to the reachable area,
  otherwise the driver silently crops it.
- set_printer_resolution.py reads/switches the driver's resolution mode. The
  driver offers 180x180 (symmetric) and 180x360 (anisotropic, 2x across the
  tape); the anisotropic mode scaled output incorrectly.

GUI
- label_printer_ptouch_gui.py: 600x300 two-column panel - controls on the
  left, live preview on the right. Advanced settings for logo, static lines,
  code prefix/suffix and error correction.
- native_file_dialog.py: the standard Windows file picker, run in a
  subprocess because Tk and Kivy both want the main event loop.

Printing
- Print failures are reported honestly. print_to_printer previously returned
  True unconditionally, so a failed print claimed success and was logged as
  SUCCESS.
- SumatraPDF exit codes are now checked (4 = no printer, 5 = driver error).
- The PDF is sent with noscale so driver-side scaling cannot distort the QR
  module pitch.

Tests
- validate_ptouch_layout.py decodes the QR back out of the rendered preview
  module by module, proving the preview and PDF are the same layout.
- test_ptouch_pipeline.py writes to a temp config so it cannot overwrite
  operator settings.
- test_gui_layout.py exercises the real app class headlessly.
- test_print_reporting.py covers the failure paths.

Diagnostics: check_print_area.py, check_print_geometry.py, check_devmode.py,
print_calibration.py, print_measure_scale.py, capture_print_job.py.
This commit is contained in:
2026-09-25 17:30:11 +03:00
parent 58082ed171
commit 93afa3c417
22 changed files with 3877 additions and 23 deletions
+15
View File
@@ -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
+86
View File
@@ -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.
+177
View File
@@ -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())
+99
View File
@@ -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())
+142
View File
@@ -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())
+99
View File
@@ -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())
+160
View File
@@ -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**.
+43
View File
@@ -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
+850
View File
@@ -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()
+106
View File
@@ -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
View File
@@ -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
+134
View File
@@ -0,0 +1,134 @@
"""
Print a calibration label to verify physical geometry on the PT-E560BT.
The label carries four things that can be measured with a ruler on the tape:
1. A hairline box exactly at the 63 x 20 mm content bounds
2. Corner ticks at 10 mm from each corner, for checking scale (a 10 mm tick
measures 10 mm only if the driver did not rescale)
3. A QR symbol with a known 12.28 mm size and 3 dots per module
4. Ruler marks every 10 mm along the bottom edge
Whether the driver is scaling, rotating or offsetting the page can be read
directly off the print, instead of being inferred.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
os.chdir(os.path.dirname(os.path.abspath(__file__)))
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
from label_config import load_config, save_config
from ptouch_label import PRINTER_DPI, LabelConfig, build_code_bitmap
PRINTER = "Brother PT-E560BT"
# One module of the QR at 3 dots/module, i.e. what 0.4233 mm should measure.
MODULE_MM = 3.0 / PRINTER_DPI * 25.4
def build_calibration_pdf(cfg: LabelConfig, path: str) -> str:
page_w, page_h = cfg.page_size_mm
ox, oy = cfg.origin_mm
c = canvas.Canvas(path, pagesize=(page_w * mm, page_h * mm))
c.setTitle("PT-E560BT calibration")
def X(label_mm: float) -> float:
return (ox + label_mm) * mm
def Y(label_mm: float) -> float:
return (oy + label_mm) * mm
# 1. Content bounds, hairline
c.setStrokeColorRGB(0, 0, 0)
c.setLineWidth(0.25)
c.rect(X(0), Y(0), cfg.page_width_mm * mm, cfg.page_height_mm * mm)
# 2. Corner ticks: 10 mm legs just inside the box
c.setLineWidth(0.5)
for cx, cy, dx, dy in [
(0, 0, 1, 1),
(cfg.page_width_mm, 0, -1, 1),
(0, 20, 1, -1),
(cfg.page_width_mm, 20, -1, -1),
]:
c.line(X(cx), Y(cy), X(cx + 10 * dx), Y(cy))
c.line(X(cx), Y(cy), X(cx), Y(cy + 10 * dy))
# 3. Ruler marks every 10 mm along the bottom, labelled
c.setFont("Helvetica", 4)
for i in range(0, 61, 10):
tick_h = 2.0 if i % 20 == 0 else 1.0
c.setLineWidth(0.4)
c.line(X(i + 1), Y(1), X(i + 1), Y(1 + tick_h))
if i % 20 == 0:
c.drawString(X(i + 2), Y(1.2), str(i))
# 4. Column guides at the real boundaries: 20 mm and 45 mm
c.setLineWidth(0.25)
for x in (cfg.col_logo_mm, cfg.col_logo_mm + cfg.col_text_mm):
c.line(X(x), Y(1), X(x), Y(cfg.page_height_mm - 1))
# 5. Labels so the print can be interpreted without this file
c.setFont("Helvetica-Bold", 5)
c.drawString(X(20.8), Y(16.2), "CALIBRATION")
c.setFont("Helvetica", 4)
c.drawString(X(20.8), Y(13.0), "box = 63 x 20 mm")
c.drawString(X(20.8), Y(9.5), "ticks = 10 mm")
c.drawString(X(20.8), Y(6.5), "columns at")
c.drawString(X(20.8), Y(3.5), "20 / 45 mm")
c.drawString(X(20.8), Y(1.6), f"qr mod {MODULE_MM:.3f}mm")
# 6. The QR itself, with a known module size
bitmap = build_code_bitmap(cfg, "CAL", 112)
if bitmap is not None:
temp = os.path.join(os.path.dirname(path), "_cal_qr.png")
bitmap.image.save(temp, "PNG")
geo_box_w = bitmap.width_mm
c.drawImage(
temp,
X(cfg.page_width_mm - 1 - geo_box_w),
Y((cfg.page_height_mm - geo_box_w) / 2),
width=geo_box_w * mm,
height=geo_box_w * mm,
mask=None,
)
os.remove(temp)
print(f"QR: {bitmap.modules} modules, {bitmap.dots_per_module:.0f} dots/module, "
f"{bitmap.width_mm:.2f} mm across")
c.showPage()
c.save()
return path
def main() -> int:
cfg = load_config()
if not os.path.exists("label_config.json"):
save_config(cfg)
out = os.path.join("pdf_backup", "calibration_63x20.pdf")
os.makedirs("pdf_backup", exist_ok=True)
build_calibration_pdf(cfg, out)
print(f"Page : {cfg.page_size_mm[0]:.1f} x {cfg.page_size_mm[1]:.1f} mm")
print(f"Content : {cfg.page_width_mm:.1f} x {cfg.page_height_mm:.1f} mm "
f"at origin {cfg.origin_mm}")
print(f"Written : {out}")
if "--no-print" in sys.argv:
print("(--no-print given, not sending to printer)")
return 0
from print_label import print_to_printer
ok = print_to_printer(PRINTER, out, settings=cfg.sumatra_settings)
print(f"Printed : {'SUCCESS' if ok else 'FAILED'}")
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())
+175 -20
View File
@@ -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
# falling back to a tiny bitmap font.
font_candidates = [
(r"C:\Windows\Fonts\arialbd.ttf", r"C:\Windows\Fonts\arial.ttf"),
(r"C:\Windows\Fonts\segoeuib.ttf", r"C:\Windows\Fonts\segoeui.ttf"),
("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
]
label_font = value_font = None
for bold_path, regular_path in font_candidates:
if os.path.exists(bold_path) and os.path.exists(regular_path):
try: try:
label_font = ImageFont.truetype(font_path, 16) label_font = ImageFont.truetype(bold_path, 16)
value_font = ImageFont.truetype(font_path, 14) value_font = ImageFont.truetype(regular_path, 14)
except IOError: 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}")
ok, message = _sumatra_print(
sumatra_path, printer_name, file_path,
print_settings
)
print(f"SumatraPDF: {message}")
if ok:
printed = True printed = True
break 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,6 +533,7 @@ 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:
@@ -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
+142
View File
@@ -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())
+56
View File
@@ -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
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
python-barcode
pillow
qrcode[pil]
kivy
reportlab
pywin32
pypdf
+193
View File
@@ -0,0 +1,193 @@
"""
Read or change the PT-E560BT driver's print resolution.
Why this exists: the Brother driver offers two resolution modes
180 x 180 dpi balanced, matches the 128-dot head in both axes
180 x 360 dpi anisotropic - 2x the resolution along the tape feed
In the anisotropic mode the driver reports the media as 24 x 65 mm but rasters
along the tape at 360 dpi, so a page authored for the media dimensions can be
scaled by the wrong factor and printed short. This tool lets the mode be switched
from the command line so the effect can be tested without clicking through the
printer properties dialog.
Changing the resolution modifies a system printer default, so it is reversible:
use --restore, or simply re-run with the other value. Administrator rights are
required; without them the script reports what to change by hand.
Usage:
python set_printer_resolution.py # show current setting
python set_printer_resolution.py --mode 180 # symmetric 180x180
python set_printer_resolution.py --mode 360 # anisotropic 180x360
python set_printer_resolution.py --restore # put back the old value
"""
from __future__ import annotations
import argparse
import json
import os
import sys
PRINTER = "Brother PT-E560BT"
STATE_FILE = ".printer_resolution_backup.json"
# DEVMODE field flags
DM_PRINTQUALITY = 0x00000400
DM_YRESOLUTION = 0x00040000
DM_MODIFY = 0x00000008
DM_IN_BUFFER = 0x00000008
DM_OUT_BUFFER = 0x00000002
def _devmode_field_bits():
return DM_PRINTQUALITY | DM_YRESOLUTION
def current_settings():
"""Return the printer's current resolution-related DEVMODE values."""
import win32print
handle = win32print.OpenPrinter(PRINTER)
try:
info = win32print.GetPrinter(handle, 2)
dm = info["pDevMode"]
return {
"port": info.get("pPortName"),
"paper_size": dm.PaperSize,
"paper_width": dm.PaperWidth,
"paper_length": dm.PaperLength,
"orientation": dm.Orientation,
"print_quality": dm.PrintQuality,
"y_resolution": dm.YResolution,
"fields": dm.Fields,
}
finally:
win32print.ClosePrinter(handle)
def measure_dc():
"""Read the resolution and paper dimensions the driver reports via a DC."""
try:
import win32ui
except ImportError:
return None
try:
dc = win32ui.CreateDC()
dc.CreatePrinterDC(PRINTER)
except Exception as exc: # noqa: BLE001
print(f" Could not create DC: {exc}")
return None
try:
return {
"logpixelsx": dc.GetDeviceCaps(88),
"logpixelsy": dc.GetDeviceCaps(90),
"physicalwidth": dc.GetDeviceCaps(110),
"physicalheight": dc.GetDeviceCaps(111),
"horzres": dc.GetDeviceCaps(8),
"vertres": dc.GetDeviceCaps(10),
}
finally:
try:
dc.DeleteDC()
except Exception: # noqa: BLE001
pass
def show(label=""):
settings = current_settings()
caps = measure_dc()
print(f"\n{label}")
print(f" DEVMODE PrintQuality : {settings['print_quality']}")
print(f" DEVMODE YResolution : {settings['y_resolution']}")
print(f" DEVMODE PaperSize : {settings['paper_size']} "
f"({settings['paper_width']/10:.1f} x {settings['paper_length']/10:.1f} mm)")
if caps:
x, y = caps["logpixelsx"], caps["logpixelsy"]
if x and y:
print(f" DC resolution : {x} x {y} dpi", end="")
if max(x, y) / min(x, y) >= 1.9:
print(" <-- ANISOTROPIC (2x mismatch between axes)")
else:
print(" (symmetric)")
print(f" Physical page : {caps['physicalwidth']} x "
f"{caps['physicalheight']} dots = "
f"{caps['physicalwidth']/max(x,1)*25.4:.2f} x "
f"{caps['physicalheight']/max(y,1)*25.4:.2f} mm")
print(f" Printable area : {caps['horzres']} x {caps['vertres']} dots")
return settings
def apply_mode(mode: str) -> bool:
"""Set the driver resolution mode. mode is '180' or '360'."""
import win32print
y_res = 360 if mode == "360" else 0
try:
handle = win32print.OpenPrinter(
PRINTER, {"DesiredAccess": win32print.PRINTER_ALL_ACCESS}
)
except Exception as exc: # noqa: BLE001
print(f"Cannot open printer for writing: {exc}")
print("Re-run this from an elevated (Administrator) terminal.")
return False
try:
info = win32print.GetPrinter(handle, 2)
dm = info["pDevMode"]
dm.PrintQuality = 180
dm.YResolution = y_res
dm.Fields |= _devmode_field_bits()
info["pDevMode"] = dm
win32print.SetPrinter(handle, 2, info, 0)
print(f"Applied resolution mode {mode} (YResolution={y_res}).")
return True
except Exception as exc: # noqa: BLE001
print(f"Failed to set resolution: {exc}")
print("This normally needs Administrator rights.")
return False
finally:
win32print.ClosePrinter(handle)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices=["180", "360"], help="resolution mode to set")
parser.add_argument("--restore", action="store_true", help="restore the saved value")
args = parser.parse_args()
before = show("BEFORE")
if args.restore:
if not os.path.exists(STATE_FILE):
print(f"\nNo backup found at {STATE_FILE}; nothing to restore.")
return 1
with open(STATE_FILE) as handle:
saved = json.load(handle)
mode = "360" if saved.get("y_resolution") else "180"
print(f"\nRestoring YResolution={saved.get('y_resolution')} (mode {mode})")
if apply_mode(mode):
show("AFTER")
return 0
if not args.mode:
print("\n(no --mode given; nothing changed)")
return 0
# Save the previous value so the change can be undone.
with open(STATE_FILE, "w") as handle:
json.dump(before, handle, indent=2)
print(f"\nSaved previous setting to {STATE_FILE}")
if apply_mode(args.mode):
show("AFTER")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+89
View File
@@ -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())
+37
View File
@@ -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())
+135
View File
@@ -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())
+184
View File
@@ -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())