Initial P-Touch web editor
This commit is contained in:
+354
@@ -0,0 +1,354 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, jsonify, render_template, request, send_from_directory
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
||||
|
||||
try:
|
||||
import barcode
|
||||
from barcode.writer import ImageWriter
|
||||
except Exception: # pragma: no cover - optional at runtime until installed
|
||||
barcode = None
|
||||
ImageWriter = None
|
||||
|
||||
try:
|
||||
import qrcode
|
||||
except Exception: # pragma: no cover - optional at runtime until installed
|
||||
qrcode = None
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
LABEL_DIR = Path(os.environ.get("LABEL_DIR", BASE_DIR / "labels"))
|
||||
LABEL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
DEFAULT_PRINT_COMMAND = "ptouch-print --image {image}"
|
||||
PRINT_COMMAND = os.environ.get("PRINT_COMMAND", DEFAULT_PRINT_COMMAND)
|
||||
PRINT_ENABLED = os.environ.get("PRINT_ENABLED", "1").lower() in {"1", "true", "yes", "on"}
|
||||
DPI = int(os.environ.get("LABEL_DPI", "180"))
|
||||
|
||||
TAPE_WIDTHS_MM = [3.5, 6, 9, 12, 18, 24]
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get("SECRET_KEY", "change-me")
|
||||
|
||||
|
||||
def mm_to_px(mm: float) -> int:
|
||||
return max(1, round(mm / 25.4 * DPI))
|
||||
|
||||
|
||||
def clamp(value: float, low: float, high: float) -> float:
|
||||
return max(low, min(high, value))
|
||||
|
||||
|
||||
def get_font(size_px: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
candidates = [
|
||||
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
]
|
||||
for path in candidates:
|
||||
if Path(path).exists():
|
||||
return ImageFont.truetype(path, size_px)
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def new_label_image(width_px: int, height_px: int) -> Image.Image:
|
||||
image = Image.new("P", (width_px, height_px), 0)
|
||||
image.putpalette([255, 255, 255, 0, 0, 0] + [0, 0, 0] * 254)
|
||||
return image
|
||||
|
||||
|
||||
def object_box(obj: dict[str, Any]) -> tuple[int, int, int, int]:
|
||||
x = mm_to_px(float(obj.get("x", 0)))
|
||||
y = mm_to_px(float(obj.get("y", 0)))
|
||||
w = mm_to_px(float(obj.get("w", 10)))
|
||||
h = mm_to_px(float(obj.get("h", 5)))
|
||||
return x, y, max(1, w), max(1, h)
|
||||
|
||||
|
||||
def wrap_text(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for raw_line in text.splitlines() or [""]:
|
||||
current = ""
|
||||
for word in raw_line.split(" "):
|
||||
candidate = word if not current else f"{current} {word}"
|
||||
if draw.textbbox((0, 0), candidate, font=font)[2] <= max_width:
|
||||
current = candidate
|
||||
continue
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = word
|
||||
if current:
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
|
||||
def draw_text(draw: ImageDraw.ImageDraw, obj: dict[str, Any]) -> None:
|
||||
x, y, w, h = object_box(obj)
|
||||
text = str(obj.get("text", "Text"))
|
||||
font_size = mm_to_px(float(obj.get("fontSizeMm", 5)))
|
||||
font = get_font(font_size, bool(obj.get("bold", False)))
|
||||
align = obj.get("align", "left")
|
||||
valign = obj.get("valign", "middle")
|
||||
lines = wrap_text(draw, text, font, w)
|
||||
boxes = [draw.textbbox((0, 0), line, font=font) for line in lines]
|
||||
line_heights = [box[3] - box[1] for box in boxes]
|
||||
gap = max(1, round(font_size * 0.15))
|
||||
total_h = sum(line_heights) + max(0, len(lines) - 1) * gap
|
||||
if valign == "top":
|
||||
cy = y
|
||||
elif valign == "bottom":
|
||||
cy = y + h - total_h
|
||||
else:
|
||||
cy = y + (h - total_h) // 2
|
||||
for line, box, line_h in zip(lines, boxes, line_heights):
|
||||
text_w = box[2] - box[0]
|
||||
if align == "center":
|
||||
cx = x + (w - text_w) // 2
|
||||
elif align == "right":
|
||||
cx = x + w - text_w
|
||||
else:
|
||||
cx = x
|
||||
draw.text((cx, cy - box[1]), line, font=font, fill=1)
|
||||
cy += line_h + gap
|
||||
|
||||
|
||||
def draw_shape(draw: ImageDraw.ImageDraw, obj: dict[str, Any]) -> None:
|
||||
x, y, w, h = object_box(obj)
|
||||
line = max(1, mm_to_px(float(obj.get("lineMm", 0.25))))
|
||||
shape = obj.get("shape", "rect")
|
||||
fill = 1 if obj.get("fill", False) else None
|
||||
box = [x, y, x + w, y + h]
|
||||
if shape == "ellipse":
|
||||
draw.ellipse(box, outline=1, fill=fill, width=line)
|
||||
elif shape == "line":
|
||||
draw.line((x, y, x + w, y + h), fill=1, width=line)
|
||||
else:
|
||||
draw.rectangle(box, outline=1, fill=fill, width=line)
|
||||
|
||||
|
||||
def paste_mono(image: Image.Image, source: Image.Image, x: int, y: int, w: int, h: int) -> None:
|
||||
source = ImageOps.grayscale(source).resize((w, h), Image.Resampling.LANCZOS)
|
||||
mask = source.point(lambda p: 255 if p < 160 else 0, "1")
|
||||
black = Image.new("P", (w, h), 1)
|
||||
black.putpalette([255, 255, 255, 0, 0, 0] + [0, 0, 0] * 254)
|
||||
image.paste(black, (x, y), mask)
|
||||
|
||||
|
||||
def decode_data_image(data_url: str) -> Image.Image:
|
||||
if "," in data_url:
|
||||
data_url = data_url.split(",", 1)[1]
|
||||
return Image.open(io.BytesIO(base64.b64decode(data_url)))
|
||||
|
||||
|
||||
def draw_qr(image: Image.Image, obj: dict[str, Any]) -> None:
|
||||
if qrcode is None:
|
||||
raise RuntimeError("qrcode ist nicht installiert.")
|
||||
x, y, w, h = object_box(obj)
|
||||
qr = qrcode.QRCode(border=1, box_size=10)
|
||||
qr.add_data(str(obj.get("text", "https://example.local")))
|
||||
qr.make(fit=True)
|
||||
qr_img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
|
||||
side = min(w, h)
|
||||
paste_mono(image, qr_img, x, y, side, side)
|
||||
|
||||
|
||||
def draw_barcode(image: Image.Image, obj: dict[str, Any]) -> None:
|
||||
if barcode is None or ImageWriter is None:
|
||||
raise RuntimeError("python-barcode ist nicht installiert.")
|
||||
x, y, w, h = object_box(obj)
|
||||
writer = ImageWriter()
|
||||
code = barcode.get("code128", str(obj.get("text", "1234567890")), writer=writer)
|
||||
buffer = io.BytesIO()
|
||||
code.write(buffer, {"write_text": bool(obj.get("showText", True)), "quiet_zone": 1.0, "module_height": 8.0})
|
||||
buffer.seek(0)
|
||||
paste_mono(image, Image.open(buffer), x, y, w, h)
|
||||
|
||||
|
||||
def draw_table(draw: ImageDraw.ImageDraw, obj: dict[str, Any]) -> None:
|
||||
x, y, w, h = object_box(obj)
|
||||
rows = max(1, int(obj.get("rows", 2)))
|
||||
cols = max(1, int(obj.get("cols", 2)))
|
||||
line = max(1, mm_to_px(float(obj.get("lineMm", 0.2))))
|
||||
draw.rectangle([x, y, x + w, y + h], outline=1, width=line)
|
||||
for col in range(1, cols):
|
||||
cx = x + round(w * col / cols)
|
||||
draw.line((cx, y, cx, y + h), fill=1, width=line)
|
||||
for row in range(1, rows):
|
||||
cy = y + round(h * row / rows)
|
||||
draw.line((x, cy, x + w, cy), fill=1, width=line)
|
||||
|
||||
|
||||
def render_design(design: dict[str, Any]) -> Path:
|
||||
tape_width = float(design.get("tapeWidthMm", 24))
|
||||
if tape_width not in TAPE_WIDTHS_MM:
|
||||
raise ValueError("Ungueltige Bandbreite.")
|
||||
length = clamp(float(design.get("lengthMm", 70)), 10, 500)
|
||||
orientation = design.get("orientation", "landscape")
|
||||
if orientation == "portrait":
|
||||
canvas_w_mm, canvas_h_mm = tape_width, length
|
||||
else:
|
||||
canvas_w_mm, canvas_h_mm = length, tape_width
|
||||
|
||||
image = new_label_image(mm_to_px(canvas_w_mm), mm_to_px(canvas_h_mm))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
for obj in design.get("objects", []):
|
||||
if not isinstance(obj, dict) or obj.get("hidden"):
|
||||
continue
|
||||
obj_type = obj.get("type")
|
||||
if obj_type == "text" or obj_type == "symbol":
|
||||
draw_text(draw, obj)
|
||||
elif obj_type == "shape":
|
||||
draw_shape(draw, obj)
|
||||
elif obj_type == "line":
|
||||
draw_shape(draw, {**obj, "shape": "line"})
|
||||
elif obj_type == "table":
|
||||
draw_table(draw, obj)
|
||||
elif obj_type == "image" and obj.get("dataUrl"):
|
||||
x, y, w, h = object_box(obj)
|
||||
paste_mono(image, decode_data_image(str(obj["dataUrl"])), x, y, w, h)
|
||||
elif obj_type == "qr":
|
||||
draw_qr(image, obj)
|
||||
elif obj_type == "barcode":
|
||||
draw_barcode(image, obj)
|
||||
|
||||
output = LABEL_DIR / f"label-{uuid.uuid4().hex}.png"
|
||||
image.save(output)
|
||||
return output
|
||||
|
||||
|
||||
def run_print_command(image_path: Path, copies: int) -> subprocess.CompletedProcess[str]:
|
||||
command = PRINT_COMMAND.format(image=shlex.quote(str(image_path)), copies=copies)
|
||||
return subprocess.run(shlex.split(command), capture_output=True, check=False, text=True, timeout=90)
|
||||
|
||||
|
||||
def print_label(image_path: Path, copies: int) -> list[subprocess.CompletedProcess[str]]:
|
||||
if "{copies}" in PRINT_COMMAND:
|
||||
return [run_print_command(image_path, copies)]
|
||||
return [run_print_command(image_path, 1) for _ in range(copies)]
|
||||
|
||||
|
||||
def detect_tape_width() -> tuple[float | None, str]:
|
||||
result = subprocess.run(
|
||||
["ptouch-print", "--info"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
output = "\n".join(part for part in [result.stdout.strip(), result.stderr.strip()] if part)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(format_print_error(result))
|
||||
|
||||
candidates: list[float] = []
|
||||
for pattern in [
|
||||
r"(?:tape|media|band|width|breite|size|groesse|printing)[^\n:=-]*[:=-]?\s*(\d+(?:[.,]\d+)?)\s*mm",
|
||||
r"(\d+(?:[.,]\d+)?)\s*mm",
|
||||
]:
|
||||
for match in re.finditer(pattern, output, re.IGNORECASE):
|
||||
candidates.append(float(match.group(1).replace(",", ".")))
|
||||
|
||||
for supported in TAPE_WIDTHS_MM:
|
||||
if any(abs(candidate - supported) < 0.26 for candidate in candidates):
|
||||
return supported, output
|
||||
return None, output
|
||||
|
||||
|
||||
def format_print_error(result: subprocess.CompletedProcess[str]) -> str:
|
||||
details = result.stderr.strip() or result.stdout.strip() or "Unbekannter Fehler"
|
||||
if "PLite Mode" in details or "RemovableDisk" in details:
|
||||
return (
|
||||
"Drucker ist im Editor-Lite/PLite-Modus. Am PT-P700 die Editor-Lite-Taste deaktivieren, "
|
||||
"bis die Editor-Lite-LED aus ist, USB neu verbinden und erneut drucken. "
|
||||
f"Details: {details}"
|
||||
)
|
||||
if "LIBUSB_ERROR_IO" in details or "libusb_open error" in details:
|
||||
return (
|
||||
"Drucker wurde gefunden, konnte aber per libusb nicht geoeffnet werden. "
|
||||
"In Unraid pruefen: Privileged an, /dev/bus/usb Read/Write gemountet, usblp nicht geladen. "
|
||||
f"Details: {details}"
|
||||
)
|
||||
return f"Druck fehlgeschlagen: {details}"
|
||||
|
||||
|
||||
def design_from_request() -> tuple[dict[str, Any], int]:
|
||||
payload = request.get_json(force=True)
|
||||
design = payload.get("design") if isinstance(payload, dict) else None
|
||||
if not isinstance(design, dict):
|
||||
raise ValueError("Kein gueltiges Layout erhalten.")
|
||||
copies = int(payload.get("copies", 1))
|
||||
return design, max(1, min(20, copies))
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return render_template(
|
||||
"index.html",
|
||||
tape_widths=TAPE_WIDTHS_MM,
|
||||
print_enabled=PRINT_ENABLED,
|
||||
default_command=PRINT_COMMAND,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/mobile")
|
||||
def mobile():
|
||||
return render_template(
|
||||
"mobile.html",
|
||||
tape_widths=TAPE_WIDTHS_MM,
|
||||
print_enabled=PRINT_ENABLED,
|
||||
default_command=PRINT_COMMAND,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/preview")
|
||||
def api_preview():
|
||||
try:
|
||||
design, _copies = design_from_request()
|
||||
image_path = render_design(design)
|
||||
return jsonify({"ok": True, "image": f"/labels/{image_path.name}"})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@app.post("/api/print")
|
||||
def api_print():
|
||||
try:
|
||||
design, copies = design_from_request()
|
||||
image_path = render_design(design)
|
||||
if PRINT_ENABLED:
|
||||
results = print_label(image_path, copies)
|
||||
failed = next((result for result in results if result.returncode != 0), None)
|
||||
if failed:
|
||||
raise RuntimeError(format_print_error(failed))
|
||||
message = "Etikett wurde an den Drucker gesendet."
|
||||
else:
|
||||
message = f"Testmodus: Etikett erstellt, nicht gedruckt: {image_path.name}"
|
||||
return jsonify({"ok": True, "message": message, "image": f"/labels/{image_path.name}"})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@app.get("/api/tape-info")
|
||||
def api_tape_info():
|
||||
try:
|
||||
width, raw = detect_tape_width()
|
||||
if width is None:
|
||||
return jsonify({"ok": False, "error": "Bandbreite konnte nicht erkannt werden.", "raw": raw}), 400
|
||||
return jsonify({"ok": True, "width": width, "raw": raw})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@app.get("/labels/<path:filename>")
|
||||
def labels(filename: str):
|
||||
return send_from_directory(LABEL_DIR, filename)
|
||||
Reference in New Issue
Block a user