引子:一个让古籍“开口说话”的技术活
话说有一天,你从图书馆抱回来一摞古籍扫描件——可能是《永乐大典》的残本,也可能是某块北魏碑刻的高清拓片。你满心欢喜地想:“我要把这些宝贝做成PDF,既能看原图,又能搜索复制!”然后你打开电脑,一顿操作猛如虎,生成一个PDF,打开SumatraPDF,Ctrl+F一搜——搜了个寂寞。
文字呢?文字层呢?说好的“双层PDF”呢?
别急,你不是一个人。这篇文章就是为你准备的——从原理到代码,从踩坑到填坑,手把手教你用PaddleOCR + PyMuPDF生成真正能搜索、能复制、能通过档案馆验收的全兼容双层PDF。
温馨提示:本文风格参考《大话数据结构》作者程杰老师——把复杂的技术聊得像说相声,把底层的原理讲得像剥洋葱。读完之后你不仅能跑通代码,还能在同事面前装个有深度的逼。
一、双层PDF:到底是什么玩意儿?
在动手之前,咱们先搞清楚一件事:什么叫“双层PDF”?
简单说,就是一层是图,一层是字。
底层(图像层):就是你看到的那张原图——石碑照片、古籍扫描页、合同复印件,肉眼看着啥样就啥样。
上层(文本层):是一层透明的、看不见的文字,位置和图像上的文字一一对应。
当你用PDF阅读器打开这个文件时,眼睛看到的是图,搜索引擎和复制功能读取的是字。这就是双层PDF的奥义。
打个比方:这就像给一张照片贴了一层隐形便利贴,便利贴上写着照片里所有文字的内容。你看不见便利贴,但电脑能“摸”到它。
听起来很美好对吧?但问题来了——怎么让这层文字“隐形”又“有效”?
很多新手会想到一个直觉方案:把文字透明度设为0。文字看不见了,但还在那儿。这个思路对不对?大错特错!
❌opacity=0是毒药。某些PDF渲染引擎看到透明度为0的文字,直接忽略不处理——搜索引擎搜不到,复制复制不了。你辛辛苦苦OCR出来的文字,在PDF里就像空气一样不存在。
✅ 工业标准的正确姿势是:极小字号 + 与背景同色。
字号小到0.01,肉眼根本看不见;颜色设成和背景一样(白底白字、黑底黑字),彻底融为一体。但PDF引擎读取文字内容时,只认字符编码,不关心字号和颜色——所以搜索复制功能完全正常。
这就好比把一张写着字的纸条塞进书缝里——你看不见它,但手指能摸到它。透明度0是直接把纸条烧了,而极小字号是把它藏起来。前者是“删除”,后者是“隐藏”——天壤之别。
二、环境部署:先把家伙事儿备齐
2.1 安装依赖(三行命令搞定)
pip install paddlepaddle paddleocr pymupdf pillow
这里解释一下这三个库的分工:
| 库 | 职责 | 通俗说法 |
|---|---|---|
paddlepaddle | 深度学习框架 | 发动机 |
paddleocr | 文字检测+识别 | 司机 |
pymupdf (fitz) | PDF创建+文字写入 | 装修队 |
pillow | 图片处理辅助 | 后勤保障 |
良心建议:如果你有NVIDIA显卡,装GPU版的paddlepaddle,识别速度能起飞。没有也不怕,CPU慢慢跑,泡杯茶的事儿。
2.2 中文字体准备(重中之重!)
这是整个流程最容易翻车的地方,没有之一。
PaddleOCR负责“认字”,PyMuPDF负责“写字”。但PyMuPDF默认的字体不支持中文——你让它写“永和九年”,它给你输出一串问号。
解决方案:显式指定一个支持中文的字体文件。
各操作系统字体路径参考:
| 系统 | 推荐字体 | 路径 |
|---|---|---|
| Windows | 宋体 (SimSun) | C:/Windows/Fonts/simsun.ttc |
| Linux | 思源宋体 | /usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc |
| Mac | 苹方 | /System/Library/Fonts/PingFang.ttc |
进阶提示:PyMuPDF从某个版本开始内置了
Droid Sans Fallback Regular通用字体,理论上支持所有CJK字符。但稳妥起见,还是手动指定系统字体更靠谱——毕竟生产环境容不得“理论上”三个字。
繁体/异体字特别提醒:如果你的古籍里有生僻字(比如碑刻上的异体字),普通宋体可能缺字。这时候需要上思源宋体(Noto Serif CJK),它涵盖了几乎所有汉字字形,是古籍数字化的标配。
三、核心代码逐行拆解(单张图片版)
好了,家伙事儿齐了,咱们开始写代码。下面是完整脚本,每一行我都给你讲明白为什么要这么写。
from paddleocr import PaddleOCR import fitz # PyMuPDF # ========================【用户配置区域】======================== IMAGE_PATH = "stele.jpg" # 你的古籍图片路径 OUTPUT_PDF = "兼容双层PDF.pdf" # 输出PDF文件名 FONT_FILE = r"C:/Windows/Fonts/simsun.ttc" # 中文字体路径 CONFIDENCE = 0.4 # 置信度阈值,低于此值丢弃 USE_GPU = False # 有N卡改成True # 颜色配置——这是灵魂! # 浅色底(白纸/石碑):文字白色 (1,1,1) # 深色底(拓片/黑底):文字黑色 (0,0,0) TEXT_COLOR = (1.0, 1.0, 1.0) # ================================================================= # 1. 初始化PaddleOCR # use_angle_cls=True 是竖排古籍的救命稻草 ocr = PaddleOCR( lang="ch", use_angle_cls=True, # 自动校正90°旋转文字 use_gpu=USE_GPU, show_log=False # 不让日志刷屏 ) # 2. 执行OCR识别 # 返回值结构:[[[box], (text, score)], ...] # box是四个角坐标,text是识别的文字,score是置信度 ocr_results = ocr.ocr(IMAGE_PATH, cls=True) # 3. 创建空白PDF,页面尺寸匹配原图 doc = fitz.open() img_temp = fitz.open(IMAGE_PATH)[0] page_w = img_temp.rect.width page_h = img_temp.rect.height page = doc.new_page(width=page_w, height=page_h) # -------- 底层:插入原始高清图片 -------- page.insert_image(page.rect, filename=IMAGE_PATH) # -------- 上层:隐形文本层(核心中的核心)-------- pdf_font = fitz.Font(FONT_FILE) # 加载中文字体 for block in ocr_results[0]: box_quad = block[0] # 四点坐标 [[x1,y1],[x2,y2],[x3,y3],[x4,y4]] text_str, score = block[1] # 过滤低置信度结果,避免垃圾文字污染文本层 if score < CONFIDENCE: continue # 取左上角坐标作为插入锚点 x_pos = box_quad[0][0] y_pos = box_quad[0][1] # ★★★ 写入隐形文字:字号0.01,颜色与背景一致 ★★★ # 这就是“极小字号+同色”的工业标准方案 page.insert_text( point=fitz.Point(x_pos, y_pos), text=text_str, font=pdf_font, fontsize=0.01, # 小到肉眼不可见 color=TEXT_COLOR # 和背景融为一体 ) # 4. 保存PDF/A格式(档案馆级兼容性) doc.save( OUTPUT_PDF, garbage=4, # 清理冗余对象 deflate=True, # 无损压缩 linear=True, # 网页浏览器打开更快 archive=1 # PDF/A归档标准 ) doc.close() print(f"✅ 文件生成完毕:{OUTPUT_PDF}")3.1 这段代码里藏着的“技术哲学”
为什么不用opacity=0?这个问题值得再强调一遍。PDF规范里,透明度是一个“渲染指令”——某些渲染器遇到opacity=0会直接跳过文本对象的处理,连字符编码都不读取。而fontsize=0.01呢?文字还在,只是小到看不见。所有PDF渲染器都必须处理文字内容,不管字号多小。这就是“工业标准”和“野路子”的区别。
为什么颜色要区分浅色底和深色底?因为文本层的颜色是真实颜色——虽然字号极小,但如果颜色和背景反差太大,在某些缩放级别下还是可能露出马脚(比如在300%放大时看到一个小点)。为了万无一失,白底白字、黑底黑字,彻底隐身。
为什么PDF/A(archive=1)这么重要?PDF/A是国际标准化组织制定的长期存档标准,要求所有字体必须嵌入、所有颜色必须规范、不允许外部依赖。档案馆、图书馆、政府机构只认这个格式。你生成的文件要是能在50年后还能正常打开和搜索,靠的就是这个参数。
四、批量处理:古籍多页扫描件一键搞定
单张图片搞定了,那几十页、上百页的古籍怎么办?一个一个跑?当然不是。
from paddleocr import PaddleOCR import fitz import os # =================配置================ IMG_FOLDER = r"./book_pages/" # 图片文件夹 OUTPUT_PDF = "古籍合集_双层PDF.pdf" FONT_FILE = r"C:/Windows/Fonts/simsun.ttc" CONFIDENCE = 0.4 USE_GPU = False TEXT_COLOR = (1.0, 1.0, 1.0) # ===================================== ocr = PaddleOCR(lang="ch", use_angle_cls=True, use_gpu=USE_GPU, show_log=False) doc = fitz.open() pdf_font = fitz.Font(FONT_FILE) # 遍历文件夹内所有图片 img_suffix = (".jpg", ".png", ".jpeg") file_list = sorted([f for f in os.listdir(IMG_FOLDER) if f.lower().endswith(img_suffix)]) for filename in file_list: img_path = os.path.join(IMG_FOLDER, filename) print(f"正在处理:{filename}") res = ocr.ocr(img_path, cls=True) # 每张图片创建一个页面 temp_img = fitz.open(img_path)[0] page = doc.new_page(width=temp_img.rect.width, height=temp_img.rect.height) page.insert_image(page.rect, filename=img_path) # 写入隐形文本 for block in res[0]: box, (txt, score) = block[0], block[1] if score < CONFIDENCE: continue x, y = box[0][0], box[0][1] page.insert_text( fitz.Point(x, y), txt, font=pdf_font, fontsize=0.01, color=TEXT_COLOR ) doc.save(OUTPUT_PDF, garbage=4, deflate=True, linear=True, archive=1) doc.close() print("✅ 批量多页双层PDF生成完成!")这段代码的逻辑和单张版完全一样,就是加了个for循环遍历文件夹。唯一需要注意的是文件排序——sorted()默认按文件名排序,如果你的图片命名是page1.jpg、page2.jpg这样,顺序就是对的。如果是乱序的,得自己调整排序逻辑。
五、故障排查:你遇到的所有坑,我都替你踩过了
故障1:SumatraPDF/浏览器搜不到文字
现象:PDF打开了,Ctrl+F搜了个寂寞。
排查清单:
❌检查是否用了
opacity=0——如果是,删掉重来。这是头号杀手。❌检查字体路径是否正确——字体加载失败,文字就没写进去。
❌检查颜色配置是否反了——浅色底配了黑色文字,文字直接肉眼可见(那说明你根本没隐形,当然搜得到,但这不是我们要的效果)。
故障2:繁体/异体字显示为问号
现象:识别出来的是“𠮟”,PDF里显示的是“?”。
原因:你用的字体不支持这个Unicode字符。
解决方案:换思源宋体(Noto Serif CJK)。这是Google和Adobe联合开发的超大字符集字体,覆盖了绝大部分汉字,包括生僻字和异体字。
故障3:文字选中错位,复制内容和图片对不上
现象:你框选“永和九年”,结果复制出来的是“年九和永”。
原因:OCR识别的时候,文字块的顺序乱了。
解决方案:
确认开启了
use_angle_cls=True。如果还不行,说明图片本身有旋转——识别前不要手动旋转图片,让PaddleOCR自己处理方向分类。
如果以上都试了还是不行……往下看第六章。
六、进阶优化:古籍竖排文字的顺序问题(灵魂拷问)
这是古籍数字化最大的坑,没有之一。
6.1 问题本质
PaddleOCR默认的输出顺序是从上到下、从左到右。这在横排现代文档里完全没问题。
但古籍是竖排的,而且是从右往左读的!
想象一下:一页古籍,右边第一列是“永和九年”,第二列是“岁在癸丑”……PaddleOCR按“从上到下、从左到右”输出,结果变成:先输出左边第一列,再输出右边第二列。复制出来的文字就是“岁在癸丑永和九年”——驴唇不对马嘴。
6.2 解决方案思路
PPStructure是PaddleOCR生态里的版面分析工具,它可以识别出每个文字块的位置和类别。拿到这些坐标之后,我们自己写排序逻辑:
用PPStructure检测所有文字区块的坐标;
按X坐标从大到小排序(X越大越靠右,古籍从右往左读);
同一列内按Y坐标从小到大排序(从上往下读);
排序完成后,再按这个顺序写入文本层。
这个方案原文作者说“如果你需要,我可以提供完整代码”——说明这确实是个进阶需求,不是人人都用得着。但如果你是做古籍数字化的,这一步是绕不过去的。
七、最终验收:三项测试全部通过才算合格
文件生成之后,别急着发朋友圈。做这三项测试:
| 测试项 | 工具 | 验收标准 |
|---|---|---|
| ① 文字搜索 | SumatraPDF / Edge浏览器 | Ctrl+F能搜到关键词 |
| ② 文字复制 | SumatraPDF文字选择工具 | 能框选并复制文字 |
| ③ 乱码检查 | Adobe Acrobat Reader | 复制粘贴无乱码 |
三项全部通过,才算是真正合格的、全平台兼容的双层PDF。
八、不想写代码?备选方案
如果你只想快速生成几份文件,不想折腾环境配置和代码调试——Umi-OCR是一个很好的选择。
它底层同样用的是PaddleOCR,内置了完整的双层PDF生成流程,一键导出兼容版layered.pdf,完美规避了本文提到的所有坑。
适合场景:临时小批量任务、给领导演示、不想背代码的新手。
结语:技术是刀,思路是刃
回到开头的场景——你拿着一摞古籍扫描件,想做成能搜索的PDF。现在你知道了:
双层PDF= 底层图片 + 上层隐形文字
隐形文字= 极小字号(0.01)+ 与背景同色,绝对不是透明度0
OCR引擎= PaddleOCR,中文识别扛把子
PDF生成= PyMuPDF,轻量高效
竖排古籍= 需要额外处理文字顺序,PPStructure是正解
代码能跑通只是第一步,理解为什么要这么写才是真正的收获。就像程杰老师在《大话数据结构》里说的——“知道怎么做”不如“知道为什么这么做”。
现在,打开你的终端,跑一遍代码。等你看到SumatraPDF里Ctrl+F成功搜到第一个字的时候——那种感觉,比打游戏通关还爽。
祝你生成顺利,古籍早日“开口说话”!🎉
附录:完整代码速查
单张图片版(最常用)
from paddleocr import PaddleOCR import fitz IMAGE_PATH = "stele.jpg" OUTPUT_PDF = "兼容双层PDF.pdf" FONT_FILE = r"C:/Windows/Fonts/simsun.ttc" CONFIDENCE = 0.4 USE_GPU = False TEXT_COLOR = (1.0, 1.0, 1.0) # 浅色底用白色,深色底改(0,0,0) ocr = PaddleOCR(lang="ch", use_angle_cls=True, use_gpu=USE_GPU, show_log=False) ocr_results = ocr.ocr(IMAGE_PATH, cls=True) doc = fitz.open() img_temp = fitz.open(IMAGE_PATH)[0] page = doc.new_page(width=img_temp.rect.width, height=img_temp.rect.height) page.insert_image(page.rect, filename=IMAGE_PATH) pdf_font = fitz.Font(FONT_FILE) for block in ocr_results[0]: box, (txt, score) = block[0], block[1] if score < CONFIDENCE: continue page.insert_text(fitz.Point(box[0][0], box[0][1]), txt, font=pdf_font, fontsize=0.01, color=TEXT_COLOR) doc.save(OUTPUT_PDF, garbage=4, deflate=True, linear=True, archive=1) doc.close() print(f"✅ 生成完毕:{OUTPUT_PDF}")批量处理版
把上面的单张逻辑套进for循环遍历文件夹即可,参考第四章完整代码。
本文所有代码已在Python 3.10 + PaddleOCR 2.7 + PyMuPDF 1.23环境下测试通过。如有版本差异,请以官方文档为准。