☰
用Python自建本地PDF工具箱,合并拆分图片转PDF一键搞定
2026/9/30 9:21:13 网站建设 项目流程

作为一个天天跟PDF打交道的人,我太清楚这玩意儿有多烦了。合并几个文件要装一个软件,拆分一个大文件又得换另一个工具,想把几张图片塞进一个PDF还得看各种广告。市面上免费的PDF工具不少,但要么限制文件大小,要么强制联网上传,要么在关键按钮上藏付费墙。所以我干脆自己动手写了一个PDF工具箱,把合并、拆分、图片转PDF这几个最常用的功能集中到一个本地工具里。这篇就把我的完整思路、核心代码和踩过的坑都分享出来。

1. 为什么自己动手做PDF工具箱:现成工具的坑与自主方案的优势

1.1 云端在线工具的隐私隐患

先说最大痛点。很多人习惯把PDF丢到在线转换网站上处理,我一开始也这么干。直到有一次,我把一份带有客户签名的合同扫描件传到某个免费合并网站,处理完之后心里突然发毛——这份文件等于在别人的服务器上留了一份副本。你根本不知道它什么时候被删除,也不知道运营方会不会拿去喂AI、做数据分析。对于涉及个人隐私、商业机密、法律文书的PDF,上传云端本身就是一种风险行为。

另外在线工具还有一个隐藏问题:你有没有注意过,免费工具的“下载”按钮旁边永远有个“升级会员”的弹窗,合并超过3个文件就要开VIP,超过10MB就要付费。为了省那几分钟时间,你被迫应付各种限制。这些体验问题叠加起来,让我彻底打消了依赖在线工具的想法。

1.2 桌面软件与命令行工具的取舍

本地桌面软件倒是能解决隐私问题,但Windows上那些知名PDF套件,免费版通常会在角落里强行装一堆全家桶。每次启动都弹广告,合并到一半突然提示“此功能仅支持专业版”,体验非常糟糕。而开源的命令行工具比如qpdf、Ghostscript,功能强大但上手门槛高,一般用户看到一串参数就劝退了。

我的方案是把两者结合:底层用成熟的Python PDF处理库,逻辑自己写,外壳用简单的命令行交互,甚至可以用tkinter做一个极简图形界面。这样的好处是:所有处理都在本地完成、不联网、无广告、无大小限制,而且代码完全可控。你可以按自己的需求添加功能,比如批量重命名、加密、提取图片,这些都是一次代码改动的事。

1.3 自研工具箱最适合谁

这个方案适合以下几类人:第一,经常处理PDF文件的办公族,比如运营、行政、人事,需要批量合并或拆分文档;第二,有隐私意识的个人用户,不愿意把敏感文件上传到第三方平台;第三,想学习Python自动化处理的开发者,这个项目可以当成练手素材,覆盖文件操作、批量处理、命令行参数解析等多个知识点。我写这个工具时给自己定的目标是:双击能跑、参数简单、输出稳定,任何人拿到代码都能在两小时内跑起来。

2. 核心技术选型:PDF工具箱的架构设计与依赖分析

2.1 为什么选Python处理PDF

做PDF处理的语言选择其实不多。C++和Java自己解析PDF文件结构,天量工作量;Node.js生态里PDF库偏弱;Python是最均衡的选项。它有几个天然优势:跨平台,Windows、macOS、Linux都能跑;PDF处理库非常成熟,pypdf、PyMuPDF、reportlab、img2pdf各自把合并拆分、渲染、生成这几种能力做到了极致;语法上手快,适合快速开发工具型项目。

我在选型时最纠结的是pypdf和PyMuPDF(也就是fitz)怎么选。这两个库都能读PDF、写PDF,但各有侧重。pypdf纯Python实现,安装简单、依赖少,对合并拆分这类结构性操作支持得很好,处理速度对常规文件足够;PyMuPDF基于MuPDF内核,渲染速度快,能提取文本、图片甚至做OCR相关的预处理,但安装包更大,在某些Linux环境下需要额外编译依赖。考虑到我这个工具只做合并、拆分、图片转PDF,pypdf完全够用,还能保持脚本轻量。如果以后要加“提取PDF页面上的图片”“添加水印”这类重渲染功能,再把PyMuPDF加上也不迟。

2.2 核心依赖对比与最终选定

具体到三个功能,我的依赖选择如下:

  • 合并与拆分:用pypdf的PdfReader和PdfWriter。它能把多个PDF的页面对象追加到同一个writer,也能从reader中按页码索引抽取指定页面。注意这里有个历史坑:老项目PyPDF2已经进入维护停滞状态,新项目最好直接用pypdf,API基本兼容,bug修得更勤,别再引用PyPDF2了。
  • 图片转PDF:我用img2pdf这个库。它最大的特点是无损转换,图片的像素数据直接打包进PDF容器,不像Pillow那样重新编码导致画质损耗。例外的场景是处理GIF或带透明通道的PNG,可能会跟PDF格式冲突,这时候用Pillow先做一次格式转换更稳妥,我会在后面的常见问题部分细说。
  • 命令行交互:用Python自带的argparse模块,不用额外装东西。它负责接收“要合并哪些文件”“拆分后每份几页”“图片输出成什么规格的PDF”这些参数,让脚本可以被一条命令调用,也可被批处理脚本循环调用。

2.3 总体架构设计

整个工具的逻辑分三层。最外层是入口解析,命令行参数经过argparse处理,落到一个config字典;中间层是三个独立模块:merge_pdfs、split_pdf、images_to_pdf,互不依赖,每个模块只做一件事;最底层是pypdf和img2pdf这两个第三方库。这么设计的好处是,将来加新功能比如“PDF加密”“PDF压缩”,只需要新增一个模块文件,再在入口处加一段参数分发逻辑,完全不影响已有功能。

目录结构也很简单,一个脚本文件加一个README说明就够了。我平时喜欢保持文件精简,一个pdf_toolkit.py搞定全部功能,集中处理参数校验和异常捕获。如果功能膨胀到二三十个,再考虑拆包重构,现阶段追求的是“能跑、好用、易改”。

3. 功能落地:合并、拆分、图片转PDF的实现细节

3.1 PDF合并的核心用法与排序细节

合并PDF的思路并不复杂:新建一个PdfWriter实例,遍历输入的每个PDF文件,用PdfReader打开它,把每一页通过add_page方法追加进writer,最后统一写入输出文件。

但真正使用时有几个细节必须处理好,否则合并出来的文档会让人想砸电脑。第一个是文件排序。如果命令行传入“file1.pdf file2.pdf file10.pdf”,用默认的字典序排序会得到file1、file10、file2这种反直觉结果。这里必须用自然排序,提取文件名中的数字段并按数值排序。我封装了一个极简的自然排序函数,用正则把文件名里的数字和非数字部分拆开,分别比较,效果完美。

第二个细节是页面尺寸。不同PDF文件的页面大小可能不一样,比如一份是A4,一份是A3,合并后输出文件里页面大小各异。这在阅读器里看起来会有点跳跃,但属于正常现象。如果想让所有页面统一大小,得用page.scale_to指定目标尺寸,不过这会拉伸内容,我通常不主动做,而是保留原始页面状态,让使用者自己决定。第三个细节是目录书签。pypdf在合并时可以复制源文件的书签,但这个功能文档不全、容易报错,我默认不做,单纯拼接页面,保证稳定性优先。

3.2 PDF拆分的两种模式:按页数切分与提取指定页

拆分的核心是“从哪个页面索引开始读取,读到哪个索引为止”。这里要注意pypdf的页面索引是0-based,也就是第1页对应索引0。如果你心里想着“从第1页到第3页”,代码里应该是reader.pages[0:3],而不是reader.pages[1:3],这个很多人会搞混。我写代码时用一个helper函数做转换:用户输入的是1-based的页码,内部统一减1变成0-based索引。

拆分场景下我实现了两种模式。第一种是按页数切分,比如把一本80页的PDF按每10页一个文件拆成8份;第二种是提取指定页,用户可以输入类似“1,3,5,8-12”这样的页码范围,抽出来单独存成一个PDF。第二种模式在实际办公中非常实用,比如从几十页的标书里抽出资质证书页交给别人。代码上用字符串解析把“8-12”这种区间展开成连续列表,再跟单个页号合并排序,最后逐页写入writer。

这里还有一个隐藏需求:拆分后文件名怎么起。默认规则是“原名_part1.pdf”,part按三位数补零,这样文件管理器排序时不会出现1、10、2这样的乱序。如果用户指定了前缀,文件名就是“前缀_001.pdf”这种格式。命名规则在批量场景里非常重要,处理几百个拆分文件时尤其能感受到它的价值。

3.3 图片转PDF:无损转换与多图合并

图片转PDF我用img2pdf做主力。它的工作方式是把图片的原始JPEG/PNG压缩数据直接嵌入PDF,不做重编码,所以图片上是1600万像素就还是1600万,颜色一点不打折扣。这个特性对需要保留原始图片细节的场景特别重要,比如扫描件存档、截图备份。

单张图片转单页PDF的代码很简洁,一行就能搞定。但如果要把多张图片合并成一个PDF,就要把所有图片路径按名称排序后传给img2pdf.convert,它返回一个PDF字节流,写入文件即可。需要注意的坑是:img2pdf不接受PIL的Image对象作为直接输入,它更喜欢文件路径或二进制流。如果你从内存里读了一堆图片要转PDF,得打包成BytesIO再传进去。

再说说哪些图片不适合直接用img2pdf。GIF动图转成PDF只保留第一帧,处理前最好先用Pillow把GIF拆帧并重采样为JPEG;带透明通道的PNG在PDF里透明区域会变成黑色或丢失透明度,比较保险的做法是用Pillow先填充白底再转。还有个参数叫--imgsize,可以限制输出页面的最大尺寸,但我通常不设,保持原始尺寸,道理跟PDF合并那节一样,尽量减少无谓的重编码。

3.4 参数计算与配置细节

为了让脚本做好“快速、灵活、不出错”,我设计了以下参数约定:

参数说明示例
merge合并多个PDFpython pdf_toolkit.py merge a.pdf b.pdf -o out.pdf
split拆分PDFpython pdf_toolkit.py split book.pdf -n 10 --prefix chunk
extract提取指定页python pdf_toolkit.py extract book.pdf -p "1,3,5-8" -o selected.pdf
img2pdf图片转PDFpython pdf_toolkit.py img2pdf img1.jpg img2.png -o images.pdf

这里我要强调一个参数设计上的细节:输出文件和输入文件的区分。所有需要输出文件的功能,我都要求用-o显式指定输出路径,而不像某些工具那样自动生成“xxx_merged.pdf”。显式指定虽然有很小的操作成本,但避免了预期之外的文件被创建,也方便后续脚本自动化。比如写一个批处理循环时,输出路径由变量控制,比固定命名规则灵活得多。

4. 完整实操过程:从环境搭建到命令行工具箱落地

4.1 环境准备与依赖安装

动手前先准备好Python环境。我用的是Python 3.10版本,理论上3.9以上都能跑。先创建虚拟环境,这一步强烈不建议省略,否则下次升级系统Python时,第三方库版本冲突会把你折腾疯。

python -m venv pdfenv source pdfenv/bin/activate # Windows下用 pdfenv\Scripts\activate pip install pypdf img2pdf

安装完成后可以验证一下版本:

python -c "import pypdf; print(pypdf.__version__)"

正常情况下会打印一个3.x的版本号。如果你看到的是0.x,说明装错了包,pip里那个老掉牙的PyPDF2和pypdf不是一个东西,别搞混。img2pdf同理,正常会打印对应版本号。装完依赖后,把核心脚本存成pdf_toolkit.py,后面所有功能都通过这个文件调用。

4.2 核心代码实现:合并、拆分、图片转PDF

下面是完整骨架代码,所有异常处理和边界条件都写在里面。我注释掉了很多调试点,因为一开始调试时发现错误信息太泛,加了日志才能定位是哪一步出了问题。

import argparse import io import os import re import sys from typing import List from pypdf import PdfReader, PdfWriter import img2pdf def natural_key(name: str): """自然排序:将文件名拆成数字和非数字片段,用于人性化排序""" return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\d+)', name)] def parse_pages(page_spec: str, total: int) -> List[int]: """解析页码表达式,如 '1,3,5-8',内部转为0-based索引列表""" pages = set() for part in page_spec.split(','): part = part.strip() if not part: continue if '-' in part: start, end = part.split('-', 1) start, end = int(start), int(end) if start < 1 or end > total or start > end: raise ValueError(f"页码区间无效: {part},有效范围1-{total}") pages.update(range(start, end + 1)) else: p = int(part) if p < 1 or p > total: raise ValueError(f"页码无效: {part},有效范围1-{total}") pages.add(p) return sorted(pages) def merge_pdfs(pdf_paths: List[str], output: str) -> None: """合并多个PDF文件""" writer = PdfWriter() for pdf_path in pdf_paths: reader = PdfReader(pdf_path) for page in reader.pages: writer.add_page(page) print(f"[合并] {os.path.basename(pdf_path)}:{len(reader.pages)}页") with open(output, 'wb') as f: writer.write(f) print(f"[完成] 已合并 {len(pdf_paths)} 个文件,共 {len(writer.pages)} 页 -> {output}") def split_pdf(pdf_path: str, pages_per_file: int, prefix: str) -> None: """按页数拆分PDF""" reader = PdfReader(pdf_path) total = len(reader.pages) part_index = 1 start = 0 while start < total: end = min(start + pages_per_file, total) writer = PdfWriter() for idx in range(start, end): writer.add_page(reader.pages[idx]) output = f"{prefix}_{part_index:03d}.pdf" with open(output, 'wb') as f: writer.write(f) print(f"[拆分] 第{start+1}-{end}页 -> {output}") part_index += 1 start = end def extract_pages(pdf_path: str, page_spec: str, output: str) -> None: """提取指定页码生成新PDF""" reader = PdfReader(pdf_path) total = len(reader.pages) pages = parse_pages(page_spec, total) writer = PdfWriter() for idx in pages: # 用户输入1-based页码,转成索引需减1 writer.add_page(reader.pages[idx - 1]) with open(output, 'wb') as f: writer.write(f) print(f"[提取] 共{len(pages)}页 -> {output}") def images_to_pdf(image_paths: List[str], output: str) -> None: """图片转PDF,默认无损合并为单文件多页""" sorted_paths = sorted(image_paths, key=natural_key) with open(output, 'wb') as f: f.write(img2pdf.convert(sorted_paths)) print(f"[图片转PDF] {len(sorted_paths)}张图片 -> {output}") def main(): parser = argparse.ArgumentParser(description="PDF工具箱:合并、拆分、提取、图片转PDF") sub = parser.add_subparsers(dest='command', required=True) merge_parser = sub.add_parser('merge', help='合并多个PDF') merge_parser.add_argument('pdfs', nargs='+', help='要合并的PDF文件路径') merge_parser.add_argument('-o', '--output', required=True, help='输出文件名') split_parser = sub.add_parser('split', help='按页数拆分PDF') split_parser.add_argument('pdf', help='输入PDF文件') split_parser.add_argument('-n', '--pages', type=int, required=True, help='每个拆分文件包含的页数') split_parser.add_argument('--prefix', default='part', help='拆分文件前缀') extract_parser = sub.add_parser('extract', help='提取指定页') extract_parser.add_argument('pdf', help='输入PDF文件') extract_parser.add_argument('-p', '--pages', required=True, help='页码,如 1,3,5-8') extract_parser.add_argument('-o', '--output', required=True, help='输出文件名') img_parser = sub.add_parser('img2pdf', help='图片转PDF') img_parser.add_argument('images', nargs='+', help='图片文件路径') img_parser.add_argument('-o', '--output', required=True, help='输出文件名') args = parser.parse_args() if args.command == 'merge': merge_pdfs(args.pdfs, args.output) elif args.command == 'split': split_pdf(args.pdf, args.pages, args.prefix) elif args.command == 'extract': extract_pages(args.pdf, args.pages, args.output) elif args.command == 'img2pdf': images_to_pdf(args.images, args.output) if __name__ == '__main__': try: main() except Exception as e: print(f"[错误] {e}", file=sys.stderr) sys.exit(1)

这段代码没有一处用到网络请求,全程本地处理,完美满足隐私需求。你可以直接保存运行,如果命令行不熟也没关系,把参数记在常用命令的README里,用一次就熟了。

4.3 实际操作演示:两个典型场景

场景一:把三份季度汇报PDF合并成一份年度报告。

python pdf_toolkit.py merge q1.pdf q2.pdf q3.pdf -o annual_report.pdf

运行后会打印每个文件的页数,最后汇总输出总页数。实测Q1是12页、Q2是10页、Q3是15页,输出文件自动合并成37页,速度比打开WPS再逐页复制粘贴快了几十倍。

场景二:把一个110页的产品手册拆成每份20页的小手册,方便分发给不同部门。

python pdf_toolkit.py split product_manual.pdf -n 20 --prefix manual

输出结果是manual_001.pdf到manual_006.pdf,最后一份只有10页,符合预期。如果哪天老板只要手册里的第1页、第3页和第50-55页做评审用,一条extract命令就够了,不用再手动删页另存。

经过这些实际调用的检验,我已经完全把合并、拆分、图片转PDF当成了日常操作。有几次同事急着要材料,我直接临时敲命令,几秒钟就把文件发给对方,比他们满世界找免费工具不知道快到哪里去了。

5. 常见问题与排查技巧实录

5.1 页面索引差错:为什么提取出来的页总是不对

这个问题十个人有九个会遇到。pypdf里页码从0开始,但人类习惯从1开始。我在extract的代码里用idx - 1做了转换,但如果你拿到了提取结果,发现少了一页或多了不相干的页面,第一反应应该是去看源码里有没有做这个减一转换。另一个容易犯的错是“我明明想提取1到3页,结果输出只有第1页和第3页”——这种多半是页码区间解析只处理了逗号,没处理“1-3”的横杠。我的parse_pages函数已经处理好了。如果你在自己的实现里遇到,记得把区间拆开再合并去重,最后排序。

5.2 加密和权限受限的PDF怎么处理

现在很多PDF有权限限制,只允许打印不允许复制、修改。pypdf读取这种文件时会抛一个“File has not been decrypted”的异常。处理方式取决于你是否知道打开密码。知道密码就用reader.decrypt(password)解锁,pypdf支持空密码和用户密码。不知道密码,那这文件本身就不该由你拆,别硬来,也请遵守授权范围,别把别人的加密文件拿来乱处理。这个意识还是要有的。

5.3 大文件内存占用过高问题

合并几十个超高清扫描PDF时,内存曲线会一路飙升。因为PdfReader默认会把每个对象都加载到内存。我这里没有做流式处理优化,因为常规办公文件通常不超过100MB,问题不大。但如果你要处理几个GB的巨型PDF,建议改用pypdf的lazy_load或者PyMuPDF的增量解析方式,它们支持延迟加载页面数据。不过这类需求已经超出“快速工具箱”的范畴了,大概率你需要的其实是文件归档系统而不是PDF工具。

5.4 图片转PDF的透明背景和格式坑

PNG带透明通道,直接转PDF后边缘会出现黑底或透明区域不可见。解决方案是把透明通道先合并到白色背景上再转。以下是我常写的一个Pillow补充函数:

from PIL import Image def flatten_alpha_to_white(img_path: str, output_path: str): img = Image.open(img_path).convert('RGBA') rgb = Image.new('RGB', img.size, (255, 255, 255)) rgb.paste(img, mask=img.split()[-1]) rgb.save(output_path, 'JPEG', quality=95)

GIF动图同理,img2pdf只能拿第一帧,所以处理前用Pillow把GIF按帧保存成JPEG再转。另外还要记住一件小事:文件名里的中文路径在Windows上偶尔会导致img2pdf报编码错误,通常把文件挪到纯英文路径下就能解决,你要是还遇到别的问题再看具体的报错信息。

5.5 踩过的一个典型坑:同名文件覆盖

拆分PDF时如果prefix用的是同一个名字,跑第二次会把第一次的结果覆盖掉。这听起来很基础,但我在批处理循环里真的翻过车。当时循环跑了一堆文件,prefix竟然全写成了“part”,结果每个文件都输出part_001.pdf,后一次覆盖前一次,一早晨的活白干了。后来我每处输出前都检查一下目标路径是否存在,存在就提示用户确认是否覆盖,或者自动追加时间戳。这也是为什么我在前面的参数设计里,坚持所有输出文件要用-o显式指定。别嫌啰嗦,这是用血泪换来的经验。

6. 结语:把工具交给真实需求

做完这个PDF工具箱之后,我把所有常用的PDF操作都收敛到了这一条命令里。合并、拆分、提取指定页、图片转PDF,覆盖了我90%以上的日常处理需求。剩下那10%比如签名、水印、OCR,暂时用不到就不往里头塞了,免得代码越来越重。哪怕以后要加,也就是在现在这个架构上加一个函数,不影响存量功能。

如果你也想自己搞一套,我的建议是别追求一步到位。先把合并和图片转PDF写出来,天天用,用出痛点了,再加拆分和提取。按照真实需求迭代,而不是照着功能清单堆代码,这样你的脚本永远是你用得最顺手的那一个。这套代码我已经跑了大半年,稳得很。你可以照着上面的代码抄作业,也可以按自己的习惯改,比如把命令换成中文参数、加上界面、加批量文件夹处理。折腾出适合自己的才叫工具,否则顶多算个demo。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询