PySide6 档案附件页码编号系统:从空白页检测到批量报告的全流程实践

PySide6 档案附件页码编号系统:从空白页检测到批量报告的全流程实践

前言

档案管理有一项基本要求:每份附件的每一页必须编有页号,以便卷内目录的页码引用与实体页面一一对应。空白页(无有效内容的分隔页)应跳过不编号,有内容的页面从 1 开始连续编号。

这套看似简单的需求,在工程实现中涉及多个技术难点:

  • 空白页检测:PDF 页面"空白"的定义是什么?如何区分真正的空白页与含矢量文字但无位图的扫描页?
  • 人工复核:自动检测不可能 100% 准确,必须提供逐页缩略图预览供用户修正
  • 页号叠加 :用 reportlab 生成 overlay 再用 pypdf 拼接到原页面(详见第五篇的图形状态陷阱)
  • 旋转与裁剪 :横向页 /Rotate 90 和 CropBox 偏移页的坐标计算
  • 批量编号:一个分卷下数十个附件逐个编号,中间不弹窗中断,完成后出详细报告
  • 线程安全:QPixmap 不能跨线程,pdftoppm 子进程需要后台执行

本文完整记录这套系统的设计与实现,技术栈:PySide6 6.6.3 + pypdf 6.15.0 + reportlab 5.0.1 + PIL + poppler-utils,麒麟 Linux。

本系列前情:


一、系统架构

1.1 模块划分

复制代码
┌──────────────────────────────────────────────────┐
│  交互层  page_number_dialogs.py                   │
│  PageNumberSettingDialog    页号设置(字体/位置)  │
│  BlankPagePreviewDialog     空白页逐页复核        │
│  BatchNumberReportDialog    批量编号报告+复制      │
├──────────────────────────────────────────────────┤
│  编排层  main_window.py                           │
│  _embed_page_number()       单个编号入口          │
│  _do_embed_page_number()    核心流程编排           │
│  _batch_embed_page_numbers()批量编号循环           │
│  _on_embed_finished()       完成回调+成功提示      │
├──────────────────────────────────────────────────┤
│  引擎层  page_number_engine.py                    │
│  detect_blank_pages()       空白页检测            │
│  embed_page_numbers()       页号嵌入              │
│  _calc_number_xy()          坐标计算(CropBox+Rotate)│
│  remove_page_numbers()      取消编号(.origin还原)  │
├──────────────────────────────────────────────────┤
│  线程层  main_window.py 内 Worker                 │
│  DetectBlankWorker          检测+渲染预览         │
│  EmbedWorker                页号嵌入              │
└──────────────────────────────────────────────────┘

1.2 单个编号流程

复制代码
用户点击"编写页号"
    │
    ▼
PageNumberSettingDialog(字体/字号/边距/位置/忽略空白页)
    │
    ▼
_do_embed_page_number()
    │
    ├─ 格式预校验(.pdf/.jpg/.png... 不支持则报错退出)
    │
    ├─ 阶段1:DetectBlankWorker(后台线程)
    │   ├─ 空白页检测(文本+矢量+位图三重判断)
    │   └─ pdftoppm 渲染预览 PNG(50dpi 缩略图)
    │
    ├─ BlankPagePreviewDialog(逐页缩略图+复选框)
    │   └─ 用户修正自动检测结果
    │
    ├─ 阶段2:EmbedWorker(后台线程)
    │   ├─ reportlab 生成 overlay PDF
    │   └─ pypdf 拼接内容流(含图形状态复位)
    │
    └─ _on_embed_finished()
        ├─ 成功提示("页号编写完成,共编号 N 页")
        ├─ 页数一致性检查(页数≠页号数则询问是否更新)
        └─ 刷新详情面板(按钮变为"取消编号")

1.3 批量编号流程

复制代码
用户点击"批量编号"
    │
    ▼
PageNumberSettingDialog(共享设置)
    │
    ▼
for 每个附件 in 分卷:
    ├─ 文件缺失 → 记录到 missing_items
    ├─ 已嵌入   → 记录到 skipped_items
    ├─ 格式不支持 → 记录到 unsupported_items(不弹窗)
    └─ 执行嵌入(show_blank_review=False,不弹复核)
        ├─ QEventLoop 等待回调
        ├─ 成功 → 记录到 success_items(含空白页清单)
        └─ 失败 → 记录到 failed_items
    │
    ▼
