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