grocy-ql-labeler/app.py
2025-10-02 17:16:00 +02:00

287 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# app.py
import asyncio
from functools import lru_cache
from typing import Optional
from brother_ql.backends.helpers import send
from brother_ql.conversion import convert
from brother_ql.raster import BrotherQLRaster
from fastapi import FastAPI, HTTPException
from PIL import Image, ImageDraw, ImageFont
from pydantic import BaseModel, ConfigDict, Field
from pylibdmtx.pylibdmtx import encode
TARGET_H = 106
DPI = 300
app = FastAPI(title="QL-1060N Stacked Barcode Printer")
class PrintRequest(BaseModel):
# from LABEL_PRINTER_PARAMS
printer_ip: str = Field(..., description="QL-1060N IP on LAN")
model: str = Field(..., description="Brother model")
label: str = Field(..., description="12 mm tape")
# grocy payload:
grocycode: str = Field(..., description="Raw GrocyCode")
product: str = Field(..., description="Product name")
due_date: Optional[str] = Field(None, description="Due date from stock entry")
model_config = ConfigDict(extra="ignore")
def mm_for_px(px, dpi=DPI):
return 25.4 * px / dpi
@lru_cache(maxsize=256)
def make_datamatrix_scaled(grocycode: str) -> Image.Image:
dm = encode(grocycode.encode("utf-8"))
# pylibdmtx returns RGB pixel data; convert to grayscale explicitly
img = Image.frombytes("RGB", (dm.width, dm.height), dm.pixels).convert("L")
factor = max(
1, TARGET_H // img.height
) # integer up-scale, never down with fraction
img_up = img.resize((img.width * factor, img.height * factor), Image.NEAREST)
# If it doesnt land exactly at 106 px, dont fractional-resize it.
# Just place it on a 106 px tall strip when composing.
return img_up
def compose_row(parts: list[Image.Image], spacing: int = 18) -> Image.Image:
h = TARGET_H
w = sum(p.width for p in parts) + spacing * (len(parts) - 1)
out = Image.new("L", (w, h), 255)
x = 0
for p in parts:
y = (h - p.height) // 2 # vertical align; still a 106 px output image
out.paste(p, (x, y))
x += p.width + spacing
return out.convert("1") # binarize once at the very end
def send_to_printer(image: Image.Image, printer_ip: str, model: str, label: str):
qlr = BrotherQLRaster(model)
qlr.exception_on_warning = True
instructions = convert(
qlr=qlr,
images=[image],
label=label,
rotate="90", # landscape
threshold=70.0,
dither=False,
compress=True,
cut=True,
)
send(
instructions=instructions,
printer_identifier=printer_ip,
backend_identifier="network",
blocking=True,
)
def _try_load_font(size: int) -> ImageFont.ImageFont:
"""Try to load a decent TTF font; fall back to PIL default."""
candidates = [
"DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
"/System/Library/Fonts/Supplemental/Arial.ttf",
]
for path in candidates:
try:
return ImageFont.truetype(path, size)
except Exception:
continue
return ImageFont.load_default()
def _ellipsize(
text: str, font: ImageFont.ImageFont, max_width: int, draw: ImageDraw.ImageDraw
) -> str:
if not text:
return ""
if draw.textlength(text, font=font) <= max_width:
return text
low, high = 0, len(text)
best = ""
while low <= high:
mid = (low + high) // 2
candidate = (text[:mid] + "") if mid < len(text) else text
if draw.textlength(candidate, font=font) <= max_width:
best = candidate
low = mid + 1
else:
high = mid - 1
return best or ""
def make_text_panel(
product: str, due_date: Optional[str], max_width: int = 480, padding: int = 4
) -> Image.Image:
"""Render product (wrapped to 2 lines) + due_date in a fixed-height strip.
- Keeps height = TARGET_H.
- Wraps product to up to 2 lines; ellipsizes overflow.
- Ellipsizes due_date; keeps panel width <= max_width.
"""
# Create a temporary image for measurement
meas_img = Image.new("L", (max_width, TARGET_H), 255)
draw = ImageDraw.Draw(meas_img)
# Try font size pairs, largest first, until content fits vertically
product_sizes = [28, 26, 24, 22, 20, 18, 16, 14, 12, 11, 10, 9, 8]
due_sizes = [14, 13, 12, 11, 10, 9, 8]
chosen = None
have_due = bool(due_date)
line_gap = 3
def text_h(f: ImageFont.ImageFont) -> int:
return f.getbbox("Ag")[3] if hasattr(f, "getbbox") else f.getsize("Ag")[1]
def wrap_ellipsize(
text: str, font: ImageFont.ImageFont, max_w: int, max_lines: int
) -> list[str]:
if not text:
return []
words = text.split()
lines: list[str] = []
i = 0
while len(lines) < max_lines:
line = ""
while i < len(words):
cand = (line + " " + words[i]).strip()
if draw.textlength(cand, font=font) <= max_w:
line = cand
i += 1
else:
if not line:
# Hard-break a very long word
low, high = 0, len(words[i])
best = ""
while low <= high:
mid = (low + high) // 2
seg = words[i][:mid]
if draw.textlength(seg, font=font) <= max_w:
best = seg
low = mid + 1
else:
high = mid - 1
line = best or words[i]
if best:
words[i] = words[i][len(best) :]
else:
i += 1
break
# If last allowed line and text remains, ellipsize tail into this line
if len(lines) == max_lines - 1 and i < len(words):
tail = (line + " " + " ".join(words[i:])).strip()
line = _ellipsize(tail, font, max_w, draw)
i = len(words)
if line:
lines.append(line)
else:
break
if i >= len(words):
break
return lines
for ps in product_sizes:
pf = _try_load_font(ps)
ph = text_h(pf)
prod_lines = wrap_ellipsize(
product or "", pf, max_width - 2 * padding, max_lines=2
)
lines_count = max(1, len(prod_lines))
prod_block_h = ph * lines_count + line_gap * (lines_count - 1)
if have_due:
for ds in due_sizes:
df = _try_load_font(ds)
dh = text_h(df)
total_h = prod_block_h + line_gap + dh
if total_h + 2 * padding <= TARGET_H:
chosen = (pf, df, prod_lines)
break
if chosen:
break
else:
if prod_block_h + 2 * padding <= TARGET_H:
chosen = (pf, None, prod_lines)
break
if not chosen:
# Fallback to default tiny font(s)
pf = _try_load_font(8)
df = _try_load_font(8) if have_due else None
prod_lines = wrap_ellipsize(
product or "", pf, max_width - 2 * padding, max_lines=2
)
chosen = (pf, df, prod_lines)
pf, df, prod_lines = chosen
# Prepare due text (single line, ellipsized)
due_text = (
_ellipsize(due_date or "", df or pf, max_width - 2 * padding, draw)
if have_due
else None
)
# Measure and compute panel width
ph = pf.getbbox("Ag")[3] if hasattr(pf, "getbbox") else pf.getsize("Ag")[1]
dh = (
(df.getbbox("Ag")[3] if hasattr(df, "getbbox") else df.getsize("Ag")[1])
if have_due
else 0
)
p_w = max(
(int(draw.textlength(line, font=pf)) for line in (prod_lines or [""])),
default=0,
)
d_w = int(draw.textlength(due_text, font=df or pf)) if have_due else 0
panel_w = min(max(p_w, d_w) + 2 * padding, max_width)
# Render the panel
out = Image.new("L", (panel_w, TARGET_H), 255)
odraw = ImageDraw.Draw(out)
content_h = ph * max(1, len(prod_lines)) + line_gap * (max(1, len(prod_lines)) - 1)
if have_due and due_text:
content_h += line_gap + dh
y = (TARGET_H - content_h) // 2
# Draw product lines
for idx, line in enumerate(prod_lines or []):
odraw.text((padding, y + idx * (ph + line_gap)), line, fill=0, font=pf)
# Draw due date under product
if have_due and due_text:
prod_block_h = ph * max(1, len(prod_lines)) + line_gap * (
max(1, len(prod_lines)) - 1
)
odraw.text(
(padding, y + prod_block_h + line_gap), due_text, fill=0, font=df or pf
)
return out
@app.post("/print", summary="Print DataMatrix + text side-by-side")
async def print_from_grocy(req: PrintRequest):
try:
loop = asyncio.get_running_loop()
dm_img = await loop.run_in_executor(None, make_datamatrix_scaled, req.grocycode)
# Render a compact text panel; width-capped to keep label length reasonable
text_panel = make_text_panel(req.product, req.due_date, max_width=360)
# compose expects a list of images
label_img = compose_row([dm_img, text_panel], spacing=12)
send_to_printer(label_img, req.printer_ip, req.model, req.label)
except Exception:
# optionally: log e with traceback and return a generic message
raise HTTPException(status_code=500, detail="Printing failed.")
return {
"status": "printed",
"model": req.model,
"label": req.label,
"layout": "datamatrix | text",
}