BatchNumberReportDialog(六节报告 + 复制按钮)

二、空白页检测

2.1 为什么需要三重判断

PDF 页面的"空白"定义并不直观。以下三种情况都可能存在:

情况 文本提取 位图方差 矢量操作 真实状态
纯白页 空 无图 无 空白
扫描白页 空(扫描件无文本层) 低方差 无 空白
矢量文字页 可能为空(编码问题) 无图 有 TJ/S 非空
扫描内容页 空 高方差 有 Do 非空
全黑扫描页 空 低方差(全0) 有 Do 非空

如果只看位图方差,矢量文字页会被误判为空白;如果只看文本提取,扫描件和编码异常的页面会误判。因此需要三层递进检测。

2.2 检测算法

python 复制代码
def _detect_blank_pdf_pages(file_path, progress_callback=None):
    """三重递进检测:文本 → 矢量操作 → 位图方差"""
    reader = pypdf.PdfReader(file_path)
    result = []
    for idx, page in enumerate(reader.pages):
        is_blank = True

        # 第一层:文本内容
        try:
            text = page.extract_text()
            if text and text.strip() and len(text.strip()) > 2:
                is_blank = False
        except Exception:
            pass

        # 第二层:内容流矢量操作
        if is_blank:
            try:
                data = _get_content_bytes(page)
                has_text_op = b'TJ' in data or b'Tj' in data
                has_draw_op = any(
                    op in data for op in
                    [b'S\r', b'S\n', b'f*', b' re', b' m\r', b' m\n']
                )
                if (has_text_op or has_draw_op) and len(data.strip()) > 20:
                    is_blank = False
            except Exception:
                pass

        # 第三层:位图像素方差(扫描件场景)
        if is_blank:
            images = list(page.images) if hasattr(page, 'images') else []
            for img_info in images:
                try:
                    img = Image.open(io.BytesIO(img_info.data))
                    gray = img.convert('L')
                    stat = ImageStat.Stat(gray)
                    if stat.stddev and stat.stddev[0] >= 5.0:
                        is_blank = False
                        break
                except Exception:
                    is_blank = False  # 解析失败,保守判为非空
                    break

        result.append(is_blank)
    return result

关键设计决策:

  • 阈值 5.0:像素标准差低于 5.0 判为空白。这个值经过实测调优------纯白页方差接近 0,有文字的扫描页方差通常在 30 以上,5.0 是安全分界线。
  • len(text.strip()) > 2:忽略 1~2 个字符的"噪声文本"(如页眉的孤立页码),避免把只有页码的白页判为非空。
  • 解析失败保守判非空:位图解析异常时不判为空白,避免误删有效页面。
  • 矢量操作检测 S\r/S\n :S 是描边操作符,但要区分操作符 S 和出现在操作数中的字母 S,所以检查 S\r/S\n(后跟换行)。f* 是填充操作符,re 是矩形路径,m 是移动到点。

2.3 图片格式检测

图片附件只有一页,检测更简单:

python 复制代码
def _detect_blank_image(file_path):
    img = Image.open(file_path).convert('L')
    stat = ImageStat.Stat(img)
    std = stat.stddev[0] if stat.stddev else 0
    return [std < 5.0]

三、预览渲染与线程安全

3.1 QPixmap 的线程约束

PySide6 的 QPixmap 必须在主线程创建,后台线程不能直接构造。但空白页检测和 PDF 渲染都是耗时操作,必须放在后台线程。

解决方案:后台线程渲染 PNG 文件,主线程加载 QPixmap。

python 复制代码
# 后台线程:用 pdftoppm 渲染 PNG 到临时目录
def _render_pdf_preview_files(file_path):
    """pdftoppm 一次性渲染所有页为 PNG 文件"""
    tmpdir = tempfile.mkdtemp(prefix='ias_preview_')
    subprocess.run(
        ['pdftoppm', '-png', '-r', '50', file_path,
         os.path.join(tmpdir, 'page')],
        check=True, capture_output=True, timeout=120
    )
    files = sorted(glob.glob(os.path.join(tmpdir, 'page-*.png')),
                   key=lambda f: int(''.join(
                       c for c in os.path.basename(f) if c.isdigit())))
    return files, tmpdir

