
做项目这几年被PDF折磨的次数比写业务逻辑还多。客户发来一堆扫描合同要提取关键字段财务甩过来几百页对账单要按月份拆分归档产品手册要批量打水印……每次遇到这类需求我第一反应都是打开Python写个脚本而不是手点鼠标搞到半夜。Python做PDF处理与操作这件事生态里能用的库很多但每套库的脾气都不一样选错了后面全是坑。这篇文章不打算面面俱到讲API而是从一个实际干过不少PDF处理活的人的角度把文本提取、表格还原、页面拆分、生成新文档、加密水印这些常见需求串起来讲清楚每步该用什么库、为什么这么选、会踩哪些坑。1. 先搞清楚PDF到底是什么这决定了你处理它的方式1.1 为什么PDF既不是图片也不是Word文档很多初学者拿到PDF的第一反应是“它长得像图片那就转成图片处理呗”。这个思路部分正确但会浪费大量效率。PDF是一种页面描述格式它的核心是“在页面上哪些位置画什么图形”包含文本、矢量路径、位图图像三种基本元素。文本在PDF里是以“字形绘制指令”的形式存在的理论上是可以直接抽取出来的字符数据而不是像素点。这也就是为什么我们能对PDF做文本提取而不是只能靠OCR。但事情没这么简单。PDF里的文本有两种存储方式一种是带有Unicode映射的规范文本提取起来很干净另一种是只有字形IDGlyph ID没有Unicode映射的文本常见于某些设计软件导出、字体子集化处理过的文档提取出来全是乱码。遇到这种就得走OCR路线。理解了这一点就能明白为什么处理PDF不能只用一个库搞定所有事——因为PDF本身是个容器里面的内容形态千差万别。后面的所有库选型都是围绕“你这个PDF里到底有什么”来决定的。1.2 Python生态里处理PDF的常用库与分工我按用途把常用库分成几类先看下面这张表库名主要用途强项弱点pdfplumber文本和表格提取表格提取能力强能拿到字符级定位信息页面渲染、图像操作较弱PyMuPDFfitz文本、图像、页面操作速度快API丰富能渲染页面、提取图片表格提取不如pdfplumber精细pypdf原PyPDF2页面合并、拆分、旋转、加密、元数据轻量聚焦页面级操作文本提取效果一般reportlab生成新PDF精确控制版面适合做报表、票据学习曲线稍陡camelot表格提取基于线框的表格识别很稳对无线框表格支持有限pdf2image pytesseract扫描件OCR把PDF转图片再识别文字速度慢依赖外部工具实际项目中我的惯例是提取文本和表格优先pdfplumber做页面操作优先pypdf需要渲染或提取图片优先PyMuPDF生成文档优先reportlab。OCR永远作为兜底方案而不是首选。这里还要提一嘴版本问题。PyPDF2已经更名为pypdf老代码里import PyPDF2的写法现在建议改成from pypdf import ...。我见过不少同事还在用已经停止维护的旧版PyPDF2写脚本有些API在新版里已经换了名字遇到报错先检查这个。2. 文本抽取把PDF里的字变成可用的数据2.1 pdfplumber的文本提取完整流程最基础的文本提取代码长这样import pdfplumber with pdfplumber.open(合同文件.pdf) as pdf: for i, page in enumerate(pdf.pages): text page.extract_text() if text: print(f--- 第 {i 1} 页 ---) print(text)这个写法人人都能跑通。但实际做项目时我几乎不会直接这样用原因在于PDF的文本布局经常不是按阅读顺序排列的。多栏排版、页眉页脚、表格线框内的文字extract_text()的默认行为可能会把两栏内容混在一起。解决方式是利用extract_text(layoutTrue)保留相对布局或者缩小提取区域# 只提取页面顶部区域的内容 bbox (0, 0, page.width, 200) # (x0, y0, x1, y1)单位是PDF的点 text page.within_bbox(bbox).extract_text()定位区域提取在处理票据、合同这类固定版式文档时非常有效。比如发票上“发票号码”的位置基本固定可以按坐标切出来做字段识别比全页文本正则匹配可靠得多。2.2 遇到扫描件时OCR是唯一的出路前面说过文本提取的前提是PDF里有真正的文本层。如果客户发来的合同是从扫描仪出来的整份PDF其实全部是位图图像拿pdfplumber去提取只会得到None。这种场景我的标准处理链路是pdf2image转图片pytesseract配合中文语言包做OCR。import pdf2image import pytesseract from PIL import Image # 需要本机安装 poppler并将 tesseract 的 chi_sim 语言包装好 images pdf2image.convert_from_path(扫描件.pdf, dpi300) all_text [] for page_no, img in enumerate(images, start1): text pytesseract.image_to_string(img, langchi_sim) all_text.append(f 第 {page_no} 页 \n{text}) with open(ocr_result.txt, w, encodingutf-8) as f: f.write(\n.join(all_text))这里有两个关键参数值得说明。一个是dpi我固定用300因为OCR识别率对分辨率非常敏感低于200识别质量明显下降高于300速度会拖慢但收益有限。另一个是langchi_sim中文文档必须显式指定不指定默认英文模型会把中文识别成乱码。OCR跑完千万别直接信结果。我遇到过表格数字被识别成相近字母的情况比如“0”变成“O”“8”变成“B”金额字段尤其要人工复核。可以先把OCR文本落盘成txt再用正则抽关键字段最后抽样人工比对。2.3 文本提取常见的乱码与排版错乱原因踩过的坑里最常见的三类第一类是字体子集化导致的乱码。有些打印系统为了减小文件体积会把字体子集化只保留用到的字形同时去掉Unicode映射。pypdf老版本遇到这种文件提取出来是乱七八糟的字符。pdfplumber和PyMuPDF对这种文件的容错稍好一些如果两个库都提取失败基本只能OCR。第二类是文本顺序错乱。某些PDF的文本流里字符绘制的顺序并不是视觉上的阅读顺序。块状散排、竖排文字都容易乱。这时候调layoutTrue会改善但如果PDF本身就没按顺序写入那就没法靠库的选项完全修复。第三类是空白字符丢失。文本在PDF里是按位置绘制的绘制指令之间未必有空格。extract_text()会尽量根据字符间距加空格但有时代码里的英文数字和中文混排间距小于阈值就出现了“我在做PythonPDF处理”这种词挤在一起的结果。遇到这种情况可以在提取后做一轮正则清洗比如给中文和英文数字之间补空格import re def fix_spacing(text): # 在中文和英文/数字之间补空格 text re.sub(r([\u4e00-\u9fff])([A-Za-z0-9]), r\1 \2, text) text re.sub(r([A-Za-z0-9])([\u4e00-\u9fff]), r\1 \2, text) return text这段函数我几乎每个文本提取项目里都会带上算是低成本高收益的标配。3. 表格和图片从PDF里挖出结构化信息3.1 表格提取的实操与参数调优财务对账单、银行流水、检测报告这类PDF里的表格才是真正有价值的数据。pdfplumber的extract_tables()是主力方法import pdfplumber with pdfplumber.open(对账单.pdf) as pdf: page pdf.pages[0] tables page.extract_tables({ vertical_strategy: lines, horizontal_strategy: lines, }) for table in tables: for row in table: print(row)这里有两个关键参数vertical_strategy和horizontal_strategy它们决定了表格线框的识别逻辑我根据经验对比一下strategy取值含义适用场景lines只依据绘制出的直线来切分单元格有完整表格线的扫描件或生成文档text依据文本间距推测单元格边界无线框的简约风表格explicit手动指定垂直/水平线坐标表格线不完整、自动识别跑偏时默认策略通常能处理大多数有线的表格但现实情况永远是有的线断、有的线粗、有的表格跨页。跨页表格是最头疼的pdfplumber默认每页独立处理表头不会自动识别。我的做法是把每页提取出来的表格行拼接然后单独处理表头重复的问题all_rows [] for page in pdf.pages: for table in page.extract_tables(): for row in table: # 过滤掉明显是表头的行可按关键字判断 if row and row[0] and 日期 in row[0]: continue all_rows.append(row)提取完的表格数据本质上是二维列表直接转成pandas DataFrame或csv都很快。但单元格里有换行的文本会提取成带\n的字符串清洗时要统一处理。3.2 图片提取把PDF里的图和照片抠出来有时候需求不是提文字而是把PDF里的图片全部导出比如产品手册里的插图、邮件附件里的图片型报告。PyMuPDF做这个最顺手import fitz # PyMuPDF doc fitz.open(产品手册.pdf) image_count 0 for page_no in range(len(doc)): page doc[page_no] images page.get_images(fullTrue) for img_index, img in enumerate(images): xref img[0] base_image doc.extract_image(xref) image_bytes base_image[image] ext base_image[ext] filename fpage{page_no 1}_img{img_index 1}.{ext} with open(filename, wb) as f: f.write(image_bytes) image_count 1 print(f共导出 {image_count} 张图片)这里有个概念要解释xref是PDF内部对象的引用编号一个页面用到的图片资源都在页面对象的资源字典里get_images()返回的元组中第一个元素就是xref。同一张图片被多处引用时xref相同导出时注意去重不然会得到一堆重复文件。我遇到过图片导出来后打不开的情况原因是PDF里嵌入了JPX图像但扩展名写成了jpg。稳妥的处理是用PIL重新校验一遍from PIL import Image import io img Image.open(io.BytesIO(image_bytes)) img.save(filename) # 让PIL根据实际格式重新保存4. 页面级操作合并、拆分、旋转与重排4.1 用pypdf做页面操作轻量又干净页面级的增删改查pypdf是主力。合并多个PDF文件是最典型的场景from pypdf import PdfReader, PdfWriter def merge_pdfs(pdf_list, output_path): writer PdfWriter() for fname in pdf_list: reader PdfReader(fname) for page in reader.pages: writer.add_page(page) with open(output_path, wb) as f: writer.write(f)拆分就更简单了把指定页面拷到新writer里from pypdf import PdfReader, PdfWriter reader PdfReader(大文件.pdf) # 提取第2页到第5页 writer PdfWriter() for i in range(1, 5): # 页码从0开始所以是第2到第5页 writer.add_page(reader.pages[i]) with open(部分文件.pdf, wb) as f: writer.write(f)旋转页面也很常见特别是扫描件经常歪page reader.pages[0] page.rotate(90) # 顺时针旋转90度rotate()是按当前角度做相对旋转顺时针传正值逆时针传负值。如果页面本身已经转过一次再次调用是在现有基础上叠加。需要绝对角度时可以用page.rotation 270这种赋值方式。4.2 按章节拆分PDF一个现实中的完整例子去年一个项目里客户提供了一本几百页的产品技术手册要求按目录拆成几十个单独的PDF文件。目录是结构化的Excel清单每一行对应一个章节的标题和页码范围。我的处理思路是先读Excel拿到每个章节的起止页码再循环调用pypdf切片。核心代码大致长这样import pandas as pd from pypdf import PdfReader, PdfWriter reader PdfReader(技术手册.pdf) chapters pd.read_excel(章节清单.xlsx) # 字段: 章节名, 起始页, 结束页 for _, row in chapters.iterrows(): writer PdfWriter() start int(row[起始页]) - 1 # 转成0基索引 end int(row[结束页]) # 切片右边界不含 for i in range(start, end): writer.add_page(reader.pages[i]) safe_name .join(c for c in row[章节名] if c not in \\/:*?|) with open(f拆分结果/{safe_name}.pdf, wb) as f: writer.write(f)这个例子看起来简单但实际推进时遇到了两个问题一是目录里的页码和PDF实际页面数对不上原因是PDF开头有几页封面没有算进页码体系解决方式是先扫描一遍PDF找到正文起始页面数做偏移修正二是章节名里带了特殊字符直接作为文件名会报错所以做了字符过滤。页面操作的另一个高频场景是把A3幅面的扫描件拆成多个A4输出。这时候用pypdf做不了内容裁剪得用PyMuPDF的set_cropboximport fitz doc fitz.open(A3扫描件.pdf) for page in doc: rect page.rect # 左半部分 left fitz.Rect(rect.x0, rect.y0, rect.x0 rect.width / 2, rect.y1) page.set_cropbox(left) # 然后输出新文档这个需求在办公自动化里遇到得非常多值得记下来。5. 从零生成PDF可打印文档的构建思路5.1 reportlab的基本绘制与布局处理PDF不只是“读”还有“写”。然后我发现网上关于“写”的教程明显比“读”的少。其实用reportlab生成报表、标签、票据、电子证书这类文档非常可靠。最基本的方式是canvas适合自由绘制from reportlab.pdfgen import canvas c canvas.Canvas(简单文档.pdf, pagesize(595.27, 841.89)) # A4尺寸单位是点 c.setFont(Helvetica, 12) c.drawString(72, 800, Hello, PDF!) c.rect(50, 50, 200, 100) c.drawString(72, 78, 这是一个矩形区域示例) c.showPage() c.save()这里有个容易懵的地方PDF坐标系的原点在页面左下角y轴向上。所以drawString(72, 800)把文字放在页面偏上的位置而不是像很多图片库那样从左上角起算。我第一次用reportlab时按照图片坐标思维来写结果所有元素都跑到了页面外面。如果要做结构化排版比如段落、表格、多页自动分页直接用canvas手绘会非常累。靠谱的做法是用platypusfrom reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle from reportlab.lib import colors doc SimpleDocTemplate(结构化文档.pdf, pagesizeA4) styles getSampleStyleSheet() content [] content.append(Paragraph(项目结题报告, styles[Title])) content.append(Spacer(1, 12)) table_data [ [项目名称, 负责人, 状态], [模拟项目X, 某开发者, 已完成], ] table Table(table_data) table.setStyle(TableStyle([ (GRID, (0, 0), (-1, -1), 0.5, colors.grey), (BACKGROUND, (0, 0), (-1, 0), colors.lightgrey), (FONTNAME, (0, 0), (-1, -1), Helvetica), ])) content.append(table) doc.build(content)platypus的核心思想是“把元素一个个append到Story列表里由框架自动排版和分页”。日常写报告脚本时我基本都走这个路线比手动控制showPage()轻松许多。5.2 中文字体的接入方案reportlab默认字体Helvetica不支持中文直接draw中文会报错或显示乱码。必须先注册系统里的中文字体文件from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont # Windows常见路径 pdfmetrics.registerFont(TTFont(SimSun, C:/Windows/Fonts/simsun.ttc)) # Linux下常见路径 # pdfmetrics.registerFont(TTFont(NotoSansCJK, /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc)) c.setFont(SimSun, 12)ttc是TrueType字体合集reportlab直接支持注册ttc文件不过如果你用的reportlab版本比较老遇到ttc读取问题可以考虑用FontTools把需要的字体重排成ttf或者直接改用思源黑体的单个ttf文件。字体文件本身也有版权问题商用项目里注意选择开源可商用的字体比如思源黑体、文泉驿正黑别直接拿系统自带的某些商用字体打包发给客户。platypus里的ParagraphStyle同样需要绑定中文字体cn_style ParagraphStyle( CNBody, fontNameSimSun, fontSize10, leading16, )6. 加密、水印、元数据让PDF达到交付标准6.1 设置密码与权限限制给导出的PDF加密码保护是给客户交付文件时常见的最后一步。pypdf的encrypt()可以直接完成from pypdf import PdfWriter, PdfReader reader PdfReader(原始文件.pdf) writer PdfWriter() for page in reader.pages: writer.add_page(page) # user_password是打开文件的密码owner_password是管理者密码 writer.encrypt( user_passworduser123, owner_passwordowner456, permissions_flag-1, # -1表示不限制权限 ) with open(加密文件.pdf, wb) as f: writer.write(f)补充说明一下这两个密码的区别user_password是给阅读者用的打开就需要owner_password是给自己留的管理权限没有它就不能修改权限设置。permissions_flag控制是否可以打印、复制、编辑等常用的几个值网上文档都有我一般直接传-1保留全部权限只要求打开密码。读取加密文件时reader需要显式传入密码reader PdfReader(加密文件.pdf) if reader.is_encrypted: reader.decrypt(user123)这里有个坑decrypt()成功返回1失败返回0。旧版PyPDF2在某些情况下即使返回1也会留下内部种子新版本pypdf处理得更干净一些。遇到解密后页面内容为空的情况检查一下是不是用的旧版库。6.2 水印叠加的两种实现路径水印需求分两种一种是给已有PDF叠加图片或文字水印另一种是生成PDF时直接把水印画进去。第一种用pypdf的merge_page实现from pypdf import PdfReader, PdfWriter # 准备一个单独的水印PDF可以用reportlab先做出来 watermark_reader PdfReader(水印.pdf) wm_page watermark_reader.pages[0] with open(目标文件.pdf, rb) as f: reader PdfReader(f) writer PdfWriter() for page in reader.pages: page.merge_page(wm_page) # 水印覆盖在原页面内容上 writer.add_page(page) with open(带水印文件.pdf, wb) as f_out: writer.write(f_out)merge_page是把水印页面的内容绘制到当前页面上原页面内容不会被覆盖水印里的透明背景很重要reportlab生成水印页时不要把背景设置成白色。第二种是生成报表时顺手画水印用reportlab的saveState和rotate可以做出斜向的水印文字c canvas.Canvas(带水印报表.pdf, pagesizeA4) # 画正文前先画水印 c.saveState() c.translate(A4[0] / 2, A4[1] / 2) c.rotate(45) c.setFont(SimSun, 40) c.setFillAlpha(0.1) # 透明度 c.drawCentredString(0, 0, 仅供内部使用) c.restoreState() # 然后正常绘制内容setFillAlpha(0.1)的意思是只有10%的不透明度水印就不会干扰正文阅读这个细节决定了水印的专业感。全不透名的大字水印糊在文字上客户看得眼睛疼。6.3 读取和写入元数据PDF的标题、作者、创建时间这些信息在文档管理场景里也经常需要批量修正。pypdf读写都很简单from pypdf import PdfReader reader PdfReader(文档.pdf) meta reader.metadata if meta: print(meta.title) print(meta.author) # 写入元数据 writer PdfWriter() for page in reader.pages: writer.add_page(page) writer.add_metadata({ /Title: 2025年第一季度对账单, /Author: 财务部, })注意元数据键名要带上斜杠前缀这是PDF内部对象命名规范。老一些的PDF文件元数据可能是乱码多半是编码没有标准化新版pypdf遇到这种情况会用UTF-8尽量解码不能完全指望重要的信息还是以正文提取为准。7. 实战总结我踩过的坑和长期有效的习惯7.1 坐标系与单位换算最容易出错的细节全文写到这里坐标系已经出现过好几次了。我单独拿出来强调是因为这是个隐蔽性很高的坑。PyMuPDF和pdfplumber坐标系的y轴方向是相反的pdfplumber沿袭PDF规范原点在左下角PyMuPDF偏向视觉习惯原点在左上角。当你需要“把pdfplumber定位到的区域交给PyMuPDF去渲染图片”时必须先做y轴翻转# pdfplumber坐标系下bbox (x0, y0, x1, y1) # 转成PyMuPDF坐标页面高度已知 y_top page_height - y1 y_bottom page_height - y0 fitz_rect fitz.Rect(x0, y_top, x1, y_bottom)这种跨库传坐标的场景工作中经常出现每次都要静下来理一遍。我建议在项目开始时统一封装一个坐标转换函数之后所有代码都走这个函数别在业务代码里到处手写换算不然早晚算错一次。7.2 批量处理时的内存与性能控制PDF处理脚本最常挂在批量场景。几百页的PDF如果用pdfplumber整份读入再处理内存直接拉满。我的习惯是能逐页处理的绝不全量加载# 不要这样 with pdfplumber.open(大文件.pdf) as pdf: for page in pdf.pages: process(page) # 虽然页对象是懒加载的但文本缓存会随着遍历累积 # 建议只保留需要的页面结果单独处理完一页就释放PyMuPDF处理大文件时速度明显占优它底层的C代码效率高。我批量渲染页面缩略图时都用PyMuPDF单页耗时可以做到几十毫秒级别。pdfplumber更适合追求提取精度的场景速度慢点但结果可靠。设置一个合理的超时和中断机制也值得做。PDF处理任务经常要跑很久脚本如果中途崩溃最好支持从断点续跑文件输出先写到临时目录全部成功后rename到正式目录。7.3 库版本与兼容性的现实问题最后聊一个容易被忽视的点Python处理PDF的库迭代速度挺快API变动也频繁。我整理几条实操建议pypdf是老PyPDF2的继任者新项目直接用pypdf别再用旧库。pdfplumber依赖pdfminer.six安装时注意不要手动装成pdfminer两者是不同项目的包。PyMuPDF的模块名是fitz第一次用的人都会困惑它和云存储里那个FITZ框架没关系纯粹是历史命名遗留。reportlab版本升级后部分字体注册方式有过变化升级大版本前先跑一遍现有脚本的回归测试。锁版本是另一个好习惯。pip install pypdf4.x.x这种精确指定版本的方式比pip install pypdf稳定得多。我吃过一次亏一次部署时新版本pypdf调整了某个内部结构导致线上脚本在处理加密文件时报错排查了一个多小时才发现是版本被动升级了。7.4 几个可以直接抄的兜底函数分享几个我会在几乎所有PDF脚本里备份的兜底函数。第一个是“提取失败时自动降级到OCR”的封装def extract_text_smart(pdf_path, page_index0): import pdfplumber try: with pdfplumber.open(pdf_path) as pdf: page pdf.pages[page_index] text page.extract_text() if text and len(text.strip()) 10: return text.strip() # 文本过短大概率是扫描页 raise ValueError(无文本层转为OCR) except Exception: return ocr_page(pdf_path, page_index)第二个是“安全文件名”的清理函数import re def safe_filename(name, max_length80): cleaned re.sub(r[\\/:*?|\r\n\t], _, name) cleaned cleaned.strip().strip(.) return cleaned[:max_length] or untitled这两个函数帮我省了非常多重复劳动现在写新脚本时直接复制粘贴再根据具体场景微调。做PDF处理这行最大的体会就是没有一个库是全能的也没有一个方案是万能的。把读取、提取、生成、操作拆成独立的环节每个环节选择合适的工具再用异常处理和坐标转换把它们串起来这才是稳定的工程方案。遇到难以处理的文件时先别急着怀疑库有bug先用pdfinfo之类工具看看这个文件本身是否正常、是文本型还是扫描型、用的是什么字体定位方向对了问题就解决了一半。