简介:这是一款基于PaddleOCR识别引擎的批量转双层PDF工具,适合档案数字化、电子书制作及日常文档管理场景,帮助用户将大量纸质扫描版PDF文档快速转换成可检索、可复制文本的双层PDF。压缩包共包含1075个文件,大小约129.99MB,除核心Python脚本及程序主入口外,还集成了PaddleOCR模型文件、动态库、依赖组件及时区等配置数据,可视为一套完整可运行的工具包。双层PDF由上层原始图像与下层OCR识别文本组成,既能100%保留原始版面,又便于建立索引数据库,实现对扫描文档的科学管理,同时自动具备去污、纠偏等图像预处理能力。工具可批量识别指定文件夹内的PDF,据资源作者说明,在中文及手写内容上均有较好的识别表现。当前已有239人学习使用,适合需要批量处理扫描PDF并追求中文识别效果的个人用户与团队。
1. 批量转双层PDF:扫描件归档最容易被低估的一步
搞档案数字化、合同归档、论文扫描件整理的朋友,大概率都遇到过这种局面:一摞扫描好的PDF,肉眼看得清清楚楚,但想搜个关键词、复制一段条款,完全无从下手,只能一页页翻。批量转双层PDF解决的正是这个“能看不能查”的问题。所谓双层,就是在一页PDF里同时存一层原始扫描图像和一层看不见的文字:视觉上还是原图,鼠标选中、全文搜索走的却是文字层。V1.0这个方向真正值得投入的地方在于,它不是把单个文件转好就完事,而是要把成百上千个扫描件批量变成“可搜索、体积不膨胀、版式不变”的归档文件。而这件事最容易被低估的一点是:难点不在调通一个文件的转换,而在成批处理时怎么保证500个文件里没有一个“假双层”。
2. 双层PDF的底层原理:图像层、透明文本层与坐标对齐
2.1 一个双层PDF页面的内部结构
把双层PDF拆开看,每个页面对象里其实叠着两层内容流。底层是一张完整的扫描图像,通常以DCTDecode或JPX格式内嵌,负责还原原稿的每一处细节,包括印章、折痕、手写批注;上层是一段文本绘制指令,按词或按行把OCR识别出的文字写在与原图对应位置相同的坐标上。阅读器渲染时,底层图像先画满整页,上层文字再叠上去;由于上层文字颜色极淡或透明度趋近于零,肉眼只会看到扫描件本身。
搜索和复制走的是上层文本。PDF规范里文本对象带字符编码和ToUnicode映射,全文搜索时阅读器读的是映射后的Unicode字符,所以即使文字那一层是透明的,照样能被搜到。要注意一点:文本层只是为了让文字“可检索、可选中”,它不需要承担重新排版的任务,因此OCR出来的每个词按原坐标摆放即可,不需要恢复段落结构。这也是双层PDF能保持版式不变的关键——上层文字不会参与文档重排。常见的翻车案例是误把双层PDF理解成“纯文本PDF”,试图让文字层参与流式排版,结果一改字号整页错位。
2.2 三种实现方案对比:为什么透明文本层最稳
常见做法是三种:透明文本层方案、把OCR文字直接涂抹回图像上的方案、重建纯文本PDF的方案。我一般会直接选第一种,理由可以看这张对比表:
| 方案 | 视觉保真 | 可搜索 | 可复制 | 文件体积 | 主要坑点 |
|---|---|---|---|---|---|
| 透明文本层 | 高,维持原扫描件原貌 | 支持 | 支持 | 增加约5-10%文本流 | 坐标对齐、中文字体嵌入 |
| OCR文字重绘到图像 | 低,文字覆盖印章或底纹时明显 | 视情况 | 不保留文本 | 明显增大 | 画上去容易擦不掉 |
| 重建纯文本PDF | 低,丢版式、签名、盖章 | 支持 | 支持 | 最小 | 无法用于法律效力归档 |
透明文本层的优势在于图像层完全不动,文字层是“挂在旁边的索引”,阅读器和搜索引擎都能利用它,打印时还能选择隐藏文本层。重建纯文本PDF虽然文件小,但会把扫描件变成了“看起来像文档”的东西,版式基本靠运气,碰到公章、手写签名就直接失真,归档场景一般不接受。重绘方案更是亏:既破坏了原图,又没真正保留文本层。所以凡是跟归档沾边的场景,透明文本层都是最可靠的落点。
2.3 坐标对齐规则:原点、MediaBox与页面旋转
坐标对齐是双层PDF里最核心的细节,也是最容易出错的地方。PDF的坐标系原点在左下角,y轴向上;而OCR引擎拿到的渲染图像坐标系原点在左上角,y轴向下,两者的换算关系是:pdf_y = page_height_pt - (ocr_top_px + ocr_height_px) * 72 / dpi。这里的page_height_pt来自PDF页面的MediaBox尺寸,不是渲染图片的像素高度。很多人图省事直接用图片的像素高度做换算,扫描件页面尺寸稍有非标准规格,文本层就会整体偏移一行或半行。
坐标系外另一个坑是页面旋转标记/Rotate。如果原PDF某个页面带了90度或270度的旋转标记,而OCR引擎拿到的是“旋转前”的像素图,那坐标换算出来的文本层位置就全乱了。常见做法是先把旋转合并掉:在资源渲染阶段检测page.rotation,非0时先调用page.set_rotation(0)并重新渲染,保证OCR输入和最终PDF的显示方向一致,再按MediaBox做坐标换算。字号方面,OCR返回每个词的bounding box,文本层字号可以取词框高度换算成pt后乘以1.05到1.25的系数,稍微放大一点,方便阅读器做框选;不放大也行,只是选中范围会显得偏小。
3. 跑通单文件:从PDF到双层PDF的最小Python脚本
3.1 环境准备与依赖清单
在动手之前,先明确一下工具链分工:渲染和图像层生成用PyMuPDF,OCR识别用Tesseract引擎加pytesseract封装,文本层PDF生成用reportlab,最后合并用pypdf。这套组合每个环节都是可替换的,好处是出了问题能定位到具体环节,不至于整个黑匣子一起调。
pip install pymupdf pytesseract pillow reportlab pypdf系统层面还需要安装Tesseract本体和中文语言包。Debian系执行apt install tesseract-ocr tesseract-ocr-chi-sim,macOS这边常见做法是brew install tesseract tesseract-lang。安装完可以用tesseract --list-langs确认chi_sim已经注册。这里有一步很容易遗漏:reportlab要写中文文本,必须注册CID字体,代码里会用一个名为STSong-Light的内置字体,它不需要字体文件,但需要显式registerFont,否则文本层中文全部变成方块。
3.2 第一步:把扫描页渲染成高分辨率图像
OCR的识别质量直接取决于渲染分辨率,经验值300DPI起步,文字密集或字号偏小时建议400DPI。DPI越高,OCR越准,但渲染时间和图像内存也线性增长。v1.0默认300就够了,识别置信度过低时再把DPI调上去重识别。
import fitz doc = fitz.open("input.pdf") page = doc[0] # 注意用 MediaBox 的宽高,单位是 pt,后面坐标换算全靠它 pw, ph = page.rect.width, page.rect.height zoom = 300 / 72 mat = fitz.Matrix(zoom, zoom) pix = page.get_pixmap(matrix=mat) pix.save("/tmp/page0.png")page.rect反映的是页面显示方向下的实际宽高,单位pt;zoom = 300 / 72把PDF的1磅换算成300/72像素。这里不要用page.get_pixmap(dpi=300)的便捷写法,因为后面的坐标换算统一走72 / dpi这个系数,自己维护会清晰一些。渲染后的PNG在后续合并完成后可以直接删掉,不用长期保留。
3.3 第二步:Tesseract OCR并提取单词坐标
pytesseract的image_to_data能返回每个词的文本内容、置信度和四个坐标值。调用时的两个参数值得反复调:--psm和--oem。默认PSM 3适合带标题和正文的混合版面;如果整页就是工整的单栏正文,PSM 6更稳;PSM 7只适合单行文本,一般归档用不到。--oem 1是纯LSTM引擎,中文识别速度和质量均衡。
import pytesseract from PIL import Image ocr_data = pytesseract.image_to_data( Image.open("/tmp/page0.png"), lang="chi_sim+eng", config="--psm 3 --oem 1", output_type=pytesseract.Output.DICT ) words = [] for i in range(len(ocr_data["text"])): text = ocr_data["text"][i].strip() if not text: continue conf = float(ocr_data["conf"][i]) if conf < 40: # 低置信度词宁可不要,也不要放错位置干扰搜索 continue x = ocr_data["left"][i] * 72 / 300 y = ph - (ocr_data["top"][i] + ocr_data["height"][i]) * 72 / 300 size = max(ocr_data["height"][i] * 72 / 300 * 1.15, 8) words.append({"text": text, "x": x, "y": y, "size": size})ph来自上一个代码块里page.rect.height,这里再次强调不要用PNG像素高度。置信度阈值40是经验值,扫描件质量好可以降到30,但批量处理时宁高勿低,不然错误文本会造成搜索误导。lang="chi_sim+eng"表示中英混合识别,如果资料纯中文,去掉+eng能稍微提速,但大多数扫描件里多少带点英文数字,建议保留。
3.4 第三步:用reportlab生成透明文本层
文本层是一个独立的单页PDF,页面尺寸必须和原PDF完全一致,否则后续合并时会触发缩放或裁剪。颜色这里有个取舍:不要用alpha完全等于0的透明色,某些阅读器会把“完全透明的文本对象”当成不绘制,导致页面看着正常但搜不到文字。常见做法是把颜色设为白色,alpha给一个极小值如0.01,视觉上不可见,各阅读器都能正常检索。
from reportlab.pdfgen import canvas from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.cidfonts import UnicodeCIDFont pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light")) c = canvas.Canvas("/tmp/layer.pdf", pagesize=(pw, ph)) c.setFillColorRGB(1, 1, 1, alpha=0.01) for w in words: c.setFont("STSong-Light", w["size"]) c.drawString(w["x"], w["y"], w["text"]) c.showPage() c.save()STSong-Light是reportlab内置的Adobe CID字体,不需要额外下载字体文件,中文场景可以直接用。drawString的y坐标是文本基线的位置,所以前面换算时用ocr_top + ocr_height而不是单纯ocr_top,算出来的y才贴近文字底部。文本层暂时不按行合并,保持“每词一个drawString”最安全,后续如果发现选中时文字断断续续,可以再把同一水平线的词拼成一行,减少PDF文本对象数量。
3.5 第四步:生成图像层PDF并合并
图像层不能直接把扫描图渲染成PDF就完事,页面尺寸必须按原PDF的MediaBox创建,否则后面合并时pypdf会默认以第一个页面的尺寸为基准,文本层坐标就漂了。先创建一个和原页面一样尺寸的PDF页,把PNG铺满,再把3.4生成的文本层用merge_page叠加上去。
img_pdf = fitz.open() img_page = img_pdf.new_page(width=pw, height=ph) img_page.insert_image(img_page.rect, filename="/tmp/page0.png") img_pdf.save("/tmp/image_layer.pdf") from pypdf import PdfReader, PdfWriter base = PdfReader("/tmp/image_layer.pdf") overlay = PdfReader("/tmp/layer.pdf") writer = PdfWriter() for i in range(len(base.pages)): bp = base.pages[i] if i < len(overlay.pages): bp.merge_page(overlay.pages[i]) writer.add_page(bp) with open("output.pdf", "wb") as f: writer.write(f)merge_page会把文本层的内容叠加到图像层的页面上,pypdf内部会先匹配两个页面的MediaBox。只要两边尺寸一致,这一步不会做任何缩放,坐标保持不变。多页扫描件就循环渲染每一页、为每一页生成独立文本层,再逐页合并。到此,一个单页扫描PDF已经是可搜索的双层PDF了。
3.6 参数速查:psm、oem、lang、DPI怎么设
给一个能直接抄的参数建议表,批量处理前先拿三五页扫描件试跑一遍,再决定要不要改默认值:
| 参数 | 建议值 | 应用场景 |
|---|---|---|
| --psm 3 | 默认 | 标题、正文、页眉页脚混合的常见版面 |
| --psm 6 | 替换 | 单栏正文、文字工整的扫描件 |
| --oem 1 | 默认 | 纯LSTM,中文识别质量和速度均衡 |
| --oem 3 | 备选 | LSTM+Legacy组合,遇到识别结果诡异时试试 |
| lang | chi_sim+eng | 绝大多数中文扫描件 |
| DPI | 300起步 | 文字密集或小字号时才升到400 |
测试判断标准就两条:第一,原图中肉眼能看清的字,识别出来的文本是否都存在于输出PDF的搜索命中里;第二,鼠标框选文字时,高亮色块是否紧贴原图对应字的位置。两条都通过,再放全量批量跑。
4. 批量改造:目录遍历、并行加速与断点续跑
4.1 目录映射规则:别把几百个文件堆进一个目录
批量操作的第一件事不是写并行,而是定好输入输出目录映射。常见做法是保留相对路径结构:输入目录scan_2025/,输出目录searchable_2025/,每个输入文件对应一个输出路径,路径前缀替换但相对子目录不变。这样做的好处是万一某个文件转坏了,能直接顺着原路径找到源文件重新处理;同时避免不同子目录下重名文件互相覆盖。
from pathlib import Path input_root = Path("/data/scan") output_root = Path("/data/searchable") input_files = sorted(input_root.rglob("*.pdf")) for src in input_files: rel = src.relative_to(input_root) dst = output_root / rel.with_suffix(".pdf") dst.parent.mkdir(parents=True, exist_ok=True)rglob("*.pdf")递归匹配所有子目录,with_suffix(".pdf")保留原后缀名。如果源文件名带空格或中文,这段代码天然支持,不用额外处理。mkdir(parents=True, exist_ok=True)要先于转换逻辑执行,否则进程池并发时多个worker同时创建目录可能抛异常。
4.2 并行方式选择:进程池而不是线程池
OCR是CPU密集型操作,尤其300DPI渲染和Tesseract识别都会吃满CPU,Python多线程在这种场景下受GIL限制,加速效果远不如进程池。v1.0的批量并行用ProcessPoolExecutor,按“文件”粒度分发任务,而不是按“页面”粒度。按文件分发的优点是单个PDF内部的页面顺序天然保序,且每个worker只需要打开一个文档,内存可控。
from concurrent.futures import ProcessPoolExecutor, as_completed def process_one(src: Path, dst: Path) -> dict: # 断点续跑:输出文件已存在且大于阈值,视为已完成 if dst.exists() and dst.stat().st_size > 100_000: return {"status": "skipped", "src": str(src)} # 先写临时文件,全部完成后再原子替换 tmp = dst.with_suffix(".tmp.pdf") try: convert_pdf(src, tmp) # 复用第3章里的流程,封装成函数 tmp.replace(dst) return {"status": "ok", "src": str(src)} except Exception as exc: return {"status": "failed", "src": str(src), "error": str(exc)} with ProcessPoolExecutor(max_workers=4) as pool: futures = { pool.submit(process_one, src, dst): src for src, dst in zip(input_files, output_paths) } for fut in as_completed(futures): result = fut.result() # 这里可以叠加进度条或日志,v1.0先写日志max_workers=4是起点值,而不是经验万能值。常见做法是设为os.cpu_count() - 1,但还要看内存:一个worker处理300DPI的A4扫描页,峰值内存大概200到400MB,16G内存的机器开6个没问题,8G机器建议4个。tmp.replace(dst)保证了断点续跑时检测到的文件永远是完整生成的,不会出现“输出文件存在但其实是半截”的情况。
4.3 日志、计数与失败清单:批量工具的保命三件套
批量跑几百个文件,最大的风险不是单个文件转失败,而是失败后找不到哪个文件失败、为什么失败。v1.0至少要在日志里记录三件事:源文件路径、输出路径、失败原因。更进一步,把失败文件单独写到一个failed.txt,处理完直接对照这个清单重跑。
import logging logging.basicConfig( filename="/data/searchable/batch.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" ) # 在 process_one 的返回值处统一收口 if result["status"] == "ok": logging.info(f"OK {result['src']}") elif result["status"] == "skipped": logging.info(f"SKIP {result['src']}") else: logging.error(f"FAIL {result['src']} {result.get('error')}")日志文件写在输出目录,这样每次批量处理的完整轨迹都跟着文件走。失败清单不要只记文件名,要带上异常信息,因为很多OCR异常,比如图片解码失败、PDF加密、页面尺寸异常,从异常信息里就能判断是整体调参还是单独处理。v1.0不需要做自动重试——批量场景下人工核对失败清单后调整参数再跑,比自动重试的确定性更高。
5. 批量转双层PDF避坑:五个高发翻车点与排查路径
5.1 转换后白屏或图像层消失
现象:输出PDF打开后整页空白,扫描图像没了。
原因:这个坑大多出在合并环节。图像层的页面尺寸和文本层的页面尺寸不一致时,比如图像层按A4创建但原扫描件实际是Letter,merge_page在叠加层时按目标页Mediabox做了裁剪或平移,文本层内容被画到了可见区域之外;部分阅读器在内容流渲染顺序异常时,整个页面会被跳过绘制。
解决:在第3章代码里把pw和ph写成从page.rect读取的常量,两处页面创建都用同一个变量。合并前再加一条断言,检查两个PDF的页面尺寸误差不超过1pt。批量版本里,遇到尺寸不一致的源文件时直接记入失败清单,不要硬转。
5.2 中文全部变成方块或乱码
现象:英文数字能搜到,中文要么搜不到,要么复制出来是一堆方块。
原因:reportlab默认字体是Helvetica,不支持中文。不注册CID字体就画中文,PDF里写入的既不是Unicode也没有ToUnicode映射,阅读器只能按字形乱猜。
解决:pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light"))这一行必须有;每次setFont必须用STSong-Light,不能中间切回默认字体。检查方法很简单,生成文本层PDF后用pdftotext命令行工具提取文本,如果中文正常显示,说明字体链路已经通了。
5.3 文字能选中但整体错位半行
现象:框选文字时高亮区域与图像上的字在水平或垂直方向偏差明显,尤其页面下半部分更严重。
原因:坐标换算时用了渲染图像的像素高度,而不是MediaBox高度。渲染图高度是像素,原页面高度是pt,两者在非标准扫描尺寸下并不成固定比例;或者OCR坐标的top值没有加上height,导致y坐标取到了文字顶部而不是基线。
解决:统一使用page.rect.height做换算,代码写成y = ph - (top + height) * 72 / dpi。加一条自检逻辑:选一个页面上部的词和一个下部的词,拿PDF阅读器分别框选,确认高亮位置紧贴文字,两个区域都准了再放量跑。
5.4 批量跑到一半内存涨到几个GB
现象:前几十个文件正常,越往后越慢,内存占用持续上升,最终进程卡死或被杀。
原因:两个叠加因素——第3章代码里的pix、doc等对象在循环里没有显式释放,PyMuPDF对象累积;同时进程池的任务结果队列里堆积了大量返回值,主进程来不及处理。
解决:单文件处理函数里用with fitz.open(...)管理文档生命周期,pix.save之后立即del pix;ProcessPoolExecutor初始化时加max_tasks_per_child=50,让worker每处理50个文件后自动重启,释放一切隐性内存。主进程的as_completed循环里及时消费结果,不要把future对象堆积在列表里。
5.5 文档显示正常但搜索命中为空
现象:肉眼检查PDF文本层存在,但全文搜索一个原图里明确存在的词,什么都搜不到。
原因:文本层里文本的字符编码与ToUnicode映射缺失或错乱。这个坑常发生在纯透明色alpha=0的写法上;一些阅读器在渲染时会优化掉“不可见文本对象”,实际上等于没写入内容流。
解决:不要用alpha完全为0的颜色,统一setFillColorRGB(1, 1, 1, alpha=0.01)。验证时不要只靠肉眼框选,用pdfplumber或PyMuPDF把文本直接提取出来,和原图逐词对照。我在这件事上的血泪经验是:必须写一个自动校验脚本,抽查每页文本字符数是否超过阈值,低于阈值直接标记可疑文件。
6. 验证“真双层”的三个手段与v1.0下一版计划
6.1 写一个自动检查脚本:文本层和图像层必须同时存在
转完一批后,我不会只看进度条拉到100%就收工。最稳的自动检查是用PyMuPDF重新打开输出文件,统计每页的文本单词数和图像对象数,两者都大于0才说明页面不是“假双层”。
import fitz def verify_double_layer(pdf_path): doc = fitz.open(pdf_path) for no, page in enumerate(doc): words = page.get_text("words") images = page.get_images(full=True) if len(words) < 10: print(f"警告:第{no + 1}页文本层字符过少") if len(images) == 0: print(f"警告:第{no + 1}页缺少图像层") doc.close()文本层字符数阈值设10是底限,正常A4扫描页一页至少几十个词;低于10的基本可以断定OCR失败或文本层未正确合并。图像层数量为0则说明扫描图根本没进去。这个检查脚本应该放进批量任务的收尾流程,而不是靠抽查几页赌运气。
6.2 搜索验证与视觉保真对比
自动脚本确认两层都存在后,还有两道人工验证值得做。第一道是搜索:用PDF阅读器在全文档范围搜索一个原图里出现次数较多的词,比如合同标题里的核心词,确认命中数量和位置合理。第二道是视觉保真——把原PDF第一页和输出PDF第一页分别渲染成PNG,用PIL对比像素差异率,差异来源应该只有文本层那层几乎不可见的文字,正常差值在0.5%以下;如果差值过高,说明文本层的颜色或位置干扰到了视觉,需要回炉调整透明度或字号。
6.3 v1.0之后的优先级建议
这一版工具最该优先补的不是界面,而是三个实用能力:一是增量处理,利用输出文件的时间戳跳过已经转换过的文档,批量重跑时效率高很多;二是多语言自动检测,当前固定chi_sim+eng,碰到纯英文扫描件效率偏低,可以OCR前先用Tesseract的OSD模块快速判断主语言;三是失败重试队列,把failed.txt变成结构化任务清单,下次运行自动按清单重试,而不是人工挑出来手动跑。至于Web界面,我一般不建议优先做——命令行工具加参数化已经能覆盖绝大多数归档场景。
最后说一个我的个人习惯:每次批量结束,随机抽开头、中间、结尾各一页,框选一段文字复制出来,再放大图像看对应位置有没有被文本层盖住。这两步花不了两分钟,却是我在扫描件归档这件事上最值的投入。很多离谱的批量问题,比如某个扫描仪批次整体偏色导致文本层可见、某个文件夹的源文件其实是加密PDF,都能靠这个动作提前拦住。希望帮到你。
本文还有配套的精品资源,点击获取