# 主线程:PNG 文件 → QPixmap
def _on_detect_finished(result):
    blank_flags, preview_files, tmpdir = result
    pixmaps = []
    for f in preview_files:
        if f and os.path.exists(f):
            pm = QPixmap(f)  # 主线程创建
            pixmaps.append(pm.scaled(200, 280, Qt.KeepAspectRatio,
                                      Qt.SmoothTransformation))
    shutil.rmtree(tmpdir, ignore_errors=True)  # 清理临时目录

3.2 为什么用 50dpi

预览缩略图只需辨识"这页有没有内容",不需要阅读文字。50dpi 足够区分空白页和有内容页,同时:

  • 一页 A4(210×297mm)在 50dpi 下约 414×583 像素,PNG 约 20-50KB
  • 257 页总共约 5-12MB 临时文件,加载速度快
  • 与 BlankPagePreviewDialog 的 140×180 缩略图控件匹配

3.3 统一进度对话框

检测阶段(0-50%)和嵌入阶段(50-100%)共用一个 QProgressDialog,保持视觉连续性:

python 复制代码
progress = QProgressDialog("正在检测空白页...", None, 0, 100, self)
progress.show()

# 阶段1:检测(0-50%)
def _on_detect_progress(cur, total):
    progress.setValue(int(cur / total * 50))

# 阶段2:嵌入(50-100%)
def _on_embed_progress(cur, total):
    progress.setValue(50 + int(cur / total * 50))

四、空白页人工复核

4.1 交互设计

自动检测不可能 100% 准确(特别是扫描质量差、页面有印章水印等情况)。因此提供逐页缩略图 + 复选框的人工复核界面:

复制代码
┌─────────────────────────────────────────────┐
│ 空白页复核                                    │
│ 文件: 投标文件.pdf                           │
│ ☐ 仅显示空白页                               │
├─────────────────────────────────────────────┤
│ ┌─────┐  ┌─────┐  ┌─────┐  ┌─────┐         │
│ │第1页│  │第2页│  │第3页│  │第4页│         │
│ │缩略图│  │缩略图│  │缩略图│  │缩略图│         │
│ │☐空白│  │☐空白│  │☑空白│  │☐空白│         │
│ └─────┘  └─────┘  └─────┘  └─────┘         │
│ ...                                          │
├─────────────────────────────────────────────┤
│ 共 257 页,空白 3 页,将编号 254 页    [确定] │
└─────────────────────────────────────────────┘

4.2 筛选功能

当文件页数很多(如 257 页),逐页滚动效率低。提供"仅显示空白页"筛选:

