diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a7b639..ad9f3a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # 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. +- Persistierte Kalibrierung speichert den Modus explizit als `calibration_mode = "delta"`; bestehende Legacy-Configs ohne Marker werden kompatibel übernommen. +- GUI-Titel zeigt jetzt die aktuelle App-Version an. + +### Fixed +- Doppelten `_build_ui()`-Aufruf entfernt, damit beim Start keine doppelten Widgets oder Event-Bindings entstehen. +- Fehler beim Laden/Speichern der `config.json` werden in der Oberfläche sichtbar angezeigt statt still ignoriert. + ## 0.1.2 - 2026-02-03 ### Added - Persistente Einstellungen (config.json) im User-Config-Verzeichnis (XDG_CONFIG_HOME / ~/.config, macOS Application Support, Windows APPDATA). @@ -7,4 +40,4 @@ - Button "Config löschen": löscht config.json und setzt Standard-Preset-Werte zurück. ### Fixed -- Stabileres Laden/Speichern (fehlerhafte config bricht GUI nicht ab). \ No newline at end of file +- Stabileres Laden/Speichern (fehlerhafte config bricht GUI nicht ab). diff --git a/README.md b/README.md index 42ff2ab..2863e1a 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,205 @@ -# paperless-ngx ASN Label Generator (GUI) – Avery L4731 / L4731REV +# paperless-ngx ASN Label Generator (GUI + CLI) -GUI-Tool (Tkinter), das ASN-Labels als PDF erzeugt – QR oder Code128 – inkl. Kalibrierung (Offset & Pitch-Drift) für saubere Ausdrucke auf echten Etikettenbögen. +Kleines Tool (Tkinter + CLI), das **ASN-Labels als PDF** erzeugt – wahlweise **QR** oder **Code128** – für mehrere **A4-Etikettenlayouts**. + +✅ 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. +

+ Screenshot der Anwendung +

+ --- ## Voraussetzungen - **Python 3.10+** -- **Git** (nur nötig, wenn du direkt von GitHub installieren willst) -- Auf **Linux** ggf. zusätzlich: **Tkinter** (siehe unten) +- Optional: **Git** (nur wenn Installation direkt von GitHub erfolgt) +- **Linux**: `python3-tk` (Tkinter GUI Paket) --- ## Installation & Start -### Windows (empfohlen) +> **Wichtig (Linux):** Viele Distributionen blockieren systemweite `pip3 install ...` Installationen (PEP 668). +> Nutze dort am besten **pipx** oder eine **virtuelle Umgebung**. -#### 1) Python installieren -- Lade Python von der offiziellen Seite. -- **Wichtig:** Beim Installer unbedingt **„Add Python to PATH“** aktivieren. +### Windows -Prüfen: +#### Empfohlen: pipx (sauber, isoliert) +1) Python installieren (bei python.org) und **„Add Python to PATH“** aktivieren. +2) pipx installieren: ```bash -python --version -pip --version +python -m pip install --upgrade pip +python -m pip install pipx +python -m pipx ensurepath ``` +Terminal neu öffnen. -#### 2) Installation direkt von GitHub +3) Installieren & starten: ```bash -pip install git+https://github.com/DEINNAME/paperless-asn-label-generator +pipx install git+https://github.com/MrLetsPlay4Fun/paperless-asn-label-generator.git paperless-asn-labels ``` -> Wenn `pip` meldet, dass `git` fehlt: Git für Windows installieren, dann nochmal versuchen. +#### Alternative: Virtualenv +```bash +python -m venv .venv +.venv\Scripts\activate +pip install git+https://github.com/MrLetsPlay4Fun/paperless-asn-label-generator.git +paperless-asn-labels +``` --- ### macOS -#### 1) Python prüfen/ installieren -Prüfen: -```bash -python3 --version -pip3 --version -``` - -Falls Python fehlt oder zu alt ist, am einfachsten über Homebrew: +#### Empfohlen: pipx +1) (Optional) Python via Homebrew: ```bash brew install python ``` -#### 2) Installation direkt von GitHub +2) pipx installieren: ```bash -pip3 install git+https://github.com/DEINNAME/paperless-asn-label-generator -paperless-asn-labels +python3 -m pip install --upgrade pip +python3 -m pip install pipx +python3 -m pipx ensurepath ``` +Terminal neu öffnen. -> Falls `paperless-asn-labels` nicht gefunden wird, starte einmal neu das Terminal oder nutze: +3) Installieren & starten: ```bash -python3 -m paperless_asn_label_generator +pipx install git+https://github.com/MrLetsPlay4Fun/paperless-asn-label-generator.git +paperless-asn-labels ``` --- -### Linux +### Linux (Debian/Ubuntu/Pop!_OS) -#### 1) Python + pip prüfen +#### 1) Tkinter installieren (GUI) ```bash -python3 --version -pip3 --version +sudo apt update +sudo apt install -y python3-tk ``` -Falls nötig (Debian/Ubuntu): +#### 2) Empfohlen: pipx installieren ```bash -sudo apt-get update -sudo apt-get install -y python3 python3-pip +sudo apt install -y pipx +pipx ensurepath ``` +Terminal neu öffnen. -#### 2) Tkinter installieren (wichtig für GUI) -Debian/Ubuntu: +#### 3) Installieren & starten (GitHub) ```bash -sudo apt-get install -y python3-tk -``` - -Fedora: -```bash -sudo dnf install -y python3-tkinter -``` - -Arch: -```bash -sudo pacman -S tk -``` - -#### 3) Installation direkt von GitHub -```bash -pip3 install git+https://github.com/DEINNAME/paperless-asn-label-generator +pipx install git+https://github.com/MrLetsPlay4Fun/paperless-asn-label-generator.git paperless-asn-labels ``` +#### Alternative: Virtuelle Umgebung (wenn du pipx nicht willst) +```bash +python3 -m venv ~/.venvs/paperless-asn +source ~/.venvs/paperless-asn/bin/activate +pip install --upgrade pip +pip install git+https://github.com/MrLetsPlay4Fun/paperless-asn-label-generator.git +paperless-asn-labels +``` + +--- + +## Update + +### Wenn mit pipx installiert +```bash +pipx upgrade paperless-asn-labels +``` + +Falls das Paket unter pipx anders heißt: +```bash +pipx upgrade-all +``` + +### Wenn in einer venv installiert +```bash +pip install -U --force-reinstall git+https://github.com/MrLetsPlay4Fun/paperless-asn-label-generator.git +``` + --- ## 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: Rahmen aktivieren (hilft beim Kalibrieren) +- 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). +Der Speicherort ist: + +- **Linux:** `~/.config/paperless-ngx-asn-labels/config.json` (oder `$XDG_CONFIG_HOME/...`) +- **macOS:** `~/Library/Application Support/paperless-ngx-asn-labels/config.json` +- **Windows:** `%APPDATA%\paperless-ngx-asn-labels\config.json` + +Im Programm gibt es außerdem den Button **„Config löschen“**, der die Datei entfernt und auf Standardwerte zurücksetzt. + +--- + ## Prefix in paperless-ngx Der Prefix muss zu deiner paperless-ngx Einstellung passen: @@ -141,15 +226,11 @@ Wenn die Position nicht exakt passt: ## Entwicklung (Repo klonen & lokal starten) ```bash -git clone https://github.com/DEINNAME/paperless-asn-label-generator +git clone https://github.com/MrLetsPlay4Fun/paperless-asn-label-generator.git cd paperless-asn-label-generator -# Virtuelle Umgebung -python -m venv .venv -# Windows: -# .venv\Scripts\activate -# macOS/Linux: -# source .venv/bin/activate +python3 -m venv .venv +source .venv/bin/activate pip install -e . paperless-asn-labels diff --git a/assets/screenshot.png b/assets/screenshot.png new file mode 100644 index 0000000..2532914 Binary files /dev/null and b/assets/screenshot.png differ diff --git a/pyproject.toml b/pyproject.toml index 6807365..d74dc54 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.2" +version = "0.1.6" description = "GUI tool to generate ASN barcode/QR labels for paperless-ngx (A4/Avery L4731) with calibration." readme = "README.md" requires-python = ">=3.10" @@ -31,4 +31,3 @@ package-dir = {"" = "src"} [tool.setuptools.packages.find] where = ["src"] - diff --git a/src/paperless_asn_label_generator/__init__.py b/src/paperless_asn_label_generator/__init__.py index 834cdae..d34dc43 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.2" \ No newline at end of file +__version__ = "0.1.6" diff --git a/src/paperless_asn_label_generator/__main__.py b/src/paperless_asn_label_generator/__main__.py index 37c2a2d..c58ddac 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__": - main() + raise SystemExit(main()) diff --git a/src/paperless_asn_label_generator/app.py b/src/paperless_asn_label_generator/app.py index a2a1ef8..e660d3b 100644 --- a/src/paperless_asn_label_generator/app.py +++ b/src/paperless_asn_label_generator/app.py @@ -19,24 +19,45 @@ 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 +from tkinter import ttk, filedialog, messagebox, simpledialog 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) @@ -64,19 +85,36 @@ class SheetLayout: return self.label_h + self.gap_y -# 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, -) +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" def make_asn_text(prefix: str, number: int, leading_zeros: int) -> str: @@ -96,6 +134,56 @@ 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, @@ -150,6 +238,7 @@ 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) @@ -162,10 +251,14 @@ 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 - labels_per_page = layout.labels_per_page - pages = math.ceil(count / labels_per_page) + 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) # Effective pitch + offsets (points) pitch_x = layout.pitch_x + (pitch_dx_mm * mm) @@ -178,12 +271,16 @@ def generate_pdf( current = start_number remaining = count - for _ in range(pages): - n_this_page = min(remaining, labels_per_page) + 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 i in range(n_this_page): - r = i // layout.cols - col = i % layout.cols + slot = first_slot_zero_based + i + r = slot // layout.cols + col = slot % 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 @@ -193,26 +290,38 @@ 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(AVERY_L4731.labels_per_page), + "count": str(LAYOUTS[DEFAULT_LAYOUT_KEY].labels_per_page), "pages": "1", + "start_pos": "1", "prefix": "ASN", "zeros": "7", "kind": "QR", + "post_open": "none", "border": False, - # deine aktuellen Preset-Kalibrierwerte: - "off_x": "-4.5", - "off_y": "7.0", - "pitch_dx": "1.2", - "pitch_dy": "0.42", + # neutrale Kalibrierung für frische Installationen + "off_x": "0.0", + "off_y": "0.0", + "pitch_dx": "0.0", + "pitch_dy": "0.0", +} + +# Effektive Basiswerte, die beim Drucken intern immer addiert werden. +BASE_CALIBRATION_MM = { + "off_x": -4.5, + "off_y": 7.0, + "pitch_dx": 1.2, + "pitch_dy": 0.42, } class App(ttk.Frame): @@ -220,13 +329,17 @@ 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"]) @@ -236,25 +349,26 @@ 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 # 1) Laden (überschreibt Defaults, falls config existiert) self._load_settings() - # UI bauen + # UI einmal bauen self._build_ui() + self._refresh_profile_combo() + self._flush_pending_config_status() self._apply_mode() self._update_preview() # 2) Autosave aktivieren self._install_autosave_traces() - self._build_ui() - self._apply_mode() - self._update_preview() - def _config_path(self) -> Path: """ @@ -274,38 +388,72 @@ class App(ttk.Frame): return base / "paperless-ngx-asn-labels" / "config.json" - def _settings_to_dict(self) -> dict: + def _settings_snapshot(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(), "pitch_dx": self.var_pitch_dx.get(), "pitch_dy": self.var_pitch_dy.get(), + "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 == "": + # Übergangsfall: Wenn alle Werte 0 sind (vorherige Zwischenversion), als Delta interpretieren. + parsed = [self._coerce_float(d.get(k)) for k in ("off_x", "off_y", "pitch_dx", "pitch_dy") if k in d] + if parsed and all(v is not None and abs(v) < 1e-9 for v in parsed): + legacy_absolute = False + + def set_calibration_var(key: str, var: tk.StringVar) -> None: + if key not in d: + return + if legacy_absolute: + absolute = self._coerce_float(d.get(key)) + if absolute is not None: + var.set(str(absolute - BASE_CALIBRATION_MM[key])) + return + var.set(str(d[key])) + if "start" in d: self.var_start.set(str(d["start"])) 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"])) - if "off_x" in d: self.var_off_x.set(str(d["off_x"])) - if "off_y" in d: self.var_off_y.set(str(d["off_y"])) - if "pitch_dx" in d: self.var_pitch_dx.set(str(d["pitch_dx"])) - if "pitch_dy" in d: self.var_pitch_dy.set(str(d["pitch_dy"])) + 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 @@ -315,10 +463,28 @@ 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) - except Exception: - # bewusst still: fehlerhafte config soll GUI nicht killen - pass + else: + 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}", "warn") def _save_settings_now(self) -> None: if getattr(self, "_loading_settings", False): @@ -329,9 +495,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) - except Exception: - # still ignorieren oder optional status zeigen - pass + self._set_config_status("", "info") + except Exception as 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 @@ -347,13 +513,17 @@ 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, @@ -368,7 +538,7 @@ class App(ttk.Frame): v.trace("w", lambda *_: self._schedule_save()) def _build_ui(self) -> None: - self.master.title("paperless-ngx ASN Label Generator (A4 / Avery L4731) – mit Kalibrierung") + self.master.title(f"paperless-ngx ASN Label Generator v{__version__}") self.grid(sticky="nsew") self.master.columnconfigure(0, weight=1) self.master.rowconfigure(0, weight=1) @@ -383,7 +553,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("", lambda _e: self._update_preview()) if store == "count": self._count_entry = ent @@ -398,22 +570,34 @@ 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") - 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") + 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("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("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)") - ttk.Label(frm, text="Code-Typ").grid(row=6, column=0, sticky="w", padx=(10, 6), pady=6) + 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) cmb = ttk.Combobox(frm, textvariable=self.var_kind, values=["QR", "CODE128"], state="readonly", width=12) - cmb.grid(row=6, column=1, sticky="w", padx=(0, 10), pady=6) + cmb.grid(row=8, 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=6, column=2, columnspan=2, sticky="w", padx=(0, 10), pady=6) + chk.grid(row=8, column=2, columnspan=2, sticky="w", padx=(0, 10), pady=6) cal = ttk.LabelFrame(frm, text="Kalibrierung (mm)") - cal.grid(row=7, column=0, columnspan=4, sticky="we", padx=10, pady=(10, 6)) + cal.grid(row=9, column=0, columnspan=4, sticky="we", padx=10, pady=(10, 6)) for i in range(6): cal.columnconfigure(i, weight=1) @@ -422,7 +606,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("", 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") @@ -432,15 +618,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("", 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") @@ -459,9 +649,159 @@ 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="#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=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("", 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: + lbl = getattr(self, "lbl_config_status", None) + if lbl is None: + self._pending_config_status = (message, level) + return + 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: + 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)) def _apply_mode(self) -> None: mode = self.var_mode.get() @@ -484,19 +824,43 @@ class App(ttk.Frame): except Exception: raise ValueError(f"{field} ist keine gültige Zahl (z.B. 0.2).") + def _coerce_float(self, value: object) -> float | None: + try: + return float(str(value).strip().replace(",", ".")) + 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.") - count = pages * AVERY_L4731.labels_per_page + 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 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 = math.ceil(count / AVERY_L4731.labels_per_page) + pages = self._pages_for_count(count, start_position) return count, pages def _current_text(self) -> str: @@ -511,13 +875,22 @@ class App(ttk.Frame): raise ValueError("Führende Nullen muss >= 0 sein.") return make_asn_text(prefix, start, zeros) - def _calibration(self) -> tuple[float, float, float, float]: + def _calibration_delta(self) -> tuple[float, float, float, float]: off_x = self._parse_float(self.var_off_x.get(), "Offset X") off_y = self._parse_float(self.var_off_y.get(), "Offset Y") pdx = self._parse_float(self.var_pitch_dx.get(), "Spalten-Pitch Δ") pdy = self._parse_float(self.var_pitch_dy.get(), "Zeilen-Pitch Δ") return off_x, off_y, pdx, pdy + def _calibration(self) -> tuple[float, float, float, float]: + off_x_delta, off_y_delta, pdx_delta, pdy_delta = self._calibration_delta() + return ( + BASE_CALIBRATION_MM["off_x"] + off_x_delta, + BASE_CALIBRATION_MM["off_y"] + off_y_delta, + BASE_CALIBRATION_MM["pitch_dx"] + pdx_delta, + BASE_CALIBRATION_MM["pitch_dy"] + pdy_delta, + ) + def _update_preview(self, *_args) -> None: try: text = self._current_text() @@ -527,40 +900,60 @@ 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.lbl_status.configure( - text=f"Beispiel: {text} | {count} Labels ≈ {pages} Seiten | Offset({off_x},{off_y})mm PitchΔ({pdx},{pdy})mm" + 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", ) 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: @@ -571,10 +964,12 @@ 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_{start}_{count}.pdf" + default_name = f"asn_labels_{self._layout_key().lower()}_{start}_{count}.pdf" out = filedialog.asksaveasfilename( title="PDF speichern", defaultextension=".pdf", @@ -591,31 +986,50 @@ class App(ttk.Frame): prefix=prefix, leading_zeros=zeros, kind=kind, - layout=AVERY_L4731, + layout=layout, 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: - 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: + self._open_path(folder) + except Exception as e: + self._set_status(f"Ordner konnte nicht geöffnet werden: {e}", "warn") messagebox.showinfo("Info", f"Ordner: {folder}") def on_reset_config(self) -> None: @@ -637,6 +1051,9 @@ 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 @@ -645,10 +1062,11 @@ 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 main() -> None: +def _run_gui() -> int: root = tk.Tk() try: style = ttk.Style() @@ -658,9 +1076,113 @@ def main() -> None: pass App(root) - root.minsize(900, 430) + root.geometry("1160x620") + root.minsize(900, 460) 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__": - main() + raise SystemExit(main())