change composition

This commit is contained in:
Piotr Oleszczyk 2025-09-09 13:18:58 +02:00
parent 172cc678d3
commit 811e32e3da

241
app.py
View file

@ -1,7 +1,7 @@
# app.py # app.py
import asyncio import asyncio
from functools import lru_cache from functools import lru_cache
from typing import List, Optional from typing import Optional
from barcode import Code128 from barcode import Code128
from barcode.writer import ImageWriter 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.conversion import convert
from brother_ql.raster import BrotherQLRaster from brother_ql.raster import BrotherQLRaster
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from PIL import Image, ImageOps from PIL import Image, ImageDraw, ImageFont
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pylibdmtx.pylibdmtx import encode from pylibdmtx.pylibdmtx import encode
if not hasattr(Image, "ANTIALIAS"): TARGET_H = 106
Image.ANTIALIAS = Image.Resampling.LANCZOS DPI = 300
app = FastAPI(title="QL-1060N Stacked Barcode Printer") app = FastAPI(title="QL-1060N Stacked Barcode Printer")
@ -34,74 +34,58 @@ class PrintRequest(BaseModel):
extra = "ignore" extra = "ignore"
def mm_for_px(px, dpi=DPI):
return 25.4 * px / dpi
@lru_cache(maxsize=256) @lru_cache(maxsize=256)
def make_code128( def make_code128_exact(
grocycode: str, product_name: str, due_date: Optional[str] grocycode: str,
xdim_px: int = 4, # 46 px is a good, scannable range
dpi: int = DPI,
) -> Image.Image: ) -> Image.Image:
# 1) Instantiate the barcode object (checksum auto-added for Code128)
code = Code128(grocycode, writer=ImageWriter()) code = Code128(grocycode, writer=ImageWriter())
pil = code.render(
# 2) Render to a PIL.Image, passing any writer options and optional text override
pil_img = code.render(
writer_options={ writer_options={
"module_height": 15.0, # bar height "write_text": False,
"module_width": 0.5, # bar thickness "dpi": dpi,
"quiet_zone": 1.0, # margin on each side "module_height": mm_for_px(TARGET_H, dpi), # exact 106 px tall
"font_size": 10, # text size "module_width": mm_for_px(xdim_px, dpi), # integer px/narrow bar
"text_distance": 5.0, # gap between bars and text "quiet_zone": max(2.5, 10 * mm_for_px(xdim_px, dpi)), # generous quiet zone
# you can also pass 'format': 'PNG' here if needed }
}, ).convert(
text=( "L"
" | ".join([product_name, due_date]) if due_date else product_name ) # keep grayscale
), # explicitly draw your data string under the bars # 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)
# 3) Convert to 1-bit for your Brother QL workflow return pil
return pil_img.convert("1")
@lru_cache(maxsize=256) @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")) dm = encode(grocycode.encode("utf-8"))
pil_img = Image.frombytes("RGB", (dm.width, dm.height), dm.pixels) # pylibdmtx returns RGB pixel data; convert to grayscale explicitly
return pil_img.convert("1") 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_label(images: List[Image.Image]) -> Image.Image: def compose_row(parts: list[Image.Image], spacing: int = 18) -> Image.Image:
""" h = TARGET_H
Horizontally stack each barcode image with padding, w = sum(p.width for p in parts) + spacing * (len(parts) - 1)
first scaling them all to the same height. out = Image.new("L", (w, h), 255)
"""
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)
x = 0 x = 0
for img in framed: for p in parts:
# vertical center (though theyre all same height now) y = (h - p.height) // 2 # vertical align; still a 106 px output image
y = (final_h - img.height) // 2 out.paste(p, (x, y))
canvas.paste(img, (x, y)) x += p.width + spacing
x += img.width + spacing return out.convert("1") # binarize once at the very end
return canvas
def send_to_printer(image: Image.Image, printer_ip: str, model: str, label: str): 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") @app.post("/print", summary="Print Code128 + DataMatrix side-by-side")
async def print_from_grocy(req: PrintRequest): async def print_from_grocy(req: PrintRequest):
try: try:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
code_img, dm_img = await asyncio.gather( code_img, dm_img = await asyncio.gather(
loop.run_in_executor( # use default x-dimension and DPI; only pass the payload
None, make_code128, req.grocycode, req.product, req.due_date loop.run_in_executor(None, make_code128_exact, req.grocycode),
), loop.run_in_executor(None, make_datamatrix_scaled, req.grocycode),
loop.run_in_executor(None, make_datamatrix, 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) send_to_printer(label_img, req.printer_ip, req.model, req.label)
except Exception as e: except Exception:
raise HTTPException(status_code=500, detail=str(e)) # optionally: log e with traceback and return a generic message
raise HTTPException(status_code=500, detail="Printing failed.")
return { return {
"status": "printed", "status": "printed",
"model": req.model, "model": req.model,
"label_width_mm": req.label, "label": req.label,
"layout": "code128 + datamatrix side by side", "layout": "code128 | datamatrix | text",
} }