certflow.handlers.scan_handler 源代码

# src/certflow/handlers/scan_handler.py
"""合格证扫描件处理器

负责扫描件的底层绘制和文件生成:
- 图片绘制 (PIL)
- 背景图片处理
- 文字渲染
- JPG/PDF 导出
- 布局计算
- 文件命名
"""

from __future__ import annotations

import os
from datetime import datetime

from PIL import Image, ImageDraw, ImageFont

from certflow.utils.logger import logger


[文档] class ScanImageHandler: """扫描件图片处理器(底层绘制)""" # 渲染分辨率:扫描件按 300 DPI 输出。背景图资源即按 300 DPI 制作 # (如 full_chinese_bg.png = 709×1187 ≈ 60×100mm@300DPI)。此前用 96 DPI # (3.78 px/mm)会把高清背景下采样到约 227×378 像素,导致发虚;对齐 300 DPI # 后背景 1:1 绘制、文字按 FONT_SCALE 同步放大,清晰度提升且版式比例不变。 RENDER_DPI = 300 PX_PER_MM = RENDER_DPI / 25.4 # ≈ 11.81 px/mm # 字段坐标中的 font_size 以 96 DPI 设计稿为基准,渲染到 RENDER_DPI 时按此比例 # 放大,确保相对版式与该 96 DPI 预览一致(仅像素密度提升,不放大错位)。 FONT_SCALE = RENDER_DPI / 96.0 MM_TO_PX = PX_PER_MM # 兼容别名 def __init__(self) -> None: """初始化扫描件图片处理器(建立字体缓存)""" self._font_cache = {} # 字体候选默认值(与 paths.fonts.candidates 配置一致;配置缺失时回退此列表)。 # 内置 CJK 资源优先,其次系统路径,最后 DejaVu 回退;优先含中文形的字体避免方块(tofu)。 _FONT_CANDIDATES_DEFAULT = [ "resources/fonts/wqy-microhei.ttc", "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttf", "simsun.ttc", # 宋体 "simhei.ttf", # 黑体 "msyh.ttc", # 微软雅黑 "C:/Windows/Fonts/simsun.ttc", "C:/Windows/Fonts/simhei.ttf", "C:/Windows/Fonts/msyh.ttc", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", ] def _font_candidates(self) -> list[str]: """构造字体候选路径列表(配置驱动,见 ``paths.fonts.candidates``)。 相对路径(如 ``resources/fonts/...``)解析为项目根下的绝对路径; 内置资源字体(``resources/fonts/wqy-microhei.ttc``)始终置于最前,保证仓库自包含优先。 """ from certflow.config.paths_override import cfg from certflow.utils.path_utils import get_project_root raw = cfg("paths.fonts.candidates", self._FONT_CANDIDATES_DEFAULT) root = get_project_root() internal = str(root / "resources" / "fonts" / "wqy-microhei.ttc") candidates: list[str] = [internal] # 内置资源字体最高优先级(T8 配置化前的硬编码行为) for p in raw: p = str(p) if not os.path.isabs(p): # 相对路径优先按项目根解析,再保留原值兜底 candidates.append(str(root / p)) candidates.append(p) return candidates def _resolve_font_path(self) -> str | None: """解析首个可用的字体路径(结果缓存,避免每次重建字体探测开销)""" if getattr(self, "_resolved_font", None): return self._resolved_font for path in self._font_candidates(): try: ImageFont.truetype(path, 10) self._resolved_font = path return path except OSError: continue self._resolved_font = None return None self._resolved_font = None return None def _get_font(self, font_size: int) -> ImageFont.FreeTypeFont: """获取字体(带缓存)。优先 CJK 字体以正确渲染中文,避免方块(tofu)。""" key = int(font_size) if key in self._font_cache: return self._font_cache[key] path = self._resolve_font_path() try: font = ImageFont.truetype(path, key) if path else ImageFont.load_default() except OSError: font = ImageFont.load_default() self._font_cache[key] = font return font
[文档] def mm_to_px(self, mm: float) -> int: """毫米转像素 Args: mm: 毫米值 Returns: 像素值 """ return int(mm * self.PX_PER_MM)
# 色彩空间标记(ICC profile 头 16 字节内)到 PIL 图像 mode 的对应 _ICC_SPACE_TO_MODE = {"RGB": "RGB", "GRAY": "L", "CMYK": "CMYK", "LAB": "LAB"}
[文档] @classmethod def load_background_rgb(cls, background_path: str) -> Image.Image: """加载背景图并归一化到干净 sRGB(修复俄英文背景在 Linux/IDE 偏色)。 问题根因(实测 ``ru_en_bg.png``):该 PNG 像素为 **RGB** 模式,却内嵌了约 557KB 的 **CMYK 打印机 profile**(``Japan Color 2001 Coated``, device class=prtr, space=CMYK)——profile 与像素模式**不匹配**。支持色彩管理的 看图器(Linux/IDE 预览)会试图按该 CMYK profile 解读 RGB 像素 → 顶栏/印章 偏色;不做色彩管理的看图器则正常。全中文/中英文背景无 ICC,故不受影响。 解法: - profile 的色彩空间与图像 mode **匹配**(如 RGB profile 配 RGB 图)→ 用 ``ImageCms`` 正常转换到 sRGB; - profile 与 mode **不匹配**(本例 CMYK profile 配 RGB 图,无法转换)→ 判定为错误标签,**剥离 profile**,直接使用 RGB 像素值; - 无 ICC → 仅确保 mode == RGB。 最终一律**清除 ``icc_profile``**,使生成结果在任意看图器下渲染一致(消除 用户所述「Linux 环境 / IDE 打开导致偏色」的不确定性)。 Args: background_path: 背景图片路径。 Returns: 无 ICC profile 的 sRGB `Image.Image`。 """ bg = Image.open(background_path) icc = bg.info.get("icc_profile") if icc: # ICC profile 头第 16-20 字节为数据色彩空间标记(如 "RGB ""GRAY""CMYK") profile_space = icc[16:20].decode("ascii", "ignore").strip() expected_mode = cls._ICC_SPACE_TO_MODE.get(profile_space) if expected_mode == bg.mode: try: import io from PIL import ImageCms src_profile = ImageCms.ImageCmsProfile(io.BytesIO(icc)) dst_profile = ImageCms.createProfile("sRGB") bg = ImageCms.profileToProfile(bg, src_profile, dst_profile, outputMode="RGB") logger.debug( "背景图 ICC({}) → sRGB 转换完成: {}", profile_space, background_path ) except Exception as e: # 转换失败回退普通 RGB logger.warning("背景图 ICC 转换失败,回退 convert(RGB): {}", e) bg = bg.convert("RGB") else: # profile 与像素模式不匹配(错误标签),剥离 profile 直接用像素值 logger.warning( "背景图 ICC profile({}) 与图像模式({}) 不匹配,已剥离: {}", profile_space or "?", bg.mode, background_path, ) bg = bg.convert("RGB") elif bg.mode != "RGB": bg = bg.convert("RGB") # 一律清除 icc_profile,保证输出为干净 sRGB、跨看图器一致 bg.info.pop("icc_profile", None) return bg
[文档] def create_canvas( self, width_mm: float, height_mm: float, background_path: str | None = None, ) -> Image.Image: """创建画布 Args: width_mm: 宽度(毫米) height_mm: 高度(毫米) background_path: 背景图片路径(可选) Returns: PIL Image 对象 """ width_px = self.mm_to_px(width_mm) height_px = self.mm_to_px(height_mm) img = Image.new("RGB", (width_px, height_px), "white") if background_path and os.path.exists(background_path): try: bg = self.load_background_rgb(background_path) bg = bg.resize((width_px, height_px), Image.Resampling.LANCZOS) img.paste(bg, (0, 0)) logger.debug(f"已加载背景图片: {background_path}") except Exception as e: logger.warning(f"加载背景图片失败: {e}") return img
[文档] def draw_text( self, img: Image.Image, text: str, x_mm: float, y_mm: float, font_size: int = 9, color: str = "black", ) -> None: """在画布上绘制文字(单段,不自动换行;框内换行请用 draw_field)。 Args: img: PIL Image 对象 text: 文字内容 x_mm: X 坐标(毫米) y_mm: Y 坐标(毫米) font_size: 字号(像素) color: 颜色 Returns: None: 直接在 img 上绘制,无返回值 """ self.draw_field(img, text, x_mm, y_mm, font_size, color=color)
[文档] def draw_field( self, img: Image.Image, value: str, x_mm: float, y_mm: float, font_size: int = 9, width_mm: float | None = None, height_mm: float | None = None, align: str = "left", v_align: str = "top", color: str = "black", ) -> None: """绘制字段:按框宽自动换行、超框高自动缩小字号、按对齐绘制。 解决阶段5扫描件两类渲染缺陷(BUG-006): - 长字符串(如产品型号 ``BESDZY-320``)按框宽折行,不再溢出整行; - 字号按基准 ``font_size`` 给出,不再被放大 2 倍; - 内容超高时自动缩小字号以适配框高; - 支持水平(align)/垂直(v_align)对齐,使用坐标配置中的 width/height。 Args: img: PIL Image 对象 value: 字段文字内容 x_mm, y_mm: 字段框左上角坐标(毫米) font_size: 基准字号(像素) width_mm, height_mm: 字段框宽/高(毫米);提供后启用换行与缩放 align: 水平对齐 left/center/right v_align: 垂直对齐 top/center/bottom color: 文字颜色 Returns: None: 直接在 img 上绘制,无返回值 """ if not value or not str(value).strip(): return draw = ImageDraw.Draw(img) x_px = self.mm_to_px(x_mm) y_px = self.mm_to_px(y_mm) width_px = self.mm_to_px(width_mm) if width_mm else None height_px = self.mm_to_px(height_mm) if height_mm else None text = str(value) # 自适应字号:从 font_size 起,换行后总高超出框高则逐步缩小至下限, # 确保长内容(含多行产品名称)不溢出框。 min_size = max(6, int(font_size * 0.6 * self.FONT_SCALE)) size = int(font_size * self.FONT_SCALE) font = self._get_font(size) lines = self._wrap_text(text, font, width_px) line_height = int(font.size * 1.25) while height_px and line_height * len(lines) > height_px and size > min_size: size -= 1 font = self._get_font(size) lines = self._wrap_text(text, font, width_px) line_height = int(font.size * 1.25) total_height = line_height * len(lines) ty = y_px if height_px and v_align == "center": ty = y_px + max(0, (height_px - total_height) // 2) elif height_px and v_align == "bottom": ty = y_px + max(0, height_px - total_height) for i, line in enumerate(lines): line_w = draw.textlength(line, font=font) if align == "center" and width_px: tx = x_px + max(0, (width_px - line_w) // 2) elif align == "right" and width_px: tx = x_px + max(0, width_px - line_w) else: tx = x_px draw.text((tx, ty + i * line_height), line, fill=color, font=font)
def _wrap_text( self, text: str, font: ImageFont.FreeTypeFont, max_width: int | None ) -> list[str]: """按最大像素宽度折行:CJK 逐字断行,ASCII 尽量在空格处断词。 Args: text: 原始文本 font: 当前字体(用于测量宽度) max_width: 最大像素宽度;为 None 时仅按显式换行符分段 Returns: 折行后的文本行列表 """ if max_width is None: return text.split("\n") out: list[str] = [] for paragraph in text.split("\n"): if paragraph == "": out.append("") continue cur = "" for ch in paragraph: if font.getlength(cur + ch) <= max_width: cur += ch else: # 优先在空格处断词,保持 ASCII 单词完整;否则逐字断行 if " " in cur: head, tail = cur.rsplit(" ", 1) out.append(head) cur = tail + ch else: if cur: out.append(cur) cur = ch out.append(cur) return out
[文档] def save_jpg(self, img: Image.Image, file_path: str, quality: int = 95) -> str: """保存为 JPG 文件 Args: img: PIL Image 对象 file_path: 文件路径 quality: 质量 (1-100) Returns: 文件路径 """ os.makedirs(os.path.dirname(file_path), exist_ok=True) # 写入 DPI 元数据,使看图器按物理尺寸显示(避免被默认按 96 DPI 缩成小图) img.save(file_path, "JPEG", quality=quality, dpi=(self.RENDER_DPI, self.RENDER_DPI)) logger.debug( f"已保存 JPG: {file_path} ({img.size[0]}x{img.size[1]} @ {self.RENDER_DPI}DPI)" ) return file_path
[文档] def save_pdf(self, img: Image.Image, file_path: str) -> str: """保存为 PDF 文件 Args: img: PIL Image 对象 file_path: 文件路径 Returns: 文件路径 """ os.makedirs(os.path.dirname(file_path), exist_ok=True) img.save(file_path, "PDF", resolution=float(self.RENDER_DPI)) logger.debug(f"已保存 PDF: {file_path}") return file_path
[文档] class ScanLayoutHandler: """扫描件布局处理器(负责位置计算)""" def __init__(self, rows: int = 1, cols: int = 1) -> None: """初始化布局处理器 Args: rows: 每页行数(合格证张数),默认 1 cols: 每页列数(合格证张数),默认 1 """ self.rows = rows self.cols = cols self.per_page = rows * cols
[文档] def calculate_canvas_size( self, cert_width_mm: float, cert_height_mm: float, ) -> tuple[int, int]: """计算画布尺寸 Args: cert_width_mm: 单张合格证宽度(毫米) cert_height_mm: 单张合格证高度(毫米) Returns: (width_px, height_px) 像素尺寸 """ mm_to_px = ScanImageHandler.PX_PER_MM width_px = int(cert_width_mm * self.cols * mm_to_px) height_px = int(cert_height_mm * self.rows * mm_to_px) return width_px, height_px
[文档] def calculate_canvas_size_mm( self, cert_width_mm: float, cert_height_mm: float, ) -> tuple[float, float]: """计算画布尺寸(毫米) Args: cert_width_mm: 单张合格证宽度(毫米) cert_height_mm: 单张合格证高度(毫米) Returns: (width_mm, height_mm) 毫米尺寸 """ width_mm = cert_width_mm * self.cols height_mm = cert_height_mm * self.rows return width_mm, height_mm
[文档] def get_position( self, index: int, cert_width_mm: float, cert_height_mm: float, ) -> tuple[float, float, int, int]: """获取指定索引的位置 Args: index: 合格证索引(0-based) cert_width_mm: 单张宽度(毫米) cert_height_mm: 单张高度(毫米) Returns: (x_offset_mm, y_offset_mm, row, col) """ row = index // self.cols col = index % self.cols x_offset_mm = col * cert_width_mm y_offset_mm = row * cert_height_mm return x_offset_mm, y_offset_mm, row, col
[文档] def is_multi_page(self) -> bool: """是否为多张拼接模式 Args: Returns: bool: 当 rows*cols > 1(多张拼接)时为 True """ return self.per_page > 1
[文档] def get_total_pages(self, total_certs: int) -> int: """计算总页数 Args: total_certs: 总合格证数量 Returns: 总页数 """ if self.per_page <= 1: return total_certs return (total_certs + self.per_page - 1) // self.per_page
[文档] def effective_grid(self, n: int) -> tuple[int, int]: """返回实际占用网格 ``(used_rows, used_cols)``,用于最后一页(非满)裁剪画布。 满页(``n == per_page``)时返回完整 ``(rows, cols)``,行为不变;非满页时 去除多余空白单元格: - 仅当所有单元落在**单行**(``used_rows == 1``)时,按实际列数横向裁剪, 彻底消除尾部空白; - 否则(上方已有满行,仅末行不满)保留整列宽,仅裁剪纵向空行。 这样最后一页不会生成整块 180×200 的空白合格证,画布尺寸贴合实际内容。 Args: n: 本页实际单元(单台编号)数量。 Returns: (used_rows, used_cols) 实际参与排版的行列数。 """ if n <= 0: return (0, 0) used_rows = (n + self.cols - 1) // self.cols used_cols = n if used_rows == 1 else self.cols return (used_rows, used_cols)
[文档] class ScanExportHandler: """扫描件导出处理器(负责文件命名和组织)""" def __init__(self, output_dir: str, output_format: str = "JPG") -> None: """初始化扫描件导出处理器 Args: output_dir: 导出文件输出目录 output_format: 导出格式,"JPG" 或 "PDF",默认 "JPG"(自动转大写) """ self.output_dir = output_dir self.output_format = output_format.upper()
[文档] def ensure_output_dir(self) -> None: """确保输出目录存在 Args: Returns: None: 递归创建 output_dir,已存在则跳过 """ os.makedirs(self.output_dir, exist_ok=True)
[文档] def generate_filename_single(self, certificate_no: str) -> str: """生成单张模式文件名 Args: certificate_no: 合格证编号 Returns: 完整文件路径 """ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") ext = self.output_format.lower() # 清理文件名中的非法字符 safe_name = self._sanitize_filename(certificate_no or "certificate") filename = f"{safe_name}_{timestamp}.{ext}" return os.path.join(self.output_dir, filename)
[文档] def generate_filename_multi(self, page_num: int, total_pages: int) -> str: """生成多张拼接模式文件名 Args: page_num: 当前页码 total_pages: 总页数 Returns: 完整文件路径 """ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") ext = self.output_format.lower() filename = f"scan_page{page_num:03d}_of_{total_pages:03d}_{timestamp}.{ext}" return os.path.join(self.output_dir, filename)
[文档] def generate_filename(self, base_name: str, page_num: int = 1, total_pages: int = 1) -> str: """生成文件名(兼容接口) Args: base_name: 基础名称 page_num: 页码 total_pages: 总页数 Returns: 完整文件路径 """ if total_pages == 1: return self.generate_filename_single(base_name) return self.generate_filename_multi(page_num, total_pages)
@staticmethod def _sanitize_filename(filename: str) -> str: """清理文件名中的非法字符""" illegal_chars = '<>:"/\\|?*' for char in illegal_chars: filename = filename.replace(char, "_") return filename[:100] # 限制长度