# 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 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 = 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", }