- Introduced a global search button and modal for enhanced user experience. - Added a new section for displaying email results in the global search modal. - Implemented functionality to fetch and display emails based on user queries. - Updated the UI to include a reminders button and improved accessibility features. fix: Update docker-compose to allow reload configuration - Changed ENABLE_RELOAD environment variable to default to true for easier development. chore: Update requirements for new dependencies - Added brother_ql, pyzbar, and pypdfium2 to requirements for label printing and PDF processing. feat: Implement Brother label printing service - Created a new service for printing labels using Brother QL printers. - Supports direct printing of case hardware labels with customizable layouts. feat: Add Vaultwarden service for credential management - Implemented a service to interact with Vaultwarden for secure credential storage and retrieval. sql: Add migrations for email thread keys and document tokens - Created migrations to backfill email thread keys and manage document tokens for work orders. - Introduced new tables and updated existing structures to support token-based linking of scanned documents. sql: Import links into the database - Added a script to import a predefined set of links into the database with associated categories.
264 lines
9.4 KiB
Python
264 lines
9.4 KiB
Python
"""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
|