mirror of
https://github.com/AxonByteDev/paperless-asn-label-generator.git
synced 2026-08-06 08:42:03 +00:00
fix(ui): widen window and preview layout
This commit is contained in:
parent
71ed2e5c3e
commit
739813b990
4 changed files with 152 additions and 49 deletions
|
|
@ -1,5 +1,13 @@
|
|||
# Changelog
|
||||
|
||||
## 0.1.4 - 2026-02-08
|
||||
### Changed
|
||||
- Vorschau für `CODE128` rendert jetzt als echter Barcode statt Platzhalter.
|
||||
- Vorschau-Layout wurde neu ausbalanciert: QR kleiner, Text deutlich größer/lesbarer, kein Überlappen von CODE128 und Text.
|
||||
- Fensterbreite und Vorschaufläche wurden vergrößert, damit alle UI-Elemente und der Vorschau-Text vollständig sichtbar bleiben.
|
||||
- Statusmeldungen in der UI sind vereinheitlicht (`[INFO]`, `[OK]`, `[WARN]`, `[ERROR]`) und farblich klar getrennt.
|
||||
- Layout verhält sich auf kleineren Displays robuster (reduzierte `minsize`, bessere Spalten-Gewichtung, umbrochene Hilfstexte/Statuszeilen).
|
||||
|
||||
## 0.1.3 - 2026-02-08
|
||||
### Changed
|
||||
- Kalibrierungs-Eingaben sind jetzt als sichtbare Delta-Werte ausgelegt (Default `0.0` in der UI), während intern weiterhin die Basiswerte genutzt werden.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "paperless-asn-label-generator"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
description = "GUI tool to generate ASN barcode/QR labels for paperless-ngx (A4/Avery L4731) with calibration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
__all__ = ["__version__"]
|
||||
__version__ = "0.1.3"
|
||||
__version__ = "0.1.4"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import math
|
|||
import os
|
||||
import sys
|
||||
import json
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Tuple
|
||||
|
|
@ -30,15 +31,31 @@ from reportlab.pdfgen import canvas as rl_canvas
|
|||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import mm, cm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.graphics.barcode import code128
|
||||
from reportlab.graphics import renderPM
|
||||
from reportlab.graphics.barcode import code128, createBarcodeDrawing
|
||||
|
||||
import qrcode
|
||||
from PIL import Image, ImageTk
|
||||
from PIL import Image, ImageTk, ImageDraw, ImageFont
|
||||
|
||||
from . import __version__
|
||||
|
||||
|
||||
BarcodeKind = Literal["QR", "CODE128"]
|
||||
StatusLevel = Literal["info", "success", "warn", "error"]
|
||||
|
||||
STATUS_COLORS: dict[StatusLevel, str] = {
|
||||
"info": "#2b2b2b",
|
||||
"success": "#0b7d2b",
|
||||
"warn": "#9a6700",
|
||||
"error": "#b00020",
|
||||
}
|
||||
|
||||
STATUS_PREFIX: dict[StatusLevel, str] = {
|
||||
"info": "[INFO]",
|
||||
"success": "[OK]",
|
||||
"warn": "[WARN]",
|
||||
"error": "[ERROR]",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -98,6 +115,46 @@ def make_qr_image(data: str, target_px: int) -> Image.Image:
|
|||
return img.resize((target_px, target_px), Image.Resampling.LANCZOS)
|
||||
|
||||
|
||||
def make_code128_preview_image(data: str, target_w: int, target_h: int) -> Image.Image:
|
||||
try:
|
||||
drawing = createBarcodeDrawing(
|
||||
"Code128",
|
||||
value=data,
|
||||
barHeight=max(18.0, target_h * 0.55),
|
||||
barWidth=0.24 * mm,
|
||||
humanReadable=False,
|
||||
)
|
||||
png_bytes = renderPM.drawToString(drawing, fmt="PNG")
|
||||
img = Image.open(BytesIO(png_bytes)).convert("RGB")
|
||||
img.thumbnail((max(10, target_w), max(10, target_h)), Image.Resampling.LANCZOS)
|
||||
return img
|
||||
except Exception:
|
||||
# Fallback: simple barcode-like preview, falls Render-Pipeline fehlt.
|
||||
fallback = Image.new("RGB", (max(10, target_w), max(10, target_h)), "white")
|
||||
draw = ImageDraw.Draw(fallback)
|
||||
x = 2
|
||||
while x < fallback.width - 2:
|
||||
bar_w = 1 if (x // 3) % 2 == 0 else 2
|
||||
draw.rectangle([x, 2, min(fallback.width - 2, x + bar_w), fallback.height - 3], fill="black")
|
||||
x += bar_w + 2
|
||||
return fallback
|
||||
|
||||
|
||||
def fit_preview_font(draw: ImageDraw.ImageDraw, text: str, max_w: int, max_h: int) -> ImageFont.ImageFont:
|
||||
max_size = max(10, min(42, max_h))
|
||||
for size in range(max_size, 9, -1):
|
||||
try:
|
||||
font = ImageFont.truetype("DejaVuSans.ttf", size)
|
||||
except Exception:
|
||||
break
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
w = bbox[2] - bbox[0]
|
||||
h = bbox[3] - bbox[1]
|
||||
if w <= max_w and h <= max_h:
|
||||
return font
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def draw_label(
|
||||
c: rl_canvas.Canvas,
|
||||
x: float,
|
||||
|
|
@ -249,7 +306,7 @@ class App(ttk.Frame):
|
|||
|
||||
self._loading_settings = False
|
||||
self._save_job = None
|
||||
self._pending_config_status: str | None = None
|
||||
self._pending_config_status: tuple[str, StatusLevel] | None = None
|
||||
|
||||
# 1) Laden (überschreibt Defaults, falls config existiert)
|
||||
self._load_settings()
|
||||
|
|
@ -344,10 +401,10 @@ class App(ttk.Frame):
|
|||
if isinstance(data, dict):
|
||||
self._apply_settings_from_dict(data)
|
||||
else:
|
||||
self._set_config_status(f"⚠️ Config hat falsches Format, verwende Defaults: {path}")
|
||||
self._set_config_status(f"Config hat falsches Format, verwende Defaults: {path}", "warn")
|
||||
except Exception as e:
|
||||
# GUI bleibt benutzbar, Fehler wird aber sichtbar gemacht
|
||||
self._set_config_status(f"⚠️ Config konnte nicht geladen werden ({path}): {e}")
|
||||
self._set_config_status(f"Config konnte nicht geladen werden ({path}): {e}", "warn")
|
||||
|
||||
def _save_settings_now(self) -> None:
|
||||
if getattr(self, "_loading_settings", False):
|
||||
|
|
@ -358,9 +415,9 @@ class App(ttk.Frame):
|
|||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(self._settings_to_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
self._set_config_status("")
|
||||
self._set_config_status("", "info")
|
||||
except Exception as e:
|
||||
self._set_config_status(f"⚠️ Config konnte nicht gespeichert werden ({path}): {e}")
|
||||
self._set_config_status(f"Config konnte nicht gespeichert werden ({path}): {e}", "error")
|
||||
|
||||
def _schedule_save(self) -> None:
|
||||
# Debounce: erst nach kurzer Pause speichern
|
||||
|
|
@ -397,7 +454,7 @@ class App(ttk.Frame):
|
|||
v.trace("w", lambda *_: self._schedule_save())
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.master.title(f"paperless-ngx ASN Label Generator v{__version__}")
|
||||
self.master.title(f"paperless-ngx ASN Label Generator v{__version__} (A4 / Avery L4731)")
|
||||
self.grid(sticky="nsew")
|
||||
self.master.columnconfigure(0, weight=1)
|
||||
self.master.rowconfigure(0, weight=1)
|
||||
|
|
@ -412,7 +469,9 @@ class App(ttk.Frame):
|
|||
ent = ttk.Entry(frm, textvariable=var, width=14)
|
||||
ent.grid(row=row, column=1, sticky="we", padx=(0, 10), pady=6)
|
||||
if hint:
|
||||
ttk.Label(frm, text=hint, foreground="#555").grid(row=row, column=2, columnspan=2, sticky="w", padx=(0, 10), pady=6)
|
||||
ttk.Label(frm, text=hint, foreground="#555", wraplength=250, justify="left").grid(
|
||||
row=row, column=2, columnspan=2, sticky="w", padx=(0, 10), pady=6
|
||||
)
|
||||
ent.bind("<KeyRelease>", lambda _e: self._update_preview())
|
||||
if store == "count":
|
||||
self._count_entry = ent
|
||||
|
|
@ -451,7 +510,9 @@ class App(ttk.Frame):
|
|||
e = ttk.Entry(cal, textvariable=var, width=10)
|
||||
e.grid(row=0, column=col + 1, sticky="we", padx=(0, 10), pady=6)
|
||||
e.bind("<KeyRelease>", lambda _e: self._update_preview())
|
||||
ttk.Label(cal, text=hint, foreground="#555").grid(row=0, column=col + 2, sticky="w", padx=(0, 10), pady=6)
|
||||
ttk.Label(cal, text=hint, foreground="#555", wraplength=130, justify="left").grid(
|
||||
row=0, column=col + 2, sticky="w", padx=(0, 10), pady=6
|
||||
)
|
||||
|
||||
add_cal("Offset X", self.var_off_x, 0, "+ rechts")
|
||||
add_cal("Offset Y", self.var_off_y, 3, "+ hoch")
|
||||
|
|
@ -461,15 +522,19 @@ class App(ttk.Frame):
|
|||
e = ttk.Entry(cal, textvariable=var, width=10)
|
||||
e.grid(row=row, column=col + 1, sticky="we", padx=(0, 10), pady=6)
|
||||
e.bind("<KeyRelease>", lambda _e: self._update_preview())
|
||||
ttk.Label(cal, text=hint, foreground="#555").grid(row=row, column=col + 2, sticky="w", padx=(0, 10), pady=6)
|
||||
ttk.Label(cal, text=hint, foreground="#555", wraplength=130, justify="left").grid(
|
||||
row=row, column=col + 2, sticky="w", padx=(0, 10), pady=6
|
||||
)
|
||||
|
||||
add_cal2("Spalten-Pitch Δ", self.var_pitch_dx, 1, 0, "→ drift links/rechts")
|
||||
add_cal2("Zeilen-Pitch Δ", self.var_pitch_dy, 1, 3, "→ top/bottom drift")
|
||||
|
||||
right = ttk.Frame(self)
|
||||
right.grid(row=0, column=1, sticky="nsew", padx=(12, 0))
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.columnconfigure(1, weight=1)
|
||||
self.rowconfigure(0, weight=1)
|
||||
right.columnconfigure(0, weight=1)
|
||||
right.rowconfigure(1, weight=1)
|
||||
|
||||
ttk.Label(right, text="Vorschau (1 Label)").grid(row=0, column=0, sticky="w")
|
||||
|
|
@ -489,22 +554,36 @@ class App(ttk.Frame):
|
|||
row=1, column=0, columnspan=2, sticky="we", pady=(6, 0)
|
||||
)
|
||||
|
||||
self.lbl_status = ttk.Label(self, text="", foreground="#333")
|
||||
self.lbl_status = ttk.Label(self, text="", foreground=STATUS_COLORS["info"], wraplength=900, justify="left")
|
||||
self.lbl_status.grid(row=1, column=0, columnspan=2, sticky="we", pady=(10, 0))
|
||||
self.lbl_config_status = ttk.Label(self, text="", foreground="#b00020")
|
||||
self.lbl_config_status = ttk.Label(self, text="", foreground=STATUS_COLORS["warn"], wraplength=900, justify="left")
|
||||
self.lbl_config_status.grid(row=2, column=0, columnspan=2, sticky="we", pady=(4, 0))
|
||||
self.bind("<Configure>", self._on_resize)
|
||||
self.after_idle(self._on_resize)
|
||||
|
||||
def _set_config_status(self, message: str) -> None:
|
||||
def _set_status(self, message: str, level: StatusLevel = "info") -> None:
|
||||
text = f"{STATUS_PREFIX[level]} {message}" if message else ""
|
||||
self.lbl_status.configure(text=text, foreground=STATUS_COLORS[level])
|
||||
|
||||
def _set_config_status(self, message: str, level: StatusLevel = "warn") -> None:
|
||||
lbl = getattr(self, "lbl_config_status", None)
|
||||
if lbl is None:
|
||||
self._pending_config_status = message
|
||||
self._pending_config_status = (message, level)
|
||||
return
|
||||
lbl.configure(text=message)
|
||||
self._pending_config_status = message
|
||||
text = f"{STATUS_PREFIX[level]} {message}" if message else ""
|
||||
lbl.configure(text=text, foreground=STATUS_COLORS[level])
|
||||
self._pending_config_status = (message, level)
|
||||
|
||||
def _flush_pending_config_status(self) -> None:
|
||||
if self._pending_config_status is not None:
|
||||
self._set_config_status(self._pending_config_status)
|
||||
message, level = self._pending_config_status
|
||||
self._set_config_status(message, level)
|
||||
|
||||
def _on_resize(self, _event=None) -> None:
|
||||
width = max(280, self.winfo_width() - 30)
|
||||
for label in (getattr(self, "lbl_status", None), getattr(self, "lbl_config_status", None)):
|
||||
if label is not None:
|
||||
label.configure(wraplength=width)
|
||||
|
||||
def _apply_mode(self) -> None:
|
||||
mode = self.var_mode.get()
|
||||
|
|
@ -587,43 +666,55 @@ class App(ttk.Frame):
|
|||
count, pages = self._effective_count_and_pages()
|
||||
off_x_delta, off_y_delta, pdx_delta, pdy_delta = self._calibration_delta()
|
||||
off_x, off_y, pdx, pdy = self._calibration()
|
||||
self.lbl_status.configure(
|
||||
text=(
|
||||
self._set_status(
|
||||
(
|
||||
f"Beispiel: {text} | {count} Labels ≈ {pages} Seiten"
|
||||
f" | Eingabe Offset({off_x_delta},{off_y_delta})mm PitchΔ({pdx_delta},{pdy_delta})mm"
|
||||
f" | Effektiv Offset({off_x},{off_y})mm PitchΔ({pdx},{pdy})mm"
|
||||
)
|
||||
),
|
||||
"info",
|
||||
)
|
||||
except Exception as e:
|
||||
self.preview.configure(image="")
|
||||
self.lbl_status.configure(text=f"⚠️ {e}")
|
||||
self._set_status(str(e), "warn")
|
||||
|
||||
def _render_preview_image(self, text: str, kind: BarcodeKind, border: bool) -> Image.Image:
|
||||
scale = 6
|
||||
w = int((AVERY_L4731.label_w / mm) * scale * 10)
|
||||
h = int((AVERY_L4731.label_h / mm) * scale * 10)
|
||||
img = Image.new("RGB", (w, h), "white")
|
||||
preview_w, preview_h = 440, 180
|
||||
img = Image.new("RGB", (preview_w, preview_h), "white")
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
pad = 12
|
||||
code_box_h = int(preview_h * 0.76)
|
||||
code_box_w = code_box_h
|
||||
code_x = pad
|
||||
code_y = (preview_h - code_box_h) // 2
|
||||
|
||||
if kind == "QR":
|
||||
size = int(h * 0.90)
|
||||
qr = make_qr_image(text, max(120, size))
|
||||
img.paste(qr, (10, (h - qr.size[1]) // 2))
|
||||
qr = make_qr_image(text, max(100, code_box_h))
|
||||
img.paste(qr, (code_x, code_y))
|
||||
else:
|
||||
box = Image.new("RGB", (int(h * 0.90), int(h * 0.90)), "white")
|
||||
img.paste(box, (10, (h - box.size[1]) // 2))
|
||||
barcode = make_code128_preview_image(
|
||||
text,
|
||||
target_w=code_box_w,
|
||||
target_h=int(code_box_h * 0.72),
|
||||
)
|
||||
bx = code_x + (code_box_w - barcode.size[0]) // 2
|
||||
by = code_y + (code_box_h - barcode.size[1]) // 2
|
||||
img.paste(barcode, (bx, by))
|
||||
|
||||
from PIL import ImageDraw, ImageFont
|
||||
draw = ImageDraw.Draw(img)
|
||||
try:
|
||||
font = ImageFont.truetype("DejaVuSans.ttf", 18)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
draw.text((int(h * 0.95), (h - 18) // 2), text, fill="black", font=font)
|
||||
text_x = code_x + code_box_w + pad
|
||||
text_max_w = max(40, preview_w - text_x - pad)
|
||||
text_max_h = max(20, preview_h - (2 * pad))
|
||||
font = fit_preview_font(draw, text, text_max_w, text_max_h)
|
||||
text_bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_h = text_bbox[3] - text_bbox[1]
|
||||
text_y = max(pad, (preview_h - text_h) // 2)
|
||||
draw.text((text_x, text_y), text, fill="black", font=font)
|
||||
|
||||
if border:
|
||||
draw.rectangle([0, 0, w - 1, h - 1], outline="black", width=2)
|
||||
draw.rectangle([0, 0, preview_w - 1, preview_h - 1], outline="black", width=2)
|
||||
|
||||
return img.resize((360, 160), Image.Resampling.LANCZOS)
|
||||
return img
|
||||
|
||||
def on_generate_pdf(self) -> None:
|
||||
try:
|
||||
|
|
@ -662,12 +753,14 @@ class App(ttk.Frame):
|
|||
pitch_dy_mm=pdy,
|
||||
)
|
||||
|
||||
self.lbl_status.configure(
|
||||
text=f"✅ PDF: {out} | Seiten: {pages_generated} | Labels: {count} | Nächster Start: {next_number}"
|
||||
)
|
||||
self.var_start.set(str(next_number))
|
||||
self._update_preview()
|
||||
self._set_status(
|
||||
f"PDF: {out} | Seiten: {pages_generated} | Labels: {count} | Nächster Start: {next_number}",
|
||||
"success",
|
||||
)
|
||||
except Exception as e:
|
||||
self._set_status(str(e), "error")
|
||||
messagebox.showerror("Fehler", str(e))
|
||||
|
||||
def on_open_folder(self) -> None:
|
||||
|
|
@ -708,6 +801,7 @@ class App(ttk.Frame):
|
|||
|
||||
# 3) Optional: Defaults direkt wieder speichern (damit nächster Start garantiert Default hat)
|
||||
self._save_settings_now()
|
||||
self._set_status("Einstellungen wurden auf Standardwerte zurückgesetzt.", "success")
|
||||
|
||||
messagebox.showinfo("OK", f"Zurückgesetzt.\n\nConfig-Pfad:\n{path}")
|
||||
|
||||
|
|
@ -721,7 +815,8 @@ def main() -> None:
|
|||
pass
|
||||
|
||||
App(root)
|
||||
root.minsize(900, 430)
|
||||
root.geometry("1160x620")
|
||||
root.minsize(900, 460)
|
||||
root.mainloop()
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue