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.
+
+
+
+
---
## 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())