287 lines
9.7 KiB
Python
287 lines
9.7 KiB
Python
# 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 doesn’t land exactly at 106 px, don’t 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",
|
||
}
|