diff --git a/.gitignore b/.gitignore index 82c1a96..2c3b80d 100755 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,18 @@ label/ build/ logs/ pdf_backup/ + +# Python caches +__pycache__/ +*.pyc + +# Machine-specific printer state (per-workstation) +.printer_resolution_backup.json + +# Operator's own layout settings - holds a local logo path, not portable +label_config.json + +# Generated / calibration output +captures/ +*.prn +SumatraPDF/SumatraPDF-settings.txt diff --git a/README_PTOUCH.md b/README_PTOUCH.md new file mode 100644 index 0000000..32fdea6 --- /dev/null +++ b/README_PTOUCH.md @@ -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. diff --git a/capture_print_job.py b/capture_print_job.py new file mode 100644 index 0000000..7645683 --- /dev/null +++ b/capture_print_job.py @@ -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 ... + 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 + 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()) diff --git a/check_devmode.py b/check_devmode.py new file mode 100644 index 0000000..680a3ac --- /dev/null +++ b/check_devmode.py @@ -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}: ") + 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()) diff --git a/check_print_area.py b/check_print_area.py new file mode 100644 index 0000000..ce9b061 --- /dev/null +++ b/check_print_area.py @@ -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()) diff --git a/check_print_geometry.py b/check_print_geometry.py new file mode 100644 index 0000000..8e92c3f --- /dev/null +++ b/check_print_geometry.py @@ -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()) diff --git a/documentation/PTOUCH_63x20.md b/documentation/PTOUCH_63x20.md new file mode 100644 index 0000000..96a8cb0 --- /dev/null +++ b/documentation/PTOUCH_63x20.md @@ -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**. diff --git a/label_config.py b/label_config.py new file mode 100644 index 0000000..9a3e9b1 --- /dev/null +++ b/label_config.py @@ -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 diff --git a/label_printer_ptouch_gui.py b/label_printer_ptouch_gui.py new file mode 100644 index 0000000..ffc8201 --- /dev/null +++ b/label_printer_ptouch_gui.py @@ -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() diff --git a/native_file_dialog.py b/native_file_dialog.py new file mode 100644 index 0000000..1cf598e --- /dev/null +++ b/native_file_dialog.py @@ -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: 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()) diff --git a/print_area.py b/print_area.py new file mode 100644 index 0000000..a289ee2 --- /dev/null +++ b/print_area.py @@ -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 diff --git a/print_calibration.py b/print_calibration.py new file mode 100644 index 0000000..ea7eac8 --- /dev/null +++ b/print_calibration.py @@ -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()) diff --git a/print_label.py b/print_label.py index 4eb8e0e..179dd82 100755 --- a/print_label.py +++ b/print_label.py @@ -8,6 +8,16 @@ import datetime import platform import subprocess from print_label_pdf import PDFLabelGenerator +from ptouch_label import ( + LabelConfig, + PtouchLabelRenderer, + code_density_warning, +) + +try: + from print_area import apply_to_config as apply_print_area +except ImportError: # pragma: no cover - print_area is optional + apply_print_area = None # Cross-platform printer support try: @@ -118,12 +128,25 @@ def create_label_image(text): left_margin = 15 row_spacing = 3 - # Fonts - font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" - try: - label_font = ImageFont.truetype(font_path, 16) - value_font = ImageFont.truetype(font_path, 14) - except IOError: + # Fonts - use whatever bold/regular TrueType pair exists on this platform. + # The previous hardcoded Linux path always failed on Windows, silently + # falling back to a tiny bitmap font. + font_candidates = [ + (r"C:\Windows\Fonts\arialbd.ttf", r"C:\Windows\Fonts\arial.ttf"), + (r"C:\Windows\Fonts\segoeuib.ttf", r"C:\Windows\Fonts\segoeui.ttf"), + ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"), + ] + label_font = value_font = None + for bold_path, regular_path in font_candidates: + if os.path.exists(bold_path) and os.path.exists(regular_path): + try: + label_font = ImageFont.truetype(bold_path, 16) + value_font = ImageFont.truetype(regular_path, 14) + break + except OSError: + continue + if label_font is None: label_font = ImageFont.load_default() value_font = ImageFont.load_default() @@ -225,7 +248,131 @@ def create_label_pdf(text): return generator.create_label_pdf(sap_nr, cantitate, lot_number, pdf_filename) -def print_to_printer(printer_name, file_path): +def create_ptouch_label_pdf(inventory, config=None, filename=None): + """ + Create a PT-E560BT formatted label PDF (63 x 20 mm, three columns). + + Layout: logo | three static lines + variable inventory line | QR code. + + Args: + inventory (str): Value for the variable field and the QR payload + config (LabelConfig): Layout settings (defaults used if None) + filename (str): Output filename (auto-generated in pdf_backup if None) + + Returns: + str: Path to the generated PDF file + """ + cfg = config or LabelConfig() + + pdf_backup_dir = 'pdf_backup' + os.makedirs(pdf_backup_dir, exist_ok=True) + + if not filename: + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f") + safe_value = "".join(c for c in (inventory or "empty") if c.isalnum() or c in "-_")[:20] + filename = os.path.join(pdf_backup_dir, f"ptouch_{safe_value}_{timestamp}.pdf") + + renderer = PtouchLabelRenderer(cfg) + return renderer.render_pdf(inventory, filename) + + +def print_ptouch_label(inventory, printer, config=None): + """ + Generate and print a PT-E560BT label. + + Before rendering, the layout is fitted to the printer's actual printable + area when that can be queried. The PT-E560BT driver rejects anything drawn + outside roughly 61.1 x 18.06 mm, so fitting here prevents the driver from + silently cropping the label. + + Args: + inventory (str): Variable field / QR payload value + printer (str): Target printer name, or "PDF" to only save the file + config (LabelConfig): Layout settings + + Returns: + tuple: (success (bool), pdf_path (str), warning (str or None)) + """ + cfg = config or LabelConfig() + + # Fit to the real printable area (no-op for the "PDF" pseudo-printer). + if apply_print_area is not None and printer and printer != "PDF": + if apply_print_area(cfg, printer): + print( + f"Fitted to printable area: content {cfg.page_width_mm} x " + f"{cfg.page_height_mm} mm on {cfg.media_width_mm} x " + f"{cfg.media_height_mm} mm media" + ) + + warning = code_density_warning(cfg, inventory) + + try: + pdf_path = create_ptouch_label_pdf(inventory, cfg) + print(f"P-touch label PDF created: {pdf_path}") + except Exception as e: + print(f"Error creating P-touch label: {e}") + return False, "", warning + + # The page must be sent at its true size. Any driver-side scaling + # would distort the QR module pitch and break scanning. + success = print_to_printer( + printer, pdf_path, scale=cfg.printer_scale, settings=cfg.sumatra_settings + ) + return success, pdf_path, warning + + +# SumatraPDF exit codes when printing (from its documented behaviour). +SUMATRA_EXIT_CODES = { + 0: "success", + 2: "could not open the file (not found or unsupported format)", + 3: "the document does not allow printing", + 4: "the printer does not exist", + 5: "the printer driver / device failed", + 6: "printing is disabled by restriction policy", +} + + +def _sumatra_print(sumatra_path, printer_name, file_path, settings): + """ + Send a PDF to a printer through SumatraPDF and report the real outcome. + + Args: + sumatra_path (str): Path to SumatraPDF.exe + printer_name (str): Target printer + file_path (str): PDF to print + settings (str): Value for -print-settings, e.g. "noscale,center" + + Returns: + tuple: (success (bool), message (str)) + """ + result = subprocess.run( + [ + sumatra_path, + "-print-to", printer_name, + file_path, + "-print-settings", settings, + "-silent", + "-exit-when-done", + ], + capture_output=True, + text=True, + check=False, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + + code = result.returncode + if code == 0: + return True, f"sent to '{printer_name}' (settings: {settings})" + + reason = SUMATRA_EXIT_CODES.get(code, f"unknown exit code {code}") + detail = (result.stderr or result.stdout or "").strip() + message = f"SumatraPDF failed (exit {code}): {reason}" + if detail: + message += f" - {detail.splitlines()[-1]}" + return False, message + + +def print_to_printer(printer_name, file_path, scale="noscale", settings=None): """ Print file to printer (cross-platform). @@ -294,19 +441,25 @@ def print_to_printer(printer_name, file_path): for sumatra_path in sumatra_paths: if os.path.exists(sumatra_path): try: - subprocess.run([ - sumatra_path, - "-print-to", - printer_name, - file_path, - "-print-settings", - "fit,landscape", - "-silent", - "-exit-when-done" - ], check=False, creationflags=subprocess.CREATE_NO_WINDOW) - print(f"Label sent to printer via SumatraPDF: {printer_name}") - printed = True - break + # "noscale" keeps the PDF at its true physical + # size; the P-touch label layout depends on it. + # "center" stops the label hugging one edge when + # the page is shorter than the media. + if settings: + print_settings = settings + elif scale == "noscale": + print_settings = "noscale" + else: + print_settings = "fit,landscape" + + ok, message = _sumatra_print( + sumatra_path, printer_name, file_path, + print_settings + ) + print(f"SumatraPDF: {message}") + if ok: + printed = True + return True except Exception as e: print(f"SumatraPDF error: {e}") @@ -370,7 +523,8 @@ def print_to_printer(printer_name, file_path): pass if not printed: - # Fallback: Let user know and save PDF + # No silent-print backend worked. Report failure so the + # caller does not claim a label was printed. print("=" * 60) print("NOTICE: Silent PDF printing requires SumatraPDF") print("SumatraPDF not found (should be bundled inside the app)") @@ -379,7 +533,8 @@ def print_to_printer(printer_name, file_path): print("=" * 60) print(f"PDF saved to: {file_path}") print("The PDF can be printed manually.") - + return False + return True else: # Non-PDF files @@ -394,7 +549,7 @@ def print_to_printer(printer_name, file_path): except Exception as e: print(f"Windows print error: {e}") print("PDF backup saved as fallback") - return True + return False elif SYSTEM == "Darwin": # macOS: Use lp command diff --git a/print_measure_scale.py b/print_measure_scale.py new file mode 100644 index 0000000..d5a0b6e --- /dev/null +++ b/print_measure_scale.py @@ -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()) diff --git a/print_test_label.py b/print_test_label.py new file mode 100644 index 0000000..030f188 --- /dev/null +++ b/print_test_label.py @@ -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()) diff --git a/ptouch_label.py b/ptouch_label.py new file mode 100644 index 0000000..0b35cd1 --- /dev/null +++ b/ptouch_label.py @@ -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} + + {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 diff --git a/requirements_ptouch.txt b/requirements_ptouch.txt new file mode 100644 index 0000000..5132600 --- /dev/null +++ b/requirements_ptouch.txt @@ -0,0 +1,7 @@ +python-barcode +pillow +qrcode[pil] +kivy +reportlab +pywin32 +pypdf diff --git a/set_printer_resolution.py b/set_printer_resolution.py new file mode 100644 index 0000000..58654bc --- /dev/null +++ b/set_printer_resolution.py @@ -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()) diff --git a/test_gui_layout.py b/test_gui_layout.py new file mode 100644 index 0000000..79f8ede --- /dev/null +++ b/test_gui_layout.py @@ -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()) diff --git a/test_print_reporting.py b/test_print_reporting.py new file mode 100644 index 0000000..782bdcb --- /dev/null +++ b/test_print_reporting.py @@ -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()) diff --git a/test_ptouch_pipeline.py b/test_ptouch_pipeline.py new file mode 100644 index 0000000..1aa469d --- /dev/null +++ b/test_ptouch_pipeline.py @@ -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()) diff --git a/validate_ptouch_layout.py b/validate_ptouch_layout.py new file mode 100644 index 0000000..7efd1ea --- /dev/null +++ b/validate_ptouch_layout.py @@ -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())