diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43b38ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +labels.db +*.pyc +lang/ diff --git a/LICENSE b/LICENSE index 753e191..659a456 100644 --- a/LICENSE +++ b/LICENSE @@ -1,18 +1,21 @@ 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 -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the -following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial -portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f6b01f5..e22423b 100644 --- a/README.md +++ b/README.md @@ -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://.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). diff --git a/app.py b/app.py new file mode 100644 index 0000000..3f51e7e --- /dev/null +++ b/app.py @@ -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/") +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/.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//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/") +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/") +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/") +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/.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/") +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/") +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) diff --git a/docs/ANLEITUNG.en.md b/docs/ANLEITUNG.en.md new file mode 100644 index 0000000..9be9048 --- /dev/null +++ b/docs/ANLEITUNG.en.md @@ -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. diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md new file mode 100644 index 0000000..5837b09 --- /dev/null +++ b/docs/ANLEITUNG.md @@ -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. diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..5604dbc --- /dev/null +++ b/docs/INSTALL.md @@ -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. diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..1da8b99 --- /dev/null +++ b/install.sh @@ -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 < +LabelPi + +
+ + + +
+
+
+ + + + + +
+
+
+
+
+
+
+
+
+

Drucker

+
+

Passwortschutz

+

Daten

Backup

+

Zähler

+
+Mit {lfd} oder {lfd.name} nummeriert LabelPi fortlaufend und merkt sich die nächste Nummer – auch nach Neustart und Gerätewechsel. +

Sprachen

+Dateiname = Sprachcode (z. B. fr.json). Aufbau und Übersetzung der Anleitung: siehe Hilfe, Abschnitt „Sprachen“. +

Über

LabelPi · MIT-Lizenz · entstanden in Zusammenarbeit mit Claude (Anthropic)

+

QR-Assistent

+ +
+

Eingabe-Modus
+
+

Raster – mehrere Etiketten pro Streifen

+Nur bei Endlosrollen: Das Etikett wird in Spalten × Zeilen gedruckt (bei einer Serie mit jeweils anderem Inhalt). + +
+
+

Serie / Seriennummern

+ +
+

+Platzhalter in Text, QR, Barcode und Tabelle, z. B. {n}, {n:5}, {lfd}, {datum+30}, {kw}, {Spaltenname}. Alle Platzhalter stehen unter „Hilfe“. +
+

+ +
+