Compare commits

...

7 commits
v0.1.3 ... main

Author SHA1 Message Date
MrLetsPlay4Fun
d07abc8cab Change readme picture 2026-02-08 10:20:34 +01:00
MrLetsPlay4Fun
d2f1f3c6b0 bump 0.1.6: layouts, profiles, cli, profile export 2026-02-08 09:38:05 +01:00
MrLetsPlay4Fun
4c669debb5 add start-position printing, post-open options, subprocess opener, exact A4 sheet mode (v0.1.5) 2026-02-08 09:12:05 +01:00
MrLetsPlay4Fun
ae067c5d92 Merge branch 'main' of https://github.com/MrLetsPlay4Fun/paperless-asn-label-generator 2026-02-08 08:57:15 +01:00
MrLetsPlay4Fun
3728f0df51 Edit picrure 2026-02-08 08:57:09 +01:00
MrLetsPlay4Fun
07124f52e7
Add files via upload 2026-02-08 08:54:36 +01:00
MrLetsPlay4Fun
739813b990 fix(ui): widen window and preview layout 2026-02-08 08:50:07 +01:00
7 changed files with 620 additions and 94 deletions

View file

@ -1,5 +1,28 @@
# 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
### 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 ## 0.1.3 - 2026-02-08
### Changed ### Changed
- Kalibrierungs-Eingaben sind jetzt als sichtbare Delta-Werte ausgelegt (Default `0.0` in der UI), während intern weiterhin die Basiswerte genutzt werden. - Kalibrierungs-Eingaben sind jetzt als sichtbare Delta-Werte ausgelegt (Default `0.0` in der UI), während intern weiterhin die Basiswerte genutzt werden.

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).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Before After
Before After

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.3" 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.3" __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,26 +19,45 @@ import math
import os import os
import sys import sys
import json import json
import argparse
import subprocess
from io import BytesIO
from pathlib import Path from pathlib import Path
from dataclasses import dataclass 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
from reportlab.lib.units import mm, cm from reportlab.lib.units import mm, cm
from reportlab.lib.utils import ImageReader 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 import qrcode
from PIL import Image, ImageTk from PIL import Image, ImageTk, ImageDraw, ImageFont
from . import __version__ from . import __version__
BarcodeKind = Literal["QR", "CODE128"] 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) @dataclass(frozen=True)
@ -66,19 +85,36 @@ class SheetLayout:
return self.label_h + self.gap_y return self.label_h + self.gap_y
# NOTE: Many shops/templates call this "L4731" (25.4 x 10mm, 7x27 = 189 labels). LAYOUTS: dict[str, SheetLayout] = {
# If your sheet is slightly different, use calibration below. # NOTE: Many shops/templates call this "L4731" (25.4 x 10mm, 7x27 = 189 labels).
AVERY_L4731 = SheetLayout( "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:
@ -98,6 +134,56 @@ def make_qr_image(data: str, target_px: int) -> Image.Image:
return img.resize((target_px, target_px), Image.Resampling.LANCZOS) 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( def draw_label(
c: rl_canvas.Canvas, c: rl_canvas.Canvas,
x: float, x: float,
@ -152,6 +238,7 @@ def generate_pdf(
offset_y_mm: float = 0.0, # + moves up offset_y_mm: float = 0.0, # + moves up
pitch_dx_mm: float = 0.0, # added to pitch_x (distance between columns) 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) 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]: ) -> Tuple[int, int]:
""" """
Returns: (pages_generated, next_number) Returns: (pages_generated, next_number)
@ -164,10 +251,14 @@ def generate_pdf(
raise ValueError("leading_zeros must be >= 0") raise ValueError("leading_zeros must be >= 0")
if not prefix: if not prefix:
raise ValueError("prefix must not be empty") 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 _page_w, page_h = A4
labels_per_page = layout.labels_per_page first_page_capacity = labels_per_page - (start_position - 1)
pages = math.ceil(count / labels_per_page) remaining_after_first = max(0, count - first_page_capacity)
pages = 1 + math.ceil(remaining_after_first / labels_per_page)
# Effective pitch + offsets (points) # Effective pitch + offsets (points)
pitch_x = layout.pitch_x + (pitch_dx_mm * mm) pitch_x = layout.pitch_x + (pitch_dx_mm * mm)
@ -180,12 +271,16 @@ def generate_pdf(
current = start_number current = start_number
remaining = count remaining = count
for _ in range(pages): for page_idx in range(pages):
n_this_page = min(remaining, labels_per_page) 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): for i in range(n_this_page):
r = i // layout.cols slot = first_slot_zero_based + i
col = i % layout.cols r = slot // layout.cols
col = slot % layout.cols
x = layout.margin_left + col * pitch_x + off_x x = layout.margin_left + col * pitch_x + off_x
y = page_h - layout.margin_top - layout.label_h - r * pitch_y + off_y y = page_h - layout.margin_top - layout.label_h - r * pitch_y + off_y
@ -195,20 +290,24 @@ def generate_pdf(
current += 1 current += 1
c.showPage()
remaining -= n_this_page remaining -= n_this_page
if page_idx < pages - 1:
c.showPage()
c.save() c.save()
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",
"prefix": "ASN", "prefix": "ASN",
"zeros": "7", "zeros": "7",
"kind": "QR", "kind": "QR",
"post_open": "none",
"border": False, "border": False,
# neutrale Kalibrierung für frische Installationen # neutrale Kalibrierung für frische Installationen
"off_x": "0.0", "off_x": "0.0",
@ -230,13 +329,17 @@ 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"])
self.var_pages = tk.StringVar(value=DEFAULT_SETTINGS["pages"]) 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_prefix = tk.StringVar(value=DEFAULT_SETTINGS["prefix"])
self.var_zeros = tk.StringVar(value=DEFAULT_SETTINGS["zeros"]) self.var_zeros = tk.StringVar(value=DEFAULT_SETTINGS["zeros"])
self.var_kind = tk.StringVar(value=DEFAULT_SETTINGS["kind"]) 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_border = tk.BooleanVar(value=DEFAULT_SETTINGS["border"])
self.var_off_x = tk.StringVar(value=DEFAULT_SETTINGS["off_x"]) self.var_off_x = tk.StringVar(value=DEFAULT_SETTINGS["off_x"])
self.var_off_y = tk.StringVar(value=DEFAULT_SETTINGS["off_y"]) self.var_off_y = tk.StringVar(value=DEFAULT_SETTINGS["off_y"])
@ -246,16 +349,19 @@ 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
self._pending_config_status: str | None = None self._pending_config_status: tuple[str, StatusLevel] | None = None
# 1) Laden (überschreibt Defaults, falls config existiert) # 1) Laden (überschreibt Defaults, falls config existiert)
self._load_settings() self._load_settings()
# 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()
@ -282,15 +388,18 @@ 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(),
"pages": self.var_pages.get(), "pages": self.var_pages.get(),
"start_pos": self.var_start_pos.get(),
"prefix": self.var_prefix.get(), "prefix": self.var_prefix.get(),
"zeros": self.var_zeros.get(), "zeros": self.var_zeros.get(),
"kind": self.var_kind.get(), "kind": self.var_kind.get(),
"post_open": self.var_post_open.get(),
"border": bool(self.var_border.get()), "border": bool(self.var_border.get()),
"off_x": self.var_off_x.get(), "off_x": self.var_off_x.get(),
"off_y": self.var_off_y.get(), "off_y": self.var_off_y.get(),
@ -299,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 == "":
@ -325,14 +443,17 @@ class App(ttk.Frame):
if "mode" in d: self.var_mode.set(str(d["mode"])) if "mode" in d: self.var_mode.set(str(d["mode"]))
if "count" in d: self.var_count.set(str(d["count"])) if "count" in d: self.var_count.set(str(d["count"]))
if "pages" in d: self.var_pages.set(str(d["pages"])) 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 "prefix" in d: self.var_prefix.set(str(d["prefix"]))
if "zeros" in d: self.var_zeros.set(str(d["zeros"])) if "zeros" in d: self.var_zeros.set(str(d["zeros"]))
if "kind" in d: self.var_kind.set(str(d["kind"])) 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 "border" in d: self.var_border.set(bool(d["border"]))
set_calibration_var("off_x", self.var_off_x) set_calibration_var("off_x", self.var_off_x)
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
@ -342,12 +463,28 @@ 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}") self._set_config_status(f"Config hat falsches Format, verwende Defaults: {path}", "warn")
except Exception as e: except Exception as e:
# GUI bleibt benutzbar, Fehler wird aber sichtbar gemacht # GUI bleibt benutzbar, Fehler wird aber sichtbar gemacht
self._set_config_status(f"⚠️ Config konnte nicht geladen werden ({path}): {e}") self._set_config_status(f"Config konnte nicht geladen werden ({path}): {e}", "warn")
def _save_settings_now(self) -> None: def _save_settings_now(self) -> None:
if getattr(self, "_loading_settings", False): if getattr(self, "_loading_settings", False):
@ -358,9 +495,9 @@ class App(ttk.Frame):
tmp = path.with_suffix(".tmp") tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(self._settings_to_dict(), indent=2, ensure_ascii=False), encoding="utf-8") tmp.write_text(json.dumps(self._settings_to_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(path) tmp.replace(path)
self._set_config_status("") self._set_config_status("", "info")
except Exception as e: except Exception as e:
self._set_config_status(f"⚠️ Config konnte nicht gespeichert werden ({path}): {e}") self._set_config_status(f"Config konnte nicht gespeichert werden ({path}): {e}", "error")
def _schedule_save(self) -> None: def _schedule_save(self) -> None:
# Debounce: erst nach kurzer Pause speichern # Debounce: erst nach kurzer Pause speichern
@ -376,13 +513,17 @@ 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,
self.var_pages, self.var_pages,
self.var_start_pos,
self.var_prefix, self.var_prefix,
self.var_zeros, self.var_zeros,
self.var_kind, self.var_kind,
self.var_post_open,
self.var_border, self.var_border,
self.var_off_x, self.var_off_x,
self.var_off_y, self.var_off_y,
@ -412,7 +553,9 @@ class App(ttk.Frame):
ent = ttk.Entry(frm, textvariable=var, width=14) ent = ttk.Entry(frm, textvariable=var, width=14)
ent.grid(row=row, column=1, sticky="we", padx=(0, 10), pady=6) ent.grid(row=row, column=1, sticky="we", padx=(0, 10), pady=6)
if hint: 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("<KeyRelease>", lambda _e: self._update_preview()) ent.bind("<KeyRelease>", lambda _e: self._update_preview())
if store == "count": if store == "count":
self._count_entry = ent self._count_entry = ent
@ -427,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="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(
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, 4, "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, 5, "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=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 = 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("<<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=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 = 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): for i in range(6):
cal.columnconfigure(i, weight=1) cal.columnconfigure(i, weight=1)
@ -451,7 +606,9 @@ class App(ttk.Frame):
e = ttk.Entry(cal, textvariable=var, width=10) e = ttk.Entry(cal, textvariable=var, width=10)
e.grid(row=0, column=col + 1, sticky="we", padx=(0, 10), pady=6) e.grid(row=0, column=col + 1, sticky="we", padx=(0, 10), pady=6)
e.bind("<KeyRelease>", lambda _e: self._update_preview()) e.bind("<KeyRelease>", 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 X", self.var_off_x, 0, "+ rechts")
add_cal("Offset Y", self.var_off_y, 3, "+ hoch") add_cal("Offset Y", self.var_off_y, 3, "+ hoch")
@ -461,15 +618,19 @@ class App(ttk.Frame):
e = ttk.Entry(cal, textvariable=var, width=10) e = ttk.Entry(cal, textvariable=var, width=10)
e.grid(row=row, column=col + 1, sticky="we", padx=(0, 10), pady=6) e.grid(row=row, column=col + 1, sticky="we", padx=(0, 10), pady=6)
e.bind("<KeyRelease>", lambda _e: self._update_preview()) e.bind("<KeyRelease>", 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("Spalten-Pitch Δ", self.var_pitch_dx, 1, 0, "→ drift links/rechts")
add_cal2("Zeilen-Pitch Δ", self.var_pitch_dy, 1, 3, "→ top/bottom drift") add_cal2("Zeilen-Pitch Δ", self.var_pitch_dy, 1, 3, "→ top/bottom drift")
right = ttk.Frame(self) right = ttk.Frame(self)
right.grid(row=0, column=1, sticky="nsew", padx=(12, 0)) right.grid(row=0, column=1, sticky="nsew", padx=(12, 0))
self.columnconfigure(0, weight=1)
self.columnconfigure(1, weight=1) self.columnconfigure(1, weight=1)
self.rowconfigure(0, weight=1) self.rowconfigure(0, weight=1)
right.columnconfigure(0, weight=1)
right.rowconfigure(1, weight=1) right.rowconfigure(1, weight=1)
ttk.Label(right, text="Vorschau (1 Label)").grid(row=0, column=0, sticky="w") ttk.Label(right, text="Vorschau (1 Label)").grid(row=0, column=0, sticky="w")
@ -488,23 +649,159 @@ 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-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_status.grid(row=1, column=0, columnspan=2, sticky="we", pady=(10, 0))
self.lbl_config_status = ttk.Label(self, text="", foreground="#b00020") 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.lbl_config_status.grid(row=2, column=0, columnspan=2, sticky="we", pady=(4, 0))
self.bind("<Configure>", self._on_resize)
self.after_idle(self._on_resize)
def _set_config_status(self, message: str) -> None: 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) lbl = getattr(self, "lbl_config_status", None)
if lbl is None: if lbl is None:
self._pending_config_status = message self._pending_config_status = (message, level)
return return
lbl.configure(text=message) text = f"{STATUS_PREFIX[level]} {message}" if message else ""
self._pending_config_status = message lbl.configure(text=text, foreground=STATUS_COLORS[level])
self._pending_config_status = (message, level)
def _flush_pending_config_status(self) -> None: def _flush_pending_config_status(self) -> None:
if self._pending_config_status is not None: if self._pending_config_status is not None:
self._set_config_status(self._pending_config_status) 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: def _apply_mode(self) -> None:
mode = self.var_mode.get() mode = self.var_mode.get()
@ -533,19 +830,37 @@ class App(ttk.Frame):
except Exception: except Exception:
return None 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]: def _effective_count_and_pages(self) -> tuple[int, int]:
start_position = self._start_position()
mode = self.var_mode.get() mode = self.var_mode.get()
if mode == "pages": if mode == "pages":
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.")
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 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")
if count <= 0: if count <= 0:
raise ValueError("Anzahl Labels muss > 0 sein.") 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 return count, pages
def _current_text(self) -> str: def _current_text(self) -> str:
@ -585,45 +900,60 @@ class App(ttk.Frame):
self.preview.configure(image=self._preview_imgtk) self.preview.configure(image=self._preview_imgtk)
count, pages = self._effective_count_and_pages() 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_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.lbl_status.configure( self._set_status(
text=( (
f"Beispiel: {text} | {count} Labels ≈ {pages} Seiten" f"Beispiel: {text} | Layout {self._layout_key()} ({layout.labels_per_page}/Seite)"
f" | Eingabe Offset({off_x_delta},{off_y_delta})mm PitchΔ({pdx_delta},{pdy_delta})mm" f" | {count} Labels | Startposition {start_pos} | Ausgabe {pages} Seiten"
f" | Effektiv Offset({off_x},{off_y})mm PitchΔ({pdx},{pdy})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"
),
"info",
) )
except Exception as e: except Exception as e:
self.preview.configure(image="") 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: def _render_preview_image(self, text: str, kind: BarcodeKind, border: bool) -> Image.Image:
scale = 6 preview_w, preview_h = 440, 180
w = int((AVERY_L4731.label_w / mm) * scale * 10) img = Image.new("RGB", (preview_w, preview_h), "white")
h = int((AVERY_L4731.label_h / mm) * scale * 10) draw = ImageDraw.Draw(img)
img = Image.new("RGB", (w, h), "white")
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": if kind == "QR":
size = int(h * 0.90) qr = make_qr_image(text, max(100, code_box_h))
qr = make_qr_image(text, max(120, size)) img.paste(qr, (code_x, code_y))
img.paste(qr, (10, (h - qr.size[1]) // 2))
else: else:
box = Image.new("RGB", (int(h * 0.90), int(h * 0.90)), "white") barcode = make_code128_preview_image(
img.paste(box, (10, (h - box.size[1]) // 2)) 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 text_x = code_x + code_box_w + pad
draw = ImageDraw.Draw(img) text_max_w = max(40, preview_w - text_x - pad)
try: text_max_h = max(20, preview_h - (2 * pad))
font = ImageFont.truetype("DejaVuSans.ttf", 18) font = fit_preview_font(draw, text, text_max_w, text_max_h)
except Exception: text_bbox = draw.textbbox((0, 0), text, font=font)
font = ImageFont.load_default() text_h = text_bbox[3] - text_bbox[1]
draw.text((int(h * 0.95), (h - 18) // 2), text, fill="black", font=font) text_y = max(pad, (preview_h - text_h) // 2)
draw.text((text_x, text_y), text, fill="black", font=font)
if border: 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: def on_generate_pdf(self) -> None:
try: try:
@ -634,10 +964,12 @@ class App(ttk.Frame):
raise ValueError("Prefix darf nicht leer sein.") raise ValueError("Prefix darf nicht leer sein.")
count, _pages = self._effective_count_and_pages() 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" 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",
@ -654,31 +986,50 @@ 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,
pitch_dx_mm=pdx, pitch_dx_mm=pdx,
pitch_dy_mm=pdy, 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.var_start.set(str(next_number))
self._update_preview() 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: except Exception as e:
self._set_status(str(e), "error")
messagebox.showerror("Fehler", str(e)) 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: def on_open_folder(self) -> None:
folder = os.getcwd() folder = os.getcwd()
try: try:
if os.name == "nt": self._open_path(folder)
os.startfile(folder) # type: ignore[attr-defined] except Exception as e:
elif os.name == "posix": self._set_status(f"Ordner konnte nicht geöffnet werden: {e}", "warn")
cmd = "open" if sys.platform == "darwin" else "xdg-open"
os.system(f'{cmd} "{folder}"')
except Exception:
messagebox.showinfo("Info", f"Ordner: {folder}") messagebox.showinfo("Info", f"Ordner: {folder}")
def on_reset_config(self) -> None: def on_reset_config(self) -> None:
@ -700,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
@ -708,10 +1062,11 @@ class App(ttk.Frame):
# 3) Optional: Defaults direkt wieder speichern (damit nächster Start garantiert Default hat) # 3) Optional: Defaults direkt wieder speichern (damit nächster Start garantiert Default hat)
self._save_settings_now() 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}") 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()
@ -721,9 +1076,113 @@ def main() -> None:
pass pass
App(root) App(root)
root.minsize(900, 430) root.geometry("1160x620")
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())