"""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