certflow.services.printer.printer_service 源代码

# src/certflow/services/printer/printer_service.py
"""打印机业务服务 - 专为 LQ-635KII 设计"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

try:
    import win32print

    _HAS_WIN32 = True
except ImportError:
    _HAS_WIN32 = False
    win32print = None  # type: ignore[assignment]

from .lq635kii_printer import LQ635KIIPrinter, PaperSize, PrintQuality
from .template_manager import get_template_manager

if TYPE_CHECKING:
    from certflow.config.print_config import RuntimePrintConfig


[文档] class PrinterService: """LQ-635KII 打印机服务 提供合格证打印等高级功能 """ # 合格证字段定义 (Y坐标基于 1/6 英寸行距) CERT_FIELDS = [ ("product_name", "产品名称:", 1), ("product_model", "产品型号:", 2), ("dn", "公称通径:", 3), ("pn", "公称压力:", 4), ("temperature", "工作温度:", 5), ("medium", "适用介质:", 6), ("check_standard", "检验标准:", 7), ("inspector_id", "检验工号:", 8), ("manufacture_date", "出厂日期:", 9), ("serial_number", "产品编号:", 10), ] def __init__(self, printer_name: str | None = None) -> None: """初始化打印机服务 Args: printer_name: 打印机名称,为 None 时使用系统默认打印机 """ self.printer_name = printer_name if printer_name is None: self.printer_name = win32print.GetDefaultPrinter() if _HAS_WIN32 else "" self._printer_cal_x_offset = self._load_x_offset() self._printer_cal_y_offset = self._load_y_offset() # 复用全局单例,避免每次打印作业都重新加载并打日志 self._template_manager = get_template_manager() self._last_os_job_id: int | None = None # 最近一次打印提交的 OS 作业 ID def _load_x_offset(self) -> float: """从 config.yaml 加载 X 偏移量 Returns: float: X 方向偏移量 (mm),默认 14.0 """ try: from certflow.config.settings import cfg return cfg("print.layout.x_offset", 14.0) except Exception: return 14.0 def _load_y_offset(self) -> float: """从 config.yaml 加载 Y 校准偏移量(原 LQ635KIIPrinter 硬编码 -5,已配置化) 物理装纸/打印头起点偏差对**所有**文字统一叠加,正值向下、负值向上。 实际偏移需按 EPSON LQ-635KII 实机微调,故配置化暴露。 Returns: float: Y 方向偏移量 (mm),默认 0.0(与预览 RuntimePrintConfig 回退一致) """ try: from certflow.config.settings import cfg return cfg("print.layout.y_offset", 0.0) except Exception: return 0.0
[文档] def get_printer(self) -> LQ635KIIPrinter: """获取打印机实例 Returns: LQ635KIIPrinter: 打印机控制实例 """ return LQ635KIIPrinter( self.printer_name, calibration_x_offset_mm=self._printer_cal_x_offset, y_offset_mm=self._printer_cal_y_offset, )
@property def last_os_job_id(self) -> int | None: """最近一次打印提交的 OS 作业 ID(由 ``LQ635KIIPrinter._flush`` 捕获)。 用于打印队列的 OS 级取消 / 状态查询(BLUEPRINT §2.3 偏差 5 作业 ID 捕获链路)。每次新打印前由 ``_flush`` 覆盖,无 win32print(headless) 时为 ``None``。 """ return self._last_os_job_id
[文档] def print_certificate(self, data: dict[str, Any], copies: int = 1) -> bool: """简单文本模式打印(备用方案) 精确定位请使用 print_by_template_config(读 coordinates.yaml,单一坐标源) 使用文本模式打印,LQ-635KII 自动处理中文 Args: data: 打印数据 copies: 打印份数 Returns: 是否成功 """ try: with self.get_printer() as printer: for _ in range(copies): printer.init() printer.set_paper_size(PaperSize.certificate()) printer.set_quality(PrintQuality.LETTER) printer.set_line_spacing_1_6() # 1/6 英寸行距 # 打印内容 (使用制表位对齐) for field_key, label, _line_num in self.CERT_FIELDS: value = data.get(field_key, "") if field_key == "serial_number": value = data.get("cert_number", value) if value: # 使用制表位实现标签和值的对齐 printer.println(f"{label}{value}") else: # 空值只打印标签 printer.println(label) printer.form_feed() # 换页 printer.flush() self._last_os_job_id = getattr(printer, "_last_job_id", None) return True except Exception as e: print(f"打印失败: {e}") return False
[文档] def print_by_template( self, template_key: str, data: dict[str, Any], copies: int = 1, x_offsets: dict[str, float] | None = None, ) -> bool: """根据模板打印合格证 使用 coordinates.yaml 中定义的模板坐标进行精确定位打印。 Args: template_key: 模板键名(如 "russian", "full_chinese_1") data: 打印数据字典 copies: 打印份数 x_offsets: 英制字段 X 偏移(mm),键为字段名(如 ``pn``/``dn``), 非零时将该字段坐标右移,使数值落在预印单位标签(MPa/Lb)下。 Returns: 是否成功 """ template = self._template_manager.get_template(template_key) if not template: print(f"模板不存在: {template_key}") return False try: with self.get_printer() as printer: printer.init() printer.set_paper_size(PaperSize.certificate()) for _ in range(copies): for field_name, pos in template["fields"].items(): value = data.get(field_name, "") if field_name == "serial_number": value = data.get("cert_number", value) if not value: continue off = x_offsets.get(field_name, 0.0) if x_offsets else 0.0 printer.set_absolute_x_mm(pos["x_mm"] + off) printer.set_absolute_y_mm(pos["y_mm"]) printer.print_text(str(value)) printer.form_feed() printer.flush() self._last_os_job_id = getattr(printer, "_last_job_id", None) return True except Exception as e: print(f"模板打印失败 [{template_key}]: {e}") return False
[文档] def print_by_template_config( self, config: RuntimePrintConfig, data: dict[str, Any], copies: int = 1, x_offsets: dict[str, float] | None = None, ) -> bool: """根据 RuntimePrintConfig 打印合格证 使用 RuntimePrintConfig.template.fields 中定义的坐标进行精确定位打印。 相比 print_by_template(),不再依赖 TemplateManager,直接使用 RuntimePrintConfig。 Args: config: 聚合打印配置(包含 engine/template/layout 等) data: 打印数据字典 copies: 打印份数 x_offsets: 英制字段 X 偏移(mm),键为字段名(如 ``pn``/``dn``), 非零时将该字段坐标右移,使数值落在预印单位标签(MPa/Lb)下。 Returns: 是否成功 """ if not config.template or not config.template.fields: print("PrintConfig 中无模板字段,无法执行精确定位打印") return False try: with self.get_printer() as printer: printer.init() printer.set_paper_size(PaperSize.certificate()) printer.set_quality(PrintQuality.LETTER) for _ in range(copies): for field_def in config.template.fields: value = data.get(field_def.key, "") if field_def.key == "serial_number": value = data.get("cert_number", value) if not value: continue off = x_offsets.get(field_def.key, 0.0) if x_offsets else 0.0 printer.set_absolute_x_mm(field_def.x_mm + off) printer.set_absolute_y_mm(field_def.y_mm) printer.print_text(str(value)) printer.form_feed() printer.flush() self._last_os_job_id = getattr(printer, "_last_job_id", None) return True except Exception as e: print(f"模板配置打印失败 [{config.template.key}]: {e}") return False
[文档] def render_certificate_bytes( self, config: RuntimePrintConfig, data: dict[str, Any], copies: int = 1, x_offsets: dict[str, float] | None = None, ) -> bytes: """渲染合格证为 ESC/P-K 原始字节流(**不发送到打印机**)。 与 :meth:`print_by_template_config` 共用同一套命令构建逻辑,但刻意 不调用 ``open()`` / ``flush()``,因此无需真实打印机、不会真正出纸, 可直接拿到「即将发送给打印机的完整字节」,供调试落盘 / 十六进制分析。 Args: config: RuntimePrintConfig 聚合配置(含 ``template.fields`` 坐标)。 data: 打印数据字典(含 product_name/dn/pn/... 及 ``cert_number``)。 copies: 份数,默认 1。 x_offsets: 英制字段 X 偏移(mm),键为字段名。 Returns: bytes: 完整 ESC/P-K 命令字节流;配置无模板字段时返回空字节。 """ if not config.template or not config.template.fields: return b"" # 仅构造,不打开真实打印机(open() 会调用 win32print.OpenPrinter, # 在无打印机/CI 环境下会失败;渲染过程本身只向 _buffer 追加字节)。 printer = self.get_printer() printer.init() printer.set_paper_size(PaperSize.certificate()) printer.set_quality(PrintQuality.LETTER) for _ in range(max(1, copies)): for field_def in config.template.fields: value = data.get(field_def.key, "") if field_def.key == "serial_number": value = data.get("cert_number", value) if not value: continue off = x_offsets.get(field_def.key, 0.0) if x_offsets else 0.0 printer.set_absolute_x_mm(field_def.x_mm + off) printer.set_absolute_y_mm(field_def.y_mm) printer.print_text(str(value)) printer.form_feed() return bytes(printer._buffer)
[文档] def get_available_printers(self) -> list[str]: """获取可用打印机列表 Returns: list[str]: 本地和网络连接的所有打印机名称列表 """ try: printers = win32print.EnumPrinters( win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS ) return [p[2] for p in printers] except Exception: return []
[文档] def get_default_printer(self) -> str: """获取系统默认打印机 Returns: str: 默认打印机名称,失败时返回空字符串 """ try: return win32print.GetDefaultPrinter() except Exception: return ""
[文档] def is_printer_ready(self) -> bool: """检查打印机是否就绪 通过查询打印机状态位判断是否处于离线、缺纸、错误或暂停状态。 Returns: bool: True 表示打印机就绪可用 """ try: handle = win32print.OpenPrinter(self.printer_name) info = win32print.GetPrinter(handle, 2) win32print.ClosePrinter(handle) status = info.get("Status", 0) offline = win32print.PRINTER_STATUS_OFFLINE paper_out = win32print.PRINTER_STATUS_PAPER_OUT error = win32print.PRINTER_STATUS_ERROR paused = win32print.PRINTER_STATUS_PAUSED return not (status & (offline | paper_out | error | paused)) except Exception: return False