我接过一个需求:几百页扫描版 PDF,客户想直接把里面的文字抠出来。这种单子接多了,心里很清楚,光靠 PyPDF2 这类库去拉文本,遇到扫描件就是自讨苦吃——提出来全是一堆乱码,有时干脆是空集。后来我试过 PyMuPDF 渲染成图片,再交给 PaddleOCR 识别,效果好是好,可装环境的时候差点没把自己搭进去,又是 Python 包又是模型文件,一套下来一个项目目录比 PDF 本身还大好几倍。
折腾几轮之后,我反而回到一条看起来很“朴素”的路:用系统里两个最常见的命令行工具,PDF 先变图片,图片再做 OCR,Python 脚本全程只用标准库。我把这套流程叫零依赖方案,意思是代码层面不再碰任何第三方 PDF 库,渲染和识别全部交给系统命令。这篇文章就把完整的实践过程、关键参数、实测数据和踩过的坑都梳理一遍,给同样被 PDF 文档文字提取折磨的人一个能直接抄作业的思路。
1. PDF 文字提取为什么要先转图片?
1.1 PDF 里的文字,不是你想提就能提的
很多人下意识认为 “PDF 里能看到文字就一定能提取出来”,这个前提是最大的误会。PDF 本质是一个页面描述格式,里面存的是绘制指令:哪里放一个字形、字形编码是多少、颜色坐标是什么。电子版 PDF 往往有一个 ToUnicode 映射表,程序才能把内部字符编码还原成人类语言。但实际生产中的 PDF 经常没这么规范,尤其是一些老系统自动导出的报告、直接用“虚拟打印机”生成的打印版文件,字体子集只嵌入了字形 ID,字符到 Unicode 的映射完全缺失。你在屏幕上看着文字清清楚楚,一旦用程序去解析文本层,出来的就是乱码、空白或者一堆方框。
另一个头疼的问题是内容流碎片化。PDF 里一行文字经常被拆成十几个小块,中间还夹着坐标变换指令,文本顺序被打得七零八落。pdfplumber 这类库能处理一部分,但碰到复杂版式,要么顺序乱掉,要么把两栏文本串到同一行,后期清洗成本高得离谱。与其在 PDF 内部结构里逐条排查,不如把问题整个换一个维度:直接渲染成图片,让 OCR 引擎去“看图识字”。只要人眼能读出来的内容,OCR 大概率也能认出来,而且不需要理解 PDF 的内部格式。
1.2 扫描件和原生电子 PDF 是两回事
如果文件本身是扫描件,那问题就更彻底了。扫描件本质是 PDF 外壳里包着几张 JPEG 或 TIFF 图像,内部没有任何文本对象,只有像素点。这种文档你用什么 PDF 解析库都白搭,因为它们解析的都是文本流,而不是图片。扫描件在合同归档、图书数字化、老档案整理场景里太常见了,不转图做 OCR,基本没有别的路可走。
1.3 先转图再 OCR,代价最小、适用范围最广
“先转图再 OCR”还有一个隐藏好处:它能统一整个处理链路。你手里可能是扫描版 PDF、可能是拍歪了的照片合成 PDF、也可能只是一张截图,但只要最后都变成图片,后面流程完全一致。你不需要先判断“这个 PDF 到底有没有文本层”,直接统一过一遍图,能识别多少是多少,实在不行再用文本层精修。对来源不明的脏数据场景来说,这是最稳妥的兜底方案。
2. 零依赖方案的两个核心命令行工具
2.1 我说的“零依赖”到底是什么
先把概念说清楚,避免被骂标题党。严格讲,Tesseract 和 Poppler 都是外部软件,不可能做到系统层面一个依赖都没有。我这里说的零依赖,指的是 Python 代码层面不 import 任何第三方库:不用 PyPDF2、不用 pdfplumber、不用 PyMuPDF、不用 pytesseract,只靠subprocess、os、pathlib这些标准库去调度系统命令。渲染和识别都发生在 Python 进程之外,脚本只负责组织流程和读结果文件。
这个取舍在实际协作里特别有价值。以前同事跑我的 PDF 脚本,装 PyMuPDF 偶尔会遇到 wheel 权限问题,装 PaddleOCR 更是能折腾一整天,还要担心 CUDA 版本。现在只要系统里有pdftoppm和tesseract这两个命令,脚本传过去就能跑。运维同事也省心,poppler 和 tesseract 在主流发行版都有软件源,一条命令装完,不需要编译,不需要手动配 Python 环境。
2.2 pdftoppm:渲染 PDF 的标准利器
Poppler 是一个老牌开源 PDF 渲染库,它提供了一组命令行小工具,其中最常用的就是pdftoppm。它的作用是把 PDF 页面渲染成图片,基本用法:
pdftoppm -png -r 300 -f 1 -l 10 input.pdf output_prefix-png表示输出 PNG 格式,适合后续 OCR 分析;-r 300是渲染分辨率,单位为 DPI,后面实测时你会看到这个参数对识别率影响巨大;-f 1 -l 10是页码范围,不写就默认渲染所有页;output_prefix是输出文件名前缀,实际会生成output_prefix-1.png、output_prefix-2.png这种格式。
我对比过pdf2image这个 Python 库,发现它本质上就是pdftoppm的封装,只是帮你管理临时文件。既然底层还是 poppler,那直接subprocess.run调用命令反而少一层,出错时错误信息也更直接,不用钻到第三方库内部去猜它到底怎么调的。
2.3 Tesseract OCR:老牌但依然能打的识别引擎
图片生成之后,文字提取的重任落到 OCR 引擎上。开源 OCR 引擎里目前最普及的还是 Tesseract,从 1995 年一路发展到现在,已经被 Google 接手维护了很多年,对印刷体文本的识别能力非常成熟。命令行调用:
tesseract page-1.png page-1 -l eng --psm 6page-1是输出文件前缀,命令会生成page-1.txt。-l eng指定英文语言包,--psm 6用来自动判断页面布局,告诉引擎“假设图片是一个统一的文本块”。Tesseract 语言包需要额外安装,中英文混排可以写成-l chi_sim+eng,注意中间用加号连接。如果没有对应的语言包,命令会直接报错,这个细节我放在后面踩坑部分重点讲。
我选择 Tesseract 而不是 PaddleOCR,理由也很真实:PaddleOCR 中文识别精度确实更漂亮,但它要装 PaddlePaddle 全家桶,还要下载模型,和“零依赖”的目标完全相反。Tesseract 对印刷体已经足够用,而且系统包管理器里基本都有现成包,完美契合这个方案的定位。
2.4 为什么不直接用 PyMuPDF 或 PaddleOCR
有人肯定会问,PyMuPDF 渲染速度比pdftoppm快不少,为什么不选它?我的看法是:性能只是众多因素之一,PyMuPDF 是第三方 C 扩展库,AGPL 许可证在某些商业环境里可能触发法律问题,而且针对不同 Python 版本编译安装确实不够省心。PaddleOCR 更不必说,模型文件动辄几百兆,部署成本直接上一个台阶。
反过来看pdftoppm和tesseract,两个独立进程通过命令行参数和文件交互,任何一个环节崩了都能单独排查。这种边界清晰的模块化思路,比单纯追求单步性能更适合需要长期维护的批处理服务。
3. 从 PDF 到图片再到文字的完整脚本
3.1 需求明确:输入 PDF,输出一份完整文本
动手之前先定义输入输出。输入是一个 PDF 文件路径;输出是一个合并好的 UTF-8 文本文件,中间产生的 PNG 图片自动清理。为了保持脚本简单,我第一版先不搞分页精细控制,把整个 PDF 全部处理完,然后按页码顺序把每一页的 OCR 结果拼起来。
3.2 第一版代码:串行跑完
下面这段脚本是我在实际项目里的简化版,只依赖标准库:
import subprocess import tempfile import os from pathlib import Path def run_command(cmd): result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(f"命令失败: {' '.join(cmd)}\n{result.stderr}") return result.stdout def natural_sort_key(path): # pdftoppm 输出 page-1.png, page-2.png ... 需要按页码自然排序 return int(Path(path).stem.split("-")[-1]) def pdf_to_images(pdf_path, output_dir, dpi=300): prefix = Path(output_dir) / "page" run_command(["pdftoppm", "-png", "-r", str(dpi), str(pdf_path), str(prefix)]) images = list(Path(output_dir).glob("page-*.png")) images.sort(key=natural_sort_key) return images def ocr_image(image_path, lang="eng", psm="6"): txt_path = image_path.with_suffix("") run_command(["tesseract", str(image_path), str(txt_path), "-l", lang, "--psm", psm]) return txt_path.with_suffix(".txt") def process_pdf(pdf_path, output_dir, lang="eng", dpi=300): output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) final_txt = output_dir / "result.txt" # 使用临时目录放置中间图片,处理完自动清理 with tempfile.TemporaryDirectory() as tmpdir: images = pdf_to_images(pdf_path, tmpdir, dpi) for i, img in enumerate(images, 1): print(f"[INFO] 处理第 {i}/{len(images)} 页...") txt_file = ocr_image(img, lang) text = txt_file.read_text(encoding="utf-8") with final_txt.open("a", encoding="utf-8") as f: f.write(f"\n\n===== 第 {i} 页 =====\n\n") f.write(text) return final_txt if __name__ == "__main__": process_pdf("scanned.pdf", "output", lang="chi_sim+eng", dpi=300)流程很直接:pdftoppm把 PDF 渲染成多张 PNG,循环交给tesseract生成 txt,再按页码顺序写入 result.txt。TemporaryDirectory上下文结束时会自动删除临时图片,所以磁盘上只留最终结果。这里有个细节要注意:tesseract的输出 txt 文件是通过“输出前缀”生成的,你给它page-1.png,它会在同目录生成page-1.txt,所以代码里要通过with_suffix(".txt")去定位文本文件。
3.3 处理大文件时的改进:逐页转换
上面脚本有个隐患:一次性把几百页 PDF 全转成图片,内存和磁盘都受不了。我处理过一份 500 页的扫描件,300 DPI 生成的 PNG 加起来接近 3GB。改进办法是逐页处理,用pdftoppm -f 页号 -l 页号一次只转一页,转完立刻 OCR,OCR 完删掉图片。
import subprocess from pathlib import Path def process_single_page(pdf_path, page_num, tmpdir, lang, dpi): prefix = Path(tmpdir) / f"page_{page_num}" subprocess.run( ["pdftoppm", "-png", "-r", str(dpi), "-f", str(page_num), "-l", str(page_num), str(pdf_path), str(prefix)], check=True, capture_output=True, text=True ) images = list(Path(tmpdir).glob(f"page_{page_num}-*.png")) if not images: return "" img = images[0] txt_path = img.with_suffix("") subprocess.run( ["tesseract", str(img), str(txt_path), "-l", lang, "--psm", "6"], check=True, capture_output=True, text=True ) return txt_path.with_suffix(".txt").read_text(encoding="utf-8")虽然每页都要启动一次pdftoppm和tesseract进程,但内存占用从“全量转储”降到了“只保留一页”。在低配服务器上,这种内存占用上的降低比那点进程启动时间重要得多。
3.4 并发加速:多进程处理多页
如果你还是嫌弃速度,可以对页做多进程并发。OCR 是 CPU 密集型任务,用ThreadPoolExecutor没什么效果,得用ProcessPoolExecutor。经验是并发数不要超过 CPU 核心数,因为 Tesseract 自身就会用多核,开太多反而互相抢资源。
from concurrent.futures import ProcessPoolExecutor def process_one_page_wrapper(args): pdf_path, page_num, tmpdir, lang, dpi = args return page_num, process_single_page(pdf_path, page_num, tmpdir, lang, dpi) def get_pdf_page_count(pdf_path): info = subprocess.check_output(["pdfinfo", str(pdf_path)], text=True) for line in info.splitlines(): if line.startswith("Pages:"): return int(line.split(":")[1]) return 0pdfinfo也是 poppler-utils 自带的命令,用它拿页数比用 Python 解析 PDF 可靠得多。这个并发版本处理 50 页以上的文档时效果尤其明显,实测四核机器能快接近三倍。
4. 实测效果与关键参数调整
4.1 DPI 设置:300 是底线,不是情怀
不同 DPI 下 OCR 效果的差距非常直观,我专门做了一个小实验:
| DPI | 单页平均耗时 | 识别错误示例 | 体验结论 |
|---|---|---|---|
| 150 | 2.1s | “m” 和 “rn” 混、中文笔画粘连 | 不建议 |
| 200 | 2.8s | 小字号标点易丢失 | 勉强可用 |
| 300 | 4.5s | 很少出现字形混淆 | 推荐默认 |
| 450 | 8.7s | 稳定但速度明显下降 | 小字体才需要 |
| 600 | 15s | 稳定但文件体积爆炸 | 特殊需求才用 |
150 DPI 下错误率大概比 300 DPI 高 3 到 4 倍,而 300 到 600 DPI 的提升很有限。原因是 Tesseract 内部会做二值化和连通域分析,分辨率太低时笔画容易粘在一起,分辨率太高又会产生大量无用细节。我的建议很简单:普通印刷文本设 300,如果原文是小五号字甚至更小,临时调到 400。别一上来就 600,否则你会在等待中怀疑人生。
4.2 语言包怎么选
Tesseract 默认只带英文,想识别中文必须装语言包。常见系统安装方式:
- macOS:
brew install tesseract-lang,装完全部语言包; - Ubuntu/Debian:
sudo apt install tesseract-ocr-chi-sim; - CentOS/RHEL:需要 EPEL 源,然后
yum install tesseract-langpack-chi_sim; - Windows:从 GitHub 下载 tesseract 安装包,把语言包放到
tessdata目录。
调用时用-l chi_sim识别纯中文,用-l chi_sim+eng识别中英文混排。混排模式识别准确率会略低于单一语言模式,因为引擎需要在两套字符集里做选择。所以如果 PDF 是纯中文技术手册,老实写chi_sim,别为了省事写eng+chi_sim,效果反而更差。
4.3 页面背景和水印的影响
纯白黑印刷体是 Tesseract 最舒适的工作区。如果原 PDF 是彩色背景,比如淡黄色底纹、反白字、水印叠字,OCR 会把背景噪声也当成内容,识别率明显下降。这时候可以尝试给pdftoppm加-gray参数直接生成灰度图,去掉彩色信息干扰;或者依赖 Tesseract 默认的自适应二值化,有时能救回来一部分。如果背景复杂到连灰度图都救不回来,就得引入 OpenCV 做形态学处理了,那就和零依赖的目标相违背。所以我在实际脚本里默认加了-gray,它几乎无损,还能让 PNG 体积更小。
4.4 表格、公式和特殊版式
表格线会让 Tesseract 把边框识别成字符,输出排版乱成一团。针对纯表格文档,可以试试--psm 4,它假设输入是“单列可变大小文本”,在某些场景下比--psm 6好。但公式里的上下标基本无解,Tesseract 本质是文本识别引擎,不是公式识别器。如果 PDF 里满屏数学公式,建议直接换专业工具,别指望这个方案一条龙。这套方案最适合的还是合同、报告、书页这类以自然语言段落为主的文档。
5. 这个方案与常见替代方案的对比
5.1 先祭出 pdftotext:白嫖原生文本层
很多人不知道 poppler-utils 里还有pdftotext,它可以直接提取电子版 PDF 的文本层,速度快到毫秒级,而且输出顺序通常比 OCR 更准确。所以我的完整流程里,第一步永远是先跑pdftotext:
pdftotext -layout input.pdf output.txt-layout参数会尽量保留原始排版。如果输出是乱码或只有几行空白,再上“转图片 + OCR”方案。把这两个工具组合起来,性价比最高。这也是零依赖思想的延伸:同一套系统命令,能用的都用上,不要傻傻只走一条路。
5.2 本地 OCR 和云 OCR 的取舍
云 OCR 服务识别率高,尤其对复杂表格和手写体,但把公司合同、内部技术文档传到第三方服务器,很多人心里不踏实。本地方案把数据留在本机,没有网络请求,对隐私敏感的文档更安全。成本上,云 OCR 按量计费,处理一千页可能需要几十到几百块,本地方案只要机器能跑,基本零成本。代价是你得自己承担环境维护和识别率调节的成本,不能甩锅给厂商。
5.3 和 pdf2image + pytesseract 的对比
早期我也用过pdf2image配pytesseract,后来逐步换掉了。原因很简单:这些库只是命令行工具的包装,包装层越多,排查问题越麻烦。pdf2image超时后,你得去看它怎么调用pdftoppm;pytesseract报错时,还得担心它内部版本和系统 Tesseract 是否匹配。直接用subprocess调系统命令,错误信息原样打印,是路径问题、语言包缺失还是文件权限问题,一眼就能定位。
我的选择建议是:如果写一次性小工具,怎么省事怎么来;如果要做长期运行的批处理服务,选依赖更少、边界更清晰的方案。
6. 踩坑记录:我实际遇到的 5 个问题
6.1 命令找不到,PATH 里什么都没有
在 Windows 服务器上部署时,我遇到的第一个坑就是subprocess.run(["pdftoppm", ...])直接抛FileNotFoundError。poppler 在 Windows 上没有官方安装包,通常是从第三方站点下载压缩包解压,里面是Library/bin/pdftoppm.exe,必须把这个目录写进系统 PATH。macOS 上用 Homebrew 装 poppler 一般没有这个问题,但 Linux 的某些精简容器里可能没装,所以脚本运行前最好先执行which pdftoppm确认一遍。
6.2 图片文件名乱序,OCR 输出页序全反
pdftoppm生成的图片命名是page-1.png、page-2.png、一直到page-10.png。如果直接用字符串排序,page-10.png会排在page-2.png前面,超过 9 页的文档必然出错。这个坑必须用自然排序解决:
images.sort(key=lambda p: int(p.stem.split("-")[-1]))我在 3.2 节代码里专门写natural_sort_key就是这个原因。小细节,但坑起来是真的坑。
6.3 识别结果空白或全是乱码
最常见原因是语言包没装,其次是--psm参数不合理。我之前识别一个两栏论文,默认--psm 6把两栏文字当成连续文本,输出句子错乱不堪。后来改用--psm 3,让引擎全自动检测页面分割,才算正常。实用技巧是:拿到一张图片先跑一遍--psm 3,看输出效果再针对性微调,别一上来就迷信某个固定参数。
6.4 临时文件占满磁盘
用 300 DPI 渲染 100 页 PDF,每张 PNG 约 2 到 5 MB,中间文件总量轻轻松松超过 400 MB。如果/tmp空间不大,很容易告警。我后来把临时目录改成用户指定目录,同时每处理一页就立刻删除这张图片,逻辑参考 3.3 节的逐页处理。还有一种方式是把pdftoppm输出直接管道给tesseract的标准输入输出,但参数复杂,大图容易卡,我试过之后还是选择放弃。
6.5 中文标点丢失严重
Tesseract 对中文句号、逗号的识别率确实不如英文字符标点,输出文本经常漏符号。这个没法完全避免,只能在后处理阶段做补偿:把多个空行压缩成一个,句末若无标点且换行则补一个回车。如果下游是全文搜索,标点丢失影响不大;如果是要重新排成 Word,就必须考虑用正则清理空格和换行。我记得有个同事直接拿 OCR 文本来做合同比对,因为标点丢失导致上千份文件匹配率下降,最后重新按段落分块 OCR 才解决。
这套“转图片 + OCR”的方案,基本覆盖了日常工作中 80% 的扫描版 PDF 文字化需求。后来我还把同一套思路搬到了截图文字提取、长图转文本上,因为本质上它们是一回事:把视觉信息变成可搜索的文本。如果你也正被 PDF 提取问题困扰,先用pdftotext试试原生文本层,不行就上pdftoppm配合tesseract,这两板斧能解决大部分问题。等你真的需要更高识别率时,再考虑 PaddleOCR 或商业服务也不迟,那时你已经对问题本身有了足够清楚的认识。