diff --git a/CHANGELOG.md b/CHANGELOG.md
index ad9f3a2..6a7b639 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,38 +1,5 @@
# Changelog
-## 0.1.6 - 2026-02-08
-### Changed
-- Mehrere Layout-Profile auswählbar (`L4731`, `L7160`) in GUI und CLI.
-- Profilverwaltung in der GUI hinzugefügt (Profil speichern/laden/löschen) ohne manuelles JSON-Editieren.
-- Standard-Profilname ist jetzt `Brother MFC-L2710DW` (alte `default`-Einträge werden automatisch migriert).
-- Profile können jetzt per Button als JSON exportiert werden.
-- Optionaler CLI-Modus für PDF-Erzeugung ohne GUI (`--cli ...`) inkl. Layoutwahl und Seiten-/Labelmodus.
-
-## 0.1.5 - 2026-02-08
-### Changed
-- Startposition auf angebrochenem Bogen ist jetzt einstellbar (`1..189`), inklusive korrekter Seitenberechnung und Druckplatzierung.
-- Option nach PDF-Erzeugung eingeführt: `none`, `file` oder `folder` direkt öffnen.
-- Ordner/Datei-Öffnen ist plattformstabil über `subprocess` umgesetzt (ohne `os.system`), auch bei Sonderzeichen im Pfad.
-- Im Modus `A4 Blätter` wird jetzt exakt die gewählte Blattanzahl erzeugt (erstes Blatt ab Startposition auffüllen, kein zusätzlich angebrochenes Folgeblatt).
-
-## 0.1.4 - 2026-02-08
-### Changed
-- Vorschau für `CODE128` rendert jetzt als echter Barcode statt Platzhalter.
-- Vorschau-Layout wurde neu ausbalanciert: QR kleiner, Text deutlich größer/lesbarer, kein Überlappen von CODE128 und Text.
-- Fensterbreite und Vorschaufläche wurden vergrößert, damit alle UI-Elemente und der Vorschau-Text vollständig sichtbar bleiben.
-- Statusmeldungen in der UI sind vereinheitlicht (`[INFO]`, `[OK]`, `[WARN]`, `[ERROR]`) und farblich klar getrennt.
-- Layout verhält sich auf kleineren Displays robuster (reduzierte `minsize`, bessere Spalten-Gewichtung, umbrochene Hilfstexte/Statuszeilen).
-
-## 0.1.3 - 2026-02-08
-### Changed
-- Kalibrierungs-Eingaben sind jetzt als sichtbare Delta-Werte ausgelegt (Default `0.0` in der UI), während intern weiterhin die Basiswerte genutzt werden.
-- 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).
@@ -40,4 +7,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).
+- Stabileres Laden/Speichern (fehlerhafte config bricht GUI nicht ab).
\ No newline at end of file
diff --git a/README.md b/README.md
index 2863e1a..42ff2ab 100644
--- a/README.md
+++ b/README.md
@@ -1,205 +1,120 @@
-# paperless-ngx ASN Label Generator (GUI + CLI)
+# paperless-ngx ASN Label Generator (GUI) – Avery L4731 / L4731REV
-Kleines Tool (Tkinter + CLI), das **ASN-Labels als PDF** erzeugt – wahlweise **QR** oder **Code128** – für mehrere **A4-Etikettenlayouts**.
-
-✅ 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
+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.
> Hinweis: Inoffizielles Tool, nicht affiliated mit paperless-ngx.
-
-
-
-
---
## Voraussetzungen
- **Python 3.10+**
-- Optional: **Git** (nur wenn Installation direkt von GitHub erfolgt)
-- **Linux**: `python3-tk` (Tkinter GUI Paket)
+- **Git** (nur nötig, wenn du direkt von GitHub installieren willst)
+- Auf **Linux** ggf. zusätzlich: **Tkinter** (siehe unten)
---
## Installation & Start
-> **Wichtig (Linux):** Viele Distributionen blockieren systemweite `pip3 install ...` Installationen (PEP 668).
-> Nutze dort am besten **pipx** oder eine **virtuelle Umgebung**.
+### Windows (empfohlen)
-### Windows
+#### 1) Python installieren
+- Lade Python von der offiziellen Seite.
+- **Wichtig:** Beim Installer unbedingt **„Add Python to PATH“** aktivieren.
-#### Empfohlen: pipx (sauber, isoliert)
-1) Python installieren (bei python.org) und **„Add Python to PATH“** aktivieren.
-2) pipx installieren:
+Prüfen:
```bash
-python -m pip install --upgrade pip
-python -m pip install pipx
-python -m pipx ensurepath
+python --version
+pip --version
```
-Terminal neu öffnen.
-3) Installieren & starten:
+#### 2) Installation direkt von GitHub
```bash
-pipx install git+https://github.com/MrLetsPlay4Fun/paperless-asn-label-generator.git
+pip install git+https://github.com/DEINNAME/paperless-asn-label-generator
paperless-asn-labels
```
-#### 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
-```
+> Wenn `pip` meldet, dass `git` fehlt: Git für Windows installieren, dann nochmal versuchen.
---
### macOS
-#### Empfohlen: pipx
-1) (Optional) Python via Homebrew:
+#### 1) Python prüfen/ installieren
+Prüfen:
+```bash
+python3 --version
+pip3 --version
+```
+
+Falls Python fehlt oder zu alt ist, am einfachsten über Homebrew:
```bash
brew install python
```
-2) pipx installieren:
+#### 2) Installation direkt von GitHub
```bash
-python3 -m pip install --upgrade pip
-python3 -m pip install pipx
-python3 -m pipx ensurepath
-```
-Terminal neu öffnen.
-
-3) Installieren & starten:
-```bash
-pipx install git+https://github.com/MrLetsPlay4Fun/paperless-asn-label-generator.git
+pip3 install git+https://github.com/DEINNAME/paperless-asn-label-generator
paperless-asn-labels
```
+> Falls `paperless-asn-labels` nicht gefunden wird, starte einmal neu das Terminal oder nutze:
+```bash
+python3 -m paperless_asn_label_generator
+```
+
---
-### Linux (Debian/Ubuntu/Pop!_OS)
+### Linux
-#### 1) Tkinter installieren (GUI)
+#### 1) Python + pip prüfen
```bash
-sudo apt update
-sudo apt install -y python3-tk
+python3 --version
+pip3 --version
```
-#### 2) Empfohlen: pipx installieren
+Falls nötig (Debian/Ubuntu):
```bash
-sudo apt install -y pipx
-pipx ensurepath
+sudo apt-get update
+sudo apt-get install -y python3 python3-pip
```
-Terminal neu öffnen.
-#### 3) Installieren & starten (GitHub)
+#### 2) Tkinter installieren (wichtig für GUI)
+Debian/Ubuntu:
```bash
-pipx install git+https://github.com/MrLetsPlay4Fun/paperless-asn-label-generator.git
+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
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: **Nach PDF öffnen** (`none`, `file`, `folder`)
-- Optional: **Rahmen aktivieren** (hilft beim Kalibrieren)
-- Optional: Profil speichern/laden/löschen (z. B. je Drucker)
+- Optional: Rahmen aktivieren (hilft beim Kalibrieren)
- „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:
@@ -226,11 +141,15 @@ Wenn die Position nicht exakt passt:
## Entwicklung (Repo klonen & lokal starten)
```bash
-git clone https://github.com/MrLetsPlay4Fun/paperless-asn-label-generator.git
+git clone https://github.com/DEINNAME/paperless-asn-label-generator
cd paperless-asn-label-generator
-python3 -m venv .venv
-source .venv/bin/activate
+# Virtuelle Umgebung
+python -m venv .venv
+# Windows:
+# .venv\Scripts\activate
+# macOS/Linux:
+# source .venv/bin/activate
pip install -e .
paperless-asn-labels
diff --git a/assets/screenshot.png b/assets/screenshot.png
deleted file mode 100644
index 2532914..0000000
Binary files a/assets/screenshot.png and /dev/null differ
diff --git a/pyproject.toml b/pyproject.toml
index d74dc54..6807365 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "paperless-asn-label-generator"
-version = "0.1.6"
+version = "0.1.2"
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,3 +31,4 @@ 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 d34dc43..834cdae 100644
--- a/src/paperless_asn_label_generator/__init__.py
+++ b/src/paperless_asn_label_generator/__init__.py
@@ -1,2 +1,2 @@
__all__ = ["__version__"]
-__version__ = "0.1.6"
+__version__ = "0.1.2"
\ No newline at end of file
diff --git a/src/paperless_asn_label_generator/__main__.py b/src/paperless_asn_label_generator/__main__.py
index c58ddac..37c2a2d 100644
--- a/src/paperless_asn_label_generator/__main__.py
+++ b/src/paperless_asn_label_generator/__main__.py
@@ -1,4 +1,4 @@
from .app import main
if __name__ == "__main__":
- raise SystemExit(main())
+ main()
diff --git a/src/paperless_asn_label_generator/app.py b/src/paperless_asn_label_generator/app.py
index e660d3b..a2a1ef8 100644
--- a/src/paperless_asn_label_generator/app.py
+++ b/src/paperless_asn_label_generator/app.py
@@ -19,45 +19,24 @@ import math
import os
import sys
import json
-import argparse
-import subprocess
-from io import BytesIO
from pathlib import Path
from dataclasses import dataclass
from typing import Literal, Tuple
import tkinter as tk
-from tkinter import ttk, filedialog, messagebox, simpledialog
+from tkinter import ttk, filedialog, messagebox
from reportlab.pdfgen import canvas as rl_canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm, cm
from reportlab.lib.utils import ImageReader
-from reportlab.graphics import renderPM
-from reportlab.graphics.barcode import code128, createBarcodeDrawing
+from reportlab.graphics.barcode import code128
import qrcode
-from PIL import Image, ImageTk, ImageDraw, ImageFont
-
-from . import __version__
+from PIL import Image, ImageTk
BarcodeKind = Literal["QR", "CODE128"]
-StatusLevel = Literal["info", "success", "warn", "error"]
-
-STATUS_COLORS: dict[StatusLevel, str] = {
- "info": "#2b2b2b",
- "success": "#0b7d2b",
- "warn": "#9a6700",
- "error": "#b00020",
-}
-
-STATUS_PREFIX: dict[StatusLevel, str] = {
- "info": "[INFO]",
- "success": "[OK]",
- "warn": "[WARN]",
- "error": "[ERROR]",
-}
@dataclass(frozen=True)
@@ -85,36 +64,19 @@ class SheetLayout:
return self.label_h + self.gap_y
-LAYOUTS: dict[str, SheetLayout] = {
- # NOTE: Many shops/templates call this "L4731" (25.4 x 10mm, 7x27 = 189 labels).
- "L4731": SheetLayout(
- name="Avery Zweckform L4731 / L4731REV (7x27, 25.4mm x 10mm)",
- cols=7,
- rows=27,
- label_w=25.4 * mm,
- label_h=10.0 * mm,
- gap_x=0.25 * cm,
- gap_y=0.0 * cm,
- margin_left=0.85 * cm,
- margin_top=1.35 * cm,
- ),
- # Alternative A4 layout with much larger labels.
- "L7160": SheetLayout(
- name="Avery L7160 (3x7, 63.5mm x 38.1mm)",
- cols=3,
- rows=7,
- label_w=63.5 * mm,
- label_h=38.1 * mm,
- gap_x=2.5 * mm,
- gap_y=0.0 * mm,
- margin_left=6.5 * mm,
- margin_top=15.2 * mm,
- ),
-}
-
-DEFAULT_LAYOUT_KEY = "L4731"
-AVERY_L4731 = LAYOUTS[DEFAULT_LAYOUT_KEY]
-DEFAULT_PROFILE_NAME = "Brother MFC-L2710DW"
+# NOTE: Many shops/templates call this "L4731" (25.4 x 10mm, 7x27 = 189 labels).
+# If your sheet is slightly different, use calibration below.
+AVERY_L4731 = SheetLayout(
+ name="Avery Zweckform L4731 / L4731REV (7x27, 25.4mm x 10mm)",
+ cols=7,
+ rows=27,
+ label_w=25.4 * mm,
+ label_h=10.0 * mm,
+ gap_x=0.25 * cm, # base guess (tweak with Δ if needed)
+ gap_y=0.0 * cm, # base guess (tweak with Δ if needed)
+ margin_left=0.85 * cm,
+ margin_top=1.35 * cm,
+)
def make_asn_text(prefix: str, number: int, leading_zeros: int) -> str:
@@ -134,56 +96,6 @@ def make_qr_image(data: str, target_px: int) -> Image.Image:
return img.resize((target_px, target_px), Image.Resampling.LANCZOS)
-def make_code128_preview_image(data: str, target_w: int, target_h: int) -> Image.Image:
- try:
- drawing = createBarcodeDrawing(
- "Code128",
- value=data,
- barHeight=max(18.0, target_h * 0.55),
- barWidth=0.24 * mm,
- humanReadable=False,
- )
- png_bytes = renderPM.drawToString(drawing, fmt="PNG")
- img = Image.open(BytesIO(png_bytes)).convert("RGB")
- img.thumbnail((max(10, target_w), max(10, target_h)), Image.Resampling.LANCZOS)
- return img
- except Exception:
- # Fallback: simple barcode-like preview, falls Render-Pipeline fehlt.
- fallback = Image.new("RGB", (max(10, target_w), max(10, target_h)), "white")
- draw = ImageDraw.Draw(fallback)
- x = 2
- while x < fallback.width - 2:
- bar_w = 1 if (x // 3) % 2 == 0 else 2
- draw.rectangle([x, 2, min(fallback.width - 2, x + bar_w), fallback.height - 3], fill="black")
- x += bar_w + 2
- return fallback
-
-
-def fit_preview_font(draw: ImageDraw.ImageDraw, text: str, max_w: int, max_h: int) -> ImageFont.ImageFont:
- max_size = max(10, min(42, max_h))
- for size in range(max_size, 9, -1):
- try:
- font = ImageFont.truetype("DejaVuSans.ttf", size)
- except Exception:
- break
- bbox = draw.textbbox((0, 0), text, font=font)
- w = bbox[2] - bbox[0]
- h = bbox[3] - bbox[1]
- if w <= max_w and h <= max_h:
- return font
- return ImageFont.load_default()
-
-
-def open_path(path: str) -> None:
- if os.name == "nt":
- os.startfile(path) # type: ignore[attr-defined]
- return
- if sys.platform == "darwin":
- subprocess.Popen(["open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
- return
- subprocess.Popen(["xdg-open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
-
-
def draw_label(
c: rl_canvas.Canvas,
x: float,
@@ -238,7 +150,6 @@ def generate_pdf(
offset_y_mm: float = 0.0, # + moves up
pitch_dx_mm: float = 0.0, # added to pitch_x (distance between columns)
pitch_dy_mm: float = 0.0, # added to pitch_y (distance between rows)
- start_position: int = 1, # 1..labels_per_page (first label slot on first page)
) -> Tuple[int, int]:
"""
Returns: (pages_generated, next_number)
@@ -251,14 +162,10 @@ def generate_pdf(
raise ValueError("leading_zeros must be >= 0")
if not prefix:
raise ValueError("prefix must not be empty")
- labels_per_page = layout.labels_per_page
- if not (1 <= start_position <= labels_per_page):
- raise ValueError(f"start_position must be between 1 and {labels_per_page}")
_page_w, page_h = A4
- first_page_capacity = labels_per_page - (start_position - 1)
- remaining_after_first = max(0, count - first_page_capacity)
- pages = 1 + math.ceil(remaining_after_first / labels_per_page)
+ labels_per_page = layout.labels_per_page
+ pages = math.ceil(count / labels_per_page)
# Effective pitch + offsets (points)
pitch_x = layout.pitch_x + (pitch_dx_mm * mm)
@@ -271,16 +178,12 @@ def generate_pdf(
current = start_number
remaining = count
- for page_idx in range(pages):
- first_slot = start_position if page_idx == 0 else 1
- first_slot_zero_based = first_slot - 1
- slots_available = labels_per_page - first_slot_zero_based
- n_this_page = min(remaining, slots_available)
+ for _ in range(pages):
+ n_this_page = min(remaining, labels_per_page)
for i in range(n_this_page):
- slot = first_slot_zero_based + i
- r = slot // layout.cols
- col = slot % layout.cols
+ r = i // layout.cols
+ col = i % layout.cols
x = layout.margin_left + col * pitch_x + off_x
y = page_h - layout.margin_top - layout.label_h - r * pitch_y + off_y
@@ -290,38 +193,26 @@ def generate_pdf(
current += 1
+ c.showPage()
remaining -= n_this_page
- if page_idx < pages - 1:
- c.showPage()
c.save()
return pages, current
DEFAULT_SETTINGS = {
- "layout": DEFAULT_LAYOUT_KEY,
"start": "1",
"mode": "labels",
- "count": str(LAYOUTS[DEFAULT_LAYOUT_KEY].labels_per_page),
+ "count": str(AVERY_L4731.labels_per_page),
"pages": "1",
- "start_pos": "1",
"prefix": "ASN",
"zeros": "7",
"kind": "QR",
- "post_open": "none",
"border": False,
- # neutrale Kalibrierung für frische Installationen
- "off_x": "0.0",
- "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,
+ # deine aktuellen Preset-Kalibrierwerte:
+ "off_x": "-4.5",
+ "off_y": "7.0",
+ "pitch_dx": "1.2",
+ "pitch_dy": "0.42",
}
class App(ttk.Frame):
@@ -329,17 +220,13 @@ class App(ttk.Frame):
super().__init__(master, padding=12)
self.master = master
- self.var_layout = tk.StringVar(value=DEFAULT_SETTINGS["layout"])
- self.var_profile = tk.StringVar(value=DEFAULT_PROFILE_NAME)
self.var_start = tk.StringVar(value=DEFAULT_SETTINGS["start"])
self.var_mode = tk.StringVar(value=DEFAULT_SETTINGS["mode"])
self.var_count = tk.StringVar(value=DEFAULT_SETTINGS["count"])
self.var_pages = tk.StringVar(value=DEFAULT_SETTINGS["pages"])
- self.var_start_pos = tk.StringVar(value=DEFAULT_SETTINGS["start_pos"])
self.var_prefix = tk.StringVar(value=DEFAULT_SETTINGS["prefix"])
self.var_zeros = tk.StringVar(value=DEFAULT_SETTINGS["zeros"])
self.var_kind = tk.StringVar(value=DEFAULT_SETTINGS["kind"])
- self.var_post_open = tk.StringVar(value=DEFAULT_SETTINGS["post_open"])
self.var_border = tk.BooleanVar(value=DEFAULT_SETTINGS["border"])
self.var_off_x = tk.StringVar(value=DEFAULT_SETTINGS["off_x"])
self.var_off_y = tk.StringVar(value=DEFAULT_SETTINGS["off_y"])
@@ -349,26 +236,25 @@ 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 einmal bauen
+ # UI 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:
"""
@@ -388,72 +274,38 @@ class App(ttk.Frame):
return base / "paperless-ngx-asn-labels" / "config.json"
- def _settings_snapshot(self) -> dict:
+ def _settings_to_dict(self) -> dict:
return {
- "layout": self.var_layout.get(),
"start": self.var_start.get(),
"mode": self.var_mode.get(),
"count": self.var_count.get(),
"pages": self.var_pages.get(),
- "start_pos": self.var_start_pos.get(),
"prefix": self.var_prefix.get(),
"zeros": self.var_zeros.get(),
"kind": self.var_kind.get(),
- "post_open": self.var_post_open.get(),
"border": bool(self.var_border.get()),
"off_x": self.var_off_x.get(),
"off_y": self.var_off_y.get(),
"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"]))
- 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()
+ 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"]))
finally:
self._loading_settings = False
@@ -463,28 +315,10 @@ class App(ttk.Frame):
if path.exists():
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
- profiles = data.get("profiles")
- if isinstance(profiles, dict):
- self._profiles = {str(k): v for k, v in profiles.items() if isinstance(v, dict)}
- # Migration: alter fixer Name "default" -> neuer fixer Name.
- if "default" in self._profiles and DEFAULT_PROFILE_NAME not in self._profiles:
- self._profiles[DEFAULT_PROFILE_NAME] = self._profiles["default"]
- self._profiles.pop("default", None)
- if DEFAULT_PROFILE_NAME not in self._profiles:
- self._profiles[DEFAULT_PROFILE_NAME] = dict(DEFAULT_SETTINGS)
- active_profile = str(data.get("active_profile", DEFAULT_PROFILE_NAME))
- if active_profile == "default":
- active_profile = DEFAULT_PROFILE_NAME
- if active_profile in self._profiles:
- self.var_profile.set(active_profile)
- else:
- self.var_profile.set(DEFAULT_PROFILE_NAME)
self._apply_settings_from_dict(data)
- else:
- self._set_config_status(f"Config hat falsches Format, verwende Defaults: {path}", "warn")
- 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")
+ except Exception:
+ # bewusst still: fehlerhafte config soll GUI nicht killen
+ pass
def _save_settings_now(self) -> None:
if getattr(self, "_loading_settings", False):
@@ -495,9 +329,9 @@ class App(ttk.Frame):
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(self._settings_to_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)
- self._set_config_status("", "info")
- except Exception as e:
- self._set_config_status(f"Config konnte nicht gespeichert werden ({path}): {e}", "error")
+ except Exception:
+ # still ignorieren oder optional status zeigen
+ pass
def _schedule_save(self) -> None:
# Debounce: erst nach kurzer Pause speichern
@@ -513,17 +347,13 @@ class App(ttk.Frame):
def _install_autosave_traces(self) -> None:
# alle Variablen bei Änderung -> save schedulen
vars_to_watch = [
- self.var_layout,
- self.var_profile,
self.var_start,
self.var_mode,
self.var_count,
self.var_pages,
- self.var_start_pos,
self.var_prefix,
self.var_zeros,
self.var_kind,
- self.var_post_open,
self.var_border,
self.var_off_x,
self.var_off_y,
@@ -538,7 +368,7 @@ class App(ttk.Frame):
v.trace("w", lambda *_: self._schedule_save())
def _build_ui(self) -> None:
- self.master.title(f"paperless-ngx ASN Label Generator v{__version__}")
+ self.master.title("paperless-ngx ASN Label Generator (A4 / Avery L4731) – mit Kalibrierung")
self.grid(sticky="nsew")
self.master.columnconfigure(0, weight=1)
self.master.rowconfigure(0, weight=1)
@@ -553,9 +383,7 @@ class App(ttk.Frame):
ent = ttk.Entry(frm, textvariable=var, width=14)
ent.grid(row=row, column=1, sticky="we", padx=(0, 10), pady=6)
if hint:
- ttk.Label(frm, text=hint, foreground="#555", wraplength=250, justify="left").grid(
- row=row, column=2, columnspan=2, sticky="w", padx=(0, 10), pady=6
- )
+ ttk.Label(frm, text=hint, foreground="#555").grid(row=row, column=2, columnspan=2, sticky="w", padx=(0, 10), pady=6)
ent.bind("", lambda _e: self._update_preview())
if store == "count":
self._count_entry = ent
@@ -570,34 +398,22 @@ class App(ttk.Frame):
ttk.Radiobutton(mode_box, text="Labels", value="labels", variable=self.var_mode, command=self._apply_mode).pack(side="left", padx=(0, 10))
ttk.Radiobutton(mode_box, text="A4 Blätter", value="pages", variable=self.var_mode, command=self._apply_mode).pack(side="left")
- ttk.Label(frm, text="Layout").grid(row=2, column=0, sticky="w", padx=(10, 6), pady=6)
- layout_box = ttk.Combobox(
- frm,
- textvariable=self.var_layout,
- values=list(LAYOUTS.keys()),
- state="readonly",
- width=16,
- )
- layout_box.grid(row=2, column=1, sticky="w", padx=(0, 10), pady=6)
- layout_box.bind("<>", self._on_layout_changed)
+ add_row("Anzahl Labels", self.var_count, 2, f"{AVERY_L4731.labels_per_page} Labels pro A4-Seite", store="count")
+ add_row("Anzahl A4 Blätter", self.var_pages, 3, "z.B. 3 → erzeugt 3 Seiten", store="pages")
- add_row("Anzahl Labels", self.var_count, 3, "Labels pro A4-Seite sind layoutabhängig", store="count")
- add_row("Anzahl A4 Blätter", self.var_pages, 4, "z.B. 3 → erzeugt exakt 3 Seiten", store="pages")
- add_row("Startposition auf Bogen", self.var_start_pos, 5, "1..Labels/Seite (abhängig vom Layout)")
+ add_row("Prefix", self.var_prefix, 4, "Muss zu PAPERLESS_CONSUMER_ASN_BARCODE_PREFIX passen")
+ add_row("Führende Nullen", self.var_zeros, 5, "z.B. 5 → ASN00001")
- add_row("Prefix", self.var_prefix, 6, "Muss zu PAPERLESS_CONSUMER_ASN_BARCODE_PREFIX passen")
- add_row("Führende Nullen", self.var_zeros, 7, "z.B. 5 → ASN00001")
-
- ttk.Label(frm, text="Code-Typ").grid(row=8, column=0, sticky="w", padx=(10, 6), pady=6)
+ ttk.Label(frm, text="Code-Typ").grid(row=6, column=0, sticky="w", padx=(10, 6), pady=6)
cmb = ttk.Combobox(frm, textvariable=self.var_kind, values=["QR", "CODE128"], state="readonly", width=12)
- cmb.grid(row=8, column=1, sticky="w", padx=(0, 10), pady=6)
+ cmb.grid(row=6, column=1, sticky="w", padx=(0, 10), pady=6)
cmb.bind("<>", lambda _e: self._update_preview())
chk = ttk.Checkbutton(frm, text="Rahmen um Labels zeichnen (Kalibrierung)", variable=self.var_border, command=self._update_preview)
- chk.grid(row=8, column=2, columnspan=2, sticky="w", padx=(0, 10), pady=6)
+ chk.grid(row=6, column=2, columnspan=2, sticky="w", padx=(0, 10), pady=6)
cal = ttk.LabelFrame(frm, text="Kalibrierung (mm)")
- cal.grid(row=9, column=0, columnspan=4, sticky="we", padx=10, pady=(10, 6))
+ cal.grid(row=7, column=0, columnspan=4, sticky="we", padx=10, pady=(10, 6))
for i in range(6):
cal.columnconfigure(i, weight=1)
@@ -606,9 +422,7 @@ class App(ttk.Frame):
e = ttk.Entry(cal, textvariable=var, width=10)
e.grid(row=0, column=col + 1, sticky="we", padx=(0, 10), pady=6)
e.bind("", lambda _e: self._update_preview())
- ttk.Label(cal, text=hint, foreground="#555", wraplength=130, justify="left").grid(
- row=0, column=col + 2, sticky="w", padx=(0, 10), pady=6
- )
+ ttk.Label(cal, text=hint, foreground="#555").grid(row=0, column=col + 2, sticky="w", padx=(0, 10), pady=6)
add_cal("Offset X", self.var_off_x, 0, "+ rechts")
add_cal("Offset Y", self.var_off_y, 3, "+ hoch")
@@ -618,19 +432,15 @@ class App(ttk.Frame):
e = ttk.Entry(cal, textvariable=var, width=10)
e.grid(row=row, column=col + 1, sticky="we", padx=(0, 10), pady=6)
e.bind("", lambda _e: self._update_preview())
- ttk.Label(cal, text=hint, foreground="#555", wraplength=130, justify="left").grid(
- row=row, column=col + 2, sticky="w", padx=(0, 10), pady=6
- )
+ ttk.Label(cal, text=hint, foreground="#555").grid(row=row, column=col + 2, sticky="w", padx=(0, 10), pady=6)
add_cal2("Spalten-Pitch Δ", self.var_pitch_dx, 1, 0, "→ drift links/rechts")
add_cal2("Zeilen-Pitch Δ", self.var_pitch_dy, 1, 3, "→ top/bottom drift")
right = ttk.Frame(self)
right.grid(row=0, column=1, sticky="nsew", padx=(12, 0))
- self.columnconfigure(0, weight=1)
self.columnconfigure(1, weight=1)
self.rowconfigure(0, weight=1)
- right.columnconfigure(0, weight=1)
right.rowconfigure(1, weight=1)
ttk.Label(right, text="Vorschau (1 Label)").grid(row=0, column=0, sticky="w")
@@ -649,159 +459,9 @@ class App(ttk.Frame):
ttk.Button(btns, text="Config löschen", command=self.on_reset_config).grid(
row=1, column=0, columnspan=2, sticky="we", pady=(6, 0)
)
- ttk.Label(btns, text="Nach PDF-Erstellung öffnen").grid(row=2, column=0, sticky="w", pady=(8, 0))
- cmb_open = ttk.Combobox(
- btns,
- textvariable=self.var_post_open,
- values=["none", "file", "folder"],
- state="readonly",
- width=12,
- )
- cmb_open.grid(row=2, column=1, sticky="e", pady=(8, 0))
- ttk.Label(btns, text="Profil").grid(row=3, column=0, sticky="w", pady=(8, 0))
- self._profile_combo = ttk.Combobox(
- btns,
- textvariable=self.var_profile,
- values=[DEFAULT_PROFILE_NAME],
- state="readonly",
- width=24,
- )
- self._profile_combo.grid(row=3, column=1, sticky="e", pady=(8, 0))
- ttk.Button(btns, text="Profil laden", command=self.on_load_profile).grid(row=4, column=0, sticky="we", pady=(6, 0), padx=(0, 6))
- ttk.Button(btns, text="Profil speichern", command=self.on_save_profile).grid(row=4, column=1, sticky="we", pady=(6, 0), padx=(6, 0))
- ttk.Button(btns, text="Profil löschen", command=self.on_delete_profile).grid(row=5, column=0, columnspan=2, sticky="we", pady=(6, 0))
- ttk.Button(btns, text="Profile exportieren", command=self.on_export_profiles).grid(
- row=6, column=0, columnspan=2, sticky="we", pady=(6, 0)
- )
- self.lbl_status = ttk.Label(self, text="", foreground=STATUS_COLORS["info"], wraplength=900, justify="left")
+ self.lbl_status = ttk.Label(self, text="", foreground="#333")
self.lbl_status.grid(row=1, column=0, columnspan=2, sticky="we", pady=(10, 0))
- self.lbl_config_status = ttk.Label(self, text="", foreground=STATUS_COLORS["warn"], wraplength=900, justify="left")
- self.lbl_config_status.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()
@@ -824,43 +484,19 @@ 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.")
- labels_per_page = self._layout().labels_per_page
- first_page_capacity = labels_per_page - (start_position - 1)
- # Im Seitenmodus exakt N Blätter erzeugen:
- # erstes Blatt ab Startposition auffüllen, danach volle Blätter.
- count = first_page_capacity + (pages - 1) * labels_per_page
+ count = pages * AVERY_L4731.labels_per_page
return count, pages
else:
count = self._parse_int(self.var_count.get(), "Anzahl Labels")
if count <= 0:
raise ValueError("Anzahl Labels muss > 0 sein.")
- pages = self._pages_for_count(count, start_position)
+ pages = math.ceil(count / AVERY_L4731.labels_per_page)
return count, pages
def _current_text(self) -> str:
@@ -875,22 +511,13 @@ class App(ttk.Frame):
raise ValueError("Führende Nullen muss >= 0 sein.")
return make_asn_text(prefix, start, zeros)
- def _calibration_delta(self) -> tuple[float, float, float, float]:
+ def _calibration(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()
@@ -900,60 +527,40 @@ class App(ttk.Frame):
self.preview.configure(image=self._preview_imgtk)
count, pages = self._effective_count_and_pages()
- start_pos = self._start_position()
- layout = self._layout()
- off_x_delta, off_y_delta, pdx_delta, pdy_delta = self._calibration_delta()
off_x, off_y, pdx, pdy = self._calibration()
- self._set_status(
- (
- f"Beispiel: {text} | Layout {self._layout_key()} ({layout.labels_per_page}/Seite)"
- f" | {count} Labels | Startposition {start_pos} | Ausgabe {pages} Seiten"
- f" | Eingabe Offset({off_x_delta},{off_y_delta})mm PitchΔ({pdx_delta},{pdy_delta})mm"
- f" | Effektiv Offset({off_x},{off_y})mm PitchΔ({pdx},{pdy})mm"
- ),
- "info",
+ self.lbl_status.configure(
+ text=f"Beispiel: {text} | {count} Labels ≈ {pages} Seiten | Offset({off_x},{off_y})mm PitchΔ({pdx},{pdy})mm"
)
except Exception as e:
self.preview.configure(image="")
- self._set_status(str(e), "warn")
+ self.lbl_status.configure(text=f"⚠️ {e}")
def _render_preview_image(self, text: str, kind: BarcodeKind, border: bool) -> Image.Image:
- preview_w, preview_h = 440, 180
- img = Image.new("RGB", (preview_w, preview_h), "white")
- draw = ImageDraw.Draw(img)
-
- pad = 12
- code_box_h = int(preview_h * 0.76)
- code_box_w = code_box_h
- code_x = pad
- code_y = (preview_h - code_box_h) // 2
+ scale = 6
+ w = int((AVERY_L4731.label_w / mm) * scale * 10)
+ h = int((AVERY_L4731.label_h / mm) * scale * 10)
+ img = Image.new("RGB", (w, h), "white")
if kind == "QR":
- qr = make_qr_image(text, max(100, code_box_h))
- img.paste(qr, (code_x, code_y))
+ size = int(h * 0.90)
+ qr = make_qr_image(text, max(120, size))
+ img.paste(qr, (10, (h - qr.size[1]) // 2))
else:
- barcode = make_code128_preview_image(
- text,
- target_w=code_box_w,
- target_h=int(code_box_h * 0.72),
- )
- bx = code_x + (code_box_w - barcode.size[0]) // 2
- by = code_y + (code_box_h - barcode.size[1]) // 2
- img.paste(barcode, (bx, by))
+ box = Image.new("RGB", (int(h * 0.90), int(h * 0.90)), "white")
+ img.paste(box, (10, (h - box.size[1]) // 2))
- text_x = code_x + code_box_w + pad
- text_max_w = max(40, preview_w - text_x - pad)
- text_max_h = max(20, preview_h - (2 * pad))
- font = fit_preview_font(draw, text, text_max_w, text_max_h)
- text_bbox = draw.textbbox((0, 0), text, font=font)
- text_h = text_bbox[3] - text_bbox[1]
- text_y = max(pad, (preview_h - text_h) // 2)
- draw.text((text_x, text_y), text, fill="black", font=font)
+ from PIL import ImageDraw, ImageFont
+ draw = ImageDraw.Draw(img)
+ try:
+ font = ImageFont.truetype("DejaVuSans.ttf", 18)
+ except Exception:
+ font = ImageFont.load_default()
+ draw.text((int(h * 0.95), (h - 18) // 2), text, fill="black", font=font)
if border:
- draw.rectangle([0, 0, preview_w - 1, preview_h - 1], outline="black", width=2)
+ draw.rectangle([0, 0, w - 1, h - 1], outline="black", width=2)
- return img
+ return img.resize((360, 160), Image.Resampling.LANCZOS)
def on_generate_pdf(self) -> None:
try:
@@ -964,12 +571,10 @@ class App(ttk.Frame):
raise ValueError("Prefix darf nicht leer sein.")
count, _pages = self._effective_count_and_pages()
- start_pos = self._start_position()
- layout = self._layout()
kind: BarcodeKind = "QR" if self.var_kind.get() == "QR" else "CODE128"
off_x, off_y, pdx, pdy = self._calibration()
- default_name = f"asn_labels_{self._layout_key().lower()}_{start}_{count}.pdf"
+ default_name = f"asn_labels_{start}_{count}.pdf"
out = filedialog.asksaveasfilename(
title="PDF speichern",
defaultextension=".pdf",
@@ -986,50 +591,31 @@ class App(ttk.Frame):
prefix=prefix,
leading_zeros=zeros,
kind=kind,
- layout=layout,
+ layout=AVERY_L4731,
draw_border=self.var_border.get(),
offset_x_mm=off_x,
offset_y_mm=off_y,
pitch_dx_mm=pdx,
pitch_dy_mm=pdy,
- start_position=start_pos,
)
+ self.lbl_status.configure(
+ text=f"✅ PDF: {out} | Seiten: {pages_generated} | Labels: {count} | Nächster Start: {next_number}"
+ )
self.var_start.set(str(next_number))
self._update_preview()
- self._set_status(
- f"PDF: {out} | Layout: {self._layout_key()} | Seiten: {pages_generated} | Labels: {count} | Startposition: {start_pos} | Nächster Start: {next_number}",
- "success",
- )
- try:
- self._open_after_generate(out)
- except Exception as e:
- self._set_status(f"PDF erzeugt, aber Öffnen fehlgeschlagen: {e}", "warn")
except Exception as e:
- self._set_status(str(e), "error")
messagebox.showerror("Fehler", str(e))
- def _open_path(self, path: str) -> None:
- open_path(path)
-
- def _open_after_generate(self, output_pdf: str) -> None:
- mode = self.var_post_open.get().strip().lower()
- if mode == "none":
- return
- if mode == "file":
- self._open_path(output_pdf)
- return
- if mode == "folder":
- self._open_path(str(Path(output_pdf).resolve().parent))
- return
- raise ValueError(f"Ungültige Nach-PDF-Option: {mode}")
-
def on_open_folder(self) -> None:
folder = os.getcwd()
try:
- self._open_path(folder)
- except Exception as e:
- self._set_status(f"Ordner konnte nicht geöffnet werden: {e}", "warn")
+ if os.name == "nt":
+ os.startfile(folder) # type: ignore[attr-defined]
+ elif os.name == "posix":
+ cmd = "open" if sys.platform == "darwin" else "xdg-open"
+ os.system(f'{cmd} "{folder}"')
+ except Exception:
messagebox.showinfo("Info", f"Ordner: {folder}")
def on_reset_config(self) -> None:
@@ -1051,9 +637,6 @@ class App(ttk.Frame):
return
# 2) Defaults anwenden (ohne Autosave-Trigger-Chaos)
- self._profiles = {DEFAULT_PROFILE_NAME: dict(DEFAULT_SETTINGS)}
- self.var_profile.set(DEFAULT_PROFILE_NAME)
- self._refresh_profile_combo()
self._apply_settings_from_dict(DEFAULT_SETTINGS)
# UI/Preview aktualisieren
@@ -1062,11 +645,10 @@ class App(ttk.Frame):
# 3) Optional: Defaults direkt wieder speichern (damit nächster Start garantiert Default hat)
self._save_settings_now()
- self._set_status("Einstellungen wurden auf Standardwerte zurückgesetzt.", "success")
messagebox.showinfo("OK", f"Zurückgesetzt.\n\nConfig-Pfad:\n{path}")
-def _run_gui() -> int:
+def main() -> None:
root = tk.Tk()
try:
style = ttk.Style()
@@ -1076,113 +658,9 @@ def _run_gui() -> int:
pass
App(root)
- root.geometry("1160x620")
- root.minsize(900, 460)
+ root.minsize(900, 430)
root.mainloop()
- return 0
-
-
-def _build_arg_parser() -> argparse.ArgumentParser:
- p = argparse.ArgumentParser(
- prog="paperless-asn-labels",
- description="ASN Label PDF Generator (GUI + optional CLI mode).",
- )
- p.add_argument("--cli", action="store_true", help="CLI-Modus verwenden (ohne GUI).")
- p.add_argument("--output", help="Ausgabedatei (.pdf) für CLI.")
- p.add_argument("--layout", choices=sorted(LAYOUTS.keys()), default=DEFAULT_LAYOUT_KEY, help="Layout-Profil (CLI).")
- p.add_argument("--start", type=int, default=1, help="Start-Nummer (CLI).")
- p.add_argument("--prefix", default="ASN", help="ASN Prefix (CLI).")
- p.add_argument("--zeros", type=int, default=7, help="Führende Nullen (CLI).")
- p.add_argument("--kind", choices=["QR", "CODE128"], default="QR", help="Barcode-Typ (CLI).")
- p.add_argument("--start-position", type=int, default=1, help="Startposition auf dem ersten Blatt (CLI).")
- p.add_argument("--count", type=int, help="Anzahl Labels (CLI).")
- p.add_argument("--pages", type=int, help="Anzahl A4-Blätter (CLI, exakt).")
- p.add_argument("--border", action="store_true", help="Labelrahmen zeichnen (CLI).")
- p.add_argument("--offset-x", type=float, default=BASE_CALIBRATION_MM["off_x"], help="Effektiver Offset X in mm (CLI).")
- p.add_argument("--offset-y", type=float, default=BASE_CALIBRATION_MM["off_y"], help="Effektiver Offset Y in mm (CLI).")
- p.add_argument("--pitch-dx", type=float, default=BASE_CALIBRATION_MM["pitch_dx"], help="Effektiver Pitch Δ X in mm (CLI).")
- p.add_argument("--pitch-dy", type=float, default=BASE_CALIBRATION_MM["pitch_dy"], help="Effektiver Pitch Δ Y in mm (CLI).")
- p.add_argument("--open", choices=["none", "file", "folder"], default="none", help="Nach CLI-PDF öffnen.")
- return p
-
-
-def _run_cli(args: argparse.Namespace) -> int:
- if not args.output:
- raise ValueError("Im CLI-Modus ist --output erforderlich.")
- if args.count is not None and args.pages is not None:
- raise ValueError("Bitte nur --count ODER --pages angeben, nicht beides.")
-
- layout = LAYOUTS[args.layout]
- if args.pages is not None:
- if args.pages <= 0:
- raise ValueError("--pages muss > 0 sein.")
- first_page_capacity = layout.labels_per_page - (args.start_position - 1)
- count = first_page_capacity + (args.pages - 1) * layout.labels_per_page
- elif args.count is not None:
- if args.count <= 0:
- raise ValueError("--count muss > 0 sein.")
- count = args.count
- else:
- count = layout.labels_per_page
-
- pages_generated, next_number = generate_pdf(
- output_path=args.output,
- start_number=args.start,
- count=count,
- prefix=args.prefix,
- leading_zeros=args.zeros,
- kind="QR" if args.kind == "QR" else "CODE128",
- layout=layout,
- draw_border=bool(args.border),
- offset_x_mm=args.offset_x,
- offset_y_mm=args.offset_y,
- pitch_dx_mm=args.pitch_dx,
- pitch_dy_mm=args.pitch_dy,
- start_position=args.start_position,
- )
- print(
- f"OK: {args.output} | layout={args.layout} | pages={pages_generated} | labels={count} | next={next_number}"
- )
- if args.open == "file":
- open_path(args.output)
- elif args.open == "folder":
- open_path(str(Path(args.output).resolve().parent))
- return 0
-
-
-def main(argv: list[str] | None = None) -> int:
- parser = _build_arg_parser()
- args = parser.parse_args(argv)
-
- cli_options_used = any(
- [
- args.output is not None,
- args.count is not None,
- args.pages is not None,
- args.layout != DEFAULT_LAYOUT_KEY,
- args.start != 1,
- args.prefix != "ASN",
- args.zeros != 7,
- args.kind != "QR",
- args.start_position != 1,
- args.border,
- args.offset_x != BASE_CALIBRATION_MM["off_x"],
- args.offset_y != BASE_CALIBRATION_MM["off_y"],
- args.pitch_dx != BASE_CALIBRATION_MM["pitch_dx"],
- args.pitch_dy != BASE_CALIBRATION_MM["pitch_dy"],
- args.open != "none",
- ]
- )
-
- if args.cli or cli_options_used:
- try:
- return _run_cli(args)
- except Exception as e:
- print(f"ERROR: {e}", file=sys.stderr)
- return 2
-
- return _run_gui()
if __name__ == "__main__":
- raise SystemExit(main())
+ main()