简介:这款批量转双层PDF工具基于PaddleOCR模型,面向需要将扫描件、资料册或手写文档快速转化为可检索PDF的办公与档案管理人群,解决图像型PDF无法复制、查找不便的痛点。工具可批量识别指定文件夹内的PDF文件,在保留原始版面100%效果的同时生成上层图像、下层文字的双层结构,便于建立索引数据库;压缩包共1075个文件,容量约129.99MB,包含exe主程序、dll运行库、pyd与py脚本、pdmodel/pdiparams识别模型文件及txt说明等,还附带大量时区信息文件,解压后即可组合运行。目前已有237人学习下载;由于采用Paddle识别模型,对中文、手写体的识别率优于常见方案,无需逐页人工处理,可大幅提升中文PDF批量处理与长期存档检索效率。 前阵子朋友丢给我一批合同扫描件,说这些PDF在电脑里没法搜索、没法复制文字,问能不能批量做成双层PDF。我当时第一反应是:单份转双层PDF工具网上一大把,但要一次性处理整个文件夹,还要保证文字层位置准确,这事就没那么简单了。于是花了几天时间搞了个小工具,也就是标题里的"批量转双层PDF工具v1.0"。这篇文章就把整个项目从需求拆解、技术选型到实际踩坑的过程完整记录下来,给同样被扫描件折磨的朋友一个可以抄的作业。
1. 为什么扫描件必须转成双层PDF:一次接单后的真切体会
1.1 双层PDF的底层逻辑
很多人第一次听到"双层PDF"会以为这是某种加密或特殊压缩格式,其实它没这么玄乎。一个双层PDF,简单说就是同一个页面里放了两层内容:底层是扫描得到的图片,保留原件的全部视觉细节;顶层是OCR识别出来的透明文字层,文字看不见但可以被鼠标选中、复制,也能被搜索引擎索引。
我在实际处理档案时感受特别深:你拿到的扫描件,本质上就是一页页图片拼起来的PDF。这种文件打印、传阅没问题,但只要涉及关键字检索、内容摘录,马上暴露短板。最痛苦的场景是律师调阅历史合同,几十个PDF文件堆在硬盘里,只能凭记忆逐份打开翻,效率极低。而双层PDF那个透明文字层,就是专门解决这个问题的——它能让人在保留原貌的基础上,把PDF当文本文件用。
1.2 为什么不能直接做纯文字OCR
有朋友会问:直接OCR识别成可编辑的纯文字PDF不行吗?这里要分清需求场景。纯文字PDF会将识别出来的文字重新排版成标准页面,好处是文件体积小、编辑方便,但坏处也很致命:排版完全丢失,遇到表格、手写批注、印章、复杂版式时会面目全非。档案、合同这类讲究"原样保留"的场景,绝不能容忍原稿版式被重排。
双层PDF的策略是"我全都要":底层图像保证100%还原原貌,顶层文字负责检索和复制,两层叠在一起视觉上只能看到图像层。这种方案在图书数字化、司法卷宗扫描、财务凭证归档这类行业里几乎是标配。我这次接的需求就是典型:对方要求所有页面必须保留原始手写痕迹和红章位置,但电脑里必须能按合同编号、当事人姓名、金额这些关键词搜到文件。
1.3 零散转换工具撑不起批量场景
需求定下来后,我第一反应是找现成的转换工具。市场上其实有不少能转双层PDF的软件,有的甚至做得不错,但问题全出在"批量"两个字上。单份文件用这些工具,点几下鼠标就行;一旦文件夹里躺着几百个扫描PDF,每个几十页甚至上百页,逐个打开、上传、等待、另存为,这个操作量会让人崩溃。
更麻烦的是,很多在线工具对文件有大小限制和数量限制,合同扫描件动辄上百MB,传到一半就断了;本地工具又往往需要在图形界面里一个一个拖拽。这种场景下最现实的方案就是自己写一个命令行或带简单界面的批量工具,把"扫描件到双层PDF"整条流水线串起来,丢进一个文件夹就等着收结果。这也是我开发v1.0的初衷。
2. v1.0的整体方案与工具选型:Python搭台,Tesseract唱戏
2.1 明确v1.0的功能边界
动手之前先把v1.0的功能范围定清楚。我不打算做一个大而全的商业级软件,只求解决实际卡脖子的点:
- 输入:指定一个文件夹,自动递归扫描其中的PDF文件(需要区分:已经带文字层的直接跳过,避免重复转换)
- 输出:转换完成后另存到指定目录,文件名保留原名加后缀,防止覆盖原始扫描件
- 批处理:多文件队列顺序执行,中间某个文件失败不中断整个任务
- 基础配置:OCR语言(中文简体/英文)、输出DPI、是否纠偏、是否跳过已处理文件
从这几条出发,整个项目的技术栈就非常清晰了:读取PDF图片层、调用OCR引擎识别、把识别结果写回PDF顶层。这三个环节各选一个靠谱的库,其余胶水代码自己写。
2.2 三个关键依赖的选型理由
PyMuPDF(即fitz):这是整个工具的地基。它负责读取原始PDF的页面图像,也负责最后生成带文字层的双层PDF。选它而不是pdfrw、reportlab这些老牌库,是因为PyMuPDF对PDF的操作颗粒度足够细——能直接把图片渲染成像素矩阵,也能把透明文字按精确坐标嵌到页面上,而且速度非常快。v1.0里我用它来"拆"和"合"两个阶段,一处代码复用,省心很多。
pytesseract + Tesseract OCR:OCR引擎的选择纠结过一阵。PaddleOCR的中文识别精度确实更好,但部署依赖相对重,对Windows环境不够友好;Tesseract虽然对中文长文本的识别率略逊于Paddle,但胜在轻量、跨平台、支持hOCR/TSV输出,而hOCR格式可以直接带坐标信息,这是双层PDF最需要的。v1.0先用Tesseract跑通流程,后续再评估要不要换引擎。
Pillow:图像预处理的必备库。扫描件直接进OCR识别,结果往往惨不忍睹,得先经过灰度化、二值化、降噪、纠偏这些处理,Pillow配合numpy做图像变换足够应付大部分场景。
2.3 顺带说一嘴WPS的问题
有人提过WPS批量转图公式这个热词,我特意去试了一下。WPS确实可以批处理图片、批量导出PDF页面为图片,但它没有内置"批量生成双层PDF"的功能。你可以用WPS把PDF导出成图片,再用其他工具把图片和OCR文字合成双层PDF,但本质上只是把WPS当转换器用,不是一步到位。
所以想彻底解决批量转双层PDF的需求,还是得靠自己的脚本。如果你不想装Python环境,也可以试试用WPS宏录制来处理单文件,但批量场景下稳定性远不如独立程序。vtool的逻辑恰好是绕开这类软件限制,把整个流程放在自己手里,可控性高很多。
3. 把流水线拆开:图像预处理、OCR识别、双层合成三步走
3.1 从原始PDF到页面图像:DPI取舍
第一步是把原始PDF的每个页面渲染成图片。这里有个关键参数:渲染DPI。DPI太低,OCR识别率下降;DPI太高,识别时间成倍增加,合成后的PDF体积也暴涨。我的测试结论是:打印体合同、书页这种清晰扫描件,200-300DPI足够;如果是手写体或模糊的历史档案,建议提升到350-400DPI。v1.0默认值设为300DPI,可以在配置里改。
用PyMuPDF渲染页面时,关键代码就几行:
import fitz doc = fitz.open(source_pdf) for page_num in range(len(doc)): page = doc[page_num] # 按300DPI缩放渲染成PNG格式的像素图 pix = page.get_pixmap(matrix=fitz.Matrix(300/72, 300/72), alpha=False) pix.save(f"page_{page_num:04d}.png")这段代码出来的图片就是OCR的输入。注意这里没有直接传原始PDF给Tesseract,因为OCR引擎吃的是像素图,不是PDF结构。
3.2 图像预处理:直接决定OCR天花板的环节
从扫描仪出来的原始图像几乎都有问题:底色发灰、局部倾斜、边缘带黑色边框、还有扫描时产生的噪点。如果跳过硬着头皮OCR,识别率会掉得让人怀疑人生。v1.0的预处理管线包含四步:
- 灰度化:去掉色彩信息,让前景文字和背景对比更明显。
- 降噪:用中值滤波去孤立噪点,但别用太强的模糊,否则文字边缘也会被抹掉。
- 二值化:灰度图转黑白图,阈值可以用Otsu算法自动算。注意不是所有图片都适合全局阈值,背景不均的图要用自适应阈值。
- 纠偏:用Hough变换找页面里最长的直线,估算旋转角度并做旋转校正。这一步对大幅倾斜的扫描件尤其关键,因为OCR识别时对文字的基线非常敏感。
有个细节要提醒:预处理参数并不是一个配置走天下。我v1.0里给每个步骤都留了开关,批量处理前先抽两三页测试调整,确认识别效果稳定再跑全量。
3.3 OCR识别:拿到带坐标的文字
Tesseract有两种主流输出格式适合这个项目:hOCR和TSV。两者都包含每个识别词块的边界框坐标,方便后续精确写入PDF。我用的是hOCR,因为XML结构更清晰,解析起来不容易出错。
调用pytesseract时,需要注意语言包。正常安装Tesseract时只带了eng,要识别简体中文还得装chi_sim语言包。具体命令是:
tesseract --list-langs如果输出里没有chi_sim,说明没装中文包。Windows下下载语言包放到tessdata目录即可,Linux下用包管理器安装。识别时这样调用:
import pytesseract from PIL import Image hocr_text = pytesseract.image_to_pdf_or_hocr( Image.open("page_0001.png"), # 预处理后的图 extension="hocr", # 输出hOCR格式 lang="chi_sim+eng", # 中英混排就都加上 config="--psm 4" # 自动检测页面布局 )这里--psm 4代表"假设页面包含单一列文本,自动检测列结构",对大部分合同扫描件表现稳定。如果是多栏排版的书页,可能得改用--psm 3(完全自动布局检测)。多试几种模式,选效果最好的。
3.4 双层合成:文字层和图像层怎么叠
这是整个工具的核心难点:拿到OCR识别的文字和坐标之后,如何把它们精确地叠回原始图像层上面。我的做法是先用PyMuPDF创建一个新PDF文档,把原始页面图像作为底贴上去,再解析hOCR里的每个文本框,在PDF页面的对应坐标位置插入不可见文字。
这里需要做一次坐标换算。hOCR里的坐标是基于页面图像尺寸的像素坐标,而PDF页面的坐标系统是点(1/72英寸)坐标。比如原始PDF页面渲染成300DPI的图片,图片宽度是2500像素,PDF页面本身的宽度可能是595点(A4),那么从像素坐标换算到PDF坐标的缩放系数就是595 / 2500 = 0.238。
核心合成逻辑大致如下:
import fitz from lxml import html doc = fitz.open() for page_name in page_list: page_rect = fitz.paper_rect("a4") page = doc.new_page(width=page_rect.width, height=page_rect.height) # 先贴底层图像 page.insert_image(page_rect, filename=f"{page_name}.png") # 解析hOCR,把文字插入到对应位置 tree = html.fromstring(open(f"{page_name}.hocr", encoding="utf-8").read()) for box in tree.xpath("//span[@class='ocrx_word']"): text = box.text_content().strip() if not text: continue # 读取像素坐标和大小 ocr_bbox = box.get("title") # bbox 10 15 100 35 格式 # 解析后统一点坐标系换算 x0, y0, x1, y1 = parse_bbox(ocr_bbox) # 写入透明文字,字体用Helvetica,字号按框高估算 page.insert_text( fitz.Point(x0_pt, y0_pt), text, fontname="helv", fontsize=font_size, render_mode=3 # 0表示透明不可见 )这里有个小技巧:PyMuPDF的render_mode=3会让文字完全透明,鼠标选中时却依然存在。这是双层PDF的关键参数。
3.5 批量任务调度与状态跟踪
流程单页跑通只是第一步,批量转换还得考虑任务编排。v1.0里我用了一个简单的循环:遍历输入目录,对每个PDF文件执行完整处理链,处理完写一个日志记录,包括文件名、页码数、OCR单词量、耗时、是否成功。失败的文件单独记到错误清单里,不会让整个任务中断。
另外一个容易被忽略的点是:PDF文件到底有没有文字层?如果输入文件本身就是带文字层的数字PDF,再转一次就是多此一举。v1.0里添加了一个检查逻辑:用page.get_text()遍历页面,如果文字内容足够多,就判定为已含文字层,跳过处理,只在日志里标注“已跳过”。
4. 批量处理中最容易翻车的五类问题,逐个说排查思路
这个工具从"单页能跑"到"批量稳定",我花了比预期更多的时间。问题不是出在核心流程,而是出在那些你平时根本预想不到的细节上。我把最典型的问题整理出来,给后面做同类项目的朋友提个醒。
4.1 内存爆炸:几百页PDF直接卡死
第一次跑全量转换时,我处理了一个80页的扫描PDF,到第50页左右程序内存占用直接飙升到2GB,最后操作系统开始疯狂交换,程序彻底卡死。
根因是我在循环里把每页渲染出来的大图像都保存在了内存里,没有及时释放。PyMuPDF的get_pixmap返回的像素对象在下次迭代时如果没被重新赋值,确实会被Python的GC回收,但我用了全局列表保存渲染结果,等着后面合成时再取,导致所有页面的图像被同时驻留内存。
解决方案是重构流程:每一页独立完成"渲染→预处理→OCR→合成"整条链路,处理完立即写入输出PDF并销毁中间对象,绝不在内存里囤积多页图像。具体操作就是把处理函数拆成单页处理单元,循环调用时显式调用del和gc.collect()。
4.2 中英文混排识别率突然下降
这批合同里有不少中英文混排的表格,尤其是地址栏、公司名那一栏,英文大写和中文紧挨着,Tesseract经常把英文识别成乱码或直接漏掉。
排查后发现两个问题。一是语言包组合顺序会影响识别结果,我试下来chi_sim+eng比eng+chi_sim效果更好,因为主体语言排前面,引擎会以中文为主、英文为辅来分词。二是--psm模式对混排表格不友好,后来改成--psm 6(自动检测表格块)以后,识别率提升了不少,代价是偶尔会把标题误判成表格,但在可接受范围内。
4.3 扫描歪斜导致文字层错位
OCR识别出的文字坐标是相对图像本身的,如果图像明显歪斜,坐标也歪。关键是预处理里做了纠偏,但纠偏后输出的图片尺寸会改变,如果不重新计算坐标映射,合成时文字层和图像层就对不齐。
我在v1.0里踩过这个坑:纠偏前图片是2000x3000像素,旋转15度后新图片变成2100x3100像素,边缘多出的部分会被填充成白色。但我一开始直接沿用了旧图片的坐标,结果所有文字位置偏移了50-100像素,选中文字时空格和中文对不上。修正方式是纠偏后重新获取新图片的宽度和高度,并在坐标换算时以新图片尺寸为基准。另外,旋转后边缘的白色填充区也要在合成前裁掉,否则双层PDF四周会出现白色边框,影响视觉还原。
4.4 输出PDF体积暴涨
一个原本20MB的扫描PDF,转完双层PDF后变成80MB,这不太正常。双层PDF理论上只增加很小的文字层,体积上涨主要来自图像渲染时DPI设太高、图像未压缩。
v1.0里我调了两处:一是渲染时DPI保持300,不再提高到400;二是用PyMuPDF插入图像时,显式设置压缩参数。PyMuPDF的insert_image有个filter="DCT"参数,相当于JPEG压缩,能显著降低体积。压缩质量设在85左右,肉眼观察基本无损,但文件体积能缩到原来的三分之一左右。
4.5 文件名和路径含中文导致编码报错
这算是老生常谈的坑了,但确实最容易忽略。我的输出目录设置在桌面,文件名是全中文合同编号,第一次跑全量时程序中途报错,弹出一串UnicodeDecodeError。原因是Windows下某些系统接口返回的路径字符串编码不是UTF-8,而Python默认读取环境变量时可能踩到GBK。
解决方法是在脚本开头统一处理路径编码:
import os import sys sys.stdout.reconfigure(encoding='utf-8') # 所有文件和路径操作都显式使用Path对象,避免直接拼字符串 from pathlib import Path input_dir = Path(r"E:\扫描件") output_dir = Path(r"E:\扫描件\双层输出")用pathlib.Path统一管理路径,并且所有打开文件的操作都显式声明encoding="utf-8",这个问题就再也没出现过。
5. 实测效果与下一步改造计划
5.1 批量处理的表现:稳定性是第一位
v1.0在测试机上跑了一个完整批次:150个扫描PDF,合计3000多个页面,耗时大概1小时40分钟,中途有2个文件因为原始PDF损坏处理失败,但整个任务没有中断,剩余文件全部正常完成。识别质量方面,我用25份合同做了抽查,印刷体部分的关键字搜索全部能命中,手写备注区域的识别率确实偏低,但手写内容本身在双层PDF里也不再是完全不可检索的状态,粗略能搜到个别字词。
处理速度上,平均每页耗时约2秒,大部分时间花在OCR引擎上,渲染和合成的时间几乎可以忽略。实测下来这个速度在可接受范围内,毕竟档案转存这种事情属于一次投入长期收益的活儿。
5.2 当前版本的限制和可扩展方向
v1.0只是一个命令行工具,没有图形界面,普通文员用起来还是有点门槛。我下一步打算做一个极简的GUI外壳,只保留三个控件:选择输入文件夹、选择输出文件夹、点击开始。另外OCR引擎方面,PaddleOCR的中文识别率确实比Tesseract高一截,我会计划增加一个可切换引擎的接口,默认用Tesseract,需要高精度时切换PaddleOCR。
对了,还有一个很实用的扩展点:双层PDF加书签目录。如果原始扫描件有目录页或章节标题,识别出来后可以自动生成PDF书签,这样检索效率又能提升一大截。目前v1.0还没做,但已经列进v1.1的计划表了。
5.3 给后来者的一句话建议
做批量转双层PDF这类工具,技术上真正的难点从来不是某个环节的代码写不出来,而是把各个环节串起来之后,面对真实场景里千奇百怪的扫描件,能不能保证整个流程稳定、不崩、不丢页。所以我的建议是:第一版宁可功能少,也要把错误处理和日志做扎实。批量处理的每个文件都尽量独立,一个文件失败绝不能让整个任务陪葬。这个原则帮我省下了大量重跑的时间。
最后再分享一个我在实际使用中的小技巧:如果原始扫描件的文件名本身包含了编号信息,可以利用PDF的元数据字段把编号写进去,后续检索时可以按元数据过滤。这个功能不复杂,但对于档案管理场景来说非常贴心。我的v1.0已经顺手加上了,算是一个意外的加分项。
本文还有配套的精品资源,点击获取