mirror of
https://github.com/AxonByteDev/paperless-asn-label-generator.git
synced 2026-08-06 08:42:03 +00:00
Initial release
This commit is contained in:
commit
1b9bafdc4a
7 changed files with 740 additions and 0 deletions
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||
161
README.md
Normal file
161
README.md
Normal file
|
|
@ -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)
|
||||
34
pyproject.toml
Normal file
34
pyproject.toml
Normal file
|
|
@ -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"]
|
||||
|
||||
2
src/paperless_asn_label_generator/__init__.py
Normal file
2
src/paperless_asn_label_generator/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
__all__ = ["__version__"]
|
||||
__version__ = "0.1.0"
|
||||
4
src/paperless_asn_label_generator/__main__.py
Normal file
4
src/paperless_asn_label_generator/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
495
src/paperless_asn_label_generator/app.py
Normal file
495
src/paperless_asn_label_generator/app.py
Normal file
|
|
@ -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("<KeyRelease>", 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("<<ComboboxSelected>>", lambda _e: self._update_preview())
|
||||
|
||||
chk = ttk.Checkbutton(frm, text="Rahmen um Labels zeichnen (Kalibrierung)", variable=self.var_border, command=self._update_preview)
|
||||
chk.grid(row=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("<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)
|
||||
|
||||
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("<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)
|
||||
|
||||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue