add start-position printing, post-open options, subprocess opener, exact A4 sheet mode (v0.1.5)

This commit is contained in:
MrLetsPlay4Fun 2026-02-08 09:12:05 +01:00
parent ae067c5d92
commit 4c669debb5
4 changed files with 103 additions and 25 deletions

View file

@ -1,5 +1,12 @@
# Changelog # Changelog
## 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 ## 0.1.4 - 2026-02-08
### Changed ### Changed
- Vorschau für `CODE128` rendert jetzt als echter Barcode statt Platzhalter. - Vorschau für `CODE128` rendert jetzt als echter Barcode statt Platzhalter.

View file

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

View file

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

View file

@ -19,6 +19,7 @@ import math
import os import os
import sys import sys
import json import json
import subprocess
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from dataclasses import dataclass from dataclasses import dataclass
@ -209,6 +210,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)
@ -221,10 +223,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)
@ -237,12 +243,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
@ -252,8 +262,9 @@ 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
@ -263,9 +274,11 @@ DEFAULT_SETTINGS = {
"mode": "labels", "mode": "labels",
"count": str(AVERY_L4731.labels_per_page), "count": str(AVERY_L4731.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",
@ -291,9 +304,11 @@ class App(ttk.Frame):
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"])
@ -345,9 +360,11 @@ class App(ttk.Frame):
"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(),
@ -382,9 +399,11 @@ 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)
@ -437,9 +456,11 @@ class App(ttk.Frame):
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,
@ -488,20 +509,21 @@ class App(ttk.Frame):
add_row("Anzahl Labels", self.var_count, 2, f"{AVERY_L4731.labels_per_page} Labels pro A4-Seite", store="count") 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 A4 Blätter", self.var_pages, 3, "z.B. 3 → erzeugt 3 Seiten", store="pages")
add_row("Startposition auf Bogen", self.var_start_pos, 4, f"1..{AVERY_L4731.labels_per_page} (z.B. 37 bei angebrochenem Bogen)")
add_row("Prefix", self.var_prefix, 4, "Muss zu PAPERLESS_CONSUMER_ASN_BARCODE_PREFIX passen") add_row("Prefix", self.var_prefix, 5, "Muss zu PAPERLESS_CONSUMER_ASN_BARCODE_PREFIX passen")
add_row("Führende Nullen", self.var_zeros, 5, "z.B. 5 → ASN00001") add_row("Führende Nullen", self.var_zeros, 6, "z.B. 5 → ASN00001")
ttk.Label(frm, text="Code-Typ").grid(row=6, column=0, sticky="w", padx=(10, 6), pady=6) ttk.Label(frm, text="Code-Typ").grid(row=7, 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=7, 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=7, 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=8, 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)
@ -553,6 +575,15 @@ class App(ttk.Frame):
ttk.Button(btns, text="Config löschen", command=self.on_reset_config).grid( ttk.Button(btns, text="Config löschen", command=self.on_reset_config).grid(
row=1, column=0, columnspan=2, sticky="we", pady=(6, 0) row=1, column=0, columnspan=2, sticky="we", pady=(6, 0)
) )
ttk.Label(btns, text="Nach PDF öffnen").grid(row=2, column=0, sticky="w", pady=(8, 0))
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))
self.lbl_status = ttk.Label(self, text="", foreground=STATUS_COLORS["info"], wraplength=900, justify="left") self.lbl_status = ttk.Label(self, text="", foreground=STATUS_COLORS["info"], wraplength=900, justify="left")
self.lbl_status.grid(row=1, column=0, columnspan=2, sticky="we", pady=(10, 0)) self.lbl_status.grid(row=1, column=0, columnspan=2, sticky="we", pady=(10, 0))
@ -612,19 +643,34 @@ class App(ttk.Frame):
except Exception: except Exception:
return None return None
def _start_position(self) -> int:
pos = self._parse_int(self.var_start_pos.get(), "Startposition auf Bogen")
if not (1 <= pos <= AVERY_L4731.labels_per_page):
raise ValueError(f"Startposition auf Bogen muss zwischen 1 und {AVERY_L4731.labels_per_page} liegen.")
return pos
def _pages_for_count(self, count: int, start_position: int) -> int:
first_page_capacity = AVERY_L4731.labels_per_page - (start_position - 1)
remaining_after_first = max(0, count - first_page_capacity)
return 1 + math.ceil(remaining_after_first / AVERY_L4731.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 first_page_capacity = AVERY_L4731.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) * AVERY_L4731.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:
@ -664,11 +710,12 @@ 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()
off_x_delta, off_y_delta, pdx_delta, pdy_delta = self._calibration_delta() off_x_delta, off_y_delta, pdx_delta, pdy_delta = self._calibration_delta()
off_x, off_y, pdx, pdy = self._calibration() off_x, off_y, pdx, pdy = self._calibration()
self._set_status( self._set_status(
( (
f"Beispiel: {text} | {count} Labels {pages} Seiten" f"Beispiel: {text} | {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"
), ),
@ -725,6 +772,7 @@ 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()
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()
@ -751,27 +799,50 @@ class App(ttk.Frame):
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.var_start.set(str(next_number)) self.var_start.set(str(next_number))
self._update_preview() self._update_preview()
self._set_status( self._set_status(
f"PDF: {out} | Seiten: {pages_generated} | Labels: {count} | Nächster Start: {next_number}", f"PDF: {out} | Seiten: {pages_generated} | Labels: {count} | Startposition: {start_pos} | Nächster Start: {next_number}",
"success", "success",
) )
try:
self._open_after_generate(out)
except Exception as e:
self._set_status(f"PDF erzeugt, aber Öffnen fehlgeschlagen: {e}", "warn")
except Exception as e: except Exception as e:
self._set_status(str(e), "error") self._set_status(str(e), "error")
messagebox.showerror("Fehler", str(e)) messagebox.showerror("Fehler", str(e))
def _open_path(self, 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 _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: