有一次我从客户发来的 PDF 里整理产品图,页面上图片清清楚楚,右键还能“另存为”,可真正批量导出以后,导出的图片不是尺寸不对,就是数量少了很多。后来换了个思路,用 Python 提取 PDF 中的图片,几行代码跑通,可一放到真实文件上,图片少、文件乱、原图变样、程序中途崩溃这些问题又全冒出来了。
那时候我就意识到,问题通常不出在代码本身,而在于很多人没有先把需求想清楚:你要的是 PDF 里的“原始图片素材”,还是页面上肉眼看到的那张图。这两类需求看着像一回事,实际完全对应着不同的技术路线,也会得到完全不同的结果质量。
这篇文章不是只给你一段能跑的代码,而是想把“用 Python 提取 PDF 图片”这件事拆成需求判断、最小实现、批量工程化、兜底渲染和排查链路几个层次来讲。你跟着这套思路走一遍,以后再拿到奇怪 PDF,就不至于每次都靠试。
1. 先想清楚:你要的是 PDF 里的“原始素材”,还是页面上的那张图
1.1 两种需求看起来一样,结果逻辑完全不同
第一种需求,是把 PDF 当作一个容器。你希望把里面嵌入的图片对象完整取出来,保留原始编码、原始尺寸、原始色彩信息。比如一个排版文件里包含了高质量的商品图,你要把它们还原成独立图片,这属于“提取素材”。
第二种需求,是把 PDF 的页面当作一张画布。你想把页面某一块区域的视觉效果保存成图片,比如从一张数据报表里截取一个图表区域,或者把扫描件里某个图形块裁出来,这属于“提取画面”。
这两种需求和最终输出并不完全等价。
PDF 里的图片对象,不等于阅读器上显示给你看的那张图。一张大图可以被转 90 度引用,可以被局部裁剪,可以被半透明遮罩叠加,也可以被内容流里的矢量图形盖住一部分。如果你直接导出图片对象,得到的很可能是原始未旋转、未裁切、未修饰的图片。而如果你用页面渲染工具去截取某个区域,得到的又可能是重采样之后的结果,不是原图质量。
所以第一步不是写代码,而是问自己:到底要原素材,还是要视觉结果。
1.2 为什么不能靠“找文件头、文件尾”的方式硬抠图片
很多人一上手会想到用 Python 读取二进制文件,然后通过 JPG 的文件头FF D8 FF或者 PNG 的文件头去扫描数据。这个思路对某些简单 PDF 也许能跑通,但非常脆弱。
原因有两个。
第一,PDF 内部不是一张张图片顺序排列的,图片数据可能经过压缩、编码、拆包,也可能被对象间接引用,文件里的字节顺序和你读到的视觉顺序没有直接关系。
第二,PDF 既有扫描文本,也有翻录,还有被压缩进对象流里的小对象。直接通过二进制扫描,很难准确判断一个图片数据的起止位置,很容易把图片截断或者误识别成乱码。
正确的方式是先理解 PDF 的底层结构:图片通常是页面内容流里引用的一种 Image XObject。它带有宽度、高度、滤镜、色彩空间等参数,图片数据本身按对应编码存放在 PDF 对象中。PyMuPDF 这类库做的事情,就是把这些细节封装成高层接口,让你不需要手动拼装对象引用关系。
1.3 理解一点对象概念,比多抄几段代码更有用
你不需要成为 PDF 规范专家,但至少应该知道几个关键词。
PDF 文件的基本单位是“对象”,每个对象有编号。页面对象通过资源字典去引用图片对象,图片对象在 PDF 里通常以/Subtype /Image的形式存在。真正要导出图片时,脚本要做的事可以简化成:先找到页面里引用了哪些图片对象,再按对象编号去 PDF 顶层对象里把图片数据取出来。
PyMuPDF 的page.get_images(full=True)就是负责找引用,doc.extract_image(xref)就是负责按对象编号取数据。你只要把这两个接口组合起来,就能写出一个最小可用版本。
先掌握这个对象思维,后面遇到问题时,排查方向就会清晰很多。你遇到的很多“图片提取不出来”的奇怪现象,本质上不是 Python 语法问题,而是“图片所在的位置”和“提取工具寻找的位置”不匹配。
2. 最小闭环:用 PyMuPDF 把嵌入的图片完整导出
2.1 环境准备:为什么安装 PyMuPDF 之后,导入的模块叫 fitz
先说安装,最简单的命令是:
python -m pip install PyMuPDF这里有一个很常见的困惑:装的是 PyMuPDF,代码里写的却是import fitz。
这是历史遗留命名,不是要故意绕晕你。PyMuPDF 在 Python 环境里对外的导入模块一直使用fitz,后续文档也在延续这个习惯。如果你遇到ModuleNotFoundError: No module named 'fitz',不要改成安装一个叫fitz的别的包,优先检查当前命令行使用的 Python 环境和 pip 安装环境是不是同一个。
在 Windows 上最容易出现这种问题,因为系统里可能同时存在多个 Python。最稳妥的方式不是直接执行pip install,而是用当前解释器对应的 pip 命令来安装:
python -m pip install PyMuPDF这样保证包被你当前正在用的 Python 环境接收。
如果还要对图片做校验、裁剪、格式转换,建议顺手安装 Pillow:
python -m pip install Pillow2.2 一个能真正把原图落盘的最小脚本
下面这个脚本结构比较保守,用 try/finally 保证 PDF 文件最终会被关闭。你不要小看这个细节,批量跑几百个 PDF 时,文件句柄不释放会带来很隐蔽的资源占用问题。
import os import fitz def extract_images_from_pdf(pdf_path, out_dir="images_out"): os.makedirs(out_dir, exist_ok=True) doc = fitz.open(pdf_path) saved_count = 0 used_xrefs = set() try: for page_index in range(len(doc)): page = doc.load_page(page_index) page_images = page.get_images(full=True) if not page_images: print(f"第 {page_index + 1} 页:没有找到图片对象") continue for xref in {item[0] for item in page_images}: if xref in used_xrefs: continue try: info = doc.extract_image(xref) except Exception as exc: print(f"第 {page_index + 1} 页提取图片失败,xref={xref},原因:{exc}") continue image_data = info.get("image") image_ext = info.get("ext", "bin") if not image_data: continue out_path = os.path.join(out_dir, f"img_{xref:05d}.{image_ext}") with open(out_path, "wb") as f: f.write(image_data) used_xrefs.add(xref) saved_count += 1 finally: doc.close() print(f"完成,共保存 {saved_count} 张图片") return saved_count if __name__ == "__main__": extract_images_from_pdf("input.pdf")这段代码只做了一件事:遍历每一页,找到页面引用的图片对象,按对象编号去 PDF 里取原始图片数据,然后直接写成文件。
这里有一个非常关键的选择:保存图片数据时,直接用extract_image返回的image字段和ext扩展名,中间没有经过任何图片解码。这意味着你要保存到磁盘的是 PDF 里嵌入的原始二维码和原始数据,而不是重新编码后的数据。
对于 PDF 内部的 JPEG 图片,直接保存成.jpg通常是安全的。对于 PNG、JPX 等你没见过的扩展名,也不要急着改后缀,先用文件头校验工具确认格式,再用图片浏览器打开看看。
2.3 extract_image 返回了什么,脚本才能判断怎么处理
很多人把get_images的结果直接当成图片内容来写文件,结果写出来的东西打不开。
实际上,get_images(full=True)返回的是一批元组,第一个元素才是图片对象的编号xref。真正要拿到图片数据,需要进一步调用doc.extract_image(xref)。
extract_image的返回结果通常是一个字典,里面有这些信息需要注意:
image:图片数据本体,可能已经是 JPEG 或 PNG 编码后的字节,直接用二进制方式写入文件即可。ext:推荐使用的扩展名,常见可能是jpeg、png、jpx、jb2这类值。width和height:图片的原始像素尺寸,注意不一定是页面上显示的尺寸。colorspace:颜色空间,比如 RGB、GRAY、CMYK。
不要在extract_image返回的字典里做太强的主观判断,因为不同 PDF 结构会有差异。比较稳妥的做法是:先获取image和ext,保存完用 Pillow 或文件类型工具验证,生产级脚本里再考虑用哈希去重。
注意:如果只执行
page.get_images(full=True),你拿到的只是图片对象的信息,不是图片内容。你必须把xref传给doc.extract_image(xref)之后才能拿到可写入文件的字节流。
2.4 第一次跑完以后,别急着欢呼
单页 PDF 跑通,只能说明路径没有断。这时候你应该先做一个校验,而不是立刻铺到 1000 个 PDF 上。
我建议的校验顺序是:
- 用普通图片查看器打开导出文件,确认不是乱码。
- 对比 PDF 页面里的图片数量和实际导出数量。
- 检查尺寸特别小、特别大的图片,确认是否合理。
- 用 Pillow 打开几张有代表性的图片,读取
size和mode,确认没有在解码层出问题。
如果这一步出现“数量对不上”,先不要急,大概率不是 Python 代码的 bug,而是 PDF 本身对同一张图片的引用方式和视觉呈现方式不同。
3. 从“跑通一次”到“批量处理”:该补脚本能力而不是循环
3.1 去重:同一个对象出现在多个页面,不应该重复保存
一份真实的 PDF,尤其是那种从办公软件导出的文件,经常会出现同一个 Logo、同一个背景素材在多页里反复出现的情况。
如果简单按“页码 + 图片序号”来命名,会产生大量重复文件。这里可以通过xref去重,因为同一张嵌入图片在 PDF 里往往会共享同一个对象编号。上面的最小脚本里已经加入了used_xrefs集合,核心逻辑就是用xref判断这张图是不是已经处理过了。
但这里还有一个更隐蔽的情况:有时候同一个视觉图片会被保存成两个不同的对象编号,比如一个文件被重复嵌入两次。如果你确定要去重到“内容级别”,可以用内容哈希来处理。
import hashlib def image_key(data: bytes) -> str: return hashlib.sha256(data).hexdigest()在保存前先计算image_key(image_data),如果这个 key 已经出现过了,就跳过。
哈希去重的价格是额外的 CPU 和内存开销。如果 PDF 本身非常大、图片数量很多,建议先用xref集合去重,把大部分明显重复挡掉,再决定是否做内容哈希。
3.2 过滤干扰对象:不要把所有东西都当作有效图片保存
真实 PDF 里的图片对象千奇百怪,常见会干扰你判断的对象包括:
- 只有几个像素宽的小图标或纹理。
- 用于透明效果的 SMask 遮罩对象。
- 用于页面平铺背景的 pattern。
- 表单字段的外观流。
- 被隐藏图层里的图片。
如果你直接把所有图片都导出,会得到一堆看起来没用的文件。比较好的过滤策略是先看尺寸和内容类型。
一个通用做法是,把width或height小于一定阈值的图片标记为低价值。不过阈值不能一刀切,有些 PDF 里的商品缩略图可能本来就只有 200 像素宽。建议第一次先用“不删文件只打印统计信息”的方式运行,把尺寸分布打出来,再决定要不要真正过滤。
还可以在 Pillow 层验证:如果 Pillow 打开图片后能确认它不是空图、不是纯色马赛克,通常说明这张图的二进制流是完整的。
注意:过滤逻辑宁可保守,不要激进。直接把小于 100 像素的图片全删掉,容易误伤重要素材。更安全的做法是先加一个“低价值目录”,把疑似噪声单独放,不污染主输出目录。
3.3 日志、写入校验和异常恢复,少一个都很难收场
批量处理时,最常见的失败不是提取逻辑本身,而是文件系统问题、PDF 损坏、权限受限和路径冲突。
建议给脚本补充几件事:
- 每一条失败记录都要打印页码或 xref,不要只打印一句“提取失败”。
- 图片写入以后,最好校验一次文件大小不是 0。
- 不要因为一张图失败就中断整个 PDF。
- 对多 PDF 批量任务,建议给每个 PDF 单独建立输出目录。
这样即使跑到第 500 个 PDF 时出了问题,你也能快速定位到是哪个输入文件、哪个 xref 出错,而不是盯着控制台看只有一行FileNotFoundError。
3.4 一个更实用的批量脚本骨架
在进入真实项目前,我会把脚本组织成三层结构:单个 PDF 处理函数、批量遍历逻辑、主入口参数解析。
import os import glob import hashlib import fitz def extract_single_pdf(pdf_path, out_dir): os.makedirs(out_dir, exist_ok=True) doc = fitz.open(pdf_path) used_xrefs = set() stats = {"total": 0, "saved": 0, "skipped": 0, "failed": 0} try: for page_index in range(len(doc)): page = doc.load_page(page_index) images = page.get_images(full=True) stats["total"] += len(images) for xref in {item[0] for item in images}: if xref in used_xrefs: stats["skipped"] += 1 continue try: info = doc.extract_image(xref) except Exception as exc: stats["failed"] += 1 print(f"extract failed: {pdf_path} page {page_index + 1}, xref {xref}, {exc}") continue data = info.get("image") ext = info.get("ext", "bin") if not data: stats["failed"] += 1 continue digest = hashlib.sha256(data).hexdigest()[:16] out_path = os.path.join(out_dir, f"{digest}.{ext}") if os.path.exists(out_path): stats["skipped"] += 1 continue with open(out_path, "wb") as f: f.write(data) stats["saved"] += 1 used_xrefs.add(xref) print(f"{pdf_path}: saved={stats['saved']}, skipped={stats['skipped']}, failed={stats['failed']}") return stats finally: doc.close() def process_many(pdf_dir, out_root): for pdf_path in glob.glob(os.path.join(pdf_dir, "*.pdf")): basename = os.path.splitext(os.path.basename(pdf_path))[0] target_dir = os.path.join(out_root, basename) extract_single_pdf(pdf_path, target_dir)这个骨架并不是最精简的,但它能覆盖大多数批量场景:去重、容错、独立目录、统计信息都齐了。如果你只需要临时处理一份 PDF,可以去掉批量遍历层。
4. 页面里明明看得到图,对象列表里却拿不到,怎么办
4.1 你看到的不一定是嵌入图片对象
有几种情况会让get_images返回空列表,但页面仍然有图形内容。
第一种,图片是矢量图。页面里看到的是一个用路径、曲线和颜色填充组成的矢量图形,而不是嵌入的位图对象。此时不存在“图片 XObject”,自然也没有原图可提取。你要么接受矢量结果,要么把页面渲染成图片。
第二种,图片被嵌在复杂的表单 XObject 或内容流里。PyMuPDF 虽然已经会递归搜索页面资源,但遇到非常规的 PDF 结构时,你仍然可能拿不到想要的对象编号。
第三种,页面显示的是图片,但整个图被放在一个容器或裁剪区域里。你从对象层面拿出来的图片是完整的,视觉上却被裁剪了。
遇到这种情况,不要反复折腾get_images。你应该把策略切换成“渲染页面区域”,直接按视觉看到的内容来生成图片。
4.2 兜底策略:按位置渲染页面局部区域
PyMuPDF 的页面对象渲染能力此时更有用。你可以先通过page.get_image_info(xrefs=True)获取图片在页面上的位置信息,再用渲染功能把对应区域画成一张新的 PNG。
import fitz def render_region(pdf_path, page_index, bbox, output_path, zoom=2): doc = fitz.open(pdf_path) try: page = doc.load_page(page_index) clip = fitz.Rect(bbox) matrix = fitz.Matrix(zoom, zoom) pix = page.get_pixmap(matrix=matrix, clip=clip) pix.save(output_path) finally: doc.close()这里的bbox一般是一个四元组或 Rect,表示页面坐标系里的左上角 x、左上角 y、右下角 x、右下角 y。直接打印image_info["bbox"],把它作为clip传入即可。
zoom参数影响导出的清晰度。90 是常见的 PDF 页面基础分辨率,zoom=2意味着按 2 倍去渲染。如果你需要放大原区域的清晰度,可以继续提高 zoom,但渲染耗时和内存占用也会增加。
4.3 “原图”和“视觉图”的差异,在导出素材场景下不能混用
做素材导出时,我用一个很朴素的判断标准:
如果对方要的是“可以直接放进设计稿的高清原图”,优先用extract_image拿原对象数据。如果图片在 PDF 里已经被裁剪过,对方要的是“页面里那个可见区域”,那么原对象反而不能直接交付,因为裁剪、旋转、缩放都没有体现在导出对象上。
这两种结果没有绝对的好坏,只是适用场景不同。
最稳妥的方式是,同一个 PDF 可以同时生成两个文件夹:originals存extract_image拿到的原始素材,rendered存按页面视觉裁剪出的区域。先让使用者自己看哪个符合需求,再根据结果固定代码逻辑。
注意:如果要处理的是加密 PDF 或设置了使用权限的文档,请先确认自己有权访问和提取内容,并且使用合法获取的密码进行解密后再操作。不要尝试绕过文档访问控制。
5. 别急着选库:先了解边界,再决定用哪条路线
5.1 几个常见 Python 库的大致定位
不同库处理 PDF 图片的侧重点不同,下面是一个粗粒度的对照:
| 库名 | 适合任务 | 主要限制 |
|---|---|---|
| PyMuPDF | 遍历页面图片对象、提取原图、渲染页面区域 | 不是纯 Python,依赖底层二进制库 |
| pypdf / PyPDF2 | 处理页面结构、合并拆分、基础图片文件对象提取 | 对图片滤镜、复杂 XObject 的覆盖力不如 PyMuPDF |
| pdfplumber | 解析文本、表格、页面坐标信息 | 不是为了提取图片而设计,图片处理不是最佳选择 |
| PyPdfium2 | 页面渲染场景 | 主要用于把 PDF 画成图像,不是素材提取首选 |
| pdfimages | 命令行工具,用于对照结果 | 不是 Python 库,但在批量验证时很好用 |
我的建议是:优先用 PyMuPDF 跑通最小流程,因为它在“找图片对象”和“渲染页面”两方面都覆盖得比较完整。pypdf 在纯粹做 PDF 元数据操作时也非常方便,但用它提取复杂 PDF 里的图片,你需要处理更多底层细节。
5.2 一个通用排查链路
当你发现提取结果不对时,按下面这个顺序排查会比乱猜高效很多:
- 先看现象:是根本没有输出,还是图片数量不匹配,还是输出文件打不开,还是程序直接崩溃。
- 再看输入:PDF 是否加密,是否损坏,文件扩展名是否只是前缀,内容是否为空。
- 再确认图片类型:页面上到底是不是位图,是矢量绘图,还是嵌入的图片对象。
- 检查 xref:用
get_images是否有返回,返回的xref是否能被extract_image正常解析。 - 检查格式:输出文件的扩展名是否正确,文件大小是否为 0。
- 最后用渲染兜底:如果对象层拿不到,就用
get_pixmap渲染页面区域。
这套链路看起来简单,却能覆盖绝大多数“图片为什么提取不出来”的疑问。
5.3 工程化建议:把脚本当成可以被下班后重新跑的东西来写
一旦处理量到了几十份 PDF 以上,脚本就要往“可重复、可审计、可恢复”的方向设计。
我会把输出的每一步都记录下来。不是让你上一套复杂日志框架,而是在关键节点打印文件名 + 页码 + 结果。最少要能回答三个问题:哪一份文件跑过了,成功多少张,失败在哪里。
如果同一个任务需要反复修改参数,建议使用命令行参数而不是改代码:
python extract_pdf_images.py --pdf_dir ./input --out_dir ./output --min_width 50同时把输出目录按输入文件名分开,避免多份 PDF 的图片互相覆盖。
注意:如果只是临时跑一次,默认配置通常够用。如果要长期维护,还需要考虑磁盘空间、重复文件清理、任务断点续跑和关键路径是否存在这些工程问题。
最后说一个实际经验
我见过很多同学照着一篇教程复制代码,把图片提取出来以后,存到自己都分不清的地方。下一次遇到新 PDF,又把代码翻出来改两行,再次碰运气。
真正高效的做法,其实是在动手前先明确两个定义:第一,输出物是“原始素材”还是“视觉区域”;第二,脚本需要满足“单次可用”还是“批量反复使用”。
只要这两个问题回答清楚了,技术路线基本就定了:需要原始素材,就去页面资源里找图片对象;需要视觉区域,就做页面渲染;要反复使用,就补上日志、去重、异常处理、目录规划和结果校验。
PDF 格式本身并不复杂,但它隐藏的边界情况非常多。图片提取这件事,真正难的不是不会写 Python,而是没有意识到 PDF 里“图片数据和图片显示”是两套逻辑。理解了这个,你提取图片的准确率和稳定性都会比那些只会复制代码的人高一个档次。