mirror of
https://github.com/AxonByteDev/paperless-asn-label-generator.git
synced 2026-08-06 08:42:03 +00:00
Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d07abc8cab | ||
|
|
d2f1f3c6b0 | ||
|
|
4c669debb5 | ||
|
|
ae067c5d92 | ||
|
|
3728f0df51 | ||
|
|
07124f52e7 | ||
|
|
739813b990 |
7 changed files with 620 additions and 94 deletions
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -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.
|
||||||
|
|
|
||||||
48
README.md
48
README.md
|
|
@ -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 |
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "0.1.3"
|
__version__ = "0.1.6"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from .app import main
|
from .app import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
raise SystemExit(main())
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
@ -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" | {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"
|
||||||
)
|
),
|
||||||
|
"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:
|
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))
|
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())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue