diff --git a/CHANGELOG.md b/CHANGELOG.md index ad9f3a2..23e1a5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,28 +1,5 @@ # Changelog -## 0.1.6 - 2026-02-08 -### Changed -- Mehrere Layout-Profile auswählbar (`L4731`, `L7160`) in GUI und CLI. -- Profilverwaltung in der GUI hinzugefügt (Profil speichern/laden/löschen) ohne manuelles JSON-Editieren. -- Standard-Profilname ist jetzt `Brother MFC-L2710DW` (alte `default`-Einträge werden automatisch migriert). -- Profile können jetzt per Button als JSON exportiert werden. -- Optionaler CLI-Modus für PDF-Erzeugung ohne GUI (`--cli ...`) inkl. Layoutwahl und Seiten-/Labelmodus. - -## 0.1.5 - 2026-02-08 -### Changed -- Startposition auf angebrochenem Bogen ist jetzt einstellbar (`1..189`), inklusive korrekter Seitenberechnung und Druckplatzierung. -- Option nach PDF-Erzeugung eingeführt: `none`, `file` oder `folder` direkt öffnen. -- Ordner/Datei-Öffnen ist plattformstabil über `subprocess` umgesetzt (ohne `os.system`), auch bei Sonderzeichen im Pfad. -- Im Modus `A4 Blätter` wird jetzt exakt die gewählte Blattanzahl erzeugt (erstes Blatt ab Startposition auffüllen, kein zusätzlich angebrochenes Folgeblatt). - -## 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. diff --git a/README.md b/README.md index 2863e1a..56252e8 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,10 @@ -# paperless-ngx ASN Label Generator (GUI + CLI) +# paperless-ngx ASN Label Generator (GUI) – Avery L4731 / L4731REV -Kleines Tool (Tkinter + CLI), das **ASN-Labels als PDF** erzeugt – wahlweise **QR** oder **Code128** – für mehrere **A4-Etikettenlayouts**. +Kleines Desktop-GUI-Tool (Tkinter), das **ASN-Labels als PDF** erzeugt – wahlweise **QR** oder **Code128** – speziell für **A4-Etikettenbögen (z.B. Avery Zweckform L4731 / L4731REV, 7×27)**. ✅ Ideal, um in paperless-ngx sauber mit ASN-Barcodes zu arbeiten ✅ Mit **Kalibrierung** (Offset X/Y + Pitch-Drift) für exakte Druckausrichtung ✅ **Persistente Einstellungen**: Drucker-Kalibrierwerte werden automatisch gespeichert -✅ **Mehrere Layouts** (z. B. `L4731`, `L7160`) -✅ **Drucker-Profile** speichern/laden (ohne JSON-Edit) -✅ **CLI-Modus** für Automatisierung ✅ **Reset-Button**: “Config löschen” setzt alles auf Standardwerte zurück > Hinweis: Inoffizielles Tool, nicht affiliated mit paperless-ngx. @@ -137,56 +134,15 @@ pip install -U --force-reinstall git+https://github.com/MrLetsPlay4Fun/paperless ## Nutzung - Start-Nummer wählen (z.B. 1 oder 190) -- Layout wählen (z. B. `L4731` oder `L7160`) - Prefix setzen (muss zu paperless-ngx passen) - Führende Nullen einstellen (z.B. 7 → `ASN0000001`) - Menge als **Labels** oder **A4-Blätter** -- Optional: **Startposition auf Bogen** setzen (bei angebrochenem Blatt) - Code-Typ: **QR** oder **CODE128** -- Optional: **Nach PDF öffnen** (`none`, `file`, `folder`) - Optional: **Rahmen aktivieren** (hilft beim Kalibrieren) -- Optional: Profil speichern/laden/löschen (z. B. je Drucker) - „PDF erzeugen…“ → Datei speichern --- -## Profile (Drucker-Profile) - -Profile speichern deine aktuellen Einstellungen als Set (Layout, Kalibrierung, Startposition, Code-Typ usw.). - -- Standardprofil: **`Brother MFC-L2710DW`** -- Buttons in der GUI: -- **Profil speichern**: aktuelle Einstellungen unter neuem Namen speichern -- **Profil laden**: gewähltes Profil aktivieren -- **Profil löschen**: Profil entfernen (Standardprofil ist geschützt) -- **Profile exportieren**: alle gespeicherten Profile als JSON-Datei exportieren - ---- - -## CLI-Modus (Automatisierung) - -Du kannst PDFs ohne GUI erzeugen: - -```bash -paperless-asn-labels --cli --output /tmp/asn.pdf --layout L4731 --count 30 --prefix ASN --zeros 7 --kind QR -``` - -Seitenmodus (exakte Blattanzahl): - -```bash -paperless-asn-labels --cli --output /tmp/asn_pages.pdf --layout L7160 --pages 2 --start-position 5 --kind CODE128 -``` - -Alle Optionen: - -```bash -paperless-asn-labels --help -``` - -Hinweis: Wenn CLI-Optionen gesetzt sind (z. B. `--output`, `--count`, `--pages`), läuft das Tool automatisch im CLI-Modus. - ---- - ## Gespeicherte Einstellungen (Config) Deine Kalibrierwerte & UI-Einstellungen werden automatisch gespeichert (persistente Config). diff --git a/assets/screenshot.png b/assets/screenshot.png index 2532914..7a1f965 100644 Binary files a/assets/screenshot.png and b/assets/screenshot.png differ diff --git a/pyproject.toml b/pyproject.toml index d74dc54..99cc277 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "paperless-asn-label-generator" -version = "0.1.6" +version = "0.1.3" description = "GUI tool to generate ASN barcode/QR labels for paperless-ngx (A4/Avery L4731) with calibration." readme = "README.md" requires-python = ">=3.10" diff --git a/src/paperless_asn_label_generator/__init__.py b/src/paperless_asn_label_generator/__init__.py index d34dc43..0c08b61 100644 --- a/src/paperless_asn_label_generator/__init__.py +++ b/src/paperless_asn_label_generator/__init__.py @@ -1,2 +1,2 @@ __all__ = ["__version__"] -__version__ = "0.1.6" +__version__ = "0.1.3" diff --git a/src/paperless_asn_label_generator/__main__.py b/src/paperless_asn_label_generator/__main__.py index c58ddac..37c2a2d 100644 --- a/src/paperless_asn_label_generator/__main__.py +++ b/src/paperless_asn_label_generator/__main__.py @@ -1,4 +1,4 @@ from .app import main if __name__ == "__main__": - raise SystemExit(main()) + main() diff --git a/src/paperless_asn_label_generator/app.py b/src/paperless_asn_label_generator/app.py index e660d3b..95cec83 100644 --- a/src/paperless_asn_label_generator/app.py +++ b/src/paperless_asn_label_generator/app.py @@ -19,45 +19,26 @@ import math import os import sys import json -import argparse -import subprocess -from io import BytesIO from pathlib import Path from dataclasses import dataclass from typing import Literal, Tuple import tkinter as tk -from tkinter import ttk, filedialog, messagebox, simpledialog +from tkinter import ttk, filedialog, messagebox 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 import renderPM -from reportlab.graphics.barcode import code128, createBarcodeDrawing +from reportlab.graphics.barcode import code128 import qrcode -from PIL import Image, ImageTk, ImageDraw, ImageFont +from PIL import Image, ImageTk 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) @@ -85,36 +66,19 @@ class SheetLayout: return self.label_h + self.gap_y -LAYOUTS: dict[str, SheetLayout] = { - # NOTE: Many shops/templates call this "L4731" (25.4 x 10mm, 7x27 = 189 labels). - "L4731": SheetLayout( - name="Avery Zweckform L4731 / L4731REV (7x27, 25.4mm x 10mm)", - cols=7, - rows=27, - label_w=25.4 * mm, - label_h=10.0 * mm, - gap_x=0.25 * cm, - gap_y=0.0 * cm, - margin_left=0.85 * cm, - margin_top=1.35 * cm, - ), - # Alternative A4 layout with much larger labels. - "L7160": SheetLayout( - name="Avery L7160 (3x7, 63.5mm x 38.1mm)", - cols=3, - rows=7, - label_w=63.5 * mm, - label_h=38.1 * mm, - gap_x=2.5 * mm, - gap_y=0.0 * mm, - margin_left=6.5 * mm, - margin_top=15.2 * mm, - ), -} - -DEFAULT_LAYOUT_KEY = "L4731" -AVERY_L4731 = LAYOUTS[DEFAULT_LAYOUT_KEY] -DEFAULT_PROFILE_NAME = "Brother MFC-L2710DW" +# NOTE: Many shops/templates call this "L4731" (25.4 x 10mm, 7x27 = 189 labels). +# If your sheet is slightly different, use calibration below. +AVERY_L4731 = SheetLayout( + name="Avery Zweckform L4731 / L4731REV (7x27, 25.4mm x 10mm)", + cols=7, + rows=27, + label_w=25.4 * mm, + label_h=10.0 * mm, + gap_x=0.25 * cm, # base guess (tweak with Δ if needed) + gap_y=0.0 * cm, # base guess (tweak with Δ if needed) + margin_left=0.85 * cm, + margin_top=1.35 * cm, +) def make_asn_text(prefix: str, number: int, leading_zeros: int) -> str: @@ -134,56 +98,6 @@ 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 open_path(path: str) -> None: - if os.name == "nt": - os.startfile(path) # type: ignore[attr-defined] - return - if sys.platform == "darwin": - subprocess.Popen(["open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - return - subprocess.Popen(["xdg-open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - - def draw_label( c: rl_canvas.Canvas, x: float, @@ -238,7 +152,6 @@ def generate_pdf( offset_y_mm: float = 0.0, # + moves up pitch_dx_mm: float = 0.0, # added to pitch_x (distance between columns) pitch_dy_mm: float = 0.0, # added to pitch_y (distance between rows) - start_position: int = 1, # 1..labels_per_page (first label slot on first page) ) -> Tuple[int, int]: """ Returns: (pages_generated, next_number) @@ -251,14 +164,10 @@ def generate_pdf( raise ValueError("leading_zeros must be >= 0") if not prefix: raise ValueError("prefix must not be empty") - labels_per_page = layout.labels_per_page - if not (1 <= start_position <= labels_per_page): - raise ValueError(f"start_position must be between 1 and {labels_per_page}") _page_w, page_h = A4 - first_page_capacity = labels_per_page - (start_position - 1) - remaining_after_first = max(0, count - first_page_capacity) - pages = 1 + math.ceil(remaining_after_first / labels_per_page) + labels_per_page = layout.labels_per_page + pages = math.ceil(count / labels_per_page) # Effective pitch + offsets (points) pitch_x = layout.pitch_x + (pitch_dx_mm * mm) @@ -271,16 +180,12 @@ def generate_pdf( current = start_number remaining = count - for page_idx in range(pages): - first_slot = start_position if page_idx == 0 else 1 - first_slot_zero_based = first_slot - 1 - slots_available = labels_per_page - first_slot_zero_based - n_this_page = min(remaining, slots_available) + for _ in range(pages): + n_this_page = min(remaining, labels_per_page) for i in range(n_this_page): - slot = first_slot_zero_based + i - r = slot // layout.cols - col = slot % layout.cols + r = i // layout.cols + col = i % layout.cols x = layout.margin_left + col * pitch_x + off_x y = page_h - layout.margin_top - layout.label_h - r * pitch_y + off_y @@ -290,24 +195,20 @@ def generate_pdf( current += 1 + c.showPage() remaining -= n_this_page - if page_idx < pages - 1: - c.showPage() c.save() return pages, current DEFAULT_SETTINGS = { - "layout": DEFAULT_LAYOUT_KEY, "start": "1", "mode": "labels", - "count": str(LAYOUTS[DEFAULT_LAYOUT_KEY].labels_per_page), + "count": str(AVERY_L4731.labels_per_page), "pages": "1", - "start_pos": "1", "prefix": "ASN", "zeros": "7", "kind": "QR", - "post_open": "none", "border": False, # neutrale Kalibrierung für frische Installationen "off_x": "0.0", @@ -329,17 +230,13 @@ class App(ttk.Frame): super().__init__(master, padding=12) self.master = master - self.var_layout = tk.StringVar(value=DEFAULT_SETTINGS["layout"]) - self.var_profile = tk.StringVar(value=DEFAULT_PROFILE_NAME) self.var_start = tk.StringVar(value=DEFAULT_SETTINGS["start"]) self.var_mode = tk.StringVar(value=DEFAULT_SETTINGS["mode"]) self.var_count = tk.StringVar(value=DEFAULT_SETTINGS["count"]) self.var_pages = tk.StringVar(value=DEFAULT_SETTINGS["pages"]) - self.var_start_pos = tk.StringVar(value=DEFAULT_SETTINGS["start_pos"]) self.var_prefix = tk.StringVar(value=DEFAULT_SETTINGS["prefix"]) self.var_zeros = tk.StringVar(value=DEFAULT_SETTINGS["zeros"]) self.var_kind = tk.StringVar(value=DEFAULT_SETTINGS["kind"]) - self.var_post_open = tk.StringVar(value=DEFAULT_SETTINGS["post_open"]) self.var_border = tk.BooleanVar(value=DEFAULT_SETTINGS["border"]) self.var_off_x = tk.StringVar(value=DEFAULT_SETTINGS["off_x"]) self.var_off_y = tk.StringVar(value=DEFAULT_SETTINGS["off_y"]) @@ -349,19 +246,16 @@ class App(ttk.Frame): self._preview_imgtk: ImageTk.PhotoImage | None = None self._count_entry: ttk.Entry | None = None self._pages_entry: ttk.Entry | None = None - self._profile_combo: ttk.Combobox | None = None - self._profiles: dict[str, dict] = {DEFAULT_PROFILE_NAME: dict(DEFAULT_SETTINGS)} self._loading_settings = False self._save_job = None - self._pending_config_status: tuple[str, StatusLevel] | None = None + self._pending_config_status: str | None = None # 1) Laden (überschreibt Defaults, falls config existiert) self._load_settings() # UI einmal bauen self._build_ui() - self._refresh_profile_combo() self._flush_pending_config_status() self._apply_mode() self._update_preview() @@ -388,18 +282,15 @@ class App(ttk.Frame): return base / "paperless-ngx-asn-labels" / "config.json" - def _settings_snapshot(self) -> dict: + def _settings_to_dict(self) -> dict: return { - "layout": self.var_layout.get(), "start": self.var_start.get(), "mode": self.var_mode.get(), "count": self.var_count.get(), "pages": self.var_pages.get(), - "start_pos": self.var_start_pos.get(), "prefix": self.var_prefix.get(), "zeros": self.var_zeros.get(), "kind": self.var_kind.get(), - "post_open": self.var_post_open.get(), "border": bool(self.var_border.get()), "off_x": self.var_off_x.get(), "off_y": self.var_off_y.get(), @@ -408,19 +299,10 @@ class App(ttk.Frame): "calibration_mode": "delta", } - def _settings_to_dict(self) -> dict: - payload = self._settings_snapshot() - payload["profiles"] = self._profiles - payload["active_profile"] = self.var_profile.get() - return payload - def _apply_settings_from_dict(self, d: dict) -> None: # Beim Laden sollen keine Autosaves getriggert werden self._loading_settings = True try: - if "layout" in d: - layout_key = str(d["layout"]) - self.var_layout.set(layout_key if layout_key in LAYOUTS else DEFAULT_LAYOUT_KEY) calibration_mode = str(d.get("calibration_mode", "")).strip().lower() legacy_absolute = calibration_mode != "delta" if calibration_mode == "": @@ -443,17 +325,14 @@ class App(ttk.Frame): if "mode" in d: self.var_mode.set(str(d["mode"])) if "count" in d: self.var_count.set(str(d["count"])) if "pages" in d: self.var_pages.set(str(d["pages"])) - if "start_pos" in d: self.var_start_pos.set(str(d["start_pos"])) if "prefix" in d: self.var_prefix.set(str(d["prefix"])) if "zeros" in d: self.var_zeros.set(str(d["zeros"])) if "kind" in d: self.var_kind.set(str(d["kind"])) - if "post_open" in d: self.var_post_open.set(str(d["post_open"])) if "border" in d: self.var_border.set(bool(d["border"])) set_calibration_var("off_x", self.var_off_x) set_calibration_var("off_y", self.var_off_y) set_calibration_var("pitch_dx", self.var_pitch_dx) set_calibration_var("pitch_dy", self.var_pitch_dy) - self._clamp_start_position() finally: self._loading_settings = False @@ -463,28 +342,12 @@ class App(ttk.Frame): if path.exists(): data = json.loads(path.read_text(encoding="utf-8")) if isinstance(data, dict): - profiles = data.get("profiles") - if isinstance(profiles, dict): - self._profiles = {str(k): v for k, v in profiles.items() if isinstance(v, dict)} - # Migration: alter fixer Name "default" -> neuer fixer Name. - if "default" in self._profiles and DEFAULT_PROFILE_NAME not in self._profiles: - self._profiles[DEFAULT_PROFILE_NAME] = self._profiles["default"] - self._profiles.pop("default", None) - if DEFAULT_PROFILE_NAME not in self._profiles: - self._profiles[DEFAULT_PROFILE_NAME] = dict(DEFAULT_SETTINGS) - active_profile = str(data.get("active_profile", DEFAULT_PROFILE_NAME)) - if active_profile == "default": - active_profile = DEFAULT_PROFILE_NAME - if active_profile in self._profiles: - self.var_profile.set(active_profile) - else: - self.var_profile.set(DEFAULT_PROFILE_NAME) self._apply_settings_from_dict(data) else: - self._set_config_status(f"Config hat falsches Format, verwende Defaults: {path}", "warn") + self._set_config_status(f"⚠️ Config hat falsches Format, verwende Defaults: {path}") except Exception as e: # GUI bleibt benutzbar, Fehler wird aber sichtbar gemacht - self._set_config_status(f"Config konnte nicht geladen werden ({path}): {e}", "warn") + self._set_config_status(f"⚠️ Config konnte nicht geladen werden ({path}): {e}") def _save_settings_now(self) -> None: if getattr(self, "_loading_settings", False): @@ -495,9 +358,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("", "info") + self._set_config_status("") except Exception as e: - self._set_config_status(f"Config konnte nicht gespeichert werden ({path}): {e}", "error") + self._set_config_status(f"⚠️ Config konnte nicht gespeichert werden ({path}): {e}") def _schedule_save(self) -> None: # Debounce: erst nach kurzer Pause speichern @@ -513,17 +376,13 @@ class App(ttk.Frame): def _install_autosave_traces(self) -> None: # alle Variablen bei Änderung -> save schedulen vars_to_watch = [ - self.var_layout, - self.var_profile, self.var_start, self.var_mode, self.var_count, self.var_pages, - self.var_start_pos, self.var_prefix, self.var_zeros, self.var_kind, - self.var_post_open, self.var_border, self.var_off_x, self.var_off_y, @@ -553,9 +412,7 @@ 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", wraplength=250, justify="left").grid( - row=row, column=2, columnspan=2, sticky="w", padx=(0, 10), pady=6 - ) + ttk.Label(frm, text=hint, foreground="#555").grid(row=row, column=2, columnspan=2, sticky="w", padx=(0, 10), pady=6) ent.bind("", lambda _e: self._update_preview()) if store == "count": self._count_entry = ent @@ -570,34 +427,22 @@ class App(ttk.Frame): ttk.Radiobutton(mode_box, text="Labels", value="labels", variable=self.var_mode, command=self._apply_mode).pack(side="left", padx=(0, 10)) ttk.Radiobutton(mode_box, text="A4 Blätter", value="pages", variable=self.var_mode, command=self._apply_mode).pack(side="left") - ttk.Label(frm, text="Layout").grid(row=2, column=0, sticky="w", padx=(10, 6), pady=6) - layout_box = ttk.Combobox( - frm, - textvariable=self.var_layout, - values=list(LAYOUTS.keys()), - state="readonly", - width=16, - ) - layout_box.grid(row=2, column=1, sticky="w", padx=(0, 10), pady=6) - layout_box.bind("<>", self._on_layout_changed) + add_row("Anzahl Labels", self.var_count, 2, f"{AVERY_L4731.labels_per_page} Labels pro A4-Seite", store="count") + add_row("Anzahl A4 Blätter", self.var_pages, 3, "z.B. 3 → erzeugt 3 Seiten", store="pages") - add_row("Anzahl Labels", self.var_count, 3, "Labels pro A4-Seite sind layoutabhängig", store="count") - add_row("Anzahl A4 Blätter", self.var_pages, 4, "z.B. 3 → erzeugt exakt 3 Seiten", store="pages") - add_row("Startposition auf Bogen", self.var_start_pos, 5, "1..Labels/Seite (abhängig vom Layout)") + add_row("Prefix", self.var_prefix, 4, "Muss zu PAPERLESS_CONSUMER_ASN_BARCODE_PREFIX passen") + add_row("Führende Nullen", self.var_zeros, 5, "z.B. 5 → ASN00001") - add_row("Prefix", self.var_prefix, 6, "Muss zu PAPERLESS_CONSUMER_ASN_BARCODE_PREFIX passen") - add_row("Führende Nullen", self.var_zeros, 7, "z.B. 5 → ASN00001") - - ttk.Label(frm, text="Code-Typ").grid(row=8, column=0, sticky="w", padx=(10, 6), pady=6) + ttk.Label(frm, text="Code-Typ").grid(row=6, column=0, sticky="w", padx=(10, 6), pady=6) cmb = ttk.Combobox(frm, textvariable=self.var_kind, values=["QR", "CODE128"], state="readonly", width=12) - cmb.grid(row=8, column=1, sticky="w", padx=(0, 10), pady=6) + cmb.grid(row=6, column=1, sticky="w", padx=(0, 10), pady=6) cmb.bind("<>", lambda _e: self._update_preview()) chk = ttk.Checkbutton(frm, text="Rahmen um Labels zeichnen (Kalibrierung)", variable=self.var_border, command=self._update_preview) - chk.grid(row=8, column=2, columnspan=2, sticky="w", padx=(0, 10), pady=6) + chk.grid(row=6, column=2, columnspan=2, sticky="w", padx=(0, 10), pady=6) cal = ttk.LabelFrame(frm, text="Kalibrierung (mm)") - cal.grid(row=9, column=0, columnspan=4, sticky="we", padx=10, pady=(10, 6)) + cal.grid(row=7, column=0, columnspan=4, sticky="we", padx=10, pady=(10, 6)) for i in range(6): cal.columnconfigure(i, weight=1) @@ -606,9 +451,7 @@ 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("", lambda _e: self._update_preview()) - ttk.Label(cal, text=hint, foreground="#555", wraplength=130, justify="left").grid( - row=0, column=col + 2, sticky="w", padx=(0, 10), pady=6 - ) + ttk.Label(cal, text=hint, foreground="#555").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") @@ -618,19 +461,15 @@ 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("", lambda _e: self._update_preview()) - ttk.Label(cal, text=hint, foreground="#555", wraplength=130, justify="left").grid( - row=row, column=col + 2, sticky="w", padx=(0, 10), pady=6 - ) + ttk.Label(cal, text=hint, foreground="#555").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") @@ -649,159 +488,23 @@ class App(ttk.Frame): ttk.Button(btns, text="Config löschen", command=self.on_reset_config).grid( row=1, column=0, columnspan=2, sticky="we", pady=(6, 0) ) - ttk.Label(btns, text="Nach PDF-Erstellung öffnen").grid(row=2, column=0, sticky="w", pady=(8, 0)) - cmb_open = ttk.Combobox( - btns, - textvariable=self.var_post_open, - values=["none", "file", "folder"], - state="readonly", - width=12, - ) - cmb_open.grid(row=2, column=1, sticky="e", pady=(8, 0)) - ttk.Label(btns, text="Profil").grid(row=3, column=0, sticky="w", pady=(8, 0)) - self._profile_combo = ttk.Combobox( - btns, - textvariable=self.var_profile, - values=[DEFAULT_PROFILE_NAME], - state="readonly", - width=24, - ) - self._profile_combo.grid(row=3, column=1, sticky="e", pady=(8, 0)) - ttk.Button(btns, text="Profil laden", command=self.on_load_profile).grid(row=4, column=0, sticky="we", pady=(6, 0), padx=(0, 6)) - ttk.Button(btns, text="Profil speichern", command=self.on_save_profile).grid(row=4, column=1, sticky="we", pady=(6, 0), padx=(6, 0)) - ttk.Button(btns, text="Profil löschen", command=self.on_delete_profile).grid(row=5, column=0, columnspan=2, sticky="we", pady=(6, 0)) - ttk.Button(btns, text="Profile exportieren", command=self.on_export_profiles).grid( - row=6, column=0, columnspan=2, sticky="we", pady=(6, 0) - ) - self.lbl_status = ttk.Label(self, text="", foreground=STATUS_COLORS["info"], wraplength=900, justify="left") + self.lbl_status = ttk.Label(self, text="", foreground="#333") self.lbl_status.grid(row=1, column=0, columnspan=2, sticky="we", pady=(10, 0)) - self.lbl_config_status = ttk.Label(self, text="", foreground=STATUS_COLORS["warn"], wraplength=900, justify="left") + self.lbl_config_status = ttk.Label(self, text="", foreground="#b00020") self.lbl_config_status.grid(row=2, column=0, columnspan=2, sticky="we", pady=(4, 0)) - self.bind("", self._on_resize) - self.after_idle(self._on_resize) - 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: + def _set_config_status(self, message: str) -> None: lbl = getattr(self, "lbl_config_status", None) if lbl is None: - self._pending_config_status = (message, level) + self._pending_config_status = message return - text = f"{STATUS_PREFIX[level]} {message}" if message else "" - lbl.configure(text=text, foreground=STATUS_COLORS[level]) - self._pending_config_status = (message, level) + lbl.configure(text=message) + self._pending_config_status = message def _flush_pending_config_status(self) -> None: if self._pending_config_status is not None: - 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 _layout_key(self) -> str: - key = self.var_layout.get().strip() - if key not in LAYOUTS: - raise ValueError(f"Unbekanntes Layout: {key}") - return key - - def _layout(self) -> SheetLayout: - return LAYOUTS[self._layout_key()] - - def _clamp_start_position(self) -> None: - try: - pos = int(self.var_start_pos.get().strip()) - except Exception: - return - max_pos = self._layout().labels_per_page - if pos > max_pos: - self.var_start_pos.set(str(max_pos)) - elif pos < 1: - self.var_start_pos.set("1") - - def _on_layout_changed(self, _event=None) -> None: - try: - self._clamp_start_position() - self._update_preview() - except Exception as e: - self._set_status(str(e), "warn") - - def _refresh_profile_combo(self) -> None: - if self._profile_combo is None: - return - names = sorted(self._profiles.keys()) - self._profile_combo.configure(values=names) - if self.var_profile.get() not in self._profiles: - self.var_profile.set(DEFAULT_PROFILE_NAME) - - def on_save_profile(self) -> None: - name = simpledialog.askstring("Profil speichern", "Profilname:") - if name is None: - return - name = name.strip() - if not name: - self._set_status("Profilname darf nicht leer sein.", "warn") - return - self._profiles[name] = self._settings_snapshot() - self.var_profile.set(name) - self._refresh_profile_combo() - self._save_settings_now() - self._set_status(f'Profil "{name}" gespeichert.', "success") - - def on_load_profile(self) -> None: - name = self.var_profile.get().strip() - data = self._profiles.get(name) - if not isinstance(data, dict): - self._set_status(f'Profil "{name}" nicht gefunden.', "warn") - return - self._apply_settings_from_dict(data) - self._apply_mode() - self._update_preview() - self._save_settings_now() - self._set_status(f'Profil "{name}" geladen.', "success") - - def on_delete_profile(self) -> None: - name = self.var_profile.get().strip() - if name == DEFAULT_PROFILE_NAME: - self._set_status(f'Profil "{DEFAULT_PROFILE_NAME}" kann nicht gelöscht werden.', "warn") - return - if name not in self._profiles: - self._set_status(f'Profil "{name}" nicht gefunden.', "warn") - return - if not messagebox.askyesno("Profil löschen", f'Profil "{name}" wirklich löschen?'): - return - del self._profiles[name] - self.var_profile.set(DEFAULT_PROFILE_NAME) - self._refresh_profile_combo() - self._save_settings_now() - self._set_status(f'Profil "{name}" gelöscht.', "success") - - def on_export_profiles(self) -> None: - out = filedialog.asksaveasfilename( - title="Profile exportieren", - defaultextension=".json", - initialfile="paperless_asn_profiles.json", - filetypes=[("JSON", "*.json")], - ) - if not out: - return - payload = { - "format": "paperless-asn-labels-profiles-v1", - "active_profile": self.var_profile.get(), - "profiles": self._profiles, - } - try: - Path(out).write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") - self._set_status(f"Profile exportiert: {out}", "success") - except Exception as e: - self._set_status(f"Profile konnten nicht exportiert werden: {e}", "error") - messagebox.showerror("Fehler", str(e)) + self._set_config_status(self._pending_config_status) def _apply_mode(self) -> None: mode = self.var_mode.get() @@ -830,37 +533,19 @@ class App(ttk.Frame): except Exception: return None - def _start_position(self) -> int: - labels_per_page = self._layout().labels_per_page - pos = self._parse_int(self.var_start_pos.get(), "Startposition auf Bogen") - if not (1 <= pos <= labels_per_page): - raise ValueError(f"Startposition auf Bogen muss zwischen 1 und {labels_per_page} liegen.") - return pos - - def _pages_for_count(self, count: int, start_position: int) -> int: - labels_per_page = self._layout().labels_per_page - first_page_capacity = labels_per_page - (start_position - 1) - remaining_after_first = max(0, count - first_page_capacity) - return 1 + math.ceil(remaining_after_first / labels_per_page) - def _effective_count_and_pages(self) -> tuple[int, int]: - start_position = self._start_position() mode = self.var_mode.get() if mode == "pages": pages = self._parse_int(self.var_pages.get(), "Anzahl A4 Blätter") if pages <= 0: raise ValueError("Anzahl A4 Blätter muss > 0 sein.") - labels_per_page = self._layout().labels_per_page - first_page_capacity = labels_per_page - (start_position - 1) - # Im Seitenmodus exakt N Blätter erzeugen: - # erstes Blatt ab Startposition auffüllen, danach volle Blätter. - count = first_page_capacity + (pages - 1) * labels_per_page + count = pages * AVERY_L4731.labels_per_page return count, pages else: count = self._parse_int(self.var_count.get(), "Anzahl Labels") if count <= 0: raise ValueError("Anzahl Labels muss > 0 sein.") - pages = self._pages_for_count(count, start_position) + pages = math.ceil(count / AVERY_L4731.labels_per_page) return count, pages def _current_text(self) -> str: @@ -900,60 +585,45 @@ class App(ttk.Frame): self.preview.configure(image=self._preview_imgtk) count, pages = self._effective_count_and_pages() - start_pos = self._start_position() - layout = self._layout() off_x_delta, off_y_delta, pdx_delta, pdy_delta = self._calibration_delta() off_x, off_y, pdx, pdy = self._calibration() - self._set_status( - ( - f"Beispiel: {text} | Layout {self._layout_key()} ({layout.labels_per_page}/Seite)" - f" | {count} Labels | Startposition {start_pos} | Ausgabe {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", + self.lbl_status.configure( + text=( + 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" + ) ) except Exception as e: self.preview.configure(image="") - self._set_status(str(e), "warn") + self.lbl_status.configure(text=f"⚠️ {e}") def _render_preview_image(self, text: str, kind: BarcodeKind, border: bool) -> Image.Image: - 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 + 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") if kind == "QR": - qr = make_qr_image(text, max(100, code_box_h)) - img.paste(qr, (code_x, code_y)) + size = int(h * 0.90) + qr = make_qr_image(text, max(120, size)) + img.paste(qr, (10, (h - qr.size[1]) // 2)) else: - 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)) + box = Image.new("RGB", (int(h * 0.90), int(h * 0.90)), "white") + img.paste(box, (10, (h - box.size[1]) // 2)) - 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) + 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) if border: - draw.rectangle([0, 0, preview_w - 1, preview_h - 1], outline="black", width=2) + draw.rectangle([0, 0, w - 1, h - 1], outline="black", width=2) - return img + return img.resize((360, 160), Image.Resampling.LANCZOS) def on_generate_pdf(self) -> None: try: @@ -964,12 +634,10 @@ class App(ttk.Frame): raise ValueError("Prefix darf nicht leer sein.") count, _pages = self._effective_count_and_pages() - start_pos = self._start_position() - layout = self._layout() kind: BarcodeKind = "QR" if self.var_kind.get() == "QR" else "CODE128" off_x, off_y, pdx, pdy = self._calibration() - default_name = f"asn_labels_{self._layout_key().lower()}_{start}_{count}.pdf" + default_name = f"asn_labels_{start}_{count}.pdf" out = filedialog.asksaveasfilename( title="PDF speichern", defaultextension=".pdf", @@ -986,50 +654,31 @@ class App(ttk.Frame): prefix=prefix, leading_zeros=zeros, kind=kind, - layout=layout, + layout=AVERY_L4731, draw_border=self.var_border.get(), offset_x_mm=off_x, offset_y_mm=off_y, pitch_dx_mm=pdx, pitch_dy_mm=pdy, - start_position=start_pos, ) + 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} | Layout: {self._layout_key()} | Seiten: {pages_generated} | Labels: {count} | Startposition: {start_pos} | Nächster Start: {next_number}", - "success", - ) - try: - self._open_after_generate(out) - except Exception as e: - self._set_status(f"PDF erzeugt, aber Öffnen fehlgeschlagen: {e}", "warn") except Exception as e: - self._set_status(str(e), "error") messagebox.showerror("Fehler", str(e)) - def _open_path(self, path: str) -> None: - open_path(path) - - def _open_after_generate(self, output_pdf: str) -> None: - mode = self.var_post_open.get().strip().lower() - if mode == "none": - return - if mode == "file": - self._open_path(output_pdf) - return - if mode == "folder": - self._open_path(str(Path(output_pdf).resolve().parent)) - return - raise ValueError(f"Ungültige Nach-PDF-Option: {mode}") - def on_open_folder(self) -> None: folder = os.getcwd() try: - self._open_path(folder) - except Exception as e: - self._set_status(f"Ordner konnte nicht geöffnet werden: {e}", "warn") + if os.name == "nt": + os.startfile(folder) # type: ignore[attr-defined] + elif os.name == "posix": + cmd = "open" if sys.platform == "darwin" else "xdg-open" + os.system(f'{cmd} "{folder}"') + except Exception: messagebox.showinfo("Info", f"Ordner: {folder}") def on_reset_config(self) -> None: @@ -1051,9 +700,6 @@ class App(ttk.Frame): return # 2) Defaults anwenden (ohne Autosave-Trigger-Chaos) - self._profiles = {DEFAULT_PROFILE_NAME: dict(DEFAULT_SETTINGS)} - self.var_profile.set(DEFAULT_PROFILE_NAME) - self._refresh_profile_combo() self._apply_settings_from_dict(DEFAULT_SETTINGS) # UI/Preview aktualisieren @@ -1062,11 +708,10 @@ 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}") -def _run_gui() -> int: +def main() -> None: root = tk.Tk() try: style = ttk.Style() @@ -1076,113 +721,9 @@ def _run_gui() -> int: pass App(root) - root.geometry("1160x620") - root.minsize(900, 460) + root.minsize(900, 430) root.mainloop() - return 0 - - -def _build_arg_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser( - prog="paperless-asn-labels", - description="ASN Label PDF Generator (GUI + optional CLI mode).", - ) - p.add_argument("--cli", action="store_true", help="CLI-Modus verwenden (ohne GUI).") - p.add_argument("--output", help="Ausgabedatei (.pdf) für CLI.") - p.add_argument("--layout", choices=sorted(LAYOUTS.keys()), default=DEFAULT_LAYOUT_KEY, help="Layout-Profil (CLI).") - p.add_argument("--start", type=int, default=1, help="Start-Nummer (CLI).") - p.add_argument("--prefix", default="ASN", help="ASN Prefix (CLI).") - p.add_argument("--zeros", type=int, default=7, help="Führende Nullen (CLI).") - p.add_argument("--kind", choices=["QR", "CODE128"], default="QR", help="Barcode-Typ (CLI).") - p.add_argument("--start-position", type=int, default=1, help="Startposition auf dem ersten Blatt (CLI).") - p.add_argument("--count", type=int, help="Anzahl Labels (CLI).") - p.add_argument("--pages", type=int, help="Anzahl A4-Blätter (CLI, exakt).") - p.add_argument("--border", action="store_true", help="Labelrahmen zeichnen (CLI).") - p.add_argument("--offset-x", type=float, default=BASE_CALIBRATION_MM["off_x"], help="Effektiver Offset X in mm (CLI).") - p.add_argument("--offset-y", type=float, default=BASE_CALIBRATION_MM["off_y"], help="Effektiver Offset Y in mm (CLI).") - p.add_argument("--pitch-dx", type=float, default=BASE_CALIBRATION_MM["pitch_dx"], help="Effektiver Pitch Δ X in mm (CLI).") - p.add_argument("--pitch-dy", type=float, default=BASE_CALIBRATION_MM["pitch_dy"], help="Effektiver Pitch Δ Y in mm (CLI).") - p.add_argument("--open", choices=["none", "file", "folder"], default="none", help="Nach CLI-PDF öffnen.") - return p - - -def _run_cli(args: argparse.Namespace) -> int: - if not args.output: - raise ValueError("Im CLI-Modus ist --output erforderlich.") - if args.count is not None and args.pages is not None: - raise ValueError("Bitte nur --count ODER --pages angeben, nicht beides.") - - layout = LAYOUTS[args.layout] - if args.pages is not None: - if args.pages <= 0: - raise ValueError("--pages muss > 0 sein.") - first_page_capacity = layout.labels_per_page - (args.start_position - 1) - count = first_page_capacity + (args.pages - 1) * layout.labels_per_page - elif args.count is not None: - if args.count <= 0: - raise ValueError("--count muss > 0 sein.") - count = args.count - else: - count = layout.labels_per_page - - pages_generated, next_number = generate_pdf( - output_path=args.output, - start_number=args.start, - count=count, - prefix=args.prefix, - leading_zeros=args.zeros, - kind="QR" if args.kind == "QR" else "CODE128", - layout=layout, - draw_border=bool(args.border), - offset_x_mm=args.offset_x, - offset_y_mm=args.offset_y, - pitch_dx_mm=args.pitch_dx, - pitch_dy_mm=args.pitch_dy, - start_position=args.start_position, - ) - print( - f"OK: {args.output} | layout={args.layout} | pages={pages_generated} | labels={count} | next={next_number}" - ) - if args.open == "file": - open_path(args.output) - elif args.open == "folder": - open_path(str(Path(args.output).resolve().parent)) - return 0 - - -def main(argv: list[str] | None = None) -> int: - parser = _build_arg_parser() - args = parser.parse_args(argv) - - cli_options_used = any( - [ - args.output is not None, - args.count is not None, - args.pages is not None, - args.layout != DEFAULT_LAYOUT_KEY, - args.start != 1, - args.prefix != "ASN", - args.zeros != 7, - args.kind != "QR", - args.start_position != 1, - args.border, - args.offset_x != BASE_CALIBRATION_MM["off_x"], - args.offset_y != BASE_CALIBRATION_MM["off_y"], - args.pitch_dx != BASE_CALIBRATION_MM["pitch_dx"], - args.pitch_dy != BASE_CALIBRATION_MM["pitch_dy"], - args.open != "none", - ] - ) - - if args.cli or cli_options_used: - try: - return _run_cli(args) - except Exception as e: - print(f"ERROR: {e}", file=sys.stderr) - return 2 - - return _run_gui() if __name__ == "__main__": - raise SystemExit(main()) + main()