
第一次接到“把这些PDF处理一下”的需求时你很可能也和我一样第一反应是打开搜索引擎找Python库。PDF在Python世界里不像图片和文本那样有非常统一的标准处理路径因为PDF文件本身可以由完全不同的引擎生成有的带真实文本层有的只是矢量图有的干脆就是扫描图片。这个差异决定了一件事处理PDF之前先想清楚你到底要做什么——是拆页、合并、提取文字、填表格还是把一堆数据生成漂亮的PDF报告。方向不同选的库完全不同。我最初接触这个需求是在一个批量归档项目里几百份PDF要按页拆开其中有扫描件还要提取标题文字最后生成一份汇总目录。折腾下来最大的体会是PDF处理本身不难难的是先把“定位”这件事搞清楚。很多人在网上搜到一个库就用结果要么提取不出文字要么拼出来的PDF格式全乱最后把锅甩给Python。所以这篇东西我不打算只丢几个现成函数而是把每个环节的选型逻辑、常见坑、可复现的代码拆开讲清楚。1. 处理PDF前先把这几个库的定位搞清楚1.1 PDF不是普通电子文档更像一张电子纸PDF本质上是一个“最终版式”文件它的核心目标是保证在任何设备上看起来完全一致而不是提供方便编辑的结构化内容。与Word这类流式文档不同PDF内部是一个对象图字体、图片、页面尺寸、内容流、元数据、交叉引用表全都以对象形式存在。文本在PDF里并不是保存在一个干净的“段落”里而是以“绘制字符”的指令写在内容流中。更极端的情况是有些PDF在导出时把所有文字都转成了曲线表面上你能看到字实际上文件里根本不存在任何文本信息。这就意味着用Python处理PDF有点像在一张已经印好的纸上做操作你可以复印、裁边、盖戳但要“提取段落文字”取决于印刷时是否保留了文字数据。很多初学者搜“Python PDF”第一个搜到的是PyPDF2。这个库曾经很流行但维护基本停滞了社区已经迁移到它的后继项目pypdf。如果你还在看PyPDF2的教程建议直接换到pypdfAPI非常接近但更新更活跃问题修复更快。先把这层背景理解了后面遇到“为什么这个库读不出文字”之类的问题就不会一头雾水。1.2 常用库的分工对比处理PDF的Python生态里常见的选择其实就五个pypdf、pdfplumber、PyMuPDF、reportlab再加上OCR方向的pdf2image与pytesseract。它们之间不是替代关系而是定位不同。使用场景推荐库擅长的事主要限制页面级操作pypdf拆分、合并、旋转、裁剪、加密复杂表格和文本提取能力弱文本与表格提取pdfplumber基于pdfminer.six能拿到精确坐标扫描件、图片型PDF无效快速渲染/加批注PyMuPDF速度快能导出图片、加注释包名和导入名容易混乱从零生成PDFreportlab生成报告、合同、表格、条码只适合写不适合读扫描件OCRpdf2image pytesseract识别图片型PDF文字需要额外安装系统级依赖我见过有人拿pdfplumber去拆分PDF也能跑但本质上还是逐页复制远不如pypdf顺手。也有人拿pypdf去提取复杂表格折腾半天后放弃。工具没有绝对好坏选型对了事情就成了一半。1.3 一个需求往往是一条流水线实际项目里很少只用到一个库。比如“把PDF里的表格提取出来再生成一份新报告”这至少是两段pdfplumber负责提取reportlab负责生成。再比如“扫描件需要识别”中间还要补一段pdf2image渲染和OCR识别。我惯用的做法是先画一条流程线输入PDF是什么类型文本型/扫描型中间要产出什么文本/表格/单页文件/加密文件最后要交付什么原始PDF汇总报告。一次画不清没关系但心里至少要有这条链才不会在一个环节里反复绕圈子。2. 环境准备与最基础的读取操作2.1 安装依赖和版本坑这一步很简单但你可能会在导入阶段被坑一次。pip install pypdf pdfplumber reportlab pymupdf如果需要OCR能力再补两个pip install pdf2image pytesseract注意pdf2image需要本地环境中有一个PDF渲染程序配合这不是pip能解决的问题需要先安装系统级依赖否则运行时会直接报找不到可执行文件。pytesseract也是一样它只是一个封装真正的OCR引擎需要单独安装语言包也得单独准备。我第一次做OCR的时候就是在这里卡了大半天后来才发现不是Python代码的问题。版本坑一个在PyMuPDF它的包名是PyMuPDF但老版本导入名是fitz新版本也支持import pymupdf。如果你照网上老教程写import fitz在很新的环境里可能收到弃用警告但绝大多数旧项目里仍然能用。另一个坑在PyPDF2新项目请直接pypdf不要再用PyPDF2。2.2 读取页数、尺寸和元数据读取PDF的基础操作很简单pypdf的PdfReader就能完成。from pypdf import PdfReader reader PdfReader(sample.pdf) print(页数:, len(reader.pages)) meta reader.metadata print(标题:, meta.title) print(作者:, meta.author) print(创建时间:, meta.creation_date) for i, page in enumerate(reader.pages): w page.mediabox.width h page.mediabox.height print(f第{i 1}页: {w:.1f} x {h:.1f} pt)这里的单位是pt也就是磅1磅等于1/72英寸。A4纸的标准尺寸大约是595.27 x 841.89磅。很多人在后面做裁剪时把像素值直接当磅用结果尺寸完全不对原因就在这里。有一点要注意meta可能为None尤其是一些程序自动生成的PDF没有填写任何元数据。如果直接访问meta.title会报错。稳妥的写法是先判断print(meta.title if meta else unknown)2.3 页面对象不是普通字典别乱改pypdf里的页面对象是从PDF对象树解析出来的我们可以访问mediabox、cropbox但不建议像操作普通Python对象一样随意修改底层属性。PDF由交叉引用表和对象树组成一旦破坏对象关系轻则报错重则产出损坏文件。更合理的习惯是“读取”和“写入”分开用PdfReader读文件用PdfWriter写新文件。即使只是修改一页的旋转角度也尽量先读完再写到新的writer里不要在原reader对象上反复修改。这个习惯能帮你避开很多莫名其妙的问题。3. 文本与表格提取最容易踩坑的一环3.1 提取普通文本先试试pdfplumber提取文本我默认用的是pdfplumber因为它基于pdfminer.six底层拿到了字符级坐标能处理很多pypdf做不了的排布问题。import pdfplumber with pdfplumber.open(report.pdf) as pdf: page pdf.pages[0] text page.extract_text() print(text)extract_text()返回一个字符串pdfplumber会按坐标做排版。但它的排序逻辑并不适合所有版面最典型的是双栏PDF。比如一份论文左栏读完还没到底就跳到右栏底部提取出来的文本顺序完全是乱的。这时我会手动按x坐标分栏with pdfplumber.open(report.pdf) as pdf: page pdf.pages[0] words page.extract_words() mid page.width / 2 left_col [w for w in words if w[x0] mid] right_col [w for w in words if w[x0] mid] def sort_words(items): return sorted(items, keylambda w: (w[top], w[x0])) left_col sort_words(left_col) right_col sort_words(right_col) for w in left_col right_col: print(w[text], end )这只是个朴素算法但对于绝大多数双栏PDF它比直接extract_text()可靠得多。3.2 表格提取有框和没框是两个世界pdfplumber提取表格时默认依赖页面上的线条来识别表格边界。所以一份“画了完整表格线”的PDF提取效果会很好。with pdfplumber.open(table.pdf) as pdf: page pdf.pages[0] tables page.extract_tables() for table in tables: for row in table: print(row)每个cell是字符串或None。如果遇到“看起来是表格但没有线条只是用空格对齐”的PDF默认策略会失效。这种情况需要给extract_tables传table_settings让表格识别改用文本边缘作为参考settings { vertical_strategy: text, horizontal_strategy: text, } with pdfplumber.open(table.pdf) as pdf: page pdf.pages[0] tables page.extract_tables(settings) for table in tables: for row in table: print(row)注意这种策略也可能把文字之间的空白误判成表格线产生大量空cell。我的经验是先小范围测试再调整x_tolerance和y_tolerance。如果你要批量处理不同供应商的PDF表格几乎不可能用一个固定配置通吃最好按文件夹分别调参。3.3 扫描件只能走OCR兜底如果extract_text()返回空字符串或者内容少得离谱那大概率是扫描件或图片型PDF。这时候不要继续在PDF解析库上钻研了思路换成“渲染成图片再做OCR”。from pdf2image import convert_from_path import pytesseract images convert_from_path(scan.pdf, dpi200) for image in images: text pytesseract.image_to_string(image, langchi_simeng) print(text)dpi建议从200起步。dpi太低OCR识别率差dpi太高渲染耗时会成倍增加且识别率并不会线性提升。如果文档是中英混合语言参数写chi_simeng。如果你只需要英文甚至可以只写eng速度会快一些。OCR结果天然有误差不要指望它达到人工录入的准确率最好结合正则从结果里抽取关键字段而不是整段信任。4. 拆分、合并、旋转、裁剪用pypdf实现页面级操作4.1 按页拆分先想清楚范围页面级操作是pypdf的主场。拆分的核心逻辑很简单读取原PDF把每一页放进一个新的PdfWriter再写出去。from pypdf import PdfReader, PdfWriter reader PdfReader(input.pdf) for i, page in enumerate(reader.pages): writer PdfWriter() writer.add_page(page) with open(fpage_{i 1:03d}.pdf, wb) as out: writer.write(out)文件名里的{i 1:03d}会生成page_001.pdf这种格式方便排序。如果只需要第2页到第5页不要循环全部页面直接用切片for i in range(2, 6): writer PdfWriter() writer.add_page(reader.pages[i]) writer.write(...)注意页码从0开始所以range(2, 6)对应的是第3页到第6页。这个偏移很多人写错过。4.2 合并PDF与书签问题合并多个PDF用append很方便。from pypdf import PdfWriter writer PdfWriter() for path in [part1.pdf, part2.pdf]: writer.append(path) with open(merged.pdf, wb) as out: writer.write(out)但这里有个隐藏问题append合并的是页面不一定保留源PDF里的书签/目录结构。如果你合并的是几十页的文档交付后对方发现目录消失了体验会非常差。所以在合并前先检查源文件是否有outlinefor path in [part1.pdf, part2.pdf]: reader PdfReader(path) print(path, reader.outline)reader.outline返回书签结构。如果源PDF有书签而合并后没有说明你还需要手动迁移书签。pypdf支持重建outline但要根据合并后页面偏移重新定位写起来有点繁琐。实际项目里如果书签很重要我会直接用PyMuPDF做合并它对书签的处理更灵活或者抽时间写一个书签迁移脚本不要拖到交付前。4.3 旋转和裁剪的坐标细节旋转页面在pypdf里一行就能完成from pypdf import PdfReader, PdfWriter reader PdfReader(input.pdf) writer PdfWriter() page reader.pages[0] page.rotate(90) writer.add_page(page) with open(rotated.pdf, wb) as out: writer.write(out)注意rotate是增量操作多次调用会累加。如果你想固定某个角度更稳妥的是直接设置page.rotation 90。裁剪我建议用RectangleObject四个数字分别是左下角x、左下角y、右上角x、右上角y。这个坐标系原点在左下角。from pypdf import PdfReader, PdfWriter from pypdf.generic import RectangleObject reader PdfReader(input.pdf) writer PdfWriter() page reader.pages[0] page.cropbox RectangleObject((50, 50, 545, 791)) writer.add_page(page) with open(cropped.pdf, wb) as out: writer.write(out)很多人以为裁剪会“切掉”内容、减少文件体积其实cropbox只是改变了页面的显示区域里面的内容对象依然存在。真要让文件变小还需要进一步压缩或重写内容流。这种细节在验收时很容易出问题提前跟需求方确认清楚比较好。5. 从空白开始生成PDFreportlab的排布逻辑5.1 什么时候用reportlab而不是其他库生成PDF是反方向的需求我手里有数据要把它变成一份排版良好的PDF。reportlab是这方面的标准选择因为它从零绘制页面可以精确控制文字、表格、图形。它不像pypdf那样能读取已有PDF也不要拿它去“改”已有的PDF。记住这句话reportlab只写不读pypdf只读不写pdfplumber专注提取。理解了各自边界选型就不会乱。reportlab有两个层次canvas是给完全自由绘图用的像拿一支笔在纸上画platypus是更高层的文档流适合段落、表格、自动分页适合生成报告。我日常90%的需求用platypus就够。5.2 最简单的中文段落输出reportlab默认字体不支持中文这是大多数人遇到的第一个坎。解决办法是注册一个支持中文的TTF字体然后在样式中指定它。from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet from reportlab.lib.units import mm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle pdfmetrics.registerFont(TTFont(CNFont, /path/to/chinese-font.ttf)) styles getSampleStyleSheet() styles[Title].fontName CNFont styles[Normal].fontName CNFont doc SimpleDocTemplate(report.pdf, pagesizeA4) story [] story.append(Paragraph(项目进度汇总, styles[Title])) story.append(Spacer(1, 6 * mm)) story.append(Paragraph(这里是正文内容可以包含中文。, styles[Normal])) doc.build(story)/path/to/chinese-font.ttf要替换成你本机中文字体文件的绝对路径。不同系统字体路径差异很大最省事的是把自己常用字体文件放到项目目录下用相对路径引用。这样换环境跑脚本时也不会因为系统字体变化而出问题。5.3 表格和图形怎么排platypus里用Table类配上TableStyle控制边框和背景色。from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet from reportlab.lib.units import mm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.platypus import SimpleDocTemplate, Paragraph, Table, TableStyle pdfmetrics.registerFont(TTFont(CNFont, /path/to/chinese-font.ttf)) styles getSampleStyleSheet() styles[Normal].fontName CNFont doc SimpleDocTemplate(report.pdf, pagesizeA4) story [] data [ [编号, 数量], [A001, 12], [A002, 8], ] table Table(data, colWidths[60 * mm, 40 * mm]) table.setStyle(TableStyle([ (FONTNAME, (0, 0), (-1, -1), CNFont), (BACKGROUND, (0, 0), (-1, 0), #EEEEEE), (GRID, (0, 0), (-1, -1), 0.5, #999999), ])) story.append(Paragraph(数据清单, styles[Normal])) story.append(table) doc.build(story)TableStyle里的坐标范围用(列起点, 行起点, 列终点, 行终点)表示(-1, -1)代表最后一行最后一列。这个写法初看有点反直觉但用熟了就很方便。platypus会自动处理分页长表格跨页时会自动拆分。如果表头希望每一页都重复可以给TableStyle加一行(REPEATROWS, (0, 0), (-1, -1))这个在实际业务报表里很常用。6. 加密、加水印、去水印安全类操作的常见实现6.1 加密与权限设置加密PDF也是pypdf的强项。加密时可以设置用户密码和所有者密码用户密码用于打开文件所有者密码用于控制打印、复制等权限。from pypdf import PdfWriter writer PdfWriter() writer.append(source.pdf) writer.encrypt(user_password123456, owner_passwordadmin, algorithmAES-256) with open(encrypted.pdf, wb) as out: writer.write(out)读取加密文件时需要传入密码from pypdf import PdfReader reader PdfReader(encrypted.pdf, password123456) print(len(reader.pages))有一个细节并非所有阅读器都支持256位AES加密旧版本阅读器可能打不开。如果业务上有兼容老设备的需求加密算法要降级。这种兼容性问题在封装交付时特别容易漏最好提前确认使用环境。6.2 文字水印的完整流程加水印的本质是“先做一页水印PDF再把它合并到目标页上”。第一步用reportlab生成水印页from reportlab.pdfgen import canvas c canvas.Canvas(watermark.pdf, pagesize(595.27, 841.89)) c.setFont(Helvetica, 48) c.setFillAlpha(0.15) c.drawCentredString(297.6, 420.9, SAMPLE) c.save()setFillAlpha(0.15)控制透明度透明度越低水印越淡适合不想遮挡正文的场景。第二步把水印合并到目标PDF每一页from pypdf import PdfReader, PdfWriter wm_reader PdfReader(watermark.pdf) wm_page wm_reader.pages[0] reader PdfReader(source.pdf) writer PdfWriter() for page in reader.pages: page.merge_page(wm_page) writer.add_page(page) with open(watermarked.pdf, wb) as out: writer.write(out)水印位置如果想调整可以在reportlab生成水印时用drawString精确指定坐标。水印大小、角度也可以用rotate变换来控制。需要斜着铺满页面时可以多画几个字符串铺成斜线阵列。6.3 去水印的边界与限制先给个结论不要相信“一键无损去水印”的通用方案。水印如果是一个独立对象理论上可以从页面对象树里移除但如果水印已经和正文内容流混在一起或者只是背景图片普通Python脚本很难无损移除。网上有些做法是直接在内容流里删除包含水印文本的指令这很容易误删正文里的相同文字。还有一种是甩白底矩形盖住水印区域但水印下面的内容也会被挡住。所以在接到“去水印”需求时一定要先搞清楚对方要什么。如果是内部归档、视觉上不碍眼用白色矩形覆盖就能接受如果是要求真的还原成无水印的原始文档那基本要回到生成端重新导出Python能做的非常有限。这个问题不是Python能力不行而是PDF格式本身就不适合做无损逆行操作。我最后通常会先给需求方看一段测试效果再确认方案避免白做。7. 完整自动化小流程批量提取加生成汇总报告7.1 业务需求拆解前面讲了很多零散操作但实际项目里很少只做一件事。拿一个常见的归档需求举例把一个目录下所有PDF按页拆分命名成原文件名_页码.pdf提取每个PDF首页第一段文字用于后续检索最终生成一份汇总报告PDF列出文件名、页数、首页文本摘要。这个流程覆盖了pypdf拆分、pdfplumber提取、reportlab生成三个阶段。7.2 代码骨架from pathlib import Path from pypdf import PdfReader, PdfWriter import pdfplumber from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet from reportlab.platypus import SimpleDocTemplate, Paragraph, Table input_dir Path(./inbox) output_dir Path(./out) output_dir.mkdir(exist_okTrue) rows [[文件名, 页数, 首页摘要]] for pdf_file in input_dir.glob(*.pdf): reader PdfReader(pdf_file) page_count len(reader.pages) for i, page in enumerate(reader.pages): writer PdfWriter() writer.add_page(page) out_path output_dir / f{pdf_file.stem}_{i 1:04d}.pdf with open(out_path, wb) as f: writer.write(f) first_line with pdfplumber.open(pdf_file) as pdf: text pdf.pages[0].extract_text() or first_line text.strip().splitlines()[0][:20] if text.strip() else (无文本层) rows.append([pdf_file.name, str(page_count), first_line]) doc SimpleDocTemplate(str(output_dir / summary.pdf), pagesizeA4) styles getSampleStyleSheet() story [Paragraph(PDF批量处理汇总, styles[Title]), Table(rows)] doc.build(story)这段代码直接跑能通但样式很简陋实际使用时记得给Table加上TableStyle否则交付略显粗糙。批量拆分产生的文件如果很多建议按日期或批次建立子目录不要把所有文件堆在一个目录里否则后面清理和归档都是灾难。7.3 实测性能与内存问题处理几十个文件这些代码没有问题。但到上千个文件时有几个地方必须注意不要在一个大循环里复用同一个PdfWriter每次写完新文件后一定要重新创建writer否则writer内部会累积页面对象内存越吃越多。上面代码每次循环都PdfWriter()这是正确做法。用pdfplumber打开文件时一定要用with否则底层文件句柄不会立刻释放。在部分系统上这会导致后面想移动或删除目录时报“文件被占用”错误。OCR环节是整个流水线里最耗时的而且tesseract等底层引擎通常不是线程安全的。如果要做并行优先用进程池而不是线程池。最省事的做法是先渲染出图片存到临时目录再分多进程做识别最后汇总结果。8. 我实际操作中最想提醒的几个坑8.1 文本提取前先检查是不是图片型PDF很多人遇到提取不出文字第一反应是怀疑库坏了。其实更快的做法是先做一个快速体检用pdfplumber打开首页同时看文本内容和图片数量。import pdfplumber with pdfplumber.open(scan.pdf) as pdf: page pdf.pages[0] text page.extract_text() or if not text.strip() and page.images: print(疑似扫描件需要走OCR) else: print(PDF包含文本层)这个检查放在脚本开头能在处理大量文件时省下不少时间。8.2 坐标系的单位与原点PDF默认单位是pt1pt等于1/72英寸。很多人在裁剪时把像素值当pt用结果尺寸完全不对。另一个容易混淆的是坐标系原点PDF规范里原点通常在左下角但不同库封装后有的接口把左上角当原点。遇到输出不直观时先打印一下mediabox和页面上第一个字符的坐标确认坐标系后再写裁剪或定位代码。不要凭感觉填数字哪怕只差一次也可能导致整个页面错位。8.3 版本更迭导致的API断裂PDF处理相关的库更新比较频繁旧教程里的API经常失效。我遇到过几个典型的PyPDF2已经不被推荐新项目要用pypdf老代码里的reader.getNumPages()在新版里会报错正确的是len(reader.pages)PyMuPDF老写法是import fitz新版本推荐import pymupdf。如果代码运行报AttributeError先看库版本和导入名再去改逻辑。快速查看版本可以用pip show pypdf pymupdf pdfplumber reportlab8.4 合并水印时别污染原页面对象pypdf的merge_page会修改当前页面对象。如果你对reader.pages[i]直接执行merge_page再添加到writer输出没问题但如果你后面还要继续使用原始页面就不要再拿这个被改过的对象去操作。最稳妥的方式是每次从reader重新取页或者先做一个页面副本。这个坑很隐蔽通常要跑到很后面才会发现前几页可能已经被悄悄加上了不该有的内容。我自己在实际处理PDF时最常用的组合是pypdf做页面级操作pdfplumber做内容提取reportlab做最终生成需要OCR时再根据情况补一段渲染和识别。先把定位分清楚再动手写代码后面的事基本就都是体力活了。遇到奇怪问题时也别慌用最小样本测试一下通常很快就能定位到是格式、坐标还是版本的问题。