Add mobile mode and print cut controls

This commit is contained in:
Mikei386
2026-06-22 06:46:52 +02:00
parent 7029ee9519
commit a6025eef47
10 changed files with 303 additions and 48 deletions
+155 -36
View File
@@ -2,10 +2,12 @@ from __future__ import annotations
import base64
import io
import signal
import os
import re
import shlex
import subprocess
import threading
import uuid
from pathlib import Path
from typing import Any
@@ -34,17 +36,40 @@ 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"))
PTOUCH_SUPPORTS_HALFCUT: bool | None = None
TAPE_WIDTHS_MM = [3.5, 6, 9, 12, 18, 24]
PRINTABLE_TAPE_PX = {
3.5: 24,
6.0: 32,
9.0: 52,
12.0: 76,
18.0: 120,
24.0: 128,
}
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "change-me")
PRINT_LOCK = threading.Lock()
def mm_to_px(mm: float) -> int:
return max(1, round(mm / 25.4 * DPI))
def printable_px_for_tape(tape_width_mm: float) -> int:
normalized = float(tape_width_mm)
return PRINTABLE_TAPE_PX.get(normalized, mm_to_px(normalized))
def render_scale_for_tape(tape_width_mm: float) -> float:
return printable_px_for_tape(tape_width_mm) / float(tape_width_mm)
def scaled_mm_to_px(mm: float, scale: float) -> int:
return max(1, round(mm * scale))
def clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
@@ -67,11 +92,11 @@ def new_label_image(width_px: int, height_px: int) -> Image.Image:
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)))
def object_box(obj: dict[str, Any], scale: float) -> tuple[int, int, int, int]:
x = scaled_mm_to_px(float(obj.get("x", 0)), scale)
y = scaled_mm_to_px(float(obj.get("y", 0)), scale)
w = scaled_mm_to_px(float(obj.get("w", 10)), scale)
h = scaled_mm_to_px(float(obj.get("h", 5)), scale)
return x, y, max(1, w), max(1, h)
@@ -92,10 +117,10 @@ def wrap_text(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> lis
return lines
def draw_text(draw: ImageDraw.ImageDraw, obj: dict[str, Any]) -> None:
x, y, w, h = object_box(obj)
def draw_text(draw: ImageDraw.ImageDraw, obj: dict[str, Any], scale: float) -> None:
x, y, w, h = object_box(obj, scale)
text = str(obj.get("text", "Text"))
font_size = mm_to_px(float(obj.get("fontSizeMm", 5)))
font_size = scaled_mm_to_px(float(obj.get("fontSizeMm", 5)), scale)
font = get_font(font_size, bool(obj.get("bold", False)))
align = obj.get("align", "left")
valign = obj.get("valign", "middle")
@@ -122,9 +147,9 @@ def draw_text(draw: ImageDraw.ImageDraw, obj: dict[str, Any]) -> None:
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))))
def draw_shape(draw: ImageDraw.ImageDraw, obj: dict[str, Any], scale: float) -> None:
x, y, w, h = object_box(obj, scale)
line = max(1, scaled_mm_to_px(float(obj.get("lineMm", 0.25)), scale))
shape = obj.get("shape", "rect")
fill = 1 if obj.get("fill", False) else None
box = [x, y, x + w, y + h]
@@ -150,10 +175,10 @@ def decode_data_image(data_url: str) -> Image.Image:
return Image.open(io.BytesIO(base64.b64decode(data_url)))
def draw_qr(image: Image.Image, obj: dict[str, Any]) -> None:
def draw_qr(image: Image.Image, obj: dict[str, Any], scale: float) -> None:
if qrcode is None:
raise RuntimeError("qrcode ist nicht installiert.")
x, y, w, h = object_box(obj)
x, y, w, h = object_box(obj, scale)
qr = qrcode.QRCode(border=1, box_size=10)
qr.add_data(str(obj.get("text", "https://example.local")))
qr.make(fit=True)
@@ -162,10 +187,10 @@ def draw_qr(image: Image.Image, obj: dict[str, Any]) -> None:
paste_mono(image, qr_img, x, y, side, side)
def draw_barcode(image: Image.Image, obj: dict[str, Any]) -> None:
def draw_barcode(image: Image.Image, obj: dict[str, Any], scale: float) -> None:
if barcode is None or ImageWriter is None:
raise RuntimeError("python-barcode ist nicht installiert.")
x, y, w, h = object_box(obj)
x, y, w, h = object_box(obj, scale)
writer = ImageWriter()
code = barcode.get("code128", str(obj.get("text", "1234567890")), writer=writer)
buffer = io.BytesIO()
@@ -174,11 +199,11 @@ def draw_barcode(image: Image.Image, obj: dict[str, Any]) -> None:
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)
def draw_table(draw: ImageDraw.ImageDraw, obj: dict[str, Any], scale: float) -> None:
x, y, w, h = object_box(obj, scale)
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))))
line = max(1, scaled_mm_to_px(float(obj.get("lineMm", 0.2)), scale))
draw.rectangle([x, y, x + w, y + h], outline=1, width=line)
for col in range(1, cols):
cx = x + round(w * col / cols)
@@ -188,6 +213,16 @@ def draw_table(draw: ImageDraw.ImageDraw, obj: dict[str, Any]) -> None:
draw.line((x, cy, x + w, cy), fill=1, width=line)
def draw_cutmark(draw: ImageDraw.ImageDraw, width_px: int, height_px: int, scale: float) -> None:
x = max(0, width_px - scaled_mm_to_px(1.5, scale))
dash = max(2, scaled_mm_to_px(1, scale))
gap = max(2, scaled_mm_to_px(0.7, scale))
y = 0
while y < height_px:
draw.line((x, y, x, min(height_px, y + dash)), fill=1, width=1)
y += dash + gap
def render_design(design: dict[str, Any]) -> Path:
tape_width = float(design.get("tapeWidthMm", 24))
if tape_width not in TAPE_WIDTHS_MM:
@@ -199,7 +234,14 @@ def render_design(design: dict[str, Any]) -> Path:
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))
scale = render_scale_for_tape(tape_width)
image = new_label_image(scaled_mm_to_px(canvas_w_mm, scale), scaled_mm_to_px(canvas_h_mm, scale))
max_height_px = printable_px_for_tape(tape_width)
if image.height > max_height_px:
raise ValueError(
f"Gerendertes Etikett ist zu hoch ({image.height}px). "
f"Maximal fuer {tape_width:g} mm Band sind {max_height_px}px."
)
draw = ImageDraw.Draw(image)
for obj in design.get("objects", []):
@@ -207,35 +249,93 @@ def render_design(design: dict[str, Any]) -> Path:
continue
obj_type = obj.get("type")
if obj_type == "text" or obj_type == "symbol":
draw_text(draw, obj)
draw_text(draw, obj, scale)
elif obj_type == "shape":
draw_shape(draw, obj)
draw_shape(draw, obj, scale)
elif obj_type == "line":
draw_shape(draw, {**obj, "shape": "line"})
draw_shape(draw, {**obj, "shape": "line"}, scale)
elif obj_type == "table":
draw_table(draw, obj)
draw_table(draw, obj, scale)
elif obj_type == "image" and obj.get("dataUrl"):
x, y, w, h = object_box(obj)
x, y, w, h = object_box(obj, scale)
paste_mono(image, decode_data_image(str(obj["dataUrl"])), x, y, w, h)
elif obj_type == "qr":
draw_qr(image, obj)
draw_qr(image, obj, scale)
elif obj_type == "barcode":
draw_barcode(image, obj)
draw_barcode(image, obj, scale)
if design.get("cutMode") in {"cutmark", "cutmark-chain"}:
draw_cutmark(draw, image.width, image.height, scale)
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 ptouch_supports_halfcut() -> bool:
global PTOUCH_SUPPORTS_HALFCUT
if PTOUCH_SUPPORTS_HALFCUT is not None:
return PTOUCH_SUPPORTS_HALFCUT
result = subprocess.run(
["ptouch-print", "--help"],
capture_output=True,
check=False,
text=True,
timeout=10,
)
help_text = f"{result.stdout}\n{result.stderr}"
PTOUCH_SUPPORTS_HALFCUT = "--no-halfcut" in help_text and "--halfcut" in help_text
return PTOUCH_SUPPORTS_HALFCUT
def print_label(image_path: Path, copies: int) -> list[subprocess.CompletedProcess[str]]:
def cut_args_for_design(design: dict[str, Any]) -> str:
args = []
if design.get("cutMode") in {"chain", "cutmark-chain"}:
args.append("--chain")
halfcut = design.get("halfcut", "on")
if halfcut == "off":
if not ptouch_supports_halfcut():
raise RuntimeError(
"Halbschnitt Aus braucht die gepatchte ptouch-print-Version. "
"Bitte start.sh und patches/ptouch-print-cut-options.patch nach Unraid kopieren, "
"ptouch-print im Container entfernen oder Container neu erstellen und danach neu starten."
)
args.append("--no-halfcut")
elif halfcut == "on" and ptouch_supports_halfcut():
args.append("--halfcut")
return " ".join(args)
def run_print_command(image_path: Path, copies: int, design: dict[str, Any]) -> subprocess.CompletedProcess[str]:
cut_args = cut_args_for_design(design)
command = PRINT_COMMAND.format(image=shlex.quote(str(image_path)), copies=copies, cut_args=cut_args)
if cut_args and "{cut_args}" not in PRINT_COMMAND:
command = f"{command} {cut_args}"
process = subprocess.Popen(
shlex.split(command),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
start_new_session=True,
)
try:
stdout, stderr = process.communicate(timeout=90)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
stdout, stderr = process.communicate()
return subprocess.CompletedProcess(
shlex.split(command),
124,
stdout,
f"{stderr}\nDruckprozess wurde nach Timeout beendet.".strip(),
)
return subprocess.CompletedProcess(shlex.split(command), process.returncode, stdout, stderr)
def print_label(image_path: Path, copies: int, design: dict[str, Any]) -> 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)]
return [run_print_command(image_path, copies, design)]
return [run_print_command(image_path, 1, design) for _ in range(copies)]
def detect_tape_width() -> tuple[float | None, str]:
@@ -278,6 +378,20 @@ def format_print_error(result: subprocess.CompletedProcess[str]) -> str:
"In Unraid pruefen: Privileged an, /dev/bus/usb Read/Write gemountet, usblp nicht geladen. "
f"Details: {details}"
)
if "timeout while waiting for status response" in details:
return (
"Drucker wurde gefunden, antwortet aber nicht auf die Statusabfrage. "
"PT-P700 kurz ausschalten oder USB abziehen, usblp auf dem Host erneut pruefen/entladen "
"und danach den Container neu starten. "
f"Details: {details}"
)
if result.returncode == 124 or "Druckprozess wurde nach Timeout beendet" in details:
return (
"Druckprozess hing und wurde beendet. "
"Bitte auf dem Host pruefen, ob kein ptouch-print mehr das USB-Geraet blockiert, "
"danach Drucker kurz neu verbinden und erneut versuchen. "
f"Details: {details}"
)
return f"Druck fehlgeschlagen: {details}"
@@ -326,10 +440,15 @@ def api_print():
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))
if not PRINT_LOCK.acquire(blocking=False):
raise RuntimeError("Es laeuft bereits ein Druckjob. Bitte warten oder haengenden ptouch-print-Prozess beenden.")
try:
results = print_label(image_path, copies, design)
failed = next((result for result in results if result.returncode != 0), None)
if failed:
raise RuntimeError(format_print_error(failed))
finally:
PRINT_LOCK.release()
message = "Etikett wurde an den Drucker gesendet."
else:
message = f"Testmodus: Etikett erstellt, nicht gedruckt: {image_path.name}"