今天这篇是文档自动化系列的第 44 天,主题是 PDF。上午同事发来三十几份盖章扫描件,要求按编号拆开、把第一页的金额字段抓出来汇总到表格,手点肯定是不现实的,我直接在 Python 里用 PyPDF2 一把梭搞定。标题里喊它"文档处理的屠龙刀",我觉得一点不夸张——日常能遇到的 PDF 大多数操作,读页数、提取文本、合并、拆分、旋转、裁剪、上锁加密,它都能处理,而且 API 简单到小白也能直接上手。
这篇笔记适合两类人:一是 Python 基础学到函数、文件读写阶段、想找个真实练手场景的学习者;二是被 PDF 工作流折磨的运营、财务、文档岗,想用 Python 把自己从重复劳动里捞出来。下面我按从安装到实战的顺序,把 PyPDF2 的用法、踩过的坑、以及它和其他 PDF 库的分工一次讲清楚。
1. PDF 为什么难搞?先搞懂它再上手 PyPDF2
1.1 PDF 的底层逻辑:电子打印纸
先说为什么 PDF 处理在 Python 生态里地位这么特殊。你有没有想过:一个 PDF 文档在编辑器里能改字,用代码提取却不完美?合并、拆分、转换的库一大堆,为什么还经常翻车?问题出在 PDF 的底层设计上。
PDF 本质上是"电子打印纸"。它保存的不是"第几行是什么文字",而是一套精确到点的绘制指令:在什么坐标、用哪份字体子集、绘制哪段内容流。你看到的每个页面,在 PDF 内部只是一个页面对象加一串内容流,外加资源字典里的字体、图像、颜色对象。拿 Word 对比就很好理解:Word 是"菜谱",PDF 是"做好的菜的照片"。你能从照片里看出放了盐,但没法直接抽出一行"盐 3 克"。同理,PDF 里能看见文字,不代表代码能直接按段落读出来。
这个设计带来的直接后果:想从 PDF 里提取文本,前提是 PDF 里的字体对象提供了 Unicode 映射(专业术语叫 ToUnicode CMap)。现实中大量扫描件、老式排版工具生成的 PDF 根本没有这个映射,所以直接抽文字,经常得到空白或乱码。这不是 PyPDF2 的锅,是 PDF 格式天生如此,搞清楚这一点,后面遇到问题你就不会冤枉库了。
1.2 PyPDF2 的价值:页面级手术刀
在这样的生态里,PyPDF2 的位置是"页面级手术刀"。它的核心 API 围绕 page 和 writer 展开,让你把 PDF 当成"一叠纸"去处理:添加页面、删除页面、调整顺序、拆分、合并、旋转、裁剪。这些操作都在动页面树和文档结构,而不是去管文字长什么样。
打个比方:PDF 像一座房子,PyPDF2 管的是墙体、门窗的拆装和房间分割,它不管房子里沙发怎么摆。沙发摆放(文本布局、表格坐标)是 pdfplumber 的活,敲墙拆房才是它的主场。学习的时候把这条界限记住,能少走很多弯路——不要指望 PyPDF2 把表格数据完美读出来,那是另一个库的擅长领域。
2. 装库和选版本,别让 API 坑了你
2.1 安装方案与 PyPDF2 / pypdf 的关系
安装本身没什么难度,一个 pip 命令的事:
pip install PyPDF2但这里有个特别容易踩的坑:PyPDF2 的维护已经转移了。老仓库发布到 2.12.x 后就进入只读维护,官方把核心代码合并到了 pypdf 项目,以 3.x 版本继续迭代。所以你在网上搜教程,可能看到两种写法混着出现,实际是同一套代码的"新老马甲"。
我给的建议很简单:新项目直接用 pypdf,老项目继续用 PyPDF2 也没问题,关键是别混。如果你刚开始学,PyPDF2 的资料足够丰富,代码能跑起来,学习阶段完全可以用。要是做生产环境的新项目,建议直接依赖 pypdf,后续维护更省心。
2.2 版本差异对照,写新旧代码不再糊涂
网上很多教程代码是 2.0 之前的老 API,当时类名叫 PdfFileReader、PdfFileWriter,方法叫 getNumPages、extractText。现在的 PyPDF2 2.x 和 pypdf 3.x 都把这些改了。拿一份对照表,你排查报错更快:
| 旧写法(别用) | 新写法(推荐) |
|---|---|
| from PyPDF2 import PdfFileReader | from PyPDF2 import PdfReader |
| pdf.getNumPages() | len(reader.pages) |
| page.extractText() | page.extract_text() |
| writer.write("路径.pdf") | writer.write(文件对象) |
最坑的是最后一行:老写法 write 接收的是文件路径字符串,新写法要求传文件对象。用老写法直接传字符串,大概率报错。我看到太多初学者卡在这一步,其实就是 API 迁移导致的。代码报错的时候,先扫一眼是不是这几个名字的差别,往往一秒钟定位问题。
3. 读取 PDF 信息:页数、元数据、文本提取
3.1 打开 PDF 与惰性读取的陷阱
先来个最基础的打开和读信息:
from PyPDF2 import PdfReader reader = PdfReader("sample.pdf") print("页数:", len(reader.pages)) meta = reader.metadata if meta: print("标题:", meta.title) print("作者:", meta.author)reader.pages 返回的是页面对象列表,len 一下就知道总页数。metadata 是文档自带的信息,但有些 PDF 生成器不写元数据,它的值可能是 None,所以用 if 判断一下再访问属性。
这里要郑重提醒一个惰性读取的坑。PdfReader 打开文件后,不会立刻把所有内容加载到内存,页面对象在内部还持有对原始文件流的引用。如果你图省事这么写:
# 这样不行,文件句柄被提前关了 with open("sample.pdf", "rb") as f: reader = PdfReader(f) page = reader.pages[0] # 报错!页面对象拿不到底层流了正确做法是把读取和后续操作放在同一个 with 块里,或者先把文件内容读进内存再用 BytesIO:
import io from PyPDF2 import PdfReader with open("sample.pdf", "rb") as f: data = f.read() file_like = io.BytesIO(data) reader = PdfReader(file_like) print(len(reader.pages))这个坑相当隐蔽,因为报错信息不那么直白。我自己就栽过两次,之后统一用 BytesIO 方式,省心多了。
3.2 批量遍历页面提取文本的实操写法
提取文本是 PyPDF2 最高频的需求之一。语法很简单:
for i, page in enumerate(reader.pages, start=1): text = page.extract_text() print(f"--- 第 {i} 页 ---") print(text)但我要泼一盆冷水:这个提取是"尽力而为"。对排版规整、字体映射完整的 PDF,效果很好;对双栏排版、表格、扫描件,结果可能乱成一团。文本顺序被打乱、段落间多出奇怪换行、中文变空白,都属于常见现象。
我的实操习惯是:先只提取前两三页打印看看,确认输出顺序能接受,再跑全量。别上来就全量导出一堆数据,最后发现顺序是乱的,返工成本很高。如果你要做的是从特定区域取文本,PyPDF2 做不到,那得换 pdfplumber 按坐标切割。
4. 合并与拆分 PDF:最常用的两个操作
4.1 合并多个 PDF:PdfMerger 一行接一行
合并是办公场景里的高频需求:把多个合同附件合成一份、把扫描分段的文件拼起来、把日报汇总成月报。PyPDF2 提供了 PdfMerger,写法非常直白:
from PyPDF2 import PdfMerger merger = PdfMerger() for pdf in ("contract1.pdf", "contract2.pdf", "contract3.pdf"): merger.append(pdf, import_bookmarks=False) with open("all-in-one.pdf", "wb") as f: merger.write(f) merger.close()append 按你给的顺序追加整个文件。import_bookmarks=False 是我习惯加的,不然原 PDF 里的书签目录会被带进来,有些场景不需要。文件列表用 sorted 排好序再循环,能避免"1、10、2"这种数字顺序错乱问题。
这里说个经验:PdfMerger 操作大文件时内存占用偏高,因为它要把所有文件的结构临时挂在内存里维护。合并 100MB 以上的文件,容易卡顿甚至报递归错误(后面排查部分细说)。如果文件很大,我建议每次只合并两三个,分几轮合并,或者干脆用别的库。
4.2 按页拆分:切片思路写清楚
拆分跟合并正好反过来,核心是"按索引挑页面、放进新 Writer、另存文件":
from PyPDF2 import PdfReader, PdfWriter reader = PdfReader("source.pdf") writer = PdfWriter() for page in reader.pages[2:5]: writer.add_page(page) with open("part.pdf", "wb") as f: writer.write(f)pages[2:5] 是左闭右开区间,实际取出第 3、4、5 页,总共 3 页。想拆单页,循环用页号一层一层来即可:
pages = [1, 5, 9] for n in pages: writer = PdfWriter() writer.add_page(reader.pages[n - 1]) with open(f"page_{n}.pdf", "wb") as f: writer.write(f)这里要注意索引从 0 开始,所以第 1 页对应 reader.pages[0]。这种小细节看着不起眼,但循环批量拆的时候错了,文件名和内容对不上,真要命。建议先拆一页做校验,核对页数再跑全量。
5. 旋转与裁剪:把页面折腾成你要的样子
5.1 旋转角度与方向心法
扫描件方向不对是常见问题。竖着排版的合同被扫成横的,转 90 度;有些扫描仪逆时针转了 90 度,导致内容整个倒过来。PyPDF2 处理很简单:
from PyPDF2 import PdfReader, PdfWriter reader = PdfReader("tilted.pdf") page = reader.pages[0] page.rotate_clockwise(90) with open("rotated.pdf", "wb") as f: writer = PdfWriter() writer.add_page(page) writer.write(f)反向旋转用 page.rotate_counter_clockwise(90) 即可。但注意两个细节:
第一,PyPDF2 的旋转只认 90 的倍数。PDF 页面内部有个 /Rotate 字段,只存方向不存任意角度,你传 45 度它也不会真正旋转。想转任意角度,PyPDF2 做不到,得用 PyMuPDF。
第二,旋转是叠加的。如果页面本来已经被旋转过 90 度,再 rotate_clockwise(90) 就变成 180 度。想要"绝对方向复位",先看页面自带的 rotation 属性,再决定转几次。我在代码里经常这样判断:
current_rot = page.rotation or 0 if current_rot == 90: page.rotate_counter_clockwise(90)这样能精确把倒转的页面转回来,而不是越转越乱。
5.2 裁剪坐标:别让留白吃掉内容
裁剪的需求也很常见:扫描件四周有大黑边、PDF 里混入了页眉页脚、某张截图想只保留中间区域。PyPDF2 裁剪的本质是修改页面的裁剪框(cropbox),相当于在纸张上画一个矩形框,只保留框内内容。
PDF 坐标系统的原点在页面左下角,单位是点(1 英寸 = 72 点),A4 纸张大约是 595 x 842 点。裁剪框用四个坐标来描述:左下角 (x1, y1) 和右上角 (x2, y2)。看代码:
page.cropbox.lower_left = (50, 50) page.cropbox.upper_right = (542, 742)这样就从四周各裁掉 50 点,把内容往里收了一圈。也可以整体赋值:
from PyPDF2.generic import RectangleObject page.cropbox = RectangleObject([50, 50, 542, 742])必须提醒的是:裁剪只是声明"显示这个区域",PDF 里被裁掉区域的数据(尤其是嵌入的图像)并没有被真正删除,所以文件体积经常不减反增。想彻底瘦身,需要重新压缩图像或做内容流清理,这不是 PyPDF2 的强项。
还有一个顺序问题:先旋转还是先裁剪?我的经验是先旋转、后裁剪。因为旋转会改变页面内容的方向感知,如果先精确裁剪再旋转,旋转后你心里算的坐标就全反了;反过来先转好方向,再以当前页面坐标系去设置裁剪框,符合直觉,基本不会翻车。
6. 给 PDF 加密与解密
6.1 设置打开密码和权限密码
办公场景里,给 PDF 加打开密码特别常见:工资单、报价单、签字版合同,这些敏感文档适合上锁后再分发。PyPDF2 的 encrypt 方法一行搞定:
from PyPDF2 import PdfReader, PdfWriter reader = PdfReader("source.pdf") writer = PdfWriter() for page in reader.pages: writer.add_page(page) writer.encrypt("user-password") with open("locked.pdf", "wb") as f: writer.write(f)如果只传一个密码,它作为打开文档的用户口令。PyPDF2 还支持第二个参数 owner password,用来限制打印、复制、修改权限。例如:
writer.encrypt("user-password", "owner-password")这里的逻辑是:打开文档只需要 user 口令,但 PDF 软件里看到"文档受保护,不可编辑不可打印"的提示,是因为 owner 口令没匹配。如果两个参数都不传,不会输出加密 PDF;只传 user 参数时,权限不受限。
另外注意,encrypt 传空字符串有特殊含义:表示"不需要打开密码,但限制某些权限"。新手容易把空串当"没加密",实际输出文件还是带了限制标记。
6.2 验证密码、处理加密文件时的注意事项
读取加密 PDF 时,必须先解密再访问页面,否则直接 reader.pages 会抛异常。标准流程:
reader = PdfReader("locked.pdf") if reader.is_encrypted: result = reader.decrypt("user-password") # result 是 PasswordType 枚举: # USER_PASSWORD 表示用用户口令解开了 # OWNER_PASSWORD 表示用权限口令解开了 # NOT_DECRYPTED 表示密码不对 print(len(reader.pages))我建议写完 decrypt 后检查一下返回值,别偷懒。因为密码错误时不会抛异常,只是返回 NOT_DECRYPTED,而后续访问 pages 时才报错,报错信息还容易让人误会成"文件损坏"。提前判断返回值,就能写出更友好的提示。
PyPDF2 对老式 RC4 加密支持不错,但对某些新版 AES 加密的 PDF 可能无能为力,遇到打不开的加密文档,别死磕这一个库,先确认加密算法,必要时换 pypdf 新版本或 PyMuPDF。另外切记:PDF 加密是硬加密,密码忘了基本没有后门可用。唯一合理的建议是,处理自己遗忘密码的文档时,多试试可能的常用密码,再不行就用源文件重新生成。谨慎建议生产脚本里不要把密码硬编码,从环境变量或配置文件读,避免密码随代码泄露。
7. 选型:PyPDF2 的边界,以及和其他库的分工
7.1 三库对比:各管一摊
谈到 PDF 处理的 Python 方案,很多人在 PyPDF2、pdfplumber、PyMuPDF 之间纠结。我直接给一张对比表:
| 库 | 强项 | 弱项 | 适合场景 |
|---|---|---|---|
| PyPDF2 / pypdf | 页面拆分合并、裁剪旋转、加密解密,API 直观 | 文本提取不强、表格几乎不可用 | 办公自动化、PDF 批量加工 |
| pdfplumber | 文本坐标、表格解析很强 | 不适合修改页面结构 | 抓表格、按坐标提取信息 |
| PyMuPDF(fitz) | 速度快、渲染、注释、OCR 配合全面 | 安装包较大、API 更复杂 | 大规模内容抽取、文档引擎 |
我的选库决策路径很简单:改页面结构(拆、合、转、裁、加密、解密)选 PyPDF2 系;读内容、抽表格选 pdfplumber;既要快又要复杂处理,选 PyMuPDF。它仨不是竞争关系,而是互补关系。
7.2 什么时候别用 PyPDF2
有三类场景我强烈建议别选 PyPDF2,免得浪费时间:
第一是提取复杂表格数据。PyPDF2 的 extract_text 对表格几乎无能为力,它拿到的只是文字流,没有"行"和"列"的概念。这种需求直接上 pdfplumber,它有 row、cell 的概念,按页面坐标还原表格。
第二是把 PDF 渲染成图片。PyPDF2 没有渲染引擎,不能把某页转成 PNG。无论是做缩略图还是 OCR 预处理,都用 PyMuPDF 的 get_pixmap。
第三是矢量图解析。PDF 里的线条、形状、矢量路径,PyPDF2 看不到,能看到的只有文字和图像对象。要对 PDF 里的图形做处理,也得换 PyMuPDF。
记住判断原则:先问自己"我是要动页面,还是要读内容"。动页面找 PyPDF2,读内容找解析库,两者都需要的,就组合使用。
8. 常见问题排查:这是我踩过的坑
8.1 大文件合并直接崩溃:RecursionError 解决方案
用 PyPDF2 合并几十份大文件时,我遇到过明显的 RecursionError,看报错以为是代码写错了,后来才发现是库内部在解析对象树时递归深度超过 Python 默认限制。临时解法简单:
import sys sys.setrecursionlimit(10000)但治本的方法有两个:一是每批只合并少量文件,分批汇总;二是如果有条件,换 PyMuPDF 处理超大文件的合并。真实场景里,我处理 30 份报表合并时,调高递归限制后顺利通过,但文件到了 200MB 级别,还是明显变慢。这个经验仅供参考,不是无限调大就万能的,内存占用也要考虑。
8.2 页面对象读取时报错:文件句柄被提前关闭
前面在惰性读取的部分提过,我再把报错现场描述一下:你明明打开了文件,代码跑得也正常,但一旦出了 with 块,再去取页面属性,就提示找不到文件。新手的直觉是"这库有 bug",实际上是作用域问题。PyPDF2 的 Reader 不会把文件内容全部加载完,页面对象靠原始流"边走边读"。
修正方案就是那两种:把读取和操作放同一个上下文里,或者用 BytesIO 把数据装进内存。我平时写工具函数,都会把"读文件"这一步统一封装成 BytesIO 加载,这样返回的 reader 可以安全传递给任何函数使用,不会被上下文关闭问题坑到。
8.3 中文提取乱码:字体映射与 OCR 兜底
中文文档用 extract_text 得到一堆乱码或者空字符串,这是 PyPDF2 用户最常见的痛点。原因就是我在第 1 节讲的 ToUnicode CMap 映射缺失。有些 PDF 生成工具(尤其是某些扫描一体机、老式排版软件)写入字体时,没把字符编码和 Unicode 的对应关系写进文件。
我的判断流程是:先提取一两页看输出。如果乱码,马上换 OCR 思路,不要继续在 PyPDF2 里耗时间。常见做法是把 PDF 页面渲染成图片(用 PyMuPDF),再对接 RapidOCR、PaddleOCR 这类离线识别引擎。这样虽然多几步,但成功率远高于硬抠文本。记住这是 PDF 格式本身的设计问题,不是哪个库坏了。
8.4 API 张冠李戴:PyPDF2 与 pypdf 混用
这个问题相当普遍。你自己可能只装了一个,但从网上 copy 的代码 import 的是另一个。更隐蔽的是,有些环境里两个库都被间接装上了,代码用的 import 和 pip 里实际依赖的版本对不上,导致明明装了库却导入报错。
排查办法:先在终端跑 pip list | findstr PDF(Windows)或 pip list | grep -i pdf(macOS/Linux),看环境里有哪些相关库。再检查代码头部的 import 一致。统一用 PyPDF2 就全部 from PyPDF2,统一用 pypdf 就全部 from pypdf。最怕修一处漏一处,报错信息还指向另一个方法名,让人一头雾水。
8.5 排查思路速查表
我把日常高频问题整理成一张速查表,可以直接贴在笔记里:
| 现象 | 可能原因 | 第一步处理 |
|---|---|---|
| 导入报错 | PyPDF2 和 pypdf 混用 | 检查 pip 列表,统一 import |
| 访问 pages 报加密错误 | PDF 有口令保护 | 先 decrypt 再操作 |
| 大文件合并卡死 | 递归深度超限 | 调高 recursionlimit 并分批处理 |
| 中文乱码 / 提取空白 | 字体缺少 Unicode 映射 | 改用 OCR 方案兜底 |
| 合并后顺序混乱 | 文件路径排序错误 | 用 sorted 按版本号排序 |
| write 写入报类型错误 | 旧 API 写了路径字符串 | 改用文件对象 |
这张表我打印出来贴在工位旁边,遇到问题先对着表检查,大概率能省半小时排查时间。
我个人实际用下来的体会是:PyPDF2 不一定是最强的 PDF 库,但作为文档处理的第一把刀,它的"页面级操作"思路是最容易上手的。如果你接下来要做 PDF 自动化,我建议把解密、提取、合并、拆分这四个动作写成四个小函数,放进自己的工具模块里,以后遇到什么批量需求,直接组合调用就行。真碰到 PyPDF2 干不动的,再切 PyMuPDF 也不迟。这个系列走到 Day 44,PDF 这块的日常需求基本就通透了,下一步你可以把页面坐标和文本位置分析也学一下,那样对文档的理解会更进一层。