多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PyPDF2实战:从合并拆分到加密水印的完整指南

PyPDF2实战:从合并拆分到加密水印的完整指南 说实话看到“屠龙刀”这三个字的时候我第一反应不是兴奋是警惕。因为文档处理这个领域“看上去万能”的工具往往是坑最多的那个。PyPDF2确实配得上这个名号但前提是你得知道刀锋在哪、刀背在哪。今天是99天精通Python系列的第44天主题就是PyPDF2一个做起PDF合并、拆分、旋转、加密来干净利落但如果你拿它去搞复杂文本提取又会直接给你脸色看的库。这篇文章会把我实际用过、踩过、调过的东西一次讲清楚PyPDF2能做什么、不能做什么、版本坑在哪、核心API怎么用、批量场景怎么落地、遇到报错怎么定位。不管你是刚学Python想拿文档练手还是工作中被一堆PDF折腾得要命这篇应该都能给你省下不少时间。1. PyPDF2的刀锋和刀背先看清这个库的能力边界1.1 三下连击合并、拆分、加解密PyPDF2最稳的功能集中在“PDF页面级操作”上。合并、拆分、旋转、裁剪、加密、加水印这些都属于对一个PDF文件做结构性修改操作对象是PDF内部的页面对象和对象树不是文字内容本身。PDF规范对页面树、加密字典、资源对象的定义相当固定所以这一类操作在PyPDF2里非常可靠这么多年下来几乎没有太多变化。我自己做过的几个典型的活儿都是靠这些功能搞定的把十几份课程讲义合成一个PDF方便打印从一整套合同模板里拆出指定几页发给别人把扫描版的材料批量旋转到正常方向再统一加密归档。这些业务场景里的PDF往往不是给人“读文字”用的而是要作为文件整体被处理——合并、拆分、上锁。这时候PyPDF2的所有优势都发挥出来了纯Python实现、不用额外安装外部工具、API设计直观、处理速度快。1.2 屠龙刀也有卷刃的时候文本提取的边界但这里必须说清楚一个很多人栽过的跟头PyPDF2的文本提取能力远没有它页面操作能力那么强。extract_text()方法提取的是PDF里已有的文本层。也就是说PDF来源如果是Word排版后导出的、有真实文字层的那种文件提取基本没问题。但如果PDF本身就是扫描件、图片拼接成的“假PDF”或者报错信息里出现了“unsupported filter”那PyPDF2就彻底没辙了。更无奈的是复杂版式。双栏排版、大面积表格、页眉页脚混排的文档用extract_text()提取出来的文字顺序大概率是乱的因为PDF本身并不记录“阅读顺序”它只记录每个文字块的坐标和字体信息。PyPDF2按内容流顺序去抽文字抽出来的是“原始字符流”不是“人眼阅读的顺序”。所以如何定位PyPDF2它是一把给PDF做外科手术的刀不是翻译器。遇到真正需要还原版式、抽取内容的场景建议直接上pdfminer.six或者走OCR方案别在这个库上死磕。2. 安装第一步就踩的坑PyPDF2与pypdf的版本纠葛2.1 版本现状与选择如果你现在去搜索引擎找PyPDF2的教程大概率看到的是这样的代码from PyPDF2 import PdfFileReader, PdfFileWriter然后你装完库一跑直接报ModuleNotFoundError或者AttributeError。这不是你写错了而是版本变了。PyPDF2在早期版本中确实用PdfFileReader和PdfFileWriter但后来的版本中这些类被更简洁的PdfReader和PdfWriter取代。再往后PyPDF2的维护重点转移到了pypdf这个库上官方推荐的做法是直接用pypdf。PyPI上传统的PyPDF2发布包已经停止功能更新代码层面的新旧API也不再统一。我的建议很简单新项目直接装pypdf。pip install pypdf如果项目里写死了PyPDF2的依赖或者你只是照着老教程学习那至少把版本锁在支持新API的版本上同时做好代码兼容处理。实际业务中我见过太多因为“教程代码跑不通”而判定库有问题的案例其实问题出在版本。2.2 代码兼容的快速判断怎么判断你手上的代码到底是哪个时代的看导入语句就行。老写法PyPDF2旧版新写法PyPDF2新版 / pypdffrom PyPDF2 import PdfFileReaderfrom pypdf import PdfReaderfrom PyPDF2 import PdfFileWriterfrom pypdf import PdfWriterfrom PyPDF2 import PdfFileMergerfrom pypdf import PdfMergerpage.extractText()page.extract_text()reader.getNumPages()len(reader.pages)writer.addPage(page)writer.add_page(page)writer.encrypt(password)writer.encrypt(password)基本一致老实说新API的函数命名更符合Python社区一贯风格短横线命名法读起来也舒服。如果你在网上找到一篇特别好的文章却是旧代码直接按照这张表做机械替换绝大多数情况下都能跑通。这也是我在处理历史代码时反复用到的办法。3. 第一刀页面信息、文本提取与“无字天书”扫描件3.1 读页数、尺寸、元数据处理PDF的第一步永远是“摸清底细”。一个PDF有多少页、每一页多大、有没有设置作者和标题这些都可以用PyPDF2在几行代码里拿到from pypdf import PdfReader reader PdfReader(example.pdf) print(页数:, len(reader.pages)) first_page reader.pages[0] print(页面尺寸:, first_page.mediabox.width, x, first_page.mediabox.height) print(旋转角度:, first_page.rotation) metadata reader.metadata print(标题:, metadata.title) print(作者:, metadata.author) print(创建工具:, metadata.creator)这里最常用的是len(reader.pages)不管是后续拆分、合并还是循环处理都要先知道一共有多少页。mediabox返回的是Point对象可以取.width和.height做批量尺寸校验时很管用。比如你要把一批PDF统一转成A4幅面就得先知道当前页面的大小再决定是否用mediabox去调整。元数据这一块容易被忽略但实际工作中价值不低。我曾经接手过一个知识库整理脚本就是靠读取所有PDF的metadata.title和metadata.subject来做自动分类归档比打开文件一个个看效率高多了。PDF的元数据本质上是一个字典键名以斜杠开头这是PDF规范里的标准命名方式。3.2 文本提取能提哪些不能提哪些文本提取是大家最关心、也最容易失望的功能。先看一段最基本的用法from pypdf import PdfReader reader PdfReader(report.pdf) page reader.pages[0] text page.extract_text() print(text)如果是普通的电子版PDF这一小段代码就能把文字抽出来。但请记住extract_text()只认PDF里的文本层。这里有几个我实测总结出来的规律由Word、WPS、LaTeX直接导出的PDF文本层完整提取效果好。由扫描件、传真件、图片拼接生成的PDF没有文本层返回空字符串或乱码。此时要么用OCR方案比如配合Tesseract要么就得承认这条路走不通。中文字符提取时偶尔会出现空结果原因是PDF里使用了CID字体且没有ToUnicode映射。这种情况不是代码问题是文件本身没给“字符到Unicode”的翻译表。双栏排版和多层表格提取出来的文字顺序混乱需要后处理排序但这已经超出PyPDF2的能力范围。我通常的建议是如果你只是做关键词搜索、全文索引这类粗粒度需求extract_text()够用。一旦涉及版面还原、段落重组就不要再纠结这个库了上pdfminer.six它能输出每个文字块的坐标信息方便你自己做重排。为不同需求选不同工具这才是成熟的做法。4. 组合技合并、拆分、旋转、裁剪一次讲透4.1 合并PdfWriter逐页还是PdfMerger批量合并PDF是最常见的需求。假设你有一批分章节的PDF想合成一整本用PdfMerger最省事from pypdf import PdfMerger merger PdfMerger() merger.append(chapter1.pdf) merger.append(chapter2.pdf) merger.append(chapter3.pdf) with open(full_book.pdf, wb) as f: merger.write(f) merger.close()PdfMerger的好处是append()方法支持直接传文件名内部会自行处理文件打开和页面读取还允许只合并指定范围merger.append(big_file.pdf, pages(0, 5)) # 只合并第0页到第4页如果你已经持有PdfReader对象不想重复打开文件也可以直接用PdfWriter逐页取from pypdf import PdfReader, PdfWriter reader1 PdfReader(a.pdf) reader2 PdfReader(b.pdf) writer PdfWriter() for page in reader1.pages: writer.add_page(page) for page in reader2.pages: writer.add_page(page) with open(merged.pdf, wb) as f: writer.write(f)注意一个细节append()传入的是页码范围范围是一个左闭右开的切片(0, 5)表示取第0到第4页第5页不包含。这个和Python列表切片的习惯完全一致。如果你在合并后觉得文件变大了不用太奇怪因为页面对象被复制进新PDF时相关的资源对象也可能被一并复制这是PDF结构决定的——后面我会讲如何压缩。4.2 拆分从大文件里切出目标范围拆分的核心思路是用PdfReader读取原文件然后用PdfWriter把想要的页面重新组装成新文件。最常见的两种需求按页码范围切一份以及把一个多页PDF拆成多个单页文件。按范围切比如取原文件的第3页到第6页索引2到5from pypdf import PdfReader, PdfWriter reader PdfReader(source.pdf) writer PdfWriter() for i in range(2, 6): writer.add_page(reader.pages[i]) with open(slice.pdf, wb) as f: writer.write(f)拆成单页文件并自动命名from pypdf import PdfReader, PdfWriter reader PdfReader(source.pdf) for i, page in enumerate(reader.pages): writer PdfWriter() writer.add_page(page) with open(fpage_{i 1}.pdf, wb) as f: writer.write(f)拆分过程中最常见的错误是忘了页面索引从0开始。一个100页的PDF它的最后一页索引是99。想要原文件第N页到第M页代码里就要从N - 1循环到M - 1。这个逻辑我建议写进函数时用一个清晰的参数名来避免绕晕比如直接用start_page和end_page这种1开始的用户输入然后在函数内部减1转换。4.3 旋转方向别搞反正负角度的实测结论扫描件方向不对处理办法是旋转页面。PyPDF2里的rotate()方法传入正数角度表示顺时针旋转from pypdf import PdfReader, PdfWriter reader PdfReader(scanned.pdf) writer PdfWriter() page reader.pages[0] page.rotate(90) # 顺时针旋转90度 writer.add_page(page) with open(rotated.pdf, wb) as f: writer.write(f)如果想要逆时针旋转90度用负数page.rotate(-90)或者用rotate_clockwise()和rotate_counter_clockwise()这两个更明确的方法。我自己的习惯是直接用rotate(angle)配合注释因为传入负数的语义足够清晰。但如果你跟我一样有一堆扫描件要批量调整方向建议先在单个文件上试一张确认方向对了再跑全量别一口气处理几百个文件结果全转反了那种体验相当酸爽。旋转操作还有一个容易被忽略的特点旋转后页面尺寸不会变化PDF内部只是记录了一个显示角度并不会把内容重新排版。所以一个A4横向扫描的页面转90度后内容的显示方向对了但页面物理尺寸还是原来的横向尺寸。如果你希望页面尺寸也跟着变成纵向后续还需要配合mediabox做处理或者在旋转前先裁剪页面到目标区域。4.4 裁剪cropbox只改“视口”不删内容裁剪的需求通常来自两类场景一类是PDF页面四周有大量白边阅读体验差另一类是页面中混入了无关内容比如角落的页码或标记想直接去掉。PyPDF2裁剪靠的是cropbox属性它相当于一个“可视窗口”决定显示哪一部分页面的内容from pypdf import PdfReader, PdfWriter reader PdfReader(input.pdf) page reader.pages[0] # 设置裁剪区域左下角坐标和右上角坐标 page.cropbox.lower_left (50, 50) page.cropbox.upper_right (550, 800) writer PdfWriter() writer.add_page(page) with open(cropped.pdf, wb) as f: writer.write(f)这里的坐标单位是PDF的点point1英寸等于72点。A4纸的默认尺寸大约是595 x 842点所以上面例子里的(0, 0)就是页面左下角(595, 842)就是页面右上角。调整cropbox的坐标值就可以切出你想要的任意矩形区域。但有一个关键点必须记住cropbox只是改“视口”不是真的把内容删掉。裁掉区域的底层数据仍然存在所以裁剪后的文件体积基本不会变小甚至在某些实现里因为额外保存了裁剪信息而变大。如果裁剪是为了去掉敏感信息再外发文件那就得先确认信息真的被隐藏了再用。如果纯粹是为了减少体积裁剪不是有效手段应该考虑重新渲染或压缩。5. 守护文档加密、权限、元数据与水印5.1 给PDF上锁用户密码与所有者密码给PDF设置密码是PyPDF2的高频应用尤其是文档归档和外发场景。写入密码用的是PdfWriter的encrypt()方法必须在write()之前调用from pypdf import PdfReader, PdfWriter reader PdfReader(source.pdf) writer PdfWriter() for page in reader.pages: writer.add_page(page) # 设置打开密码 writer.encrypt(user_password) with open(protected.pdf, wb) as f: writer.write(f)这里有个容易被忽视的概念PDF加密其实有两个密码角色。一个是“用户密码”user password也就是打开文档时需要的密码另一个是“所有者密码”owner password用于限制打印、复制、编辑等权限。encrypt()方法如果只传一个参数那么用户密码和所有者密码会同时设成同一个字符串。如果想分开设置就传两个参数writer.encrypt(user_password123456, owner_passwordadmin123, use_128bitTrue)PyPDF2支持40位和128位加密use_128bitTrue时安全性更好现代工具都能正常打开。需要提醒的是就算设置了权限密码PDF的权限控制本质上是“软约束”专业的工具仍然可以绕过所以这个加密更适合防普通用户而不是防有技术的攻击者。读取加密PDF时需要把密码传给PdfReaderreader PdfReader(protected.pdf, password123456)如果密码错误或缺失会直接抛异常捕获一下再提示用户就行不要放任程序崩溃。5.2 元数据的读取与写入文档身份证元数据是PDF的“身份证”记录标题、作者、主题、关键词、创建时间等信息。PyPDF2写入元数据的方式是把一个字典传给add_metadata()from pypdf import PdfReader, PdfWriter reader PdfReader(source.pdf) writer PdfWriter() for page in reader.pages: writer.add_page(page) writer.add_metadata({ /Title: 2025年度技术分享合集, /Author: 团队资料组, /Subject: 内部培训材料, /Keywords: Python, PDF, 文档处理, }) with open(with_metadata.pdf, wb) as f: writer.write(f)注意键名必须是PDF规范里的标准字段斜杠开头比如/Title、/Author、/Subject、/Keywords、/Creator。自己发明字段名虽然技术上也可以但很多阅读器可能不识别。批量归档的场景里我习惯在处理PDF的同时统一写入标题和作者信息这样后续文件管理系统的元数据就是从PDF里直接读出来的不用再单独维护清单。5.3 水印merge_page叠加法给PDF加水印用的核心方法是merge_page()。你需要先准备一个水印PDF文件里面的内容就是水印文字或图形然后将这个水印页作为overlay叠加到目标页面上from pypdf import PdfReader, PdfWriter watermark_reader PdfReader(watermark.pdf) watermark_page watermark_reader.pages[0] reader PdfReader(target.pdf) writer PdfWriter() for page in reader.pages: page.merge_page(watermark_page) # 叠加水印 writer.add_page(page) with open(watermarked.pdf, wb) as f: writer.write(f)这里的经验是水印PDF最好单独制作并且页面背景保持透明只包含水印内容本身。如果水印页带白色底叠加之后会把原页面内容直接盖住那就不是水印而是“贴纸”了。另一个常见问题是水印页尺寸和目标页不一致。我的习惯是制作水印时直接做成和常用页面等大的尺寸比如A4然后在合并之前检查一遍mediabox尺寸不一致就先把水印页的mediabox调整成目标页的尺寸再执行merge_page()。水印的用途不光是“内部资料”这种字样还可以是公司Logo、文档编号、日期戳。做批量方案时水印页内容可以在代码里用其他库动态生成也可以预先准备好多个版本的水印文件轮流使用看场景需要。6. 一次性实战批量合并、提取文本、统一加密把前面讲的东西串起来写一个真实能用的脚本。场景是一个目录下有一堆PDF章节文件需要按文件名排序合并成一本完整的文档然后在合并后的文档上统一设置打开密码并写入规范的标题元数据。import glob from pathlib import Path from pypdf import PdfReader, PdfWriter def merge_and_encrypt(input_dir: str, output_file: str, password: str, title: str) - None: pdf_files sorted(glob.glob(str(Path(input_dir) / *.pdf))) if not pdf_files: raise ValueError(目录下没有PDF文件) writer PdfWriter() for pdf_file in pdf_files: reader PdfReader(pdf_file) print(f正在处理: {pdf_file}共{len(reader.pages)}页) for page in reader.pages: writer.add_page(page) # 设置元数据 writer.add_metadata({ /Title: title, /Creator: MergeAndEncryptScript, }) # 加密用户密码和权限密码分开设置 writer.encrypt(user_passwordpassword, owner_passwordowner_key_2025, use_128bitTrue) with open(output_file, wb) as f: writer.write(f) print(f合并完成: {output_file}) if __name__ __main__: merge_and_encrypt( input_dir./chapters, output_file./merged_book.pdf, passwordread2025, title2025年度技术文档合集, )这个脚本把三个核心操作串起来了批量读取并合并、写入元数据、统一加密。我自己跑这种脚本的习惯是先在测试目录里放两个小PDF验证逻辑确认输出文件正常后再切换到真实数据目录避免一把梭把生产文件搞坏。验证输出文件的打开密码是否生效用PdfReader重新读取时传不传密码就能测出来from pypdf import PdfReader # 不传密码应该抛异常或报错 try: reader PdfReader(merged_book.pdf) print(未加密) except Exception as e: print(确实加密了:, type(e).__name__) # 传正确密码应该正常打开 reader PdfReader(merged_book.pdf, passwordread2025) print(页数:, len(reader.pages))这里再补充一个实战中会遇到的细节如果目录下的PDF数量非常多比如上百个那writer.add_page()循环仍然可行但内存占用会涨得比较快因为每个页面对象都驻留在内存里。效率要求高的场景建议改用PdfMerger的append()方式它内部做了更优化的大文件处理内存管理更好。如果文件量巨大且机器配置一般最朴素的方案是分批合并先两个两个合合出的小文件再合并成最终文件牺牲一点磁盘I/O换取内存稳定。7. 高频报错与性能优化笔记遇到这些情况别慌7.1 常见报错排查表PyPDF2/pypdf用多了有几个报错几乎是必遇到的。我把它们整理成了一张排查表每个都对应我实际踩过的场景。报错信息常见原因解决办法EOF marker not foundPDF文件损坏、末尾标记丢失常见于网络上传输不全的文件先用工具修复比如qpdf --recover input.pdf output.pdf再重新读取File has not been decrypted打开了加密PDF但没传密码读取时传入密码PdfReader(path, passwordxxx)中文提取为空字符串PDF使用了CID字体且缺少ToUnicode映射这是文件层面的问题改用OCR方案或者接受无法提取的事实unsupported filterPDF页面使用了PyPDF2不支持的压缩滤镜如JBIG2用qpdf转换一次或换用支持更全的库AttributeError: PdfFileReader object has no attribute...你照着旧教程写了老代码但装的是新版本库对照第2章的API替换表修改代码cryptography is required加密功能缺失可选加密依赖安装时加上依赖pip install pypdf[crypto]这里最值得展开的是EOF marker not found。PDF文件末尾有一个%%EOF标记如果文件在上传下载中被截断、被某些邮件系统折腾过这个标记就会丢失。PyPDF2非常严格读到缺标记就直接抛错。之前我处理一批历史档案扫描件时有三分之一文件都在这一步挂了。后来我把“先修复再处理”写成了标准流程任何PDF进入处理管线之前先跑一遍qpdf --recover把文件修好再交给PyPDF2。这个操作虽然多了一步但能极大降低后续异常概率。7.2 性能优化四个亲测有效的习惯如果你要批量处理大量PDF我建议从一开始就养成下面几个习惯省得后面返工。第一能不读内容就不读内容。只用extract_text()处理真正需要抽取文字的页面而不是对每个页面都全量提取。如果是加密码、加水印这类操作完全不需要碰内容流直接页面级复制就可以速度会比逐页提取快很多。第二小心合并后文件膨胀。前面提到过用PdfWriter逐页复制页面时页面引用的字体、图像等资源会被一并复制多次合并后文件会明显变大。如果对体积有要求合并完成后用qpdf做一次压缩优化qpdf --optimize-images --object-streamsgenerate input.pdf output.pdf这一步在批量归档场景里几乎是必做的能把一些扫描件PDF的体积压掉不少而且质量损失可接受。第三用完就关。PyPDF2的Reader和Merger经常持有文件句柄尤其PdfMerger如果不调用close()在Windows上会导致后续复制、删除文件失败。代码里能写with就写with不能用上下文管理器的对象记得finally里清理。第四批次测试。无论脚本写得多漂亮先拿2个文件跑通全流程再扩大到全集。这个建议听起来没什么技术含量但真的能救命——我就在批量旋转扫描件时因为角度正负搞反一次性生成了几百个错误方向的PDF最后全部重新处理白白浪费了一个下午。PyPDF2给PDF处理带来的改变是实实在在的不需要昂贵的商业软件不需要复杂的Java环境一个Python库就能承接从合并拆分到加密水印的完整流程。我把这个工具用在日常文件归档、报告整理和自动化任务里稳定性和效率都够用。唯一需要记住的是它擅长的是“动刀”而非“翻译”搞清楚边界它就是你文档处理工具箱里那把最好用的屠龙刀。
返回列表