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