Files
ske087 93afa3c417 Add P-touch Labels for Asset: 63x20 mm label format for PT-E560BT
New three-column label format for Brother P-touch EDGE PT-E560BT asset tags:
logo | three static lines + variable field | QR code.

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

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

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

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

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

Diagnostics: check_print_area.py, check_print_geometry.py, check_devmode.py,
print_calibration.py, print_measure_scale.py, capture_print_job.py.
2026-09-25 17:30:11 +03:00

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