"""标签打印服务 — 工业级精确定位标签 (480×360) + TSPL 发送""" import base64 import socket from io import BytesIO from pathlib import Path from typing import Optional import qrcode from PIL import Image, ImageDraw, ImageFont from app.services.print_config import PrintConfigManager # ============================================================ # 字体 — 项目内 simhei.ttf # ============================================================ _FONT_PATH = str(Path(__file__).resolve().parent.parent.parent / "simhei.ttf") FONT_NORMAL = ImageFont.truetype(_FONT_PATH, 28) # 右侧:名/规/单 FONT_LARGE = ImageFont.truetype(_FONT_PATH, 34) # 底部:序列号 # ============================================================ # 画布 & 坐标常量 # ============================================================ WIDTH, HEIGHT = 480, 360 QR_X, QR_Y = 24, 60 QR_SIZE = 180 TEXT_X = 228 TEXT_MAX_W = WIDTH - TEXT_X - 12 # ~240px BOTTOM_Y = 260 # 序列号 y LINE_H = 44 # 28px 行高 # ============================================================ # QR 码 # ============================================================ def _generate_qr(content: str) -> Image.Image: qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=10, border=2, ) qr.add_data(content) qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") return img.resize((QR_SIZE, QR_SIZE), Image.NEAREST) # ============================================================ # 文字绘制 — 带描边 + 自动换行 # ============================================================ def _draw( draw: ImageDraw.Draw, text: str, x: int, y: int, font: ImageFont.FreeTypeFont, max_width: int, stroke_width: int = 1, ) -> int: """ 绘制文字,超出 max_width 自动折行。 返回下一行可用的 y 坐标。 """ if not text: return y + LINE_H # 单行不超宽 → 直接画(带描边) bbox = draw.textbbox((0, 0), text, font=font) if bbox[2] - bbox[0] <= max_width: draw.text((x, y), text, font=font, fill="black", stroke_width=stroke_width, stroke_fill="black") return y + LINE_H # 逐字符折行 lines: list[str] = [] current = "" for char in text: test = current + char w = draw.textbbox((0, 0), test, font=font)[2] if w <= max_width: current = test else: lines.append(current) current = char if current: lines.append(current) cy = y for line in lines: draw.text((x, cy), line, font=font, fill="black", stroke_width=stroke_width, stroke_fill="black") cy += LINE_H return cy # ============================================================ # 标签绑制 — 工业级精确坐标 # ============================================================ def _create_label(data: dict) -> Image.Image: """ 480×360 工业级排版: ┌───────────────┬──────────────────────────────┐ │ │ 名: 样品升降台V1J y=60 │ ← 28px sw=1 │ [QR Code] │ 规: PH-B4V1J/类A y=104 │ │ 180×180 │ 单: ORD-2024-001 y=148 │ │ (24, 60) │ │ │ │ │ │ 码: 0000000000000001 y=260 │ ← 34px sw=2 └───────────────┴──────────────────────────────┘ """ img = Image.new("RGB", (WIDTH, HEIGHT), color="white") draw = ImageDraw.Draw(img) serial = data.get("serial_number", "") # ── QR 码 (24, 60) ── if serial: qr_img = _generate_qr(serial) img.paste(qr_img, (QR_X, QR_Y)) # ── 右侧文字 x=228 — 28px, stroke_width=1 ── y = 60 name = data.get("material_name", "") or "未命名" y = _draw(draw, f"名: {name}", TEXT_X, y, FONT_NORMAL, TEXT_MAX_W, stroke_width=1) spec = data.get("spec_model", "") or "-" y = _draw(draw, f"规: {spec}", TEXT_X, y, FONT_NORMAL, TEXT_MAX_W, stroke_width=1) order_no = data.get("order_no", "") if order_no and str(order_no).strip(): y = _draw(draw, f"单: {str(order_no).strip()}", TEXT_X, y, FONT_NORMAL, TEXT_MAX_W, stroke_width=1) # ── 底部通栏 (24, 260) — 34px, stroke_width=2 ── code = serial or "-" draw.text((24, BOTTOM_Y), f"码: {code}", font=FONT_LARGE, fill="black", stroke_width=2, stroke_fill="black") return img # ============================================================ # 公开 API # ============================================================ def generate_preview_image(**data) -> str: """返回 Base64 JPEG data URL""" img = _create_label(data) buf = BytesIO() img.save(buf, format="JPEG", quality=92) b64 = base64.b64encode(buf.getvalue()).decode() return f"data:image/jpeg;base64,{b64}" def send_to_printer( copies: int = 1, printer_ip: Optional[str] = None, printer_port: Optional[int] = None, **data, ) -> dict: """二值化 → TSPL → Socket 发送""" printer = PrintConfigManager.get_printer("label_printer") ip = printer_ip or printer.get("ip", "192.168.9.221") port = printer_port or printer.get("port", 9100) img_rgb = _create_label(data) img_gray = img_rgb.convert("L") img_bw = img_gray.point(lambda px: 0 if px < 128 else 255, "1") width_bytes = (img_bw.width + 7) // 8 height_dots = img_bw.height tspl = ( "SIZE 40 mm, 30 mm\r\n" "GAP 2 mm, 0 mm\r\n" "CLS\r\n" "DIRECTION 1\r\n" ).encode("gbk", errors="replace") bitmap_cmd = f"BITMAP 0,0,{width_bytes},{height_dots},0,".encode("gbk", errors="replace") bitmap_data = img_bw.tobytes() footer = f"\r\nPRINT 1,{copies}\r\n".encode("gbk", errors="replace") payload = tspl + bitmap_cmd + bitmap_data + footer s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(5) try: s.connect((ip, port)) s.sendall(payload) s.close() return { "success": True, "message": f"打印指令已发送 → {ip}:{port},份数: {copies}", "printer": f"{ip}:{port}", } except Exception as e: return { "success": False, "message": f"打印机连接失败 ({ip}:{port}): {str(e)}", "printer": f"{ip}:{port}", }