
提到用 Python 处理 PDF很多人第一反应是“装个 PyPDF2把下载下来的文档拼一拼、拆一拆”。作为一个做了十来年数据处理和文档自动化的人我得说真实项目里的 PDF 处理远不止“会调用某个函数”这么简单。你可能要处理的是合同归档、财报数字抽取、网页另存为 PDF 后的版面修正甚至是 PDF 与图片之间的批量互转。这些需求恰好都落在 Python 生态的射程之内——pdfplumber 管文本、PyMuPDF 管页面、pikepdf 管结构和加密、reportlab 管生成每个库各管一段拼起来就是一条能跑的流水线。这篇文章不打算带你从零认识什么叫 PDF 格式而是直接进入实操层面。我会按场景把这几个主流库的选型逻辑讲清楚给出可以直接抄作业的代码再把我在实际项目中踩过的坑——中文乱码、表格提取不全、合并后文件体积爆炸——统一放在最后章节。无论你是刚写完第一个爬虫的新手还是要搭文档自动化流水线的开发者这篇内容都可以先收藏再慢慢对照着改。1. Python 处理 PDF 的整体思路与库选型1.1 为什么偏偏是 Python 干这活PDF 处理很少是孤立需求它往往是整条数据链路里的一环前面有爬虫批量下载文件中间要抽关键内容入库后面可能还要生成合并报告或导出给客户。Python 在这条链路里最大的价值不是某个库多强而是“粘合能力强”。你可以在同一个脚本里用 requests 下载 PDF、用 pdfplumber 抽取表格、把结果写进 DataFrame、再用 reportlab 生成一张汇总表。换别的语言每一环节都得单独找库衔接成本高得多。另外PDF 格式本身就是一门“老复杂”的学问——内容流、对象、字体、压缩算法全揉在一起。Python 生态里恰好有不同深度、不同侧重的库有的偏重“看得见的内容”有的偏重“底层的结构”组合起来几乎能覆盖所有日常需求。这也是我推荐把它当成主力工具的原因。1.2 主流库全景先认清牌桌上的选手做个项目之前先搞清楚每个库的定位否则很容易用错。我把常用的库放在一张表里尽量说得直白些库专长最擅长的事一句话点评pdfplumber文本与表格提取从页面里抠文字、抓表格提取界的工匠慢但细致PyMuPDFfitz全功能页面操作、渲染、合并拆分快稳狠综合实力最强pikepdfPDF 结构与加密合并拆分、加密解密、无损处理底层是 QPDF对结构理解深reportlabPDF 生成从零创建新 PDF做报表、发票、通知单的利器PyPDF2 / pypdf基础操作老牌小操作新项目建议优先考虑 pypdfpdf2image转图片PDF 页面渲染成 PNG简单直接但依赖外部工具我的选型经验就一句话“务实不装杯”。提取内容优先 pdfplumber操作页面优先 PyMuPDF动结构或加密优先 pikepdf生成新文档用 reportlab。实际项目中我经常在一个脚本里同时引用其中两三个库各取所长。2. 文本提取与解析从 PDF 里把字“抠”出来2.1 普通文本读取pdfplumber 和 PyMuPDF 怎么选先看最常见的需求——把一个 PDF 里的文字全部提取出来。这里有两个主力可选但它们的性格完全不同。PyMuPDF 的get_text提取速度非常快大文件几百页也能秒级返回。它走的是一种相对宽松的解析路径适合只需要“拿到一整页文本”的场景。import fitz doc fitz.open(report.pdf) for i, page in enumerate(doc): text page.get_text(text) if text.strip(): print(f--- 第 {i 1} 页 ---) print(text) doc.close()pdfplumber 就慢不少几百页跑几十秒很正常。但它对版面的还原度和容错性更好尤其是双栏文档、带复杂布局的页面它提取出来的文字顺序会更接近人眼阅读的顺序。import pdfplumber with pdfplumber.open(report.pdf) as pdf: for i, page in enumerate(pdf.pages): text page.extract_text() if text and text.strip(): print(f--- 第 {i 1} 页 ---) print(text)我的建议是先拿 PyMuPDF 快速跑一遍如果得到的文本顺序乱、缺字多再换 pdfplumber 做“精细提取”。两条腿走路比单押一个库省时间得多。2.2 表格提取别让数据散架了PDF 里的表格是所有提取任务里最“磨人”的一部分。很多人第一次用extract_table会发现返回值要么是空的要么列全挤在一起根本没法直接用。原因通常出在表格线不完整。pdfplumber 默认会先找线条和文字的布局关系但很多设计稿导出的 PDF 里面根本没有真正的表格线只有“看起来像表格”的空白间距。这时候可以指定提取策略优先依赖文字的垂直对齐关系。import pdfplumber with pdfplumber.open(财务表.pdf) as pdf: page pdf.pages[3] tables page.extract_tables({ vertical_strategy: lines, horizontal_strategy: lines, }) for table in tables: for row in table: print(row)这里把两个策略都设为lines是告诉解析器“优先沿表格线切分”。如果表格本身没有线可以改成text策略让程序按文字列的分布去猜测边界。实际使用中我往往先试lines不行再试text比一上来就调一堆参数更省心。提示extract_table返回的数据通常带有None空值代表该格子跨行或为空。入库前记得做空值清洗。2.3 中文乱码的成因与两条退路用 Python 提取中文 PDF 遇到乱码是很常见的事但先别急着甩锅给编码。大部分乱码不是 Python 解码错了而是 PDF 本身就没有内嵌正确的“字符映射表”ToUnicode CMap。换句话说文件里存的只是字形编号提取时系统根本不知道这个编号对应哪个汉字。遇到这种情况我的排查顺序是换解析模式。PyMuPDF 里除了text还可以试words、rawdict不同模式走不同的解析路径有时能绕过有问题的 CMap。换库。同一份 PDFPyMuPDF 提取失败pdfplumber 反而可能成功因为两者的字符映射处理逻辑不一样。实在不行把页面渲染成高清图片走 OCR。这一步失去了纯文本的优雅但它是“能出结果”的最后保障。如果 PDF 本来就是扫描件那么任何文本提取库都救不了你。别浪费时间直接走 OCR 方向。很多刚入门的朋友在这里把大量时间耗在配置 pdfplumber 参数上最后发现页面根本没有文本层。3. 页面的合并、拆分与重组3.1 用 pikepdf 做整本合并与单页拆分合并多个 PDF、拆分其中几页这类“动结构”的操作我强烈推荐 pikepdf。它底层基于 QPDF处理的是 PDF 内部的对象结构而不是像某些库那样把页面当成“图片”再拼起来。所以操作又快、又能保持原始内容质量不会出现字体丢失或清晰度下降的问题。整本合并的代码非常简洁import pikepdf pdf1 pikepdf.open(a.pdf) pdf2 pikepdf.open(b.pdf) out pikepdf.Pdf.new() out.pages.extend(pdf1.pages) out.pages.extend(pdf2.pages) out.save(merged.pdf) pdf1.close() pdf2.close()拆分呢本质上就是“新建一个空 PDF把原文件的某几页追加进去”import pikepdf source pikepdf.open(source.pdf) for i in range(len(source.pages)): singleton pikepdf.Pdf.new() singleton.pages.append(source.pages[i]) singleton.save(fpage_{i 1}.pdf) singleton.close() source.close()要注意一个细节pikepdf 直接操作页面对象引用所以千万不要在循环里一边遍历一边改动同一个 PDF 的对象否则容易出现引用混乱。上面这种“先建新文档再追加”的写法是最稳的。3.2 用 PyMuPDF 做精确抽取与插入如果说 pikepdf 是“结构派”那 PyMuPDF 就是“实用派”。它提供的insert_pdf和select方法在做页面级别的精确操作时特别顺手。比如要把 A 文件的第 2、5、7 页加上 B 文件的全部页面抽成一个新文件可以直接这样写import fitz doc_a fitz.open(a.pdf) doc_b fitz.open(b.pdf) doc_out fitz.open() doc_out.insert_pdf(doc_a, from_page1, to_page1) # 第2页注意索引从0开始 doc_out.insert_pdf(doc_a, from_page4, to_page4) # 第5页 doc_out.insert_pdf(doc_a, from_page6, to_page6) # 第7页 doc_out.insert_pdf(doc_b) # B文件全部页 doc_out.save(selected_pages.pdf)如果你需要调整页序比如“把第 3 页提到第 1 页”可以用select传入一个新的页码列表doc fitz.open(book.pdf) doc.select([2, 0, 1]) # 原第3页放最前然后是第1、2页 doc.save(reranged.pdf)这个 API 看起来简单但它背后做的是真正的页面重排而不是按像素裁剪拼接所以结果文件质量有保证。我自己做“抽页另存为”的小工具时最常用的就是这段逻辑。4. 加密解密与权限控制4.1 用户密码与所有者密码别把两个权限混为一谈很多人以为 PDF 加密就是“设一个密码”其实 PDF 标准里有两套密码用户密码user password用于打开文档所有者密码owner password用于控制打印、复制、修改等操作权限。简单理解用户密码是“进门钥匙”所有者密码是“管理钥匙”。pikepdf 里可以同时设置也可以只设置其中一个。大多数情况下业务需求只是“打开要输密码”那就只需要设置 user 密码owner 密码随意给一个不重复的值即可。import pikepdf pdf pikepdf.open(source.pdf) pdf.save( encrypted.pdf, encryptionpikepdf.Encryption( ownerowner_secret, user123456, R6, # AES-256 加密 ), ) pdf.close()R参数是加密等级。R4 对应 AES-128兼容旧版阅读器R6 对应 AES-256安全性更高但个别很老的工具可能打不开。没有特殊要求我建议用 R6。注意owner 密码和 user 密码不要设成一样。某些阅读器遇到两个密码相同时会把权限全部放开导致你“加密了却等于没加密”。4.2 设置权限位能不能打印、能不能复制光有密码还不够真实需求里经常是“允许别人打开看但不允许复制文字也不允许打印”。这就要用到权限位了。pikepdf 提供了Permissions对象来精确控制import pikepdf pdf pikepdf.open(source.pdf) perm pikepdf.Permissions( printTrue, copyFalse, modifyFalse, ) pdf.save( protected.pdf, encryptionpikepdf.Encryption( ownerowner_secret, userread_only, R6, permissionsperm, ), ) pdf.close()这里把复制和修改都关掉了用户只能用阅读器打开查看。需要说明的是这种权限限制是对“按规矩阅读器”的约束并不能完全阻止技术手段破解。它适用于合同分发、内部资料传阅这类防护场景让普通用户不能随便二次传播而不是做绝对安全的文件保险柜。4.3 批量解密遗留文件还有一种反过来的需求公司内部积压了很多带密码的 PDF需要批量去掉密码或者换成一个统一的密码。解密其实就是用密码打开后以无加密参数保存import pikepdf from pathlib import Path src_dir Path(./encrypted) out_dir Path(./decrypted) out_dir.mkdir(exist_okTrue) for pdf_path in src_dir.glob(*.pdf): try: with pikepdf.open(pdf_path, passwordold_pass) as pdf: pdf.save(out_dir / pdf_path.name) except pikepdf.PasswordError: print(f密码错误{pdf_path.name})批量操作时我建议把失败的路径单独打印出来而不是直接抛异常中断整个任务。这样既能保住已处理文件的结果又能把需要人工介入的文件单独挑出来。5. 内容写入从零生成 PDF 与添加水印5.1 reportlab 从零生成一份中文 PDF很多自动化需求最终都要落到“生成一份 PDF”。比如报表导出、发票打印、成绩单生成。reportlab 是这领域最经典的库它可以直接操作画布精确控制坐标。需要留意的第一个坑是坐标体系。PDF 的坐标原点在左下角横向是 x纵向是 y单位是磅。A4 纸的尺寸是 595 x 842 磅。刚上手时很容易把 drawString 的 y 坐标按“从左上角往下数”来写结果字跑到页面外面去。第二个坑是中文。reportlab 默认字体不支持中文必须手动注册一个中文字体文件。from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import A4 from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont # 注册中文字体这里以 Windows 自带的宋体为例 pdfmetrics.registerFont(TTFont(SimSun, C:/Windows/Fonts/simsun.ttc)) c canvas.Canvas(hello_cn.pdf, pagesizeA4) c.setFont(SimSun, 14) # 左下角为原点x100, y700 表示页面偏上位置 c.drawString(100, 700, 这是一份用 reportlab 生成的中文 PDF) c.drawString(100, 660, 第二行文字) c.showPage() c.save()如果你在 Linux 服务器上跑没有 simsun.ttc可以从系统字体目录里找一个中文字体文件或者下载开源的中文字体比如思源黑体放到项目目录下用绝对路径注册。这个坑我在部署的时候踩过好几次核心就一句话字体文件要跟着代码走别假定所有机器上都有同样的字体。5.2 给已有 PDF 批量盖水印水印需求在公司内部其实非常高频打印的合同要盖“内部资料”、提交给客户的文件要盖“仅供参考”。实现思路不复杂就是把一个做好的水印 PDF 叠加到目标文件的每一页上。PyMuPDF 的show_pdf_page可以很方便地做到这一点import fitz doc fitz.open(original.pdf) wm fitz.open(watermark.pdf) # 水印文件可以先用 reportlab 生成 for page in doc: # overlayTrue 表示水印盖在内容上层 page.show_pdf_page(page.rect, wm, 0, overlayTrue) doc.save(watermarked.pdf) doc.close() wm.close()那水印文件怎么做最省事的方式就是用 reportlab 画一个带透明度的“内部资料”字样生成单独一页的 PDF再用show_pdf_page叠上去。这样水印是真正的矢量内容而不是一张会被微信群压缩的图片打印效果也更干净。6. PDF 与图片的双向转换6.1 PDF 页面渲染成高清 PNG有些场景需要对 PDF 做“可视化处理”比如生成缩略图、送 OCR、给前端做在线预览。这时候要把页面渲染成图片。PyMuPDF 渲染速度很快而且控制清晰度非常直观。import fitz doc fitz.open(page_layout.pdf) for i, page in enumerate(doc): # Matrix 放大倍数200 DPI ≈ 2.78 倍150 DPI ≈ 2.08 倍 mat fitz.Matrix(2.78, 2.78) pix page.get_pixmap(matrixmat) pix.save(fpage_{i 1}.png) doc.close()DPI 和 Matrix 的关系是PDF 页面默认以 72 DPI 为基准想得到 150 DPI 就用 150 / 72 ≈ 2.08。日常预览 150 DPI 就够了OCR 场景建议到 300 DPI也就是 matrix 设为 4.17 左右。渲染太高的 DPI 会导致文件巨无霸识别率也不会无限提升这个平衡点需要自己按需求调。6.2 多张图片拼成一份 PDF反方向操作——把多张图片合并成一个 PDF可以用 Pillow 直接完成连 pdf 专用库都不用引from PIL import Image images [Image.open(fpage_{i}.png) for i in range(1, 6)] # 第一张图作为底其余追加 images[0].save( combined.pdf, save_allTrue, append_imagesimages[1:], resolution100.0, )这里有一个容易被忽略的细节所有图片最好统一成相同的色彩模式和尺寸否则生成的 PDF 里页面大小会不一致。images [img.convert(RGB) for img in images]CMYK 图片不转 RGB 直接合部分阅读器显示颜色会异常尺寸不一致则会在翻页时忽大忽小。先用 Pillow 统一处理一遍能省不少后续麻烦。7. 实操中的常见问题与排查技巧实录7.1 提取出来的文本是空白怎么办这是被问过最多的问题。发生这种情况先做一步判断把这一页渲染成图片肉眼看看到底有没有文字。如果图片上有文字但提取出来是空的说明 PDF 是扫描件或图片型 PDF没有文本层。这种情况只能走 OCR。如果图片上也没有文字那就是整页内容被转成了曲线或矢量图形。部分设计软件导出 PDF 时会把字体“轮廓化”文字彻底变成图形也就无从提取。如果图片上有文字、代码提取到一点点但不全那就是字体没有正确映射 Unicode可以按前文的中文乱码思路去换库、换模式尝试。很多朋友一上来就执着于调库参数我建议先花一分钟做上面这个判断能省下一整晚。7.2 合并后文件体积突然变大合并 PDF 后文件体积暴涨通常不是因为页面太多而是因为每个源文件都自带了各自的字体、图片资源合并时这些冗余资源也全被保留下来了。比如三个文件各自内嵌了同一套中文字体合并后字体数据被重复存了三份。解决思路是用 PyMuPDF 做一次“垃圾回收”和压缩import fitz doc fitz.open(merged.pdf) doc.save( merged_compressed.pdf, garbage4, # 移除未引用对象和重复资源 deflateTrue, # 对内容流做压缩 ) doc.close()garbage4是能进行的最高级别清理会拆掉冗余的字体内嵌和重复对象。实测里合并文件体积从 30MB 压回 12MB 是很常见的结果。如果追求更激进还可以考虑用 pikepdf 的save配合compress-streams参数做深度优化。7.3 打开就报密码错误怎么判别是哪种加密批量解密时经常遇到某些文件解不开。先别急着下结论看一眼报错类型PasswordError说明提供的密码不对或者该文件根本没设置密码但你硬填了一个。没有报错但打开后仍然受限说明只设置了 owner 权限密码没有设置 user 密码。这种情况下PDF 可以被任何人打开但复制打印受限制。很多人会误以为“文件加密了就打不开”其实它只是“打开了但功能受限”。判断文件是否加密pikepdf 可以直接看import pikepdf with pikepdf.open(suspicious.pdf) as pdf: print(是否加密, pdf.is_encrypted) print(加密方式, pdf.encryption)pdf.encryption会返回具体的加密算法和权限信息拿到它你就能判断文件是被打开密码卡住了还是只是权限受限。7.4 常见异常速查表异常现象可能原因优先解决手段文本提取为空白图片型 PDF / 字体被转曲线渲染成图片看内容考虑 OCR中文乱码缺少 ToUnicode 映射换库、换文本提取模式extract_table返回空列表表格没有绘制线把策略改为text或混合策略合并文件体积暴涨内嵌字体和对象冗余garbage4deflateTruePasswordError密码错误或文件未加密检查pdf.is_encrypted和pdf.encryptionreportlab 中文不显示未注册中文字体注册 TTF 字体并确认路径存在渲染出的图片偏色原 PDF 为 CMYK 色彩转成 RGB 后再保存页面顺序错乱索引从 0 开始写错了页码统一按 0-based 索引调试8. 一个完整的综合实战脚本前面把各个功能拆开了讲可能有人会犯嘀咕这些操作能不能串成一个完整流程这里有我经常用来处理“客户发来的一堆 PDF”的小脚本功能是合并指定目录下所有 PDF给合并后的文件统一添加水印加密保存并设置不可复制。import fitz import pikepdf from pathlib import Path src_dir Path(./incoming) merged_path ./merged_raw.pdf watermark_path ./watermark.pdf final_path ./最终交付.pdf # 第一步合并所有 PDF doc_out fitz.open() for pdf_file in sorted(src_dir.glob(*.pdf)): with fitz.open(pdf_file) as doc: doc_out.insert_pdf(doc) doc_out.save(merged_path) doc_out.close() # 第二步添加水印 doc fitz.open(merged_path) wm fitz.open(watermark_path) for page in doc: page.show_pdf_page(page.rect, wm, 0, overlayTrue) doc.save(./watermarked_merge.pdf) doc.close() # 第三步加密并限制复制 with pikepdf.open(./watermarked_merge.pdf) as pdf: pdf.save( final_path, encryptionpikepdf.Encryption( ownerowner_pass_2024, userclient_read, R6, permissionspikepdf.Permissions(copyFalse), ), ) print(处理完成最终文件, final_path)这段流程看起来不长但每一步之间都需要注意中间文件的保存与清理。实际项目中我会把中间文件放到临时目录最后只保留交付文件避免服务器上堆满半成品。9. 关于性能与批处理效率的一些心得用 Python 处理 PDF最容易出问题的不是逻辑而是性能。我遇到过有人用 pdfplumber 对一份几千页的合同做逐页提取跑了一个多小时还没结束。这种场景下做两层优化第一层能不用 pdfplumber 就不用。它的提取最精细但代价是慢。先看数据需求如果只是拿关键字不需要精确的版面顺序PyMuPDF 足够快性能差距能到几十倍。第二层考虑用多进程。PDF 页面的提取和渲染是天然并行的各页之间互不依赖。用multiprocessing把页面列表分给多个 worker能显著压缩耗时。注意某些 PDF 库在多进程下需要把文件路径传给子进程由子进程自己打开文件而不是直接传一个已经打开的对象否则很容易出现文件句柄争用问题。如果你要处理的是批量生产环境我建议把任务拆成“提取文本”“渲染图片”“合并文件”三个独立模块然后用队列串联。这样单独某一步崩了不会拖垮整个流程也方便扩展。最后再分享一个小技巧处理完的 PDF 一定要检查页数和文件大小而不是只看程序有没有报错。我吃过一次亏——批处理后发现输出文件全是 0KB程序却没抛任何异常。后来排查发现是源文件的页面对象为空合并后自然什么都没有。从那以后我每个批处理任务末尾都会加一个“输出文件非空且页数合理”的断言别嫌这一步多余关键时刻能救命。