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

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

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

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

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

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

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

Diagnostics: check_print_area.py, check_print_geometry.py, check_devmode.py,
print_calibration.py, print_measure_scale.py, capture_print_job.py.
This commit is contained in:
2026-09-25 17:30:11 +03:00
parent 58082ed171
commit 93afa3c417
22 changed files with 3877 additions and 23 deletions
+178 -23
View File
@@ -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