Initial P-Touch web editor
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
labels/*.png
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
labels/*.png
|
||||||
|
.DS_Store
|
||||||
+29
@@ -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"]
|
||||||
@@ -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: <http://localhost:8080>
|
||||||
|
|
||||||
|
Mobile Ansicht: <http://localhost:8080/mobile>
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
+354
@@ -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/<path:filename>")
|
||||||
|
def labels(filename: str):
|
||||||
|
return send_from_directory(LABEL_DIR, filename)
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>P-Touch Web Editor</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<strong>P-Touch Web</strong>
|
||||||
|
<span>PT-P700 · <a href="{{ url_for('mobile') }}">Mobile</a></span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar" aria-label="Werkzeuge">
|
||||||
|
<button data-tool="text" title="Textfeld">Text</button>
|
||||||
|
<button data-tool="frame" title="Rahmen">Rahmen</button>
|
||||||
|
<button data-tool="rect" title="Form">Form</button>
|
||||||
|
<button data-tool="line" title="Linie">Linie</button>
|
||||||
|
<button data-tool="table" title="Tabelle">Tabelle</button>
|
||||||
|
<button data-tool="qr" title="QR-Code">QR</button>
|
||||||
|
<button data-tool="barcode" title="Barcode">Barcode</button>
|
||||||
|
<button data-tool="symbol" title="Symbol">Symbol</button>
|
||||||
|
<label class="file-button" title="Bild">
|
||||||
|
Bild
|
||||||
|
<input id="imageInput" type="file" accept="image/*">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="zoom-group">
|
||||||
|
<button id="zoomOut">-</button>
|
||||||
|
<span id="zoomLabel">150%</span>
|
||||||
|
<button id="zoomIn">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="print-actions">
|
||||||
|
<button id="previewBtn">Vorschau</button>
|
||||||
|
<button id="printBtn" class="primary">Drucken</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<aside class="sidebar left-panel">
|
||||||
|
<section>
|
||||||
|
<h2>Druckmedium</h2>
|
||||||
|
<label>
|
||||||
|
Bandbreite
|
||||||
|
<div class="input-action">
|
||||||
|
<select id="tapeWidth">
|
||||||
|
{% for width in tape_widths %}
|
||||||
|
<option value="{{ width }}" {% if width == 24 %}selected{% endif %}>{{ width }} mm</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button id="detectTape" type="button">Erkennen</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Laenge
|
||||||
|
<input id="lengthMm" type="number" min="10" max="500" step="1" value="70">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Rand
|
||||||
|
<input id="marginMm" type="number" min="0" max="20" step="0.5" value="2">
|
||||||
|
</label>
|
||||||
|
<div class="radio-row">
|
||||||
|
<label><input type="radio" name="orientation" value="landscape" checked> Querformat</label>
|
||||||
|
<label><input type="radio" name="orientation" value="portrait"> Hochformat</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Kopien
|
||||||
|
<input id="copies" type="number" min="1" max="20" step="1" value="1">
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Layout</h2>
|
||||||
|
<button id="saveLayout">Speichern</button>
|
||||||
|
<button id="loadLayout">Laden</button>
|
||||||
|
<button id="clearLayout">Leeren</button>
|
||||||
|
<button id="exportJson">JSON</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Status</h2>
|
||||||
|
<p>Druck: {{ 'aktiv' if print_enabled else 'Testmodus' }}</p>
|
||||||
|
<code>{{ default_command }}</code>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<div class="ruler ruler-x" id="rulerX"></div>
|
||||||
|
<div class="ruler ruler-y" id="rulerY"></div>
|
||||||
|
<div class="stage-wrap">
|
||||||
|
<canvas id="labelCanvas" width="900" height="260"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="message" class="message-line"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="sidebar right-panel">
|
||||||
|
<section id="noSelection">
|
||||||
|
<h2>Keine Auswahl</h2>
|
||||||
|
<p>Objekt auswaehlen oder ueber die Werkzeugleiste hinzufuegen.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="objectProps" hidden>
|
||||||
|
<h2>Objekt</h2>
|
||||||
|
<label>
|
||||||
|
Inhalt
|
||||||
|
<textarea id="propText" rows="4"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label>X <input id="propX" type="number" step="0.5"></label>
|
||||||
|
<label>Y <input id="propY" type="number" step="0.5"></label>
|
||||||
|
<label>B <input id="propW" type="number" step="0.5"></label>
|
||||||
|
<label>H <input id="propH" type="number" step="0.5"></label>
|
||||||
|
</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label>Schrift <input id="propFont" type="number" min="1" max="30" step="0.5"></label>
|
||||||
|
<label>Linie <input id="propLine" type="number" min="0.1" max="5" step="0.1"></label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Ausrichtung
|
||||||
|
<select id="propAlign">
|
||||||
|
<option value="left">Links</option>
|
||||||
|
<option value="center">Mitte</option>
|
||||||
|
<option value="right">Rechts</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="check-row">
|
||||||
|
<label><input id="propBold" type="checkbox"> Fett</label>
|
||||||
|
<label><input id="propFill" type="checkbox"> Fuellung</label>
|
||||||
|
</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label>Zeilen <input id="propRows" type="number" min="1" max="20" step="1"></label>
|
||||||
|
<label>Spalten <input id="propCols" type="number" min="1" max="20" step="1"></label>
|
||||||
|
</div>
|
||||||
|
<button id="duplicateObj">Duplizieren</button>
|
||||||
|
<button id="deleteObj" class="danger">Loeschen</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Vorschau</h2>
|
||||||
|
<div class="preview-box">
|
||||||
|
<img id="previewImage" alt="Etikettenvorschau">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='editor.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>P-Touch Mobile</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="mobile-shell">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>P-Touch Mobile</h1>
|
||||||
|
<p>PT-P700</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('index') }}">Editor</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<label>
|
||||||
|
Text
|
||||||
|
<textarea id="mobileText" rows="5" autofocus placeholder="Etikettentext"></textarea>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card settings">
|
||||||
|
<label>
|
||||||
|
Band
|
||||||
|
<div class="input-action">
|
||||||
|
<select id="mobileTape">
|
||||||
|
{% for width in tape_widths %}
|
||||||
|
<option value="{{ width }}" {% if width == 24 %}selected{% endif %}>{{ width }} mm</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button id="mobileDetect" type="button">Erkennen</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<label>
|
||||||
|
Laenge
|
||||||
|
<input id="mobileLength" type="number" min="20" max="300" step="1" value="70">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Rand
|
||||||
|
<input id="mobileMargin" type="number" min="0" max="20" step="0.5" value="2">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<label>
|
||||||
|
Schrift
|
||||||
|
<input id="mobileFont" type="number" min="2" max="24" step="0.5" value="6">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Kopien
|
||||||
|
<input id="mobileCopies" type="number" min="1" max="20" step="1" value="1">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Ausrichtung
|
||||||
|
<select id="mobileAlign">
|
||||||
|
<option value="left">Links</option>
|
||||||
|
<option value="center" selected>Mitte</option>
|
||||||
|
<option value="right">Rechts</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="check-row">
|
||||||
|
<label><input id="mobileBold" type="checkbox" checked> Fett</label>
|
||||||
|
<label><input id="mobileFrame" type="checkbox"> Rahmen</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="preview">
|
||||||
|
<img id="mobilePreview" alt="Vorschau">
|
||||||
|
</div>
|
||||||
|
<p id="mobileMessage" class="message"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="bottom-actions">
|
||||||
|
<button id="mobilePreviewBtn" type="button">Vorschau</button>
|
||||||
|
<button id="mobilePrintBtn" type="button" class="primary">Drucken</button>
|
||||||
|
</nav>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='mobile.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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}"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
Pillow==10.4.0
|
||||||
|
qrcode==8.0
|
||||||
|
python-barcode==0.15.1
|
||||||
@@ -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}"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<Container version="2">
|
||||||
|
<Name>ptouch-web</Name>
|
||||||
|
<Repository>python:3.12-alpine</Repository>
|
||||||
|
<Registry/>
|
||||||
|
<Network>bridge</Network>
|
||||||
|
<Privileged>true</Privileged>
|
||||||
|
<Support/>
|
||||||
|
<Project/>
|
||||||
|
<Overview>Web-GUI fuer Brother P-Touch PT-P700 USB-Etikettendrucker.</Overview>
|
||||||
|
<Category>Productivity:</Category>
|
||||||
|
<WebUI>http://[IP]:[PORT:8080]/</WebUI>
|
||||||
|
<TemplateURL/>
|
||||||
|
<Icon/>
|
||||||
|
<ExtraParams/>
|
||||||
|
<PostArgs>sh /srv/ptouch-web/start.sh</PostArgs>
|
||||||
|
<CPUset/>
|
||||||
|
<DateInstalled/>
|
||||||
|
<DonateText/>
|
||||||
|
<DonateLink/>
|
||||||
|
<Requires>/dev/bus/usb</Requires>
|
||||||
|
<Config Name="WebUI Port" Target="8080" Default="8080" Mode="tcp" Description="Weboberflaeche" Type="Port" Display="always" Required="true" Mask="false">8080</Config>
|
||||||
|
<Config Name="App" Target="/srv/ptouch-web" Default="/mnt/user/appdata/ptouch-web" Mode="rw" Description="Projektdateien: app, start.sh, requirements.txt" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ptouch-web</Config>
|
||||||
|
<Config Name="Labels" Target="/data/labels" Default="/mnt/user/appdata/ptouch-web/labels" Mode="rw" Description="Speicherort fuer erzeugte Etiketten-PNGs" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ptouch-web/labels</Config>
|
||||||
|
<Config Name="USB Bus" Target="/dev/bus/usb" Default="/dev/bus/usb" Mode="rw" Description="USB-Zugriff auf den P-Touch" Type="Path" Display="always" Required="true" Mask="false">/dev/bus/usb</Config>
|
||||||
|
<Config Name="APP_DIR" Target="APP_DIR" Default="/srv/ptouch-web" Mode="" Description="Containerpfad der App" Type="Variable" Display="advanced" Required="true" Mask="false">/srv/ptouch-web</Config>
|
||||||
|
<Config Name="FLASK_APP" Target="FLASK_APP" Default="app.app" Mode="" Description="Flask Modul" Type="Variable" Display="advanced" Required="true" Mask="false">app.app</Config>
|
||||||
|
<Config Name="PRINT_ENABLED" Target="PRINT_ENABLED" Default="1" Mode="" Description="1 druckt, 0 erzeugt nur PNGs" Type="Variable" Display="advanced" Required="true" Mask="false">1</Config>
|
||||||
|
<Config Name="PRINT_COMMAND" Target="PRINT_COMMAND" Default="ptouch-print --image {image}" Mode="" Description="Druckkommando. {image} wird ersetzt." Type="Variable" Display="advanced" Required="true" Mask="false">ptouch-print --image {image}</Config>
|
||||||
|
<Config Name="LABEL_DIR" Target="LABEL_DIR" Default="/data/labels" Mode="" Description="Containerpfad fuer Etiketten" Type="Variable" Display="advanced" Required="true" Mask="false">/data/labels</Config>
|
||||||
|
<Config Name="LABEL_DPI" Target="LABEL_DPI" Default="180" Mode="" Description="PT-P700 Druckauflosung" Type="Variable" Display="advanced" Required="true" Mask="false">180</Config>
|
||||||
|
</Container>
|
||||||
Reference in New Issue
Block a user