Compare commits

...

4 Commits

Author SHA1 Message Date
71ccdb7b96 Fix blank label between copies: send all copies in single GDI print job (StartDoc/N pages/EndDoc) instead of N separate jobs - thermal printers eject blank label between rapid separate jobs 2026-02-25 16:20:45 +02:00
be1b494527 Fix printing: use pymupdf+GDI for silent direct printing at correct physical label size - removes Adobe Reader/ShellExecute, prints 35x25mm label 1:1 using printer native DPI 2026-02-25 16:05:33 +02:00
b35f278c1e Remove GhostScript, use SumatraPDF only for printing
- Remove find_ghostscript() and print_pdf_with_ghostscript() from print_label.py
- SumatraPDF is now the primary print method with -print-settings noscale
- Fix SumatraPDF arg order: file_path moved to last position
- Add -appdata-dir so SumatraPDF reads conf/SumatraPDF-settings.txt (PrintScale=noscale)
- win32api ShellExecute kept as last-resort fallback
- Remove GhostScript bundling from LabelPrinter.spec and build_exe.py
- Add conf/ folder pre-flight check to build_exe.py
- Rebuild dist/LabelPrinter.exe (41 MB, SumatraPDF-only)
2026-02-25 10:00:56 +02:00
faef90f98b Add dist/ release build (LabelPrinter.exe + conf with bundled GhostScript and SumatraPDF) 2026-02-24 13:41:17 +02:00
6 changed files with 205 additions and 292 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,5 @@
label/
build/
dist/
logs/
pdf_backup/
conf/ghostscript/

View File

@@ -59,11 +59,12 @@ a = Analysis(
binaries=gs_binaries,
datas=base_datas + gs_datas,
hiddenimports=[
'kivy', 'PIL', 'barcode', 'reportlab',
'kivy', 'PIL', 'PIL.ImageWin', 'barcode', 'reportlab',
'print_label', 'print_label_pdf',
'fitz', 'pymupdf',
'svglib', 'cairosvg',
'watchdog', 'watchdog.observers', 'watchdog.events',
'pystray', 'win32timezone',
'pystray', 'win32api', 'win32print', 'win32ui', 'win32con', 'win32timezone',
],
hookspath=[],
hooksconfig={},

View File

@@ -11,91 +11,41 @@ To build for Windows:
3. Run this script: python build_exe.py
4. The Windows .exe will be created in the dist/ folder
GhostScript bundling
--------------------
If GhostScript is installed on this build machine the script will
automatically copy the required files into conf\\ghostscript\\ before
calling PyInstaller so they are embedded in LabelPrinter.exe.
The target machine then needs NO separate GhostScript install.
Printing method
---------------
SumatraPDF is bundled inside the exe and used for all printing.
It sends the PDF at its exact 35x25 mm page size via -print-settings noscale.
No GhostScript installation required on the build or target machine.
"""
import os
import sys
import subprocess
import shutil
# Get the current directory
script_dir = os.path.dirname(os.path.abspath(__file__))
def prepare_ghostscript():
def check_conf_folder():
"""
Find the system GhostScript installation and copy the minimum files
needed for bundling into conf\\ghostscript\\.
Files copied:
conf\\ghostscript\\bin\\gswin64c.exe (or 32-bit variant)
conf\\ghostscript\\bin\\gsdll64.dll
conf\\ghostscript\\lib\\*.ps (PostScript init files)
Returns True if GhostScript was found and prepared, False otherwise.
Verify that required files are present in the conf\ folder before building.
Returns True if all required files exist, False otherwise.
"""
import glob
gs_exe = None
gs_dll = None
gs_lib = None
for pf in [r"C:\Program Files", r"C:\Program Files (x86)"]:
gs_base = os.path.join(pf, "gs")
if not os.path.isdir(gs_base):
continue
# Iterate versions newest-first
for ver_dir in sorted(os.listdir(gs_base), reverse=True):
bin_dir = os.path.join(gs_base, ver_dir, "bin")
lib_dir = os.path.join(gs_base, ver_dir, "lib")
if os.path.exists(os.path.join(bin_dir, "gswin64c.exe")):
gs_exe = os.path.join(bin_dir, "gswin64c.exe")
gs_dll = os.path.join(bin_dir, "gsdll64.dll")
gs_lib = lib_dir
break
if os.path.exists(os.path.join(bin_dir, "gswin32c.exe")):
gs_exe = os.path.join(bin_dir, "gswin32c.exe")
gs_dll = os.path.join(bin_dir, "gsdll32.dll")
gs_lib = lib_dir
break
if gs_exe:
break
if not gs_exe:
print(" WARNING: GhostScript not found on this machine.")
print(" The exe will still build but GhostScript will NOT be bundled.")
print(" Install GhostScript from https://ghostscript.com/releases/gsdnld.html")
print(" then rebuild for sharp vector-quality printing.")
return False
dest_bin = os.path.join("conf", "ghostscript", "bin")
dest_lib = os.path.join("conf", "ghostscript", "lib")
os.makedirs(dest_bin, exist_ok=True)
os.makedirs(dest_lib, exist_ok=True)
print(f" GhostScript found: {os.path.dirname(gs_exe)}")
shutil.copy2(gs_exe, dest_bin)
print(f" Copied: {os.path.basename(gs_exe)}")
if os.path.exists(gs_dll):
shutil.copy2(gs_dll, dest_bin)
print(f" Copied: {os.path.basename(gs_dll)}")
count = 0
for ps_file in glob.glob(os.path.join(gs_lib, "*.ps")):
shutil.copy2(ps_file, dest_lib)
count += 1
print(f" Copied {count} .ps init files from lib/")
print(f" GhostScript prepared in conf\\ghostscript\\")
return True
required = [
os.path.join('conf', 'SumatraPDF.exe'),
os.path.join('conf', 'SumatraPDF-settings.txt'),
os.path.join('conf', 'label_template_ok.svg'),
os.path.join('conf', 'label_template_nok.svg'),
os.path.join('conf', 'label_template.svg'),
]
all_ok = True
for f in required:
if os.path.exists(f):
print(f" OK: {f}")
else:
print(f" MISSING: {f}")
all_ok = False
return all_ok
if __name__ == '__main__':
@@ -106,15 +56,14 @@ if __name__ == '__main__':
# Change to script directory so relative paths work
os.chdir(script_dir)
# Step 1: Prepare GhostScript for bundling (Windows only)
if sys.platform == "win32":
print("\n[1/2] Preparing GhostScript for bundling...")
prepare_ghostscript()
else:
print("\n[1/2] Skipping GhostScript prep (non-Windows build machine)")
# Step 1: Verify conf\ folder contents
print("\n[1/2] Checking conf\\ folder...")
if not check_conf_folder():
print("\nERROR: One or more required conf\\ files are missing.")
print("Place SumatraPDF.exe and the SVG templates in the conf\\ folder, then retry.")
sys.exit(1)
# Step 2: Build with PyInstaller using the handcrafted spec file.
# The spec's Python code auto-detects conf\\ghostscript\\ and includes it.
print("\n[2/2] Building standalone executable via LabelPrinter.spec...")
print("This may take a few minutes...\n")
@@ -130,8 +79,7 @@ if __name__ == '__main__':
print("=" * 60)
print("\nExecutable location: ./dist/LabelPrinter.exe")
print("\nBundled components:")
print(" - GhostScript (vector-quality printing)")
print(" - SumatraPDF (fallback printing)")
print(" - SumatraPDF (primary printing, noscale 35x25 mm)")
print(" - SVG templates, conf files")
print("\nYou can now:")
print(" 1. Double-click LabelPrinter.exe to run")

BIN
dist/LabelPrinter.exe vendored Normal file

Binary file not shown.

View File

@@ -982,23 +982,13 @@ printer = PDF
success = False
all_success = True
try:
# Print multiple copies if count > 1
for i in range(count):
if count > 1:
Clock.schedule_once(lambda dt, idx=i: popup.content.children[0].text.replace(
f'Processing {count} label(s)...',
f'Printing {idx+1} of {count}...'
), 0)
success = print_label_standalone(label_text, printer, preview=0, svg_template=template_path)
if not success:
all_success = False
break
# Small delay between prints for multiple copies
if i < count - 1:
time.sleep(0.5)
# Send all copies in ONE print job to prevent blank labels
# being ejected between separate jobs on thermal printers.
success = print_label_standalone(
label_text, printer, preview=0,
svg_template=template_path, copies=count
)
all_success = success
# Get the PDF filename that was created
# Files are saved to pdf_backup/ with timestamp

View File

@@ -6,6 +6,21 @@ import platform
import subprocess
from print_label_pdf import PDFLabelGenerator
def _write_print_log(msg):
"""Write a timestamped debug line to print_debug.log next to the exe / script."""
try:
if getattr(sys, 'frozen', False):
log_dir = os.path.dirname(sys.executable)
else:
log_dir = os.path.dirname(os.path.abspath(__file__))
log_path = os.path.join(log_dir, 'print_debug.log')
ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with open(log_path, 'a', encoding='utf-8') as f:
f.write(f"[{ts}] {msg}\n")
except Exception:
pass
# Cross-platform printer support
try:
import cups
@@ -143,105 +158,154 @@ def create_label_pdf(text, svg_template=None):
return generator.create_label_pdf(article, nr_art, serial, pdf_filename, image_path, selected_template)
def find_ghostscript():
def _print_pdf_windows(file_path, printer_name, copies=1):
"""
Find GhostScript executable. Search order:
1. Bundled inside the PyInstaller .exe (ghostscript/bin/ in _MEIPASS)
2. System-level installation (C:\\Program Files\\gs\\...)
Returns the full path to gswin64c.exe / gswin32c.exe, or None.
Print a PDF on Windows using pymupdf (fitz) to render and win32print/GDI to send.
All copies are sent as multiple pages within ONE print job so the printer
never sees a gap (which causes blank labels on thermal printers).
No external application is launched. Falls back to SumatraPDF if pymupdf
is unavailable.
Returns True on success, False on failure.
"""
# 1 ── Bundled location (PyInstaller one-file build)
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
for exe_name in ['gswin64c.exe', 'gswin32c.exe']:
bundled = os.path.join(sys._MEIPASS, 'ghostscript', 'bin', exe_name)
if os.path.exists(bundled):
print(f"Using bundled GhostScript: {bundled}")
return bundled
# 2 ── System installation (newest version first)
for program_files in [r"C:\Program Files", r"C:\Program Files (x86)"]:
gs_base = os.path.join(program_files, "gs")
if os.path.exists(gs_base):
for version_dir in sorted(os.listdir(gs_base), reverse=True):
for exe_name in ["gswin64c.exe", "gswin32c.exe"]:
gs_path = os.path.join(gs_base, version_dir, "bin", exe_name)
if os.path.exists(gs_path):
return gs_path
return None
def print_pdf_with_ghostscript(pdf_path, printer_name):
"""
Print PDF via GhostScript mswinpr2 device for true vector quality.
GhostScript sends native vector data to the printer driver, avoiding
the low-DPI rasterisation that causes dotted/pixelated output.
Returns True on success, False if GhostScript is unavailable or fails.
"""
gs_path = find_ghostscript()
if not gs_path:
print("GhostScript not found skipping to SumatraPDF fallback.")
return False
# Build environment: if running as a bundled exe, point GS_LIB at the
# extracted lib/ folder so GhostScript can find its .ps init files.
env = os.environ.copy()
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
bundled_lib = os.path.join(sys._MEIPASS, 'ghostscript', 'lib')
if os.path.isdir(bundled_lib):
env['GS_LIB'] = bundled_lib
print(f"GS_LIB → {bundled_lib}")
copies = max(1, int(copies))
_write_print_log(f"_print_pdf_windows called: printer={printer_name!r} copies={copies} file={file_path!r}")
# ── Method 1: pure-Python GDI (pymupdf + win32print) ─────────────────
try:
# -sDEVICE=mswinpr2 : native Windows printer device (vector path)
# -dNOPAUSE / -dBATCH : batch mode, no user prompts
# -r600 : 600 DPI matches typical thermal-printer head
# -dTextAlphaBits=4 : anti-aliasing for text
# -dGraphicsAlphaBits=4
# -dNOSAFER : allow file access needed for fonts
# The printer paper size and orientation are already configured
# in the driver do not override them here.
cmd = [
gs_path,
"-dNOPAUSE",
"-dBATCH",
"-sDEVICE=mswinpr2",
"-dNOSAFER",
"-r600",
"-dTextAlphaBits=4",
"-dGraphicsAlphaBits=4",
f"-sOutputFile=%printer%{printer_name}",
pdf_path,
]
print(f"Printing with GhostScript (vector quality) to: {printer_name}")
result = subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
timeout=60,
env=env,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
import fitz # pymupdf
import win32print
import win32ui
import win32con
from PIL import ImageWin, Image as PILImage
_write_print_log("pymupdf + win32 imports OK using GDI method")
doc = fitz.open(file_path)
if doc.page_count == 0:
_write_print_log("PDF has no pages aborting")
return False
page = doc[0]
# Get the printer's native resolution
hdc = win32ui.CreateDC()
hdc.CreatePrinterDC(printer_name)
x_dpi = hdc.GetDeviceCaps(win32con.LOGPIXELSX)
y_dpi = hdc.GetDeviceCaps(win32con.LOGPIXELSY)
_write_print_log(f"Printer DPI={x_dpi}x{y_dpi}")
# Render PDF page at printer DPI (PDF uses 72 pt/inch)
mat = fitz.Matrix(x_dpi / 72.0, y_dpi / 72.0)
pix = page.get_pixmap(matrix=mat, alpha=False)
img = PILImage.frombytes("RGB", [pix.width, pix.height], pix.samples)
# Read physical page size before closing the document
pdf_page_w_pts = page.rect.width # PDF points (1pt = 1/72 inch)
pdf_page_h_pts = page.rect.height
doc.close()
dest_w = int(round(pdf_page_w_pts * x_dpi / 72.0))
dest_h = int(round(pdf_page_h_pts * y_dpi / 72.0))
_write_print_log(
f"PDF page={pdf_page_w_pts:.1f}x{pdf_page_h_pts:.1f}pt "
f"rendered={pix.width}x{pix.height}px dest={dest_w}x{dest_h}px"
)
print(f"✅ Label sent via GhostScript: {printer_name}")
# Draw at exact physical size NOT stretched to the driver's paper area.
# All copies go into ONE print job to prevent blank labels between jobs.
hdc.StartDoc(os.path.basename(file_path))
dib = ImageWin.Dib(img)
for copy_idx in range(copies):
hdc.StartPage()
dib.draw(hdc.GetHandleOutput(), (0, 0, dest_w, dest_h))
hdc.EndPage()
hdc.EndDoc()
hdc.DeleteDC()
_write_print_log(f"GDI print SUCCESS → {printer_name} ({copies} cop{'y' if copies==1 else 'ies'} in 1 job)")
return True
except subprocess.CalledProcessError as e:
print(f"GhostScript print failed (exit {e.returncode}): {e.stderr.strip() if e.stderr else ''}")
return False
except ImportError as ie:
_write_print_log(f"GDI method unavailable ({ie}) trying SumatraPDF fallback")
except Exception as e:
print(f"GhostScript error: {e}")
return False
_write_print_log(f"GDI print error: {e}")
try:
hdc.DeleteDC()
except Exception:
pass
# ── Method 2: SumatraPDF fallback ────────────────────────────────────
_write_print_log("Attempting SumatraPDF fallback")
sumatra_paths = []
if getattr(sys, 'frozen', False):
if hasattr(sys, '_MEIPASS'):
sumatra_paths.append(os.path.join(sys._MEIPASS, 'SumatraPDF.exe'))
app_dir = os.path.dirname(sys.executable)
sumatra_paths.append(os.path.join(app_dir, 'conf', 'SumatraPDF.exe'))
sumatra_paths.append(os.path.join(app_dir, 'SumatraPDF.exe'))
else:
app_dir = os.path.dirname(os.path.abspath(__file__))
sumatra_paths.append(os.path.join(app_dir, 'conf', 'SumatraPDF.exe'))
sumatra_paths.append(os.path.join(app_dir, 'SumatraPDF.exe'))
sumatra_paths += [
r"C:\Program Files\SumatraPDF\SumatraPDF.exe",
r"C:\Program Files (x86)\SumatraPDF\SumatraPDF.exe",
]
_write_print_log(f"SumatraPDF search paths: {sumatra_paths}")
for sumatra_path in sumatra_paths:
_write_print_log(f"Checking: {sumatra_path} → exists={os.path.exists(sumatra_path)}")
if os.path.exists(sumatra_path):
try:
# Find conf folder with SumatraPDF-settings.txt
settings_candidates = []
if getattr(sys, 'frozen', False):
if hasattr(sys, '_MEIPASS'):
settings_candidates.append(os.path.join(sys._MEIPASS, 'conf'))
settings_candidates.append(os.path.join(os.path.dirname(sys.executable), 'conf'))
else:
settings_candidates.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'conf'))
settings_candidates.append(os.path.dirname(sumatra_path))
appdata_dir = next(
(d for d in settings_candidates
if os.path.exists(os.path.join(d, 'SumatraPDF-settings.txt'))),
None
)
_write_print_log(f"SumatraPDF settings dir: {appdata_dir}")
cmd = [sumatra_path, "-print-to", printer_name,
"-print-settings", "noscale",
"-silent", "-exit-when-done"]
if appdata_dir:
cmd += ["-appdata-dir", appdata_dir]
cmd.append(file_path)
_write_print_log(f"SumatraPDF cmd: {' '.join(cmd)}")
result = subprocess.run(
cmd, check=False,
creationflags=subprocess.CREATE_NO_WINDOW,
capture_output=True, text=True, timeout=30)
_write_print_log(f"SumatraPDF exit={result.returncode} "
f"stdout={result.stdout.strip()!r} "
f"stderr={result.stderr.strip()!r}")
return True # treat any completed run as spooled
except Exception as se:
_write_print_log(f"SumatraPDF error: {se}")
_write_print_log("All print methods failed")
return False
def print_to_printer(printer_name, file_path):
def print_to_printer(printer_name, file_path, copies=1):
"""
Print file to printer (cross-platform).
Uses SumatraPDF for silent printing on Windows.
Uses pymupdf+GDI for silent printing on Windows with all copies in one job.
Args:
printer_name (str): Name of printer or "PDF" for PDF output
file_path (str): Path to file to print
copies (int): Number of copies to print in a single print job
Returns:
bool: True if successful
"""
@@ -259,106 +323,14 @@ def print_to_printer(printer_name, file_path):
return True
elif SYSTEM == "Windows":
# Windows: Print PDF silently without any viewer opening
try:
if WIN32_AVAILABLE:
import win32print
if file_path.endswith('.pdf'):
# Try silent printing methods (no viewer opens)
import os
printed = False
# Method 1: GhostScript (best quality true vector path).
# Tried first regardless of whether we are running as a
# script or as the packaged .exe, because GhostScript is
# a system-level installation and will be present on the
# machine independently of how this app is launched.
# If GhostScript is not installed it returns False
# immediately and we fall through to SumatraPDF.
printed = print_pdf_with_ghostscript(file_path, printer_name)
if printed:
return True
# Method 2: 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)
# Check app directory and conf subfolder 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"))
sumatra_paths.append(os.path.join(app_dir, "conf", "SumatraPDF.exe")) # conf subfolder next to exe
sumatra_paths.append(os.path.join(os.getcwd(), "conf", "SumatraPDF.exe")) # conf relative to cwd
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"))
sumatra_paths.append(os.path.join(app_dir, "conf", "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:
print(f"Using SumatraPDF: {sumatra_path}")
print(f"Sending to printer: {printer_name}")
# "noscale" = print the PDF at its exact page size.
# Do NOT add "landscape" the printer driver
# already knows the orientation from its own settings.
result = subprocess.run([
sumatra_path,
"-print-to",
printer_name,
file_path,
"-print-settings",
"noscale",
"-silent",
"-exit-when-done"
], check=False, creationflags=subprocess.CREATE_NO_WINDOW,
capture_output=True, text=True, timeout=30)
if result.returncode != 0:
print(f"SumatraPDF returned code {result.returncode}")
if result.stderr:
print(f"SumatraPDF stderr: {result.stderr.strip()}")
else:
print(f"Label sent to printer via SumatraPDF: {printer_name}")
printed = True
break
except Exception as e:
print(f"SumatraPDF error: {e}")
# Do not launch default PDF viewers (Adobe/Edge/etc.) as fallback.
if not printed:
print("SumatraPDF not found or failed. PDF saved as backup only (no viewer launched).")
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
except Exception as e:
print(f"Windows print error: {e}")
print("PDF backup saved as fallback")
# Windows: Print PDF using Python GDI (pymupdf + win32print).
# No external viewer is launched at any point.
if file_path.endswith('.pdf'):
return _print_pdf_windows(file_path, printer_name, copies=copies)
else:
subprocess.run(['notepad', '/p', file_path],
check=False,
creationflags=subprocess.CREATE_NO_WINDOW)
return True
elif SYSTEM == "Darwin":
@@ -378,20 +350,23 @@ def print_to_printer(printer_name, file_path):
return True
def print_label_standalone(value, printer, preview=0, svg_template=None):
def print_label_standalone(value, printer, preview=0, svg_template=None, copies=1):
"""
Generate a PDF label, save it to pdf_backup/, and print it on the printer.
The printer's own paper size and orientation settings are used as-is.
All copies are sent in a single print job to avoid blank labels on thermal
printers that eject a label between separate jobs.
Args:
value (str): Label data in format "article;nr_art;serial;status"
printer (str): Printer name (or "PDF" to skip physical printing)
preview (int): 0 = print immediately; 1-3 = 3 s delay; >3 = 5 s delay
svg_template (str): Path to SVG template (optional; auto-selected if None)
copies (int): Number of copies to print in one job (default 1)
Returns:
bool: True if sending to printer succeeded, False otherwise
"""
copies = max(1, int(copies))
pdf_file = None
try:
# Step 1 Generate and save PDF to pdf_backup/
@@ -422,9 +397,9 @@ def print_label_standalone(value, printer, preview=0, svg_template=None):
print("\nCancelled by user")
return False
# Step 3 Send to printer
print("Sending to printer...")
return print_to_printer(printer, pdf_file)
# Step 3 Send to printer (all copies in one job)
print(f"Sending to printer ({copies} cop{'y' if copies==1 else 'ies'})...")
return print_to_printer(printer, pdf_file, copies=copies)
except Exception as e:
print(f"Error printing label: {str(e)}")