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 |
总结
档案页码编号系统的核心挑战不在于"画一个数字",而在于:
- 准确性:空白页检测的三重递进策略,确保不漏不误
- 可控性:人工复核 + 批量报告,让用户对结果有信心
- 健壮性:图形状态复位、坐标旋转映射、.origin 备份,应对各种异常 PDF
- 可逆性:.origin 备份机制确保任何操作都能撤销
- 线程安全:后台渲染 + 主线程加载 QPixmap,不阻塞 UI
其中最深刻的教训来自图形状态继承------一个"看起来正确"的 q/Q 包裹,实际上完全没有隔离效果。PDF 规范的复杂性远超想象,任何对 PDF 内容流的操作都必须考虑前序状态泄漏的风险。
本系列前情: