bump 0.1.6: layouts, profiles, cli, profile export

This commit is contained in:
MrLetsPlay4Fun 2026-02-08 09:38:05 +01:00
parent 4c669debb5
commit d2f1f3c6b0
6 changed files with 396 additions and 51 deletions

View file

@ -1,5 +1,13 @@
# Changelog # 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 ## 0.1.5 - 2026-02-08
### Changed ### Changed
- Startposition auf angebrochenem Bogen ist jetzt einstellbar (`1..189`), inklusive korrekter Seitenberechnung und Druckplatzierung. - Startposition auf angebrochenem Bogen ist jetzt einstellbar (`1..189`), inklusive korrekter Seitenberechnung und Druckplatzierung.

View file

@ -1,10 +1,13 @@
# paperless-ngx ASN Label Generator (GUI) Avery L4731 / L4731REV # paperless-ngx ASN Label Generator (GUI + CLI)
Kleines Desktop-GUI-Tool (Tkinter), das **ASN-Labels als PDF** erzeugt wahlweise **QR** oder **Code128** speziell für **A4-Etikettenbögen (z.B. Avery Zweckform L4731 / L4731REV, 7×27)**. 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 ✅ Ideal, um in paperless-ngx sauber mit ASN-Barcodes zu arbeiten
✅ Mit **Kalibrierung** (Offset X/Y + Pitch-Drift) für exakte Druckausrichtung ✅ Mit **Kalibrierung** (Offset X/Y + Pitch-Drift) für exakte Druckausrichtung
**Persistente Einstellungen**: Drucker-Kalibrierwerte werden automatisch gespeichert **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 **Reset-Button**: “Config löschen” setzt alles auf Standardwerte zurück
> Hinweis: Inoffizielles Tool, nicht affiliated mit paperless-ngx. > Hinweis: Inoffizielles Tool, nicht affiliated mit paperless-ngx.
@ -134,15 +137,56 @@ pip install -U --force-reinstall git+https://github.com/MrLetsPlay4Fun/paperless
## Nutzung ## Nutzung
- Start-Nummer wählen (z.B. 1 oder 190) - Start-Nummer wählen (z.B. 1 oder 190)
- Layout wählen (z. B. `L4731` oder `L7160`)
- Prefix setzen (muss zu paperless-ngx passen) - Prefix setzen (muss zu paperless-ngx passen)
- Führende Nullen einstellen (z.B. 7 → `ASN0000001`) - Führende Nullen einstellen (z.B. 7 → `ASN0000001`)
- Menge als **Labels** oder **A4-Blätter** - Menge als **Labels** oder **A4-Blätter**
- Optional: **Startposition auf Bogen** setzen (bei angebrochenem Blatt)
- Code-Typ: **QR** oder **CODE128** - Code-Typ: **QR** oder **CODE128**
- Optional: **Nach PDF öffnen** (`none`, `file`, `folder`)
- Optional: **Rahmen aktivieren** (hilft beim Kalibrieren) - Optional: **Rahmen aktivieren** (hilft beim Kalibrieren)
- Optional: Profil speichern/laden/löschen (z. B. je Drucker)
- „PDF erzeugen…“ → Datei speichern - „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) ## Gespeicherte Einstellungen (Config)
Deine Kalibrierwerte & UI-Einstellungen werden automatisch gespeichert (persistente Config). Deine Kalibrierwerte & UI-Einstellungen werden automatisch gespeichert (persistente Config).

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "paperless-asn-label-generator" name = "paperless-asn-label-generator"
version = "0.1.5" version = "0.1.6"
description = "GUI tool to generate ASN barcode/QR labels for paperless-ngx (A4/Avery L4731) with calibration." description = "GUI tool to generate ASN barcode/QR labels for paperless-ngx (A4/Avery L4731) with calibration."
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View file

@ -1,2 +1,2 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.5" __version__ = "0.1.6"

View file

@ -1,4 +1,4 @@
from .app import main from .app import main
if __name__ == "__main__": if __name__ == "__main__":
main() raise SystemExit(main())

View file

