专栏第 2 篇。上一篇讲了 DA/T 31-2017 的九环节全流程,其中"图像处理"和"OCR/目录建库"之间有一个被很多同行忽略的衔接产物:双层 PDF。这篇把我们的实现方案、完整代码和踩过的坑都放出来。
一、什么是双层 PDF,为什么档案场景必须用它
把一张扫描图直接存成 PDF,你得到的是"一层皮"------只有图像层,看起来是文档,但里面没有一个字,搜不到、复制不了、选不中。
档案数字化的利用场景恰恰全是"要字"的:
- 查档系统里搜"某某项目"要能命中这份文件;
- 律师调阅时想复制一段判决书原文;
- 用户按页打印时又要保持原貌,不能是重排的文本。
"原貌 + 可检索"两个要求同时满足,解决方案就是双层 PDF:图像层保留扫描原貌,隐形文本层承载 OCR 识别结果。人眼看到的永远是图像,搜索引擎和文本工具拿到的永远是文字层。
行业里管这个叫"OCR 全文本 PDF"或"检索 PDF",档案验收和档案局接收系统对它的接受度已经非常高。对我们的系统来说,双层 PDF 是 OCR 能力的"交付形态"------识别结果不落成文字层,OCR 就等于白做。

二、三条技术路线对比
我们调研并实测过三条路线,工程取舍如下:
| 路线 | 实现方式 | 优点 | 缺点 | 我们的结论 |
|---|---|---|---|---|
| A. ocrmypdf | 开源命令行工具(tesseract + ghostscript) | 开箱即用、参数齐全 | 中文识别依赖 tesseract 模型,坐标可控性弱 | 快速原型可用,生产不采用 |
| B. PaddleOCR + reportlab | 自研管线,OCR 出坐标,reportlab 写隐形文本层 | 坐标完全可控、中文识别质量最好 | 要自己处理坐标系、字体、批量并发 | 生产主方案 |
| C. PyMuPDF(fitz) | 直接在 PDF 上叠文本对象 | 免重绘图像、速度快 | 隐形文本属性设置繁琐,和 OCR 输出对接要自己写 | 适合给已有 PDF 补文字层 |
下面给出主方案 B 的完整实现,以及方案 C 的补充代码。
三、主方案:PaddleOCR + reportlab
3.1 整体思路
扫描图 → PaddleOCR(出文本 + 坐标框) → 坐标换算(图像像素 → PDF 点)
→ reportlab 画图像层 → 叠加隐形文本层 → 输出双层 PDF
核心难点只有一个:坐标系换算。图像坐标系原点在左上角、Y 轴向下;PDF 坐标系原点在左下角、Y 轴向上。OCR 返回的每个文本框坐标都要翻转到 PDF 坐标,否则文字层会跑到镜像位置,复制出来的文字顺序还是反的。
3.2 完整代码
python
"""双层 PDF 生成器:PaddleOCR 识别 + reportlab 隐形文本层
运行环境:Python 3.9+,pip install paddleocr reportlab pillow
"""
from paddleocr import PaddleOCR
from PIL import Image
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
# 关键点:中文必须注册 CID 字体,否则隐形文本层无法嵌入中文字形,
# 用 Helvetica 画中文会全部变成空白占位,复制出来是乱码。
pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light"))
ocr = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False)
# 默认识别结果结构:
# result[0] = [ [[x1,y1,x2,y2...4点坐标], (文本, 置信度)], ... ]
# 每个文本框是 4 个角点的坐标列表 [左上, 右上, 右下, 左下]
def _image_dpi(img_path: str) -> int:
"""读取扫描图的元数据 dpi,取不到就按 300 兜底(PDF 点换算用)。"""
with Image.open(img_path) as im:
dpi = im.info.get("dpi")
if dpi and dpi[0] > 0:
return int(dpi[0])
return 300
def build_double_layer_pdf(img_path: str, pdf_path: str) -> None:
result = ocr.ocr(img_path, cls=True)
lines = result[0] if result else []
with Image.open(img_path) as im:
w_px, h_px = im.size
dpi = _image_dpi(img_path)
# 像素 → PDF 点(1 英寸 = 72 点)
w_pt = w_px * 72.0 / dpi
h_pt = h_px * 72.0 / dpi
c = canvas.Canvas(pdf_path, pagesize=(w_pt, h_pt))
# 1) 图像层:原貌
c.drawImage(img_path, 0, 0, width=w_pt, height=h_pt)
# 2) 隐形文本层:render mode 3 = 不渲染可见字形,仅用于提取
for line in lines:
box, (text, conf) = line[0], line[1]
xs = [p[0] for p in box]
ys = [p[1] for p in box]
x_pt = min(xs) * 72.0 / dpi
# 图像原点在左上 → PDF 原点在左下:Y 轴翻转
y_pt = h_pt - max(ys) * 72.0 / dpi
font_size = (max(ys) - min(ys)) * 72.0 / dpi
if font_size <= 0 or conf < 0.6: # 低置信度不落文字层,避免污染检索
continue
c.setFont("STSong-Light", font_size)
t = c.beginText(x_pt, y_pt)
t.setTextRenderMode(3) # 隐形
t.textOut(text)
c.drawText(t)
c.showPage()
c.save()
print(f"[ok] {img_path} -> {pdf_path}({len(lines)} 个文本块,{w_px}x{h_px}@{dpi}dpi)")
if __name__ == "__main__":
import sys
build_double_layer_pdf(sys.argv[1], sys.argv[2])
用法:
bash
python double_layer_pdf.py 扫描件.png 双层结果.pdf
3.3 批量流水线
单页跑通后上批量,我们直接按"页"做多进程(图片处理是 CPU 密集,进程池比线程池合适):
python
from multiprocessing import Pool
from pathlib import Path
def process_one(page: Path, out_dir: Path) -> None:
build_double_layer_pdf(str(page), str(out_dir / (page.stem + ".pdf")))
def batch(src_dir: Path, out_dir: Path, workers: int = 4) -> None:
out_dir.mkdir(parents=True, exist_ok=True)
pages = list(src_dir.glob("*.png")) + list(src_dir.glob("*.jpg"))
with Pool(workers) as pool:
pool.map(lambda p: process_one(p, out_dir), pages)
四、三个必须处理的坑
代码能跑只是开始,这三点不处理,生产环境必翻车:
坑 1:中文隐形文字层是乱码
不注册 UnicodeCIDFont 直接用 Helvetica 画中文,视觉上没问题(反正隐形),但复制出来全乱码 ,等于文字层白做。这也是为什么很多"看起来做了双层 PDF"的产品验收时一复制就露馅。务必用 pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light"))。
坑 2:坐标 Y 轴翻转漏掉
图像左上原点 → PDF 左下原点,Y 必须翻转。漏翻转的症状是:检索能命中,但复制出来文字顺序是反的、选中区域错位。测试时用 PDF 阅读器的"选择文本"功能逐页抽查,选中的矩形区域应该和文字位置对得上。
坑 3:低置信度文本全塞进去
把 OCR 的"幻觉文本"(置信度 0.3 的噪声)也写进文字层,检索时会搜出一堆根本不存在的内容------档案检索出错误结果比搜不到更严重。我们的策略:置信度低于 0.6 不落文字层,宁可少搜到,不可搜错。
五、性能与成本参考
我们的实测口径(具体数据因机器和页面密度而异,仅供参考):
| 配置 | 单页耗时(300dpi A4) | 说明 |
|---|---|---|
| CPU(8 核)PaddleOCR | 约 2~4 秒/页 | 并发 4 路后约 1 秒/页折算 |
| GPU(单卡)PaddleOCR | 约 0.3~0.8 秒/页 | 适合 30 万页以上大项目 |
存储上,双层 PDF 相比纯图像 PDF 增量通常在 5%~15%(文字层是矢量文本,几乎不占空间),完全可接受。
六、方案 C 补充:给已有 PDF 补文字层
如果你手头已经是单层图像 PDF(比如从扫描仪直出的 PDF),用 PyMuPDF 补文字层更划算,不用重新渲染图像:
python
import fitz # PyMuPDF
def add_text_layer(pdf_path: str, out_path: str, text_items: list) -> None:
"""text_items: [(x_pt, y_pt, font_size, text), ...],坐标已是 PDF 左下原点"""
doc = fitz.open(pdf_path)
page = doc[0]
for x, y, size, text in text_items:
page.insert_text(
fitz.Point(x, y), text,
fontname="china-s", fontsize=size,
render_mode=3, # 隐形
overlay=True,
)
doc.save(out_path)
注意 PyMuPDF 的 china-s 内置字体同样是为了中文提取正确性。
七、小结
双层 PDF 是个"小功能、大讲究"的活儿:代码量不大,但坐标系、字体、置信度三个细节决定成败。总结成一句话口诀:
原图铺底,坐标翻转,中文字体,低置信度丢弃。
下一篇讲我们做过的一个 30 万页高校学籍档案数字化项目复盘------那里有一堆比技术更疼的坑。