Upload per ZIP-Tool (1/3)
This commit is contained in:
1 parent
14a4880a09
commit
9e7561b040
20 files changed
+1403
-14
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
labels.db
|
||||||
|
*.pyc
|
||||||
|
lang/
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 olli1986
|
Copyright (c) 2026 Oliver Braasch
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
in the Software without restriction, including without limitation the rights
|
||||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
following conditions:
|
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
|
The above copyright notice and this permission notice shall be included in all
|
||||||
portions of the Software.
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
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.
|
||||||
@@ -1,2 +1,47 @@
|
|||||||
# labelpi
|
# LabelPi
|
||||||
|
|
||||||
|
Webbasierter Etiketten-Designer und Druckserver für **Brother-QL-Drucker** – gedacht für den Raspberry Pi (läuft aber auf jedem Linux-Rechner). Bedienung per Browser vom PC, iPad oder iPhone, auf dem Client ist kein Treiber nötig.
|
||||||
|
|
||||||
|
*English: web-based label designer and print server for Brother QL label printers, made for the Raspberry Pi. No client drivers required.*
|
||||||
|
|
||||||
|
## Funktionen
|
||||||
|
- **Designer:** Text (viele Schriftarten, lokal mitgeliefert), QR-Codes, Bilder, Rechtecke/Linien, **Tabellen** mit wählbarem Rahmen
|
||||||
|
- **Alle QL-Modelle der Bibliothek [brother_ql](https://github.com/pklaus/brother_ql)** (QL-500 bis QL-1060N) mit den jeweils passenden DK-Rollen (Endlos, Stanz, rund); **Autoschnitt** wählbar, wenn das Modell ihn kann
|
||||||
|
- Mehrere Drucker: ein USB-Drucker wird automatisch Standard, bei mehreren erscheint eine Auswahl; **Netzwerkdrucker** (LAN/WLAN, Port 9100) in den Einstellungen hinzufügbar
|
||||||
|
- **Barcodes** (Code 128, EAN-13/8, Code 39), **Seriennummern** (Zähler, `{n:5}`, `{datum}`) und **Serienetiketten aus CSV**
|
||||||
|
- **Formen** (Kreis, Linie, Pfeil, Dreieck) und **Symbole** (Warn-, Recycling-, Gefahrenzeichen u. v. m.), **Auto-Textgröße**, GS1-128 und **Datamatrix**
|
||||||
|
- **Raster** (mehrere Etiketten pro Streifen), **Rollen-Check** (Warnung bei Rollenwechsel), **Backup/Wiederherstellung** der Datenbank
|
||||||
|
- **Mehrsprachig** (Deutsch, Englisch; weitere Sprachen per Datei hinzufügbar), **Zähler, die sich merken**, Datum-Rechnen (`{datum+30}`), **Eingabe-Modus**, **QR-Assistent**, **Schnittmarken**
|
||||||
|
- **PDF-Import** (Seitengröße wählt die passende Rolle, mehrseitig als Serie), **Rückgängig/Wiederholen**, **Ausrichtungshilfen** (Einrasten, Mittig/Rand)
|
||||||
|
- **Vorlagen** mit Ordnern, **Verlauf** mit „Nochmal drucken“ und Mehrfachauswahl
|
||||||
|
- **Export/Import** einzelner oder aller Etiketten/Vorlagen (JSON), Download als **PNG/PDF**, Speichern ohne Drucken
|
||||||
|
- Einstellungen (Rolle, Länge, Drehung …) werden auf dem Server gemerkt, also auf allen Geräten gleich
|
||||||
|
- Optionaler **Passwortschutz** (in der Weboberfläche einstellbar), Neustart/Herunterfahren per Button
|
||||||
|
|
||||||
|
## Installation (Raspberry Pi OS Lite)
|
||||||
|
Bedienung, Platzhalter, Serien, Barcodes: [docs/ANLEITUNG.md](docs/ANLEITUNG.md) (Deutsch) · [docs/ANLEITUNG.en.md](docs/ANLEITUNG.en.md) (English) (in der App über den Button *? Hilfe*). Ausführliche Installationsanleitung inkl. SD-Karte, WLAN und Fehlerbehebung: [docs/INSTALL.md](docs/INSTALL.md)
|
||||||
|
|
||||||
|
**Ein Befehl** (installiert nach `~/labelpi`, richtet Dienst und USB-Rechte ein):
|
||||||
|
```
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/DEIN-USER/labelpi/main/install.sh | bash
|
||||||
|
```
|
||||||
|
**Oder manuell:** Ordner per `scp -r` auf den Pi kopieren, dann `cd labelpi && bash install.sh`.
|
||||||
|
|
||||||
|
Danach im Browser: `http://<hostname>.local` (z. B. `http://labelpi.local`).
|
||||||
|
|
||||||
|
**Update:** `cd ~/labelpi && git pull && sudo systemctl restart labelpi`
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
- Rot/Schwarz-Rollen werden nicht unterstützt (der Designer ist Schwarz/Weiß).
|
||||||
|
- QL-700/710W/720NW: die Taste „Editor Lite“ (LED) muss aus sein.
|
||||||
|
- Netzwerkdrucker: nur Modelle mit LAN/WLAN (z. B. QL-580N, QL-710W, QL-720NW, QL-810W, QL-820NWB, QL-1060N); die Drucker-IP am besten reservieren/fest vergeben.
|
||||||
|
- Mehrere **gleiche** USB-Modelle werden über den USB-Port unterschieden – nach dem Umstecken in einen anderen Port die Auswahl neu prüfen.
|
||||||
|
- Word-Dateien werden nicht direkt importiert (als PDF speichern).
|
||||||
|
- Ohne Passwortschutz hat jeder im Netz Zugriff – nur in vertrauenswürdigen Netzen betreiben. HTTP-Basic-Auth sendet das Passwort unverschlüsselt; für Zugriff aus dem Internet einen HTTPS-Reverse-Proxy davorsetzen.
|
||||||
|
- Die DK-Nummern sind nach bestem Wissen ergänzt; maßgeblich sind die Rollenmaße.
|
||||||
|
|
||||||
|
## Entstehung
|
||||||
|
Dieses Projekt ist in Zusammenarbeit von Oliver Braasch und **Claude (Anthropic)** entstanden: Anforderungen, Tests und Ziel-Hardware stammen vom Autor, der Code wurde im Dialog mit Claude geschrieben und iterativ verbessert (KI-unterstützte Entwicklung).
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
[MIT](LICENSE). Verwendet u. a. Flask, Pillow, qrcode, brother_ql (GPL-3.0, als separate Abhängigkeit installiert) und Schriften über [Fontsource](https://fontsource.org) (SIL OFL / Apache 2.0).
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
"""LabelPi – Etikettendruck für Brother-QL-Drucker per Weboberfläche (MIT-Lizenz)."""
|
||||||
|
import base64, glob, io, json, os, re, socket, sqlite3, subprocess, tempfile, threading, time
|
||||||
|
from flask import Flask, Response, jsonify, request, send_file, send_from_directory
|
||||||
|
from PIL import Image
|
||||||
|
import barcode
|
||||||
|
import markdown
|
||||||
|
from ppf.datamatrix import DataMatrix
|
||||||
|
import qrcode
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
from brother_ql.conversion import convert
|
||||||
|
from brother_ql.raster import BrotherQLRaster
|
||||||
|
from brother_ql.backends.helpers import send, discover
|
||||||
|
from brother_ql.labels import ALL_LABELS
|
||||||
|
from brother_ql.models import ALL_MODELS
|
||||||
|
|
||||||
|
VERSION = "0.6.0"
|
||||||
|
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DB = os.path.join(BASE, "labels.db")
|
||||||
|
app = Flask(__name__, static_folder="static")
|
||||||
|
lock = threading.Lock()
|
||||||
|
MODELS = {m.identifier: m for m in ALL_MODELS}
|
||||||
|
PIDS = {0x2015: "QL-500", 0x2016: "QL-550", 0x2027: "QL-560", 0x2028: "QL-570", 0x2029: "QL-580N", 0x201b: "QL-650TD",
|
||||||
|
0x2042: "QL-700", 0x2043: "QL-710W", 0x2044: "QL-720NW", 0x209b: "QL-800", 0x209c: "QL-810W", 0x209d: "QL-820NWB",
|
||||||
|
0x2020: "QL-1050", 0x202a: "QL-1060N"} # USB-Produkt-ID -> Modell
|
||||||
|
# DK-Nummern (Größen kommen aus der brother_ql-Bibliothek); leer = unbekannt
|
||||||
|
DK = {"12": "DK-22214", "29": "DK-22210", "38": "DK-22225", "50": "DK-22223", "54": "DK-N55224", "62": "DK-22205",
|
||||||
|
"102": "DK-22243", "17x54": "DK-11204", "17x87": "DK-11203", "23x23": "DK-11221", "29x90": "DK-11201",
|
||||||
|
"39x90": "DK-11208", "39x48": "DK-11220", "62x29": "DK-11209", "62x100": "DK-11202", "102x51": "DK-11240",
|
||||||
|
"102x152": "DK-11241", "d12": "DK-11219", "d24": "DK-11218", "d58": "DK-11207"}
|
||||||
|
LABELS = []
|
||||||
|
for l in ALL_LABELS:
|
||||||
|
if l.identifier == "62red": continue # Rot/Schwarz: Designer ist nur Schwarz/Weiß
|
||||||
|
ff, w = l.form_factor.name, l.tape_size
|
||||||
|
name = f"{w[0]} mm Endlos" if ff == "ENDLESS" else f"Ø {w[0]} mm rund" if ff == "ROUND_DIE_CUT" else f"{w[0]}×{w[1]} mm"
|
||||||
|
LABELS.append(dict(id=l.identifier, name=name, dk=DK.get(l.identifier, ""), endless=ff == "ENDLESS",
|
||||||
|
tw=w[0], tl=w[1], across=l.dots_printable[0], along=l.dots_printable[1], only=list(l.restricted_to_models)))
|
||||||
|
|
||||||
|
LBL = {l["id"]: l for l in LABELS}
|
||||||
|
|
||||||
|
def db():
|
||||||
|
c = sqlite3.connect(DB); c.row_factory = sqlite3.Row; return c
|
||||||
|
|
||||||
|
with db() as c:
|
||||||
|
c.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS templates(id INTEGER PRIMARY KEY, name TEXT, label TEXT, design TEXT, png BLOB, ts TEXT DEFAULT (datetime('now','localtime')));
|
||||||
|
CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY, ts TEXT DEFAULT (datetime('now','localtime')), label TEXT, copies INT, rotate INT, dither INT, design TEXT, png BLOB);
|
||||||
|
CREATE TABLE IF NOT EXISTS settings(k TEXT PRIMARY KEY, v TEXT);
|
||||||
|
CREATE TABLE IF NOT EXISTS counters(name TEXT PRIMARY KEY, v INTEGER);""")
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
with db() as c:
|
||||||
|
try: c.execute("ALTER TABLE templates ADD COLUMN folder TEXT DEFAULT ''")
|
||||||
|
except sqlite3.OperationalError: pass
|
||||||
|
|
||||||
|
migrate()
|
||||||
|
|
||||||
|
def sget(k, d=None):
|
||||||
|
with db() as c: r = c.execute("SELECT v FROM settings WHERE k=?", (k,)).fetchone()
|
||||||
|
v = json.loads(r["v"]) if r else None
|
||||||
|
return d if v is None else v
|
||||||
|
|
||||||
|
def sput(k, v):
|
||||||
|
with db() as c: c.execute("INSERT OR REPLACE INTO settings(k,v) VALUES(?,?)", (k, json.dumps(v)))
|
||||||
|
|
||||||
|
def b64(s): return base64.b64decode(s.split(",", 1)[1])
|
||||||
|
def pngd(b): return "data:image/png;base64," + base64.b64encode(b).decode()
|
||||||
|
|
||||||
|
def gray(png):
|
||||||
|
b = io.BytesIO(); Image.open(io.BytesIO(png)).convert("L").save(b, "PNG", optimize=True); return b.getvalue()
|
||||||
|
|
||||||
|
# ---------- Login (optional) ----------
|
||||||
|
OK = set()
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def guard():
|
||||||
|
a = sget("auth")
|
||||||
|
if not a: return
|
||||||
|
h = request.headers.get("Authorization", "")
|
||||||
|
if h in OK: return
|
||||||
|
r = request.authorization
|
||||||
|
if r and r.username == a["user"] and check_password_hash(a["hash"], r.password or ""):
|
||||||
|
OK.add(h); return
|
||||||
|
return Response("Login erforderlich", 401, {"WWW-Authenticate": 'Basic realm="LabelPi"'})
|
||||||
|
|
||||||
|
@app.get("/api/auth")
|
||||||
|
def a_get():
|
||||||
|
a = sget("auth"); return jsonify(enabled=bool(a), user=(a or {}).get("user", ""))
|
||||||
|
|
||||||
|
@app.post("/api/auth")
|
||||||
|
def a_set():
|
||||||
|
j = request.get_json() or {}; old = sget("auth")
|
||||||
|
if not j.get("enabled"): sput("auth", None)
|
||||||
|
else:
|
||||||
|
user, pw = (j.get("user") or "").strip(), j.get("password") or ""
|
||||||
|
if not user or (not pw and not old): return jsonify(error="Benutzername und Passwort angeben"), 400
|
||||||
|
sput("auth", {"user": user, "hash": generate_password_hash(pw, method="pbkdf2:sha256:60000") if pw else old["hash"]})
|
||||||
|
OK.clear(); return jsonify(ok=True)
|
||||||
|
|
||||||
|
# ---------- Drucker ----------
|
||||||
|
def reachable(h):
|
||||||
|
try: socket.create_connection((h, 9100), 0.7).close(); return True
|
||||||
|
except OSError: return False
|
||||||
|
|
||||||
|
def printers():
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
for d in discover("pyusb"):
|
||||||
|
dev = d["instance"]; pid = dev.idProduct
|
||||||
|
if pid in PIDS: # gleiche Modelle werden über den USB-Port unterschieden
|
||||||
|
port = f"{dev.bus}-" + ".".join(map(str, dev.port_numbers or ()))
|
||||||
|
out.append(dict(id=f"usb:{pid:04x}:{port}", name=PIDS[pid] + " (USB)", model=PIDS[pid], host="", port=port,
|
||||||
|
uri=f"usb://0x04f9:0x{pid:04x}", backend="pyusb", online=True, dev=dev))
|
||||||
|
except Exception: pass
|
||||||
|
for p in out:
|
||||||
|
if sum(q["model"] == p["model"] for q in out) > 1: p["name"] = f'{p["model"]} (USB-Port {p["port"]})'
|
||||||
|
for p in sget("printers", []):
|
||||||
|
out.append(dict(id="net:" + p["host"], name=f'{p["name"]} (Netzwerk)', model=p["model"], host=p["host"], uri=f'tcp://{p["host"]}:9100', backend="network", online=reachable(p["host"])))
|
||||||
|
for p in out: p["cutting"] = MODELS[p["model"]].cutting
|
||||||
|
return out
|
||||||
|
|
||||||
|
def pick(pid):
|
||||||
|
ps = printers()
|
||||||
|
p = next((x for x in ps if x["id"] == pid), None) or next((x for x in ps if x["online"]), None)
|
||||||
|
if not p: raise RuntimeError("Kein Drucker gefunden (USB, Strom, Netzwerk prüfen)")
|
||||||
|
return p
|
||||||
|
|
||||||
|
def compose(tiles, label, rot, grid):
|
||||||
|
"""Mehrere Etiketten (Kacheln) als Raster auf eine Endlosrolle legen."""
|
||||||
|
W = LBL[label]["across"]; cols, rows = int(grid["cols"]), int(grid["rows"]); gap = round(float(grid.get("gap", 2)) * 300 / 25.4)
|
||||||
|
out = []
|
||||||
|
for i in range(0, len(tiles), cols * rows):
|
||||||
|
grp = [t.rotate(-90, expand=True) if rot else t for t in tiles[i:i + cols * rows]]
|
||||||
|
tw, th = grp[0].size; used = -(-len(grp) // cols)
|
||||||
|
if cols * tw + (cols - 1) * gap > W: raise ValueError("Das Raster ist breiter als die Rolle (Etikettenbreite/Spalten prüfen)")
|
||||||
|
x0 = (W - (cols * tw + (cols - 1) * gap)) // 2
|
||||||
|
im = Image.new("RGB", (W, used * th + (used - 1) * gap), "white")
|
||||||
|
for k, t in enumerate(grp): im.paste(t, (x0 + (k % cols) * (tw + gap), (k // cols) * (th + gap)))
|
||||||
|
out.append(im)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def do_print(pngs, label, rot, dither, pid, cut, grid=None):
|
||||||
|
tiles = [Image.open(io.BytesIO(b)).convert("RGB") for b in pngs]
|
||||||
|
if grid and LBL[label]["endless"] and int(grid["cols"]) * int(grid["rows"]) > 1: imgs = compose(tiles, label, rot, grid)
|
||||||
|
else: imgs = [t.rotate(-90, expand=True) if rot else t for t in tiles] # 90° im Uhrzeigersinn
|
||||||
|
with lock:
|
||||||
|
for i in range(0, len(imgs), 20): # in Blöcken, schont den Speicher des Pi
|
||||||
|
p = pick(pid)
|
||||||
|
ins = convert(qlr=BrotherQLRaster(p["model"]), images=imgs[i:i + 20], label=label, rotate="0", threshold=70.0,
|
||||||
|
dither=dither, compress=False, red=False, dpi_600=False, hq=True, cut=bool(cut) and p["cutting"])
|
||||||
|
send(instructions=ins, printer_identifier=p.get("dev") or p["uri"], backend_identifier=p["backend"], blocking=True)
|
||||||
|
|
||||||
|
@app.errorhandler(Exception)
|
||||||
|
def err(e): return jsonify(error=str(e)), getattr(e, "code", 500)
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index(): return send_from_directory("static", "index.html")
|
||||||
|
|
||||||
|
@app.get("/api/models")
|
||||||
|
def models(): return jsonify(list(MODELS))
|
||||||
|
|
||||||
|
@app.get("/api/printers")
|
||||||
|
def p_list(): return jsonify([{k: v for k, v in p.items() if k != "dev"} for p in printers()])
|
||||||
|
|
||||||
|
@app.post("/api/printers")
|
||||||
|
def p_add():
|
||||||
|
j = request.get_json() or {}; host = (j.get("host") or "").strip()
|
||||||
|
if not host or j.get("model") not in MODELS: return jsonify(error="Name, Adresse und Modell angeben"), 400
|
||||||
|
sput("printers", [p for p in sget("printers", []) if p["host"] != host] + [dict(name=(j.get("name") or host).strip(), host=host, model=j["model"])])
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
@app.post("/api/printers/delete")
|
||||||
|
def p_del():
|
||||||
|
h = (request.get_json() or {}).get("host"); sput("printers", [p for p in sget("printers", []) if p["host"] != h]); return jsonify(ok=True)
|
||||||
|
|
||||||
|
@app.get("/api/labels")
|
||||||
|
def labels():
|
||||||
|
p = next((x for x in printers() if x["id"] == request.args.get("printer")), None)
|
||||||
|
model = p["model"] if p else "QL-500"
|
||||||
|
return jsonify([l for l in LABELS if not l["only"] or model in l["only"]])
|
||||||
|
|
||||||
|
@app.get("/api/bc")
|
||||||
|
def bc():
|
||||||
|
t, d = request.args.get("t", "code128"), request.args.get("d", "")
|
||||||
|
if not d or t not in ("code128", "ean13", "ean8", "code39", "gs1_128", "dm"): return jsonify(error="ungültig"), 400
|
||||||
|
try:
|
||||||
|
if t == "dm": return jsonify(m=["".join(map(str, r)) for r in DataMatrix(d).matrix])
|
||||||
|
cls = barcode.get_barcode_class(t)
|
||||||
|
return jsonify(m=(cls(d, add_checksum=False) if t == "code39" else cls(d)).build()[0])
|
||||||
|
except Exception as e: return jsonify(error=str(e)), 400
|
||||||
|
|
||||||
|
@app.post("/api/pdfin")
|
||||||
|
def pdfin():
|
||||||
|
f, page = request.files["f"], max(1, int(request.form.get("page", 1)))
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory() as t:
|
||||||
|
src = os.path.join(t, "in.pdf"); f.save(src)
|
||||||
|
mt = re.search(r"Pages:\s+(\d+)", subprocess.run(["pdfinfo", src], capture_output=True, text=True).stdout)
|
||||||
|
pages = int(mt.group(1)) if mt else 1; page = min(page, pages)
|
||||||
|
def enc(fp, mx):
|
||||||
|
im = Image.open(fp).convert("L"); mm = (im.width / 300 * 25.4, im.height / 300 * 25.4)
|
||||||
|
im.thumbnail((mx, mx)); b = io.BytesIO(); im.save(b, "PNG", optimize=True); return pngd(b.getvalue()), mm
|
||||||
|
if request.form.get("all") == "1": # alle Seiten (max. 60) -> Serie
|
||||||
|
subprocess.run(["pdftoppm", "-r", "300", "-png", "-l", "60", src, os.path.join(t, "o")], check=True, timeout=600)
|
||||||
|
res = [enc(fp, 2400) for fp in sorted(glob.glob(os.path.join(t, "o-*.png")))]
|
||||||
|
return jsonify(pngs=[r[0] for r in res], w=res[0][1][0], h=res[0][1][1], pages=len(res))
|
||||||
|
subprocess.run(["pdftoppm", "-r", "300", "-png", "-f", str(page), "-l", str(page), "-singlefile", src, os.path.join(t, "o")], check=True, timeout=120)
|
||||||
|
png, (w, h) = enc(os.path.join(t, "o.png"), 4000)
|
||||||
|
except FileNotFoundError: return jsonify(error="poppler-utils fehlt: sudo apt install poppler-utils"), 500
|
||||||
|
return jsonify(png=png, w=w, h=h, pages=pages)
|
||||||
|
|
||||||
|
@app.get("/qr")
|
||||||
|
def qr():
|
||||||
|
q = qrcode.QRCode(border=0, box_size=8, error_correction=qrcode.constants.ERROR_CORRECT_M)
|
||||||
|
q.add_data(request.args.get("d", "")); q.make(fit=True)
|
||||||
|
b = io.BytesIO(); q.make_image(fill_color="black", back_color="white").save(b); b.seek(0)
|
||||||
|
return send_file(b, mimetype="image/png")
|
||||||
|
|
||||||
|
@app.post("/api/pdf")
|
||||||
|
def pdf():
|
||||||
|
b = io.BytesIO(); Image.open(io.BytesIO(b64(request.get_json()["png"]))).convert("RGB").save(b, "PDF", resolution=300.0); b.seek(0)
|
||||||
|
return send_file(b, mimetype="application/pdf")
|
||||||
|
|
||||||
|
def log(j, n):
|
||||||
|
with db() as c:
|
||||||
|
c.execute("INSERT INTO history(label,copies,rotate,dither,design,png) VALUES(?,?,?,?,?,?)",
|
||||||
|
(j["label"], n, int(bool(j.get("rotate"))), int(bool(j.get("dither"))), json.dumps(j["design"]), gray(b64(j["png"]))))
|
||||||
|
c.execute("DELETE FROM history WHERE id NOT IN (SELECT id FROM history ORDER BY id DESC LIMIT 1000)")
|
||||||
|
|
||||||
|
@app.post("/api/print")
|
||||||
|
def print_():
|
||||||
|
j = request.get_json(); n = max(1, min(50, int(j.get("copies", 1))))
|
||||||
|
pngs = [b64(x) for x in j["pngs"]]
|
||||||
|
do_print([b for b in pngs for _ in range(n)][:600], j["label"], bool(j.get("rotate")), bool(j.get("dither")), j.get("printer"), j.get("cut"), j.get("grid"))
|
||||||
|
if j.get("log", True): j["png"] = j["pngs"][0]; log(j, n * len(pngs))
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
@app.post("/api/save")
|
||||||
|
def save():
|
||||||
|
log(request.get_json(), 0); return jsonify(ok=True)
|
||||||
|
|
||||||
|
# ---------- Verlauf ----------
|
||||||
|
@app.get("/api/history")
|
||||||
|
def h_list():
|
||||||
|
with db() as c: return jsonify([dict(r) for r in c.execute("SELECT id,ts,label,copies FROM history ORDER BY id DESC LIMIT 200")])
|
||||||
|
|
||||||
|
@app.get("/api/history/<int:i>")
|
||||||
|
def h_get(i):
|
||||||
|
with db() as c: return jsonify(design=c.execute("SELECT design FROM history WHERE id=?", (i,)).fetchone()["design"])
|
||||||
|
|
||||||
|
@app.get("/api/history/<int:i>.png")
|
||||||
|
def h_png(i):
|
||||||
|
with db() as c: return send_file(io.BytesIO(c.execute("SELECT png FROM history WHERE id=?", (i,)).fetchone()["png"]), mimetype="image/png")
|
||||||
|
|
||||||
|
@app.post("/api/history/<int:i>/reprint")
|
||||||
|
def h_reprint(i):
|
||||||
|
j = request.get_json(silent=True) or {}
|
||||||
|
with db() as c: r = c.execute("SELECT * FROM history WHERE id=?", (i,)).fetchone()
|
||||||
|
n = max(1, min(50, int(j.get("copies", 1))))
|
||||||
|
do_print([r["png"]] * n, r["label"], bool(r["rotate"]), bool(r["dither"]), j.get("printer"), j.get("cut"))
|
||||||
|
with db() as c: c.execute("UPDATE history SET ts=datetime('now','localtime'), copies=? WHERE id=?", (n, i))
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
@app.post("/api/history/delete")
|
||||||
|
def h_del_many():
|
||||||
|
ids = [int(x) for x in (request.get_json() or {}).get("ids", [])]
|
||||||
|
with db() as c: c.executemany("DELETE FROM history WHERE id=?", [(i,) for i in ids])
|
||||||
|
return jsonify(ok=True, n=len(ids))
|
||||||
|
|
||||||
|
@app.delete("/api/history/<int:i>")
|
||||||
|
def h_del(i):
|
||||||
|
with db() as c: c.execute("DELETE FROM history WHERE id=?", (i,))
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
# ---------- Vorlagen ----------
|
||||||
|
@app.get("/api/templates")
|
||||||
|
def t_list():
|
||||||
|
with db() as c: return jsonify([dict(r) for r in c.execute("SELECT id,name,label,COALESCE(folder,'') AS folder FROM templates ORDER BY name COLLATE NOCASE")])
|
||||||
|
|
||||||
|
@app.post("/api/templates")
|
||||||
|
def t_add():
|
||||||
|
j = request.get_json()
|
||||||
|
with db() as c:
|
||||||
|
c.execute("INSERT INTO templates(name,label,design,png,folder) VALUES(?,?,?,?,?)", (j["name"], j["label"], json.dumps(j["design"]), gray(b64(j["png"])), (j.get("folder") or "").strip()))
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
@app.put("/api/templates/<int:i>")
|
||||||
|
def t_put(i):
|
||||||
|
j = request.get_json()
|
||||||
|
with db() as c: c.execute("UPDATE templates SET name=?, folder=? WHERE id=?", (j["name"].strip(), (j.get("folder") or "").strip(), i))
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
@app.get("/api/templates/<int:i>")
|
||||||
|
def t_get(i):
|
||||||
|
with db() as c: return jsonify(design=c.execute("SELECT design FROM templates WHERE id=?", (i,)).fetchone()["design"])
|
||||||
|
|
||||||
|
@app.get("/api/templates/<int:i>.png")
|
||||||
|
def t_png(i):
|
||||||
|
with db() as c: return send_file(io.BytesIO(c.execute("SELECT png FROM templates WHERE id=?", (i,)).fetchone()["png"]), mimetype="image/png")
|
||||||
|
|
||||||
|
@app.delete("/api/templates/<int:i>")
|
||||||
|
def t_del(i):
|
||||||
|
with db() as c: c.execute("DELETE FROM templates WHERE id=?", (i,))
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
# ---------- Export / Import (JSON) ----------
|
||||||
|
@app.get("/api/export")
|
||||||
|
def export():
|
||||||
|
tpl = request.args.get("kind") == "templates"
|
||||||
|
ids = [int(x) for x in request.args.get("ids", "").split(",") if x.strip().isdigit()]
|
||||||
|
q = f"SELECT * FROM {'templates' if tpl else 'history'}" + (f" WHERE id IN ({','.join('?' * len(ids))})" if ids else "")
|
||||||
|
with db() as c: rows = c.execute(q, ids).fetchall()
|
||||||
|
items = [dict(kind="template" if tpl else "label", name=r["name"] if tpl else "", folder=(r["folder"] or "") if tpl else "",
|
||||||
|
label=r["label"], design=json.loads(r["design"]), png=pngd(r["png"])) for r in rows]
|
||||||
|
return Response(json.dumps(dict(labelpi=1, items=items)), mimetype="application/json",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename=labelpi-{'vorlagen' if tpl else 'etiketten'}.json"})
|
||||||
|
|
||||||
|
@app.post("/api/import")
|
||||||
|
def imp():
|
||||||
|
n = 0
|
||||||
|
with db() as c:
|
||||||
|
for i in (request.get_json() or {}).get("items", []):
|
||||||
|
png, d = gray(b64(i["png"])), json.dumps(i["design"])
|
||||||
|
if i.get("kind") == "template":
|
||||||
|
c.execute("INSERT INTO templates(name,label,design,png,folder) VALUES(?,?,?,?,?)", (i.get("name") or "Import", i["label"], d, png, i.get("folder", "")))
|
||||||
|
else:
|
||||||
|
c.execute("INSERT INTO history(label,copies,rotate,dither,design,png) VALUES(?,?,?,?,?,?)", (i["label"], 0, int(bool(i["design"].get("rot"))), 0, d, png))
|
||||||
|
n += 1
|
||||||
|
return jsonify(ok=True, n=n)
|
||||||
|
|
||||||
|
# ---------- Einstellungen / System ----------
|
||||||
|
@app.get("/api/settings")
|
||||||
|
def s_get(): return jsonify(sget("ui", {}))
|
||||||
|
|
||||||
|
@app.post("/api/settings")
|
||||||
|
def s_set():
|
||||||
|
j = request.get_json() or {}; d = sget("ui", {})
|
||||||
|
d.update({k: j[k] for k in ("label", "len", "rot", "dither", "printer", "cut", "roll", "cw", "grid", "cmk") if k in j}); sput("ui", d); return jsonify(ok=True)
|
||||||
|
|
||||||
|
# ---------- Zähler (merken die nächste Nummer dauerhaft) ----------
|
||||||
|
@app.get("/api/counters")
|
||||||
|
def c_list():
|
||||||
|
with db() as c: return jsonify({r["name"]: r["v"] for r in c.execute("SELECT name,v FROM counters")})
|
||||||
|
|
||||||
|
@app.post("/api/counters/reserve")
|
||||||
|
def c_reserve():
|
||||||
|
out = {}
|
||||||
|
with db() as c:
|
||||||
|
for k, n in ((request.get_json() or {}).get("names") or {}).items():
|
||||||
|
k = k.lower()[:40]; r = c.execute("SELECT v FROM counters WHERE name=?", (k,)).fetchone(); v = r["v"] if r else 1
|
||||||
|
c.execute("INSERT OR REPLACE INTO counters(name,v) VALUES(?,?)", (k, v + max(1, int(n)))); out[k] = v
|
||||||
|
return jsonify(out)
|
||||||
|
|
||||||
|
@app.post("/api/counters/set")
|
||||||
|
def c_set():
|
||||||
|
j = request.get_json() or {}
|
||||||
|
with db() as c: c.execute("INSERT OR REPLACE INTO counters(name,v) VALUES(?,?)", ((j.get("name") or "default").lower()[:40], int(j.get("value", 1))))
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
@app.post("/api/counters/delete")
|
||||||
|
def c_del():
|
||||||
|
with db() as c: c.execute("DELETE FROM counters WHERE name=?", ((request.get_json() or {}).get("name", "").lower(),))
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
# ---------- Sprachen: eingebaut (static/lang) und eigene (lang/) ----------
|
||||||
|
LANGD = [os.path.join(BASE, "static", "lang"), os.path.join(BASE, "lang")]
|
||||||
|
CODE = re.compile(r"[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})?")
|
||||||
|
|
||||||
|
def lang_file(code):
|
||||||
|
if CODE.fullmatch(code or ""):
|
||||||
|
for d in reversed(LANGD): # eigene Dateien haben Vorrang
|
||||||
|
p = os.path.join(d, code + ".json")
|
||||||
|
if os.path.isfile(p): return p
|
||||||
|
|
||||||
|
@app.get("/api/langs")
|
||||||
|
def langs():
|
||||||
|
out = {"de": "Deutsch"}
|
||||||
|
for d in LANGD:
|
||||||
|
for p in sorted(glob.glob(os.path.join(d, "*.json"))):
|
||||||
|
try: out[os.path.basename(p)[:-5]] = json.load(open(p, encoding="utf-8")).get("_name", os.path.basename(p)[:-5])
|
||||||
|
except Exception: pass
|
||||||
|
return jsonify([dict(code=k, name=v, custom=os.path.isfile(os.path.join(LANGD[1], k + ".json"))) for k, v in out.items()])
|
||||||
|
|
||||||
|
@app.get("/api/lang/<code>")
|
||||||
|
def lang(code):
|
||||||
|
p = lang_file(code)
|
||||||
|
return Response(open(p, encoding="utf-8").read(), mimetype="application/json") if p else jsonify(strings={})
|
||||||
|
|
||||||
|
@app.post("/api/lang")
|
||||||
|
def lang_up():
|
||||||
|
f = request.files["f"]; code = os.path.basename(f.filename or "")[:-5]
|
||||||
|
try: data = json.loads(f.read().decode("utf-8-sig"))
|
||||||
|
except Exception: data = None
|
||||||
|
if not CODE.fullmatch(code) or not isinstance(data, dict) or not data.get("_name") or not isinstance(data.get("strings"), dict):
|
||||||
|
return jsonify(error="Ungültige Sprachdatei (Dateiname = Sprachcode, Inhalt mit _name und strings)"), 400
|
||||||
|
os.makedirs(LANGD[1], exist_ok=True)
|
||||||
|
json.dump(data, open(os.path.join(LANGD[1], code + ".json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
@app.post("/api/lang/delete")
|
||||||
|
def lang_del():
|
||||||
|
code = (request.get_json() or {}).get("code", "")
|
||||||
|
if CODE.fullmatch(code) and os.path.isfile(os.path.join(LANGD[1], code + ".json")): os.unlink(os.path.join(LANGD[1], code + ".json"))
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
@app.get("/api/langtemplate")
|
||||||
|
def lang_tpl():
|
||||||
|
keys = json.load(open(os.path.join(LANGD[0], "en.json"), encoding="utf-8"))["strings"]
|
||||||
|
return Response(json.dumps({"_name": "Name der Sprache", "strings": {k: "" for k in keys}}, ensure_ascii=False, indent=1),
|
||||||
|
mimetype="application/json", headers={"Content-Disposition": "attachment; filename=neue-sprache.json"})
|
||||||
|
|
||||||
|
@app.get("/api/help")
|
||||||
|
def help_():
|
||||||
|
lg = request.args.get("lang", "de")
|
||||||
|
if not CODE.fullmatch(lg): lg = "de"
|
||||||
|
for p in (os.path.join(LANGD[1], f"ANLEITUNG.{lg}.md"), os.path.join(BASE, "docs", f"ANLEITUNG.{lg}.md"), os.path.join(BASE, "docs", "ANLEITUNG.md")):
|
||||||
|
if os.path.isfile(p):
|
||||||
|
return Response(markdown.markdown(open(p, encoding="utf-8").read(), extensions=["tables", "fenced_code"]), mimetype="text/html")
|
||||||
|
return Response("", mimetype="text/html")
|
||||||
|
|
||||||
|
@app.get("/api/backup")
|
||||||
|
def backup():
|
||||||
|
t = tempfile.NamedTemporaryFile(suffix=".db", delete=False); t.close()
|
||||||
|
src, dst = sqlite3.connect(DB), sqlite3.connect(t.name); src.backup(dst); dst.close(); src.close()
|
||||||
|
r = send_file(t.name, as_attachment=True, download_name=time.strftime("labelpi-backup-%Y%m%d-%H%M.db"))
|
||||||
|
r.call_on_close(lambda: os.unlink(t.name)); return r
|
||||||
|
|
||||||
|
@app.post("/api/restore")
|
||||||
|
def restore():
|
||||||
|
t = tempfile.NamedTemporaryFile(suffix=".db", delete=False); request.files["f"].save(t.name); t.close()
|
||||||
|
try:
|
||||||
|
s = sqlite3.connect(t.name); names = {r[0] for r in s.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||||
|
if not {"templates", "history", "settings"} <= names: raise ValueError("Keine gültige LabelPi-Sicherung")
|
||||||
|
d = sqlite3.connect(DB); s.backup(d); d.close(); s.close()
|
||||||
|
finally: os.unlink(t.name)
|
||||||
|
migrate(); OK.clear(); return jsonify(ok=True)
|
||||||
|
|
||||||
|
@app.post("/api/power")
|
||||||
|
def power():
|
||||||
|
a = (request.get_json() or {}).get("action")
|
||||||
|
if a not in ("reboot", "poweroff"): return jsonify(error="Ungültige Aktion"), 400
|
||||||
|
cmd = ["sudo", "-n", "/usr/bin/systemctl", a]
|
||||||
|
if subprocess.run(cmd[:2] + ["-l"] + cmd[2:], capture_output=True).returncode != 0:
|
||||||
|
return jsonify(error="Keine sudo-Berechtigung (install.sh erneut ausführen)"), 500
|
||||||
|
subprocess.Popen("sleep 1; " + " ".join(cmd), shell=True)
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run("0.0.0.0", int(os.environ.get("PORT", 80)), threaded=True)
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# LabelPi – User guide
|
||||||
|
|
||||||
|
## 1. Basics
|
||||||
|
**Top bar:** printer (only shown if several are connected) · roll · length (continuous rolls only) · *rotated* · copies · dithering · auto-cut (models with a cutter only) · cut mark · **Print**.
|
||||||
|
|
||||||
|
- **The roll selected here must match the roll that is loaded.** LabelPi asks if you change the roll compared with the last print.
|
||||||
|
- **rotated:** On continuous rolls the length is shown horizontally and the label is rotated by 90° when printing, so you read it like on screen.
|
||||||
|
- **Dithering:** Only useful for photos/greys. Keep it **off** for text, QR codes and barcodes.
|
||||||
|
- **Cut mark** (continuous rolls without a cutter): prints a dashed line at the end of the label so you cut in the right place by hand (e.g. QL-500).
|
||||||
|
|
||||||
|
**Working in the designer**
|
||||||
|
- **View:** By default only the most important functions are shown. **Advanced view** (bar at the top right) adds rotate, dithering, auto-cut, cut mark, series, grid and zoom. More elements (PDF, shapes, symbols) are in the **+ More …** menu, saving/export under **File ▾**.
|
||||||
|
- Add elements from the toolbar, **tap** = select, **drag** = move, **bottom-right corner** = resize.
|
||||||
|
- The **properties** are in the bar at the bottom, which always stays visible. It also has buttons to align the element to the label.
|
||||||
|
- Elements **snap** to the edge, centre and other elements while moving (pink guide lines).
|
||||||
|
- **↶ / ↷** undo and redo (last 25 steps).
|
||||||
|
|
||||||
|
## 2. Elements
|
||||||
|
| Element | Notes |
|
||||||
|
|---|---|
|
||||||
|
| **Text** | Multi-line. *Auto size* fills the box with the largest possible font (set the box height). |
|
||||||
|
| **Symbol** | Warning, hazard, recycling signs etc. – they are text in a symbol font, so freely scalable. |
|
||||||
|
| **QR code** | Any text or link; placeholders allowed. The *QR assistant* builds Wi-Fi, vCard, email … for you. |
|
||||||
|
| **Barcode** | Choose a type (see below). With invalid content a red warning appears and **nothing is printed**. |
|
||||||
|
| **Image** | Photo/logo from your device. Switch dithering on for photos. |
|
||||||
|
| **PDF** | The page size selects the matching roll. Multi-page PDFs can be printed as a series (one page per label). |
|
||||||
|
| **Shapes** | Circle, line, arrow, triangle – filled or outline, rotatable in 45° steps (properties → Rotation). |
|
||||||
|
| **Table** | See section 3. |
|
||||||
|
|
||||||
|
**Barcode types**
|
||||||
|
|
||||||
|
| Type | Input |
|
||||||
|
|---|---|
|
||||||
|
| Code 128 | Any characters |
|
||||||
|
| EAN-13 | 12 digits (check digit is added) |
|
||||||
|
| EAN-8 | 7 digits |
|
||||||
|
| Code 39 | Capital letters, digits, `- . $ / + %` and space |
|
||||||
|
| GS1-128 | With brackets, e.g. `(01)09501101530003(17)271231(10)AB12` |
|
||||||
|
| Data Matrix | Any text, square |
|
||||||
|
|
||||||
|
## 3. Tables
|
||||||
|
- **Tap a cell** = select it (blue). **Double-click or double-tap** = type directly into the cell. *Enter* accepts, *Tab* jumps to the next cell, *Esc* cancels.
|
||||||
|
- In the properties: add/remove rows and columns, font, line width, *first row bold*.
|
||||||
|
- **Table border:** all lines · outside only · row lines · none.
|
||||||
|
- **Border of a single cell:** select the cell and tick *top / bottom / left / right*. This is in addition to the table border – with “none (cell borders only)” just the marked cells get lines (e.g. a single underline).
|
||||||
|
- Texts that are too long shrink automatically to fit the cell.
|
||||||
|
- You can also edit the content as text: one line per table row, columns separated by `;`.
|
||||||
|
|
||||||
|
## 4. Placeholders
|
||||||
|
Placeholders work in **text, QR codes, barcodes and table cells**. The names are German; English short forms exist.
|
||||||
|
|
||||||
|
| Placeholder | Meaning | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| `{n}` | Counter within the series | 1, 2, 3 … |
|
||||||
|
| `{n:5}` | Counter, 5 digits with leading zeros | 00001 |
|
||||||
|
| `{gesamt}` / `{total}` | Number of labels in the series | `{n}/{gesamt}` → 3/10 |
|
||||||
|
| `{lfd}`, `{lfd:5}` | **Running counter that remembers** (stored on the Pi, survives restarts and device changes) | 1001, 1002 … |
|
||||||
|
| `{lfd.invoice}` | Named counter, as many as you like | independent of `{lfd}` |
|
||||||
|
| `{datum}` / `{date}` | Today's date | 24.10.2026 |
|
||||||
|
| `{datum:JJJJ-MM-TT}` | Date with a custom format (`JJJJ`/`YYYY`, `JJ`/`YY`, `MM`, `TT`/`DD`) | 2026-10-24 |
|
||||||
|
| `{datum+30}` | Date plus 30 days (also `-7`; units `d` days, `w` weeks, `m` months, `j`/`y` years) | 23.11.2026 · `{datum+6m:MM/JJJJ}` → 04/2027 |
|
||||||
|
| `{zeit}`, `{zeit:HH:MM:SS}` | Time | 14:35 |
|
||||||
|
| `{jahr}` `{monat}` `{tag}` | Single date parts (also with offset, e.g. `{jahr+1j}`) | 2026 · 10 · 24 |
|
||||||
|
| `{kw}` / `{cw}` | ISO calendar week (e.g. `{kw+2w}` = week in two weeks) | 43 |
|
||||||
|
| `{wochentag}` `{monatname}` | Name in the interface language | Saturday |
|
||||||
|
| `{zufall:8}` / `{random:8}` | Random code (8 characters, without easily confused ones like 0/O and 1/I); every label of a series gets a different one | K7HT3QWZ |
|
||||||
|
| `{uuid}` | Random UUID | 3f2a9c1e-… |
|
||||||
|
| `{ColumnName}` | Value from the CSV column (CSV series only) | `{Name}` |
|
||||||
|
|
||||||
|
**English short forms:** `{date}` `{time}` `{year}` `{month}` `{day}` `{weekday}` `{cw}` `{random}` `{total}` `{seq}`.
|
||||||
|
|
||||||
|
**Filters** (append with `|`, combinable): `{Name|gross}` UPPERCASE · `{Name|klein}` lowercase · `{Name|titel}` Title Case (English: `|upper` `|lower` `|title`) · `{n:8|luhn}` appends a Luhn check digit · `{n:12|gtin}` appends the EAN/GTIN check digit.
|
||||||
|
|
||||||
|
**Notes**
|
||||||
|
- Date and time come from the device you print from. Unknown placeholders are left unchanged.
|
||||||
|
- `{lfd}` reserves the numbers when printing. If printing fails the counter is reset (no gaps). You see the next value in the preview and under *Settings → Counter*, where you can also change it.
|
||||||
|
- Examples: `SN-{jahr}-{lfd:4}` → `SN-2026-0001` · `Best before {datum+30}` · `CW {kw}/{jahr}` · `{n:11|gtin}` as EAN content.
|
||||||
|
|
||||||
|
## 5. Series (“Series…”)
|
||||||
|
- **Counter:** set start, step and count. Example: start 100, step 5, count 10 → 100, 105, …, 145.
|
||||||
|
- **CSV file:** first row = column names, then one label per row. Separator `;` or `,`, UTF-8.
|
||||||
|
```
|
||||||
|
Name;Item;Number
|
||||||
|
Miller;Screw M4;1001
|
||||||
|
Smith;Nut M4;1002
|
||||||
|
```
|
||||||
|
Use `{Name}`, `{Item}`, `{Number}` in the designer.
|
||||||
|
- **PDF pages:** created when you import a multi-page PDF.
|
||||||
|
- **Copies** apply per label of the series. Printing happens in blocks; the history only shows the first label of the series.
|
||||||
|
- The preview first shows label 1; use **‹ ›** next to “Series…” to page through all labels.
|
||||||
|
|
||||||
|
## 6. Grid (“Grid…”)
|
||||||
|
Continuous rolls only. Several labels side by side and below each other on one strip:
|
||||||
|
1. Set the *label width* in mm (e.g. 28 mm on a 62 mm roll for two columns).
|
||||||
|
2. Set *columns × rows* and *gap*.
|
||||||
|
3. When printing, consecutive labels (copies or series) fill the grid. If the grid does not fit the roll an error appears.
|
||||||
|
|
||||||
|
## 7. Templates, history, export, backup
|
||||||
|
- **Templates:** save with “Save as template”, sort into **folders**, edit (name/folder) and export.
|
||||||
|
- **History:** every print and every “Save” ends up here. *Print again*, *Edit*, or use *Select* to delete/export several.
|
||||||
|
- **Export/import:** labels and templates as a JSON file, e.g. to share. **⬇ PNG/PDF** exports the current label as an image or PDF.
|
||||||
|
- **Backup:** *Settings → Save database* stores everything (including printers and password), *Restore backup* puts it back.
|
||||||
|
|
||||||
|
## 8. Printers and security
|
||||||
|
- **USB printers** are detected automatically. Several identical models are told apart by their USB port.
|
||||||
|
- **Network printers** (LAN/Wi-Fi): *Settings → Add network printer* (name, IP, model).
|
||||||
|
- **Password protection:** *Settings → Password protection*. Without it anyone on the network can print – use it only in trusted networks.
|
||||||
|
|
||||||
|
## 9. Form mode, QR assistant
|
||||||
|
- **Form mode:** every template has an **Form** button. It shows only a small form with one field per custom placeholder (e.g. `{Name}`, `{Room}`), a preview and the number of copies – no designer. Ideal for colleagues who only need to print.
|
||||||
|
- **QR assistant:** select the QR code and click *QR assistant…* in the properties. Types: link, Wi-Fi access, contact (vCard), email, phone, SMS, location, free text. You fill in the fields and the content is built correctly. Placeholders are allowed here too.
|
||||||
|
|
||||||
|
## 9a. Keyboard shortcuts
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| `Del` | Delete the selected element |
|
||||||
|
| `Ctrl+Z` | Undo |
|
||||||
|
| `Ctrl+Y` or `Ctrl+Shift+Z` | Redo |
|
||||||
|
| `Enter` / `Tab` / `Esc` | In a table cell: accept / next cell / cancel |
|
||||||
|
|
||||||
|
## 10. Common problems
|
||||||
|
| Problem | Solution |
|
||||||
|
|---|---|
|
||||||
|
| Label cut off or misplaced | Is the roll in the app the same as the loaded one? Check “rotated”. |
|
||||||
|
| Barcode is not printed | Check the input (e.g. EAN-13 needs 12 digits). |
|
||||||
|
| Text looks different on paper | Dithering off; font not too small (below about 6 pt). |
|
||||||
|
| Page not reachable | Pi and device on the same network? Use the IP instead of `labelpi.local`. |
|
||||||
|
| More help | `docs/INSTALL.md` (installation, troubleshooting) |
|
||||||
|
|
||||||
|
## 11. Languages
|
||||||
|
The interface is available in German and English. Choose the language in the bar at the top right; it is remembered per device (the browser language is used on first start).
|
||||||
|
|
||||||
|
**Adding a new language**
|
||||||
|
1. *Settings → Languages → Download template for a new language* (`neue-sprache.json`).
|
||||||
|
2. Open the file: the German text on the left must not be changed, enter your translation on the right. `{}` are placeholders for numbers/names and must stay in the same number and order. Empty entries are shown in German.
|
||||||
|
3. Set `"_name"` at the top to the name of the language, e.g. `"Français"`.
|
||||||
|
4. Save the file as **languagecode.json**, e.g. `fr.json`.
|
||||||
|
5. *Add language file (.json)* – or put the file into the `lang/` folder on the Pi. Custom files survive updates and take precedence over built-in ones.
|
||||||
|
|
||||||
|
**Translating this guide (optional):** put it into the `lang/` folder (or `docs/`) as `ANLEITUNG.fr.md` – it is then shown under *Help* when that language is active. Without a translation the German guide is shown.
|
||||||
|
|
||||||
|
Placeholders such as `{datum}` keep their names; English short forms exist (`{date}` …). Dates, weekdays and month names appear in the selected language. If you would like to share your translation, the project welcomes a pull request.
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# LabelPi – Anleitung
|
||||||
|
|
||||||
|
## 1. Grundlagen
|
||||||
|
**Obere Leiste:** Drucker (nur sichtbar, wenn mehrere angeschlossen sind) · Rolle · Länge (nur bei Endlosrollen) · *gedreht* · Kopien · Dithering · Autoschnitt (nur bei Modellen mit Cutter) · **Drucken**.
|
||||||
|
|
||||||
|
- **Die gewählte Rolle muss zur eingelegten Rolle passen.** Die App fragt nach, wenn du die Rolle gegenüber dem letzten Druck wechselst.
|
||||||
|
- **gedreht:** Bei Endlosrollen wird die Länge waagerecht dargestellt und das Etikett beim Druck um 90° gedreht – so liest du es später wie am Bildschirm. Ohne Haken entspricht die Anzeige der Richtung quer zur Rolle.
|
||||||
|
- **Dithering:** Nur für Fotos/Graustufen sinnvoll (Rasterpunkte). Für Text, QR- und Barcodes **aus** lassen.
|
||||||
|
- **Schnittmarke** (nur Endlosrollen ohne Cutter): druckt eine gestrichelte Linie ans Etikettenende, damit du beim manuellen Abschneiden (z. B. QL-500) die richtige Stelle triffst.
|
||||||
|
|
||||||
|
**Bedienung im Designer**
|
||||||
|
- **Ansicht:** Standardmäßig sind nur die wichtigsten Funktionen sichtbar. Mit **Erweiterte Ansicht** (Leiste oben rechts) kommen Drehen, Dithering, Autoschnitt, Schnittmarke, Serie, Raster und Zoom dazu. Weitere Elemente (PDF, Formen, Symbole) findest du im Menü **+ Mehr …**, Speichern/Export unter **Datei ▾**.
|
||||||
|
- Element hinzufügen über die Werkzeugleiste, **antippen** = auswählen, **ziehen** = verschieben, **Ecke unten rechts** = Größe ändern.
|
||||||
|
- Die **Eigenschaften** stehen in der Leiste unten und bleiben immer sichtbar. Dort gibt es auch Buttons zum Ausrichten am Etikett (links, Mitte, rechts, oben, unten).
|
||||||
|
- Beim Verschieben **rasten** Elemente an Rand, Mitte und Kanten anderer Elemente ein (rosa Hilfslinien).
|
||||||
|
- **↶ / ↷** machen Schritte rückgängig bzw. stellen sie wieder her (die letzten 25).
|
||||||
|
|
||||||
|
## 2. Elemente
|
||||||
|
| Element | Hinweise |
|
||||||
|
|---|---|
|
||||||
|
| **Text** | Mehrzeilig möglich. *Auto-Größe* füllt die Box mit der größtmöglichen Schrift (Box-Höhe festlegen). |
|
||||||
|
| **Symbol** | Warn-, Gefahren-, Recycling-Zeichen u. a. – sind Text in einer Symbolschrift, also frei skalierbar. |
|
||||||
|
| **QR-Code** | Beliebiger Text oder Link; Platzhalter erlaubt. |
|
||||||
|
| **Barcode** | Typ wählen (siehe unten). Bei ungültigem Inhalt erscheint eine rote Warnung und es wird **nicht** gedruckt. |
|
||||||
|
| **Bild** | Foto/Logo vom Gerät. Für Fotos Dithering einschalten. |
|
||||||
|
| **PDF** | Seitengröße wählt die passende Rolle. Mehrseitige PDFs können als Serie gedruckt werden (eine Seite pro Etikett). |
|
||||||
|
| **Formen** | Kreis, Linie, Pfeil, Dreieck – gefüllt oder als Umriss, in 45°-Schritten drehbar (Eigenschaften → Drehung). |
|
||||||
|
| **Tabelle** | Siehe Abschnitt 3. |
|
||||||
|
|
||||||
|
**Barcode-Typen**
|
||||||
|
|
||||||
|
| Typ | Eingabe |
|
||||||
|
|---|---|
|
||||||
|
| Code 128 | Beliebige Zeichen (Buchstaben, Ziffern, Sonderzeichen) |
|
||||||
|
| EAN-13 | 12 Ziffern (Prüfziffer wird ergänzt) |
|
||||||
|
| EAN-8 | 7 Ziffern |
|
||||||
|
| Code 39 | Großbuchstaben, Ziffern, `- . $ / + %` und Leerzeichen |
|
||||||
|
| GS1-128 | Mit Klammern, z. B. `(01)09501101530003(17)271231(10)AB12` |
|
||||||
|
| Datamatrix | Beliebiger Text, quadratisch |
|
||||||
|
|
||||||
|
## 3. Tabellen
|
||||||
|
- **Zelle antippen** = Zelle auswählen (blau markiert). **Doppelklick bzw. doppelt antippen** = direkt in der Zelle tippen. *Enter* übernimmt, *Tab* springt zur nächsten Zelle, *Esc* bricht ab.
|
||||||
|
- In den Eigenschaften: Zeilen/Spalten hinzufügen oder entfernen, Schrift, Linienstärke, *1. Zeile fett*.
|
||||||
|
- **Rahmen der Tabelle:** alle Linien · nur außen · Zeilenlinien · keiner.
|
||||||
|
- **Rahmen einer einzelnen Zelle:** Zelle auswählen und *oben / unten / links / rechts* ankreuzen. Das geht zusätzlich zum Tabellenrahmen – mit „keiner (nur Zellrahmen)“ bekommen nur die markierten Zellen Linien (z. B. nur eine Unterstrichlinie).
|
||||||
|
- Zu lange Texte werden automatisch kleiner, damit sie in die Zelle passen.
|
||||||
|
- Alternativ lässt sich der Inhalt als Text bearbeiten: eine Zeile pro Tabellenzeile, Spalten mit `;` getrennt.
|
||||||
|
|
||||||
|
## 4. Platzhalter
|
||||||
|
Platzhalter funktionieren in **Text, QR-Code, Barcode und Tabellenzellen**.
|
||||||
|
|
||||||
|
| Platzhalter | Bedeutung | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| `{n}` | Zähler innerhalb der Serie | 1, 2, 3 … |
|
||||||
|
| `{n:5}` | Zähler, 5-stellig mit führenden Nullen | 00001 |
|
||||||
|
| `{gesamt}` | Anzahl der Etiketten der Serie | `{n}/{gesamt}` → 3/10 |
|
||||||
|
| `{lfd}`, `{lfd:5}` | **Fortlaufender Zähler, der sich merkt** (auf dem Pi gespeichert, auch nach Neustart/Gerätewechsel) | 1001, 1002 … |
|
||||||
|
| `{lfd.rechnung}` | Benannter Zähler, beliebig viele möglich | unabhängig von `{lfd}` |
|
||||||
|
| `{datum}` | heutiges Datum | 24.10.2026 |
|
||||||
|
| `{datum:JJJJ-MM-TT}` | Datum mit eigenem Format (`JJJJ` `JJ` `MM` `TT`) | 2026-10-24 |
|
||||||
|
| `{datum+30}` | Datum plus 30 Tage (auch `-7`; Einheiten `d` Tage, `w` Wochen, `m` Monate, `j` Jahre) | 23.11.2026 · `{datum+6m:MM/JJJJ}` → 04/2027 |
|
||||||
|
| `{zeit}`, `{zeit:HH:MM:SS}` | Uhrzeit | 14:35 |
|
||||||
|
| `{jahr}` `{monat}` `{tag}` | einzelne Datumsteile (auch mit Versatz, z. B. `{jahr+1j}`) | 2026 · 10 · 24 |
|
||||||
|
| `{kw}` | ISO-Kalenderwoche (z. B. `{kw+2w}` = Woche in zwei Wochen) | 43 |
|
||||||
|
| `{wochentag}` `{monatname}` | Name in der Sprache der Oberfläche | Samstag / Saturday |
|
||||||
|
| `{zufall:8}` | Zufallscode (hier 8 Zeichen, ohne leicht verwechselbare wie 0/O und 1/I); jedes Etikett einer Serie bekommt einen anderen | K7HT3QWZ |
|
||||||
|
| `{uuid}` | zufällige UUID | 3f2a9c1e-… |
|
||||||
|
| `{Spaltenname}` | Wert aus der CSV-Spalte (nur bei CSV-Serie) | `{Name}` |
|
||||||
|
|
||||||
|
**Englische Kurzformen:** `{date}` `{time}` `{year}` `{month}` `{day}` `{weekday}` `{cw}` `{random}` `{total}` `{seq}`.
|
||||||
|
|
||||||
|
**Filter** (mit `|` anhängen, auch kombinierbar): `{Name|gross}` GROSSBUCHSTABEN · `{Name|klein}` kleinbuchstaben · `{Name|titel}` Wortanfänge groß · `{n:8|luhn}` hängt eine Luhn-Prüfziffer an · `{n:12|gtin}` hängt die EAN/GTIN-Prüfziffer an.
|
||||||
|
|
||||||
|
**Hinweise**
|
||||||
|
- Datum und Uhrzeit kommen vom Gerät, mit dem du druckst. Unbekannte Platzhalter bleiben unverändert stehen.
|
||||||
|
- `{lfd}` reserviert die Nummern erst beim Drucken. Schlägt der Druck fehl, wird der Zähler zurückgesetzt (keine Lücken). Den nächsten Wert siehst du in der Vorschau und unter *Einstellungen → Zähler*, dort kannst du ihn auch ändern.
|
||||||
|
- Beispiele: `SN-{jahr}-{lfd:4}` → `SN-2026-0001` · `Haltbar bis {datum+30}` · `KW {kw}/{jahr}` · `{n:11|gtin}` als EAN-Inhalt.
|
||||||
|
|
||||||
|
## 5. Serien („Serie…“)
|
||||||
|
- **Zähler:** Start, Schritt und Anzahl festlegen. Beispiel: Start 100, Schritt 5, Anzahl 10 → 100, 105, …, 145.
|
||||||
|
- **CSV-Datei:** Erste Zeile = Spaltennamen, danach pro Zeile ein Etikett. Trennzeichen `;` oder `,`, Kodierung UTF-8.
|
||||||
|
```
|
||||||
|
Name;Artikel;Nummer
|
||||||
|
Müller;Schraube M4;1001
|
||||||
|
Schmidt;Mutter M4;1002
|
||||||
|
```
|
||||||
|
Im Designer dann `{Name}`, `{Artikel}`, `{Nummer}` verwenden.
|
||||||
|
- **PDF-Seiten:** entstehen beim Import eines mehrseitigen PDFs.
|
||||||
|
- **Kopien** gelten pro Etikett der Serie. Gedruckt wird in Blöcken; im Verlauf erscheint nur das erste Etikett der Serie.
|
||||||
|
- Die Vorschau zeigt zuerst das erste Etikett; mit **‹ ›** neben „Serie…“ blätterst du durch alle Etiketten der Serie.
|
||||||
|
|
||||||
|
## 6. Raster („Raster…“)
|
||||||
|
Nur bei Endlosrollen. Mehrere Etiketten nebeneinander und untereinander auf einem Streifen:
|
||||||
|
1. *Etikettenbreite* in mm festlegen (z. B. 28 mm bei einer 62-mm-Rolle für zwei Spalten).
|
||||||
|
2. *Spalten × Zeilen* und *Abstand* einstellen.
|
||||||
|
3. Beim Drucken füllen aufeinanderfolgende Etiketten (Kopien oder Serie) das Raster. Passt das Raster nicht auf die Rolle, erscheint eine Fehlermeldung.
|
||||||
|
|
||||||
|
## 7. Vorlagen, Verlauf, Export, Backup
|
||||||
|
- **Vorlagen:** Mit „Als Vorlage speichern“ ablegen, in **Ordnern** sortieren, bearbeiten (Name/Ordner) und exportieren.
|
||||||
|
- **Verlauf:** Jeder Druck und jedes „Speichern“ landet hier. *Nochmal drucken*, *Bearbeiten* oder per *Auswählen* mehrere löschen/exportieren.
|
||||||
|
- **Export/Import:** Etiketten und Vorlagen als JSON-Datei, z. B. zum Weitergeben. **⬇ PNG/PDF** exportiert das aktuelle Etikett als Bild bzw. PDF.
|
||||||
|
- **Backup:** *Einstellungen → Datenbank sichern* speichert alles (inkl. Drucker und Passwort), *Wiederherstellen* spielt es zurück.
|
||||||
|
|
||||||
|
## 8. Drucker und Sicherheit
|
||||||
|
- **USB-Drucker** werden automatisch erkannt. Mehrere gleiche Modelle werden über den USB-Port unterschieden.
|
||||||
|
- **Netzwerkdrucker** (LAN/WLAN): *Einstellungen → Netzwerkdrucker hinzufügen* (Name, IP, Modell).
|
||||||
|
- **Passwortschutz:** *Einstellungen → Passwortschutz*. Ohne Schutz kann jeder im Netzwerk drucken – nur in vertrauenswürdigen Netzen betreiben.
|
||||||
|
|
||||||
|
## 9. Eingabe-Modus, QR-Assistent
|
||||||
|
- **Eingabe-Modus:** Auf jeder Vorlage gibt es den Button **Eingabe**. Er zeigt nur ein kleines Formular mit einem Feld für jeden eigenen Platzhalter (z. B. `{Name}`, `{Raum}`), eine Vorschau und die Kopienzahl – ohne den Designer. Ideal für Kolleginnen und Kollegen, die nur drucken sollen.
|
||||||
|
- **QR-Assistent:** Beim QR-Code in den Eigenschaften auf *QR-Assistent…* klicken. Typen: Link, WLAN-Zugang, Kontakt (vCard), E-Mail, Telefon, SMS, Standort, Freitext. Du füllst nur die Felder aus, der Inhalt wird richtig aufgebaut. Platzhalter sind auch hier erlaubt.
|
||||||
|
|
||||||
|
## 9a. Tastenkürzel
|
||||||
|
| Taste | Aktion |
|
||||||
|
|---|---|
|
||||||
|
| `Entf` | Ausgewähltes Element löschen |
|
||||||
|
| `Strg+Z` | Rückgängig |
|
||||||
|
| `Strg+Y` oder `Strg+Shift+Z` | Wiederholen |
|
||||||
|
| `Enter` / `Tab` / `Esc` | In der Tabellenzelle: übernehmen / nächste Zelle / abbrechen |
|
||||||
|
|
||||||
|
## 10. Häufige Probleme
|
||||||
|
| Problem | Lösung |
|
||||||
|
|---|---|
|
||||||
|
| Etikett wird abgeschnitten oder falsch positioniert | Rolle im Programm gleich der eingelegten Rolle? „gedreht“ prüfen. |
|
||||||
|
| Barcode wird nicht gedruckt | Eingabe prüfen (z. B. EAN-13 braucht 12 Ziffern). |
|
||||||
|
| Text sieht auf dem Papier anders aus | Dithering aus; Schrift nicht zu klein (unter ca. 6 pt). |
|
||||||
|
| Seite nicht erreichbar | Pi und Gerät im gleichen Netz? Alternativ per IP statt `labelpi.local`. |
|
||||||
|
| Weitere Hilfe | `docs/INSTALL.md` (Installation, Fehlerbehebung) |
|
||||||
|
|
||||||
|
## 11. Sprachen
|
||||||
|
Die Oberfläche gibt es in Deutsch und Englisch. Oben rechts in der Leiste wählst du die Sprache; sie wird pro Gerät gemerkt (beim ersten Start gilt die Browsersprache).
|
||||||
|
|
||||||
|
**Neue Sprache hinzufügen**
|
||||||
|
1. *Einstellungen → Sprachen → Vorlage für neue Sprache herunterladen* (`neue-sprache.json`).
|
||||||
|
2. Datei öffnen: links steht der deutsche Text (nicht ändern), rechts trägst du die Übersetzung ein. `{}` sind Platzhalter für Zahlen/Namen und müssen in gleicher Anzahl und Reihenfolge erhalten bleiben. Leere Einträge werden auf Deutsch angezeigt.
|
||||||
|
3. Ganz oben `"_name"` auf den Namen der Sprache setzen, z. B. `"Français"`.
|
||||||
|
4. Datei als **Sprachcode.json** speichern, z. B. `fr.json`.
|
||||||
|
5. *Sprachdatei hinzufügen (.json)* – oder die Datei in den Ordner `lang/` auf dem Pi legen. Eigene Dateien überstehen Updates und haben Vorrang vor eingebauten.
|
||||||
|
|
||||||
|
**Anleitung übersetzen (optional):** Diese Anleitung als `ANLEITUNG.fr.md` im Ordner `lang/` (oder `docs/`) ablegen – sie erscheint dann unter *Hilfe*, wenn die Sprache aktiv ist. Ohne Übersetzung wird die deutsche Anleitung gezeigt.
|
||||||
|
|
||||||
|
Platzhalter wie `{datum}` behalten ihre Namen; für Englisch gibt es Kurzformen (`{date}` …). Datum, Wochentag und Monatsnamen erscheinen in der gewählten Sprache. Möchtest du deine Übersetzung teilen, freut sich das Projekt über einen Pull Request.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# LabelPi – Installationsanleitung (Raspberry Pi + Brother QL)
|
||||||
|
|
||||||
|
Kurzfassung und Funktionsübersicht: [README](../README.md). Beispiel hier: Raspberry Pi 2 B + QL-500.
|
||||||
|
|
||||||
|
## 0. Was du brauchst
|
||||||
|
- Raspberry Pi 2 B, Netzteil (5 V / 2 A), microSD (8 GB+)
|
||||||
|
- **USB-WLAN-Stick** (der Pi 2 B hat kein WLAN eingebaut; am besten ein 2,4-GHz-Stick, der unter Raspberry Pi OS ohne Zusatztreiber läuft, z. B. mit Realtek-/MediaTek-Chip)
|
||||||
|
- Brother QL-500 per USB, 62-mm-Rolle eingelegt
|
||||||
|
|
||||||
|
## 1. SD-Karte vorbereiten
|
||||||
|
1. **Raspberry Pi Imager** starten → Gerät: *Raspberry Pi 2* → OS: *Raspberry Pi OS (other) → Raspberry Pi OS Lite (32-bit)*.
|
||||||
|
2. Zahnrad / „Einstellungen bearbeiten“:
|
||||||
|
- Hostname: `labelpi`
|
||||||
|
- Benutzer + Passwort festlegen
|
||||||
|
- WLAN: SSID und Passwort deines **Hotspots** (ggf. zusätzlich dein Heim-WLAN), Land `DE`
|
||||||
|
- SSH aktivieren (Passwort oder Schlüssel)
|
||||||
|
- Zeitzone `Europe/Berlin`
|
||||||
|
3. Schreiben, SD in den Pi, WLAN-Stick einstecken, Strom an. Erster Start dauert 1–2 Minuten.
|
||||||
|
|
||||||
|
> Hotspot-Tipp (iPhone): *Einstellungen → Persönlicher Hotspot → „Kompatibilität maximieren“* einschalten, damit er 2,4 GHz nutzt. Der Hotspot muss beim Einschalten des Pi bereits aktiv sein.
|
||||||
|
|
||||||
|
## 2. Per SSH verbinden
|
||||||
|
```
|
||||||
|
ssh DEIN_BENUTZER@labelpi.local
|
||||||
|
```
|
||||||
|
Klappt `.local` nicht, die IP in den Geräteinfos des Hotspots nachsehen.
|
||||||
|
|
||||||
|
## 3. Dateien auf den Pi bringen
|
||||||
|
**Variante A (ein Befehl, empfohlen):** direkt auf dem Pi
|
||||||
|
```
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/DEIN-USER/labelpi/main/install.sh | bash
|
||||||
|
```
|
||||||
|
Damit sind Schritt 3 und 4 erledigt, weiter mit Schritt 5.
|
||||||
|
|
||||||
|
**Variante B (manuell):**
|
||||||
|
Vom PC aus (Ordner `labelpi` aus dem ZIP entpackt):
|
||||||
|
```
|
||||||
|
scp -r labelpi DEIN_BENUTZER@labelpi.local:~
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Installieren
|
||||||
|
```
|
||||||
|
cd ~/labelpi
|
||||||
|
bash install.sh
|
||||||
|
```
|
||||||
|
Das Skript installiert Python-Pakete in einer venv, legt die USB-Rechte für Brother-Drucker an und richtet den Dienst `labelpi` ein (startet automatisch beim Booten, Port 80). Die Installation dauert auf dem Pi 2 einige Minuten.
|
||||||
|
|
||||||
|
## 5. Drucker prüfen
|
||||||
|
QL-500 einschalten, dann:
|
||||||
|
```
|
||||||
|
lsusb | grep 04f9 # sollte "Brother Industries" zeigen
|
||||||
|
sudo systemctl status labelpi
|
||||||
|
```
|
||||||
|
Falls der Drucker nicht erkannt wird: einmal aus- und wieder einschalten bzw. `sudo reboot`.
|
||||||
|
|
||||||
|
## 6. Benutzen
|
||||||
|
Im Browser (PC, iPad, iPhone, gleiches Netz/Hotspot): **http://labelpi.local**
|
||||||
|
- Grüner Punkt oben = Drucker gefunden.
|
||||||
|
- iPhone/iPad: Teilen → „Zum Home-Bildschirm“ für ein App-Symbol.
|
||||||
|
- Modelle mit Autoschnitt zeigen oben die Option „Autoschnitt“. Der QL-500 schneidet **nicht** automatisch: nach dem Druck Etikett am Cutter manuell abtrennen. Bei mehreren Kopien kommen die Etiketten hintereinander raus.
|
||||||
|
- Die Schrift wird auf dem jeweiligen Gerät gerendert (WYSIWYG: gedruckt wird, was du siehst). Auf iPad/PC können Schriftarten leicht abweichen.
|
||||||
|
|
||||||
|
Oben rechts gibt es Buttons zum **Neustarten** und **Herunterfahren** des Pi. Nach dem Herunterfahren erst Strom trennen, wenn die grüne LED nicht mehr blinkt.
|
||||||
|
|
||||||
|
## 7. Wartung
|
||||||
|
- Logs: `journalctl -u labelpi -f`
|
||||||
|
- Neustart: `sudo systemctl restart labelpi`
|
||||||
|
- **Backup:** Vorlagen und Verlauf liegen in `~/labelpi/labels.db` (einfach kopieren).
|
||||||
|
- Weiteres WLAN hinzufügen: `sudo nmcli device wifi connect "SSID" password "PASSWORT"`
|
||||||
|
- Update: neue Dateien drüberkopieren, dann `sudo systemctl restart labelpi`.
|
||||||
|
|
||||||
|
## 8. Fehlerbehebung
|
||||||
|
| Problem | Lösung |
|
||||||
|
|---|---|
|
||||||
|
| `ImportError: libopenjp2.so.7` (Dienst startet nicht) | `bash install.sh` erneut ausführen (installiert die fehlenden Bibliotheken), Prüfen mit `journalctl -u labelpi -n 30` |
|
||||||
|
| „Drucker nicht gefunden“ | Strom/USB prüfen, `lsusb`, Dienst neu starten |
|
||||||
|
| „Resource busy“ / „Access denied“ | `echo 'blacklist usblp' \| sudo tee /etc/modprobe.d/blacklist-usblp.conf`, dann `sudo reboot` |
|
||||||
|
| Fehler zur Rollen-/Etikettengröße | Im Designer dieselbe Rolle wählen wie im Drucker eingelegt (QL-500 erkennt sie nicht selbst) |
|
||||||
|
| WLAN-Stick verbindet nicht mit Hotspot | `nmcli device wifi list` (sieht der Stick das Netz?), WLAN-Land `DE` setzen (`sudo raspi-config`), Hotspot auf **2,4 GHz** und **WPA2** stellen (viele Sticks können kein 5 GHz/WPA3) |
|
||||||
|
| Seite nicht erreichbar | Hotspot aktiv? Gleiches Netz? Alternativ per IP statt `labelpi.local` |
|
||||||
|
|
||||||
|
## 9. Sicherheit
|
||||||
|
Optional: Passwortschutz unter *Einstellungen*. Ohne Passwort gibt es **keinen Login**. Nutze die Seite nur im eigenen Hotspot/Heimnetz mit starkem WLAN-Passwort, nicht im Firmen-WLAN.
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# LabelPi-Installation auf Raspberry Pi OS Lite.
|
||||||
|
# Ein-Befehl-Variante: curl -fsSL https://raw.githubusercontent.com/DEIN-USER/labelpi/main/install.sh | bash
|
||||||
|
set -e
|
||||||
|
REPO="${LABELPI_REPO:-https://github.com/DEIN-USER/labelpi.git}"
|
||||||
|
if [ -f "$(dirname "$0")/app.py" ]; then
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
else
|
||||||
|
sudo apt update && sudo apt install -y git
|
||||||
|
if [ -d "$HOME/labelpi/.git" ]; then git -C "$HOME/labelpi" pull; else git clone "$REPO" "$HOME/labelpi"; fi
|
||||||
|
cd "$HOME/labelpi"
|
||||||
|
fi
|
||||||
|
DIR=$(pwd); U=${SUDO_USER:-$USER}
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y python3-venv poppler-utils libusb-1.0-0 libopenjp2-7 libtiff6 libwebp7 libjpeg62-turbo liblcms2-2 libfreetype6 libharfbuzz0b libxcb1
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -U pip
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="04f9", MODE="0666"' | sudo tee /etc/udev/rules.d/99-brother-ql.rules >/dev/null
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
echo "$U ALL=(root) NOPASSWD: /usr/bin/systemctl reboot, /usr/bin/systemctl poweroff" | sudo tee /etc/sudoers.d/labelpi >/dev/null
|
||||||
|
sudo chmod 440 /etc/sudoers.d/labelpi
|
||||||
|
sudo tee /etc/systemd/system/labelpi.service >/dev/null <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=LabelPi Etikettendruck
|
||||||
|
After=network-online.target
|
||||||
|
[Service]
|
||||||
|
User=$U
|
||||||
|
WorkingDirectory=$DIR
|
||||||
|
ExecStart=$DIR/.venv/bin/python app.py
|
||||||
|
Restart=always
|
||||||
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable labelpi
|
||||||
|
sudo systemctl restart labelpi
|
||||||
|
echo "Fertig. Aufruf im Browser: http://$(hostname).local"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
flask
|
||||||
|
pillow
|
||||||
|
qrcode
|
||||||
|
brother_ql
|
||||||
|
pyusb
|
||||||
|
python-barcode
|
||||||
|
ppf.datamatrix
|
||||||
|
markdown
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
@font-face{font-family:'Roboto';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/roboto-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Roboto';font-style:italic;font-weight:400;font-display:swap;src:url(fonts/roboto-latin-400-italic.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Roboto';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/roboto-latin-700-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Roboto';font-style:italic;font-weight:700;font-display:swap;src:url(fonts/roboto-latin-700-italic.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Roboto Condensed';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/roboto-condensed-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Roboto Condensed';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/roboto-condensed-latin-700-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/open-sans-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Open Sans';font-style:italic;font-weight:400;font-display:swap;src:url(fonts/open-sans-latin-400-italic.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Open Sans';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/open-sans-latin-700-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Open Sans';font-style:italic;font-weight:700;font-display:swap;src:url(fonts/open-sans-latin-700-italic.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Montserrat';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/montserrat-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Montserrat';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/montserrat-latin-700-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Lato';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/lato-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Lato';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/lato-latin-700-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Oswald';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/oswald-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Oswald';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/oswald-latin-700-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Anton';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/anton-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Bebas Neue';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/bebas-neue-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Playfair Display';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/playfair-display-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Playfair Display';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/playfair-display-latin-700-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Lora';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/lora-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Lora';font-style:italic;font-weight:400;font-display:swap;src:url(fonts/lora-latin-400-italic.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Lora';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/lora-latin-700-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Lora';font-style:italic;font-weight:700;font-display:swap;src:url(fonts/lora-latin-700-italic.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Roboto Mono';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/roboto-mono-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Roboto Mono';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/roboto-mono-latin-700-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Special Elite';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/special-elite-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Pacifico';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/pacifico-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Lobster';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/lobster-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Dancing Script';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/dancing-script-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Dancing Script';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/dancing-script-latin-700-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Caveat';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/caveat-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Caveat';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/caveat-latin-700-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Permanent Marker';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/permanent-marker-latin-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Noto Sans Symbols 2';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/noto-sans-symbols-2-symbols-400-normal.woff2) format('woff2')}
|
||||||
|
@font-face{font-family:'Noto Sans Symbols';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/noto-sans-symbols-symbols-400-normal.woff2) format('woff2')}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,438 @@
|
|||||||
|
<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1"><title>LabelPi</title><meta name="theme-color" content="#12141a"><link rel="stylesheet" href="/static/fonts.css">
|
||||||
|
<style>
|
||||||
|
:root{--bg:#12141a;--p:#1b1f27;--p2:#252a35;--ln:#323847;--tx:#e8eaf0;--mu:#9aa4b5;--ac:#4c8dff;--rd:#ff6b6b;color-scheme:dark}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;font:15px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--bg);color:var(--tx);-webkit-text-size-adjust:100%}
|
||||||
|
header{position:sticky;top:0;z-index:5;display:flex;flex-wrap:wrap;gap:10px;align-items:center;padding:10px 14px;background:var(--p);border-bottom:1px solid var(--ln)}
|
||||||
|
.bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:8px 14px;background:var(--p);border-bottom:1px solid var(--ln)}
|
||||||
|
.logo{font-size:18px;font-weight:700;letter-spacing:.3px}
|
||||||
|
button,select,input,textarea{font:inherit;color:var(--tx);background:var(--p2);border:1px solid var(--ln);border-radius:8px;padding:7px 11px;outline:0}
|
||||||
|
input:focus,select:focus,textarea:focus{border-color:var(--ac);box-shadow:0 0 0 2px #4c8dff33}
|
||||||
|
button{cursor:pointer;transition:.15s}button:hover{background:#2f3542;border-color:#44506a}
|
||||||
|
button.p{background:var(--ac);border-color:var(--ac);color:#fff;font-weight:600}button.p:hover{background:#3f7de8}
|
||||||
|
button.d{color:var(--rd)}button:disabled{opacity:.6}
|
||||||
|
input[type=number]{width:80px}input[type=checkbox]{width:18px;height:18px;padding:0;accent-color:var(--ac);vertical-align:middle}
|
||||||
|
input[type=range]{accent-color:var(--ac);padding:0;border:0;background:none;width:110px;vertical-align:middle}
|
||||||
|
label{color:var(--mu);font-size:13px}header label,.bar label{display:flex;gap:6px;align-items:center}
|
||||||
|
nav.bar{padding:0 14px;gap:0}nav button{border:0;border-radius:0;background:none;border-bottom:3px solid transparent;padding:11px 14px;color:var(--mu)}
|
||||||
|
nav button:hover{background:none;color:var(--tx)}nav button.on{border-bottom-color:var(--ac);color:var(--tx);font-weight:600}
|
||||||
|
nav #rb,nav #sd{border:1px solid var(--ln);border-radius:8px;padding:5px 10px;margin:6px 0 6px 6px;font-size:13px}
|
||||||
|
#st{width:12px;height:12px;border-radius:50%;background:#667}#st.ok{background:#3ddc84;box-shadow:0 0 8px #3ddc84}#st.no{background:#ff5252}
|
||||||
|
#wrap{max-height:56vh;overflow:auto;padding:20px 14px;text-align:center;background:#0d0f13 radial-gradient(#252b38 1px,transparent 1px) 0 0/18px 18px}
|
||||||
|
canvas{background:#fff;box-shadow:0 6px 28px #000b;border-radius:2px;touch-action:none;max-width:none}
|
||||||
|
#panel{position:sticky;bottom:0;z-index:4;max-height:38vh;overflow:auto;box-shadow:0 -4px 14px #0008;padding:12px 14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px;background:var(--p);border-top:1px solid var(--ln)}
|
||||||
|
#panel label{display:flex;flex-direction:column;gap:3px}#panel .w{grid-column:1/-1}#panel>span.w{color:var(--mu)}
|
||||||
|
.list{padding:14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px}
|
||||||
|
.card{background:var(--p);border:1px solid var(--ln);border-radius:12px;padding:10px;display:flex;flex-direction:column;gap:8px}
|
||||||
|
.card img{max-width:100%;max-height:200px;object-fit:contain;border-radius:6px;background:#fff}.card small{color:var(--mu)}
|
||||||
|
.hide{display:none!important}
|
||||||
|
dialog{background:var(--p);color:var(--tx);border:1px solid var(--ln);border-radius:14px;padding:18px;width:min(380px,92vw)}
|
||||||
|
dialog::backdrop{background:#000b}#df{display:flex;flex-direction:column;gap:12px}#df h3{margin:0}#df label{display:flex;flex-direction:column;gap:4px}
|
||||||
|
#df .row{display:flex;gap:8px;justify-content:flex-end}
|
||||||
|
details.fo>summary{cursor:pointer;padding:12px 14px;font-weight:600;background:var(--p);border-bottom:1px solid var(--ln);position:sticky;top:0}
|
||||||
|
details.fo>summary small{color:var(--mu);font-weight:400}
|
||||||
|
.card{position:relative}.card.on{border-color:var(--ac);box-shadow:0 0 0 2px #4c8dff66}.ck{position:absolute;top:14px;left:14px;width:24px;height:24px}
|
||||||
|
.set{padding:6px 14px 20px}.set h3{margin:18px 0 8px}.set .bar{background:none;border:0;padding:6px 0}
|
||||||
|
#sf{display:flex;flex-direction:column;gap:12px}#sf h3{margin:0}#sf label{display:flex;flex-direction:column;gap:4px}#sf .row{display:flex;gap:8px;justify-content:flex-end}#sc{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}#sf small{color:var(--mu)}
|
||||||
|
.al{display:flex;gap:6px;flex-wrap:wrap}.al button{padding:4px 9px;font-size:12px}
|
||||||
|
#gf{display:flex;flex-direction:column;gap:12px}#gf h3{margin:0}#gf label{display:flex;flex-direction:column;gap:4px}#gf .row{display:flex;gap:8px;justify-content:flex-end}#gc{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}#gf small{color:var(--mu)}
|
||||||
|
.help{max-width:920px;padding:6px 18px 40px;line-height:1.55}.help h1{font-size:24px}.help h2{margin-top:30px;border-bottom:1px solid var(--ln);padding-bottom:4px}.help table{border-collapse:collapse;margin:10px 0;display:block;overflow-x:auto}.help td,.help th{border:1px solid var(--ln);padding:6px 10px;text-align:left;vertical-align:top}.help th{background:var(--p2)}.help code{background:var(--p2);padding:1px 6px;border-radius:4px}.help pre{background:var(--p2);padding:10px;border-radius:8px;overflow-x:auto}.help pre code{padding:0}
|
||||||
|
.kv img{max-width:100%;max-height:220px;background:#fff;border-radius:6px;border:1px solid var(--ln)}#kf,#qf{display:flex;flex-direction:column;gap:12px}#kf h3,#qf h3{margin:0}#kf label,#qf label{display:flex;flex-direction:column;gap:4px}#kf .row,#qf .row{display:flex;gap:8px;justify-content:flex-end}#kfld,#qfld{display:flex;flex-direction:column;gap:8px}#kf small,.mu{color:var(--mu)}#lng{padding:5px 8px;margin:6px 0 6px 6px;font-size:13px}#pvw{display:inline-flex;gap:4px;align-items:center}
|
||||||
|
.sep{width:1px;height:22px;background:var(--ln);margin:0 4px}
|
||||||
|
#top{position:sticky;top:0;z-index:20}#top header{position:static}
|
||||||
|
#td>.bar{position:sticky;top:var(--toph,100px);z-index:15}
|
||||||
|
#panel{max-height:34vh;padding:8px 12px;gap:6px 8px;font-size:13px;grid-template-columns:repeat(auto-fill,minmax(112px,1fr))}
|
||||||
|
#panel input,#panel select,#panel textarea{padding:4px 7px;font-size:13px}#panel input[type=number]{width:100%}#panel details summary{cursor:pointer;color:var(--mu)}
|
||||||
|
.al label{flex-direction:row!important;align-items:center;gap:4px}
|
||||||
|
.adv{display:none;gap:10px;align-items:center;flex-wrap:wrap}body.adv-on .adv{display:flex}
|
||||||
|
.dd{position:relative}.ddm{position:absolute;right:0;top:calc(100% + 4px);z-index:30;background:var(--p);border:1px solid var(--ln);border-radius:10px;padding:6px;display:flex;flex-direction:column;gap:4px;min-width:230px;box-shadow:0 8px 24px #000a}
|
||||||
|
.ddm button{text-align:left;border:0;background:none}.ddm button:hover{background:var(--p2)}.advl{display:flex;gap:6px;align-items:center;margin-right:8px;font-size:13px}
|
||||||
|
@media(min-width:1100px){#td{display:grid;grid-template-columns:minmax(0,1fr) 340px;align-items:start}#td>.bar{grid-column:1/-1}
|
||||||
|
#wrap{max-height:calc(100vh - var(--toph,100px) - var(--tbh,52px) - 4px)}
|
||||||
|
#panel{position:sticky;top:calc(var(--toph,100px) + var(--tbh,52px));bottom:auto;max-height:calc(100vh - var(--toph,100px) - var(--tbh,52px));box-shadow:none;border-top:0;border-left:1px solid var(--ln);grid-template-columns:repeat(2,1fr);align-content:start}#panel .w{grid-column:1/-1}}
|
||||||
|
</style></head><body>
|
||||||
|
<div id="top"><header><b class="logo">🏷️ LabelPi</b><span id="st" title="Drucker"></span><select id="pnt" class="hide"></select><select id="lab"></select>
|
||||||
|
<label id="lenw">Länge <input id="len" type="number" min="25" max="900" value="100"> mm</label>
|
||||||
|
|
||||||
|
<label>Kopien <input id="cp" type="number" min="1" max="50" value="1" style="width:60px"></label>
|
||||||
|
<span class="adv" id="advh"><label><input id="rot" type="checkbox"> gedreht</label><label><input id="dit" type="checkbox"> Dithering</label><label id="cutw" class="hide"><input id="cut" type="checkbox"> Autoschnitt</label><label id="cmw" class="hide"><input id="cmk" type="checkbox"> Schnittmarke</label></span><button class="p" id="pr">Drucken</button><button id="hlp">? Hilfe</button></header>
|
||||||
|
<nav class="bar"><button data-t="d" class="on">Designer</button><button data-t="t">Vorlagen</button><button data-t="h">Verlauf</button><button data-t="s">Einstellungen</button><span style="flex:1"></span><label class="advl"><input type="checkbox" id="advc"> Erweiterte Ansicht</label><select id="lng"></select><button id="rb">Neustart</button><button id="sd" class="d">Herunterfahren</button></nav></div>
|
||||||
|
<section id="td"><div class="bar">
|
||||||
|
<button data-a="text">+ Text</button><button data-a="qr">+ QR</button><button data-a="table">+ Tabelle</button><button data-a="bc">+ Barcode</button><button id="addimg">+ Bild</button>
|
||||||
|
<select id="addmore"><option value="">+ Mehr …</option><option value="pdf">PDF importieren</option><option value="rect">Rechteck</option>
|
||||||
|
<optgroup label="Formen"><option value="sh:circle">Kreis / Ellipse</option><option value="sh:line">Linie</option><option value="sh:arrow">Pfeil</option><option value="sh:tri">Dreieck</option></optgroup>
|
||||||
|
<optgroup label="Symbole"><option value="sy:⚠">⚠</option><option value="sy:☢">☢</option><option value="sy:☣">☣</option><option value="sy:☠">☠</option><option value="sy:⛔">⛔</option><option value="sy:♻">♻</option><option value="sy:✔">✔</option><option value="sy:✖">✖</option><option value="sy:★">★</option><option value="sy:♥">♥</option><option value="sy:⚡">⚡</option><option value="sy:☀">☀</option><option value="sy:❄">❄</option><option value="sy:✂">✂</option><option value="sy:⚙">⚙</option><option value="sy:✈">✈</option><option value="sy:☎">☎</option><option value="sy:✉">✉</option><option value="sy:♿">♿</option><option value="sy:⚓">⚓</option><option value="sy:☂">☂</option><option value="sy:☕">☕</option><option value="sy:⚖">⚖</option><option value="sy:⚑">⚑</option><option value="sy:➜">➜</option><option value="sy:➤">➤</option><option value="sy:❗">❗</option><option value="sy:❓">❓</option><option value="sy:⌂">⌂</option><option value="sy:⛽">⛽</option><option value="sy:♨">♨</option><option value="sy:☞">☞</option></optgroup></select>
|
||||||
|
<span class="sep"></span><button id="dup">Kopie</button><button id="del" class="d">Löschen</button><button id="un" title="Rückgängig (Strg+Z)">↶</button><button id="re" title="Wiederholen (Strg+Y)">↷</button>
|
||||||
|
<span class="adv"><span class="sep"></span><button id="ser">Serie…</button><span id="pvw" class="hide"><button id="pvp">‹</button> <b id="pvl"></b> <button id="pvn">›</button></span><button id="grd">Raster…</button><label>Zoom <input id="zm" type="range" min="10" max="100" value="40"></label></span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<div class="dd"><button id="ddb">Datei ▾</button><div class="ddm hide" id="ddm"><button id="svh">Im Verlauf speichern</button><button id="sv">Als Vorlage speichern</button><button id="dpng">⬇ PNG</button><button id="dpdf">⬇ PDF</button><button id="nw">Neu</button></div></div>
|
||||||
|
<input id="file" type="file" accept="image/*" class="hide"><input id="pdff" type="file" accept="application/pdf" class="hide"></div>
|
||||||
|
<div id="wrap"><canvas id="cv"></canvas></div><div id="panel"></div></section>
|
||||||
|
<section id="tt" class="hide"><div id="tl"></div></section>
|
||||||
|
<section id="th" class="hide"><div class="bar"><button id="hsel">Auswählen</button>
|
||||||
|
<span id="hact" class="hide"><button id="hall">Alle</button> <button id="hnone">Keine</button> <button id="hexp">Exportieren</button> <button id="hdel" class="d">Löschen (0)</button> <button id="hend">Fertig</button></span></div>
|
||||||
|
<div class="list" id="hl"></div></section>
|
||||||
|
<section id="tf" class="hide"><div class="help" id="helpc" data-notr></div></section>
|
||||||
|
<section id="ts" class="hide"><div class="set">
|
||||||
|
<h3>Drucker</h3><div class="list" id="pl"></div>
|
||||||
|
<form id="pf" class="bar"><input id="pn" placeholder="Name" required><input id="ph" placeholder="IP oder Hostname" required><select id="pm"></select><button class="p">Netzwerkdrucker hinzufügen</button></form>
|
||||||
|
<h3>Passwortschutz</h3><div class="bar"><label><input type="checkbox" id="ae"> aktivieren</label><input id="au" placeholder="Benutzername" autocomplete="off"><input id="ap" type="password" placeholder="Neues Passwort" autocomplete="new-password"><button id="as" class="p">Speichern</button></div>
|
||||||
|
<h3>Daten</h3><div class="bar"><button id="imp">Importieren (.json)</button><input type="file" id="impf" accept=".json" class="hide"><button id="expt">Alle Vorlagen exportieren</button><button id="exph">Gesamten Verlauf exportieren</button></div><h3>Backup</h3><div class="bar"><button id="bk">Datenbank sichern (.db)</button><button id="rs">Sicherung wiederherstellen</button><input type="file" id="rsf" accept=".db" class="hide"></div>
|
||||||
|
<h3>Zähler</h3><div class="list" id="cnl"></div>
|
||||||
|
<div class="bar"><input id="cnn" placeholder="Name (z. B. rechnung)"><input id="cnv" type="number" value="1" min="0"><button id="cna">Zähler anlegen/setzen</button></div>
|
||||||
|
<small class="mu">Mit {lfd} oder {lfd.name} nummeriert LabelPi fortlaufend und merkt sich die nächste Nummer – auch nach Neustart und Gerätewechsel.</small>
|
||||||
|
<h3>Sprachen</h3><div class="bar"><button id="lup">Sprachdatei hinzufügen (.json)</button><input type="file" id="lupf" accept=".json" class="hide"><button id="ltpl">Vorlage für neue Sprache herunterladen</button><button id="ldel" class="d">Eigene Sprache entfernen</button></div>
|
||||||
|
<small class="mu">Dateiname = Sprachcode (z. B. fr.json). Aufbau und Übersetzung der Anleitung: siehe Hilfe, Abschnitt „Sprachen“.</small>
|
||||||
|
<h3>Über</h3><p style="color:var(--mu)">LabelPi · MIT-Lizenz · entstanden in Zusammenarbeit mit Claude (Anthropic)</p></div></section>
|
||||||
|
<dialog id="qdlg"><form method="dialog" id="qf"><h3>QR-Assistent</h3>
|
||||||
|
<label>Typ<select id="qt"><option value="url">Link (URL)</option><option value="wifi">WLAN-Zugang</option><option value="vcard">Kontakt (vCard)</option><option value="mail">E-Mail</option><option value="tel">Telefon</option><option value="sms">SMS</option><option value="geo">Standort</option><option value="text">Freitext</option></select></label>
|
||||||
|
<div id="qfld"></div><div class="row"><button type="button" onclick="$('#qdlg').close()">Abbrechen</button><button class="p" value="ok">Übernehmen</button></div></form></dialog>
|
||||||
|
<dialog id="kdlg"><form method="dialog" id="kf"><h3 id="kt"></h3><small>Eingabe-Modus</small><div id="kfld"></div><div class="kv"><img id="kimg" alt=""></div>
|
||||||
|
<label>Kopien<input id="kcp" type="number" min="1" max="50" value="1"></label><div class="row"><button type="button" onclick="$('#kdlg').close()">Abbrechen</button><button class="p" value="ok">Drucken</button></div></form></dialog>
|
||||||
|
<dialog id="gdlg"><form method="dialog" id="gf"><h3>Raster – mehrere Etiketten pro Streifen</h3>
|
||||||
|
<small>Nur bei Endlosrollen: Das Etikett wird in Spalten × Zeilen gedruckt (bei einer Serie mit jeweils anderem Inhalt).</small>
|
||||||
|
<label>Etikettenbreite in mm (leer = volle Rollenbreite)<input id="gcw" type="number" step="0.5" min="5"></label>
|
||||||
|
<div id="gc"><label>Spalten<input id="gcols" type="number" value="1" min="1" max="8"></label><label>Zeilen<input id="grows" type="number" value="1" min="1" max="20"></label><label>Abstand mm<input id="ggap" type="number" value="2" step="0.5" min="0"></label></div>
|
||||||
|
<div class="row"><button type="button" onclick="$('#gdlg').close()">Abbrechen</button><button class="p" value="ok">Übernehmen</button></div></form></dialog>
|
||||||
|
<dialog id="sdlg"><form method="dialog" id="sf"><h3>Serie / Seriennummern</h3>
|
||||||
|
<label>Modus<select id="sm"><option value="none">aus (einzelnes Etikett)</option><option value="count">Zähler</option><option value="csv">CSV-Datei</option><option value="pdf">PDF-Seiten (aus Import)</option></select></label>
|
||||||
|
<div id="sc"><label>Start<input id="s0" type="number" value="1"></label><label>Schritt<input id="s1" type="number" value="1"></label><label>Anzahl<input id="s2" type="number" value="10" min="1" max="500"></label></div>
|
||||||
|
<div id="sv2" class="hide"><input type="file" id="scsv" accept=".csv,.txt"><br><small id="sinfo"></small></div>
|
||||||
|
<small>Platzhalter in Text, QR, Barcode und Tabelle, z. B. {n}, {n:5}, {lfd}, {datum+30}, {kw}, {Spaltenname}. Alle Platzhalter stehen unter „Hilfe“.</small>
|
||||||
|
<div class="row"><button type="button" onclick="$('#sdlg').close()">Abbrechen</button><button class="p" value="ok">Übernehmen</button></div></form></dialog>
|
||||||
|
<dialog id="dlg"><form method="dialog" id="df"><h3 id="dt"></h3><label>Name<input id="dn" required></label>
|
||||||
|
<label>Ordner (optional)<input id="dfo" list="fl" placeholder="z. B. Lager, Kunden …"></label><datalist id="fl"></datalist>
|
||||||
|
<div class="row"><button type="button" onclick="$('#dlg').close()">Abbrechen</button><button class="p" value="ok">Speichern</button></div></form></dialog>
|
||||||
|
<script>
|
||||||
|
const $=s=>document.querySelector(s),MM=300/25.4,FL=[["Roboto", "Roboto, sans-serif"], ["Roboto Condensed", "Roboto Condensed, sans-serif"], ["Open Sans", "Open Sans, sans-serif"], ["Montserrat", "Montserrat, sans-serif"], ["Lato", "Lato, sans-serif"], ["Oswald", "Oswald, sans-serif"], ["Anton", "Anton, sans-serif"], ["Bebas Neue", "Bebas Neue, sans-serif"], ["Playfair Display", "Playfair Display, serif"], ["Lora", "Lora, serif"], ["Roboto Mono", "Roboto Mono, monospace"], ["Special Elite", "Special Elite, monospace"], ["Pacifico", "Pacifico, cursive"], ["Lobster", "Lobster, cursive"], ["Dancing Script", "Dancing Script, cursive"], ["Caveat", "Caveat, cursive"], ["Permanent Marker", "Permanent Marker, cursive"], ["Symbole", "'Noto Sans Symbols 2', 'Noto Sans Symbols', sans-serif"], ["System Sans", "Helvetica, Arial, sans-serif"], ["System Serif", "Georgia, serif"], ["System Mono", "Courier New, monospace"]];
|
||||||
|
const CV={x:MM,y:MM,w:MM,h:MM,lw:MM,bw:MM,s:300/72};
|
||||||
|
let selCell=null,lastTap=null,LB=[],S,sel=null,drag=null,guides=[],pend=0,ROLL={},U=[],RD=[],CTX={n:1},tmpRows=null,ct;const imgs={},tried={},cv=$('#cv'),cx=cv.getContext('2d');
|
||||||
|
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||||
|
const api=(u,o)=>fetch(u,o).then(r=>r.json().then(j=>{if(!r.ok)throw j.error||'Fehler';return j}));
|
||||||
|
const post=(u,b)=>api(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});
|
||||||
|
const T=f=>async(...a)=>{try{await f(...a)}catch(e){alert(tt('Fehler')+': '+tt(e))}};
|
||||||
|
const lab=()=>LB.find(l=>l.id==S.label);
|
||||||
|
function dims(){const l=lab(),al=l.endless?Math.round(S.len*MM):l.along,ac=l.endless&&S.cw?Math.min(l.across,Math.round(S.cw*MM)):l.across;return S.rot?[al,ac]:[ac,al]}
|
||||||
|
function fresh(id){const l=LB.find(l=>l.id==id);S={label:id,len:100,rot:l.endless||l.along>l.across,els:[]};sel=null;sync();resetU()}
|
||||||
|
function sync(){const l=lab();serBtn();setPrev();$('#lab').value=S.label;$('#len').value=S.len;$('#rot').checked=S.rot;$('#lenw').classList.toggle('hide',!l.endless);$('#cmw').classList.toggle('hide',!l.endless);size();draw();panel()}
|
||||||
|
function size(){const[w,h]=dims();cv.width=w;cv.height=h;const z=Math.max(10,Math.min(100,($('#wrap').clientWidth-24)/w*100));$('#zm').value=z;zoom()}
|
||||||
|
function zoom(){const z=$('#zm').value/100;cv.style.width=cv.width*z+'px';cv.style.height=cv.height*z+'px'}
|
||||||
|
const font=(e,s=e.s)=>`${e.i?'italic ':''}${e.b?'bold ':''}${s}px ${String(e.f).replace('Noto Sans Symbols 2, Noto Sans Symbols',"'Noto Sans Symbols 2', 'Noto Sans Symbols'")}`;
|
||||||
|
const bb=e=>e.t=='text'?[e.x,e.y,e.w,e.fit&&e.h?e.h:e.text.split('\n').length*e.s*1.2]:[e.x,e.y,e.w,e.h];
|
||||||
|
function load(e){const k=e.t=='qr'?'/qr?d='+encodeURIComponent(rv(e.text)):e.pg&&CTX._pg?CTX._pg:e.src;if(!imgs[k]){const i=new Image();pend++;i.onload=i.onerror=()=>{pend--;draw()};i.src=k;imgs[k]=i}return imgs[k]}
|
||||||
|
function tbl(e,ex){const R=rv(e.data).split('\n').map(r=>r.split(';')),nr=R.length,nc=Math.max(...R.map(r=>r.length)),cw=e.w/nc,rh=e.h/nr;
|
||||||
|
cx.fillStyle=cx.strokeStyle='#000';cx.textBaseline='middle';cx.textAlign='left';
|
||||||
|
R.forEach((r,i)=>r.forEach((t,j)=>{const f=n=>`${e.hd&&i==0?'bold ':''}${n}px ${e.f}`;let s=e.s;cx.font=f(s);const mw=cw-16,wd=cx.measureText(t).width;
|
||||||
|
if(wd>mw&&wd>0){s=Math.max(8,Math.floor(s*mw/wd));cx.font=f(s)}cx.fillText(t,e.x+j*cw+8,e.y+(i+.5)*rh)}));
|
||||||
|
if(e.cb)for(const k in e.cb){const[r,c]=k.split(',').map(Number),f=e.cb[k],x=e.x+c*cw,y=e.y+r*rh;if(r>=nr||c>=nc)continue;cx.lineWidth=e.bw||4;cx.beginPath();
|
||||||
|
if(f.includes('t')){cx.moveTo(x,y);cx.lineTo(x+cw,y)}if(f.includes('b')){cx.moveTo(x,y+rh);cx.lineTo(x+cw,y+rh)}if(f.includes('l')){cx.moveTo(x,y);cx.lineTo(x,y+rh)}if(f.includes('r')){cx.moveTo(x+cw,y);cx.lineTo(x+cw,y+rh)}cx.stroke()}
|
||||||
|
if(!ex&&sel===e&&selCell&&selCell.r<nr&&selCell.c<nc){cx.fillStyle='#4c8dff2a';cx.fillRect(e.x+selCell.c*cw,e.y+selCell.r*rh,cw,rh);cx.strokeStyle='#4c8dff';cx.lineWidth=3;cx.strokeRect(e.x+selCell.c*cw,e.y+selCell.r*rh,cw,rh);cx.strokeStyle='#000'}
|
||||||
|
if(e.bw>0&&e.bm!='none'){cx.lineWidth=e.bw;cx.strokeRect(e.x,e.y,e.w,e.h);if(e.bm!='outer'){cx.beginPath();
|
||||||
|
for(let i=1;i<nr;i++){cx.moveTo(e.x,e.y+i*rh);cx.lineTo(e.x+e.w,e.y+i*rh)}
|
||||||
|
if(e.bm=='all')for(let j=1;j<nc;j++){cx.moveTo(e.x+j*cw,e.y);cx.lineTo(e.x+j*cw,e.y+e.h)}cx.stroke()}}}
|
||||||
|
function fitS(e,L){let lo=6,hi=Math.max(8,Math.min(e.h,900))+1;while(hi-lo>1){const m=(lo+hi)>>1;cx.font=font(e,m);
|
||||||
|
if(L.length*m*1.2<=e.h&&Math.max(...L.map(l=>cx.measureText(l).width))<=e.w)lo=m;else hi=m}return lo}
|
||||||
|
function shp(e){const r=+e.rot||0,q=r==90||r==270,w=q?e.h:e.w,h=q?e.w:e.h,x=-w/2,y=-h/2,k=e.k,i=e.fill?0:e.lw;
|
||||||
|
cx.save();cx.translate(e.x+e.w/2,e.y+e.h/2);cx.rotate(r*Math.PI/180);cx.fillStyle=cx.strokeStyle='#000';cx.lineWidth=e.lw;cx.lineJoin='round';cx.beginPath();
|
||||||
|
if(k=='circle')cx.ellipse(0,0,Math.max(1,w/2-i/2),Math.max(1,h/2-i/2),0,0,7);
|
||||||
|
else if(k=='line'){if(w>=h){cx.moveTo(x,0);cx.lineTo(x+w,0)}else{cx.moveTo(0,y);cx.lineTo(0,y+h)}}
|
||||||
|
else if(k=='arrow'){[[0,.3],[.6,.3],[.6,0],[1,.5],[.6,1],[.6,.7],[0,.7]].forEach(([a,b],j)=>j?cx.lineTo(x+a*w,y+b*h):cx.moveTo(x+a*w,y+b*h));cx.closePath()}
|
||||||
|
else{cx.moveTo(0,y+i);cx.lineTo(x+w-i,y+h-i);cx.lineTo(x+i,y+h-i);cx.closePath()}
|
||||||
|
k=='line'||!e.fill?cx.stroke():cx.fill();cx.restore()}
|
||||||
|
function bcode(e,ex){const t=rv(e.text),k=e.bt+'|'+t;let m=BC[k];
|
||||||
|
if(m===undefined){BC[k]=null;m=null;pend++;api('/api/bc?t='+e.bt+'&d='+encodeURIComponent(t)).then(r=>{BC[k]=r.m}).catch(()=>{BC[k]=''}).finally(()=>{pend--;draw()})}
|
||||||
|
if(Array.isArray(m)){const n=m.length,mw=Math.max(1,Math.floor(Math.min(e.w,e.h)/n)),x0=e.x+(e.w-mw*n)/2,y0=e.y+(e.h-mw*n)/2;cx.fillStyle='#000';
|
||||||
|
m.forEach((r,j)=>{for(let i=0;i<r.length;i++)if(r[i]=='1')cx.fillRect(x0+i*mw,y0+j*mw,mw,mw)});return}
|
||||||
|
const hh=e.txt?e.h-e.s*1.4:e.h;
|
||||||
|
if(m){const mw=Math.max(1,Math.floor(e.w/m.length)),x0=e.x+(e.w-mw*m.length)/2;cx.fillStyle='#000';
|
||||||
|
for(let i=0;i<m.length;){if(m[i]=='1'){let j=i;while(m[j]=='1')j++;cx.fillRect(x0+i*mw,e.y,(j-i)*mw,hh);i=j}else i++}
|
||||||
|
if(e.txt){cx.font=`${e.s}px ${e.f}`;cx.textAlign='center';cx.textBaseline='top';cx.fillText(t,e.x+e.w/2,e.y+hh+e.s*.15)}}
|
||||||
|
else if(m===''&&!ex){cx.fillStyle='#fdd';cx.fillRect(e.x,e.y,e.w,e.h);cx.fillStyle='#c00';cx.font='40px sans-serif';cx.textAlign='left';cx.textBaseline='top';cx.fillText('Barcode ungültig',e.x+8,e.y+8)}}
|
||||||
|
function draw(ex){cx.fillStyle='#fff';cx.fillRect(0,0,cv.width,cv.height);
|
||||||
|
for(const e of S.els){
|
||||||
|
if(e.t=='text'){const L=rv(e.text).split('\n'),fo=font(e);try{if(!document.fonts.check(fo)&&!tried[fo]){tried[fo]=1;pend++;document.fonts.load(fo).finally(()=>{pend--;draw()}).catch(()=>{})}}catch(x){}
|
||||||
|
const s=e.fit&&e.h?fitS(e,L):e.s;cx.font=font(e,s);cx.fillStyle='#000';cx.textBaseline='top';cx.textAlign=e.a;
|
||||||
|
const px=e.a=='left'?e.x:e.a=='center'?e.x+e.w/2:e.x+e.w,y0=e.fit&&e.h?e.y+(e.h-L.length*s*1.2)/2:e.y;L.forEach((l,k)=>cx.fillText(l,px,y0+k*s*1.2))}
|
||||||
|
else if(e.t=='table')tbl(e,ex);else if(e.t=='shape')shp(e);else if(e.t=='bc')bcode(e,ex);else if(e.t=='rect'){cx.fillStyle=cx.strokeStyle='#000';if(e.fill)cx.fillRect(e.x,e.y,e.w,e.h);else{cx.lineWidth=e.lw;cx.strokeRect(e.x+e.lw/2,e.y+e.lw/2,e.w-e.lw,e.h-e.lw)}}
|
||||||
|
else{const im=load(e);if(im.complete&&im.naturalWidth)cx.drawImage(im,e.x,e.y,e.w,e.h)}}
|
||||||
|
if(!ex&&sel){const[x,y,w,h]=bb(sel);cx.strokeStyle=cx.fillStyle='#0a6cff';cx.lineWidth=3;cx.setLineDash([12,8]);cx.strokeRect(x,y,w,h);cx.setLineDash([]);cx.fillRect(x+w-14,y+h-14,28,28)}
|
||||||
|
if(!ex)guides.forEach(g=>{cx.strokeStyle='#ff3df5';cx.lineWidth=2;cx.beginPath();if(g.v!=null){cx.moveTo(g.v,0);cx.lineTo(g.v,cv.height)}else{cx.moveTo(0,g.h);cx.lineTo(cv.width,g.h)}cx.stroke()})
|
||||||
|
if(lab().endless&&$('#cmk').checked&&!($('#cut').checked&&curP()&&curP().cutting)){cx.save();cx.strokeStyle='#000';cx.lineWidth=3;cx.setLineDash([16,12]);cx.beginPath();if(S.rot){cx.moveTo(cv.width-5,0);cx.lineTo(cv.width-5,cv.height)}else{cx.moveTo(0,cv.height-5);cx.lineTo(cv.width,cv.height-5)}cx.stroke();cx.restore()}}
|
||||||
|
const AL='<div class="w al"><button data-al="l">Links</button><button data-al="c">Mitte ↔</button><button data-al="r">Rechts</button><button data-al="t">Oben</button><button data-al="m">Mitte ↕</button><button data-al="b">Unten</button></div>';
|
||||||
|
const fsel=()=>`<label>Schriftart<select data-k="f">${FL.map(([n,f])=>`<option value="${f}" style="font-family:${f}">${n}</option>`).join('')}</select></label>`;
|
||||||
|
function panel(){const p=$('#panel'),e=sel;if(!e){p.innerHTML='<span class="w">Element antippen oder oben hinzufügen. Ecke unten rechts = Größe ändern.</span>';return}
|
||||||
|
const n=(k,l)=>`<label>${l}<input type="number" step="0.5" data-k="${k}"></label>`,c=(k,l)=>`<label><span><input type="checkbox" data-k="${k}"> ${l}</span></label>`;
|
||||||
|
let h=AL+n('x','X mm')+n('y','Y mm')+n('w','Breite mm')+(e.t=='qr'||e.t=='text'?'':n('h','Höhe mm'));
|
||||||
|
if(e.t=='text')h+=`<label class="w">Text<textarea rows="3" data-k="text"></textarea></label>`+n('s','Schrift pt')+c('fit','Auto-Größe (füllt die Box)')+n('h','Box-Höhe mm')+`<label>Schriftart<select data-k="f">${FL.map(([n,f])=>`<option value="${f}" style="font-family:${f}">${n}</option>`).join('')}</select></label><label>Ausrichtung<select data-k="a"><option value="left">links</option><option value="center">mittig</option><option value="right">rechts</option></select></label>`+c('b','fett')+c('i','kursiv');
|
||||||
|
if(e.t=='qr')h+=`<label class="w">Inhalt<textarea rows="2" data-k="text"></textarea></label><div class="w"><button data-qa="1">QR-Assistent…</button></div>`;
|
||||||
|
if(e.t=='rect'||e.t=='shape')h+=c('fill','gefüllt')+n('lw','Linie mm');
|
||||||
|
if(e.t=='shape')h+=`<label>Drehung<select data-k="rot">${[0,45,90,135,180,225,270,315].map(d=>`<option value="${d}">${d}°</option>`).join('')}</select></label>`;
|
||||||
|
if(e.t=='table')h+=`<details class="w"><summary>Inhalt als Text bearbeiten</summary><label>Inhalt (Zeile = Tabellenzeile, Spalten mit ; trennen)<textarea rows="4" data-k="data"></textarea></label></details>`+n('s','Schrift pt')+fsel()+`<label>Rahmen<select data-k="bm"><option value="all">alle Linien</option><option value="outer">nur außen</option><option value="rows">Zeilenlinien</option><option value="none">keiner (nur Zellrahmen)</option></select></label>`+n('bw','Linie mm')+c('hd','1. Zeile fett')+`<div class="w al"><button data-tm="+r">+ Zeile</button><button data-tm="-r">− Zeile</button><button data-tm="+c">+ Spalte</button><button data-tm="-c">− Spalte</button></div><div class="w al"><b id="cl"></b><label><input type="checkbox" data-cb="t"> oben</label><label><input type="checkbox" data-cb="b"> unten</label><label><input type="checkbox" data-cb="l"> links</label><label><input type="checkbox" data-cb="r"> rechts</label></div>`;
|
||||||
|
if(e.t=='bc')h+=`<label class="w">Inhalt<input data-k="text"></label><label>Typ<select data-k="bt"><option value="code128">Code 128</option><option value="ean13">EAN-13</option><option value="ean8">EAN-8</option><option value="code39">Code 39</option><option value="gs1_128">GS1-128 (z. B. (01)0950…)</option><option value="dm">Datamatrix</option></select></label>`+n('s','Schrift pt')+fsel()+c('txt','Text darunter');
|
||||||
|
p.innerHTML=h;p.querySelectorAll('[data-k]').forEach(i=>{const t=KT[i.dataset.k];if(t)(i.closest('label')||i).title=t});p.querySelectorAll('[data-al]').forEach(b=>b.title='Am Etikett ausrichten');
|
||||||
|
p.querySelectorAll('[data-tm]').forEach(b=>b.title='Tabellenzeilen/-spalten ändern');p.querySelectorAll('[data-cb]').forEach(b=>b.title='Rahmen nur für die ausgewählte Zelle');fillPanel()}
|
||||||
|
function fillPanel(){if(!sel)return;document.querySelectorAll('#panel [data-k]').forEach(i=>{const k=i.dataset.k,v=sel[k];if(document.activeElement===i)return;if(i.type=='checkbox')i.checked=!!v;else i.value=v==null?'':CV[k]?+(v/CV[k]).toFixed(1):v});cellUI()}
|
||||||
|
$('#panel').oninput=ev=>{const i=ev.target,k=i.dataset.k;
|
||||||
|
if(i.dataset.cb&&sel&&selCell){const key=selCell.r+','+selCell.c;sel.cb=sel.cb||{};let f=sel.cb[key]||'';f=i.checked?f+i.dataset.cb:f.split(i.dataset.cb).join('');if(f)sel.cb[key]=f;else delete sel.cb[key];draw();commit();return}
|
||||||
|
if(!k||!sel)return;let v=i.type=='checkbox'?i.checked:i.value;if(CV[k])v=Math.round(parseFloat(v||0)*CV[k]);sel[k]=v;if(sel.t=='qr'&&k=='w')sel.h=v;if(k=='fit'&&v&&!sel.h)sel.h=Math.round(cv.height*.3);draw();if(k=='fit')fillPanel();clearTimeout(ct);ct=setTimeout(commit,500)};
|
||||||
|
function add(e){S.els.push(e);sel=e;draw();panel();commit()}
|
||||||
|
document.querySelectorAll('[data-a]').forEach(b=>b.onclick=()=>{const t=b.dataset.a,[W,H]=dims(),m=Math.round(Math.min(W,H)/6);
|
||||||
|
add(t=='text'?{t,x:m,y:m,w:Math.round(W*.6),s:100,f:FL[0][1],b:0,i:0,a:'left',text:'Text'}:t=='qr'?{t,x:m,y:m,w:Math.round(H*.6),h:Math.round(H*.6),text:'https://'}:t=='bc'?{t:'bc',x:m,y:m,w:Math.round(W*.6),h:Math.round(H*.4),text:'12345678',bt:'code128',txt:1,s:40,f:FL[0][1]}:t=='table'?{t:'table',x:m,y:m,w:Math.round(W*.7),h:Math.round(H*.45),data:'Name;Wert\nA;1\nB;2',s:60,f:FL[0][1],hd:1,bm:'all',bw:4}:{t:'rect',x:m,y:m,w:Math.round(W/3),h:Math.round(H/3),fill:0,lw:6})});
|
||||||
|
$('#addimg').onclick=()=>$('#file').click();
|
||||||
|
$('#file').onchange=ev=>{const f=ev.target.files[0];ev.target.value='';if(!f)return;const r=new FileReader();r.onload=()=>{const im=new Image();im.onload=()=>{
|
||||||
|
const k=Math.min(1,1200/Math.max(im.width,im.height)),c=document.createElement('canvas');c.width=Math.round(im.width*k);c.height=Math.round(im.height*k);
|
||||||
|
const g=c.getContext('2d');g.fillStyle='#fff';g.fillRect(0,0,c.width,c.height);g.drawImage(im,0,0,c.width,c.height);
|
||||||
|
const[W,H]=dims(),w=Math.round(Math.min(W,H*im.width/im.height)*.6);add({t:'img',x:20,y:20,w,h:Math.round(w*im.height/im.width),src:c.toDataURL('image/jpeg',.9)})};im.src=r.result};r.readAsDataURL(f)};
|
||||||
|
$('#dup').onclick=()=>{if(!sel)return;const c=JSON.parse(JSON.stringify(sel));c.x+=30;c.y+=30;add(c)};
|
||||||
|
function delSel(){if(!sel)return;S.els=S.els.filter(e=>e!==sel);sel=null;draw();panel();commit()}
|
||||||
|
$('#del').onclick=delSel;onkeydown=e=>{if(document.activeElement!=document.body)return;if(e.key=='Delete')delSel();else if(e.ctrlKey||e.metaKey){if(e.key=='z'&&!e.shiftKey){e.preventDefault();undo()}else if(e.key=='y'||(e.key=='z'&&e.shiftKey)){e.preventDefault();redo()}}};
|
||||||
|
function pt(ev){const r=cv.getBoundingClientRect();return[(ev.clientX-r.left)*cv.width/r.width,(ev.clientY-r.top)*cv.height/r.height]}
|
||||||
|
cv.onpointerdown=ev=>{const[px,py]=pt(ev);let hit=null,mode='m';
|
||||||
|
if(sel){const[x,y,w,h]=bb(sel);if(Math.abs(px-(x+w))<35&&Math.abs(py-(y+h))<35){hit=sel;mode='r'}}
|
||||||
|
if(!hit)for(let i=S.els.length-1;i>=0;i--){const[x,y,w,h]=bb(S.els[i]);if(px>=x&&px<=x+w&&py>=y&&py<=y+h){hit=S.els[i];break}}
|
||||||
|
const ch=hit!==sel;sel=hit;if(ch)selCell=null;
|
||||||
|
if(hit&&hit.t=='table'&&mode=='m'){const cc=cellAt(hit,px,py),now=Date.now();selCell={r:cc.r,c:cc.c};
|
||||||
|
if(lastTap&&lastTap.e===hit&&lastTap.r==cc.r&&lastTap.c==cc.c&&now-lastTap.t<450&&ev.pointerType&&ev.pointerType!='mouse'){lastTap=null;drag=null;draw();if(ch)panel();else fillPanel();setTimeout(()=>editCell(hit,cc.r,cc.c),60);return}
|
||||||
|
lastTap={e:hit,r:cc.r,c:cc.c,t:now}}
|
||||||
|
drag=hit&&{mode,px,py,x:hit.x,y:hit.y,w:hit.w,h:hit.h};if(drag)cv.setPointerCapture(ev.pointerId);draw();if(ch)panel();else fillPanel()};
|
||||||
|
cv.onpointermove=ev=>{if(!drag)return;const[px,py]=pt(ev),dx=px-drag.px,dy=py-drag.py,e=sel;
|
||||||
|
if(drag.mode=='m'){let nx=drag.x+dx,ny=drag.y+dy;const[,,w,h]=bb(e),z=$('#zm').value/100,th=Math.max(8,8/z),xs=[0,cv.width/2,cv.width],ys=[0,cv.height/2,cv.height];
|
||||||
|
S.els.forEach(o=>{if(o!==e){const[a,b,c,d]=bb(o);xs.push(a,a+c/2,a+c);ys.push(b,b+d/2,b+d)}});
|
||||||
|
let sx=null,sy=null,bx=th,by=th;guides=[];
|
||||||
|
for(const X of xs)for(const o of[0,w/2,w]){const d=X-(nx+o);if(Math.abs(d)<=Math.abs(bx)&&Math.abs(d)<th){bx=d;sx=X}}
|
||||||
|
for(const Y of ys)for(const o of[0,h/2,h]){const d=Y-(ny+o);if(Math.abs(d)<=Math.abs(by)&&Math.abs(d)<th){by=d;sy=Y}}
|
||||||
|
if(sx!=null){nx+=bx;guides.push({v:sx})}if(sy!=null){ny+=by;guides.push({h:sy})}
|
||||||
|
e.x=Math.round(nx);e.y=Math.round(ny)}
|
||||||
|
else{e.w=Math.max(20,Math.round(drag.w+dx));if(e.t=='qr')e.h=e.w;else if(e.t=='img')e.h=Math.round(e.w*drag.h/drag.w);else if(e.t=='rect'||e.t=='table'||e.t=='bc'||e.t=='shape')e.h=Math.max(6,Math.round(drag.h+dy))}
|
||||||
|
draw();fillPanel()};
|
||||||
|
cv.onpointerup=()=>{if(drag){drag=null;guides=[];draw();commit()}};
|
||||||
|
$('#zm').oninput=zoom;
|
||||||
|
$('#lab').onchange=e=>{const l=LB.find(l=>l.id==e.target.value);S.label=l.id;S.rot=l.endless||l.along>l.across;sync();sv_()};
|
||||||
|
$('#len').oninput=e=>{S.len=Math.min(900,Math.max(25,+e.target.value||100));size();draw();sv_()};
|
||||||
|
$('#rot').onchange=e=>{S.rot=e.target.checked;size();draw();sv_()};
|
||||||
|
let tm;const sv_=()=>{clearTimeout(tm);tm=setTimeout(()=>post('/api/settings',{label:S.label,len:S.len,rot:S.rot,dither:$('#dit').checked,printer:$('#pnt').value,cut:$('#cut').checked,cw:S.cw||0,grid:S.grid||null,cmk:$('#cmk').checked}).catch(()=>{}),400)};
|
||||||
|
$('#dit').onchange=sv_;
|
||||||
|
const shot=()=>{draw(1);const p=cv.toDataURL('image/png');draw();return p};
|
||||||
|
async function doPrint(say){const pid=$('#pnt').value||'x',lr=ROLL[pid],nm=id=>tt((LB.find(l=>l.id==id)||{name:id}).name);
|
||||||
|
if(lr&&lr!=S.label&&!confirm(tt('Zuletzt war die Rolle „{}“ eingelegt. Ist jetzt „{}“ eingelegt?',nm(lr),nm(S.label))))return false;
|
||||||
|
const g=lab().endless&&S.grid?S.grid:null,per=g?g.cols*g.rows:1,n=serN(),ch=per*Math.ceil(20/per),names=lfdNames(S);let rsv={},sent=false;
|
||||||
|
if(names.length){rsv=await post('/api/counters/reserve',{names:Object.fromEntries(names.map(x=>[x,n]))});Object.assign(CNT,rsv)}
|
||||||
|
try{for(let a=0;a<n;a+=ch){const pngs=[];for(let i=a;i<Math.min(n,a+ch);i++){if(n>1)say(`Etikett ${i+1}/${n}`);pngs.push(await render(i))}
|
||||||
|
say('Drucke…');await post('/api/print',{pngs,log:a==0,label:S.label,copies:+$('#cp').value||1,rotate:S.rot,dither:$('#dit').checked,design:dsg(),printer:$('#pnt').value,cut:$('#cut').checked,grid:g});sent=true}}
|
||||||
|
catch(e){if(!sent)for(const k in rsv){await post('/api/counters/set',{name:k,value:rsv[k]}).catch(()=>{});CNT[k]=rsv[k]}throw e}
|
||||||
|
for(const k in rsv)CNT[k]=rsv[k]+n;ROLL[pid]=S.label;post('/api/settings',{roll:ROLL}).catch(()=>{});return true}
|
||||||
|
$('#pr').onclick=T(async()=>{const b=$('#pr');b.disabled=true;b.textContent='Drucke…';try{if(await doPrint(x=>b.textContent=x))b.textContent='Gedruckt ✓'}finally{setPrev();draw();b.disabled=false;setTimeout(()=>b.textContent='Drucken',1500)}});
|
||||||
|
$('#sv').onclick=T(async()=>{const r=await ask('Als Vorlage speichern','',lastFolder);if(!r||!r.name)return;lastFolder=r.folder;await post('/api/templates',{name:r.name,folder:r.folder,label:S.label,design:dsg(),png:shot()});alert(tt('Vorlage gespeichert'))});
|
||||||
|
$('#nw').onclick=()=>{if(confirm(tt('Aktuelles Design verwerfen?')))fresh(S.label)};
|
||||||
|
function go(k){document.querySelectorAll('nav button').forEach(x=>x.classList.toggle('on',x.dataset.t==k));for(const j of['d','t','h','s','f'])$('#t'+j).classList.toggle('hide',j!=k);if(k=='d')sync();if(k=='t')tpl();if(k=='h')hist();if(k=='s')setp();if(k=='f')loadHelp()}
|
||||||
|
document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>go(b.dataset.t));
|
||||||
|
function open_(d){S=JSON.parse(d);if(!LB.find(l=>l.id==S.label))S.label='62';sel=null;go('d');resetU();sv_()}
|
||||||
|
let lastFolder='',hm=false,hs=new Set(),HL=[],TP={};
|
||||||
|
function ask(title,name='',folder=''){return new Promise(async res=>{const f=(await api('/api/templates').catch(()=>[])).map(t=>t.folder).filter(Boolean);
|
||||||
|
$('#fl').innerHTML=[...new Set(f)].map(x=>`<option value="${esc(x)}">`).join('');$('#dt').textContent=title;$('#dn').value=name;$('#dfo').value=folder;
|
||||||
|
const d=$('#dlg');d.returnValue='';d.onclose=()=>res(d.returnValue=='ok'?{name:$('#dn').value.trim(),folder:$('#dfo').value.trim()}:null);d.showModal()})}
|
||||||
|
const tcard=t=>`<div class="card"><img loading="lazy" src="/api/templates/${t.id}.png"><b>${esc(t.name)}</b><small>${esc(t.label)}</small><div><button class="p" onclick="useT(${t.id})">Verwenden</button> <button onclick="quick(${t.id})" title="Formular zum schnellen Ausfüllen und Drucken (ohne Designer)">Eingabe</button> <button onclick="editT(${t.id})">Bearbeiten</button> <button onclick="location.href='/api/export?kind=templates&ids=${t.id}'">Export</button> <button class="d" onclick="delT(${t.id})">Löschen</button></div></div>`;
|
||||||
|
const tpl=T(async()=>{const r=await api('/api/templates'),g={};TP={};r.forEach(t=>{TP[t.id]=t;(g[t.folder]=g[t.folder]||[]).push(t)});
|
||||||
|
const ks=Object.keys(g).sort((a,b)=>!a?1:!b?-1:a.localeCompare(b,'de'));
|
||||||
|
$('#tl').innerHTML=ks.map(k=>`<details open class="fo"><summary>${k?'📁 '+esc(k):'Ohne Ordner'} <small>(${g[k].length})</small></summary><div class="list">${g[k].map(tcard).join('')}</div></details>`).join('')||'<p style="padding:14px">Noch keine Vorlagen.</p>'});
|
||||||
|
const editT=T(async i=>{const t=TP[i],r=await ask('Vorlage bearbeiten',t.name,t.folder);if(!r||!r.name)return;await api('/api/templates/'+i,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(r)});tpl()});
|
||||||
|
const useT=T(async i=>open_((await api('/api/templates/'+i)).design));
|
||||||
|
const delT=T(async i=>{if(confirm(tt('Vorlage löschen?'))){await api('/api/templates/'+i,{method:'DELETE'});tpl()}});
|
||||||
|
const upd=()=>{$('#hdel').textContent=`Löschen (${hs.size})`;$('#hdel').disabled=!hs.size};
|
||||||
|
const hist=T(async()=>{const r=await api('/api/history');HL=r;$('#hsel').classList.toggle('hide',hm);$('#hact').classList.toggle('hide',!hm);upd();
|
||||||
|
$('#hl').innerHTML=r.map(h=>`<div class="card${hs.has(h.id)?' on':''}" data-id="${h.id}">${hm?`<input type="checkbox" class="ck"${hs.has(h.id)?' checked':''}>`:''}<img loading="lazy" src="/api/history/${h.id}.png"><small>${esc(h.ts)} · ${esc(h.label)} · ${h.copies?h.copies+'×':'nur gespeichert'}</small>${hm?'':`<div><button class="p" onclick="rep(${h.id})">Nochmal drucken</button> <button onclick="edit(${h.id})">Bearbeiten</button> <button class="d" onclick="delH(${h.id})">×</button></div>`}</div>`).join('')||'Noch nichts gedruckt.'});
|
||||||
|
$('#hl').onclick=ev=>{if(!hm)return;const c=ev.target.closest('.card');if(!c)return;const id=+c.dataset.id;hs.has(id)?hs.delete(id):hs.add(id);c.classList.toggle('on',hs.has(id));c.querySelector('.ck').checked=hs.has(id);upd()};
|
||||||
|
$('#hsel').onclick=()=>{hm=true;hist()};
|
||||||
|
$('#hend').onclick=()=>{hm=false;hs.clear();hist()};
|
||||||
|
$('#hall').onclick=()=>{HL.forEach(h=>hs.add(h.id));hist()};
|
||||||
|
$('#hnone').onclick=()=>{hs.clear();hist()};
|
||||||
|
$('#hdel').onclick=T(async()=>{if(!hs.size||!confirm(tt('{} Einträge löschen?',hs.size)))return;await post('/api/history/delete',{ids:[...hs]});hs.clear();hist()});
|
||||||
|
const rep=T(async i=>{await post('/api/history/'+i+'/reprint',{copies:+$('#cp').value||1,printer:$('#pnt').value,cut:$('#cut').checked});alert(tt('Gedruckt ✓'));hist()});
|
||||||
|
const edit=T(async i=>open_((await api('/api/history/'+i)).design));
|
||||||
|
const delH=T(async i=>{await api('/api/history/'+i,{method:'DELETE'});hist()});
|
||||||
|
const pw=a=>T(async()=>{if(confirm(tt(a=='reboot'?'Raspberry Pi neu starten?':'Raspberry Pi herunterfahren?'))){await post('/api/power',{action:a});alert(tt(a=='reboot'?'Neustart läuft, in ca. 1 Minute wieder erreichbar.':'Wird heruntergefahren. Danach Strom trennen.'))}});
|
||||||
|
$('#rb').onclick=pw('reboot');$('#sd').onclick=pw('poweroff');
|
||||||
|
let PR=[];const curP=()=>PR.find(p=>p.id==$('#pnt').value);
|
||||||
|
const cutUI=()=>{const p=curP();$('#cutw').classList.toggle('hide',!(p&&p.cutting))};
|
||||||
|
async function loadP(pref){PR=await api('/api/printers').catch(()=>[]);const cur=PR.find(p=>p.id==pref)||PR.find(p=>p.online)||PR[0];
|
||||||
|
$('#pnt').innerHTML=PR.map(p=>`<option value="${p.id}">${esc(p.name)}</option>`).join('');$('#pnt').classList.toggle('hide',PR.length<2);if(cur)$('#pnt').value=cur.id;cutUI()}
|
||||||
|
async function loadL(){LB=await api('/api/labels?printer='+encodeURIComponent($('#pnt').value||''));$('#lab').innerHTML=LB.map(x=>`<option value="${x.id}">${x.name}${x.dk?' ('+x.dk+')':''}</option>`).join('')}
|
||||||
|
$('#pnt').onchange=T(async()=>{cutUI();await loadL();if(LB.find(l=>l.id==S.label))sync();else{const l=LB.find(l=>l.id=='62')||LB[0];S.label=l.id;S.rot=l.endless||l.along>l.across;sync()}sv_()});
|
||||||
|
$('#cut').onchange=()=>{draw();sv_()};
|
||||||
|
const st=async()=>{try{const sv=$('#pnt').value,old=PR.map(p=>p.id).join();PR=await api('/api/printers');if(PR.map(p=>p.id).join()!=old)await loadP(sv);const p=curP();$('#st').className=p&&p.online?'ok':'no';$('#st').title=p?p.name:'Kein Drucker'}catch(e){$('#st').className='no'}};
|
||||||
|
const dl=(u,n)=>{const a=document.createElement('a');a.href=u;a.download=n;a.click()};
|
||||||
|
$('#dpng').onclick=()=>dl(shot(),'etikett.png');
|
||||||
|
$('#dpdf').onclick=T(async()=>{const r=await fetch('/api/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({png:shot()})});if(!r.ok)throw tt('PDF-Fehler');dl(URL.createObjectURL(await r.blob()),'etikett.pdf')});
|
||||||
|
$('#svh').onclick=T(async()=>{await post('/api/save',{png:shot(),label:S.label,rotate:S.rot,dither:$('#dit').checked,design:dsg()});alert(tt('Im Verlauf gespeichert (nicht gedruckt)'))});
|
||||||
|
$('#hexp').onclick=()=>{if(hs.size)location.href='/api/export?kind=history&ids='+[...hs]};
|
||||||
|
$('#expt').onclick=()=>location.href='/api/export?kind=templates';$('#exph').onclick=()=>location.href='/api/export?kind=history';
|
||||||
|
const setp=T(async()=>{const[pl,au,md,cn]=await Promise.all([api('/api/printers'),api('/api/auth'),api('/api/models'),api('/api/counters')]);
|
||||||
|
$('#cnl').innerHTML=Object.entries(cn).map(([k,v])=>`<div class="card"><b>{lfd${k=='default'?'':'.'+esc(k)}}</b><label>Nächste Nummer<input type="number" min="0" value="${v}"></label><div><button data-cn="${esc(k)}" onclick="cset(this)">Speichern</button> <button class="d" data-cn="${esc(k)}" onclick="cdel(this)">Löschen</button></div></div>`).join('')||'<p>Noch keine Zähler – sie entstehen automatisch beim ersten Druck mit {lfd}.</p>';
|
||||||
|
$('#pl').innerHTML=pl.map(p=>`<div class="card"><b>${esc(p.name)}</b><small>${esc(p.model)} · ${p.cutting?'Autoschnitt':'manueller Schnitt'} · ${p.online?'online':'offline'}</small>${p.host?`<div><button class="d" onclick="delP('${esc(p.host)}')">Entfernen</button></div>`:''}</div>`).join('')||'<p>Kein Drucker gefunden. USB-Drucker einschalten oder unten einen Netzwerkdrucker hinzufügen.</p>';
|
||||||
|
$('#pm').innerHTML=md.map(m=>`<option>${m}</option>`).join('');$('#ae').checked=au.enabled;$('#au').value=au.user});
|
||||||
|
const delP=T(async h=>{await post('/api/printers/delete',{host:h});await st();setp()});
|
||||||
|
$('#pf').onsubmit=T(async ev=>{ev.preventDefault();await post('/api/printers',{name:$('#pn').value,host:$('#ph').value,model:$('#pm').value});$('#pf').reset();await st();setp()});
|
||||||
|
$('#as').onclick=T(async()=>{await post('/api/auth',{enabled:$('#ae').checked,user:$('#au').value,password:$('#ap').value});$('#ap').value='';alert(tt('Gespeichert'))});
|
||||||
|
$('#imp').onclick=()=>$('#impf').click();
|
||||||
|
$('#impf').onchange=T(async ev=>{const f=ev.target.files[0];ev.target.value='';if(!f)return;const r=await post('/api/import',JSON.parse(await f.text()));alert(tt('{} Etikett(en) importiert',r.n))});
|
||||||
|
const BC={},z2=x=>String(x).padStart(2,'0');
|
||||||
|
/*PH*/
|
||||||
|
const ALI={date:'datum',time:'zeit',year:'jahr',month:'monat',weekday:'wochentag',cw:'kw',week:'kw',random:'zufall',total:'gesamt',day:'tag',monthname:'monatname'},DT=new Set(['datum','zeit','kw','monat','monatname','wochentag','jahr','tag']);
|
||||||
|
const kn=s=>{s=s.toLowerCase().replace(/^seq(?=\.|$)/,'lfd');return ALI[s]||s};
|
||||||
|
function parsePh(b){const[main,...fl]=b.split('|'),[n0,...ar]=main.split(':');let nm=n0.trim(),off=null;const m=nm.match(/^([A-Za-zäöüÄÖÜ]+)([+-]\d+[dwmjy]?)$/);if(m&&DT.has(kn(m[1]))){nm=m[1];off=m[2]}
|
||||||
|
return{nm,k:kn(nm),off,arg:ar.join(':').trim(),fl:fl.map(x=>x.trim().toLowerCase())}}
|
||||||
|
const rng=a=>()=>{a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296};
|
||||||
|
const isoWeek=d=>{const t=new Date(Date.UTC(d.getFullYear(),d.getMonth(),d.getDate())),n=t.getUTCDay()||7;t.setUTCDate(t.getUTCDate()+4-n);return Math.ceil(((t-Date.UTC(t.getUTCFullYear(),0,1))/864e5+1)/7)};
|
||||||
|
const fmtD=(d,f)=>f.replace(/JJJJ|YYYY/g,d.getFullYear()).replace(/JJ|YY/g,String(d.getFullYear()).slice(2)).replace(/TT|DD/g,z2(d.getDate())).replace(/MM/g,z2(d.getMonth()+1));
|
||||||
|
const luhn=s=>{let sum=0,alt=true;for(let i=s.length-1;i>=0;i--){let d=+s[i];if(isNaN(d)||s[i]==' ')continue;if(alt){d*=2;if(d>9)d-=9}sum+=d;alt=!alt}return String((10-sum%10)%10)};
|
||||||
|
const gtin=s=>{const ds=s.replace(/\D/g,'');let sum=0;for(let i=ds.length-1,w=3;i>=0;i--,w=4-w)sum+=+ds[i]*w;return String((10-sum%10)%10)};
|
||||||
|
function phVal(p){const{nm,k,off,arg}=p,pad=v=>/^\d+$/.test(arg)?String(v).padStart(+arg,'0'):v;
|
||||||
|
if(nm[0]!='_'&&nm in CTX)return pad(CTX[nm]);
|
||||||
|
if(k=='n'||k=='gesamt')return CTX[k]==null?null:pad(CTX[k]);
|
||||||
|
if(k=='lfd'||k.startsWith('lfd.'))return pad((CNT[k=='lfd'?'default':k.slice(4)]??1)+(CTX._i||0));
|
||||||
|
if(k=='uuid'){const r=rng(CTX._seed||1),h=()=>Math.floor(r()*16).toString(16);return'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,c=>c=='x'?h():(8+Math.floor(r()*4)).toString(16))}
|
||||||
|
if(k=='zufall'){const len=+arg||8,r=rng((CTX._seed||1)+len),A='23456789ABCDEFGHJKLMNPQRSTUVWXYZ';let s='';for(let i=0;i<len;i++)s+=A[Math.floor(r()*A.length)];return s}
|
||||||
|
if(!DT.has(k))return null;
|
||||||
|
const d=new Date();if(off){const m=off.match(/^([+-]\d+)([dwmjy]?)$/),v=+m[1],u=m[2]||'d';if(u=='d')d.setDate(d.getDate()+v);else if(u=='w')d.setDate(d.getDate()+7*v);else if(u=='m')d.setMonth(d.getMonth()+v);else d.setFullYear(d.getFullYear()+v)}
|
||||||
|
if(k=='datum')return fmtD(d,arg||'TT.MM.JJJJ');
|
||||||
|
if(k=='zeit')return(arg||'HH:MM').replace('HH',z2(d.getHours())).replace('MM',z2(d.getMinutes())).replace('SS',z2(d.getSeconds()));
|
||||||
|
if(k=='jahr')return d.getFullYear();if(k=='monat')return z2(d.getMonth()+1);if(k=='tag')return z2(d.getDate());if(k=='kw')return z2(isoWeek(d));
|
||||||
|
if(k=='wochentag')return d.toLocaleDateString(LANG,{weekday:'long'});return d.toLocaleDateString(LANG,{month:'long'})}
|
||||||
|
function applyF(v,fl){for(const f of fl){v=String(v);if(f=='gross'||f=='upper')v=v.toUpperCase();else if(f=='klein'||f=='lower')v=v.toLowerCase();else if(f=='titel'||f=='title')v=v.toLowerCase().replace(/(^|[\s-])(\S)/g,(m,a,b)=>a+b.toUpperCase());else if(f=='luhn')v+=luhn(v);else if(f=='gtin'||f=='ean')v+=gtin(v)}return v}
|
||||||
|
const rv=s=>String(s==null?'':s).replace(/\{([^{}]+)\}/g,(m,b)=>{const p=parsePh(b),v=phVal(p);return v==null?m:applyF(v,p.fl)});
|
||||||
|
function ctxFor(i){const s=S.ser||{},b={n:i+1,gesamt:serN(),_i:i,_seed:(Math.random()*4294967296)>>>0};
|
||||||
|
if(s.mode=='count')b.n=s.start+i*s.step;if(s.mode=='pdf'&&s.pages)b._pg=s.pages[i];if(s.mode=='csv'&&s.rows&&s.rows.length)Object.assign(b,s.rows[i]);return b}
|
||||||
|
function lfdNames(d){const set=new Set(),sc=x=>String(x||'').replace(/\{([^{}]+)\}/g,(m,b)=>{const k=parsePh(b).k;if(k=='lfd'||k.startsWith('lfd.'))set.add(k=='lfd'?'default':k.slice(4))});(d.els||[]).forEach(e=>{sc(e.text);sc(e.data)});return[...set]}
|
||||||
|
const BI=new Set(['n','gesamt','datum','zeit','jahr','monat','monatname','tag','kw','wochentag','zufall','uuid','lfd']);
|
||||||
|
const QV=d=>{const s=new Set(),sc=x=>String(x||'').replace(/\{([^{}]+)\}/g,(m,b)=>{const p=parsePh(b);if(!BI.has(p.k)&&!p.k.startsWith('lfd'))s.add(p.nm)});(d.els||[]).forEach(e=>{sc(e.text);sc(e.data)});return[...s]};
|
||||||
|
/*/PH*/
|
||||||
|
const serN=()=>{const s=S.ser;return !s?1:s.mode=='count'?s.count:s.mode=='pdf'?(s.pages||[]).length:(s.rows||[]).length||1};
|
||||||
|
let pv=0;const serBtn=()=>{const n=serN();$('#ser').textContent=S.ser?`Serie: ${n}`:'Serie…';$('#grd').textContent=S.grid?`Raster ${S.grid.cols}×${S.grid.rows}`:'Raster…';$('#pvw').classList.toggle('hide',n<2);setPrev()},
|
||||||
|
setPrev=()=>{pv=Math.max(0,Math.min(pv,serN()-1));CTX=ctxFor(pv);const l=$('#pvl');if(l)l.textContent=`${pv+1}/${serN()}`};
|
||||||
|
function csv(t){const sep=t.split('\n')[0].includes(';')?';':',',rows=[];let r=[],c='',q=false;
|
||||||
|
for(let i=0;i<t.length;i++){const ch=t[i];if(q){if(ch=='"'){if(t[i+1]=='"'){c+='"';i++}else q=false}else c+=ch}else if(ch=='"')q=true;else if(ch==sep){r.push(c);c=''}
|
||||||
|
else if(ch=='\n'||ch=='\r'){if(ch=='\r'&&t[i+1]=='\n')i++;r.push(c);c='';rows.push(r);r=[]}else c+=ch}
|
||||||
|
if(c||r.length){r.push(c);rows.push(r)}const hd=rows.shift().map(x=>x.trim());
|
||||||
|
return rows.filter(r=>r.some(x=>x.trim())).map(r=>Object.fromEntries(hd.map((k,i)=>[k,(r[i]||'').trim()])))}
|
||||||
|
const idle=()=>new Promise(r=>{const t=()=>pend?setTimeout(t,40):r();t()});
|
||||||
|
async function render(i){CTX=ctxFor(i);draw(1);await idle();const bad=S.els.find(e=>e.t=='bc'&&BC[e.bt+'|'+rv(e.text)]==='');if(bad)throw tt('Barcode ungültig: {}',rv(bad.text));draw(1);return cv.toDataURL('image/png')}
|
||||||
|
const dsg=()=>{const d=JSON.parse(JSON.stringify(S));if(d.ser&&d.ser.mode=='pdf')d.ser=null;return d};
|
||||||
|
const SYMF=FL.find(f=>f[0]=='Symbole')[1];
|
||||||
|
const addShape=k=>{const[W,H]=dims(),m=Math.round(Math.min(W,H)/6),w=Math.round(Math.min(W,H)*.5);add({t:'shape',k,x:m,y:m,w:k=='line'?Math.round(W*.5):w,h:k=='line'?Math.round(Math.min(W,H)*.1):w,fill:k=='arrow'?1:0,lw:6,rot:0})};
|
||||||
|
const addSym=c=>{const[W,H]=dims(),s=Math.round(Math.min(W,H)*.6);add({t:'text',x:20,y:20,w:Math.round(s*1.1),s,f:SYMF,b:0,i:0,a:'center',text:c})};
|
||||||
|
$('#addmore').onchange=ev=>{const v=ev.target.value;ev.target.value='';if(!v)return;const[W,H]=dims(),m=Math.round(Math.min(W,H)/6);
|
||||||
|
if(v=='pdf')$('#pdff').click();else if(v=='rect')add({t:'rect',x:m,y:m,w:Math.round(W/3),h:Math.round(H/3),fill:0,lw:6});else if(v.startsWith('sh:'))addShape(v.slice(3));else if(v.startsWith('sy:'))addSym(v.slice(3))};
|
||||||
|
const setAdv=on=>{document.body.classList.toggle('adv-on',on);$('#advc').checked=on;try{localStorage.setItem('adv',on?'1':'0')}catch(e){}};
|
||||||
|
$('#advc').onchange=ev=>setAdv(ev.target.checked);try{setAdv(localStorage.getItem('adv')=='1')}catch(e){setAdv(false)}
|
||||||
|
$('#ddb').onclick=ev=>{ev.stopPropagation();$('#ddm').classList.toggle('hide')};document.addEventListener('click',()=>$('#ddm').classList.add('hide'));
|
||||||
|
const setTop=()=>{const s=document.documentElement.style,b=document.querySelector('#td>.bar');s.setProperty('--toph',$('#top').offsetHeight+'px');s.setProperty('--tbh',(b?b.offsetHeight:0)+'px')};
|
||||||
|
if(window.ResizeObserver){const ro=new ResizeObserver(setTop);ro.observe($('#top'));ro.observe(document.querySelector('#td>.bar'))}else setTop();
|
||||||
|
cv.ondblclick=ev=>{const[px,py]=pt(ev);if(sel&&sel.t=='table'){const cc=cellAt(sel,px,py);selCell={r:cc.r,c:cc.c};draw();fillPanel();editCell(sel,cc.r,cc.c)}else if(sel&&sel.t=='text'){const i=document.querySelector('#panel [data-k="text"]');if(i){i.focus();i.select()}}};
|
||||||
|
Object.entries({'#addmore':'Weitere Elemente: PDF, Formen, Symbole','#ddb':'Speichern, exportieren, neues Etikett','#advc':'Erweiterte Optionen ein-/ausblenden (Drehen, Dithering, Serie, Raster, Zoom …)'}).forEach(([s,x])=>{const el=$(s);if(el)el.title=x});
|
||||||
|
$('#grd').onclick=()=>{if(!lab().endless){alert(tt('Raster ist nur bei Endlosrollen möglich.'));return}const g=S.grid||{cols:1,rows:1,gap:2};
|
||||||
|
$('#gcw').value=S.cw||'';$('#gcols').value=g.cols;$('#grows').value=g.rows;$('#ggap').value=g.gap;const d=$('#gdlg');d.returnValue='';
|
||||||
|
d.onclose=()=>{if(d.returnValue!='ok')return;const cols=Math.max(1,+$('#gcols').value||1),rows=Math.max(1,+$('#grows').value||1);S.cw=+$('#gcw').value||0;S.grid=cols*rows>1?{cols,rows,gap:+$('#ggap').value||0}:null;sync();sv_()};d.showModal()};
|
||||||
|
$('#bk').onclick=()=>location.href='/api/backup';$('#rs').onclick=()=>$('#rsf').click();
|
||||||
|
$('#rsf').onchange=T(async ev=>{const f=ev.target.files[0];ev.target.value='';if(!f||!confirm(tt('Alle aktuellen Daten werden durch die Sicherung ersetzt. Fortfahren?')))return;const fd=new FormData();fd.append('f',f);await api('/api/restore',{method:'POST',body:fd});alert(tt('Wiederhergestellt – Seite wird neu geladen'));location.reload()});
|
||||||
|
const cellsOf=e=>{const R=e.data.split('\n').map(x=>x.split(';')),nc=Math.max(...R.map(x=>x.length));R.forEach(x=>{while(x.length<nc)x.push('')});return R};
|
||||||
|
const cellsStr=R=>R.map(x=>x.join(';')).join('\n');
|
||||||
|
function cellAt(e,px,py){const R=cellsOf(e),nr=R.length,nc=R[0].length;return{r:Math.min(nr-1,Math.max(0,Math.floor((py-e.y)/(e.h/nr)))),c:Math.min(nc-1,Math.max(0,Math.floor((px-e.x)/(e.w/nc))))}}
|
||||||
|
function setCell(e,r,c,v){const R=cellsOf(e);while(R.length<=r)R.push(Array(R[0].length).fill(''));R[r][c]=String(v).replace(/[;\n]/g,' ');e.data=cellsStr(R)}
|
||||||
|
function tmod(k){const e=sel,R=cellsOf(e),nc=R[0].length;if(k=='+r')R.push(Array(nc).fill(''));if(k=='-r'&&R.length>1)R.pop();if(k=='+c')R.forEach(x=>x.push(''));if(k=='-c'&&nc>1)R.forEach(x=>x.pop());e.data=cellsStr(R);draw();fillPanel();commit()}
|
||||||
|
function cellUI(){const f=(sel&&sel.cb&&selCell&&sel.cb[selCell.r+','+selCell.c])||'';document.querySelectorAll('#panel [data-cb]').forEach(i=>{i.checked=f.includes(i.dataset.cb);i.disabled=!selCell});const cl=$('#cl');if(cl)cl.textContent=selCell?`Zelle ${selCell.r+1}/${selCell.c+1}:`:'Zelle antippen:'}
|
||||||
|
function editCell(e,r,c){const R=cellsOf(e),nr=R.length,nc=R[0].length,rc=cv.getBoundingClientRect(),sx=rc.width/cv.width,cw=e.w/nc,rh=e.h/nr,i=document.createElement('input');
|
||||||
|
i.value=R[r][c];Object.assign(i.style,{position:'fixed',left:rc.left+(e.x+c*cw)*sx+'px',top:rc.top+(e.y+r*rh)*sx+'px',width:Math.max(60,cw*sx)+'px',height:Math.max(30,rh*sx)+'px',zIndex:50,fontSize:Math.max(14,e.s*sx)+'px',boxSizing:'border-box'});
|
||||||
|
document.body.appendChild(i);i.focus();i.select();let done=false;
|
||||||
|
const fin=ok=>{if(done)return;done=true;if(ok){setCell(e,r,c,i.value);draw();commit()}i.remove()};
|
||||||
|
i.onkeydown=ev=>{if(ev.key=='Enter'){ev.preventDefault();fin(1)}else if(ev.key=='Escape')fin(0);
|
||||||
|
else if(ev.key=='Tab'){ev.preventDefault();fin(1);const nx=c+1<nc?[r,c+1]:r+1<nr?[r+1,0]:null;if(nx){selCell={r:nx[0],c:nx[1]};draw();fillPanel();editCell(e,nx[0],nx[1])}}};
|
||||||
|
setTimeout(()=>{i.onblur=()=>fin(1)},300)}
|
||||||
|
const KT={x:'Abstand vom linken Rand in mm',y:'Abstand vom oberen Rand in mm',w:'Breite in mm',h:'Höhe in mm',s:'Schriftgröße in Punkt',f:'Schriftart',a:'Ausrichtung des Textes in der Box',b:'Fett',i:'Kursiv',
|
||||||
|
text:'Inhalt – Platzhalter wie {n} oder {datum} sind möglich (siehe Hilfe)',fit:'Schrift passt sich automatisch der Box an (Box-Höhe nötig)',fill:'Fläche füllen statt nur Umriss',lw:'Linienstärke in mm',bw:'Linienstärke der Tabelle in mm',
|
||||||
|
bm:'Welche Tabellenlinien gezeichnet werden',hd:'Erste Zeile fett (Kopfzeile)',data:'Tabelleninhalt: eine Zeile pro Tabellenzeile, Spalten mit ; trennen. Tipp: Zelle doppelt antippen zum direkten Bearbeiten',rot:'Drehung des Elements in Grad',bt:'Barcode-Typ',txt:'Klartext unter dem Barcode'};
|
||||||
|
const TIPS={'#st':'Druckerstatus: grün = erreichbar, rot = nicht gefunden','#pnt':'Drucker auswählen','#lab':'Rolle / Etikettenformat – muss zur eingelegten Rolle passen','#len':'Länge des Etiketts in mm (nur Endlosrollen)',
|
||||||
|
'#rot':'Etikett beim Druck um 90° drehen: Länge wird waagerecht dargestellt','#cp':'Anzahl Kopien je Etikett','#dit':'Dithering: Fotos/Graustufen als Rasterpunkte. Für Text/Codes ausschalten','#cut':'Nach dem Druck automatisch abschneiden (nur Modelle mit Cutter)',
|
||||||
|
'#pr':'Etikett(en) drucken','#hlp':'Anleitung öffnen (Platzhalter, Barcodes, Serien, Tabellen …)','[data-a="text"]':'Textfeld hinzufügen','[data-a="qr"]':'QR-Code hinzufügen','[data-a="table"]':'Tabelle hinzufügen – Zelle doppelt antippen zum Bearbeiten','[data-a="bc"]':'Barcode hinzufügen (Code 128, EAN, GS1-128, Datamatrix …)',
|
||||||
|
'#addimg':'Bild/Logo von diesem Gerät einfügen','#pdfin':'PDF importieren – Seitengröße wählt die Rolle','#addsh':'Kreis, Linie, Pfeil oder Dreieck einfügen','#addsy':'Symbol einfügen (Warnung, Recycling …)','#dup':'Ausgewähltes Element duplizieren',
|
||||||
|
'#del':'Ausgewähltes Element löschen (Entf)','#ser':'Serie: Zähler, CSV oder PDF-Seiten – mehrere unterschiedliche Etiketten in einem Druck','#grd':'Raster: mehrere Etiketten nebeneinander/untereinander auf einem Streifen (Endlosrollen)','#zm':'Ansicht vergrößern/verkleinern',
|
||||||
|
'#dpng':'Etikett als PNG-Bild herunterladen','#dpdf':'Etikett als PDF herunterladen','#svh':'Im Verlauf speichern, ohne zu drucken','#sv':'Als wiederverwendbare Vorlage speichern','#nw':'Neues leeres Etikett','#rb':'Raspberry Pi neu starten','#sd':'Raspberry Pi herunterfahren',
|
||||||
|
'#hsel':'Mehrere Einträge auswählen','#hall':'Alle auswählen','#hnone':'Auswahl aufheben','#hexp':'Ausgewählte exportieren (JSON)','#hdel':'Ausgewählte löschen','#hend':'Auswahlmodus beenden','[data-t="d"]':'Etikett gestalten','[data-t="t"]':'Gespeicherte Vorlagen in Ordnern','[data-t="h"]':'Bisher gedruckte und gespeicherte Etiketten','[data-t="s"]':'Drucker, Passwort, Import/Export, Backup','[data-t="f"]':'Anleitung mit Platzhaltern, Barcodes, Serien u. v. m.'};
|
||||||
|
Object.entries(TIPS).forEach(([s,t])=>{const el=$(s);if(el)el.title=t});
|
||||||
|
$('#hlp').onclick=()=>go('f');
|
||||||
|
/*I18N*/
|
||||||
|
let LANG='de',ALL={},PT=[],LS=[],CNT={};
|
||||||
|
function setDict(st){ALL={};PT=[];for(const[k,v]of Object.entries(st||{})){if(!v)continue;ALL[k]=v;if(k.includes('{}'))PT.push([new RegExp('^'+k.split('{}').map(x=>x.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).join('(.+?)')+'$'),v])}}
|
||||||
|
function tt(s,...a){s=String(s);let o=ALL[s];if(o==null&&!a.length)for(const[re,v]of PT){const m=s.match(re);if(m){o=v;a=m.slice(1).map(x=>tt(x));break}}
|
||||||
|
if(o==null)o=s;let i=0;return o.replace(/\{\}/g,()=>a[i]!=null?a[i++]:'{}')}
|
||||||
|
/*/I18N*/
|
||||||
|
const SKT=new Set(['SCRIPT','STYLE','CANVAS','TEXTAREA']),noTr=n=>{const e=n.nodeType==1?n:n.parentElement;return!e||SKT.has(e.tagName)||!!e.closest('[data-notr]')};
|
||||||
|
function retr(n){if(n._t!==undefined&&n._t===n.nodeValue)n.nodeValue=n._o;n._t=undefined;trNode(n)}
|
||||||
|
function trNode(n){if(noTr(n))return;const v=n.nodeValue;if(n._t===v)return;n._o=v;const x=v.trim();if(!x){n._t=v;return}const out=v.replace(x,()=>tt(x));n._t=out;if(out!==v)n.nodeValue=out}
|
||||||
|
function trAttr(e,force){if(e.closest&&e.closest('[data-notr]'))return;if(force&&e._a){for(const a in e._a)if(e.getAttribute(a)===e._a[a].t)e.setAttribute(a,e._a[a].o);e._a=null}
|
||||||
|
for(const a of['title','placeholder','label']){const v=e.getAttribute(a);if(v==null)continue;e._a=e._a||{};const s=e._a[a];if(s&&s.t===v)continue;const o=tt(v);e._a[a]={o:v,t:o};if(o!==v)e.setAttribute(a,o)}}
|
||||||
|
function trTree(r,force){if(r.nodeType==3){force?retr(r):trNode(r);return}if(r.nodeType!=1||(r.closest&&r.closest('[data-notr]')))return;
|
||||||
|
const w=document.createTreeWalker(r,5,{acceptNode:n=>n.nodeType==1&&(SKT.has(n.tagName)&&n.tagName!='TEXTAREA'||n.hasAttribute('data-notr'))?2:1});let n=w.currentNode;
|
||||||
|
do{if(n.nodeType==1)trAttr(n,force);else force?retr(n):trNode(n)}while(n=w.nextNode())}
|
||||||
|
const CFG={subtree:true,childList:true,characterData:true,attributes:true,attributeFilter:['title','placeholder','label']};
|
||||||
|
const mo=new MutationObserver(ms=>{mo.disconnect();try{for(const m of ms){if(m.type=='childList')m.addedNodes.forEach(n=>trTree(n));else if(m.type=='characterData')trNode(m.target);else trAttr(m.target)}}finally{mo.observe(document.body,CFG)}});
|
||||||
|
const langOpts=()=>{$('#lng').innerHTML=LS.map(l=>`<option value="${l.code}">${esc(l.name)}</option>`).join('')};
|
||||||
|
async function setLang(c){LANG=c;try{localStorage.setItem('lang',c)}catch(e){}const j=await api('/api/lang/'+c).catch(()=>({}));setDict(j.strings);document.documentElement.lang=c;trTree(document.body,true);if(!$('#tf').classList.contains('hide'))loadHelp()}
|
||||||
|
async function initLang(){LS=await api('/api/langs').catch(()=>[{code:'de',name:'Deutsch'}]);langOpts();let c='de';try{c=localStorage.getItem('lang')||(navigator.language||'de').slice(0,2).toLowerCase()}catch(e){}
|
||||||
|
if(!LS.find(l=>l.code==c))c='de';$('#lng').value=c;await setLang(c);mo.observe(document.body,CFG)}
|
||||||
|
$('#lng').onchange=ev=>{mo.disconnect();setLang(ev.target.value).finally(()=>mo.observe(document.body,CFG))};
|
||||||
|
async function refreshLangs(rs){LS=await api('/api/langs');const cur=rs?'de':$('#lng').value;langOpts();$('#lng').value=LS.find(l=>l.code==cur)?cur:'de';if(rs)await setLang('de')}
|
||||||
|
const loadHelp=async()=>{$('#helpc').innerHTML=await fetch('/api/help?lang='+LANG).then(r=>r.text())};
|
||||||
|
$('#lup').onclick=()=>$('#lupf').click();
|
||||||
|
$('#lupf').onchange=T(async ev=>{const f=ev.target.files[0];ev.target.value='';if(!f)return;const fd=new FormData();fd.append('f',f);await api('/api/lang',{method:'POST',body:fd});await refreshLangs();alert(tt('Sprache hinzugefügt'))});
|
||||||
|
$('#ltpl').onclick=()=>location.href='/api/langtemplate';
|
||||||
|
$('#ldel').onclick=T(async()=>{const c=$('#lng').value,l=LS.find(x=>x.code==c);if(!l||!l.custom){alert(tt('Nur eigene Sprachdateien können entfernt werden.'));return}if(!confirm(tt('Sprachdatei „{}“ entfernen?',l.name)))return;await post('/api/lang/delete',{code:c});await refreshLangs(true)});
|
||||||
|
const cset=T(async b=>{await post('/api/counters/set',{name:b.dataset.cn,value:+b.closest('.card').querySelector('input').value});CNT=await api('/api/counters');setPrev();draw();alert(tt('Gespeichert'))});
|
||||||
|
const cdel=T(async b=>{if(!confirm(tt('Zähler „{}“ löschen?',b.dataset.cn)))return;await post('/api/counters/delete',{name:b.dataset.cn});CNT=await api('/api/counters');setPrev();draw();setp()});
|
||||||
|
$('#cna').onclick=T(async()=>{await post('/api/counters/set',{name:($('#cnn').value||'default').trim(),value:+$('#cnv').value||1});CNT=await api('/api/counters');setPrev();draw();setp()});
|
||||||
|
$('#pvp').onclick=()=>{pv--;setPrev();draw()};$('#pvn').onclick=()=>{pv++;setPrev();draw()};$('#cmk').onchange=()=>{draw();sv_()};
|
||||||
|
const QT={url:[['u','Adresse','https://']],wifi:[['s','Netzwerkname (SSID)'],['p','Passwort'],['e','Verschlüsselung']],vcard:[['n','Name'],['o','Firma'],['t','Telefon'],['m','E-Mail'],['w','Webseite']],mail:[['a','E-Mail-Adresse'],['s','Betreff'],['b','Nachricht']],tel:[['t','Telefonnummer']],sms:[['t','Nummer'],['b','Text']],geo:[['la','Breitengrad','52.52'],['lo','Längengrad','13.40']],text:[['x','Text']]};
|
||||||
|
const qe=s=>String(s||'').replace(/([\\;,:"])/g,'\\$1');
|
||||||
|
const QC={url:v=>v.u||'',wifi:v=>`WIFI:T:${v.e||'WPA'};S:${qe(v.s)};P:${qe(v.p)};;`,vcard:v=>`BEGIN:VCARD\nVERSION:3.0\nFN:${v.n||''}\n${v.o?'ORG:'+v.o+'\n':''}${v.t?'TEL:'+v.t+'\n':''}${v.m?'EMAIL:'+v.m+'\n':''}${v.w?'URL:'+v.w+'\n':''}END:VCARD`,mail:v=>`mailto:${v.a||''}?subject=${encodeURIComponent(v.s||'')}&body=${encodeURIComponent(v.b||'')}`,tel:v=>'tel:'+(v.t||''),sms:v=>`SMSTO:${v.t||''}:${v.b||''}`,geo:v=>`geo:${v.la||''},${v.lo||''}`,text:v=>v.x||''};
|
||||||
|
const qfill=()=>{$('#qfld').innerHTML=QT[$('#qt').value].map(([k,l,ph])=>k=='e'?`<label>${l}<select data-q="e"><option>WPA</option><option>WEP</option><option value="nopass">ohne Passwort</option></select></label>`:`<label>${l}<input data-q="${k}" placeholder="${ph||''}"></label>`).join('')};
|
||||||
|
$('#qt').onchange=qfill;
|
||||||
|
function openQ(){const d=$('#qdlg');$('#qt').value='url';qfill();d.returnValue='';d.onclose=()=>{if(d.returnValue!='ok'||!sel)return;const v={};document.querySelectorAll('#qfld [data-q]').forEach(i=>v[i.dataset.q]=i.value);sel.text=QC[$('#qt').value](v);draw();fillPanel();commit()};d.showModal()}
|
||||||
|
async function withS(d,fn){const keep=S,ks=sel;S=d;sel=null;try{size();return await fn()}finally{S=keep;sel=ks;size();setPrev();draw()}}
|
||||||
|
const quick=T(async id=>{const d=JSON.parse((await api('/api/templates/'+id)).design),vars=QV(d),dg=$('#kdlg');
|
||||||
|
if(!LB.find(l=>l.id==d.label))throw tt('Die Rolle dieser Vorlage wird vom gewählten Drucker nicht unterstützt');
|
||||||
|
$('#kt').textContent=TP[id]?TP[id].name:'';$('#kfld').innerHTML=vars.map(v=>`<label>${esc(v)}<input data-v="${esc(v)}"></label>`).join('')||'<small>Diese Vorlage hat keine Eingabefelder.</small>';
|
||||||
|
const vals=()=>Object.fromEntries([...document.querySelectorAll('#kfld [data-v]')].map(i=>[i.dataset.v,i.value])),setR=()=>{d.ser={mode:'csv',rows:[vals()]}};
|
||||||
|
let tm;const prev=()=>{clearTimeout(tm);tm=setTimeout(()=>withS(d,async()=>{setR();$('#kimg').src=await render(0)}).catch(()=>{}),250)};
|
||||||
|
$('#kfld').oninput=prev;prev();dg.returnValue='';
|
||||||
|
dg.onclose=T(async()=>{if(dg.returnValue!='ok')return;const old=$('#cp').value;$('#cp').value=$('#kcp').value;try{await withS(d,async()=>{setR();await doPrint(()=>{})})}finally{$('#cp').value=old}});dg.showModal()});
|
||||||
|
Object.entries({'#cmk':'Gestrichelte Linie am Etikettenende zum manuellen Abschneiden (Endlosrollen ohne Cutter)','#pvp':'Vorheriges Etikett der Serie anzeigen','#pvn':'Nächstes Etikett der Serie anzeigen','#lng':'Sprache der Oberfläche'}).forEach(([s,x])=>{const el=$(s);if(el)el.title=x});
|
||||||
|
const resetU=()=>{U=[JSON.stringify(S.els)];RD=[]};
|
||||||
|
function commit(){const j=JSON.stringify(S.els);if(j!=U[U.length-1]){U.push(j);if(U.length>25)U.shift();RD=[]}}
|
||||||
|
function undo(){if(U.length>1){RD.push(U.pop());S.els=JSON.parse(U[U.length-1]);sel=null;draw();panel()}}
|
||||||
|
function redo(){if(RD.length){const j=RD.pop();U.push(j);S.els=JSON.parse(j);sel=null;draw();panel()}}
|
||||||
|
$('#un').onclick=undo;$('#re').onclick=redo;
|
||||||
|
$('#panel').onclick=ev=>{if(ev.target.dataset.qa){openQ();return}const tm=ev.target.dataset.tm;if(tm&&sel&&sel.t=='table'){tmod(tm);return}const a=ev.target.dataset.al;if(!a||!sel)return;const[,,w,h]=bb(sel);
|
||||||
|
if(a=='l')sel.x=0;if(a=='c')sel.x=Math.round((cv.width-w)/2);if(a=='r')sel.x=Math.round(cv.width-w);if(a=='t')sel.y=0;if(a=='m')sel.y=Math.round((cv.height-h)/2);if(a=='b')sel.y=Math.round(cv.height-h);draw();fillPanel();commit()};
|
||||||
|
const serUI=()=>{$('#sc').classList.toggle('hide',$('#sm').value!='count');$('#sv2').classList.toggle('hide',$('#sm').value!='csv')};
|
||||||
|
const serInfo=()=>{$('#sinfo').textContent=tmpRows?`${tmpRows.length} Zeilen · Platzhalter: ${Object.keys(tmpRows[0]||{}).map(k=>'{'+k+'}').join(' ')}`:''};
|
||||||
|
$('#sm').onchange=serUI;
|
||||||
|
$('#scsv').onchange=T(async ev=>{const f=ev.target.files[0];if(!f)return;tmpRows=csv((await f.text()).replace(/^\uFEFF/,''));serInfo()});
|
||||||
|
$('#ser').onclick=()=>{const s=S.ser||{mode:'none',start:1,step:1,count:10};$('#sm').value=s.mode;$('#s0').value=s.start??1;$('#s1').value=s.step??1;$('#s2').value=s.count??10;tmpRows=s.rows||null;serUI();serInfo();
|
||||||
|
const d=$('#sdlg');d.returnValue='';d.onclose=()=>{if(d.returnValue!='ok')return;const m=$('#sm').value;
|
||||||
|
S.ser=m=='none'||(m=='csv'&&!tmpRows)||(m=='pdf'&&!(S.ser&&S.ser.pages))?null:m=='pdf'?S.ser:{mode:m,start:+$('#s0').value||0,step:+$('#s1').value||1,count:Math.min(500,Math.max(1,+$('#s2').value||1)),rows:m=='csv'?tmpRows:null};
|
||||||
|
serBtn();setPrev();draw()};d.showModal()};
|
||||||
|
|
||||||
|
$('#pdff').onchange=T(async ev=>{const f=ev.target.files[0];ev.target.value='';if(!f)return;const fd=new FormData();fd.append('f',f);fd.append('page',1);
|
||||||
|
let r=await api('/api/pdfin',{method:'POST',body:fd}),pages=null;
|
||||||
|
if(r.pages>1){if(confirm(tt('Das PDF hat {} Seiten.\nOK = alle Seiten als Serie (eine Seite pro Etikett)\nAbbrechen = nur eine Seite verwenden',r.pages))){fd.set('all','1');r=await api('/api/pdfin',{method:'POST',body:fd});pages=r.pngs;r.png=pages[0]}
|
||||||
|
else{const pg=+prompt(tt('Welche Seite? (1–{})',r.pages),'1')||1;if(pg!=1){fd.set('page',pg);r=await api('/api/pdfin',{method:'POST',body:fd})}}}
|
||||||
|
const A=(a,b)=>Math.abs(a-b)<1.8;let m=LB.find(l=>!l.endless&&A(l.tw,r.h)&&A(l.tl,r.w)),rot=true;
|
||||||
|
if(!m){m=LB.find(l=>!l.endless&&A(l.tw,r.w)&&A(l.tl,r.h));rot=false}
|
||||||
|
if(!m){const ls=r.w>=r.h,need=ls?r.h:r.w;m=LB.filter(l=>l.endless&&l.tw>=need-1).sort((a,b)=>a.tw-b.tw)[0];rot=ls;if(m)S.len=Math.round(Math.min(900,ls?r.w:r.h))}
|
||||||
|
if(!m)throw tt('Keine passende Rolle für diese Seitengröße gefunden');
|
||||||
|
S.label=m.id;S.rot=rot;sync();const[W,H]=dims(),nw=r.w*MM,nh=r.h*MM,k=Math.min(1,W/nw,H/nh);
|
||||||
|
if(pages)S.ser={mode:'pdf',pages};add({t:'img',pg:pages?1:0,x:0,y:0,w:Math.round(nw*k),h:Math.round(nh*k),src:r.png});sync();sv_()});
|
||||||
|
(async()=>{try{await initLang();const c=await api('/api/settings').catch(()=>({}));await loadP(c.printer);await loadL();
|
||||||
|
fresh(LB.find(l=>l.id==c.label)?c.label:(LB.find(l=>l.id=='62')?'62':LB[0].id));
|
||||||
|
if(c.len)S.len=Math.min(900,Math.max(25,+c.len||100));if(typeof c.rot=='boolean')S.rot=c.rot;ROLL=c.roll||{};$('#cmk').checked=!!c.cmk;CNT=await api('/api/counters').catch(()=>({}));if(c.cw)S.cw=c.cw;if(c.grid)S.grid=c.grid;$('#dit').checked=!!c.dither;$('#cut').checked=!!c.cut;sync();st();setInterval(st,20000)}catch(e){alert(tt('Fehler')+': '+tt(e))}})();
|
||||||
|
</script></body></html>
|
||||||
Reference in new issue
Block a user