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
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, # 46 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 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:
"""
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 theyre 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",
}