grocy-ql-labeler/app.py

319 lines
11 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 = 2, # narrower bars reduce overall width; ~0.25mm at 300dpi
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 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 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",
}