@ -19,6 +19,7 @@ import math
import os import os
import sys import sys
import json import json
import argparse
import subprocess import subprocess
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
@ -26,7 +27,7 @@ from dataclasses import dataclass
from typing import Literal, Tuple from typing import Literal, Tuple
import tkinter as tk 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.pdfgen import canvas as rl_canvas
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
@ -84,19 +85,36 @@ class SheetLayout:
return self.label_h + self.gap_y 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). # NOTE: Many shops/templates call this "L4731" (25.4 x 10mm, 7x27 = 189 labels).
# If your sheet is slightly different, use calibration below. "L4731": SheetLayout(
AVERY_L4731 = SheetLayout(
name="Avery Zweckform L4731 / L4731REV (7x27, 25.4mm x 10mm)", name="Avery Zweckform L4731 / L4731REV (7x27, 25.4mm x 10mm)",
cols=7, cols=7,
rows=27, rows=27,
label_w=25.4 * mm, label_w=25.4 * mm,
label_h=10.0 * mm, label_h=10.0 * mm,
gap_x=0.25 * cm, # base guess (tweak with Δ if needed) gap_x=0.25 * cm,
gap_y=0.0 * cm, # base guess (tweak with Δ if needed) gap_y=0.0 * cm,
margin_left=0.85 * cm, margin_left=0.85 * cm,
margin_top=1.35 * 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: def make_asn_text(prefix: str, number: int, leading_zeros: int) -> str:
@ -156,6 +174,16 @@ def fit_preview_font(draw: ImageDraw.ImageDraw, text: str, max_w: int, max_h: in
return ImageFont.load_default() 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( def draw_label(
c: rl_canvas.Canvas, c: rl_canvas.Canvas,
x: float, x: float,
@ -270,9 +298,10 @@ def generate_pdf(
return pages, current return pages, current
DEFAULT_SETTINGS = { DEFAULT_SETTINGS = {
"layout": DEFAULT_LAYOUT_KEY,
"start": "1", "start": "1",
"mode": "labels", "mode": "labels",
"count": str(AVERY_L4731.labels_per_page), "count": str(LAYOUTS[DEFAULT_LAYOUT_KEY].labels_per_page),
"pages": "1", "pages": "1",
"start_pos": "1", "start_pos": "1",
"prefix": "ASN", "prefix": "ASN",
@ -300,6 +329,8 @@ class App(ttk.Frame):
super().__init__(master, padding=12) super().__init__(master, padding=12)
self.master = master 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_start = tk.StringVar(value=DEFAULT_SETTINGS["start"])
self.var_mode = tk.StringVar(value=DEFAULT_SETTINGS["mode"]) self.var_mode = tk.StringVar(value=DEFAULT_SETTINGS["mode"])
self.var_count = tk.StringVar(value=DEFAULT_SETTINGS["count"]) self.var_count = tk.StringVar(value=DEFAULT_SETTINGS["count"])
@ -318,6 +349,8 @@ class App(ttk.Frame):
self._preview_imgtk: ImageTk.PhotoImage | None = None self._preview_imgtk: ImageTk.PhotoImage | None = None
self._count_entry: ttk.Entry | None = None self._count_entry: ttk.Entry | None = None
self._pages_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._loading_settings = False
self._save_job = None self._save_job = None
@ -328,6 +361,7 @@ class App(ttk.Frame):
# UI einmal bauen # UI einmal bauen
self._build_ui() self._build_ui()
self._refresh_profile_combo()
self._flush_pending_config_status() self._flush_pending_config_status()
self._apply_mode() self._apply_mode()
self._update_preview() self._update_preview()
@ -354,8 +388,9 @@ class App(ttk.Frame):
return base / "paperless-ngx-asn-labels" / "config.json" return base / "paperless-ngx-asn-labels" / "config.json"
def _settings_to_dict(self) -> dict: def _settings_snapshot(self) -> dict:
return { return {
"layout": self.var_layout.get(),
"start": self.var_start.get(), "start": self.var_start.get(),
"mode": self.var_mode.get(), "mode": self.var_mode.get(),
"count": self.var_count.get(), "count": self.var_count.get(),
@ -373,10 +408,19 @@ class App(ttk.Frame):
"calibration_mode": "delta", "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: def _apply_settings_from_dict(self, d: dict) -> None:
# Beim Laden sollen keine Autosaves getriggert werden # Beim Laden sollen keine Autosaves getriggert werden
self._loading_settings = True self._loading_settings = True
try: 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() calibration_mode = str(d.get("calibration_mode", "")).strip().lower()
legacy_absolute = calibration_mode != "delta" legacy_absolute = calibration_mode != "delta"
if calibration_mode == "": if calibration_mode == "":
@ -409,6 +453,7 @@ class App(ttk.Frame):
set_calibration_var("off_y", self.var_off_y) set_calibration_var("off_y", self.var_off_y)
set_calibration_var("pitch_dx", self.var_pitch_dx) set_calibration_var("pitch_dx", self.var_pitch_dx)
set_calibration_var("pitch_dy", self.var_pitch_dy) set_calibration_var("pitch_dy", self.var_pitch_dy)
self._clamp_start_position()
finally: finally:
self._loading_settings = False self._loading_settings = False
@ -418,6 +463,22 @@ class App(ttk.Frame):
if path.exists(): if path.exists():
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict): 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) self._apply_settings_from_dict(data)
else: else:
self._set_config_status(f"Config hat falsches Format, verwende Defaults: {path}", "warn") self._set_config_status(f"Config hat falsches Format, verwende Defaults: {path}", "warn")
@ -452,6 +513,8 @@ class App(ttk.Frame):
def _install_autosave_traces(self) -> None: def _install_autosave_traces(self) -> None:
# alle Variablen bei Änderung -> save schedulen # alle Variablen bei Änderung -> save schedulen
vars_to_watch = [ vars_to_watch = [
self.var_layout,
self.var_profile,
self.var_start, self.var_start,
self.var_mode, self.var_mode,
self.var_count, self.var_count,
@ -475,7 +538,7 @@ class App(ttk.Frame):
v.trace("w", lambda *_: self._schedule_save()) v.trace("w", lambda *_: self._schedule_save())
def _build_ui(self) -> None: def _build_ui(self) -> None:
self.master.title(f"paperless-ngx ASN Label Generator v{__version__} (A4 / Avery L4731)") self.master.title(f"paperless-ngx ASN Label Generator v{__version__}")
self.grid(sticky="nsew") self.grid(sticky="nsew")
self.master.columnconfigure(0, weight=1) self.master.columnconfigure(0, weight=1)
self.master.rowconfigure(0, weight=1) self.master.rowconfigure(0, weight=1)
@ -507,23 +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="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.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") ttk.Label(frm, text="Layout").grid(row=2, column=0, sticky="w", padx=(10, 6), pady=6)
add_row("Anzahl A4 Blätter", self.var_pages, 3, "z.B. 3 → erzeugt 3 Seiten", store="pages") layout_box = ttk.Combobox(
add_row("Startposition auf Bogen", self.var_start_pos, 4, f"1..{AVERY_L4731.labels_per_page} (z.B. 37 bei angebrochenem Bogen)") 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("<<ComboboxSelected>>", self._on_layout_changed)
add_row("Prefix", self.var_prefix, 5, "Muss zu PAPERLESS_CONSUMER_ASN_BARCODE_PREFIX passen") add_row("Anzahl Labels", self.var_count, 3, "Labels pro A4-Seite sind layoutabhängig", store="count")
add_row("Führende Nullen", self.var_zeros, 6, "z.B. 5 → ASN00001") 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=7, 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 = ttk.Combobox(frm, textvariable=self.var_kind, values=["QR", "CODE128"], state="readonly", width=12)
cmb.grid(row=7, column=1, sticky="w", padx=(0, 10), pady=6) cmb.grid(row=8, column=1, sticky="w", padx=(0, 10), pady=6)
cmb.bind("<<ComboboxSelected>>", lambda _e: self._update_preview()) cmb.bind("<<ComboboxSelected>>", lambda _e: self._update_preview())
chk = ttk.Checkbutton(frm, text="Rahmen um Labels zeichnen (Kalibrierung)", variable=self.var_border, command=self._update_preview) chk = ttk.Checkbutton(frm, text="Rahmen um Labels zeichnen (Kalibrierung)", variable=self.var_border, command=self._update_preview)
chk.grid(row=7, 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 = ttk.LabelFrame(frm, text="Kalibrierung (mm)")
cal.grid(row=8, 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): for i in range(6):
cal.columnconfigure(i, weight=1) cal.columnconfigure(i, weight=1)
@ -575,7 +649,7 @@ class App(ttk.Frame):
ttk.Button(btns, text="Config löschen", command=self.on_reset_config).grid( ttk.Button(btns, text="Config löschen", command=self.on_reset_config).grid(
row=1, column=0, columnspan=2, sticky="we", pady=(6, 0) row=1, column=0, columnspan=2, sticky="we", pady=(6, 0)
) )
ttk.Label(btns, text="Nach PDF öffnen").grid(row=2, column=0, sticky="w", pady=(8, 0)) ttk.Label(btns, text="Nach PDF-Erstellung öffnen").grid(row=2, column=0, sticky="w", pady=(8, 0))
cmb_open = ttk.Combobox( cmb_open = ttk.Combobox(
btns, btns,
textvariable=self.var_post_open, textvariable=self.var_post_open,
@ -584,6 +658,21 @@ class App(ttk.Frame):
width=12, width=12,
) )
cmb_open.grid(row=2, column=1, sticky="e", pady=(8, 0)) 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=STATUS_COLORS["info"], wraplength=900, justify="left")
self.lbl_status.grid(row=1, column=0, columnspan=2, sticky="we", pady=(10, 0)) self.lbl_status.grid(row=1, column=0, columnspan=2, sticky="we", pady=(10, 0))
@ -616,6 +705,104 @@ class App(ttk.Frame):
if label is not None: if label is not None:
label.configure(wraplength=width) 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: def _apply_mode(self) -> None:
mode = self.var_mode.get() mode = self.var_mode.get()
if self._count_entry is not None: if self._count_entry is not None:
@ -644,15 +831,17 @@ class App(ttk.Frame):
return None return None
def _start_position(self) -> int: 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") pos = self._parse_int(self.var_start_pos.get(), "Startposition auf Bogen")
if not (1 <= pos <= AVERY_L4731.labels_per_page): if not (1 <= pos <= labels_per_page):
raise ValueError(f"Startposition auf Bogen muss zwischen 1 und {AVERY_L4731.labels_per_page} liegen.") raise ValueError(f"Startposition auf Bogen muss zwischen 1 und {labels_per_page} liegen.")
return pos return pos
def _pages_for_count(self, count: int, start_position: int) -> int: def _pages_for_count(self, count: int, start_position: int) -> int:
first_page_capacity = AVERY_L4731.labels_per_page - (start_position - 1) 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) remaining_after_first = max(0, count - first_page_capacity)
return 1 + math.ceil(remaining_after_first / AVERY_L4731.labels_per_page) return 1 + math.ceil(remaining_after_first / labels_per_page)
def _effective_count_and_pages(self) -> tuple[int, int]: def _effective_count_and_pages(self) -> tuple[int, int]:
start_position = self._start_position() start_position = self._start_position()
@ -661,10 +850,11 @@ class App(ttk.Frame):
pages = self._parse_int(self.var_pages.get(), "Anzahl A4 Blätter") pages = self._parse_int(self.var_pages.get(), "Anzahl A4 Blätter")
if pages <= 0: if pages <= 0:
raise ValueError("Anzahl A4 Blätter muss > 0 sein.") raise ValueError("Anzahl A4 Blätter muss > 0 sein.")
first_page_capacity = AVERY_L4731.labels_per_page - (start_position - 1) labels_per_page = self._layout().labels_per_page
first_page_capacity = labels_per_page - (start_position - 1)
# Im Seitenmodus exakt N Blätter erzeugen: # Im Seitenmodus exakt N Blätter erzeugen:
# erstes Blatt ab Startposition auffüllen, danach volle Blätter. # erstes Blatt ab Startposition auffüllen, danach volle Blätter.
count = first_page_capacity + (pages - 1) * AVERY_L4731.labels_per_page count = first_page_capacity + (pages - 1) * labels_per_page
return count, pages return count, pages
else: else:
count = self._parse_int(self.var_count.get(), "Anzahl Labels") count = self._parse_int(self.var_count.get(), "Anzahl Labels")
@ -711,11 +901,13 @@ class App(ttk.Frame):
count, pages = self._effective_count_and_pages() count, pages = self._effective_count_and_pages()
start_pos = self._start_position() start_pos = self._start_position()
layout = self._layout()
off_x_delta, off_y_delta, pdx_delta, pdy_delta = self._calibration_delta() off_x_delta, off_y_delta, pdx_delta, pdy_delta = self._calibration_delta()
off_x, off_y, pdx, pdy = self._calibration() off_x, off_y, pdx, pdy = self._calibration()
self._set_status( self._set_status(
( (
f"Beispiel: {text} | {count} Labels | Startposition {start_pos} | Ausgabe {pages} Seiten" 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" | 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" f" | Effektiv Offset({off_x},{off_y})mm PitchΔ({pdx},{pdy})mm"
), ),
@ -773,10 +965,11 @@ class App(ttk.Frame):
count, _pages = self._effective_count_and_pages() count, _pages = self._effective_count_and_pages()
start_pos = self._start_position() start_pos = self._start_position()
layout = self._layout()
kind: BarcodeKind = "QR" if self.var_kind.get() == "QR" else "CODE128" kind: BarcodeKind = "QR" if self.var_kind.get() == "QR" else "CODE128"
off_x, off_y, pdx, pdy = self._calibration() 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( out = filedialog.asksaveasfilename(
title="PDF speichern", title="PDF speichern",
defaultextension=".pdf", defaultextension=".pdf",
@ -793,7 +986,7 @@ class App(ttk.Frame):
prefix=prefix, prefix=prefix,
leading_zeros=zeros, leading_zeros=zeros,
kind=kind, kind=kind,
layout=AVERY_L4731, layout=layout,
draw_border=self.var_border.get(), draw_border=self.var_border.get(),
offset_x_mm=off_x, offset_x_mm=off_x,
offset_y_mm=off_y, offset_y_mm=off_y,
@ -805,7 +998,7 @@ class App(ttk.Frame):
self.var_start.set(str(next_number)) self.var_start.set(str(next_number))
self._update_preview() self._update_preview()
self._set_status( self._set_status(
f"PDF: {out} | Seiten: {pages_generated} | Labels: {count} | Startposition: {start_pos} | Nächster Start: {next_number}", f"PDF: {out} | Layout: {self._layout_key()} | Seiten: {pages_generated} | Labels: {count} | Startposition: {start_pos} | Nächster Start: {next_number}",
"success", "success",
) )
try: try:
@ -817,13 +1010,7 @@ class App(ttk.Frame):
messagebox.showerror("Fehler", str(e)) messagebox.showerror("Fehler", str(e))
def _open_path(self, path: str) -> None: def _open_path(self, path: str) -> None:
if os.name == "nt": open_path(path)
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 _open_after_generate(self, output_pdf: str) -> None: def _open_after_generate(self, output_pdf: str) -> None:
mode = self.var_post_open.get().strip().lower() mode = self.var_post_open.get().strip().lower()
@ -864,6 +1051,9 @@ class App(ttk.Frame):
return return
# 2) Defaults anwenden (ohne Autosave-Trigger-Chaos) # 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) self._apply_settings_from_dict(DEFAULT_SETTINGS)
# UI/Preview aktualisieren # UI/Preview aktualisieren
@ -876,7 +1066,7 @@ class App(ttk.Frame):
messagebox.showinfo("OK", f"Zurückgesetzt.\n\nConfig-Pfad:\n{path}") messagebox.showinfo("OK", f"Zurückgesetzt.\n\nConfig-Pfad:\n{path}")
def main() -> None: def _run_gui() -> int:
root = tk.Tk() root = tk.Tk()
try: try:
style = ttk.Style() style = ttk.Style()
@ -889,7 +1079,110 @@ def main() -> None:
root.geometry("1160x620") root.geometry("1160x620")
root.minsize(900, 460) root.minsize(900, 460)
root.mainloop() 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__": if __name__ == "__main__":
main() raise SystemExit(main())