bmc_hub/app/services/brother_label_print_service.py

264 lines
9.4 KiB
Python
Raw Permalink Normal View History

"""Brother QL direct print service for case hardware labels."""
from __future__ import annotations
import logging
import socket
from dataclasses import dataclass
from typing import Iterable, List, Optional
from PIL import Image, ImageDraw, ImageFont
# Compatibility shim: brother_ql may still reference Image.ANTIALIAS,
# which was removed in newer Pillow releases.
if not hasattr(Image, "ANTIALIAS") and hasattr(Image, "Resampling"):
Image.ANTIALIAS = Image.Resampling.LANCZOS
logger = logging.getLogger(__name__)
try:
from brother_ql.backends.helpers import send
from brother_ql.conversion import convert
from brother_ql.raster import BrotherQLRaster
from brother_ql.labels import ALL_LABELS
except Exception: # pragma: no cover - handled at runtime
send = None
convert = None
BrotherQLRaster = None
ALL_LABELS = None
_CODE39_PATTERNS = {
"0": "nnnwwnwnn", "1": "wnnwnnnnw", "2": "nnwwnnnnw", "3": "wnwwnnnnn",
"4": "nnnwwnnnw", "5": "wnnwwnnnn", "6": "nnwwwnnnn", "7": "nnnwnnwnw",
"8": "wnnwnnwnn", "9": "nnwwnnwnn", "A": "wnnnnwnnw", "B": "nnwnnwnnw",
"C": "wnwnnwnnn", "D": "nnnnwwnnw", "E": "wnnnwwnnn", "F": "nnwnwwnnn",
"G": "nnnnnwwnw", "H": "wnnnnwwnn", "I": "nnwnnwwnn", "J": "nnnnwwwnn",
"K": "wnnnnnnww", "L": "nnwnnnnww", "M": "wnwnnnnwn", "N": "nnnnwnnww",
"O": "wnnnwnnwn", "P": "nnwnwnnwn", "Q": "nnnnnnwww", "R": "wnnnnnwwn",
"S": "nnwnnnwwn", "T": "nnnnwnwwn", "U": "wwnnnnnnw", "V": "nwwnnnnnw",
"W": "wwwnnnnnn", "X": "nwnnwnnnw", "Y": "wwnnwnnnn", "Z": "nwwnwnnnn",
"-": "nwnnnnwnw", ".": "wwnnnnwnn", " ": "nwwnnnwnn", "$": "nwnwnwnnn",
"/": "nwnwnnnwn", "+": "nwnnnwnwn", "%": "nnnwnwnwn", "*": "nwnnwnwnn",
}
@dataclass
class LabelJob:
name: str
meta_line: str
token: str
class BrotherLabelPrintService:
def __init__(
self,
model: str,
host: str,
port: int,
label_size: str,
) -> None:
self.model = (model or "QL-710W").strip()
self.host = (host or "").strip()
self.port = int(port or 9100)
self.label_size = self._normalize_label_size((label_size or "62").strip())
self.label_spec = self._resolve_label_spec(self.label_size)
self.printable_width = self._resolve_printable_width(self.label_size)
self.printable_height = self._resolve_printable_height(self.label_size)
self.is_die_cut = bool(self.label_spec and getattr(self.label_spec, "form_factor", None) and "DIE_CUT" in str(getattr(self.label_spec, "form_factor", "")))
@property
def printer_identifier(self) -> str:
return f"tcp://{self.host}:{self.port}"
def print_jobs(self, jobs: Iterable[LabelJob]) -> int:
if not self.host:
raise ValueError("Printer host is missing")
if not send or not convert or not BrotherQLRaster:
raise RuntimeError("brother_ql library is not installed in this environment")
send_func = send
convert_func = convert
raster_cls = BrotherQLRaster
rendered_images = [self._build_label_image(job) for job in jobs]
if not rendered_images:
return 0
qlr = raster_cls(self.model)
instructions = convert_func(
qlr=qlr,
images=rendered_images,
label=self.label_size,
rotate='auto' if self.is_die_cut else 0,
cut=True,
dither=False,
compress=False,
red=False,
dpi_600=False,
)
self._send_to_printer(instructions, send_func)
return len(rendered_images)
def _send_to_printer(self, instructions: List[bytes], send_func) -> None:
target = self.printer_identifier
# brother_ql helper changed call signature across versions.
try:
send_func(instructions, target, "network", blocking=True)
return
except TypeError:
pass
try:
send_func(instructions=instructions, printer_identifier=target, backend_identifier="network", blocking=True)
return
except TypeError:
pass
# Final fallback to raw socket stream for network printers.
payload = b"".join(instructions)
with socket.create_connection((self.host, self.port), timeout=10) as conn:
conn.sendall(payload)
def _build_label_image(self, job: LabelJob) -> Image.Image:
width = self.printable_width
height = self.printable_height if self.printable_height > 0 else 220
image = Image.new("RGB", (width, height), "white")
draw = ImageDraw.Draw(image)
font_title = ImageFont.load_default()
font_meta = ImageFont.load_default()
font_token = ImageFont.load_default()
title = (job.name or "Ukendt enhed")[:52]
meta = (job.meta_line or "-")[:88]
token = (job.token or "")[:64]
left = 12
top = 8
right = max(left + 1, width - 12)
# Compact layout for die-cut labels to fit exact printable area.
if self.is_die_cut:
title_y = top
meta_y = title_y + 18
barcode_y = meta_y + 16
token_y = min(height - 14, barcode_y + max(26, int(height * 0.28)) + 4)
bar_height = max(24, min(int(height * 0.28), height - barcode_y - 22))
else:
title_y = 12
meta_y = 34
barcode_y = 64
token_y = min(height - 16, 170)
bar_height = max(48, min(92, height - barcode_y - 26))
draw.text((left, title_y), title, fill="black", font=font_title)
draw.text((left, meta_y), meta, fill="black", font=font_meta)
self._draw_code39(draw, token, x=left, y=barcode_y, max_width=max(60, right - left), bar_height=bar_height)
draw.text((left, token_y), token, fill="black", font=font_token)
return image
def _normalize_label_size(self, label_size: str) -> str:
wanted = str(label_size or "").strip()
if wanted == "29":
# Legacy compatibility: old config often used "29" while hardware stock is 62x29 die-cut.
logger.warning("⚠️ Label size '29' mapped to '62x29' for Brother QL hardware labels")
return "62x29"
return wanted or "62"
@staticmethod
def _resolve_label_spec(label_size: str):
if not ALL_LABELS:
return None
wanted = str(label_size or "").strip()
for lbl in ALL_LABELS:
if getattr(lbl, "identifier", "") == wanted:
return lbl
return None
@staticmethod
def _resolve_printable_width(label_size: str) -> int:
default_width = 696 # 62mm endless printable width
if not ALL_LABELS:
return default_width
try:
wanted = str(label_size or "").strip()
for lbl in ALL_LABELS:
if getattr(lbl, "identifier", "") == wanted:
dots = getattr(lbl, "dots_printable", None)
if isinstance(dots, tuple) and len(dots) > 0 and int(dots[0]) > 0:
return int(dots[0])
except Exception:
return default_width
return default_width
@staticmethod
def _resolve_printable_height(label_size: str) -> int:
if not ALL_LABELS:
return 220
try:
wanted = str(label_size or "").strip()
for lbl in ALL_LABELS:
if getattr(lbl, "identifier", "") == wanted:
dots = getattr(lbl, "dots_printable", None)
if isinstance(dots, tuple) and len(dots) > 1 and int(dots[1]) > 0:
return int(dots[1])
return 220
except Exception:
return 220
return 220
def _draw_code39(
self,
draw: ImageDraw.ImageDraw,
value: str,
x: int,
y: int,
max_width: int,
bar_height: int,
) -> None:
safe = "".join(ch for ch in (value or "").upper() if ch in _CODE39_PATTERNS and ch != "*")
if not safe:
safe = "EMPTY"
seq = f"*{safe}*"
# Prefer physically narrower bars first; scanners struggle when Code39
# modules become too wide on small die-cut labels.
variants = [
(1, 2, 0),
(1, 3, 1),
(2, 5, 1),
]
narrow, wide, gap = variants[0]
for candidate in variants:
c_narrow, c_wide, c_gap = candidate
width = self._code39_width(seq, c_narrow, c_wide, c_gap)
if width <= max_width:
narrow, wide, gap = c_narrow, c_wide, c_gap
break
cursor = x
for ch in seq:
pattern = _CODE39_PATTERNS[ch]
for idx, code in enumerate(pattern):
stroke = wide if code == "w" else narrow
if idx % 2 == 0:
draw.rectangle([cursor, y, cursor + stroke - 1, y + bar_height], fill="black")
cursor += stroke
if idx < len(pattern) - 1:
cursor += gap
cursor += gap
@staticmethod
def _code39_width(sequence: str, narrow: int, wide: int, gap: int) -> int:
total = 0
for ch in sequence:
pattern = _CODE39_PATTERNS[ch]
for idx, code in enumerate(pattern):
total += wide if code == "w" else narrow
if idx < len(pattern) - 1:
total += gap
total += gap
return total