mirror of
https://github.com/AxonByteDev/paperless-asn-label-generator.git
synced 2026-08-06 08:42:03 +00:00
Compare commits
No commits in common. "main" and "v0.1.4" have entirely different histories.
7 changed files with 51 additions and 474 deletions
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -1,20 +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.
|
||||
|
|
|
|||
48
README.md
48
README.md
|
|
@ -1,13 +1,10 @@
|
|||
# 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**.
|
||||
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)**.
|
||||
|
||||
✅ Ideal, um in paperless-ngx sauber mit ASN-Barcodes zu arbeiten
|
||||
✅ Mit **Kalibrierung** (Offset X/Y + Pitch-Drift) für exakte Druckausrichtung
|
||||
✅ **Persistente Einstellungen**: Drucker-Kalibrierwerte werden automatisch gespeichert
|
||||
✅ **Mehrere Layouts** (z. B. `L4731`, `L7160`)
|
||||
✅ **Drucker-Profile** speichern/laden (ohne JSON-Edit)
|
||||
✅ **CLI-Modus** für Automatisierung
|
||||
✅ **Reset-Button**: “Config löschen” setzt alles auf Standardwerte zurück
|
||||
|
||||
> Hinweis: Inoffizielles Tool, nicht affiliated mit paperless-ngx.
|
||||
|
|
@ -137,56 +134,15 @@ pip install -U --force-reinstall git+https://github.com/MrLetsPlay4Fun/paperless
|
|||
## 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)
|
||||
- „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).
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 63 KiB |
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "paperless-asn-label-generator"
|
||||
version = "0.1.6"
|
||||
version = "0.1.4"
|
||||
description = "GUI tool to generate ASN barcode/QR labels for paperless-ngx (A4/Avery L4731) with calibration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
__all__ = ["__version__"]
|
||||
__version__ = "0.1.6"
|
||||
__version__ = "0.1.4"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from .app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -19,15 +19,13 @@ 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
|
||||
|
|
@ -85,36 +83,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:
|
||||
|
|
@ -174,16 +155,6 @@ def fit_preview_font(draw: ImageDraw.ImageDraw, text: str, max_w: int, max_h: in
|
|||
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 +209,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 +221,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 +237,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,24 +252,20 @@ 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",
|
||||
|
|
@ -329,17 +287,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,8 +303,6 @@ 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
|
||||
|
|
@ -361,7 +313,6 @@ class App(ttk.Frame):
|
|||
|
||||
# UI einmal bauen
|
||||
self._build_ui()
|
||||
self._refresh_profile_combo()
|
||||
self._flush_pending_config_status()
|
||||
self._apply_mode()
|
||||
self._update_preview()
|
||||
|
|
@ -388,18 +339,15 @@ 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(),
|
||||
|
|
@ -408,19 +356,10 @@ class App(ttk.Frame):
|
|||
"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 == "":
|
||||
|
|
@ -443,17 +382,14 @@ class App(ttk.Frame):
|
|||
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()
|
||||
finally:
|
||||
self._loading_settings = False
|
||||
|
||||
|
|
@ -463,22 +399,6 @@ 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")
|
||||
|
|
@ -513,17 +433,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 +454,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(f"paperless-ngx ASN Label Generator v{__version__} (A4 / Avery L4731)")
|
||||
self.grid(sticky="nsew")
|
||||
self.master.columnconfigure(0, weight=1)
|
||||
self.master.rowconfigure(0, weight=1)
|
||||
|
|
@ -570,34 +486,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("<<ComboboxSelected>>", 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("<<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.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)
|
||||
|
||||
|
|
@ -649,30 +553,6 @@ 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.grid(row=1, column=0, columnspan=2, sticky="we", pady=(10, 0))
|
||||
|
|
@ -705,104 +585,6 @@ class App(ttk.Frame):
|
|||
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()
|
||||
if self._count_entry is not None:
|
||||
|
|
@ -830,37 +612,19 @@ class App(ttk.Frame):
|
|||
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:
|
||||
|
|
@ -900,14 +664,11 @@ 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"Beispiel: {text} | {count} Labels ≈ {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"
|
||||
),
|
||||
|
|
@ -964,12 +725,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 +745,33 @@ 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.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}",
|
||||
f"PDF: {out} | Seiten: {pages_generated} | Labels: {count} | 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 +793,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
|
||||
|
|
@ -1066,7 +805,7 @@ class App(ttk.Frame):
|
|||
|
||||
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()
|
||||
|
|
@ -1079,110 +818,7 @@ def _run_gui() -> int:
|
|||
root.geometry("1160x620")
|
||||
root.minsize(900, 460)
|
||||
root.mainloop()
|
||||
return 0
|
||||
|
||||
|
||||
def _build_arg_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="paperless-asn-labels",
|
||||
description="ASN Label PDF Generator (GUI + optional CLI mode).",
|
||||
)
|
||||
p.add_argument("--cli", action="store_true", help="CLI-Modus verwenden (ohne GUI).")
|
||||
p.add_argument("--output", help="Ausgabedatei (.pdf) für CLI.")
|
||||
p.add_argument("--layout", choices=sorted(LAYOUTS.keys()), default=DEFAULT_LAYOUT_KEY, help="Layout-Profil (CLI).")
|
||||
p.add_argument("--start", type=int, default=1, help="Start-Nummer (CLI).")
|
||||
p.add_argument("--prefix", default="ASN", help="ASN Prefix (CLI).")
|
||||
p.add_argument("--zeros", type=int, default=7, help="Führende Nullen (CLI).")
|
||||
p.add_argument("--kind", choices=["QR", "CODE128"], default="QR", help="Barcode-Typ (CLI).")
|
||||
p.add_argument("--start-position", type=int, default=1, help="Startposition auf dem ersten Blatt (CLI).")
|
||||
p.add_argument("--count", type=int, help="Anzahl Labels (CLI).")
|
||||
p.add_argument("--pages", type=int, help="Anzahl A4-Blätter (CLI, exakt).")
|
||||
p.add_argument("--border", action="store_true", help="Labelrahmen zeichnen (CLI).")
|
||||
p.add_argument("--offset-x", type=float, default=BASE_CALIBRATION_MM["off_x"], help="Effektiver Offset X in mm (CLI).")
|
||||
p.add_argument("--offset-y", type=float, default=BASE_CALIBRATION_MM["off_y"], help="Effektiver Offset Y in mm (CLI).")
|
||||
p.add_argument("--pitch-dx", type=float, default=BASE_CALIBRATION_MM["pitch_dx"], help="Effektiver Pitch Δ X in mm (CLI).")
|
||||
p.add_argument("--pitch-dy", type=float, default=BASE_CALIBRATION_MM["pitch_dy"], help="Effektiver Pitch Δ Y in mm (CLI).")
|
||||
p.add_argument("--open", choices=["none", "file", "folder"], default="none", help="Nach CLI-PDF öffnen.")
|
||||
return p
|
||||
|
||||
|
||||
def _run_cli(args: argparse.Namespace) -> int:
|
||||
if not args.output:
|
||||
raise ValueError("Im CLI-Modus ist --output erforderlich.")
|
||||
if args.count is not None and args.pages is not None:
|
||||
raise ValueError("Bitte nur --count ODER --pages angeben, nicht beides.")
|
||||
|
||||
layout = LAYOUTS[args.layout]
|
||||
if args.pages is not None:
|
||||
if args.pages <= 0:
|
||||
raise ValueError("--pages muss > 0 sein.")
|
||||
first_page_capacity = layout.labels_per_page - (args.start_position - 1)
|
||||
count = first_page_capacity + (args.pages - 1) * layout.labels_per_page
|
||||
elif args.count is not None:
|
||||
if args.count <= 0:
|
||||
raise ValueError("--count muss > 0 sein.")
|
||||
count = args.count
|
||||
else:
|
||||
count = layout.labels_per_page
|
||||
|
||||
pages_generated, next_number = generate_pdf(
|
||||
output_path=args.output,
|
||||
start_number=args.start,
|
||||
count=count,
|
||||
prefix=args.prefix,
|
||||
leading_zeros=args.zeros,
|
||||
kind="QR" if args.kind == "QR" else "CODE128",
|
||||
layout=layout,
|
||||
draw_border=bool(args.border),
|
||||
offset_x_mm=args.offset_x,
|
||||
offset_y_mm=args.offset_y,
|
||||
pitch_dx_mm=args.pitch_dx,
|
||||
pitch_dy_mm=args.pitch_dy,
|
||||
start_position=args.start_position,
|
||||
)
|
||||
print(
|
||||
f"OK: {args.output} | layout={args.layout} | pages={pages_generated} | labels={count} | next={next_number}"
|
||||
)
|
||||
if args.open == "file":
|
||||
open_path(args.output)
|
||||
elif args.open == "folder":
|
||||
open_path(str(Path(args.output).resolve().parent))
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = _build_arg_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
cli_options_used = any(
|
||||
[
|
||||
args.output is not None,
|
||||
args.count is not None,
|
||||
args.pages is not None,
|
||||
args.layout != DEFAULT_LAYOUT_KEY,
|
||||
args.start != 1,
|
||||
args.prefix != "ASN",
|
||||
args.zeros != 7,
|
||||
args.kind != "QR",
|
||||
args.start_position != 1,
|
||||
args.border,
|
||||
args.offset_x != BASE_CALIBRATION_MM["off_x"],
|
||||
args.offset_y != BASE_CALIBRATION_MM["off_y"],
|
||||
args.pitch_dx != BASE_CALIBRATION_MM["pitch_dx"],
|
||||
args.pitch_dy != BASE_CALIBRATION_MM["pitch_dy"],
|
||||
args.open != "none",
|
||||
]
|
||||
)
|
||||
|
||||
if args.cli or cli_options_used:
|
||||
try:
|
||||
return _run_cli(args)
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
return _run_gui()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
main()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue