diff --git a/app.py b/app.py index 8435fc8..b6f49b6 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,7 @@ # app.py import asyncio from functools import lru_cache -from typing import List, Optional +from typing import Optional from barcode import Code128 from barcode.writer import ImageWriter @@ -9,12 +9,12 @@ 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, ImageOps +from PIL import Image, ImageDraw, ImageFont from pydantic import BaseModel, Field from pylibdmtx.pylibdmtx import encode -if not hasattr(Image, "ANTIALIAS"): - Image.ANTIALIAS = Image.Resampling.LANCZOS +TARGET_H = 106 +DPI = 300 app = FastAPI(title="QL-1060N Stacked Barcode Printer") @@ -34,74 +34,58 @@ class PrintRequest(BaseModel): extra = "ignore" +def mm_for_px(px, dpi=DPI): + return 25.4 * px / dpi + + @lru_cache(maxsize=256) -def make_code128( - grocycode: str, product_name: str, due_date: Optional[str] +def make_code128_exact( + grocycode: str, + xdim_px: int = 4, # 4–6 px is a good, scannable range + dpi: int = DPI, ) -> Image.Image: - # 1) Instantiate the barcode object (checksum auto-added for Code128) code = Code128(grocycode, writer=ImageWriter()) - - # 2) Render to a PIL.Image, passing any writer options and optional text override - pil_img = code.render( + pil = code.render( writer_options={ - "module_height": 15.0, # bar height - "module_width": 0.5, # bar thickness - "quiet_zone": 1.0, # margin on each side - "font_size": 10, # text size - "text_distance": 5.0, # gap between bars and text - # you can also pass 'format': 'PNG' here if needed - }, - text=( - " | ".join([product_name, due_date]) if due_date else product_name - ), # explicitly draw your data string under the bars - ) - - # 3) Convert to 1-bit for your Brother QL workflow - return pil_img.convert("1") + "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(grocycode: str) -> Image.Image: +def make_datamatrix_scaled(grocycode: str) -> Image.Image: dm = encode(grocycode.encode("utf-8")) - pil_img = Image.frombytes("RGB", (dm.width, dm.height), dm.pixels) - return pil_img.convert("1") + # 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_label(images: List[Image.Image]) -> Image.Image: - """ - Horizontally stack each barcode image with padding, - first scaling them all to the same height. - """ - border, spacing = 10, 20 - - # 1) Determine target inner height (max of original heights) - inner_h = max(img.height for img in images) - - # 2) Scale each image to inner_h, then frame it - framed = [] - for img in images: - w, h = img.size - new_w = int(w * inner_h / h) - # high-quality up-scaling - img_scaled = img.resize((new_w, inner_h), Image.LANCZOS) - # add white border - framed_img = ImageOps.expand(img_scaled, border=border, fill="white") - framed.append(framed_img) - - # 3) Compute final canvas size - total_w = sum(img.width for img in framed) + spacing * (len(framed) - 1) - final_h = inner_h + 2 * border - - # 4) Create canvas and paste each framed image - canvas = Image.new("1", (total_w, final_h), 1) +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 img in framed: - # vertical center (though they’re all same height now) - y = (final_h - img.height) // 2 - canvas.paste(img, (x, y)) - x += img.width + spacing - - return canvas + 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): @@ -127,24 +111,139 @@ def send_to_printer(image: Image.Image, printer_ip: str, model: str, label: str) ) +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( - loop.run_in_executor( - None, make_code128, req.grocycode, req.product, req.due_date - ), - loop.run_in_executor(None, make_datamatrix, req.grocycode), + # 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), ) - label_img = compose_label([code_img, dm_img]) + # 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 as e: - raise HTTPException(status_code=500, detail=str(e)) - + 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_width_mm": req.label, - "layout": "code128 + datamatrix side by side", + "label": req.label, + "layout": "code128 | datamatrix | text", }