certflow.services.printer_manager 源代码

"""Windows 打印机管理服务

封装 win32print + win32ui 底层调用,提供打印机枚举、状态查询、
GDI 精确打印、PDF/图片/HTML 打印等功能。

64-bit Python 环境兼容,不依赖 Access/32位 ODBC。
"""

from __future__ import annotations

import contextlib
import json
import os
import subprocess
import tempfile
from dataclasses import dataclass, field
from enum import Enum
from typing import Any

try:
    import win32print
    import win32ui

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

try:
    from PySide6.QtCore import QObject, QTimer, Signal
except ImportError:  # 分层:services 层在无 Qt(headless / 无 X11)环境仍需可导入
    QObject = None  # type: ignore[assignment]
    QTimer = None  # type: ignore[assignment]
    Signal = None  # type: ignore[assignment]

from certflow.config.paths_override import cfg
from certflow.utils.logger import logger


class _NullSignal:
    """无 Qt 环境下的 Signal 占位实现。

    提供与 PySide6 ``Signal`` 兼容的最小接口:类体内 ``x = Signal(...)``
    创建实例后,``.emit()`` 静默空转、``.connect()`` 返回自身,
    使 ``PrinterManager`` 在非 GUI 环境下仍可导入与实例化。
    """

    def __init__(self, *types: object) -> None:
        self._types = types

    def emit(self, *args: object) -> None:
        return None

    def connect(self, *args: object, **kwargs: object) -> _NullSignal:
        return self

    def disconnect(self, *args: object, **kwargs: object) -> None:
        return None


def _make_signal(*types: object):
    """根据运行环境创建信号对象。

    Args:
        *types: 信号载荷类型(仅 Qt 环境下生效,用于类型校验)。

    Returns:
        真实 Qt Signal 或降级用的 _NullSignal 实例。
    """
    if Signal is not None:
        return Signal(*types)
    return _NullSignal(*types)