python 复制代码
def _apply_filter(self):
    only_blank = self.only_blank_check.isChecked()
    visible_row = 0
    for idx, item in enumerate(self.page_items):
        check = self.checkboxes[idx]
        if only_blank and not check.isChecked():
            item.setVisible(False)
            self.grid.removeWidget(item)
        else:
            item.setVisible(True)
            self.grid.removeWidget(item)
            # 重新排列到连续位置
            self.grid.addWidget(item, visible_row // 4, visible_row % 4)
            visible_row += 1

注意 :Qt 的 QGridLayout 不会自动回收隐藏项的位置,必须先 removeWidget 再按连续行号 addWidget,否则会出现空洞。

4.3 单个编号与批量编号的区别

行为 单个编号 批量编号
空白页复核弹窗 始终弹出 不弹出
格式不支持 弹错误框 记入报告清单
嵌入失败 弹错误框 记入报告清单
页数不一致 弹确认框 自动更新页数
完成提示 弹成功框 统一报告

单个编号面向"精细操作"------用户有时间逐页复核;批量编号面向"高效处理"------用户不想被逐个弹窗打断。


五、页号嵌入

5.1 .origin 备份机制

嵌入前先备份原文件到 .origin,确保可逆:

python 复制代码
origin_path = file_path + '.origin'
if not os.path.exists(origin_path):
    shutil.copy2(file_path, origin_path)
  • .origin 只在首次嵌入时创建,重复编号不会覆盖原始备份
  • 取消编号时从 .origin 还原:shutil.copy2(origin_path, file_path)
  • 嵌入失败时也自动还原,避免"半嵌入"状态
  • 导出档案时排除 .origin 文件(避免备份被一起导出)

5.2 双面编号位置

档案管理有单面和双面两种编号方式:

python 复制代码
if page_position == 'double_front_right_back_left':
    # 双面:正面(偶数索引)右下角,背面(奇数索引)左下角
    is_front = (i % 2 == 0)
else:
    # 单面:始终右下角
    is_front = True

x, y = _calc_number_xy(page, text_width, margin, is_front)

5.3 图形状态复位

页号叠加的图形状态继承陷阱详见第五篇。核心是在 overlay 前注入强制复位前缀:

python 复制代码
state_reset = (
    b'0 0 0 rg 0 0 0 RG 0 Tr '
    b'0 Tc 0 Tw 100 Tz 0 Ts 0 w '
    + gs_name.encode() + b' gs '
)
combined = (orig_data + b'\nq\n' + state_reset +
            b'\n' + overlay_data + b'\nQ\n')

5.4 字体注册

reportlab 内置 Helvetica/Times-Roman/Courier 三种字体。中文字体需要手动注册 TTF:

python 复制代码
def _register_reportlab_font(font_family):
    """搜索系统 TTF 文件并注册到 reportlab"""
    # 内置字体映射
    builtin = {'helvetica': 'Helvetica', 'times': 'Times-Roman', ...}
    key = font_family.lower().replace(' ', '').replace('_', '')
    if key in builtin:
        return builtin[key]

    # 搜索系统字体目录
    ttf_path = _find_font_ttf(font_family)
    if ttf_path:
        register_name = f'CustomFont_{abs(hash(ttf_path)) % 100000}'
        pdfmetrics.registerFont(TTFont(register_name, ttf_path))
        return register_name

    return 'Helvetica'  # 回退

中文字体映射表处理"宋体"→simsun.ttf、"黑体"→simhei.ttf 等常见名称:

python 复制代码
cn_font_map = {
    '宋体': ['simsun', 'songti', 'notoserifcjksc', 'wqyzenhei'],
    '黑体': ['simhei', 'heiti', 'notosanscjksc', 'wqymicrohei'],
    '楷体': ['kaiti', 'simkai'],
    '仿宋': ['fangsong', 'simfang'],
}

5.5 图片格式页号

图片附件用 PIL 直接在像素层面绘制页号:

python 复制代码
def _embed_image_page_number(file_path, settings, blank_flags):
    img = Image.open(file_path)
    draw = ImageDraw.Draw(img)

    # 白色背景防透明覆盖
    draw.rectangle([x-2, y-2, x+text_w+2, y+text_h+2], fill='white')
    draw.text((x, y), "1", fill='black', font=font)

    # 保持原格式保存
    if ext.lower() in ('.jpg', '.jpeg'):
        img = img.convert('RGB')  # JPEG 不支持 Alpha
        img.save(file_path, format='JPEG')
    elif ext.lower() == '.png':
        img.save(file_path, format='PNG')

六、批量编号报告

6.1 六节报告结构

批量编号完成后弹出统一报告,按用户关注度排列:

复制代码
批量编号详细报告
分卷:第一卷
时间:2026-09-27 20:30
汇总:成功 12 个,跳过(已嵌入) 3 个,格式不支持 2 个,文件缺失 0 个,嵌入失败 0 个

一、检测到空白页的文档(共 2 个,编号时已自动跳过空白页)
1. 投标文件.pdf:共 257 页,空白页为第 3、15 页(2 页),实际编号 255 页
2. 合同扫描件.pdf:共 80 页,空白页为第 40 页(1 页),实际编号 79 页

二、格式不支持的文档(共 2 个,未编号)
1. 设计图纸.dwg:格式 DWG,仅支持 PDF 及图片格式
2. 会议纪要.docx:格式 DOCX,仅支持 PDF 及图片格式

三、成功编号的文档(共 12 个)
1. 投标文件.pdf:编号 255 页
2. 合同扫描件.pdf:编号 79 页(页数已由 80 更新为 79)
...

四、跳过的文档(已存在页号,共 3 个)
1. 中标通知书.pdf
...

五、文件缺失的文档(共 0 个)
(无)

六、嵌入失败的文档(共 0 个)
(无)

6.2 报告生成

python 复制代码
def _build_batch_number_report(self, volume_name, success_items,
                                skipped_items, unsupported_items,
                                missing_items, failed_items):
    blank_doc_items = [it for it in success_items if it['blank_pages']]
    lines = []
    lines.append('批量编号详细报告')
    lines.append(f'分卷:{volume_name}')
    lines.append(f'时间:{timestamp}')
    lines.append(
        f'汇总:成功 {len(success_items)} 个,'
        f'跳过(已嵌入) {len(skipped_items)} 个,'
        f'格式不支持 {len(unsupported_items)} 个,'
        f'文件缺失 {len(missing_items)} 个,'
        f'嵌入失败 {len(failed_items)} 个'
    )
    # 六节依次追加...
    return '\n'.join(lines)

6.3 一键复制

报告底部提供"复制报告内容"按钮,点击后反馈"已复制到剪贴板 ✓"1.5 秒后恢复:

python 复制代码
def _copy_report(self):
    clipboard = QApplication.clipboard()
    clipboard.setText(self._report_text)
    self.copy_btn.setText("已复制到剪贴板 ✓")
    self.copy_btn.setEnabled(False)
    QTimer.singleShot(1500, self._restore_copy_btn)

七、线程编排

7.1 QThread + 信号回调

检测和嵌入都是耗时操作,用 QThread 子类化实现:

python 复制代码
class DetectBlankWorker(QThread):
    progress = Signal(int, int)    # (current, total)
    finished = Signal(tuple)       # (blank_flags, preview_files, tmpdir)

    def run(self):
        result = page_number_engine.detect_blank_and_render_preview(
            self.file_path, self._emit_progress
        )
        self.finished.emit(result)

7.2 批量编号的 QEventLoop 同步等待

批量编号需要在 for 循环中逐个等待每个附件嵌入完成。用 QEventLoop 实现同步等待:

python 复制代码
for file_info in files:
    result_holder = {'done': False, ...}
    loop = QEventLoop()

    def _on_done(success, max_page_no, error, blank_flags):
        result_holder['done'] = True
        result_holder['success'] = success
        # ...
        loop.quit()

    self._do_embed_page_number(
        file_info, settings, callback=_on_done,
        show_blank_review=False
    )
    loop.exec()  # 阻塞直到 _on_done 调用 loop.quit()

    if not result_holder['success']:
        failed_items.append(...)
        continue

loop.exec() 不会冻结 UI------它启动一个事件循环,继续处理重绘、定时器等事件,直到 loop.quit() 被调用才返回。

7.3 单个编号的信号回调

单个编号不需要阻塞等待,用信号驱动 UI 刷新:

python 复制代码
def _on_embed_finished(result, file_info, blank_flags):
    success, max_page_no, error = result
    if not success:
        QMessageBox.critical(...)
        return

    # 刷新详情面板
    self._show_file_detail()

    # 页数一致性检查或成功提示
    if current_pages != max_page_no:
        self._handle_page_count_mismatch(...)
    else:
        QMessageBox.information(self, "页号编写成功", msg)

八、坐标计算:CropBox + /Rotate

8.1 为什么不能直接用 mediabox

原始实现用 mediabox 宽高硬编码坐标:

python 复制代码
x = w - margin - text_width  # w = mediabox.width
y = margin

这在以下场景会出错:

  • /Rotate 90 横向页:视觉右下角 ≠ 默认坐标系右下角
  • CropBox 偏移:CropBox 可能比 MediaBox 小且偏移,边距应相对可见区域而非整页

8.2 CropBox 与 MediaBox 的关系

概念 含义 用途
MediaBox 页面物理尺寸 定义内容流坐标系
CropBox 可见区域 渲染/打印时裁剪到的区域

大多数 PDF 的 CropBox = MediaBox,但有些 PDF(特别是裁剪过的扫描件)两者不同。页码应出现在可见区域内,所以以 CropBox 为基准。

8.3 /Rotate 逆映射

/Rotate 定义页面显示时的顺时针旋转角度。内容流坐标始终在 MediaBox 坐标系中,但渲染器会旋转显示。

通过 poppler 实测的正映射(默认坐标 → 显示坐标):

复制代码
R=0:   (x, y) → (x, y)              显示尺寸(vw, vh)
R=90:  (x, y) → (y, vw-x)           显示尺寸(vh, vw)
R=180: (x, y) → (vw-x, vh-y)        显示尺寸(vw, vh)
R=270: (x, y) → (vh-y, x)           显示尺寸(vh, vw)

逆映射(显示坐标 → 默认坐标):

python 复制代码
if rotate == 0:
    a, b = xd, yd
elif rotate == 90:
    a, b = vw - yd, xd
elif rotate == 180:
    a, b = vw - xd, vh - yd
else:  # 270
    a, b = yd, vh - xd

其中 vw/vh 是 CropBox 的宽高,xd/yd 是目标显示坐标,a/b 是默认坐标系坐标。

8.4 关键细节:旋转后宽高互换

python 复制代码
# 旋转 90/270 后显示宽高互换
disp_w = vh if rotate in (90, 270) else vw
if is_right:
    xd = disp_w - margin - text_width

这个 disp_w 必须用旋转后的显示宽度 ,而不是 CropBox 宽度 vw。初版代码这里误用了 vw,导致横向页的页码落到了可视区域外。


九、PyInstaller 打包加固

9.1 延迟导入的陷阱

page_number_engine.py 中 reportlab 和 pypdf 都是函数内延迟导入:

python 复制代码
def _embed_pdf_page_numbers(file_path, settings, ...):
    import pypdf
    from reportlab.pdfgen import canvas
    ...

PyInstaller 的静态分析无法扫描到函数体内的 import,导致打包后缺库。

9.2 三重保险

python 复制代码
# IAS_kylin.spec

# 第一重:hiddenimports 显式声明
hiddenimports = [
    'reportlab',
    'reportlab.pdfgen',
    'reportlab.pdfgen.canvas',
    'reportlab.lib',
    'reportlab.pdfbase',
    'reportlab.pdfbase.ttfonts',
    'pypdf',
]

# 第二重:collect_all 收集数据文件(字体、模板等)
from PyInstaller.utils.hooks import collect_all
for _pkg in ('reportlab', 'pypdf'):
    try:
        _d, _b, _h = collect_all(_pkg)
        datas += _d
        _binaries += _b
        hiddenimports += _h
    except Exception:
        pass

# 第三重:hiddenimports 作为兜底(打包环境未安装时告警)

collect_all 不仅收集 Python 模块,还收集包内的数据文件(如 reportlab 的内置字体 Helvetica.afm 等),这些数据文件如果缺失,运行时会报 IOError 而非 ImportError,更难排查。


十、避坑清单

# 陷阱 症状 修复
1 图形状态继承 页码变色/半透明/不可见 overlay 前注入完整状态复位前缀
2 q/Q 不平衡 merge_page 丢文本 手动拼接,只包裹 overlay
3 /Rotate 坐标 横向页页码位置错误 按旋转角度逆映射
4 CropBox 偏移 页码落到裁剪区外 以 CropBox 为基准
5 disp_w 误用 旋转页页码越界 旋转 90/270 时显示宽=vh
6 QPixmap 跨线程 段错误 后台渲染 PNG,主线程加载
7 矢量文字误判空白 有效页被跳过 三重检测(文本+矢量+位图)
8 解析异常误判空白 有效页被跳过 解析失败保守判非空
9 延迟导入打包缺库 No module named 'reportlab' hiddenimports + collect_all
10 QGridLayout 筛选空洞 隐藏项留空白 removeWidget + 连续 addWidget
11 .origin 重复覆盖 取消编号无法还原 仅首次创建 .origin
12 嵌入失败半成品 文件损坏 失败时自动还原 .origin

总结

档案页码编号系统的核心挑战不在于"画一个数字",而在于:

  1. 准确性:空白页检测的三重递进策略,确保不漏不误
  2. 可控性:人工复核 + 批量报告,让用户对结果有信心
  3. 健壮性:图形状态复位、坐标旋转映射、.origin 备份,应对各种异常 PDF
  4. 可逆性:.origin 备份机制确保任何操作都能撤销
  5. 线程安全:后台渲染 + 主线程加载 QPixmap,不阻塞 UI

其中最深刻的教训来自图形状态继承------一个"看起来正确"的 q/Q 包裹,实际上完全没有隔离效果。PDF 规范的复杂性远超想象,任何对 PDF 内容流的操作都必须考虑前序状态泄漏的风险。

本系列前情: