Complete label printer redesign: file monitoring, SVG templates, sharp print quality

- Redesigned GUI for automatic file monitoring workflow
- Changed label format to 35x25mm landscape
- Implemented SVG template support with variable substitution
- Added configuration auto-save/load (conf/app.conf)
- Added system tray minimize functionality
- Fixed print quality: landscape orientation, vector fonts, 600 DPI
- Auto-clear file after print to prevent duplicates
- All popups auto-dismiss after 2-3 seconds
- Semicolon separator for data format (article;nr_art;serial)
- SumatraPDF integration with noscale settings
- Printer configured for outline fonts (sharp output)
- Reorganized documentation into documentation/ folder
This commit is contained in:
NAME
2026-02-12 22:25:51 +02:00
parent 0743c44051
commit 8954135f93
51 changed files with 1209 additions and 6396 deletions

View File

@@ -15,6 +15,7 @@ from kivy.uix.popup import Popup
from kivy.core.window import Window
from kivy.uix.image import Image as KivyImage
from kivy.graphics import Color, Rectangle
from kivy.uix.filechooser import FileChooserListView
import os
import threading
@@ -22,8 +23,14 @@ import platform
import time
import datetime
import glob
import configparser
from print_label import print_label_standalone, get_available_printers
from kivy.clock import Clock
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import pystray
from pystray import MenuItem as item
from PIL import Image, ImageDraw
# Set window size - portrait/phone dimensions (375x667 like iPhone)
# Adjusted to be slightly wider for touch-friendly UI
@@ -33,8 +40,26 @@ Window.size = (420, 700)
class FileMonitorHandler(FileSystemEventHandler):
"""Handler for file system events"""
def __init__(self, app, file_path):
self.app = app
self.file_path = file_path
self.last_modified = 0
def on_modified(self, event):
"""Called when a file is modified"""
if event.src_path == self.file_path:
# Debounce - avoid multiple triggers
current_time = time.time()
if current_time - self.last_modified > 1: # 1 second debounce
self.last_modified = current_time
Clock.schedule_once(lambda dt: self.app.on_file_changed(), 0)
class LabelPrinterApp(App):
"""Simplified Kivy application for label printing"""
"""Simplified Kivy application for label printing with file monitoring"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -49,6 +74,15 @@ class LabelPrinterApp(App):
display_name = full_name[:20]
self.printer_display_map[display_name] = full_name
self.available_printers.append(display_name)
# File monitoring variables
self.observer = None
self.monitored_file = None
self.monitoring_active = False
# Configuration file path
self.config_file = os.path.join('conf', 'app.conf')
# System tray variables
self.tray_icon = None
self.is_minimized = False
# Clean old PDF backup files on startup
self.cleanup_old_pdfs()
# Clean old log files on startup
@@ -149,14 +183,60 @@ class LabelPrinterApp(App):
except Exception as e:
print(f"Error during log cleanup: {e}")
def log_print_action(self, sap_nr, quantity, cable_id, printer, pdf_filename, success):
def load_config(self):
"""
Load configuration from conf/app.conf file.
Returns dict with 'file_path' and 'printer' keys.
"""
config = {'file_path': '', 'printer': ''}
if not os.path.exists(self.config_file):
return config
try:
parser = configparser.ConfigParser()
parser.read(self.config_file, encoding='utf-8')
if 'Settings' in parser:
config['file_path'] = parser['Settings'].get('file_path', '')
config['printer'] = parser['Settings'].get('printer', '')
print(f"Configuration loaded from {self.config_file}")
except Exception as e:
print(f"Error loading config: {e}")
return config
def save_config(self):
"""
Save current configuration to conf/app.conf file.
Saves file_path and selected printer.
"""
# Ensure conf folder exists
os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
try:
parser = configparser.ConfigParser()
parser['Settings'] = {
'file_path': self.file_input.text.strip(),
'printer': self.printer_spinner.text
}
with open(self.config_file, 'w', encoding='utf-8') as f:
parser.write(f)
print(f"Configuration saved to {self.config_file}")
except Exception as e:
print(f"Error saving config: {e}")
def log_print_action(self, article, nr_art, serial, printer, pdf_filename, success):
"""
Log the print action to a CSV log file (table format).
Args:
sap_nr (str): SAP article number
quantity (str): Quantity value
cable_id (str): Cable ID
article (str): Article/Comanda number
nr_art (str): Nr. Art. value
serial (str): Serial number
printer (str): Printer name
pdf_filename (str): Path to the generated PDF file
success (bool): Whether the print was successful
@@ -186,9 +266,9 @@ class LabelPrinterApp(App):
# Create CSV line
log_line = (
f"{timestamp},{status},"
f"{escape_csv(sap_nr)},"
f"{escape_csv(quantity)},"
f"{escape_csv(cable_id)},"
f"{escape_csv(article)},"
f"{escape_csv(nr_art)},"
f"{escape_csv(serial)},"
f"{escape_csv(printer)},"
f"{escape_csv(pdf_filename)}\n"
)
@@ -200,7 +280,7 @@ class LabelPrinterApp(App):
with open(log_filename, 'a', encoding='utf-8') as f:
# Add header if file is new
if not file_exists:
f.write("Timestamp,Status,SAP-Nr,Quantity,Cable ID,Printer,PDF File\n")
f.write("Timestamp,Status,Article,Nr Art,Serial No,Printer,PDF File\n")
# Append log entry
f.write(log_line)
@@ -209,15 +289,15 @@ class LabelPrinterApp(App):
print(f"Error saving log entry: {e}")
def build(self):
"""Build the simplified single-column UI"""
self.title = "Label Printing"
"""Build the simplified file monitoring UI"""
self.title = "Label Printing - File Monitor"
# Main container - single column layout
main_layout = BoxLayout(orientation='vertical', spacing=8, padding=12)
# Title
title = Label(
text='[b]Label Printing[/b]',
text='[b]Label Printing - File Monitor[/b]',
markup=True,
size_hint_y=0.08,
font_size='18sp',
@@ -226,70 +306,56 @@ class LabelPrinterApp(App):
main_layout.add_widget(title)
# Scroll view for form fields
scroll = ScrollView(size_hint_y=0.75)
scroll = ScrollView(size_hint_y=0.60)
form_layout = GridLayout(cols=1, spacing=8, size_hint_y=None, padding=8)
form_layout.bind(minimum_height=form_layout.setter('height'))
# SAP-Nr. Articol
sap_label = Label(
text='SAP-Nr. Articol:',
# File path input
file_label = Label(
text='Monitor File Path:',
size_hint_y=None,
height=30,
font_size='12sp'
)
form_layout.add_widget(sap_label)
form_layout.add_widget(file_label)
self.sap_input = TextInput(
# File path row with input and browse button
file_row = BoxLayout(orientation='horizontal', size_hint_y=None, height=45, spacing=5)
self.file_input = TextInput(
multiline=False,
size_hint_y=None,
height=45,
font_size='14sp',
background_color=(0.95, 0.95, 0.95, 1),
padding=(10, 10)
)
self.sap_input.bind(text=self.on_sap_text_change)
form_layout.add_widget(self.sap_input)
# Cantitate
qty_label = Label(
text='Cantitate:',
size_hint_y=None,
height=30,
font_size='12sp'
)
form_layout.add_widget(qty_label)
self.qty_input = TextInput(
multiline=False,
size_hint_y=None,
height=45,
font_size='14sp',
size_hint_x=0.75,
font_size='12sp',
background_color=(0.95, 0.95, 0.95, 1),
padding=(10, 10),
input_filter='int' # Only allow numbers
hint_text='Enter file path to monitor'
)
self.qty_input.bind(text=self.on_qty_text_change)
form_layout.add_widget(self.qty_input)
file_row.add_widget(self.file_input)
# ID rola cablu
cable_id_label = Label(
text='ID rola cablu:',
browse_button = Button(
text='Browse',
size_hint_x=0.25,
font_size='11sp',
background_color=(0.3, 0.5, 0.7, 1),
background_normal=''
)
browse_button.bind(on_press=self.browse_file)
file_row.add_widget(browse_button)
form_layout.add_widget(file_row)
# Monitoring status
self.status_label = Label(
text='Status: Not monitoring',
size_hint_y=None,
height=30,
font_size='12sp'
font_size='11sp',
color=(1, 0.5, 0, 1)
)
form_layout.add_widget(cable_id_label)
form_layout.add_widget(self.status_label)
self.cable_id_input = TextInput(
multiline=False,
size_hint_y=None,
height=45,
font_size='14sp',
background_color=(0.95, 0.95, 0.95, 1),
padding=(10, 10)
)
self.cable_id_input.bind(text=self.on_cable_id_text_change)
form_layout.add_widget(self.cable_id_input)
# Add spacing
form_layout.add_widget(Label(text='', size_hint_y=None, height=20))
# Printer selection
printer_label = Label(
@@ -309,55 +375,346 @@ class LabelPrinterApp(App):
sync_height=True,
)
self.printer_spinner = printer_spinner
# Save config when printer changes
printer_spinner.bind(text=self.on_printer_changed)
form_layout.add_widget(printer_spinner)
scroll.add_widget(form_layout)
main_layout.add_widget(scroll)
# Print button
print_button = Button(
text='PRINT LABEL',
size_hint_y=0.15,
# Buttons layout
buttons_layout = BoxLayout(orientation='vertical', size_hint_y=0.30, spacing=5)
# Start/Stop monitoring button
self.monitor_button = Button(
text='START MONITORING',
size_hint_y=0.7,
font_size='14sp',
background_color=(0.2, 0.6, 0.2, 1),
background_color=(0.2, 0.5, 0.8, 1),
background_normal='',
bold=True
)
print_button.bind(on_press=self.print_label)
main_layout.add_widget(print_button)
self.monitor_button.bind(on_press=self.toggle_monitoring)
buttons_layout.add_widget(self.monitor_button)
# Minimize to tray button
minimize_button = Button(
text='MINIMIZE TO TRAY',
size_hint_y=0.3,
font_size='11sp',
background_color=(0.5, 0.5, 0.5, 1),
background_normal=''
)
minimize_button.bind(on_press=self.minimize_to_tray)
buttons_layout.add_widget(minimize_button)
main_layout.add_widget(buttons_layout)
# Load configuration after UI is built
Clock.schedule_once(lambda dt: self.apply_config(), 0.5)
return main_layout
def on_sap_text_change(self, instance, value):
"""Limit SAP input to 25 characters"""
if len(value) > 25:
self.sap_input.text = value[:25]
def apply_config(self):
"""
Load and apply saved configuration.
Auto-start monitoring if both file and printer are configured.
"""
config = self.load_config()
# Apply file path
if config['file_path']:
self.file_input.text = config['file_path']
print(f"Loaded file path: {config['file_path']}")
# Apply printer selection
if config['printer'] and config['printer'] in self.available_printers:
self.printer_spinner.text = config['printer']
print(f"Loaded printer: {config['printer']}")
# Auto-start monitoring if both are configured and file exists
if config['file_path'] and config['printer']:
if os.path.exists(config['file_path']) and os.path.isfile(config['file_path']):
print("Auto-starting monitoring with saved configuration...")
self.start_monitoring()
else:
print(f"Configured file not found: {config['file_path']}")
def on_qty_text_change(self, instance, value):
"""Limit Quantity input to 25 characters"""
if len(value) > 25:
self.qty_input.text = value[:25]
def on_printer_changed(self, spinner, text):
"""
Called when printer selection changes.
Save configuration.
"""
self.save_config()
def on_cable_id_text_change(self, instance, value):
"""Limit Cable ID input to 25 characters"""
if len(value) > 25:
self.cable_id_input.text = value[:25]
def create_tray_icon_image(self):
"""
Create a simple icon for the system tray.
Returns PIL Image.
"""
# Create a 64x64 icon with a simple printer symbol
width = 64
height = 64
image = Image.new('RGB', (width, height), color='white')
draw = ImageDraw.Draw(image)
# Draw a simple printer icon (rectangle with lines)
draw.rectangle([10, 20, 54, 40], fill='blue', outline='black', width=2)
draw.rectangle([15, 40, 49, 50], fill='lightblue', outline='black', width=2)
draw.rectangle([20, 10, 44, 20], fill='lightgray', outline='black', width=1)
return image
def minimize_to_tray(self, instance=None):
"""
Minimize the application to system tray.
"""
if self.is_minimized:
return
# Hide the window
Window.hide()
self.is_minimized = True
# Create tray icon if not exists
if not self.tray_icon:
icon_image = self.create_tray_icon_image()
menu = pystray.Menu(
item('Restore', self.restore_from_tray),
item('Exit', self.exit_from_tray)
)
self.tray_icon = pystray.Icon(
'Label Printer',
icon_image,
'Label Printer - Monitoring',
menu
)
# Run tray icon in separate thread
tray_thread = threading.Thread(target=self.tray_icon.run, daemon=True)
tray_thread.start()
def restore_from_tray(self, icon=None, item=None):
"""
Restore the application from system tray.
"""
if not self.is_minimized:
return
# Stop tray icon
if self.tray_icon:
self.tray_icon.stop()
self.tray_icon = None
# Show the window
Clock.schedule_once(lambda dt: Window.show(), 0)
self.is_minimized = False
def exit_from_tray(self, icon=None, item=None):
"""
Exit the application from system tray.
"""
# Stop tray icon
if self.tray_icon:
self.tray_icon.stop()
self.tray_icon = None
# Stop monitoring
if self.observer:
self.observer.stop()
self.observer.join()
# Stop the app
Clock.schedule_once(lambda dt: self.stop(), 0)
def browse_file(self, instance):
"""Open file browser to select file to monitor"""
content = BoxLayout(orientation='vertical', spacing=10, padding=10)
# File chooser
file_chooser = FileChooserListView(
path=os.path.expanduser('~'),
size_hint=(1, 0.9)
)
content.add_widget(file_chooser)
# Buttons
buttons = BoxLayout(size_hint_y=0.1, spacing=10)
popup = Popup(
title='Select File to Monitor',
content=content,
size_hint=(0.9, 0.9)
)
def select_file(instance):
if file_chooser.selection:
self.file_input.text = file_chooser.selection[0]
# Save config when file is selected
self.save_config()
popup.dismiss()
select_btn = Button(text='Select')
select_btn.bind(on_press=select_file)
buttons.add_widget(select_btn)
cancel_btn = Button(text='Cancel')
cancel_btn.bind(on_press=popup.dismiss)
buttons.add_widget(cancel_btn)
content.add_widget(buttons)
popup.open()
def toggle_monitoring(self, instance):
"""Start or stop file monitoring"""
if not self.monitoring_active:
self.start_monitoring()
else:
self.stop_monitoring()
def start_monitoring(self):
"""Start monitoring the specified file"""
file_path = self.file_input.text.strip()
if not file_path:
self.show_popup("Error", "Please enter a file path to monitor", auto_dismiss_after=3)
return
if not os.path.exists(file_path):
self.show_popup("Error", "File does not exist", auto_dismiss_after=3)
return
if not os.path.isfile(file_path):
self.show_popup("Error", "Path is not a file", auto_dismiss_after=3)
return
try:
# Stop existing observer if any
if self.observer:
self.observer.stop()
self.observer.join()
# Create and start new observer
self.monitored_file = os.path.abspath(file_path)
event_handler = FileMonitorHandler(self, self.monitored_file)
self.observer = Observer()
self.observer.schedule(event_handler, os.path.dirname(self.monitored_file), recursive=False)
self.observer.start()
self.monitoring_active = True
self.monitor_button.text = 'STOP MONITORING'
self.monitor_button.background_color = (0.8, 0.2, 0.2, 1)
self.status_label.text = f'Status: Monitoring {os.path.basename(file_path)}'
self.status_label.color = (0, 1, 0, 1)
self.show_popup("Success", f"Started monitoring:\n{file_path}", auto_dismiss_after=2)
except Exception as e:
self.show_popup("Error", f"Failed to start monitoring:\n{str(e)}", auto_dismiss_after=3)
def stop_monitoring(self):
"""Stop file monitoring"""
try:
if self.observer:
self.observer.stop()
self.observer.join()
self.observer = None
self.monitoring_active = False
self.monitor_button.text = 'START MONITORING'
self.monitor_button.background_color = (0.2, 0.5, 0.8, 1)
self.status_label.text = 'Status: Not monitoring'
self.status_label.color = (1, 0.5, 0, 1)
self.show_popup("Success", "Stopped monitoring", auto_dismiss_after=2)
except Exception as e:
self.show_popup("Error", f"Failed to stop monitoring:\n{str(e)}", auto_dismiss_after=3)
def on_file_changed(self):
"""Called when monitored file changes"""
if not self.monitoring_active or not self.monitored_file:
return
# Automatically print label when file changes
self.print_label(None)
def clear_file_after_print(self):
"""Clear the monitored file and write '-' to prevent re-printing"""
if not self.monitored_file or not os.path.exists(self.monitored_file):
return
try:
with open(self.monitored_file, 'w', encoding='utf-8') as f:
f.write('-')
print(f"Cleared file: {self.monitored_file}")
except Exception as e:
print(f"Error clearing file: {e}")
def read_file_variables(self):
"""Read variables from monitored file"""
if not self.monitored_file or not os.path.exists(self.monitored_file):
return None, None, None
try:
with open(self.monitored_file, 'r', encoding='utf-8') as f:
content = f.read().strip()
# Skip if file is empty or only contains "-" (cleared marker)
if not content or content == '-':
return None, None, None
# Parse file content - expecting format: article;nr_art;serial
# or key=value pairs on separate lines
lines = content.split('\n')
article = ""
nr_art = ""
serial = ""
# Try semicolon-separated format first
if ';' in content:
parts = content.split(';')
if len(parts) >= 1:
article = parts[0].strip()
if len(parts) >= 2:
nr_art = parts[1].strip()
if len(parts) >= 3:
serial = parts[2].strip()
else:
# Try key=value format
for line in lines:
if '=' in line:
key, value = line.split('=', 1)
key = key.strip().lower()
value = value.strip()
if key in ['article', 'articol', 'comanda', 'nr_comanda']:
article = value
elif key in ['nr_art', 'nr-art', 'nrart']:
nr_art = value
elif key in ['serial', 'serial_no', 'serial-no', 'serialno']:
serial = value
return article, nr_art, serial
except Exception as e:
print(f"Error reading file: {e}")
return None, None, None
def print_label(self, instance):
"""Handle print button press"""
sap_nr = self.sap_input.text.strip()
quantity = self.qty_input.text.strip()
cable_id = self.cable_id_input.text.strip()
"""Handle print button press - read from file and print"""
# Read variables from file
article, nr_art, serial = self.read_file_variables()
# Resolve display name to full printer name
printer = self._get_full_printer_name(self.printer_spinner.text)
# Validate input
if not sap_nr and not quantity and not cable_id:
self.show_popup("Error", "Please enter at least one field")
if not article and not nr_art and not serial:
self.show_popup("Error", "No data in file or file not set", auto_dismiss_after=3)
return
# Create combined label text
label_text = f"{sap_nr}|{quantity}|{cable_id}"
# Create combined label text using semicolon separator
label_text = f"{article};{nr_art};{serial}"
# Show loading popup
popup = Popup(
@@ -389,39 +746,39 @@ class LabelPrinterApp(App):
if success:
# Log the successful print action
self.log_print_action(sap_nr, quantity, cable_id, printer, pdf_filename or "unknown", True)
self.log_print_action(article, nr_art, serial, printer, pdf_filename or "unknown", True)
# Clear the file and write "-" to prevent re-printing
Clock.schedule_once(lambda dt: self.clear_file_after_print(), 0)
# Use Clock.schedule_once to update UI from main thread
Clock.schedule_once(lambda dt: popup.dismiss(), 0)
Clock.schedule_once(lambda dt: self.show_popup("Success", "Label printed successfully!"), 0.1)
# Clear inputs after successful print (but keep printer selection)
Clock.schedule_once(lambda dt: self.clear_inputs(), 0.2)
Clock.schedule_once(lambda dt: self.show_popup("Success", "Label printed successfully!", auto_dismiss_after=3), 0.1)
else:
# Log the failed print action
self.log_print_action(sap_nr, quantity, cable_id, printer, pdf_filename or "unknown", False)
self.log_print_action(article, nr_art, serial, printer, pdf_filename or "unknown", False)
Clock.schedule_once(lambda dt: popup.dismiss(), 0)
Clock.schedule_once(lambda dt: self.show_popup("Error", "Failed to print label"), 0.1)
Clock.schedule_once(lambda dt: self.show_popup("Error", "Failed to print label", auto_dismiss_after=3), 0.1)
except Exception as e:
# Log the error
self.log_print_action(sap_nr, quantity, cable_id, printer, pdf_filename or "unknown", False)
self.log_print_action(article, nr_art, serial, printer, pdf_filename or "unknown", False)
Clock.schedule_once(lambda dt: popup.dismiss(), 0)
Clock.schedule_once(lambda dt: self.show_popup("Error", f"Print error: {str(e)}"), 0.1)
Clock.schedule_once(lambda dt: self.show_popup("Error", f"Print error: {str(e)}", auto_dismiss_after=3), 0.1)
thread = threading.Thread(target=print_thread)
thread.daemon = True
thread.start()
def clear_inputs(self):
"""Clear only the input fields, preserving printer selection"""
self.sap_input.text = ''
self.qty_input.text = ''
self.cable_id_input.text = ''
# Printer selection is NOT cleared - it persists until user changes it
def show_popup(self, title, message):
"""Show a popup message"""
def show_popup(self, title, message, auto_dismiss_after=None):
"""Show a popup message
Args:
title (str): Popup title
message (str): Popup message
auto_dismiss_after (float): Seconds after which to auto-dismiss (None = manual dismiss only)
"""
popup = Popup(
title=title,
content=BoxLayout(
@@ -439,6 +796,10 @@ class LabelPrinterApp(App):
popup.content.add_widget(close_button)
popup.open()
# Auto-dismiss if specified
if auto_dismiss_after:
Clock.schedule_once(lambda dt: popup.dismiss(), auto_dismiss_after)
if __name__ == '__main__':