grocy-ql-labeler/app.py

249 lines
8.3 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 barcode import Code128
from barcode.writer import ImageWriter
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, 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")
class Config:
# ignore any other fields Grocy might send
extra = "ignore"
def mm_for_px(px, dpi=DPI):
return 25.4 * px / dpi
@lru_cache(maxsize=256)
def make_code128_exact(
grocycode: str,
xdim_px: int = 4, # 46 px is a good, scannable range
dpi: int = DPI,
) -> Image.Image:
code = Code128(grocycode, writer=ImageWriter())
pil = code.render(
writer_options={
"write_text": False,
"dpi": dpi,
"module_height": mm_for_px(TARGET_H, dpi), # exact 106 px tall
"module_width": mm_for_px(xdim_px, dpi), # integer px/narrow bar
"quiet_zone": max(2.5, 10 * mm_for_px(xdim_px, dpi)), # generous quiet zone
}
).convert(
"L"
) # keep grayscale
# Height should already be 106 px; if writer adds 1px border, crop vertically.
if pil.height != TARGET_H:
pil = pil.resize((pil.width, TARGET_H), Image.NEAREST)
return pil
@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 = 360, padding: int = 4
) -> Image.Image:
"""Render up to two lines of text (product, due_date) into a fixed-height strip.
- Keeps height = TARGET_H.
- Ellipsizes to ensure the panel width <= max_width.
- Chooses font sizes that fit vertically.
"""
# 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 both lines fit vertically
product_sizes = [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)
for ps in product_sizes:
pf = _try_load_font(ps)
ph = pf.getbbox("Ag")[3] if hasattr(pf, "getbbox") else pf.getsize("Ag")[1]
if have_due:
for ds in due_sizes:
df = _try_load_font(ds)
dh = (
df.getbbox("Ag")[3]
if hasattr(df, "getbbox")
else df.getsize("Ag")[1]
)
total_h = ph + dh + padding # small gap between lines
if total_h + 2 * padding <= TARGET_H:
chosen = (pf, df)
break
if chosen:
break
else:
if ph + 2 * padding <= TARGET_H:
chosen = (pf, None)
break
if not chosen:
# Fallback to default tiny font(s)
chosen = (_try_load_font(8), _try_load_font(8) if have_due else None)
pf, df = chosen
# Ellipsize to max_width
product_text = _ellipsize(product or "", pf, max_width - 2 * padding, draw)
due_text = (
_ellipsize(due_date or "", df or pf, max_width - 2 * padding, draw)
if have_due
else None
)
# Measure final sizes
p_w = int(draw.textlength(product_text, font=pf))
p_h = pf.getbbox("Ag")[3] if hasattr(pf, "getbbox") else pf.getsize("Ag")[1]
d_w = int(draw.textlength(due_text, font=df or pf)) if have_due else 0
d_h = (
(df.getbbox("Ag")[3] if hasattr(df, "getbbox") else df.getsize("Ag")[1])
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)
y = (TARGET_H - (p_h + (d_h + padding if have_due else 0))) // 2
odraw.text((padding, y), product_text, fill=0, font=pf)
if have_due and due_text:
odraw.text((padding, y + p_h + padding), due_text, fill=0, font=df or pf)
return out
@app.post("/print", summary="Print Code128 + DataMatrix side-by-side")
async def print_from_grocy(req: PrintRequest):
try:
loop = asyncio.get_running_loop()
code_img, dm_img = await asyncio.gather(
# use default x-dimension and DPI; only pass the payload
loop.run_in_executor(None, make_code128_exact, req.grocycode),
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([code_img, 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": "code128 | datamatrix | text",
}