批量把图片转成 PDF,这个需求听起来特别基础,基础到很多人的第一反应都是“随便找个工具不就行了”。但我敢打赌,真正被这个需求折磨过的人,一定不是在找工具,而是在找一个能一次搞定、不把几百张图传上云端、不乱序、能留着以后反复用的批量处理方案。我自己就是在一个需要交付 300 多张工程扫描件的下午,看着 Windows 自带的右键打印缩略图界面一张张翻页面,崩溃到决定写脚本解决的。这篇博文就把我最终落地的方案完整拆开:一个基于 Python + Pillow 的批处理脚本,既能扫描文件夹(含子文件夹)下所有图片合并成一个 PDF,也能把每张图分别转成独立的 PDF,还做成了拖拽文件夹就能跑的可执行批处理。
这个脚本适合谁?三类人:第一类是像我一样需要定期整理扫描件、拍照资料、课程讲义的人;第二类是给客户交付订单截图、产品图册、照片合集时不想一个个另存为的人;第三类是想学 Python 但又不想上来就从 hello world 开始的开发新手——因为这个例子麻雀虽小、五脏俱全,遍历、排序、批处理、异常处理、命令行参数全都有。读完你可以直接抄走,也可以顺着这个思路套到自己的场景里。
1. 从“手忙脚乱地另存为”说起:这个脚本到底解决了什么问题
1.1 我当时的崩溃现场
那次项目收尾,需要把一个文件夹里的 300 多张现场照片整理成一本带页码的 PDF 文档交付给客户。我最早尝试的是“右键图片 → 打印”,Windows 自带的照片打印向导。这个方案在只有十来张图时挺好用的,可是到了 300 张的规模,问题立刻暴露:第一次只显示前 100 张,要手动往下翻找具体的照片;想调顺序只能在列表里一张张拖;打印设置里的边框、方向一旦改错全都要重来。折腾了快四十分钟才拼出 100 多张,中途软件还卡死了一次。
之后我用过 Adobe Acrobat 的合并文件功能,确实能批量合并图片,但那是付费工具,不是每个人电脑里都有。也试过在线转换网站,传 300 张原图上去,上传就花了十几分钟,而且页面底部还有一堆诱导下载的按钮,图片内容也等于是先在别人服务器上过了一遍。我当场就把网页关了——有些资料有保密要求,不能随便传。
1.2 把需求拆开了看,其实就两条
很多人把“图片转 PDF”想成一个需求,但实际操作中其实是两个截然不同的场景:
- 场景 A:整个文件夹(含子文件夹)的所有图片,按顺序合并成一个 PDF。例如一本扫描书的多章目录、一次活动全部照片的合辑、工程现场的验收照片合集。
- 场景 B:文件夹里的每一张图片,各自生成一个独立的 PDF。例如合同扫描件、身份证复印件、产品白底图等,需要单独归档或逐个发送。
市场上大多数工具侧重其中一种,做合并不支持提取单张,做分图则不支持合并。我们的脚本只需要多一个模式参数,两种全都要。
1.3 为什么需要编程方案而不是继续找软件
我后来把常用方案列了个对比表,这也是我在写脚本前认真想过的选型过程:
| 方案 | 能合并 | 能分图 | 离线处理 | 一次处理几百张 | 可控排序 | 成本 |
|---|---|---|---|---|---|---|
| Windows 右键打印 | 勉强 | 不支持 | 是 | 很卡 | 难 | 系统自带 |
| Word/WPS 插入图片导出 | 能 | 支持 | 是 | 卡到怀疑人生 | 还可以 | 需安装 |
| Adobe Acrobat | 能 | 能 | 是 | 流畅 | 可以 | 付费 |
| 在线转换网站 | 能 | 能 | 否 | 有限制 | 难 | 免费但不安心 |
| 自写 Python 脚本 | 能 | 能 | 是 | 流畅 | 可精确控制 | 免费 |
结论很清晰:对于经常和文件打交道的人来说,花半小时写一个能复用一百次的脚本,远比下次找个新软件重新趟坑划算。而且脚本可以随时改排序规则、加水印、换文件名格式,这些都是“不会编程”就没法做到的事。
2. 方案选型踩过的弯路:为什么最后敲定 Python + Pillow
2.1 我尝试过的其他“捷径”
说实话,我一开始也没打算写代码,毕竟这需求听起来太简单了。我先试过的几个方案各有各的坑:
第一个试的是WPS/Word 插入图片再另存为 PDF。图片一多,Word 直接变成一个巨型文档,拖拽滚动都开始卡顿,导出 PDF 时还会出现个别图片被压缩模糊的情况。这不是 Word 的错,是这个场景根本不适合它——Office 类软件更适合排版,而不是批量图片转换。
第二个试的是一款免费开源的小工具。能合并,但合并后的图片顺序完全按操作系统返回的顺序排,我那一批文件明明名叫“01_001.jpg、01_002.jpg……02_001.jpg”,结果它排成了“01_001、02_001、01_002、02_002”这种字典序跳跃,需要手动在界面里调半天。而且这类小工具有的直接来自不明网站,带不带捆绑软件我心里没底。
第三个试的是Python 最低级的字符串排序。这个不算工具,是我第一次写合并脚本时的天真想法:直接sorted(os.listdir(folder))。跑出来才发现,图片顺序变成了 1.jpg、10.jpg、100.jpg、11.jpg……这就是经典的自然排序问题,字符串排序和人类认知中的数字顺序完全不同,后面专门用一节来讲。
最终方案选了Python 3.8 + Pillow(PIL)库 + natsort 排序库,原因很朴素:Pillow 是图像处理的事实标准,原生支持把多张图片保存为 PDF 文件,不需要额外安装任何图像转换组件;natsort 只有 0 依赖,就做一件事——按人类直觉排序文件名。
2.2 为什么“离线”这一点这么重要
我在选型时把“离线处理”放进了硬性条件。原因是 2023 年之后,我发现很多同事在公司电脑上根本无法访问境外在线工具,连打开都会直接超时;就算能打开,也有部分公司网络策略会拦截大文件上传。再说直白一点,把包含个人信息、合同编号、客户名称的图片丢到不明网站上,一旦出事说不清楚。所以自建离线脚本不只是技术洁癖,更是对数据负责。
2.3 环境准备里的两个小提醒
安装依赖很简单:
pip install pillow natsort但这里有两点需要提醒:
- 请务必在虚拟环境里装。Windows 用户直接在命令行执行 PIP 安装可能会遇到“拒绝访问”或污染系统 Python 环境的问题,建议在项目目录执行
python -m venv venv激活后再安装,Linux/macOS 同理。 - Python 版本建议 3.8 以上。Pillow 新版虽然还有分支支持,但旧版本在某些压缩选项和 DPI 参数上行为有差异,我踩过一次坑,后面细说。
3. 核心代码拆解:扫描文件夹、自然排序、批量输出PDF
3.1 完整脚本骨架
先看整体流程,我用--mode来区分“合并模式”和“分图模式”,这样两种需求就用一份代码解决:
import argparse import os from pathlib import Path from PIL import Image import natsort IMAGE_SUFFIXES = (".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tif", ".tiff") def collect_images(root_dir, recursive=True): """收集目录下的所有图片路径,按路径排序返回。""" files = [] if recursive: for dirpath, _, filenames in os.walk(root_dir): for name in filenames: if name.lower().endswith(IMAGE_SUFFIXES): files.append(os.path.join(dirpath, name)) else: for name in os.listdir(root_dir): full = os.path.join(root_dir, name) if os.path.isfile(full) and name.lower().endswith(IMAGE_SUFFIXES): files.append(full) # 自然排序是关键,直接字符串排序会出现 10 排在 2 前面 return natsort.natsorted(files) def open_image_safe(path): """打开图片并强制转换为 RGB,否则 PDF 保存会报错。""" try: img = Image.open(path) if img.mode != "RGB": img = img.convert("RGB") return img except Exception as e: print(f"[警告] 无法读取图片: {path},原因: {e}") return None def merge_to_single_pdf(image_paths, output_path, dpi=150.0): """把多张图片拼成一个多页 PDF。""" if not image_paths: print("没有找到任何图片。") return first = open_image_safe(image_paths[0]) if first is None: print("第一张图片读取失败,终止合并。") return rest = [] for p in image_paths[1:]: img = open_image_safe(p) if img is not None: rest.append(img) first.save( output_path, "PDF", save_all=True, append_images=rest, resolution=dpi, ) print(f"已生成 PDF: {output_path},共 {len(rest) + 1} 页") def each_image_to_pdf(image_paths, output_dir=None): """每张图片单独生成一个 PDF。""" if output_dir is None: output_dir = Path(image_paths[0]).parent Path(output_dir).mkdir(parents=True, exist_ok=True) for i, p in enumerate(image_paths, start=1): img = open_image_safe(p) if img is None: continue name = Path(p).stem out = Path(output_dir) / f"{name}.pdf" img.save(out, "PDF", resolution=200.0) print(f"[{i}/{len(image_paths)}] {out}")代码逻辑本身不复杂,但有几个点值得展开讲:它的价值不在代码量,而在于处理了我在实际批量处理中遇到的大部分边界问题。
3.2 为什么必须用自然排序而不是默认的 sort
这是最容易踩的一个坑,也是几乎所有“图片顺序不对”抱怨的真正根源。假设文件夹里有这些文件:1.jpg、2.jpg、10.jpg、20.jpg。如果用系统默认的字符串排序,结果是:1.jpg、10.jpg、2.jpg、20.jpg。因为字符串比较是一位一位比过去的,“1”和“10”比的时候,“1”和“1”相等,继续比第二位,1.jpg到这里结束,所以排在10.jpg前面;随后再逐位比较2和20。这样生成的 PDF 页码顺序就乱套了。
natsort 库专门解决这个问题,它会把连续数字识别为一个整体按数值比较,所以1、2、10、20就是人类直觉的顺序。如果你的文件名是相机输出的IMG_20240101_001.jpg,里面同样嵌入数字,natsort 也能正确处理。这一行return natsort.natsorted(files)是整个脚本质量的分水岭。
3.3 Pillow 保存 PDF 的底层原理
Pillow 保存 PDF 时用到了两个关键参数:save_all=True和append_images。
原理其实很好理解:Pillow 在保存单个图像时,默认只保存一帧;而save_all=True告诉它“这是多帧文档”。对于 PDF 格式而言,每一帧就对应 PDF 的一页。append_images接收一个可迭代对象,里面是除第一张以外的后续页面图像列表。
我特意把这个机制写出来,是为了说明一个实操要点:第一张图片不能用 append_images 传,必须作为主图像传给 save 函数。我看到过不少新手照着网上的代码抄,把所有图片都塞进append_images,然后报错“TypeError: 'Image' object is not iterable”或者输出 PDF 只有一页,其实就是第一帧没传对位置。
PDF 里的 DPI 参数也值得唠叨一句。resolution参数不是把图片放大或缩小,它只影响 PDF 打开时显示的物理尺寸。默认值是 72 DPI,这会导致某些阅读器打开时把 PDF 显示得很大,预设 150 或 200 更接近打印场景。但要注意,这个参数不能提高图片本身的清晰度——如果原图只有 800 像素宽,把 DPI 改成 300 也救不回来。优化清晰度要在扫描或拍摄阶段解决,而不是在 PDF 阶段。
3.4 分图模式的细节:同名文件和输出目录
每个图片单独转 PDF 看起来简单,但执行时第一个问题就是:同名文件覆盖怎么办?比如照片.jpg和照片.png在同一个文件夹里,按代码逻辑都会生成照片.pdf,后生成的会覆盖先生成的。我最初没处理这点,某次目录里既有 jpg 又有 png,结果一批文件直接少了几个。后来的处理方式是输出文件名带上原始序号:照片_001.pdf、照片_002.pdf。
还有个细节是输出目录可以指定到另一个文件夹,这样原目录不会被 PDF 文件污染。尤其是合并模式,默认输出在源文件夹内,但如果用户希望一批原图保持不变,就不要让 PDF 混进原图目录,免得下次扫描图片时把 PDF 也当图片扫进去了——当然我们的脚本按后缀过滤不会犯这种错,但目录整洁度还是要考虑。
4. 真机调试中遇到的三个坑:透明底、超大图、乱序文件
这里记录的是脚本从“能跑”到“跑得稳”的过程中真实踩过的坑,每个都花了不止一次调试。
4.1 RGBA 透明图报错:cannot write mode RGBA as PDF
第一次拿真实照片目录测试,前面几十张都好好的,跑到一张 PNG 时就报了个错误,核心信息是cannot write mode RGBA as PDF。我当时第一反应是拿透明背景的图片(比如抠图后的商品图、带透明通道的截图)去转换了。
问题根源:PNG 支持 RGBA 四通道(RGB 三通道 + Alpha 透明通道),而 PDF 这种页面文档格式在没有额外交互特性注入时,Pillow 并不支持直接写入带透明通道的图像数据。网上很多教程直接写img.convert("RGB"),看似能绕过报错,但结果通常不是你要的:
- 如果直接
convert("RGB"),Pillow 会把透明区域变成黑色。 - 我们真正期望的背景色,通常是白色(纸张颜色)。
正确的做法是先贴到一张白色背景画布上,再转换成 RGB:
def convert_to_white_background(img): if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info): rgba = img.convert("RGBA") background = Image.new("RGB", rgba.size, (255, 255, 255)) background.paste(rgba, mask=rgba.split()[-1]) return background return img.convert("RGB")这个坑提醒我们:看似一行convert("RGB")能解决的问题,真实业务里还得看原图有没有透明通道,有没有颜色模式是“P 模式”的调色板 GIF 或旧式 PNG。只有实际扫过一批杂七杂八的图,才会意识到图像格式的世界有多不统一。
4.2 超大目录一次性读入内存:合并到第 800 页时崩溃了
项目初期我收集完路径后直接把所有图片全部open(),存进一个列表,再统一传给append_images。处理 100 张图片时没问题,于是我很自信地拿一个装满 800 张扫描件的目录测试,结果跑到近 800 张时脚本直接卡死,任务管理器里内存占用已经到 4GB。
为什么?因为 Pillow 的 PDF 保存逻辑需要同时访问所有页面的图像数据。如果你把所有Image.open()的对象放在列表里,哪怕你还没逐帧调用load(),图像文件句柄也会占用大量资源。尤其那些扫描件,单张 20MB 的大图,800 张就是 16GB 的原始内存压力。
当时我的处理办法是:先做一个“预扫描”流程,把超过一定像素边长的大图等比缩小到 2000px 以内再进入 PDF 生成流程。对于交付查看用的 PDF 来说,2000px 宽度在屏幕上已经很清晰;如果是需要打印巨幅海报的场景,那你本身就该用专业排版软件,而不是小脚本。
def resize_if_too_large(img, max_side=2000): """防止超大图把内存耗尽。""" width, height = img.size max_dim = max(width, height) if max_dim <= max_side: return img ratio = max_side / max_dim new_size = (int(width * ratio), int(height * ratio)) return img.resize(new_size, Image.LANCZOS)另外,还可以采用真正意义上的流式追加:Pillow 的 PDF 保存并不支持“逐个追加”写法,它需要所有图像一次性传入。所以如果图片数量极大(比如 5000+),建议按子文件夹分卷输出,每个子文件夹生成一个 PDF,而不是强行合并成一个巨型文件。这很符合“每个子文件夹一个 PDF”的使用习惯,而且打开巨无霸 PDF 时阅读器也未必流畅。
4.3 文件名排序混乱与无法读取的“伪图片”
乱序问题前面已经提到,这里再说一个实际环境里更隐蔽的场景:同一个文件夹里混着第1章、第10章、第2章这种中文数字和阿拉伯数字混排的名字。natsort 对纯阿拉伯数字非常有效,但如果遇到“第1章、第10章、第2章”这种中文序数词,natsort 也只能按它识别出的数字片段排序,结果是第2章会排在第10章之前,因为切片里的数字分别是 1、10、2。如果你的目录命名方案没有强规则,建议预处理阶段统一做日期或序号前缀。
另外,图片后缀是.jpg但文件内容已经损坏、或者是被改过后缀名的其他格式,Image.open()打开时不会立刻报错,直到读取像素数据才抛异常。我在open_image_safe()里用try...except统一捕获,凡是读不出来的文件直接跳过并输出警告。这样脚本不用中断,只用最后看一遍警告列表,比跑到一半崩溃强得多。
5. 做成双击就能用的批处理:命令行参数与拖拽支持
5.1 命令行参数设计
脚本本身已经能用了,但这还不够。我最终交付给自己的不是一个.py文件,而是一个拖文件夹进来就能跑的批处理脚本。先看命令行参数:
python batch_img2pdf.py merge 目标文件夹 --output 输出路径 python batch_img2pdf.py split 目标文件夹 --output 输出目录merge模式下默认是把目标文件夹下所有图片(含子文件夹)合并成一个<文件夹名>.pdf,输出到目标文件夹的同级目录;split模式则是每张图片单独生成一个 PDF。
使用argparse很容易实现:
parser = argparse.ArgumentParser(description="批量图片转PDF工具") parser.add_argument("mode", choices=["merge", "split"], help="merge:合并为一个PDF;split:每张图独立PDF") parser.add_argument("folder", help="图片所在文件夹") parser.add_argument("--output", default=None, help="输出PDF路径或目录") parser.add_argument("--recursive", action="store_true", default=True, help="是否递归子文件夹,默认开启")设默认recursive=True而不是 False,是出于“合订本”的使用场景考虑——扫描件通常存在多级子文件夹里,如果默认不递归,用户很容易漏掉子目录里的图而不自知。
5.2 Windows 拖拽批处理
Windows 下我把脚本封装成了两个.bat文件,用法是直接把文件夹图标拖到 bat 上松手即可:
@echo off chcp 65001 >nul setlocal python "%~dp0batch_img2pdf.py" merge "%~1" echo. echo 合并完成,按任意键退出。 pause >nul关键点是chcp 65001 >nul。不带这一行,当文件夹路径里出现中文时,Python 收到的参数很容易乱码,输出也会乱。加上它之后,Python 脚本内用 Path 处理路径就没有编码问题了。
这个批处理还有个天然好处:一次可以拖多个文件夹进去。Windows 的%~1只取第一个参数,所以我改造了一下,用%*循环处理:
@echo off chcp 65001 >nul for %%F in (%*) do ( echo 正在处理: %%F python "%~dp0batch_img2pdf.py" merge "%%F" )拖十个文件夹进去,一个个输出,非常省事。
5.3 我的日常使用组合
我现在最常用的三个组合:
- 扫描件合订本:扫描软件生成
Scan_001到Scan_100的图片,放一个文件夹,拖入合并.bat,得到一个顺序正确的 PDF。 - 交付给客户的产品图册:产品图文件夹下每个子产品有一个子文件夹,我先用
split把每张图分出来单发,再用merge把每个子产品各生成一册。 - 归档合同照片:一批手机拍的合同页,直接拖进去合并,顺便用
--recursive确保子文件夹没有漏网之鱼。
顺手说一个效率心得:合并前把默认 DPI 定在 150,打印出来清晰度够用、文件体积小;分图模式我用 200 DPI,因为单张 PDF 本来就小,DPI 高一点不失真。如果原图巨大且只用于电脑查看,甚至可以改到 96 DPI,文件体积能下降一截。
5.4 从“图片转 PDF”顺手延伸出去的批量处理思路
写完这个脚本以后,我发现自己打开了一个“批量文件处理”的思维闸门。图片转 PDF 只是最基础的一种,同一套“扫描目录 → 收集 → 排序 → 按规则处理 → 输出”的骨架,稍加改动就能变成别的工具:
- 一键收集文件夹内所有图片,按拍摄日期批量重命名;
- 把 PDF 目录里的每一页再拆分成图片(Pillow 也可以反向操作);
- 给合并前的每一张图片加水印、加时间戳页脚;
- 扫描一批发票 PDF,按发票号码规则重命名并归类到对应子文件夹。
这些都属于“批量文件整理”的范畴,和热词里经常出现的“批量重命名”“批量修改文件名”是一类需求。你可以抓住这套通用的“遍历 + 排序 + 转换”范式去套各种业务场景。
我自己在实际使用中最满意的一点是:这个脚本完全离线运行,不依赖上传下载,不弹广告,不把数据送到任何第三方服务器,整个处理过程只有 Python 在本地默默干活。对经常需要处理带隐私性素材的人来说,这种安心感是任何在线工具都给不了的。如果你也经常被“几百张照片拼成一个 PDF”折磨,我建议你也花半小时搭一个属于你自己的版本,以后每次遇到同类问题,就再也不用重新找工具了。