
作为一个天天跟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环境下需要额外编译依赖。考虑到我这个工具只做合并、拆分、图片转PDFpypdf完全够用还能保持脚本轻量。如果以后要加“提取PDF页面上的图片”“添加水印”这类重渲染功能再把PyMuPDF加上也不迟。2.2 核心依赖对比与最终选定具体到三个功能我的依赖选择如下合并与拆分用pypdf的PdfReader和PdfWriter。它能把多个PDF的页面对象追加到同一个writer也能从reader中按页码索引抽取指定页面。注意这里有个历史坑老项目PyPDF2已经进入维护停滞状态新项目最好直接用pypdfAPI基本兼容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.pdfsplit拆分PDFpython pdf_toolkit.py split book.pdf -n 10 --prefix chunkextract提取指定页python pdf_toolkit.py extract book.pdf -p 1,3,5-8 -o selected.pdfimg2pdf图片转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[拆分] 第{start1}-{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, keynatural_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(descriptionPDF工具箱合并、拆分、提取、图片转PDF) sub parser.add_subparsers(destcommand, requiredTrue) merge_parser sub.add_parser(merge, help合并多个PDF) merge_parser.add_argument(pdfs, nargs, help要合并的PDF文件路径) merge_parser.add_argument(-o, --output, requiredTrue, help输出文件名) split_parser sub.add_parser(split, help按页数拆分PDF) split_parser.add_argument(pdf, help输入PDF文件) split_parser.add_argument(-n, --pages, typeint, requiredTrue, help每个拆分文件包含的页数) split_parser.add_argument(--prefix, defaultpart, help拆分文件前缀) extract_parser sub.add_parser(extract, help提取指定页) extract_parser.add_argument(pdf, help输入PDF文件) extract_parser.add_argument(-p, --pages, requiredTrue, help页码如 1,3,5-8) extract_parser.add_argument(-o, --output, requiredTrue, help输出文件名) img_parser sub.add_parser(img2pdf, help图片转PDF) img_parser.add_argument(images, nargs, help图片文件路径) img_parser.add_argument(-o, --output, requiredTrue, 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}, filesys.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, maskimg.split()[-1]) rgb.save(output_path, JPEG, quality95)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。