change composition
This commit is contained in:
parent
172cc678d3
commit
811e32e3da
1 changed files with 170 additions and 71 deletions
241
app.py
241
app.py
|
|
@ -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, # 4–6 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 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:
|
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 they’re 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",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue