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
+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>