[文档] class PrinterStatus(Enum): """打印机状态枚举""" READY = "就绪" PAUSED = "暂停" ERROR = "错误" PENDING = "待机" PAPER_OUT = "缺纸" OFFLINE = "离线" UNKNOWN = "未知"
[文档] class PaperSize(Enum): """纸张大小枚举""" CERTIFICATE_60x100 = (60, 100, "合格证标准 (60x100mm)") CERTIFICATE_130x75 = (130, 75, "合格证 (130x75mm)") A4 = (210, 297, "A4 (210x297mm)") CUSTOM = (0, 0, "自定义") def __init__(self, width: float, height: float, display_name: str) -> None: self.width = width self.height = height self.display_name = display_name
[文档] @classmethod def from_name(cls, name: str) -> PaperSize: """根据名称查找纸张规格。 Args: name: 显示名称或枚举名(如 "A4"、"合格证标准 (60x100mm)")。 Returns: PaperSize: 匹配到的纸张规格,未匹配时返回 PaperSize.CUSTOM。 """ for size in cls: if size.display_name == name or size.name == name: return size return cls.CUSTOM
[文档] @dataclass class PrinterInfo: """打印机信息""" name: str port: str = "" driver: str = "" status: PrinterStatus = PrinterStatus.UNKNOWN is_default: bool = False paper_sizes: list[str] = field(default_factory=list)
[文档] def to_dict(self) -> dict: """序列化为字典。 Returns: dict: 包含 name/port/driver/status/is_default/paper_sizes 的字典。 """ return { "name": self.name, "port": self.port, "driver": self.driver, "status": self.status.value, "is_default": self.is_default, "paper_sizes": self.paper_sizes, }
[文档] @dataclass class PrintJob: """打印任务信息""" job_id: int document_name: str printer_name: str pages: int = 0 copies: int = 1 status: str = "" submitted_time: str = ""
# ============================================================ # GDI 打印坐标常量(60mm × 100mm 预印卡纸) # ============================================================ MM2PT = 2.83465 # 1mm = 2.83465 points PAPER_WIDTH_MM = 60 PAPER_HEIGHT_MM = 100 PAPER_SIZE_PT = (PAPER_WIDTH_MM * MM2PT, PAPER_HEIGHT_MM * MM2PT) # 文字打印位置(从卡纸左上角为原点) LABEL_X = 3 # 标签列 X 起点 (mm) VALUE_X = 20 # 值列 X 起点 (mm) START_Y = 22 # 第一行 Y 起点 (mm) LINE_SPACING = 6.5 # 行间距 (mm) FONT_SIZE_MM = 3.0 # 字体高度 (mm) # 打印字段定义: (标签, 数据key, Y偏移序号) PRINT_FIELDS: list[tuple[str, str, int]] = [ ("产品名称:", "product_name", 0), ("产品型号:", "product_model", 1), ("公称通径:", "dn", 2), ("公称压力:", "pn", 3), ("工作温度:", "temperature", 4), ("适用介质:", "medium", 5), ("检验标准:", "check_standard", 6), ("检验工号:", "inspector_id", 7), ("出厂日期:", "manufacture_date", 8), ("产品编号:", "serial_number", 9), ]
[文档] class PrinterManager(QObject if QObject is not None else object): """打印机管理器 封装 Windows 打印子系统,提供打印机枚举、状态监控、 GDI 精确文字打印、文件打印等功能。 分层说明:本模块归属 services 层,不应被 UI 库强制绑定导入。 在无 PySide6(headless / 无 X11)环境下,信号与监控定时器自动降级为空操作, 模块仍可导入与实例化,仅丢失 GUI 事件通知能力。 Signals(仅 Qt 环境生效): printer_added: 新增打印机 (name) printer_removed: 移除打印机 (name) printer_status_changed: 状态变化 (name, status) print_started: 打印开始 (doc_name, job_id) print_completed: 打印完成 (doc_name, job_id) print_failed: 打印失败 (doc_name, error_msg) progress_updated: 进度更新 (current, total) """ printer_added = _make_signal(str) printer_removed = _make_signal(str) printer_status_changed = _make_signal(str, PrinterStatus) print_started = _make_signal(str, int) print_completed = _make_signal(str, int) print_failed = _make_signal(str, str) progress_updated = _make_signal(int, int) def __init__(self) -> None: if QObject is not None: super().__init__() self.printers: dict[str, PrinterInfo] = {} self.current_printer: str | None = None self.pending_jobs: list[PrintJob] = [] self._init_printers() self._start_monitor() # ================================================================ # 初始化 & 枚举 # ================================================================ def _init_printers(self) -> None: """初始化打印机列表""" try: printers = win32print.EnumPrinters( win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS, None, 1, ) default_printer = win32print.GetDefaultPrinter() for printer in printers: info = self._get_printer_info(printer[2], default_printer) if info: self.printers[info.name] = info if info.name == default_printer: self.current_printer = info.name logger.debug( f"打印机初始化完成: {len(self.printers)} 台, 默认={self.current_printer}", ) except Exception as e: logger.warning(f"打印机初始化失败: {e}") def _get_printer_info(self, printer_name: str, default_printer: str) -> PrinterInfo | None: """获取单台打印机详细信息""" try: handle = win32print.OpenPrinter(printer_name) info = win32print.GetPrinter(handle, 2) win32print.ClosePrinter(handle) status = self._get_printer_status(info) paper_sizes = self._get_paper_sizes(printer_name) return PrinterInfo( name=printer_name, port=info.get("pPortName", ""), driver=info.get("pDriverName", ""), status=status, is_default=(printer_name == default_printer), paper_sizes=paper_sizes, ) except Exception as e: logger.debug(f"获取打印机信息失败 [{printer_name}]: {e}") return None @staticmethod def _get_printer_status(printer_info: dict) -> PrinterStatus: """解析打印机状态""" status = printer_info.get("Status", 0) if status & win32print.PRINTER_STATUS_OFFLINE: return PrinterStatus.OFFLINE if status & win32print.PRINTER_STATUS_PAPER_OUT: return PrinterStatus.PAPER_OUT if status & win32print.PRINTER_STATUS_PAUSED: return PrinterStatus.PAUSED if status & win32print.PRINTER_STATUS_ERROR: return PrinterStatus.ERROR if status & win32print.PRINTER_STATUS_PENDING_DELETION: return PrinterStatus.PENDING return PrinterStatus.READY @staticmethod def _get_paper_sizes(printer_name: str) -> list[str]: """获取打印机支持的纸张大小""" paper_sizes = ["A4", "A5", "Letter"] paper_sizes.extend(["60x100mm", "130x75mm"]) return paper_sizes # ================================================================ # 监控 # ================================================================ def _start_monitor(self) -> None: """启动打印机状态监控(每10秒刷新)。 分层降级:无 PySide6 / QTimer 的 headless 环境下跳过监控, 避免 UI 库硬依赖阻断 services 层导入。 """ if QTimer is None: return self.monitor_timer = QTimer() self.monitor_timer.timeout.connect(self._refresh_status) self.monitor_timer.start(10000) def _refresh_status(self) -> None: """刷新所有打印机状态""" try: default_printer = win32print.GetDefaultPrinter() except Exception: return for printer_name in list(self.printers.keys()): new_info = self._get_printer_info(printer_name, default_printer) if new_info: old_status = self.printers[printer_name].status if old_status != new_info.status: self.printers[printer_name] = new_info self.printer_status_changed.emit(printer_name, new_info.status) # ================================================================ # 公共 API # ================================================================
[文档] def get_printers(self) -> list[PrinterInfo]: """获取所有打印机。 Returns: list[PrinterInfo]: 当前已枚举的打印机信息列表。 """ return list(self.printers.values())
[文档] def get_printer_names(self) -> list[str]: """获取打印机名称列表。 Returns: list[str]: 打印机名称列表。 """ return list(self.printers.keys())
[文档] def get_default_printer(self) -> str | None: """获取默认打印机名称。 Returns: str | None: 默认打印机名称,获取失败时回退到当前打印机。 """ try: return win32print.GetDefaultPrinter() except Exception: return self.current_printer
[文档] def set_default_printer(self, printer_name: str) -> bool: """设置默认打印机。 Args: printer_name: 目标打印机名称。 Returns: bool: 设置是否成功。 """ try: win32print.SetDefaultPrinter(printer_name) self.current_printer = printer_name for name, info in self.printers.items(): info.is_default = name == printer_name return True except Exception as e: logger.warning(f"设置默认打印机失败: {e}") return False
[文档] def get_printer_status(self, printer_name: str | None = None) -> PrinterStatus: """获取打印机状态。 Args: printer_name: 打印机名称,为 None 时使用默认打印机。 Returns: PrinterStatus: 对应打印机状态,未知时返回 PrinterStatus.UNKNOWN。 """ name = printer_name or self.get_default_printer() if name and name in self.printers: return self.printers[name].status return PrinterStatus.UNKNOWN
[文档] def is_printer_ready(self, printer_name: str | None = None) -> bool: """检查打印机是否就绪。 Args: printer_name: 打印机名称,为 None 时使用默认打印机。 Returns: bool: True 表示打印机处于就绪状态。 """ return self.get_printer_status(printer_name) == PrinterStatus.READY
# ================================================================ # GDI 精确文字打印(核心方法) # ================================================================
[文档] def print_text_only( self, data: dict[str, Any], serial_number: str, printer_name: str | None = None, ) -> bool: """使用 win32 GDI 精确打印文字到预印卡纸 在 60mm × 100mm 预印卡纸的指定坐标位置输出文字。 卡纸已预印底板(边框、标题、品保章等),打印机只输出动态文字。 跳过空值字段,避免覆盖卡纸原有内容。 Args: data: 打印数据字典,包含 product_name, product_model, dn, pn, temperature, medium, check_standard, inspector_id, manufacture_date serial_number: 产品编号(逐台打印时不同) printer_name: 打印机名称,默认使用系统默认打印机 Returns: bool: 打印是否成功 """ hprinter = None try: printer = printer_name or self.get_default_printer() if not printer: logger.error("没有可用打印机") return False logger.debug(f"GDI 打印: 编号={serial_number}, 打印机={printer}") # 打开打印机 hprinter = win32print.OpenPrinter(printer) devmode = win32print.GetPrinter(hprinter, 2)["pDevMode"] # 设置自定义纸张 60mm × 100mm devmode.PaperSize = 256 # DMPAPER_USER devmode.PaperWidth = int(PAPER_WIDTH_MM * 10) # 十分之一毫米 devmode.PaperLength = int(PAPER_HEIGHT_MM * 10) hdc = win32ui.CreateDC() hdc.CreatePrinterDC(printer) # 开始打印任务 doc_name = f"合格证_{serial_number}" hdc.StartDoc(doc_name) hdc.StartPage() # 获取设备分辨率 dpi_x = hdc.GetDeviceCaps(88) # LOGPIXELSX def mm_to_px(mm_val: float) -> int: return int(mm_val * dpi_x / 25.4) # 创建逻辑字体 font_size_px = mm_to_px(FONT_SIZE_MM) font = win32ui.CreateFont( { "name": "SimSun", "height": font_size_px, "weight": 400, } ) hdc.SelectObject(font) # 设置透明背景 + 左对齐 hdc.SetTextAlign(0) # TA_LEFT | TA_TOP hdc.SetBkMode(1) # TRANSPARENT # 逐字段绘制 field_count = 0 for label, key, offset_idx in PRINT_FIELDS: value = data.get(key, "") or "" if key == "serial_number": value = serial_number # 跳过空值,避免打印空白覆盖卡纸原有内容 if not str(value).strip(): continue y_mm = START_Y + offset_idx * LINE_SPACING x_label_px = mm_to_px(LABEL_X) x_value_px = mm_to_px(VALUE_X) y_px = mm_to_px(y_mm) # 绘制标签 hdc.TextOut(x_label_px, y_px, label) # 绘制值 hdc.TextOut(x_value_px, y_px, str(value)) field_count += 1 hdc.EndPage() hdc.EndDoc() hdc.DeleteDC() logger.info(f"GDI 打印成功: {serial_number} (共 {field_count} 个字段)") return True except Exception as e: logger.error(f"GDI 打印失败 [{serial_number}]: {e}") return False finally: if hprinter is not None: with contextlib.suppress(Exception): win32print.ClosePrinter(hprinter)
# ================================================================ # 文件打印 # ================================================================
[文档] def print_file( self, file_path: str, printer_name: str | None = None, copies: int = 1, ) -> tuple[bool, int | None]: """打印文件(PDF/图片/文本)。 Args: file_path: 待打印文件的完整路径。 printer_name: 目标打印机名称,为 None 时使用默认打印机。 copies: 打印份数。 Returns: tuple[bool, int | None]: (是否成功, 作业 ID),失败时作业 ID 为 None。 """ if not os.path.exists(file_path): self.print_failed.emit(file_path, "文件不存在") return False, None printer = printer_name or self.get_default_printer() if not printer: self.print_failed.emit(file_path, "未找到可用打印机") return False, None try: ext = os.path.splitext(file_path)[1].lower() if ext == ".pdf": return self._print_pdf(file_path, printer, copies) if ext in (".jpg", ".jpeg", ".png", ".bmp"): return self._print_image(file_path, printer, copies) if ext == ".txt": return self._print_text_file(file_path, printer) return self._print_with_shell(file_path, printer) except Exception as e: self.print_failed.emit(file_path, str(e)) return False, None
def _print_pdf(self, pdf_path: str, printer_name: str, copies: int) -> tuple[bool, int | None]: """通过 Adobe Reader 或 Edge 打印 PDF""" try: adobe_path = r"C:\Program Files\Adobe\Acrobat DC\Acrobat\Acrobat.exe" if os.path.exists(adobe_path): cmd = [adobe_path, "/t", pdf_path, printer_name] else: cmd = [ "msedge.exe", "--headless", f"--print-to-file={pdf_path.replace('.pdf', '_out.pdf')}", ] subprocess.run(cmd, capture_output=True, text=True) self.print_completed.emit(pdf_path, 0) return True, 0 except Exception: return self._print_with_shell(pdf_path, printer_name) def _print_image( self, image_path: str, printer_name: str, copies: int ) -> tuple[bool, int | None]: """通过 PIL + win32ui 打印图片""" try: from PIL import Image, ImageWin image = Image.open(image_path) printer_dc = win32ui.CreateDC() printer_dc.CreatePrinterDC(printer_name) printer_dc.StartDoc(os.path.basename(image_path)) printer_dc.StartPage() printable_area = ( printer_dc.GetDeviceCaps(110), printer_dc.GetDeviceCaps(111), ) dib = ImageWin.Dib(image) scaled_width = printable_area[0] scaled_height = int(image.height * scaled_width / image.width) if scaled_height > printable_area[1]: scaled_height = printable_area[1] scaled_width = int(image.width * scaled_height / image.height) x = (printable_area[0] - scaled_width) // 2 y = (printable_area[1] - scaled_height) // 2 dib.draw(printer_dc.GetHandleOutput(), (x, y, x + scaled_width, y + scaled_height)) printer_dc.EndPage() printer_dc.EndDoc() printer_dc.DeleteDC() self.print_completed.emit(image_path, 0) return True, 0 except Exception as e: logger.warning(f"图片打印失败: {e}") return self._print_with_shell(image_path, printer_name) def _print_text_file(self, text_path: str, printer_name: str) -> tuple[bool, int | None]: """通过记事本打印文本文件""" try: subprocess.run(["notepad.exe", "/p", text_path], capture_output=True) self.print_completed.emit(text_path, 0) return True, 0 except Exception: return False, None def _print_with_shell(self, file_path: str, printer_name: str) -> tuple[bool, int | None]: """通过 Shell 命令打印(兜底方案)""" try: ps_command = ( f'Copy-Item -Path "{file_path}" ' f'-Destination "\\\\{printer_name}\\$' f"([System.IO.Path]::GetFileName('{file_path}'))\"" ) subprocess.run(["powershell", "-Command", ps_command], capture_output=True) self.print_completed.emit(file_path, 0) return True, 0 except Exception as e: self.print_failed.emit(file_path, str(e)) return False, None
[文档] def print_html( self, html_content: str, printer_name: str | None = None, copies: int = 1, ) -> tuple[bool, int | None]: """打印 HTML 内容(先转换为 PDF 再打印)。 Args: html_content: HTML 字符串内容。 printer_name: 目标打印机名称,为 None 时使用默认打印机。 copies: 打印份数。 Returns: tuple[bool, int | None]: (是否成功, 作业 ID),失败时作业 ID 为 None。 """ try: with tempfile.NamedTemporaryFile( mode="w", suffix=".html", delete=False, encoding="utf-8" ) as f: f.write(html_content) temp_file = f.name pdf_file = temp_file.replace(".html", ".pdf") cmd = [ "msedge.exe", "--headless", f"--print-to-file={pdf_file}", f"--printer-name={printer_name or self.get_default_printer()}", temp_file, ] subprocess.run(cmd, capture_output=True) if os.path.exists(pdf_file): result = self.print_file(pdf_file, printer_name, copies) os.unlink(pdf_file) os.unlink(temp_file) return result if "result" in dir() else (False, None) except Exception as e: self.print_failed.emit("HTML内容", str(e)) return False, None
# ================================================================ # 打印队列管理 # ================================================================
[文档] def get_print_queue(self, printer_name: str | None = None) -> list[PrintJob]: """获取打印队列。 Args: printer_name: 打印机名称,为 None 时使用默认打印机。 Returns: list[PrintJob]: 当前打印队列中的任务列表。 """ jobs: list[PrintJob] = [] try: name = printer_name or self.get_default_printer() if name: handle = win32print.OpenPrinter(name) jobs_info = win32print.EnumJobs(handle, 0, 10, 1) win32print.ClosePrinter(handle) for job in jobs_info: jobs.append( PrintJob( job_id=job["JobId"], document_name=job["pDocument"], printer_name=name, pages=job.get("TotalPages", 0), copies=job.get("Copies", 1), status=self._get_job_status(job.get("Status", 0)), submitted_time=str(job.get("Submitted", "")), ) ) except Exception as e: logger.debug(f"获取打印队列失败: {e}") return jobs
@staticmethod def _get_job_status(status_code: int) -> str: if status_code & win32print.JOB_STATUS_PRINTING: return "打印中" if status_code & win32print.JOB_STATUS_PAUSED: return "已暂停" if status_code & win32print.JOB_STATUS_ERROR: return "错误" if status_code & win32print.JOB_STATUS_DELETING: return "删除中" if status_code & win32print.JOB_STATUS_SPOOLING: return "后台处理" return "等待中"
[文档] def cancel_job(self, job_id: int, printer_name: str | None = None) -> bool: """取消打印任务。 Args: job_id: 打印作业 ID。 printer_name: 打印机名称,为 None 时使用默认打印机。 Returns: bool: 取消是否成功。 """ try: name = printer_name or self.get_default_printer() if name: handle = win32print.OpenPrinter(name) win32print.SetJob(handle, job_id, 0, None, win32print.JOB_CONTROL_CANCEL) win32print.ClosePrinter(handle) return True except Exception as e: logger.warning(f"取消打印任务失败: {e}") return False
[文档] def pause_printer(self, printer_name: str | None = None) -> bool: """暂停打印机。 Args: printer_name: 打印机名称,为 None 时使用默认打印机。 Returns: bool: 暂停是否成功。 """ try: name = printer_name or self.get_default_printer() if name: handle = win32print.OpenPrinter(name) win32print.SetPrinter(handle, 0, None, win32print.PRINTER_CONTROL_PAUSE) win32print.ClosePrinter(handle) return True except Exception as e: logger.warning(f"暂停打印机失败: {e}") return False
[文档] def resume_printer(self, printer_name: str | None = None) -> bool: """恢复打印机。 Args: printer_name: 打印机名称,为 None 时使用默认打印机。 Returns: bool: 恢复是否成功。 """ try: name = printer_name or self.get_default_printer() if name: handle = win32print.OpenPrinter(name) win32print.SetPrinter(handle, 0, None, win32print.PRINTER_CONTROL_RESUME) win32print.ClosePrinter(handle) return True except Exception as e: logger.warning(f"恢复打印机失败: {e}") return False
# ================================================================ # 设置持久化 # ================================================================
[文档] def save_settings(self, settings_file: str = "printer_settings.json") -> None: """保存打印机设置到 JSON 文件。 Args: settings_file: 设置文件路径,默认为 "printer_settings.json"。 """ settings = { "default_printer": self.get_default_printer(), "printers": {name: info.to_dict() for name, info in self.printers.items()}, } with open(settings_file, "w", encoding="utf-8") as f: json.dump(settings, f, ensure_ascii=False, indent=2)
[文档] def load_settings(self, settings_file: str = "printer_settings.json") -> None: """从 JSON 文件加载并设置默认打印机。 Args: settings_file: 设置文件路径,默认为 "printer_settings.json"。 """ if os.path.exists(settings_file): with open(settings_file, encoding="utf-8") as f: settings = json.load(f) default_printer = settings.get("default_printer") if default_printer: self.set_default_printer(default_printer)
[文档] @staticmethod def cleanup_temp_files() -> None: """清理系统中本模块产生的临时 HTML/PDF 文件。""" temp_dir = cfg("paths.temp.base", tempfile.gettempdir()) for file in os.listdir(temp_dir): if file.startswith("tmp") and (file.endswith(".html") or file.endswith(".pdf")): with contextlib.suppress(OSError): os.unlink(os.path.join(temp_dir, file))