93afa3c417
New three-column label format for Brother P-touch EDGE PT-E560BT asset tags:
logo | three static lines + variable field | QR code.
Layout and rendering
- ptouch_label.py: geometry engine with PDF and preview renderers. Both
consume the same geometry, so the preview matches the print exactly.
- Code symbols are snapped to whole printer dots per module. Fractional
modules are the usual reason a small QR code will not scan.
- QR is maximised to the printable height by default (16.4 mm, 4 dots per
module) instead of a fixed 16 mm box (12.3 mm, 3 dots per module).
- Code payload is composed as prefix + inventory + suffix. A legacy
{inventory} template is migrated on first save.
Hardware handling
- print_area.py reads the driver's real printable area via GetDeviceCaps.
The PT-E560BT head is 128 dots at 180 dpi, so the maximum print height is
18.06 mm even on 24 mm tape; the driver reports media as 65x24 mm but only
about 61x18 mm is reachable. Content is fitted to the reachable area,
otherwise the driver silently crops it.
- set_printer_resolution.py reads/switches the driver's resolution mode. The
driver offers 180x180 (symmetric) and 180x360 (anisotropic, 2x across the
tape); the anisotropic mode scaled output incorrectly.
GUI
- label_printer_ptouch_gui.py: 600x300 two-column panel - controls on the
left, live preview on the right. Advanced settings for logo, static lines,
code prefix/suffix and error correction.
- native_file_dialog.py: the standard Windows file picker, run in a
subprocess because Tk and Kivy both want the main event loop.
Printing
- Print failures are reported honestly. print_to_printer previously returned
True unconditionally, so a failed print claimed success and was logged as
SUCCESS.
- SumatraPDF exit codes are now checked (4 = no printer, 5 = driver error).
- The PDF is sent with noscale so driver-side scaling cannot distort the QR
module pitch.
Tests
- validate_ptouch_layout.py decodes the QR back out of the rendered preview
module by module, proving the preview and PDF are the same layout.
- test_ptouch_pipeline.py writes to a temp config so it cannot overwrite
operator settings.
- test_gui_layout.py exercises the real app class headlessly.
- test_print_reporting.py covers the failure paths.
Diagnostics: check_print_area.py, check_print_geometry.py, check_devmode.py,
print_calibration.py, print_measure_scale.py, capture_print_job.py.
134 lines
4.7 KiB
Python
134 lines
4.7 KiB
Python
"""
|
|
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
|