批量把图片转成 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 Pillow2.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 PillowPIL库 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. 核心代码拆解扫描文件夹、自然排序、批量输出PDF3.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, recursiveTrue): 收集目录下的所有图片路径按路径排序返回。 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, dpi150.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_allTrue, append_imagesrest, resolutiondpi, ) print(f已生成 PDF: {output_path}共 {len(rest) 1} 页) def each_image_to_pdf(image_paths, output_dirNone): 每张图片单独生成一个 PDF。 if output_dir is None: output_dir Path(image_paths[0]).parent Path(output_dir).mkdir(parentsTrue, exist_okTrue) for i, p in enumerate(image_paths, start1): 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, resolution200.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_allTrue和append_images。原理其实很好理解Pillow 在保存单个图像时默认只保存一帧而save_allTrue告诉它“这是多帧文档”。对于 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 会把透明区域变成黑色。我们真正期望的背景色通常是白色纸张颜色。正确的做法是先贴到一张白色背景画布上再转换成 RGBdef 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, maskrgba.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_side2000): 防止超大图把内存耗尽。 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], helpmerge:合并为一个PDFsplit:每张图独立PDF) parser.add_argument(folder, help图片所在文件夹) parser.add_argument(--output, defaultNone, help输出PDF路径或目录) parser.add_argument(--recursive, actionstore_true, defaultTrue, help是否递归子文件夹默认开启)设默认recursiveTrue而不是 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”折磨我建议你也花半小时搭一个属于你自己的版本以后每次遇到同类问题就再也不用重新找工具了。