From 1b9bafdc4aec2b3269f7b1270a5cb6f740885546 Mon Sep 17 00:00:00 2001 From: MrLetsPlay4Fun Date: Tue, 3 Feb 2026 12:55:24 +0100 Subject: [PATCH] Initial release --- .gitignore | 23 + LICENSE | 21 + README.md | 161 ++++++ pyproject.toml | 34 ++ src/paperless_asn_label_generator/__init__.py | 2 + src/paperless_asn_label_generator/__main__.py | 4 + src/paperless_asn_label_generator/app.py | 495 ++++++++++++++++++ 7 files changed, 740 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/paperless_asn_label_generator/__init__.py create mode 100644 src/paperless_asn_label_generator/__main__.py create mode 100644 src/paperless_asn_label_generator/app.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09c3cc8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so +*.egg-info/ +dist/ +build/ +.eggs/ + +# Virtualenv +.venv/ +venv/ +ENV/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..834f4fb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MrLetsPlay4Fun + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d7caee --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# paperless-ngx ASN Label Generator (GUI) – Avery L4731 / L4731REV + +GUI-Tool (Tkinter), das ASN-Labels als PDF erzeugt – QR oder Code128 – inkl. Kalibrierung (Offset & Pitch-Drift) für saubere Ausdrucke auf echten Etikettenbögen. + +> Hinweis: Inoffizielles Tool, nicht affiliated mit paperless-ngx. + +--- + +## Voraussetzungen + +- **Python 3.10+** +- **Git** (nur nötig, wenn du direkt von GitHub installieren willst) +- Auf **Linux** ggf. zusätzlich: **Tkinter** (siehe unten) + +--- + +## Installation & Start + +### Windows (empfohlen) + +#### 1) Python installieren +- Lade Python von der offiziellen Seite. +- **Wichtig:** Beim Installer unbedingt **„Add Python to PATH“** aktivieren. + +Prüfen: +```bash +python --version +pip --version +``` + +#### 2) Installation direkt von GitHub +```bash +pip install git+https://github.com/DEINNAME/paperless-asn-label-generator +paperless-asn-labels +``` + +> Wenn `pip` meldet, dass `git` fehlt: Git für Windows installieren, dann nochmal versuchen. + +--- + +### macOS + +#### 1) Python prüfen/ installieren +Prüfen: +```bash +python3 --version +pip3 --version +``` + +Falls Python fehlt oder zu alt ist, am einfachsten über Homebrew: +```bash +brew install python +``` + +#### 2) Installation direkt von GitHub +```bash +pip3 install git+https://github.com/DEINNAME/paperless-asn-label-generator +paperless-asn-labels +``` + +> Falls `paperless-asn-labels` nicht gefunden wird, starte einmal neu das Terminal oder nutze: +```bash +python3 -m paperless_asn_label_generator +``` + +--- + +### Linux + +#### 1) Python + pip prüfen +```bash +python3 --version +pip3 --version +``` + +Falls nötig (Debian/Ubuntu): +```bash +sudo apt-get update +sudo apt-get install -y python3 python3-pip +``` + +#### 2) Tkinter installieren (wichtig für GUI) +Debian/Ubuntu: +```bash +sudo apt-get install -y python3-tk +``` + +Fedora: +```bash +sudo dnf install -y python3-tkinter +``` + +Arch: +```bash +sudo pacman -S tk +``` + +#### 3) Installation direkt von GitHub +```bash +pip3 install git+https://github.com/DEINNAME/paperless-asn-label-generator +paperless-asn-labels +``` + +--- + +## Nutzung + +- Start-Nummer wählen (z.B. 1 oder 190) +- Prefix setzen (muss zu paperless-ngx passen) +- Führende Nullen einstellen (z.B. 7 → `ASN0000001`) +- Menge als **Labels** oder **A4-Blätter** +- Code-Typ: **QR** oder **CODE128** +- Optional: Rahmen aktivieren (hilft beim Kalibrieren) +- „PDF erzeugen…“ → Datei speichern + +--- + +## Prefix in paperless-ngx + +Der Prefix muss zu deiner paperless-ngx Einstellung passen: + +- `PAPERLESS_CONSUMER_ASN_BARCODE_PREFIX` + +Beispiel: Wenn du in paperless-ngx `ASN` nutzt, dann hier auch `ASN`. + +--- + +## Druck-Hinweis (sehr wichtig) + +Bitte im PDF-Viewer/Printer **100%** drucken: +- **kein** „An Seite anpassen“ +- **kein** „Skalieren“ +- **kein** „Fit to Page“ + +Wenn die Position nicht exakt passt: +- Rahmen aktivieren +- Offset X/Y und Pitch-Δ anpassen + +--- + +## Entwicklung (Repo klonen & lokal starten) + +```bash +git clone https://github.com/DEINNAME/paperless-asn-label-generator +cd paperless-asn-label-generator + +# Virtuelle Umgebung +python -m venv .venv +# Windows: +# .venv\Scripts\activate +# macOS/Linux: +# source .venv/bin/activate + +pip install -e . +paperless-asn-labels +``` + +--- + +## Lizenz +MIT (siehe LICENSE) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9b30085 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "paperless-asn-label-generator" +version = "0.1.0" +description = "GUI tool to generate ASN barcode/QR labels for paperless-ngx (A4/Avery L4731) with calibration." +readme = "README.md" +requires-python = ">=3.10" +license = { file = "LICENSE" } +authors = [{ name = "MrLetsPlay4Fun" }] +keywords = ["paperless-ngx", "labels", "asn", "qr", "barcode", "avery", "reportlab", "tkinter"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent" +] + +dependencies = [ + "reportlab>=4.0.0", + "qrcode[pil]>=7.4.2", + "Pillow>=10.0.0" +] + +[project.scripts] +paperless-asn-labels = "paperless_asn_label_generator.app:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + diff --git a/src/paperless_asn_label_generator/__init__.py b/src/paperless_asn_label_generator/__init__.py new file mode 100644 index 0000000..469e977 --- /dev/null +++ b/src/paperless_asn_label_generator/__init__.py @@ -0,0 +1,2 @@ +__all__ = ["__version__"] +__version__ = "0.1.0" \ No newline at end of file diff --git a/src/paperless_asn_label_generator/__main__.py b/src/paperless_asn_label_generator/__main__.py new file mode 100644 index 0000000..37c2a2d --- /dev/null +++ b/src/paperless_asn_label_generator/__main__.py @@ -0,0 +1,4 @@ +from .app import main + +if __name__ == "__main__": + main() diff --git a/src/paperless_asn_label_generator/app.py b/src/paperless_asn_label_generator/app.py new file mode 100644 index 0000000..549790c --- /dev/null +++ b/src/paperless_asn_label_generator/app.py @@ -0,0 +1,495 @@ +#!/usr/bin/env python3 +""" +paperless-ngx ASN Label Generator (GUI) + Calibration + +This version adds calibration controls to fix typical "top row too low / bottom row too high" +and left/right drift issues when printing on real label sheets (e.g. Avery Zweckform L4731/L4731REV). + +New controls: +- Offset X/Y (mm): shifts the whole grid +- Spalten-Pitch Δ (mm): changes distance between columns (fixes left/right drift) +- Zeilen-Pitch Δ (mm): changes distance between rows (fixes top/bottom drift) + +Tip: In your PDF viewer / printer settings, print at 100% (no "Fit to page"/"Shrink/Expand"). +""" + +from __future__ import annotations + +import math +import os +import sys +from dataclasses import dataclass +from typing import Literal, Tuple + +import tkinter as tk +from tkinter import ttk, filedialog, messagebox + +from reportlab.pdfgen import canvas as rl_canvas +from reportlab.lib.pagesizes import A4 +from reportlab.lib.units import mm, cm +from reportlab.lib.utils import ImageReader +from reportlab.graphics.barcode import code128 + +import qrcode +from PIL import Image, ImageTk + + +BarcodeKind = Literal["QR", "CODE128"] + + +@dataclass(frozen=True) +class SheetLayout: + name: str + cols: int + rows: int + label_w: float # points + label_h: float # points + gap_x: float # points (base gap) + gap_y: float # points (base gap) + margin_left: float # points + margin_top: float # points + + @property + def labels_per_page(self) -> int: + return self.cols * self.rows + + @property + def pitch_x(self) -> float: + return self.label_w + self.gap_x + + @property + def pitch_y(self) -> float: + return self.label_h + self.gap_y + + +# 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: + return f"{prefix}{number:0{leading_zeros}d}" + + +def make_qr_image(data: str, target_px: int) -> Image.Image: + qr = qrcode.QRCode( + version=None, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=2, + ) + qr.add_data(data) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white").convert("RGB") + return img.resize((target_px, target_px), Image.Resampling.LANCZOS) + + +def draw_label( + c: rl_canvas.Canvas, + x: float, + y: float, + layout: SheetLayout, + text: str, + kind: BarcodeKind, + draw_border: bool, +) -> None: + """Draw one label in the rectangle [x, y, label_w, label_h] (origin bottom-left).""" + if draw_border: + c.rect(x, y, layout.label_w, layout.label_h, stroke=1, fill=0) + + pad = 1.0 * mm + + code_size = layout.label_h * 0.90 + code_x = x + pad + code_y = y + (layout.label_h - code_size) / 2.0 + + if kind == "QR": + dpi = 600 + target_px = max(80, int(code_size * dpi / 72.0)) + img = make_qr_image(text, target_px) + c.drawImage(ImageReader(img), code_x, code_y, width=code_size, height=code_size, preserveAspectRatio=True, mask=None) + else: + b = code128.Code128(text, barHeight=code_size * 0.85, humanReadable=False) + desired_w = code_size + scale = min(1.0, desired_w / float(b.width)) + c.saveState() + c.translate(code_x, code_y + (code_size - b.barHeight) / 2.0) + c.scale(scale, 1.0) + b.drawOn(c, 0, 0) + c.restoreState() + + text_x = code_x + code_size + pad + font_size = max(5.5, min(8.0, (layout.label_h / mm) * 0.55)) + c.setFont("Helvetica", font_size) + c.drawString(text_x, y + (layout.label_h - font_size) / 2.0, text) + + +def generate_pdf( + output_path: str, + start_number: int, + count: int, + prefix: str, + leading_zeros: int, + kind: BarcodeKind, + layout: SheetLayout = AVERY_L4731, + draw_border: bool = False, + # NEW calibration: + offset_x_mm: float = 0.0, # + moves right + 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) +) -> Tuple[int, int]: + """ + Returns: (pages_generated, next_number) + """ + if count <= 0: + raise ValueError("count must be > 0") + if start_number <= 0: + raise ValueError("start_number must be > 0") + if leading_zeros < 0: + raise ValueError("leading_zeros must be >= 0") + if not prefix: + raise ValueError("prefix must not be empty") + + _page_w, page_h = A4 + 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) + pitch_y = layout.pitch_y + (pitch_dy_mm * mm) + off_x = offset_x_mm * mm + off_y = offset_y_mm * mm + + c = rl_canvas.Canvas(output_path, pagesize=A4) + + current = start_number + remaining = count + + for _ in range(pages): + n_this_page = min(remaining, labels_per_page) + + for i in range(n_this_page): + 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 + + text = make_asn_text(prefix, current, leading_zeros) + draw_label(c, x, y, layout, text, kind, draw_border) + + current += 1 + + c.showPage() + remaining -= n_this_page + + c.save() + return pages, current + + +class App(ttk.Frame): + def __init__(self, master: tk.Tk) -> None: + super().__init__(master, padding=12) + self.master = master + + self.var_start = tk.StringVar(value="1") + + # Quantity mode + self.var_mode = tk.StringVar(value="labels") # "labels" or "pages" + self.var_count = tk.StringVar(value=str(AVERY_L4731.labels_per_page)) + self.var_pages = tk.StringVar(value="1") + + self.var_prefix = tk.StringVar(value="ASN") + self.var_zeros = tk.StringVar(value="7") + self.var_kind = tk.StringVar(value="QR") + self.var_border = tk.BooleanVar(value=False) + + # NEW calibration vars (mm) + self.var_off_x = tk.StringVar(value="-4.5") + self.var_off_y = tk.StringVar(value="7.0") + self.var_pitch_dx = tk.StringVar(value="1.2") + self.var_pitch_dy = tk.StringVar(value="0.42") + + self._preview_imgtk: ImageTk.PhotoImage | None = None + self._count_entry: ttk.Entry | None = None + self._pages_entry: ttk.Entry | None = None + + self._build_ui() + self._apply_mode() + self._update_preview() + + def _build_ui(self) -> None: + self.master.title("paperless-ngx ASN Label Generator (A4 / Avery L4731) – mit Kalibrierung") + self.grid(sticky="nsew") + self.master.columnconfigure(0, weight=1) + self.master.rowconfigure(0, weight=1) + + frm = ttk.LabelFrame(self, text="Einstellungen") + frm.grid(row=0, column=0, sticky="nsew") + for i in range(4): + frm.columnconfigure(i, weight=1) + + def add_row(label: str, var: tk.StringVar, row: int, hint: str = "", store: str | None = None) -> None: + ttk.Label(frm, text=label).grid(row=row, column=0, sticky="w", padx=(10, 6), pady=6) + ent = ttk.Entry(frm, textvariable=var, width=14) + ent.grid(row=row, column=1, sticky="we", padx=(0, 10), pady=6) + if hint: + ttk.Label(frm, text=hint, foreground="#555").grid(row=row, column=2, columnspan=2, sticky="w", padx=(0, 10), pady=6) + ent.bind("", lambda _e: self._update_preview()) + if store == "count": + self._count_entry = ent + elif store == "pages": + self._pages_entry = ent + + add_row("Start-Nummer", self.var_start, 0, "z.B. 1 oder 190") + + ttk.Label(frm, text="Menge als").grid(row=1, column=0, sticky="w", padx=(10, 6), pady=6) + mode_box = ttk.Frame(frm) + mode_box.grid(row=1, column=1, sticky="w", padx=(0, 10), pady=6) + 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") + + 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("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") + + 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=6, column=1, sticky="w", padx=(0, 10), pady=6) + cmb.bind("<>", 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=6, column=2, columnspan=2, sticky="w", padx=(0, 10), pady=6) + + cal = ttk.LabelFrame(frm, text="Kalibrierung (mm)") + 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) + + def add_cal(label: str, var: tk.StringVar, col: int, hint: str) -> None: + ttk.Label(cal, text=label).grid(row=0, column=col, sticky="w", padx=(6, 4), pady=6) + e = ttk.Entry(cal, textvariable=var, width=10) + e.grid(row=0, column=col + 1, sticky="we", padx=(0, 10), pady=6) + e.bind("", 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) + + add_cal("Offset X", self.var_off_x, 0, "+ rechts") + add_cal("Offset Y", self.var_off_y, 3, "+ hoch") + + def add_cal2(label: str, var: tk.StringVar, row: int, col: int, hint: str) -> None: + ttk.Label(cal, text=label).grid(row=row, column=col, sticky="w", padx=(6, 4), pady=6) + e = ttk.Entry(cal, textvariable=var, width=10) + e.grid(row=row, column=col + 1, sticky="we", padx=(0, 10), pady=6) + e.bind("", 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) + + 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") + + right = ttk.Frame(self) + right.grid(row=0, column=1, sticky="nsew", padx=(12, 0)) + self.columnconfigure(1, weight=1) + self.rowconfigure(0, weight=1) + right.rowconfigure(1, weight=1) + + ttk.Label(right, text="Vorschau (1 Label)").grid(row=0, column=0, sticky="w") + + self.preview = ttk.Label(right) + self.preview.grid(row=1, column=0, sticky="nsew", pady=(6, 10)) + + btns = ttk.Frame(right) + btns.grid(row=2, column=0, sticky="we") + btns.columnconfigure(0, weight=1) + btns.columnconfigure(1, weight=1) + + ttk.Button(btns, text="PDF erzeugen…", command=self.on_generate_pdf).grid(row=0, column=0, sticky="we", padx=(0, 6)) + ttk.Button(btns, text="Ordner öffnen", command=self.on_open_folder).grid(row=0, column=1, sticky="we", padx=(6, 0)) + + self.lbl_status = ttk.Label(self, text="", foreground="#333") + self.lbl_status.grid(row=1, column=0, columnspan=2, sticky="we", pady=(10, 0)) + + def _apply_mode(self) -> None: + mode = self.var_mode.get() + if self._count_entry is not None: + self._count_entry.configure(state=("normal" if mode == "labels" else "disabled")) + if self._pages_entry is not None: + self._pages_entry.configure(state=("normal" if mode == "pages" else "disabled")) + self._update_preview() + + def _parse_int(self, s: str, field: str) -> int: + try: + v = int(s.strip()) + except Exception: + raise ValueError(f"{field} ist keine gültige Zahl.") + return v + + def _parse_float(self, s: str, field: str) -> float: + try: + return float(s.strip().replace(",", ".")) + except Exception: + raise ValueError(f"{field} ist keine gültige Zahl (z.B. 0.2).") + + def _effective_count_and_pages(self) -> tuple[int, int]: + 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.") + 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 = math.ceil(count / AVERY_L4731.labels_per_page) + return count, pages + + def _current_text(self) -> str: + start = self._parse_int(self.var_start.get(), "Start-Nummer") + zeros = self._parse_int(self.var_zeros.get(), "Führende Nullen") + prefix = self.var_prefix.get().strip() + if not prefix: + raise ValueError("Prefix darf nicht leer sein.") + if start <= 0: + raise ValueError("Start-Nummer muss > 0 sein.") + if zeros < 0: + raise ValueError("Führende Nullen muss >= 0 sein.") + return make_asn_text(prefix, start, zeros) + + def _calibration(self) -> tuple[float, float, float, float]: + off_x = self._parse_float(self.var_off_x.get(), "Offset X") + off_y = self._parse_float(self.var_off_y.get(), "Offset Y") + pdx = self._parse_float(self.var_pitch_dx.get(), "Spalten-Pitch Δ") + pdy = self._parse_float(self.var_pitch_dy.get(), "Zeilen-Pitch Δ") + return off_x, off_y, pdx, pdy + + def _update_preview(self, *_args) -> None: + try: + text = self._current_text() + kind: BarcodeKind = "QR" if self.var_kind.get() == "QR" else "CODE128" + img = self._render_preview_image(text, kind, self.var_border.get()) + self._preview_imgtk = ImageTk.PhotoImage(img) + self.preview.configure(image=self._preview_imgtk) + + count, pages = self._effective_count_and_pages() + off_x, off_y, pdx, pdy = self._calibration() + self.lbl_status.configure( + text=f"Beispiel: {text} | {count} Labels ≈ {pages} Seiten | Offset({off_x},{off_y})mm PitchΔ({pdx},{pdy})mm" + ) + except Exception as e: + self.preview.configure(image="") + self.lbl_status.configure(text=f"⚠️ {e}") + + def _render_preview_image(self, text: str, kind: BarcodeKind, border: bool) -> Image.Image: + scale = 6 + w = int((AVERY_L4731.label_w / mm) * scale * 10) + h = int((AVERY_L4731.label_h / mm) * scale * 10) + img = Image.new("RGB", (w, h), "white") + + if kind == "QR": + size = int(h * 0.90) + qr = make_qr_image(text, max(120, size)) + img.paste(qr, (10, (h - qr.size[1]) // 2)) + else: + box = Image.new("RGB", (int(h * 0.90), int(h * 0.90)), "white") + img.paste(box, (10, (h - box.size[1]) // 2)) + + from PIL import ImageDraw, ImageFont + draw = ImageDraw.Draw(img) + try: + font = ImageFont.truetype("DejaVuSans.ttf", 18) + except Exception: + font = ImageFont.load_default() + draw.text((int(h * 0.95), (h - 18) // 2), text, fill="black", font=font) + + if border: + draw.rectangle([0, 0, w - 1, h - 1], outline="black", width=2) + + return img.resize((360, 160), Image.Resampling.LANCZOS) + + def on_generate_pdf(self) -> None: + try: + start = self._parse_int(self.var_start.get(), "Start-Nummer") + zeros = self._parse_int(self.var_zeros.get(), "Führende Nullen") + prefix = self.var_prefix.get().strip() + if not prefix: + raise ValueError("Prefix darf nicht leer sein.") + + count, _pages = self._effective_count_and_pages() + kind: BarcodeKind = "QR" if self.var_kind.get() == "QR" else "CODE128" + off_x, off_y, pdx, pdy = self._calibration() + + default_name = f"asn_labels_{start}_{count}.pdf" + out = filedialog.asksaveasfilename( + title="PDF speichern", + defaultextension=".pdf", + initialfile=default_name, + filetypes=[("PDF", "*.pdf")], + ) + if not out: + return + + pages_generated, next_number = generate_pdf( + output_path=out, + start_number=start, + count=count, + prefix=prefix, + leading_zeros=zeros, + kind=kind, + 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, + ) + + 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._update_preview() + except Exception as e: + messagebox.showerror("Fehler", str(e)) + + def on_open_folder(self) -> None: + folder = os.getcwd() + try: + 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 main() -> None: + root = tk.Tk() + try: + style = ttk.Style() + if "clam" in style.theme_names(): + style.theme_use("clam") + except Exception: + pass + + App(root) + root.minsize(900, 430) + root.mainloop() + + +if __name__ == "__main__": + main()