Compare commits

...

7 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
7197df9f5c Fix docstring escape warning in prepare_ghostscript.py; add dist/ and build_output.log to .gitignore 2026-02-24 13:30:38 +02:00
f67e1fb0da Replace prepare_ghostscript.bat with Python script to fix nested for/if batch parser bug 2026-02-24 11:52:42 +02:00
f7833ed4b9 Remove dead code, DEVMODE overrides, and PNG fallback; use printer default settings 2026-02-24 09:19:58 +02:00
11 changed files with 457 additions and 592 deletions

2
.gitignore vendored
View File

@@ -9,4 +9,6 @@ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
*.pyd *.pyd
build_output.log
.vscode/

View File

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

View File

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

View File

@@ -23,12 +23,12 @@ if errorlevel 1 (
exit /b 1 exit /b 1
) )
echo [1/5] Checking Python installation... echo [1/6] Checking Python installation...
python --version python --version
echo. echo.
REM Upgrade pip REM Upgrade pip
echo [2/5] Upgrading pip, setuptools, and wheel... echo [2/6] Upgrading pip, setuptools, and wheel...
python -m pip install --upgrade pip setuptools wheel python -m pip install --upgrade pip setuptools wheel
if errorlevel 1 ( if errorlevel 1 (
echo ERROR: Failed to upgrade pip echo ERROR: Failed to upgrade pip
@@ -50,7 +50,7 @@ echo.
REM Copy GhostScript binaries for bundling (optional but strongly recommended) REM Copy GhostScript binaries for bundling (optional but strongly recommended)
echo [4/6] Preparing GhostScript for bundling... echo [4/6] Preparing GhostScript for bundling...
call prepare_ghostscript.bat python prepare_ghostscript.py
echo. echo.
REM Check that SumatraPDF.exe exists before building REM Check that SumatraPDF.exe exists before building

View File

@@ -1,20 +1,110 @@
from reportlab.lib.pagesizes import landscape """
from reportlab.lib.utils import ImageReader check_pdf_size.py verify that a PDF's page dimensions match 35 mm × 25 mm.
from reportlab.pdfgen import canvas
Usage:
python check_pdf_size.py [path/to/label.pdf]
If no path is given the script operates on every PDF found in pdf_backup/.
Exit code 0 = all OK, 1 = mismatch or error.
"""
import os import os
import sys
# Check the test PDF file # ── Target dimensions ────────────────────────────────────────────────────────
if os.path.exists('test_label.pdf'): TARGET_W_MM = 35.0 # width (landscape, wider side)
file_size = os.path.getsize('test_label.pdf') TARGET_H_MM = 25.0 # height
print(f'test_label.pdf exists ({file_size} bytes)') TOLERANCE_MM = 0.5 # ± 0.5 mm is acceptable rounding from PDF viewers
print(f'Expected: 35mm x 25mm landscape (99.2 x 70.9 points)')
print(f'') PT_PER_MM = 72.0 / 25.4 # 1 mm in points
print(f'Open test_label.pdf in a PDF viewer to verify:')
print(f' - Page size should be wider than tall')
print(f' - Content should be correctly oriented') def read_page_size_pt(pdf_path):
print(f'') """
print(f'In Adobe Reader: File > Properties > Description') Return (width_pt, height_pt) of the first page of *pdf_path*.
print(f' Page size should show: 3.5 x 2.5 cm or 1.38 x 0.98 in') Tries pypdf first, then pymupdf (fitz) as a fallback.
else: Raises RuntimeError if neither library is available.
print('test_label.pdf not found') """
# ── pypdf ────────────────────────────────────────────────────────────────
try:
from pypdf import PdfReader # type: ignore
reader = PdfReader(pdf_path)
page = reader.pages[0]
w = float(page.mediabox.width)
h = float(page.mediabox.height)
return w, h
except ImportError:
pass
# ── pymupdf (fitz) ───────────────────────────────────────────────────────
try:
import fitz # type: ignore
doc = fitz.open(pdf_path)
rect = doc[0].rect
return rect.width, rect.height
except ImportError:
pass
raise RuntimeError(
"Install pypdf or pymupdf:\n"
" pip install pypdf\n"
" pip install pymupdf"
)
def check_file(pdf_path):
"""Print a pass/fail line for one PDF. Returns True if dimensions match."""
if not os.path.exists(pdf_path):
print(f" MISS {pdf_path} (file not found)")
return False
try:
w_pt, h_pt = read_page_size_pt(pdf_path)
except Exception as e:
print(f" ERR {pdf_path} ({e})")
return False
w_mm = w_pt / PT_PER_MM
h_mm = h_pt / PT_PER_MM
w_ok = abs(w_mm - TARGET_W_MM) <= TOLERANCE_MM
h_ok = abs(h_mm - TARGET_H_MM) <= TOLERANCE_MM
ok = w_ok and h_ok
status = "PASS" if ok else "FAIL"
print(
f" {status} {os.path.basename(pdf_path)}"
f" {w_mm:.2f}×{h_mm:.2f} mm"
f" (target {TARGET_W_MM}×{TARGET_H_MM} mm ±{TOLERANCE_MM} mm)"
)
return ok
def main():
targets = sys.argv[1:]
if not targets:
backup_dir = os.path.join(os.path.dirname(__file__), "pdf_backup")
if os.path.isdir(backup_dir):
targets = [
os.path.join(backup_dir, f)
for f in sorted(os.listdir(backup_dir))
if f.lower().endswith(".pdf")
]
if not targets:
# fall back to test_label.pdf in cwd
targets = ["test_label.pdf"]
print(f"Checking {len(targets)} PDF(s)…")
results = [check_file(p) for p in targets]
total = len(results)
passed = sum(results)
failed = total - passed
print(f"\n {passed}/{total} passed" + (f", {failed} FAILED" if failed else ""))
sys.exit(0 if failed == 0 else 1)
if __name__ == "__main__":
main()

BIN
dist/LabelPrinter.exe vendored Normal file

Binary file not shown.

View File

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

View File

@@ -22,6 +22,10 @@ set GS_BIN_SRC=
set GS_LIB_SRC= set GS_LIB_SRC=
set GS_EXE= set GS_EXE=
REM Pre-assign ProgramFiles(x86) to avoid batch parser choking on the parentheses
set "PF64=%ProgramFiles%"
set "PF32=%ProgramFiles(x86)%"
echo. echo.
echo ============================================================ echo ============================================================
echo GhostScript Bundle Preparation echo GhostScript Bundle Preparation
@@ -29,7 +33,7 @@ echo ============================================================
echo. echo.
REM ---- Search for GhostScript in both Program Files locations ---- REM ---- Search for GhostScript in both Program Files locations ----
for %%P in ("%ProgramFiles%" "%ProgramFiles(x86)%") do ( for %%P in ("%PF64%" "%PF32%") do (
if exist "%%~P\gs" ( if exist "%%~P\gs" (
for /d %%V in ("%%~P\gs\gs*") do ( for /d %%V in ("%%~P\gs\gs*") do (
if exist "%%~V\bin\gswin64c.exe" ( if exist "%%~V\bin\gswin64c.exe" (

115
prepare_ghostscript.py Normal file
View File

@@ -0,0 +1,115 @@
"""
prepare_ghostscript.py
Finds the system GhostScript installation and copies the files needed for
bundling into conf/ghostscript/
Files copied:
conf/ghostscript/bin/gswin64c.exe (or gswin32c.exe)
conf/ghostscript/bin/gsdll64.dll (or gsdll32.dll)
conf/ghostscript/lib/*.ps (PostScript init files)
Run this ONCE before building the exe with build_windows.bat.
"""
import os
import shutil
import glob
import sys
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DEST_BIN = os.path.join(SCRIPT_DIR, "conf", "ghostscript", "bin")
DEST_LIB = os.path.join(SCRIPT_DIR, "conf", "ghostscript", "lib")
SEARCH_ROOTS = [
os.environ.get("ProgramFiles", r"C:\Program Files"),
os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
os.environ.get("ProgramW6432", r"C:\Program Files"),
]
FLAVOURS = [
("gswin64c.exe", "gsdll64.dll"),
("gswin32c.exe", "gsdll32.dll"),
]
print()
print("=" * 60)
print(" GhostScript Bundle Preparation")
print("=" * 60)
print()
def find_ghostscript():
"""Return (bin_dir, lib_dir, exe_name, dll_name) or None."""
for root in dict.fromkeys(r for r in SEARCH_ROOTS if r): # unique, preserve order
gs_root = os.path.join(root, "gs")
if not os.path.isdir(gs_root):
continue
# Each versioned sub-folder, newest first
versions = sorted(
[d for d in glob.glob(os.path.join(gs_root, "gs*")) if os.path.isdir(d)],
reverse=True,
)
for ver_dir in versions:
bin_dir = os.path.join(ver_dir, "bin")
lib_dir = os.path.join(ver_dir, "lib")
for exe, dll in FLAVOURS:
if os.path.isfile(os.path.join(bin_dir, exe)):
print(f"Found GhostScript: {ver_dir}")
return bin_dir, lib_dir, exe, dll
return None
result = find_ghostscript()
if result is None:
print("WARNING: GhostScript is NOT installed on this machine.")
print()
print(" The exe will still build successfully, but GhostScript will")
print(" NOT be bundled. On the target machine the app will fall back")
print(" to SumatraPDF for printing (which may produce dotted output).")
print()
print(" To get sharp vector-quality printing, install GhostScript:")
print(" https://ghostscript.com/releases/gsdnld.html")
print(" Then re-run this script before building.")
print()
sys.exit(0)
src_bin, src_lib, exe_name, dll_name = result
# Create destination folders
os.makedirs(DEST_BIN, exist_ok=True)
os.makedirs(DEST_LIB, exist_ok=True)
# Copy executable and DLL
print("Copying GhostScript binaries...")
for fname in (exe_name, dll_name):
src = os.path.join(src_bin, fname)
dst = os.path.join(DEST_BIN, fname)
if os.path.isfile(src):
shutil.copy2(src, dst)
print(f" + {fname}")
else:
print(f" WARNING: {fname} not found in {src_bin}")
# Copy lib/ (PostScript init files .ps)
print()
print("Copying GhostScript lib (PostScript init files)...")
ps_files = glob.glob(os.path.join(src_lib, "*.ps"))
for ps in ps_files:
shutil.copy2(ps, DEST_LIB)
print(f" + {len(ps_files)} .ps files copied from {src_lib}")
# Report
bin_count = len(os.listdir(DEST_BIN))
lib_count = len(os.listdir(DEST_LIB))
print()
print("=" * 60)
print(" GhostScript prepared successfully in conf\\ghostscript\\")
print("=" * 60)
print()
print(f" bin\\ : {bin_count} file(s)")
print(f" lib\\ : {lib_count} file(s)")
print()
print("GhostScript will be embedded into LabelPrinter.exe at build time.")
print()

View File

@@ -1,6 +1,3 @@
from PIL import Image, ImageDraw, ImageFont
import barcode
from barcode.writer import ImageWriter
import time import time
import os import os
import sys import sys
@@ -9,6 +6,21 @@ import platform
import subprocess import subprocess
from print_label_pdf import PDFLabelGenerator 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 # Cross-platform printer support
try: try:
import cups import cups
@@ -89,119 +101,6 @@ def get_available_printers():
return ["PDF"] 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
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:
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
# Use high-quality resampling for crisp barcodes
try:
# Try newer Pillow API first
from PIL.Image import Resampling
barcode_resized = barcode_img.resize((barcode_width, barcode_height), Resampling.LANCZOS)
except (ImportError, AttributeError):
# Fallback for older Pillow versions
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, svg_template=None): def create_label_pdf(text, svg_template=None):
""" """
Create a high-quality PDF label with 3 rows: label + barcode for each field. Create a high-quality PDF label with 3 rows: label + barcode for each field.
@@ -259,197 +158,153 @@ def create_label_pdf(text, svg_template=None):
return generator.create_label_pdf(article, nr_art, serial, pdf_filename, image_path, selected_template) return generator.create_label_pdf(article, nr_art, serial, pdf_filename, image_path, selected_template)
def configure_printer_quality(printer_name, width_mm=35, height_mm=25): def _print_pdf_windows(file_path, printer_name, copies=1):
""" """
Configure printer for high quality label printing (Windows only). Print a PDF on Windows using pymupdf (fitz) to render and win32print/GDI to send.
Sets paper size, orientation, and QUALITY settings. 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.
Args: Returns True on success, False on failure.
printer_name (str): Name of the printer
width_mm (int): Label width in millimeters (default 35)
height_mm (int): Label height in millimeters (default 25)
Returns:
bool: True if successful
""" """
if SYSTEM != "Windows" or not WIN32_AVAILABLE: copies = max(1, int(copies))
return False _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: try:
import fitz # pymupdf
import win32print import win32print
import pywintypes import win32ui
import win32con
from PIL import ImageWin, Image as PILImage
hprinter = win32print.OpenPrinter(printer_name) _write_print_log("pymupdf + win32 imports OK using GDI method")
try: doc = fitz.open(file_path)
# Get current printer properties if doc.page_count == 0:
props = win32print.GetPrinter(hprinter, 2) _write_print_log("PDF has no pages aborting")
devmode = props.get("pDevMode") return False
page = doc[0]
if devmode is None: # Get the printer's native resolution
print("Could not get printer DEVMODE") hdc = win32ui.CreateDC()
return False 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}")
# CRITICAL: Set print quality to HIGHEST # Render PDF page at printer DPI (PDF uses 72 pt/inch)
# This prevents dotted/pixelated text mat = fitz.Matrix(x_dpi / 72.0, y_dpi / 72.0)
try: pix = page.get_pixmap(matrix=mat, alpha=False)
devmode.PrintQuality = 600 # 600 DPI (high quality) img = PILImage.frombytes("RGB", [pix.width, pix.height], pix.samples)
except:
try:
devmode.PrintQuality = 4 # DMRES_HIGH
except:
pass
# Set custom paper size # Read physical page size before closing the document
try: pdf_page_w_pts = page.rect.width # PDF points (1pt = 1/72 inch)
devmode.PaperSize = 256 # DMPAPER_USER (custom size) pdf_page_h_pts = page.rect.height
devmode.PaperLength = height_mm * 10 # Height in 0.1mm units doc.close()
devmode.PaperWidth = width_mm * 10 # Width in 0.1mm units dest_w = int(round(pdf_page_w_pts * x_dpi / 72.0))
except: dest_h = int(round(pdf_page_h_pts * y_dpi / 72.0))
pass _write_print_log(
f"PDF page={pdf_page_w_pts:.1f}x{pdf_page_h_pts:.1f}pt "
# Set orientation to PORTRAIT (1 = no rotation). f"rendered={pix.width}x{pix.height}px dest={dest_w}x{dest_h}px"
# For a 35mm × 25mm thermal label, Portrait means "print across the
# 35mm print-head width without rotating". Landscape (2) would
# rotate the output 90° CCW, which is exactly the reported
# "rotated-left" symptom so we must NOT use Landscape here.
try:
devmode.Orientation = 1 # Portrait = no rotation
except:
pass
# Set additional quality settings
try:
devmode.Color = 1 # Monochrome for labels
except:
pass
try:
devmode.TTOption = 2 # DMTT_BITMAP - print TrueType as graphics (sharper)
except:
pass
# Apply settings
try:
props["pDevMode"] = devmode
win32print.SetPrinter(hprinter, 2, props, 0)
print(f"Printer configured: {width_mm}x{height_mm}mm @ HIGH QUALITY")
return True
except Exception as set_err:
print(f"Could not apply printer settings: {set_err}")
return False
finally:
win32print.ClosePrinter(hprinter)
except Exception as e:
print(f"Could not configure printer quality: {e}")
return False
def find_ghostscript():
"""
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.
"""
# 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}")
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
# -dDEVICEWIDTHPOINTS \
# -dDEVICEHEIGHTPOINTS : explicit label size (35mm x 25mm in pts)
# -dFIXEDMEDIA : do not auto-scale/rotate to a different size
# 35mm = 35/25.4*72 ≈ 99.21 pt, 25mm = 25/25.4*72 ≈ 70.87 pt
cmd = [
gs_path,
"-dNOPAUSE",
"-dBATCH",
"-sDEVICE=mswinpr2",
"-dNOSAFER",
"-r600",
"-dTextAlphaBits=4",
"-dGraphicsAlphaBits=4",
"-dDEVICEWIDTHPOINTS=99.21", # 35 mm
"-dDEVICEHEIGHTPOINTS=70.87", # 25 mm
"-dFIXEDMEDIA",
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,
) )
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 return True
except subprocess.CalledProcessError as e:
print(f"GhostScript print failed (exit {e.returncode}): {e.stderr.strip() if e.stderr else ''}") except ImportError as ie:
return False _write_print_log(f"GDI method unavailable ({ie}) trying SumatraPDF fallback")
except Exception as e: except Exception as e:
print(f"GhostScript error: {e}") _write_print_log(f"GDI print error: {e}")
return False 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). 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: Args:
printer_name (str): Name of printer or "PDF" for PDF output printer_name (str): Name of printer or "PDF" for PDF output
file_path (str): Path to file to print file_path (str): Path to file to print
copies (int): Number of copies to print in a single print job
Returns: Returns:
bool: True if successful bool: True if successful
@@ -468,120 +323,14 @@ def print_to_printer(printer_name, file_path):
return True return True
elif SYSTEM == "Windows": elif SYSTEM == "Windows":
# Windows: Print PDF silently without any viewer opening # Windows: Print PDF using Python GDI (pymupdf + win32print).
try: # No external viewer is launched at any point.
if WIN32_AVAILABLE: if file_path.endswith('.pdf'):
import win32print return _print_pdf_windows(file_path, printer_name, copies=copies)
import win32api else:
subprocess.run(['notepad', '/p', file_path],
# Configure printer DEVMODE BEFORE sending any job. check=False,
# This is critical: it sets Portrait orientation (no rotation) creationflags=subprocess.CREATE_NO_WINDOW)
# and maximum print quality so the 35mm×25mm PDF maps
# directly to the physical label without being auto-rotated
# by the driver (which caused the 90° "rotated left" symptom).
configure_printer_quality(printer_name)
if file_path.endswith('.pdf'):
# Try silent printing methods (no viewer opens)
import os
import winreg
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}")
# The printer DEVMODE has already been configured
# above (Portrait, 35mm×25mm, high quality).
# "noscale" tells SumatraPDF to send the PDF
# at its exact size without any shrink/fit.
# Do NOT add "landscape" here: the DEVMODE
# Portrait setting already matches the label
# orientation; adding landscape would tell the
# driver to rotate 90° again and undo the fix.
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")
return True return True
elif SYSTEM == "Darwin": elif SYSTEM == "Darwin":
@@ -601,73 +350,44 @@ def print_to_printer(printer_name, file_path):
return True return True
def print_label_standalone(value, printer, preview=0, use_pdf=True, svg_template=None): def print_label_standalone(value, printer, preview=0, svg_template=None, copies=1):
""" """
Print a label with the specified text on the specified printer. Generate a PDF label, save it to pdf_backup/, and print it on the printer.
Always generates a PDF backup in pdf_backup and prints that PDF. All copies are sent in a single print job to avoid blank labels on thermal
printers that eject a label between separate jobs.
Args: Args:
value (str): The text to print on the label value (str): Label data in format "article;nr_art;serial;status"
printer (str): The name of the printer to use printer (str): Printer name (or "PDF" to skip physical printing)
preview (int): 0 = no preview, 1-3 = 3s preview, >3 = 5s preview preview (int): 0 = print immediately; 1-3 = 3 s delay; >3 = 5 s delay
use_pdf (bool): False to also generate a PNG if PDF generation fails svg_template (str): Path to SVG template (optional; auto-selected if None)
svg_template (str): Path to specific SVG template to use (optional) copies (int): Number of copies to print in one job (default 1)
Returns: Returns:
bool: True if printing was successful, False otherwise bool: True if sending to printer succeeded, False otherwise
""" """
# Track generated files copies = max(1, int(copies))
file_created = False
temp_file = None
pdf_file = None pdf_file = None
try: try:
# Debug output # Step 1 Generate and save PDF to pdf_backup/
print(f"Preview value: {preview}")
print(f"Preview type: {type(preview)}")
print(f"Using format: {'PDF' if use_pdf else 'PNG'}")
# Always generate a PDF backup and print that PDF for verification
try: try:
pdf_file = create_label_pdf(value, svg_template) pdf_file = create_label_pdf(value, svg_template)
if pdf_file and os.path.exists(pdf_file): if pdf_file and os.path.exists(pdf_file):
print(f"PDF label created: {pdf_file}") print(f"PDF label created: {pdf_file}")
print(f"PDF backup saved to: {pdf_file}")
else: else:
print("PDF generation returned no file path") print("PDF generation failed no output file")
return False
except Exception as pdf_err: except Exception as pdf_err:
print(f"PDF generation failed: {pdf_err}") print(f"PDF generation error: {pdf_err}")
# Optionally also create the label image (PNG)
if not pdf_file or not os.path.exists(pdf_file):
if not use_pdf:
label_img = create_label_image(value)
temp_file = 'final_label.png'
label_img.save(temp_file)
print(f"PNG label created: {temp_file}")
else:
temp_file = pdf_file
file_created = True
if not temp_file or not os.path.exists(temp_file):
print("No label file created for printing")
return False return False
# Convert preview to int if it's a string # Step 2 Optional countdown before printing
if isinstance(preview, str): if isinstance(preview, str):
preview = int(preview) preview = int(preview)
if preview > 0: # Any value above 0 shows a preview message if preview > 0:
# Calculate preview duration in seconds preview_sec = 3 if 1 <= preview <= 3 else 5
if 1 <= preview <= 3: print(f"Printing in {preview_sec} seconds… (Ctrl+C to cancel)")
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: try:
for i in range(preview_sec, 0, -1): for i in range(preview_sec, 0, -1):
print(f" {i}...", end=" ", flush=True) print(f" {i}...", end=" ", flush=True)
@@ -677,24 +397,19 @@ def print_label_standalone(value, printer, preview=0, use_pdf=True, svg_template
print("\nCancelled by user") print("\nCancelled by user")
return False return False
# Print after preview # Step 3 Send to printer (all copies in one job)
print("Sending to printer...") print(f"Sending to printer ({copies} cop{'y' if copies==1 else 'ies'})...")
return print_to_printer(printer, temp_file) return print_to_printer(printer, pdf_file, copies=copies)
else:
print("Direct printing without preview...")
# Direct printing without preview (preview = 0)
return print_to_printer(printer, temp_file)
except Exception as e: except Exception as e:
print(f"Error printing label: {str(e)}") print(f"Error printing label: {str(e)}")
return False return False
finally: finally:
# This block always executes, ensuring cleanup
if pdf_file and os.path.exists(pdf_file): if pdf_file and os.path.exists(pdf_file):
print("Cleanup complete - PDF backup saved to pdf_backup folder") print("Cleanup complete PDF backup saved to pdf_backup/")
else: else:
print("Cleanup complete - label file retained for reference") print("Cleanup complete")
# Main code removed - import this module or run as part of the Kivy GUI application # Main code removed - import this module or run as part of the Kivy GUI application

View File

@@ -49,8 +49,8 @@ class PDFLabelGenerator:
""" """
self.label_width = label_width * cm self.label_width = label_width * cm
self.label_height = label_height * cm self.label_height = label_height * cm
# Force landscape: ensure width > height # label_width (3.5 cm) > label_height (2.5 cm) → page is already landscape
self.page_size = landscape((self.label_height, self.label_width)) if self.label_width > self.label_height else (self.label_width, self.label_height) self.page_size = (self.label_width, self.label_height)
self.dpi = dpi self.dpi = dpi
self.margin = 1 * mm # Minimal margin self.margin = 1 * mm # Minimal margin