Files
label_printer/print_label.py
T
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

652 lines
25 KiB
Python
Executable File

from PIL import Image, ImageDraw, ImageFont
import barcode
from barcode.writer import ImageWriter
import time
import os
import sys
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:
import cups
CUPS_AVAILABLE = True
except ImportError:
CUPS_AVAILABLE = False
try:
import win32api
import win32print
WIN32_AVAILABLE = True
except ImportError:
WIN32_AVAILABLE = False
SYSTEM = platform.system() # 'Linux', 'Windows', 'Darwin'
def get_available_printers():
"""
Get list of available printers (cross-platform).
Includes both local and network printers on Windows.
Returns:
list: List of available printer names, with "PDF" as fallback
"""
try:
if SYSTEM == "Linux" and CUPS_AVAILABLE:
# Linux: Use CUPS
conn = cups.Connection()
printers = conn.getPrinters()
return list(printers.keys()) if printers else ["PDF"]
elif SYSTEM == "Windows":
# Windows: Get local + connected printers (includes print server connections)
try:
printers = []
# PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS captures:
# - Locally installed printers
# - Printers connected from a print server (e.g. \\server\printer)
try:
flags = win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS
for printer_info in win32print.EnumPrinters(flags):
printer_name = printer_info[2]
if printer_name and printer_name not in printers:
printers.append(printer_name)
except Exception as e:
print(f"Error enumerating printers: {e}")
# Add PDF as fallback option
if "PDF" not in printers:
printers.append("PDF")
return printers if printers else ["PDF"]
except Exception as e:
print(f"Error getting Windows printers: {e}")
return ["PDF"]
elif SYSTEM == "Darwin":
# macOS: Use lpstat command
try:
result = subprocess.run(["lpstat", "-p", "-d"],
capture_output=True, text=True)
printers = []
for line in result.stdout.split('\n'):
if line.startswith('printer'):
printer_name = line.split()[1]
printers.append(printer_name)
return printers if printers else ["PDF"]
except:
return ["PDF"]
else:
return ["PDF"]
except Exception as e:
print(f"Error getting printers: {e}")
return ["PDF"]
def create_label_image(text):
"""
Create a label image with 3 rows: label + barcode for each field.
Args:
text (str): Combined text in format "SAP|CANTITATE|LOT" or single value
Returns:
PIL.Image: The generated label image
"""
# Parse the text input
parts = text.split('|') if '|' in text else [text, '', '']
sap_nr = parts[0].strip() if len(parts) > 0 else ''
cantitate = parts[1].strip() if len(parts) > 1 else ''
lot_number = parts[2].strip() if len(parts) > 2 else ''
# Label dimensions (narrower, 3 rows)
label_width = 800 # 8 cm
label_height = 600 # 6 cm
# Create canvas
label_img = Image.new('RGB', (label_width, label_height), 'white')
draw = ImageDraw.Draw(label_img)
# Row setup - 3 equal rows
row_height = label_height // 3
left_margin = 15
row_spacing = 3
# 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()
# Data for 3 rows
rows_data = [
("SAP-Nr", sap_nr),
("Cantitate", cantitate),
("Lot Nr", lot_number),
]
# Generate barcodes first
CODE128 = barcode.get_barcode_class('code128')
writer_options = {
"write_text": False,
"module_width": 0.4,
"module_height": 8,
"quiet_zone": 2,
"font_size": 0
}
barcode_images = []
for _, value in rows_data:
if value:
try:
code = CODE128(value[:25], writer=ImageWriter())
filename = code.save('temp_barcode', options=writer_options)
barcode_img = Image.open(filename)
barcode_images.append(barcode_img)
except:
barcode_images.append(None)
else:
barcode_images.append(None)
# Draw each row with label and barcode
for idx, ((label_name, value), barcode_img) in enumerate(zip(rows_data, barcode_images)):
row_y = idx * row_height
# Draw label name
draw.text(
(left_margin, row_y + 3),
label_name,
fill='black',
font=label_font
)
# Draw barcode if available
if barcode_img:
# Resize barcode to fit in row width
barcode_width = label_width - left_margin - 10
barcode_height = row_height - 25
barcode_resized = barcode_img.resize((barcode_width, barcode_height), Image.LANCZOS)
label_img.paste(barcode_resized, (left_margin, row_y + 20))
else:
# Fallback: show value as text
draw.text(
(left_margin, row_y + 25),
value if value else "(empty)",
fill='black',
font=value_font
)
# Clean up temporary barcode files
try:
if os.path.exists('temp_barcode.png'):
os.remove('temp_barcode.png')
except:
pass
return label_img
def create_label_pdf(text):
"""
Create a high-quality PDF label with 3 rows: label + barcode for each field.
PDFs are saved to the pdf_backup folder.
Args:
text (str): Combined text in format "SAP|CANTITATE|LOT" or single value
Returns:
str: Path to the generated PDF file
"""
# Parse the text input
parts = text.split('|') if '|' in text else [text, '', '']
sap_nr = parts[0].strip() if len(parts) > 0 else ''
cantitate = parts[1].strip() if len(parts) > 1 else ''
lot_number = parts[2].strip() if len(parts) > 2 else ''
# Create PDF using high-quality generator
generator = PDFLabelGenerator()
# Ensure pdf_backup folder exists
pdf_backup_dir = 'pdf_backup'
os.makedirs(pdf_backup_dir, exist_ok=True)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
pdf_filename = os.path.join(pdf_backup_dir, f"final_label_{timestamp}.pdf")
return generator.create_label_pdf(sap_nr, cantitate, lot_number, pdf_filename)
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).
Args:
printer_name (str): Name of printer or "PDF" for PDF output
file_path (str): Path to file to print
Returns:
bool: True if successful
"""
try:
if printer_name == "PDF":
# PDF output - file is already saved
print(f"PDF output: {file_path}")
return True
elif SYSTEM == "Linux" and CUPS_AVAILABLE:
# Linux: Use CUPS
conn = cups.Connection()
conn.printFile(printer_name, file_path, "Label Print", {})
print(f"Label sent to printer: {printer_name}")
return True
elif SYSTEM == "Windows":
# Windows: Print PDF silently without any viewer opening
try:
if WIN32_AVAILABLE:
import win32print
import win32api
if file_path.endswith('.pdf'):
# Try silent printing methods (no viewer opens)
import os
import winreg
printed = False
# Method 1: SumatraPDF (bundled inside exe or external)
sumatra_paths = []
# Get the directory where this script/exe is running
if getattr(sys, 'frozen', False):
# Running as compiled executable
# PyInstaller extracts bundled files to sys._MEIPASS temp folder
if hasattr(sys, '_MEIPASS'):
# Check bundled version first (inside the exe)
bundled_sumatra = os.path.join(sys._MEIPASS, 'SumatraPDF.exe')
sumatra_paths.append(bundled_sumatra)
# Also check app directory for external version
app_dir = os.path.dirname(sys.executable)
sumatra_paths.append(os.path.join(app_dir, "SumatraPDF", "SumatraPDF.exe"))
sumatra_paths.append(os.path.join(app_dir, "SumatraPDF.exe"))
else:
# Running as script - check local folders
app_dir = os.path.dirname(os.path.abspath(__file__))
sumatra_paths.append(os.path.join(app_dir, "SumatraPDF", "SumatraPDF.exe"))
sumatra_paths.append(os.path.join(app_dir, "SumatraPDF.exe"))
# Then check system installations
sumatra_paths.extend([
r"C:\Program Files\SumatraPDF\SumatraPDF.exe",
r"C:\Program Files (x86)\SumatraPDF\SumatraPDF.exe",
])
for sumatra_path in sumatra_paths:
if os.path.exists(sumatra_path):
try:
# "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}")
# Method 2: Adobe Reader silent printing
if not printed:
adobe_path = None
for key_path in [
r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\AcroRd32.exe",
r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\Acrobat.exe"
]:
try:
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path)
adobe_path, _ = winreg.QueryValueEx(key, "")
winreg.CloseKey(key)
break
except:
pass
if adobe_path and os.path.exists(adobe_path):
try:
subprocess.run([
adobe_path,
"/t", # Print and close
file_path,
printer_name
], check=False, creationflags=subprocess.CREATE_NO_WINDOW)
print(f"Label sent to printer via Adobe Reader: {printer_name}")
printed = True
except:
pass
# Method 3: GhostScript (if installed)
if not printed:
gs_paths = [
r"C:\Program Files\gs\gs10.02.1\bin\gswin64c.exe",
r"C:\Program Files (x86)\gs\gs10.02.1\bin\gswin32c.exe",
]
# Try to find gswin in PATH
try:
gs_result = subprocess.run(['where', 'gswin64c'],
capture_output=True, text=True, check=False)
if gs_result.returncode == 0:
gs_paths.insert(0, gs_result.stdout.strip().split('\n')[0])
except:
pass
for gs_path in gs_paths:
if os.path.exists(gs_path):
try:
subprocess.run([
gs_path,
"-dNOPAUSE", "-dBATCH", "-dQUIET",
f"-sDEVICE=mswinpr2",
f"-sOutputFile=%printer%{printer_name}",
file_path
], check=False, creationflags=subprocess.CREATE_NO_WINDOW)
print(f"Label sent to printer via GhostScript: {printer_name}")
printed = True
break
except:
pass
if not printed:
# 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)")
print("If you built the app yourself, ensure SumatraPDF.exe is downloaded first.")
print("Run: setup_sumatra.ps1 before building")
print("=" * 60)
print(f"PDF saved to: {file_path}")
print("The PDF can be printed manually.")
return False
return True
else:
# Non-PDF files
subprocess.run(['notepad', '/p', file_path],
check=False,
creationflags=subprocess.CREATE_NO_WINDOW)
print(f"Label sent to printer: {printer_name}")
return True
else:
print("win32print not available, PDF saved as backup only")
return True
except Exception as e:
print(f"Windows print error: {e}")
print("PDF backup saved as fallback")
return False
elif SYSTEM == "Darwin":
# macOS: Use lp command
subprocess.run(["lp", "-d", printer_name, file_path], check=True)
print(f"Label sent to printer: {printer_name}")
return True
else:
print(f"Unsupported system: {SYSTEM}")
return False
except Exception as e:
print(f"Printer error: {str(e)}")
print("Label already saved to file as fallback...")
print(f"Label file: {file_path}")
return True
def print_label_standalone(value, printer, preview=0, use_pdf=True):
"""
Print a label with the specified text on the specified printer.
Args:
value (str): The text to print on the label
printer (str): The name of the printer to use
preview (int): 0 = no preview, 1-3 = 3s preview, >3 = 5s preview
use_pdf (bool): True to use PDF (recommended for quality), False for PNG
Returns:
bool: True if printing was successful, False otherwise
"""
# For tracking if file was created
file_created = False
temp_file = None
try:
# Debug output
print(f"Preview value: {preview}")
print(f"Preview type: {type(preview)}")
print(f"Using format: {'PDF' if use_pdf else 'PNG'}")
# Create label in selected format
if use_pdf:
temp_file = create_label_pdf(value)
print(f"PDF label created: {temp_file}")
print(f"PDF backup saved to: {temp_file}")
else:
# Create the label image (PNG)
label_img = create_label_image(value)
temp_file = 'final_label.png'
label_img.save(temp_file)
print(f"PNG label created: {temp_file}")
file_created = True
# Convert preview to int if it's a string
if isinstance(preview, str):
preview = int(preview)
if preview > 0: # Any value above 0 shows a preview message
# Calculate preview duration in seconds
if 1 <= preview <= 3:
preview_sec = 3 # 3 seconds
else: # preview > 3
preview_sec = 5 # 5 seconds
print(f"Printing in {preview_sec} seconds... (Press Ctrl+C to cancel)")
# Simple countdown timer using time.sleep
try:
for i in range(preview_sec, 0, -1):
print(f" {i}...", end=" ", flush=True)
time.sleep(1)
print("\nPrinting now...")
except KeyboardInterrupt:
print("\nCancelled by user")
return False
# Print after preview
print("Sending to printer...")
return print_to_printer(printer, temp_file)
else:
print("Direct printing without preview...")
# Direct printing without preview (preview = 0)
return print_to_printer(printer, temp_file)
except Exception as e:
print(f"Error printing label: {str(e)}")
return False
finally:
# This block always executes, ensuring cleanup
if use_pdf:
print(f"Cleanup complete - PDF backup saved to pdf_backup folder")
else:
print("Cleanup complete - label file retained for reference")
# Main code removed - import this module or run as part of the Kivy GUI application