Initial P-Touch web editor

This commit is contained in:
Mikei386
2026-06-21 23:07:20 +02:00
commit 7029ee9519
16 changed files with 1983 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.git
.venv
__pycache__
*.pyc
labels/*.png
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
.venv/
labels/*.png
.DS_Store
+29
View File
@@ -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"]
+175
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
+354
View File
@@ -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)
+464
View File
@@ -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();
+183
View File
@@ -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;
}
+88
View File
@@ -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");
});
+311
View File
@@ -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;
}
}
+150
View File
@@ -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>
+91
View File
@@ -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>
+14
View File
@@ -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}"
+4
View File
@@ -0,0 +1,4 @@
Flask==3.0.3
Pillow==10.4.0
qrcode==8.0
python-barcode==0.15.1
+77
View File
@@ -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}"
+32
View File
@@ -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>