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

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

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

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

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

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

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

Diagnostics: check_print_area.py, check_print_geometry.py, check_devmode.py,
print_calibration.py, print_measure_scale.py, capture_print_job.py.
This commit is contained in:
2026-09-25 17:30:11 +03:00
parent 58082ed171
commit 93afa3c417
22 changed files with 3877 additions and 23 deletions
+850
View File
@@ -0,0 +1,850 @@
"""
Kivy GUI for the Brother P-touch EDGE PT-E560BT (63 x 20 mm labels).
This is a new entry point alongside the original `label_printer_gui.py`, which
implements the older three-row format and is left untouched.
Window is a landscape 600 x 300 panel split into two columns:
+-----------------------------+---------------------------------+
| CONTROLS | LABEL PREVIEW |
| Inventory number | (63 x 20 mm true proportions) |
| [___________________] | |
| Printer | |
| [Brother PT-E560BT v] | |
| [ PRINT LABEL ] | |
| [ Advanced settings ] | |
+-----------------------------+---------------------------------+
The preview is produced by the same layout engine as the PDF, so the panel is a
faithful representation of the printed result rather than an approximation.
"""
from kivy.config import Config
# Window geometry must be set through Config before the window is created;
# assigning Window.size afterwards is ignored by the SDL2 backend on Windows.
WINDOW_SIZE = (600, 300)
WINDOW_MIN = (520, 280)
Config.set("graphics", "width", str(WINDOW_SIZE[0]))
Config.set("graphics", "height", str(WINDOW_SIZE[1]))
Config.set("graphics", "minimum_width", str(WINDOW_MIN[0]))
Config.set("graphics", "minimum_height", str(WINDOW_MIN[1]))
from kivy.app import App
from kivy.clock import Clock
from kivy.core.image import Image as CoreImage
from kivy.core.window import Window
from kivy.graphics import Color, Line, Rectangle
from kivy.metrics import dp
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.gridlayout import GridLayout
from kivy.uix.label import Label
from kivy.uix.popup import Popup
from kivy.uix.scrollview import ScrollView
from kivy.uix.spinner import Spinner
from kivy.uix.textinput import TextInput
import datetime
import io
import os
import subprocess
import sys
import threading
from label_config import load_config, save_config
from print_area import apply_to_config as apply_print_area, query_print_area
from print_label import get_available_printers, print_ptouch_label
from ptouch_label import PtouchLabelRenderer, code_density_warning
def _printed_logo_size(config, source_size) -> tuple[float, float]:
"""Size the logo will occupy on the label, in mm, preserving aspect ratio.
The logo is fitted inside the logo cell, so this reports the real printed
footprint rather than the source pixel dimensions.
"""
src_w, src_h = source_size
box_w = config.logo_box_mm
box_h = config.logo_box_mm
if not src_w or not src_h:
return (0.0, 0.0)
ratio = min(box_w / src_w, box_h / src_h)
return (src_w * ratio, src_h * ratio)
class LogoThumb(BoxLayout):
"""Small preview of the selected logo, or a placeholder when none is set."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.size_hint_x = None
self.width = dp(48)
self._texture = None
with self.canvas.before:
Color(0.9, 0.9, 0.92, 1)
self._bg = Rectangle(pos=self.pos, size=self.size)
self.bind(pos=self._redraw, size=self._redraw)
def set_path(self, path):
"""Load a thumbnail from a file, or clear it when the path is empty."""
self._texture = None
if path and os.path.exists(path):
try:
from PIL import Image
img = Image.open(path)
img.load()
img = img.convert("RGBA")
background = Image.new("RGBA", img.size, (255, 255, 255, 255))
img = Image.alpha_composite(background, img).convert("RGB")
img.thumbnail((128, 128))
buffer = io.BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
self._texture = CoreImage(buffer, ext="png").texture
except Exception as exc: # noqa: BLE001 - a bad file must not break the UI
print(f"Logo thumbnail error: {exc}")
self._redraw()
def _redraw(self, *_args):
self._bg.pos = self.pos
self._bg.size = self.size
self.canvas.remove_group("thumb")
if self._texture is None:
return
available_w = max(1, self.width - dp(8))
available_h = max(1, self.height - dp(8))
scale = min(
available_w / self._texture.width, available_h / self._texture.height
)
draw_w = max(1, self._texture.width * scale)
draw_h = max(1, self._texture.height * scale)
with self.canvas:
Color(1, 1, 1, 1, group="thumb")
Rectangle(
texture=self._texture,
pos=(self.center_x - draw_w / 2, self.center_y - draw_h / 2),
size=(draw_w, draw_h),
group="thumb",
)
class PreviewPanel(BoxLayout):
"""Displays the rendered label on a dark card, scaled to fit."""
CARD_BG = (0.13, 0.13, 0.15, 1)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._texture = None
with self.canvas.before:
Color(*self.CARD_BG)
self._bg = Rectangle(pos=self.pos, size=self.size)
self.bind(pos=self._on_geometry, size=self._on_geometry)
def _on_geometry(self, *_args):
self._bg.pos = self.pos
self._bg.size = self.size
# Re-place the image so it stays centred when the window is resized.
self._redraw()
def show_image(self, pil_image):
"""Store a PIL image as a texture and draw it."""
buffer = io.BytesIO()
pil_image.save(buffer, format="PNG")
buffer.seek(0)
self._texture = CoreImage(buffer, ext="png").texture
self._redraw()
def _redraw(self):
"""Draw the stored texture centred and fitted inside the panel."""
self.canvas.remove_group("preview")
if self._texture is None:
return
available_w = max(1, self.width - dp(16))
available_h = max(1, self.height - dp(16))
scale = min(
available_w / self._texture.width, available_h / self._texture.height
)
draw_w = max(1, self._texture.width * scale)
draw_h = max(1, self._texture.height * scale)
x = self.center_x - draw_w / 2
y = self.center_y - draw_h / 2
with self.canvas:
Color(1, 1, 1, 1, group="preview")
Rectangle(
texture=self._texture,
pos=(x, y),
size=(draw_w, draw_h),
group="preview",
)
# Hairline outline makes the label edge visible against the card.
Color(0.45, 0.45, 0.5, 1, group="preview")
Line(
rectangle=(x, y, draw_w, draw_h),
width=dp(1),
group="preview",
)
class PtouchPrinterApp(App):
"""P-touch label printing front-end."""
PREVIEW_DPI = 300
PREVIEW_DEBOUNCE_S = 0.35
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.label_cfg = load_config()
self._preview_event = None
full_printers = get_available_printers()
self.printer_display_map = {}
self.available_printers = []
for full_name in full_printers:
display_name = self._shorten_printer_name(full_name)
if display_name in self.printer_display_map:
display_name = full_name[:20]
self.printer_display_map[display_name] = full_name
self.available_printers.append(display_name)
self.cleanup_old_pdfs()
self.cleanup_old_logs()
# -- helpers ----------------------------------------------------------
def _shorten_printer_name(self, name, max_len=22):
"""Shorten a printer name for the dropdown, keeping it unique."""
if name.startswith("\\\\"):
parts = name.strip("\\").split("\\")
short = parts[-1] if len(parts) >= 2 else name
else:
short = name
return short[:max_len]
def _get_full_printer_name(self, display_name):
return self.printer_display_map.get(display_name, display_name)
def cleanup_old_pdfs(self, days=5):
self._cleanup_dir("pdf_backup", (".pdf",), days)
def cleanup_old_logs(self, days=5):
self._cleanup_dir("logs", (".log", ".csv"), days)
def _cleanup_dir(self, folder, suffixes, days):
"""Delete files older than `days` from a folder, ignoring errors."""
if not os.path.exists(folder):
os.makedirs(folder, exist_ok=True)
return
cutoff = datetime.datetime.now().timestamp() - days * 86400
for name in os.listdir(folder):
if not name.endswith(suffixes):
continue
path = os.path.join(folder, name)
try:
if os.path.isfile(path) and os.path.getmtime(path) < cutoff:
os.remove(path)
except OSError as exc:
print(f"Cleanup skipped {name}: {exc}")
def log_print_action(self, inventory, printer, pdf_path, success, warning=""):
"""Append a print event to the daily CSV log."""
os.makedirs("logs", exist_ok=True)
log_filename = os.path.join(
"logs", f"print_log_{datetime.datetime.now().strftime('%Y%m%d')}.csv"
)
def escape(value):
value = str(value)
if any(ch in value for ch in (",", '"', "\n")):
value = '"' + value.replace('"', '""') + '"'
return value
line = (
f"{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')},"
f"{'SUCCESS' if success else 'FAILED'},"
f"{escape(inventory)},{escape(printer)},{escape(pdf_path)},"
f"{escape(warning)}\n"
)
try:
exists = os.path.isfile(log_filename)
with open(log_filename, "a", encoding="utf-8") as handle:
if not exists:
handle.write("Timestamp,Status,Inventory,Printer,PDF File,Warning\n")
handle.write(line)
except OSError as exc:
print(f"Error saving log entry: {exc}")
# -- layout -----------------------------------------------------------
def build(self):
"""Assemble the two-column panel: controls on the left, preview right."""
self.title = "P-touch Label Printing"
root = BoxLayout(orientation="horizontal", spacing=dp(8), padding=dp(10))
# ---- column 1: controls ------------------------------------------
controls = BoxLayout(orientation="vertical", spacing=dp(6), size_hint_x=0.42)
controls.add_widget(
Label(
text="[b]P-touch Label Printing[/b]",
markup=True,
size_hint_y=None,
height=dp(22),
font_size="15sp",
halign="left",
valign="middle",
color=(1, 1, 1, 1),
text_size=(None, dp(22)),
)
)
controls.add_widget(self._field_label("Inventory number"))
self.inventory_input = TextInput(
multiline=False,
size_hint_y=None,
height=dp(42),
font_size="16sp",
padding=(dp(8), dp(8)),
write_tab=False,
)
self.inventory_input.bind(text=self._on_inventory_change)
self.inventory_input.bind(on_text_validate=self._on_enter)
controls.add_widget(self.inventory_input)
controls.add_widget(self._field_label("Printer"))
self.printer_spinner = Spinner(
text=self.available_printers[0] if self.available_printers else "PDF",
values=self.available_printers or ["PDF"],
size_hint_y=None,
height=dp(38),
font_size="13sp",
)
# The printable area differs per printer, so re-fit the preview on change.
self.printer_spinner.bind(text=lambda *_: self.update_preview())
controls.add_widget(self.printer_spinner)
# Push the buttons to the bottom of the column.
controls.add_widget(BoxLayout())
self.print_button = Button(
text="PRINT LABEL",
size_hint_y=None,
height=dp(46),
font_size="15sp",
bold=True,
background_color=(0.16, 0.62, 0.28, 1),
background_normal="",
)
self.print_button.bind(on_press=self.print_label)
controls.add_widget(self.print_button)
settings_button = Button(
text="Advanced settings",
size_hint_y=None,
height=dp(36),
font_size="13sp",
background_color=(0.24, 0.30, 0.45, 1),
background_normal="",
)
settings_button.bind(on_press=lambda *_: self.open_settings())
controls.add_widget(settings_button)
root.add_widget(controls)
# ---- column 2: preview ------------------------------------------
preview_col = BoxLayout(orientation="vertical", spacing=dp(4))
preview_col.add_widget(
self._field_label("Label preview (63 x 20 mm, true proportions)")
)
self.preview = PreviewPanel()
preview_col.add_widget(self.preview)
self.status_label = Label(
text="",
size_hint_y=None,
height=dp(34),
font_size="11sp",
markup=True,
halign="left",
valign="middle",
)
self.status_label.bind(
size=lambda widget, size: setattr(widget, "text_size", (size[0], None))
)
preview_col.add_widget(self.status_label)
root.add_widget(preview_col)
Clock.schedule_once(lambda *_: self.update_preview(), 0.1)
Clock.schedule_once(lambda *_: self.inventory_input.focus, 0.35)
return root
def _field_label(self, text):
"""A small caption label with a fixed height."""
label = Label(
text=text,
size_hint_y=None,
height=dp(18),
font_size="11sp",
halign="left",
valign="middle",
opacity=0.8,
)
label.bind(size=lambda w, s: setattr(w, "text_size", (s[0], None)))
return label
# -- preview ----------------------------------------------------------
def _on_inventory_change(self, *_args):
"""Debounce regeneration so typing does not re-render on every key."""
if self._preview_event is not None:
self._preview_event.cancel()
self._preview_event = Clock.schedule_once(
lambda *_: self.update_preview(), self.PREVIEW_DEBOUNCE_S
)
if hasattr(self, "payload_label") and self.payload_label is not None:
self._refresh_payload_label()
def _on_enter(self, *_args):
if self._preview_event is not None:
self._preview_event.cancel()
self.update_preview()
def _effective_config(self):
"""The config as it will actually be printed, fitted to the printer.
The preview must use this rather than the raw settings, otherwise the
preview would show the unfitted layout while the printer receives a
fitted one - which is precisely the mismatch that caused an earlier
'preview looks fine but prints wrong' report.
"""
cfg = self.label_cfg.copy()
printer = self._get_full_printer_name(self.printer_spinner.text)
if printer and printer != "PDF":
apply_print_area(cfg, printer)
return cfg
def _printer_area_note(self):
"""Short description of the printer's printable limits, for the status line."""
printer = self._get_full_printer_name(self.printer_spinner.text)
if not printer or printer == "PDF":
return ""
area = query_print_area(printer)
if area is None:
return ""
return (
f"printable {area.printable_width_mm:.1f} x {area.printable_height_mm:.1f} mm"
)
def update_preview(self):
"""Render the preview and surface any code-density warning."""
self._preview_event = None
inventory = self.inventory_input.text.strip()
cfg = self._effective_config()
try:
renderer = PtouchLabelRenderer(cfg)
image = renderer.render_preview(inventory, dpi=self.PREVIEW_DPI)
self.preview.show_image(image)
except Exception as exc: # noqa: BLE001 - preview must never crash the app
# Log the traceback too: a silent except here previously hid a real
# bug (config shadowed by Kivy's App.config) for an entire session.
import traceback
traceback.print_exc()
self._set_status(f"[color=ff6666]Preview error: {exc}[/color]")
return
warning = code_density_warning(cfg, inventory)
if warning:
self._set_status(f"[color=ffb347]{warning}[/color]")
else:
payload = cfg.build_code_payload(inventory)
shown = payload if len(payload) <= 34 else payload[:31] + "..."
bitmap = renderer.code_bitmap(inventory)
note = self._printer_area_note()
size = f"label {cfg.page_width_mm:.0f}x{cfg.page_height_mm:.0f} mm"
if bitmap is not None:
size += (
f" | QR {bitmap.width_mm:.1f} mm, "
f"{bitmap.dots_per_module:.0f} dots/mod"
)
tail = f" | {note}" if note else ""
self._set_status(f"QR: [b]{shown}[/b]\n{size}{tail}")
def _set_status(self, markup):
self.status_label.text = markup
# -- settings ---------------------------------------------------------
def open_settings(self):
"""Advanced settings: logo, three static lines, QR string.
Sized to fill most of the window, since the window is only 300 px tall;
the form scrolls when it does not all fit.
"""
content = BoxLayout(orientation="vertical", spacing=dp(6), padding=dp(8))
scroll = ScrollView()
form = GridLayout(cols=1, size_hint_y=None, spacing=dp(4))
form.bind(minimum_height=form.setter("height"))
form.add_widget(self._field_label("Logo image (PNG / JPG)"))
logo_row = BoxLayout(size_hint_y=None, height=dp(34), spacing=dp(6))
self.logo_input = TextInput(
text=self.label_cfg.logo_path, multiline=False, font_size="11sp"
)
logo_row.add_widget(self.logo_input)
browse = Button(text="Browse...", size_hint_x=0.28, font_size="12sp")
browse.bind(on_press=lambda *_: self.browse_logo())
logo_row.add_widget(browse)
clear = Button(text="Clear", size_hint_x=0.18, font_size="12sp")
clear.bind(on_press=lambda *_: self.clear_logo())
logo_row.add_widget(clear)
form.add_widget(logo_row)
# A thumbnail so the operator can confirm the right file was picked.
logo_preview_row = BoxLayout(size_hint_y=None, height=dp(52), spacing=dp(8))
self.logo_thumb = LogoThumb()
logo_preview_row.add_widget(self.logo_thumb)
self.logo_status = Label(
text="",
font_size="11sp",
halign="left",
valign="middle",
markup=True,
)
self.logo_status.bind(
size=lambda w, s: setattr(w, "text_size", (s[0], None))
)
logo_preview_row.add_widget(self.logo_status)
form.add_widget(logo_preview_row)
self._refresh_logo_thumb()
form.add_widget(self._field_label("Static text lines (3)"))
self.static_inputs = []
for index in range(3):
current = (
self.label_cfg.static_lines[index]
if index < len(self.label_cfg.static_lines)
else ""
)
field = TextInput(
text=current,
multiline=False,
size_hint_y=None,
height=dp(32),
font_size="13sp",
)
self.static_inputs.append(field)
form.add_widget(field)
form.add_widget(
self._field_label("Code composition: fixed prefix + inventory + fixed suffix")
)
code_row = BoxLayout(size_hint_y=None, height=dp(32), spacing=dp(6))
self.prefix_input = TextInput(
text=self.label_cfg.qr_prefix,
multiline=False,
font_size="13sp",
hint_text="prefix",
)
self.prefix_input.bind(text=lambda *_: self.preview_with_template())
code_row.add_widget(self.prefix_input)
inv_tag = Label(
text="+ [inventory] +",
size_hint_x=None,
width=dp(96),
font_size="11sp",
halign="center",
valign="middle",
opacity=0.75,
)
inv_tag.bind(size=lambda w, s: setattr(w, "text_size", (s[0], None)))
code_row.add_widget(inv_tag)
self.suffix_input = TextInput(
text=self.label_cfg.qr_suffix,
multiline=False,
font_size="13sp",
hint_text="suffix",
)
self.suffix_input.bind(text=lambda *_: self.preview_with_template())
code_row.add_widget(self.suffix_input)
form.add_widget(code_row)
# Live preview of the composed payload, so the operator can see exactly
# what will be encoded before printing anything.
self.payload_label = Label(
text="",
size_hint_y=None,
height=dp(30),
font_size="11sp",
halign="left",
valign="middle",
markup=True,
)
self.payload_label.bind(
size=lambda w, s: setattr(w, "text_size", (s[0], None))
)
form.add_widget(self.payload_label)
form.add_widget(
self._field_label("QR error correction (L = shortest, H = most robust)")
)
self.ec_spinner = Spinner(
text=self.label_cfg.qr_error_correction,
values=["L", "M", "Q", "H"],
size_hint_y=None,
height=dp(32),
font_size="13sp",
)
self.ec_spinner.bind(text=lambda *_: self.preview_with_template())
form.add_widget(self.ec_spinner)
self._refresh_payload_label()
scroll.add_widget(form)
content.add_widget(scroll)
buttons = BoxLayout(size_hint_y=None, height=dp(40), spacing=dp(6))
save = Button(
text="Save",
background_color=(0.16, 0.62, 0.28, 1),
background_normal="",
)
cancel = Button(text="Cancel")
buttons.add_widget(save)
buttons.add_widget(cancel)
content.add_widget(buttons)
self._settings_popup = Popup(
title="Advanced settings", content=content, size_hint=(0.98, 1.0)
)
save.bind(on_press=lambda *_: self.apply_settings())
cancel.bind(on_press=lambda *_: self._settings_popup.dismiss())
self._settings_popup.bind(on_dismiss=self._on_settings_dismissed)
self._settings_popup.open()
def preview_with_template(self):
"""Reflect in-dialog code edits in the preview immediately."""
self.label_cfg.qr_prefix = self.prefix_input.text
self.label_cfg.qr_suffix = self.suffix_input.text
self.label_cfg.qr_error_correction = self.ec_spinner.text
self._refresh_payload_label()
self.update_preview()
def _refresh_payload_label(self):
"""Show exactly what the code will encode, using the current field value."""
self.label_cfg.qr_prefix = self.prefix_input.text
self.label_cfg.qr_suffix = self.suffix_input.text
sample = self.inventory_input.text.strip()
payload = self.label_cfg.build_code_payload(sample)
shown = payload if payload else "[i](empty - enter an inventory number)[/i]"
self.payload_label.text = f"Encodes: [b]{shown}[/b]"
def _on_settings_dismissed(self, *_args):
"""On close, reload from disk so unsaved edits are discarded."""
self._settings_popup = None
self.label_cfg = load_config()
self.update_preview()
def browse_logo(self):
"""Open the real Windows file dialog to choose a logo.
The dialog runs in a subprocess because Tk and Kivy both want the main
event loop; giving Tk its own process avoids that conflict.
"""
script = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"native_file_dialog.py")
if not os.path.exists(script):
self.show_popup(
"Missing helper",
"native_file_dialog.py was not found next to the application.",
)
return
args = [
sys.executable,
script,
"--dir", self.label_cfg.logo_path or "",
"--title", "Choose logo image",
]
try:
result = subprocess.run(
args,
capture_output=True,
text=True,
check=False,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except OSError as exc:
self.show_popup("Error", f"Could not open the file dialog:\n{exc}")
return
output = (result.stdout or "").strip().splitlines()
line = output[-1] if output else ""
print(f"Logo dialog result: {line or '(no output)'}")
if line.startswith("SELECTED:"):
path = line[len("SELECTED:"):]
self.logo_input.text = path
self.label_cfg.logo_path = path
save_config(self.label_cfg)
self.update_preview()
elif line == "CANCELLED":
pass
else:
detail = (result.stderr or "").strip().splitlines()
detail = detail[-1] if detail else "no output from the dialog"
self.show_popup("Error", f"File dialog failed:\n{detail}")
def clear_logo(self):
"""Remove the logo from the label."""
self.logo_input.text = ""
self.label_cfg.logo_path = ""
save_config(self.label_cfg)
self._refresh_logo_thumb()
self.update_preview()
def _refresh_logo_thumb(self):
"""Update the logo thumbnail and its status caption."""
path = self.label_cfg.logo_path
self.logo_thumb.set_path(path)
if not path:
self.logo_status.text = "[i]No logo set - the first column stays empty[/i]"
return
if not os.path.exists(path):
self.logo_status.text = f"[color=ff8888]File not found:[/color]\n{os.path.basename(path)}"
return
try:
from PIL import Image
with Image.open(path) as img:
mode = img.mode
size = img.size
print_dim = _printed_logo_size(self.label_cfg, size)
self.logo_status.text = (
f"{os.path.basename(path)}\n"
f"{size[0]}x{size[1]} {mode} -> prints {print_dim[0]:.1f}x{print_dim[1]:.1f} mm"
)
except Exception as exc: # noqa: BLE001
self.logo_status.text = f"[color=ff8888]Unreadable image:[/color]\n{exc}"
def apply_settings(self):
"""Persist settings to disk and refresh the preview."""
self.label_cfg.logo_path = self.logo_input.text.strip()
self.label_cfg.static_lines = [field.text for field in self.static_inputs]
self.label_cfg.qr_prefix = self.prefix_input.text
self.label_cfg.qr_suffix = self.suffix_input.text
# The composed prefix/suffix form supersedes any legacy template.
self.label_cfg.qr_template = ""
self.label_cfg.qr_error_correction = self.ec_spinner.text
try:
print(f"Settings saved to {save_config(self.label_cfg)}")
except OSError as exc:
self.show_popup("Error", f"Could not save settings: {exc}")
popup, self._settings_popup = self._settings_popup, None
if popup is not None:
popup.unbind(on_dismiss=self._on_settings_dismissed)
popup.dismiss()
self.update_preview()
# -- printing ---------------------------------------------------------
def print_label(self, _instance):
inventory = self.inventory_input.text.strip()
if not inventory:
self.show_popup("Missing value", "Enter an inventory number first.")
return
printer = self._get_full_printer_name(self.printer_spinner.text)
popup = self._busy_popup("Generating label...\nPlease wait")
def worker():
try:
success, pdf_path, warning = print_ptouch_label(
inventory, printer, self.label_cfg
)
self.log_print_action(
inventory, printer, pdf_path, success, warning or ""
)
Clock.schedule_once(lambda *_: popup.dismiss(), 0)
if success:
message = "Label sent to printer."
if warning:
message += f"\n\nNote: {warning}"
Clock.schedule_once(
lambda *_: self.show_popup("Success", message, auto_dismiss=not warning),
0.1,
)
Clock.schedule_once(lambda *_: self.reset_inventory(), 0.2)
else:
Clock.schedule_once(
lambda *_: self.show_popup("Error", "Printing failed."), 0.1
)
except Exception as exc: # noqa: BLE001
self.log_print_action(inventory, printer, "", False, str(exc))
Clock.schedule_once(lambda *_: popup.dismiss(), 0)
Clock.schedule_once(
lambda *_: self.show_popup("Error", f"Print error: {exc}"), 0.1
)
threading.Thread(target=worker, daemon=True).start()
def _busy_popup(self, message):
"""A small modal shown while a print job runs."""
popup = Popup(
title="Please wait",
content=Label(text=message),
size_hint=(None, None),
size=(dp(260), dp(120)),
auto_dismiss=False,
)
popup.open()
return popup
def reset_inventory(self):
"""Clear the variable field, keeping printer selection and settings."""
self.inventory_input.text = ""
self.update_preview()
self.inventory_input.focus = True
def show_popup(self, title, message, auto_dismiss=False):
"""A fixed-size modal that fits the small landscape window."""
content = BoxLayout(orientation="vertical", padding=dp(8), spacing=dp(8))
body = Label(text=message, halign="center", valign="middle")
body.bind(size=lambda w, s: setattr(w, "text_size", (s[0], None)))
content.add_widget(body)
close = Button(text="OK", size_hint_y=None, height=dp(34))
content.add_widget(close)
popup = Popup(
title=title,
content=content,
size_hint=(None, None),
size=(dp(320), dp(180)),
title_size="14sp",
)
close.bind(on_press=popup.dismiss)
popup.open()
if auto_dismiss:
Clock.schedule_once(lambda *_: popup.dismiss(), 2.5)
if __name__ == "__main__":
PtouchPrinterApp().run()