commit 7029ee9519998160dd4074329e80a258801c91b2 Author: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Sun Jun 21 23:07:20 2026 +0200 Initial P-Touch web editor diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2bac599 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.venv +__pycache__ +*.pyc +labels/*.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d3d1f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.venv/ +labels/*.png +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c446db3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM alpine:3.20 AS ptouch-build + +RUN apk add --no-cache build-base cmake gd-dev gettext-dev git libusb-dev pkgconf +RUN git clone --depth 1 --branch farix-main https://github.com/farixembedded/ptouch-print.git /src/ptouch +WORKDIR /src/ptouch +RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \ + && cmake --build build \ + && install -m 0755 build/ptouch-print /usr/local/bin/ptouch-print + +FROM alpine:3.20 + +ENV PYTHONUNBUFFERED=1 \ + FLASK_APP=app.app \ + LABEL_DIR=/data/labels \ + PRINT_ENABLED=1 \ + PRINT_COMMAND="ptouch-print --image {image}" + +RUN apk add --no-cache python3 py3-pip py3-flask py3-pillow gd gettext-libs libusb ttf-dejavu +COPY --from=ptouch-build /usr/local/bin/ptouch-print /usr/local/bin/ptouch-print + +WORKDIR /srv/ptouch-web +COPY requirements.txt ./requirements.txt +COPY app ./app +COPY start.sh /usr/local/bin/ptouch-web-start +RUN mkdir -p /data/labels +RUN chmod +x /usr/local/bin/ptouch-web-start + +EXPOSE 8080 +CMD ["/usr/local/bin/ptouch-web-start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a02afd --- /dev/null +++ b/README.md @@ -0,0 +1,175 @@ +# P-Touch Web fuer Brother PT-P700 + +Kleine Flask-Weboberflaeche zum Erstellen von Etiketten und Drucken auf einem Brother P-Touch P700, der per USB an einem Unraid-Host haengt. + +Die Weboberflaeche bildet die wichtigsten P-touch-Editor-Funktionen nach: + +- Bandbreite, Laenge, Rand und Ausrichtung +- frei positionierbare Objekte auf einem Etiketten-Canvas +- Textfelder, Symbole, Rahmen/Formen, Linien und Tabellen +- Bildimport mit Schwarz/Weiss-Konvertierung +- QR-Code und Code128-Barcode +- Layout speichern/laden im Browser +- serverseitige Vorschau und Druck ueber `ptouch-print` +- vereinfachte Mobile-Webansicht unter `/mobile` + +## Wichtig fuer den PT-P700 + +Der PT-P700 muss im normalen Druckermodus laufen, nicht im "Editor Lite" Massenspeicher-Modus. Falls er im Host als USB-Laufwerk erscheint, Editor Lite am Geraet deaktivieren bzw. das Laufwerk auswerfen und den Drucker neu verbinden. + +Wenn `lsusb` so etwas zeigt, ist der Drucker noch im falschen Modus: + +```text +Brother Industries, Ltd PT-P700 P-touch Label Printer RemovableDisk +``` + +Dann am PT-P700 die `Editor Lite`-Taste druecken, bis die Editor-Lite-LED aus ist. Danach USB kurz abziehen und wieder einstecken. Erst wenn der Drucker nicht mehr als `RemovableDisk` erscheint, kann `ptouch-print` drucken. + +Der Container braucht Zugriff auf USB. In Unraid geht das am einfachsten mit: + +- `/dev/bus/usb:/dev/bus/usb` +- `privileged: true` + +## Start lokal + +```bash +docker compose up --build +``` + +Danach: + +Mobile Ansicht: + +Beim Dockerfile-Image startet der Container ueber `/usr/local/bin/ptouch-web-start`. Bei der Unraid-GUI-Variante ohne eigenes Image wird stattdessen `sh /srv/ptouch-web/start.sh` als Container-Befehl gesetzt. + +## Unraid Docker-GUI ohne eigenes Image + +Diese Variante nutzt ein fertiges Alpine/Python-Basisimage und mountet dieses Projekt in den Container. `start.sh` installiert beim Start Flask, Pillow und baut `ptouch-print`. + +Nachteil: Beim ersten Start braucht der Container Internet und der Start dauert laenger. Nach einem Container-Neuerstellen kann das erneut passieren. + +Dateien auf Unraid ablegen: + +```text +/mnt/user/appdata/ptouch-web/app +/mnt/user/appdata/ptouch-web/start.sh +/mnt/user/appdata/ptouch-web/requirements.txt +/mnt/user/appdata/ptouch-web/labels +``` + +In Unraid: Docker -> Add Container. + +1. `Name`: `ptouch-web` +2. `Repository`: `python:3.12-alpine` +3. `Network Type`: `bridge` +4. `Privileged`: `On` +5. `Post Arguments`: `sh /srv/ptouch-web/start.sh` +6. `Console shell command` ist egal +7. Bei `Command` oder `Entrypoint`, falls sichtbar: + - `Command`: leer lassen, wenn `Post Arguments` gesetzt ist + - `Entrypoint`: leer +8. Port hinzufuegen: + - Container Port: `8080` + - Host Port: `8080` + - Protocol: `TCP` +9. Path hinzufuegen fuer die App: + - Container Path: `/srv/ptouch-web` + - Host Path: `/mnt/user/appdata/ptouch-web` + - Access Mode: `Read/Write` +10. Path hinzufuegen: + - Container Path: `/data/labels` + - Host Path: `/mnt/user/appdata/ptouch-web/labels` + - Access Mode: `Read/Write` +11. Path hinzufuegen fuer USB: + - Container Path: `/dev/bus/usb` + - Host Path: `/dev/bus/usb` + - Access Mode: `Read/Write` +12. Variablen hinzufuegen: + - `APP_DIR=/srv/ptouch-web` + - `FLASK_APP=app.app` + - `PRINT_ENABLED=1` + - `PRINT_COMMAND=ptouch-print --image {image}` + - `LABEL_DIR=/data/labels` + - `LABEL_DPI=180` + +Wichtig: `/srv/ptouch-web` muss den Inhalt dieses Projektordners enthalten, also direkt `app/`, `start.sh` und `requirements.txt`. + +## Konfiguration + +Umgebungsvariablen: + +- `PRINT_ENABLED=1` druckt wirklich. Mit `0` wird nur ein PNG erzeugt. +- `PRINT_COMMAND=ptouch-print --image {image}` ist das Druckkommando. +- `LABEL_DPI=180` passt zum PT-P700. +- `LABEL_DIR=/data/labels` speichert erzeugte PNG-Dateien. + +Wenn `ptouch-print` auf deinem Setup andere Optionen braucht, nur `PRINT_COMMAND` anpassen. Der Platzhalter `{image}` wird von der App ersetzt. Wenn dein Kommando selbst Kopien unterstuetzt, kannst du zusaetzlich `{copies}` verwenden; sonst druckt die App mehrere Kopien durch mehrere Druckaufrufe. + +## USB testen + +Im laufenden Container: + +```bash +ptouch-print --info +``` + +Wenn kein Drucker gefunden wird, zuerst pruefen: + +- Ist Editor Lite deaktiviert? +- Sieht Unraid den Drucker unter USB? +- Ist `/dev/bus/usb` im Container sichtbar? +- Laeuft der Container privilegiert? + +Wenn `ptouch-print` meldet: + +```text +PT-P700 (PLite Mode) found +``` + +ist Editor Lite noch aktiv. Das ist kein Containerproblem, sondern der USB-Modus des Druckers. + +Wenn `ptouch-print` meldet: + +```text +PT-P700 found on USB bus ..., device ... +libusb_open error :LIBUSB_ERROR_IO +``` + +sieht der Container den Drucker, kann ihn aber nicht oeffnen. Dann pruefen: + +- Container in Unraid wirklich mit `Privileged: On` starten. +- `/dev/bus/usb` als Pfad `Read/Write` nach `/dev/bus/usb` mounten. +- Keine zusaetzliche Device-Zeile nur fuer `/dev/usb/lp0` verwenden. +- Auf dem Unraid-Host pruefen, ob `usblp` geladen ist: `lsmod | grep usblp` +- Falls `usblp` geladen ist, testweise entladen: `modprobe -r usblp` +- USB-Kabel danach kurz abziehen und wieder einstecken. + +Wenn das Entladen von `usblp` hilft, sollte `usblp` auf dem Host dauerhaft fuer diesen Drucker deaktiviert werden, weil `ptouch-print` direkt ueber libusb druckt. + +Wenn `usblp` entladen ist und `LIBUSB_ERROR_IO` trotzdem bleibt: + +1. Im Container-Terminal pruefen: + + ```bash + lsusb + ls -l /dev/bus/usb/001/020 + ptouch-print --info + ``` + + Die Bus-/Device-Nummer muss zur Ausgabe von `lsusb` passen. + +2. Auf dem Unraid-Host pruefen, ob ein Prozess das USB-Device offen hat: + + ```bash + fuser -v /dev/bus/usb/001/020 + lsof /dev/bus/usb/001/020 2>/dev/null + ``` + +3. Unraid-Container-Konfiguration pruefen: + + - `Privileged: On` + - `/dev/bus/usb` als `Path`, nicht nur als Datei, nach `/dev/bus/usb` + - Access Mode `Read/Write` + - keine extra `/dev/usb/lp0`-Device-Zuordnung + +4. Wenn die Device-Nummer nach USB-Neustecken anders ist, die Befehle mit der neuen Nummer wiederholen. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ + diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..ed2c4ed --- /dev/null +++ b/app/app.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import base64 +import io +import os +import re +import shlex +import subprocess +import uuid +from pathlib import Path +from typing import Any + +from flask import Flask, jsonify, render_template, request, send_from_directory +from PIL import Image, ImageDraw, ImageFont, ImageOps + +try: + import barcode + from barcode.writer import ImageWriter +except Exception: # pragma: no cover - optional at runtime until installed + barcode = None + ImageWriter = None + +try: + import qrcode +except Exception: # pragma: no cover - optional at runtime until installed + qrcode = None + + +BASE_DIR = Path(__file__).resolve().parent.parent +LABEL_DIR = Path(os.environ.get("LABEL_DIR", BASE_DIR / "labels")) +LABEL_DIR.mkdir(parents=True, exist_ok=True) + +DEFAULT_PRINT_COMMAND = "ptouch-print --image {image}" +PRINT_COMMAND = os.environ.get("PRINT_COMMAND", DEFAULT_PRINT_COMMAND) +PRINT_ENABLED = os.environ.get("PRINT_ENABLED", "1").lower() in {"1", "true", "yes", "on"} +DPI = int(os.environ.get("LABEL_DPI", "180")) + +TAPE_WIDTHS_MM = [3.5, 6, 9, 12, 18, 24] + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY", "change-me") + + +def mm_to_px(mm: float) -> int: + return max(1, round(mm / 25.4 * DPI)) + + +def clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + + +def get_font(size_px: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + candidates = [ + "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Arial.ttf", + ] + for path in candidates: + if Path(path).exists(): + return ImageFont.truetype(path, size_px) + return ImageFont.load_default() + + +def new_label_image(width_px: int, height_px: int) -> Image.Image: + image = Image.new("P", (width_px, height_px), 0) + image.putpalette([255, 255, 255, 0, 0, 0] + [0, 0, 0] * 254) + return image + + +def object_box(obj: dict[str, Any]) -> tuple[int, int, int, int]: + x = mm_to_px(float(obj.get("x", 0))) + y = mm_to_px(float(obj.get("y", 0))) + w = mm_to_px(float(obj.get("w", 10))) + h = mm_to_px(float(obj.get("h", 5))) + return x, y, max(1, w), max(1, h) + + +def wrap_text(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> list[str]: + lines: list[str] = [] + for raw_line in text.splitlines() or [""]: + current = "" + for word in raw_line.split(" "): + candidate = word if not current else f"{current} {word}" + if draw.textbbox((0, 0), candidate, font=font)[2] <= max_width: + current = candidate + continue + if current: + lines.append(current) + current = word + if current: + lines.append(current) + return lines + + +def draw_text(draw: ImageDraw.ImageDraw, obj: dict[str, Any]) -> None: + x, y, w, h = object_box(obj) + text = str(obj.get("text", "Text")) + font_size = mm_to_px(float(obj.get("fontSizeMm", 5))) + font = get_font(font_size, bool(obj.get("bold", False))) + align = obj.get("align", "left") + valign = obj.get("valign", "middle") + lines = wrap_text(draw, text, font, w) + boxes = [draw.textbbox((0, 0), line, font=font) for line in lines] + line_heights = [box[3] - box[1] for box in boxes] + gap = max(1, round(font_size * 0.15)) + total_h = sum(line_heights) + max(0, len(lines) - 1) * gap + if valign == "top": + cy = y + elif valign == "bottom": + cy = y + h - total_h + else: + cy = y + (h - total_h) // 2 + for line, box, line_h in zip(lines, boxes, line_heights): + text_w = box[2] - box[0] + if align == "center": + cx = x + (w - text_w) // 2 + elif align == "right": + cx = x + w - text_w + else: + cx = x + draw.text((cx, cy - box[1]), line, font=font, fill=1) + cy += line_h + gap + + +def draw_shape(draw: ImageDraw.ImageDraw, obj: dict[str, Any]) -> None: + x, y, w, h = object_box(obj) + line = max(1, mm_to_px(float(obj.get("lineMm", 0.25)))) + shape = obj.get("shape", "rect") + fill = 1 if obj.get("fill", False) else None + box = [x, y, x + w, y + h] + if shape == "ellipse": + draw.ellipse(box, outline=1, fill=fill, width=line) + elif shape == "line": + draw.line((x, y, x + w, y + h), fill=1, width=line) + else: + draw.rectangle(box, outline=1, fill=fill, width=line) + + +def paste_mono(image: Image.Image, source: Image.Image, x: int, y: int, w: int, h: int) -> None: + source = ImageOps.grayscale(source).resize((w, h), Image.Resampling.LANCZOS) + mask = source.point(lambda p: 255 if p < 160 else 0, "1") + black = Image.new("P", (w, h), 1) + black.putpalette([255, 255, 255, 0, 0, 0] + [0, 0, 0] * 254) + image.paste(black, (x, y), mask) + + +def decode_data_image(data_url: str) -> Image.Image: + if "," in data_url: + data_url = data_url.split(",", 1)[1] + return Image.open(io.BytesIO(base64.b64decode(data_url))) + + +def draw_qr(image: Image.Image, obj: dict[str, Any]) -> None: + if qrcode is None: + raise RuntimeError("qrcode ist nicht installiert.") + x, y, w, h = object_box(obj) + qr = qrcode.QRCode(border=1, box_size=10) + qr.add_data(str(obj.get("text", "https://example.local"))) + qr.make(fit=True) + qr_img = qr.make_image(fill_color="black", back_color="white").convert("RGB") + side = min(w, h) + paste_mono(image, qr_img, x, y, side, side) + + +def draw_barcode(image: Image.Image, obj: dict[str, Any]) -> None: + if barcode is None or ImageWriter is None: + raise RuntimeError("python-barcode ist nicht installiert.") + x, y, w, h = object_box(obj) + writer = ImageWriter() + code = barcode.get("code128", str(obj.get("text", "1234567890")), writer=writer) + buffer = io.BytesIO() + code.write(buffer, {"write_text": bool(obj.get("showText", True)), "quiet_zone": 1.0, "module_height": 8.0}) + buffer.seek(0) + paste_mono(image, Image.open(buffer), x, y, w, h) + + +def draw_table(draw: ImageDraw.ImageDraw, obj: dict[str, Any]) -> None: + x, y, w, h = object_box(obj) + rows = max(1, int(obj.get("rows", 2))) + cols = max(1, int(obj.get("cols", 2))) + line = max(1, mm_to_px(float(obj.get("lineMm", 0.2)))) + draw.rectangle([x, y, x + w, y + h], outline=1, width=line) + for col in range(1, cols): + cx = x + round(w * col / cols) + draw.line((cx, y, cx, y + h), fill=1, width=line) + for row in range(1, rows): + cy = y + round(h * row / rows) + draw.line((x, cy, x + w, cy), fill=1, width=line) + + +def render_design(design: dict[str, Any]) -> Path: + tape_width = float(design.get("tapeWidthMm", 24)) + if tape_width not in TAPE_WIDTHS_MM: + raise ValueError("Ungueltige Bandbreite.") + length = clamp(float(design.get("lengthMm", 70)), 10, 500) + orientation = design.get("orientation", "landscape") + if orientation == "portrait": + canvas_w_mm, canvas_h_mm = tape_width, length + else: + canvas_w_mm, canvas_h_mm = length, tape_width + + image = new_label_image(mm_to_px(canvas_w_mm), mm_to_px(canvas_h_mm)) + draw = ImageDraw.Draw(image) + + for obj in design.get("objects", []): + if not isinstance(obj, dict) or obj.get("hidden"): + continue + obj_type = obj.get("type") + if obj_type == "text" or obj_type == "symbol": + draw_text(draw, obj) + elif obj_type == "shape": + draw_shape(draw, obj) + elif obj_type == "line": + draw_shape(draw, {**obj, "shape": "line"}) + elif obj_type == "table": + draw_table(draw, obj) + elif obj_type == "image" and obj.get("dataUrl"): + x, y, w, h = object_box(obj) + paste_mono(image, decode_data_image(str(obj["dataUrl"])), x, y, w, h) + elif obj_type == "qr": + draw_qr(image, obj) + elif obj_type == "barcode": + draw_barcode(image, obj) + + output = LABEL_DIR / f"label-{uuid.uuid4().hex}.png" + image.save(output) + return output + + +def run_print_command(image_path: Path, copies: int) -> subprocess.CompletedProcess[str]: + command = PRINT_COMMAND.format(image=shlex.quote(str(image_path)), copies=copies) + return subprocess.run(shlex.split(command), capture_output=True, check=False, text=True, timeout=90) + + +def print_label(image_path: Path, copies: int) -> list[subprocess.CompletedProcess[str]]: + if "{copies}" in PRINT_COMMAND: + return [run_print_command(image_path, copies)] + return [run_print_command(image_path, 1) for _ in range(copies)] + + +def detect_tape_width() -> tuple[float | None, str]: + result = subprocess.run( + ["ptouch-print", "--info"], + capture_output=True, + check=False, + text=True, + timeout=20, + ) + output = "\n".join(part for part in [result.stdout.strip(), result.stderr.strip()] if part) + if result.returncode != 0: + raise RuntimeError(format_print_error(result)) + + candidates: list[float] = [] + for pattern in [ + r"(?:tape|media|band|width|breite|size|groesse|printing)[^\n:=-]*[:=-]?\s*(\d+(?:[.,]\d+)?)\s*mm", + r"(\d+(?:[.,]\d+)?)\s*mm", + ]: + for match in re.finditer(pattern, output, re.IGNORECASE): + candidates.append(float(match.group(1).replace(",", "."))) + + for supported in TAPE_WIDTHS_MM: + if any(abs(candidate - supported) < 0.26 for candidate in candidates): + return supported, output + return None, output + + +def format_print_error(result: subprocess.CompletedProcess[str]) -> str: + details = result.stderr.strip() or result.stdout.strip() or "Unbekannter Fehler" + if "PLite Mode" in details or "RemovableDisk" in details: + return ( + "Drucker ist im Editor-Lite/PLite-Modus. Am PT-P700 die Editor-Lite-Taste deaktivieren, " + "bis die Editor-Lite-LED aus ist, USB neu verbinden und erneut drucken. " + f"Details: {details}" + ) + if "LIBUSB_ERROR_IO" in details or "libusb_open error" in details: + return ( + "Drucker wurde gefunden, konnte aber per libusb nicht geoeffnet werden. " + "In Unraid pruefen: Privileged an, /dev/bus/usb Read/Write gemountet, usblp nicht geladen. " + f"Details: {details}" + ) + return f"Druck fehlgeschlagen: {details}" + + +def design_from_request() -> tuple[dict[str, Any], int]: + payload = request.get_json(force=True) + design = payload.get("design") if isinstance(payload, dict) else None + if not isinstance(design, dict): + raise ValueError("Kein gueltiges Layout erhalten.") + copies = int(payload.get("copies", 1)) + return design, max(1, min(20, copies)) + + +@app.get("/") +def index(): + return render_template( + "index.html", + tape_widths=TAPE_WIDTHS_MM, + print_enabled=PRINT_ENABLED, + default_command=PRINT_COMMAND, + ) + + +@app.get("/mobile") +def mobile(): + return render_template( + "mobile.html", + tape_widths=TAPE_WIDTHS_MM, + print_enabled=PRINT_ENABLED, + default_command=PRINT_COMMAND, + ) + + +@app.post("/api/preview") +def api_preview(): + try: + design, _copies = design_from_request() + image_path = render_design(design) + return jsonify({"ok": True, "image": f"/labels/{image_path.name}"}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 400 + + +@app.post("/api/print") +def api_print(): + try: + design, copies = design_from_request() + image_path = render_design(design) + if PRINT_ENABLED: + results = print_label(image_path, copies) + failed = next((result for result in results if result.returncode != 0), None) + if failed: + raise RuntimeError(format_print_error(failed)) + message = "Etikett wurde an den Drucker gesendet." + else: + message = f"Testmodus: Etikett erstellt, nicht gedruckt: {image_path.name}" + return jsonify({"ok": True, "message": message, "image": f"/labels/{image_path.name}"}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 400 + + +@app.get("/api/tape-info") +def api_tape_info(): + try: + width, raw = detect_tape_width() + if width is None: + return jsonify({"ok": False, "error": "Bandbreite konnte nicht erkannt werden.", "raw": raw}), 400 + return jsonify({"ok": True, "width": width, "raw": raw}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 400 + + +@app.get("/labels/") +def labels(filename: str): + return send_from_directory(LABEL_DIR, filename) diff --git a/app/static/editor.js b/app/static/editor.js new file mode 100644 index 0000000..130c242 --- /dev/null +++ b/app/static/editor.js @@ -0,0 +1,464 @@ +const MM_TO_SCREEN = 6; +const STORAGE_KEY = "ptouch-web-layout"; + +const state = { + zoom: 1.5, + selectedId: null, + dragging: null, + design: { + tapeWidthMm: 24, + lengthMm: 70, + marginMm: 2, + orientation: "landscape", + objects: [], + }, +}; + +const canvas = document.getElementById("labelCanvas"); +const ctx = canvas.getContext("2d"); +const message = document.getElementById("message"); + +const controls = { + tapeWidth: document.getElementById("tapeWidth"), + lengthMm: document.getElementById("lengthMm"), + marginMm: document.getElementById("marginMm"), + copies: document.getElementById("copies"), + zoomLabel: document.getElementById("zoomLabel"), + previewImage: document.getElementById("previewImage"), + noSelection: document.getElementById("noSelection"), + objectProps: document.getElementById("objectProps"), + propText: document.getElementById("propText"), + propX: document.getElementById("propX"), + propY: document.getElementById("propY"), + propW: document.getElementById("propW"), + propH: document.getElementById("propH"), + propFont: document.getElementById("propFont"), + propLine: document.getElementById("propLine"), + propAlign: document.getElementById("propAlign"), + propBold: document.getElementById("propBold"), + propFill: document.getElementById("propFill"), + propRows: document.getElementById("propRows"), + propCols: document.getElementById("propCols"), +}; + +function uid() { + return `obj-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function canvasMm() { + const d = state.design; + return d.orientation === "portrait" + ? { w: d.tapeWidthMm, h: d.lengthMm } + : { w: d.lengthMm, h: d.tapeWidthMm }; +} + +function scale() { + return MM_TO_SCREEN * state.zoom; +} + +function mmToPx(v) { + return v * scale(); +} + +function pxToMm(v) { + return v / scale(); +} + +function selected() { + return state.design.objects.find((obj) => obj.id === state.selectedId) || null; +} + +function setMessage(text, type = "") { + message.textContent = text; + message.className = `message-line ${type}`; +} + +function syncDesignControls() { + state.design.tapeWidthMm = Number(controls.tapeWidth.value); + state.design.lengthMm = Number(controls.lengthMm.value); + state.design.marginMm = Number(controls.marginMm.value); + state.design.orientation = document.querySelector("input[name='orientation']:checked").value; +} + +function applyDesignControls() { + controls.tapeWidth.value = state.design.tapeWidthMm; + controls.lengthMm.value = state.design.lengthMm; + controls.marginMm.value = state.design.marginMm; + document.querySelector(`input[name='orientation'][value='${state.design.orientation}']`).checked = true; +} + +function drawRulers() { + const rulerX = document.getElementById("rulerX"); + const rulerY = document.getElementById("rulerY"); + const size = canvasMm(); + const step = 10; + rulerX.innerHTML = ""; + rulerY.innerHTML = ""; + for (let x = 0; x <= size.w; x += step) { + const mark = document.createElement("span"); + mark.textContent = x; + mark.style.position = "absolute"; + mark.style.left = `${mmToPx(x) + 72}px`; + mark.style.top = "5px"; + rulerX.appendChild(mark); + } + for (let y = 0; y <= size.h; y += step) { + const mark = document.createElement("span"); + mark.textContent = y; + mark.style.position = "absolute"; + mark.style.top = `${mmToPx(y) + 72}px`; + mark.style.left = "4px"; + rulerY.appendChild(mark); + } +} + +function drawTextObject(obj) { + ctx.save(); + ctx.fillStyle = "#000"; + ctx.font = `${obj.bold ? "700 " : ""}${mmToPx(obj.fontSizeMm || 5)}px system-ui, sans-serif`; + ctx.textBaseline = "middle"; + ctx.textAlign = obj.align || "left"; + const x = mmToPx(obj.x); + const y = mmToPx(obj.y); + const w = mmToPx(obj.w); + const h = mmToPx(obj.h); + const lines = String(obj.text || "").split("\n"); + const lineHeight = mmToPx((obj.fontSizeMm || 5) * 1.15); + const total = lineHeight * lines.length; + let cy = y + h / 2 - total / 2 + lineHeight / 2; + const tx = obj.align === "center" ? x + w / 2 : obj.align === "right" ? x + w : x; + for (const line of lines) { + ctx.fillText(line, tx, cy, w); + cy += lineHeight; + } + ctx.restore(); +} + +function drawObject(obj) { + const x = mmToPx(obj.x); + const y = mmToPx(obj.y); + const w = mmToPx(obj.w); + const h = mmToPx(obj.h); + ctx.save(); + ctx.lineWidth = Math.max(1, mmToPx(obj.lineMm || 0.25)); + ctx.strokeStyle = "#000"; + ctx.fillStyle = "#000"; + + if (obj.type === "text" || obj.type === "symbol") { + drawTextObject(obj); + } else if (obj.type === "shape") { + if (obj.fill) ctx.fillRect(x, y, w, h); + ctx.strokeRect(x, y, w, h); + } else if (obj.type === "line") { + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + w, y + h); + ctx.stroke(); + } else if (obj.type === "table") { + const rows = obj.rows || 2; + const cols = obj.cols || 2; + ctx.strokeRect(x, y, w, h); + for (let c = 1; c < cols; c++) { + ctx.beginPath(); + ctx.moveTo(x + (w * c) / cols, y); + ctx.lineTo(x + (w * c) / cols, y + h); + ctx.stroke(); + } + for (let r = 1; r < rows; r++) { + ctx.beginPath(); + ctx.moveTo(x, y + (h * r) / rows); + ctx.lineTo(x + w, y + (h * r) / rows); + ctx.stroke(); + } + } else if (obj.type === "qr" || obj.type === "barcode") { + ctx.strokeRect(x, y, w, h); + ctx.font = `${Math.max(10, mmToPx(2.5))}px system-ui`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(obj.type === "qr" ? "QR" : "BARCODE", x + w / 2, y + h / 2); + } else if (obj.type === "image") { + if (obj.img) { + ctx.drawImage(obj.img, x, y, w, h); + } else { + ctx.strokeRect(x, y, w, h); + ctx.fillText("Bild", x + 8, y + 18); + } + } + + if (obj.id === state.selectedId) { + ctx.strokeStyle = "#2374e1"; + ctx.lineWidth = 2; + ctx.strokeRect(x - 3, y - 3, w + 6, h + 6); + ctx.fillStyle = "#2374e1"; + ctx.fillRect(x + w - 5, y + h - 5, 10, 10); + } + ctx.restore(); +} + +function draw() { + syncDesignControls(); + const size = canvasMm(); + canvas.width = Math.round(mmToPx(size.w)); + canvas.height = Math.round(mmToPx(size.h)); + canvas.style.width = `${canvas.width}px`; + canvas.style.height = `${canvas.height}px`; + + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + const margin = mmToPx(state.design.marginMm); + ctx.strokeStyle = "#b7b7b7"; + ctx.setLineDash([2, 2]); + ctx.strokeRect(margin, margin, canvas.width - margin * 2, canvas.height - margin * 2); + ctx.setLineDash([]); + + for (const obj of state.design.objects) drawObject(obj); + drawRulers(); + controls.zoomLabel.textContent = `${Math.round(state.zoom * 100)}%`; + updateProps(); +} + +function addObject(type, data = {}) { + syncDesignControls(); + const size = canvasMm(); + const base = { + id: uid(), + type, + x: state.design.marginMm, + y: state.design.marginMm, + w: Math.min(32, size.w - state.design.marginMm * 2), + h: Math.min(10, size.h - state.design.marginMm * 2), + text: "Text", + fontSizeMm: 5, + lineMm: 0.25, + align: "left", + bold: false, + fill: false, + rows: 2, + cols: 2, + ...data, + }; + state.design.objects.push(base); + state.selectedId = base.id; + draw(); +} + +function hitTest(mx, my) { + for (let i = state.design.objects.length - 1; i >= 0; i--) { + const obj = state.design.objects[i]; + if (mx >= obj.x && mx <= obj.x + obj.w && my >= obj.y && my <= obj.y + obj.h) return obj; + } + return null; +} + +function canvasPoint(event) { + const rect = canvas.getBoundingClientRect(); + return { x: pxToMm(event.clientX - rect.left), y: pxToMm(event.clientY - rect.top) }; +} + +function updateProps() { + const obj = selected(); + controls.noSelection.hidden = Boolean(obj); + controls.objectProps.hidden = !obj; + if (!obj) return; + controls.propText.value = obj.text || ""; + controls.propX.value = obj.x; + controls.propY.value = obj.y; + controls.propW.value = obj.w; + controls.propH.value = obj.h; + controls.propFont.value = obj.fontSizeMm || 5; + controls.propLine.value = obj.lineMm || 0.25; + controls.propAlign.value = obj.align || "left"; + controls.propBold.checked = Boolean(obj.bold); + controls.propFill.checked = Boolean(obj.fill); + controls.propRows.value = obj.rows || 2; + controls.propCols.value = obj.cols || 2; +} + +function applyProps() { + const obj = selected(); + if (!obj) return; + obj.text = controls.propText.value; + obj.x = Number(controls.propX.value); + obj.y = Number(controls.propY.value); + obj.w = Number(controls.propW.value); + obj.h = Number(controls.propH.value); + obj.fontSizeMm = Number(controls.propFont.value); + obj.lineMm = Number(controls.propLine.value); + obj.align = controls.propAlign.value; + obj.bold = controls.propBold.checked; + obj.fill = controls.propFill.checked; + obj.rows = Number(controls.propRows.value); + obj.cols = Number(controls.propCols.value); + draw(); +} + +async function sendDesign(url) { + setMessage("Rendere Etikett..."); + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ design: state.design, copies: Number(controls.copies.value) || 1 }), + }); + const payload = await response.json(); + if (!payload.ok) { + setMessage(payload.error || "Fehler", "error"); + return; + } + if (payload.image) controls.previewImage.src = `${payload.image}?t=${Date.now()}`; + setMessage(payload.message || "Vorschau erstellt.", "success"); +} + +document.querySelectorAll("[data-tool]").forEach((button) => { + button.addEventListener("click", () => { + const tool = button.dataset.tool; + if (tool === "frame") addObject("shape", { w: 28, h: 14, fill: false }); + if (tool === "rect") addObject("shape", { w: 18, h: 10, fill: true }); + if (tool === "line") addObject("line", { w: 24, h: 0.1 }); + if (tool === "text") addObject("text", { w: 35, h: 10, text: "Text" }); + if (tool === "table") addObject("table", { w: 36, h: 14, rows: 2, cols: 3 }); + if (tool === "qr") addObject("qr", { w: 16, h: 16, text: "https://example.local" }); + if (tool === "barcode") addObject("barcode", { w: 42, h: 12, text: "1234567890" }); + if (tool === "symbol") addObject("symbol", { w: 10, h: 10, text: "★", fontSizeMm: 7, align: "center" }); + }); +}); + +document.getElementById("imageInput").addEventListener("change", (event) => { + const file = event.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const img = new Image(); + img.onload = () => addObject("image", { w: 20, h: 14, dataUrl: reader.result, img }); + img.src = reader.result; + }; + reader.readAsDataURL(file); +}); + +canvas.addEventListener("mousedown", (event) => { + const point = canvasPoint(event); + const obj = hitTest(point.x, point.y); + state.selectedId = obj ? obj.id : null; + if (obj) { + const resize = point.x > obj.x + obj.w - 2 && point.y > obj.y + obj.h - 2; + state.dragging = { id: obj.id, dx: point.x - obj.x, dy: point.y - obj.y, resize }; + } + draw(); +}); + +window.addEventListener("mousemove", (event) => { + if (!state.dragging) return; + const obj = selected(); + if (!obj) return; + const point = canvasPoint(event); + if (state.dragging.resize) { + obj.w = Math.max(2, point.x - obj.x); + obj.h = Math.max(2, point.y - obj.y); + } else { + obj.x = Math.max(0, point.x - state.dragging.dx); + obj.y = Math.max(0, point.y - state.dragging.dy); + } + draw(); +}); + +window.addEventListener("mouseup", () => { + state.dragging = null; +}); + +window.addEventListener("keydown", (event) => { + const editable = event.target.closest("input, textarea, select"); + if (editable) return; + if ((event.key === "Delete" || event.key === "Backspace") && state.selectedId) { + event.preventDefault(); + state.design.objects = state.design.objects.filter((obj) => obj.id !== state.selectedId); + state.selectedId = null; + draw(); + setMessage("Objekt geloescht.", "success"); + } +}); + +["tapeWidth", "lengthMm", "marginMm"].forEach((id) => document.getElementById(id).addEventListener("input", draw)); +document.querySelectorAll("input[name='orientation']").forEach((input) => input.addEventListener("change", draw)); +Object.values(controls).forEach((control) => { + if (control && control.id && control.id.startsWith("prop")) control.addEventListener("input", applyProps); +}); + +document.getElementById("deleteObj").addEventListener("click", () => { + state.design.objects = state.design.objects.filter((obj) => obj.id !== state.selectedId); + state.selectedId = null; + draw(); +}); + +document.getElementById("duplicateObj").addEventListener("click", () => { + const obj = selected(); + if (!obj) return; + const copy = { ...obj, id: uid(), x: obj.x + 2, y: obj.y + 2 }; + state.design.objects.push(copy); + state.selectedId = copy.id; + draw(); +}); + +document.getElementById("zoomOut").addEventListener("click", () => { + state.zoom = Math.max(0.5, state.zoom - 0.25); + draw(); +}); +document.getElementById("zoomIn").addEventListener("click", () => { + state.zoom = Math.min(4, state.zoom + 0.25); + draw(); +}); + +document.getElementById("previewBtn").addEventListener("click", () => sendDesign("/api/preview")); +document.getElementById("printBtn").addEventListener("click", () => sendDesign("/api/print")); +document.getElementById("detectTape").addEventListener("click", async () => { + setMessage("Erkenne Band..."); + const response = await fetch("/api/tape-info"); + const payload = await response.json(); + if (!payload.ok) { + setMessage(payload.error || "Band konnte nicht erkannt werden.", "error"); + return; + } + controls.tapeWidth.value = String(payload.width); + state.design.tapeWidthMm = Number(payload.width); + draw(); + setMessage(`Band erkannt: ${payload.width} mm`, "success"); +}); + +document.getElementById("saveLayout").addEventListener("click", () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state.design)); + setMessage("Layout gespeichert.", "success"); +}); + +document.getElementById("loadLayout").addEventListener("click", () => { + const saved = localStorage.getItem(STORAGE_KEY); + if (!saved) return setMessage("Kein gespeichertes Layout gefunden.", "error"); + state.design = JSON.parse(saved); + state.selectedId = null; + applyDesignControls(); + hydrateImages(); + draw(); +}); + +document.getElementById("clearLayout").addEventListener("click", () => { + state.design.objects = []; + state.selectedId = null; + draw(); +}); + +document.getElementById("exportJson").addEventListener("click", async () => { + await navigator.clipboard.writeText(JSON.stringify(state.design, null, 2)); + setMessage("Layout-JSON in die Zwischenablage kopiert.", "success"); +}); + +function hydrateImages() { + for (const obj of state.design.objects) { + if (obj.type === "image" && obj.dataUrl) { + const img = new Image(); + img.onload = draw; + img.src = obj.dataUrl; + obj.img = img; + } + } +} + +addObject("text", { x: 4, y: 4, w: 36, h: 10, text: "P-Touch", fontSizeMm: 5.5, bold: true }); +state.selectedId = null; +draw(); diff --git a/app/static/mobile.css b/app/static/mobile.css new file mode 100644 index 0000000..b16ee37 --- /dev/null +++ b/app/static/mobile.css @@ -0,0 +1,183 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #171717; + color: #f1f1f1; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: #171717; +} + +button, +input, +select, +textarea { + font: inherit; +} + +.mobile-shell { + width: min(100%, 620px); + min-height: 100vh; + margin: 0 auto; + padding: max(14px, env(safe-area-inset-top)) 14px calc(96px + env(safe-area-inset-bottom)); +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +h1 { + margin: 0; + font-size: 24px; +} + +header p { + margin: 3px 0 0; + color: #aaa; +} + +header a { + color: #9fc5ff; + text-decoration: none; + font-weight: 700; +} + +.card { + display: grid; + gap: 14px; + background: #252525; + border: 1px solid #383838; + border-radius: 8px; + padding: 14px; + margin-bottom: 12px; +} + +label { + display: grid; + gap: 7px; + color: #ddd; + font-weight: 700; +} + +textarea, +input, +select { + width: 100%; + min-height: 44px; + border: 1px solid #555; + border-radius: 7px; + background: #303030; + color: #fff; + padding: 10px; +} + +textarea { + resize: vertical; + line-height: 1.35; +} + +button { + min-height: 44px; + border: 1px solid #575757; + border-radius: 7px; + background: #3a3a3a; + color: #fff; + padding: 10px 12px; + font-weight: 800; +} + +button.primary { + background: #2374e1; + border-color: #2374e1; +} + +.grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.input-action { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; +} + +.check-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.check-row label { + display: flex; + align-items: center; + gap: 9px; + min-height: 44px; + padding: 0 10px; + border: 1px solid #494949; + border-radius: 7px; + background: #303030; +} + +.check-row input { + width: 20px; + min-height: 20px; +} + +.preview { + min-height: 132px; + display: grid; + place-items: center; + overflow: auto; + background: #1f1f1f; + border: 1px dashed #555; + border-radius: 7px; + padding: 12px; +} + +.preview img { + max-width: 100%; + image-rendering: crisp-edges; +} + +.message { + min-height: 22px; + margin: 0; + color: #d5d5d5; + line-height: 1.35; +} + +.message.error { + color: #ffb4b4; +} + +.message.success { + color: #a7e8af; +} + +.bottom-actions { + position: fixed; + left: 0; + right: 0; + bottom: 0; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + width: min(100%, 620px); + margin: 0 auto; + padding: 12px 14px calc(12px + env(safe-area-inset-bottom)); + background: rgba(23, 23, 23, 0.96); + border-top: 1px solid #333; +} diff --git a/app/static/mobile.js b/app/static/mobile.js new file mode 100644 index 0000000..fbbd34b --- /dev/null +++ b/app/static/mobile.js @@ -0,0 +1,88 @@ +const fields = { + text: document.getElementById("mobileText"), + tape: document.getElementById("mobileTape"), + length: document.getElementById("mobileLength"), + margin: document.getElementById("mobileMargin"), + font: document.getElementById("mobileFont"), + copies: document.getElementById("mobileCopies"), + align: document.getElementById("mobileAlign"), + bold: document.getElementById("mobileBold"), + frame: document.getElementById("mobileFrame"), + preview: document.getElementById("mobilePreview"), + message: document.getElementById("mobileMessage"), +}; + +function setMessage(text, type = "") { + fields.message.textContent = text; + fields.message.className = `message ${type}`; +} + +function mobileDesign() { + const tapeWidth = Number(fields.tape.value); + const length = Number(fields.length.value); + const margin = Number(fields.margin.value); + const text = fields.text.value.trim() || " "; + const objects = []; + if (fields.frame.checked) { + objects.push({ + id: "frame", + type: "shape", + x: margin, + y: margin, + w: Math.max(2, length - margin * 2), + h: Math.max(2, tapeWidth - margin * 2), + lineMm: 0.25, + fill: false, + }); + } + objects.push({ + id: "text", + type: "text", + x: margin + (fields.frame.checked ? 1 : 0), + y: margin, + w: Math.max(2, length - margin * 2 - (fields.frame.checked ? 2 : 0)), + h: Math.max(2, tapeWidth - margin * 2), + text, + fontSizeMm: Number(fields.font.value), + bold: fields.bold.checked, + align: fields.align.value, + valign: "middle", + }); + return { + tapeWidthMm: tapeWidth, + lengthMm: length, + marginMm: margin, + orientation: "landscape", + objects, + }; +} + +async function send(url) { + setMessage("Rendere Etikett..."); + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ design: mobileDesign(), copies: Number(fields.copies.value) || 1 }), + }); + const payload = await response.json(); + if (!payload.ok) { + setMessage(payload.error || "Fehler", "error"); + return; + } + fields.preview.src = `${payload.image}?t=${Date.now()}`; + setMessage(payload.message || "Vorschau erstellt.", "success"); +} + +document.getElementById("mobilePreviewBtn").addEventListener("click", () => send("/api/preview")); +document.getElementById("mobilePrintBtn").addEventListener("click", () => send("/api/print")); +document.getElementById("mobileDetect").addEventListener("click", async () => { + setMessage("Erkenne Band..."); + const response = await fetch("/api/tape-info"); + const payload = await response.json(); + if (!payload.ok) { + setMessage(payload.error || "Band konnte nicht erkannt werden.", "error"); + return; + } + fields.tape.value = String(payload.width); + setMessage(`Band erkannt: ${payload.width} mm`, "success"); +}); diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..a72fc42 --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,311 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #202020; + color: #e8e8e8; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + overflow: hidden; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +.file-button { + border: 1px solid #555; + border-radius: 6px; + background: #3a3a3a; + color: #f2f2f2; + padding: 8px 10px; + cursor: pointer; +} + +button:hover, +.file-button:hover { + background: #454545; +} + +button.primary { + background: #2374e1; + border-color: #2374e1; +} + +button.danger { + background: #5a2d2d; + border-color: #7d3a3a; +} + +input, +select, +textarea { + width: 100%; + border: 1px solid #565656; + border-radius: 5px; + background: #303030; + color: #f3f3f3; + padding: 7px 8px; +} + +textarea { + resize: vertical; +} + +.app-shell { + display: grid; + grid-template-columns: 260px minmax(0, 1fr) 320px; + grid-template-rows: 78px minmax(0, 1fr); + height: 100vh; +} + +.topbar { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 180px minmax(0, 1fr) 160px 230px; + gap: 16px; + align-items: center; + border-bottom: 1px solid #111; + background: #2d2d2d; + padding: 10px 14px; +} + +.brand { + display: grid; + gap: 2px; +} + +.brand span { + color: #aaa; + font-size: 13px; +} + +.brand a { + color: #9fc5ff; + text-decoration: none; +} + +.toolbar { + display: flex; + gap: 8px; + align-items: center; + overflow-x: auto; +} + +.toolbar button, +.file-button { + min-width: 64px; + text-align: center; +} + +.file-button input { + display: none; +} + +.zoom-group, +.print-actions { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; +} + +.zoom-group span { + min-width: 56px; + text-align: center; + color: #cfcfcf; +} + +.sidebar { + overflow: auto; + background: #282828; + border-right: 1px solid #111; + padding: 16px; +} + +.right-panel { + border-right: 0; + border-left: 1px solid #111; +} + +.sidebar section { + display: grid; + gap: 12px; + padding-bottom: 18px; + margin-bottom: 18px; + border-bottom: 1px solid #3a3a3a; +} + +.sidebar h2 { + margin: 0; + font-size: 16px; +} + +.sidebar p { + margin: 0; + color: #a8a8a8; + line-height: 1.4; +} + +.sidebar label { + display: grid; + gap: 6px; + color: #d7d7d7; + font-size: 14px; + font-weight: 650; +} + +.radio-row, +.check-row { + display: grid; + gap: 9px; +} + +.radio-row label, +.check-row label { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; +} + +.radio-row input, +.check-row input { + width: auto; +} + +.grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.input-action { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; +} + +.input-action button { + min-width: 92px; + padding-inline: 9px; +} + +.workspace { + position: relative; + overflow: hidden; + background: #303030; +} + +.stage-wrap { + position: absolute; + inset: 24px 0 34px 28px; + overflow: auto; + display: grid; + align-items: start; + justify-items: start; + padding: 72px; +} + +#labelCanvas { + background: #fff; + box-shadow: 0 0 0 1px #bdbdbd, 0 18px 50px rgba(0, 0, 0, 0.35); + image-rendering: crisp-edges; +} + +.ruler { + position: absolute; + color: #aaa; + font-size: 11px; + background: #262626; + z-index: 2; + pointer-events: none; +} + +.ruler-x { + top: 0; + left: 28px; + right: 0; + height: 24px; + border-bottom: 1px solid #171717; +} + +.ruler-y { + top: 24px; + left: 0; + bottom: 34px; + width: 28px; + border-right: 1px solid #171717; +} + +.message-line { + position: absolute; + left: 46px; + right: 18px; + bottom: 8px; + min-height: 20px; + color: #d7d7d7; + font-size: 14px; +} + +.message-line.error { + color: #ffb1b1; +} + +.message-line.success { + color: #a8e6b0; +} + +.preview-box { + min-height: 120px; + display: grid; + place-items: center; + border: 1px dashed #555; + background: #222; + padding: 12px; +} + +.preview-box img { + max-width: 100%; + image-rendering: crisp-edges; +} + +code { + overflow-wrap: anywhere; + color: #b9d5ff; +} + +@media (max-width: 980px) { + body { + overflow: auto; + } + + .app-shell { + grid-template-columns: 1fr; + grid-template-rows: auto auto minmax(420px, 1fr) auto; + height: auto; + min-height: 100vh; + } + + .topbar { + grid-template-columns: 1fr; + } + + .sidebar { + border: 0; + } + + .stage-wrap { + min-height: 420px; + } +} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..bb4cca5 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,150 @@ + + + + + + P-Touch Web Editor + + + +
+
+
+ P-Touch Web + PT-P700 · Mobile +
+
+ + + + + + + + + +
+
+ + 150% + +
+ +
+ + + +
+
+
+
+ +
+
+
+ + +
+ + + + diff --git a/app/templates/mobile.html b/app/templates/mobile.html new file mode 100644 index 0000000..a90a4ed --- /dev/null +++ b/app/templates/mobile.html @@ -0,0 +1,91 @@ + + + + + + P-Touch Mobile + + + +
+
+
+

P-Touch Mobile

+

PT-P700

+
+ Editor +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + +
+ + + +
+ + +
+
+ +
+
+ Vorschau +
+

+
+ + +
+ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7919303 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + ptouch-web: + build: . + container_name: ptouch-web + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./labels:/data/labels + - /dev/bus/usb:/dev/bus/usb + privileged: true + environment: + PRINT_ENABLED: "1" + PRINT_COMMAND: "ptouch-print --image {image}" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a089cd0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.0.3 +Pillow==10.4.0 +qrcode==8.0 +python-barcode==0.15.1 diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..e8aa905 --- /dev/null +++ b/start.sh @@ -0,0 +1,77 @@ +#!/bin/sh +set -eu + +APP_DIR="${APP_DIR:-/srv/ptouch-web}" +LABEL_DIR="${LABEL_DIR:-/data/labels}" +PORT="${PORT:-8080}" +PTOUCH_SRC_DIR="${PTOUCH_SRC_DIR:-/tmp/ptouch-print}" +FLASK_APP="${FLASK_APP:-app.app}" +export FLASK_APP + +cd "$APP_DIR" +mkdir -p "${LABEL_DIR:-/data/labels}" + +echo "Starting P-Touch Web" +echo "APP_DIR=${APP_DIR}" +echo "LABEL_DIR=${LABEL_DIR}" +echo "FLASK_APP=${FLASK_APP}" +echo "PRINT_ENABLED=${PRINT_ENABLED:-1}" +echo "PRINT_COMMAND=${PRINT_COMMAND:-ptouch-print --image {image}}" + +python3 - <<'PY' +import os + +command = os.environ.get("PRINT_COMMAND", "ptouch-print --image {image}") +try: + command.format(image="/tmp/test.png", copies=1) +except Exception as exc: + raise SystemExit(f"ERROR: invalid PRINT_COMMAND: {exc}") +PY + +if command -v apk >/dev/null 2>&1; then + apk add --no-cache \ + build-base \ + cmake \ + gd \ + gd-dev \ + gettext \ + gettext-dev \ + git \ + libusb \ + libusb-dev \ + usbutils \ + pkgconf \ + ttf-dejavu +fi + +if ! python3 -c "import flask, PIL, qrcode, barcode" >/dev/null 2>&1; then + if [ -f requirements.txt ]; then + python3 -m pip install --root-user-action=ignore --no-cache-dir -r requirements.txt + else + python3 -m pip install --root-user-action=ignore --no-cache-dir Flask Pillow + fi +fi + +if ! python3 -c "import qrcode, barcode" >/dev/null 2>&1; then + python3 -m pip install --root-user-action=ignore --no-cache-dir qrcode python-barcode +fi + +if ! command -v ptouch-print >/dev/null 2>&1; then + rm -rf "$PTOUCH_SRC_DIR" + git clone --depth 1 --branch farix-main https://github.com/farixembedded/ptouch-print.git "$PTOUCH_SRC_DIR" + cmake -S "$PTOUCH_SRC_DIR" -B "$PTOUCH_SRC_DIR/build" -DCMAKE_BUILD_TYPE=Release + cmake --build "$PTOUCH_SRC_DIR/build" + install -m 0755 "$PTOUCH_SRC_DIR/build/ptouch-print" /usr/local/bin/ptouch-print +fi + +echo "ptouch-print: $(ptouch-print --version 2>/dev/null || echo installed)" + +if [ ! -d /dev/bus/usb ]; then + echo "WARNING: /dev/bus/usb is not mounted. USB printer access will not work." +else + echo "USB devices visible in container:" + lsusb 2>/dev/null || true + find /dev/bus/usb -type c -maxdepth 3 -exec ls -l {} \; 2>/dev/null || true +fi + +exec python3 -m flask --app "${FLASK_APP}" run --host=0.0.0.0 --port="${PORT}" diff --git a/unraid-template.xml b/unraid-template.xml new file mode 100644 index 0000000..226d9ec --- /dev/null +++ b/unraid-template.xml @@ -0,0 +1,32 @@ + + + ptouch-web + python:3.12-alpine + + bridge + true + + + Web-GUI fuer Brother P-Touch PT-P700 USB-Etikettendrucker. + Productivity: + http://[IP]:[PORT:8080]/ + + + + sh /srv/ptouch-web/start.sh + + + + + /dev/bus/usb + 8080 + /mnt/user/appdata/ptouch-web + /mnt/user/appdata/ptouch-web/labels + /dev/bus/usb + /srv/ptouch-web + app.app + 1 + ptouch-print --image {image} + /data/labels + 180 +