在浏览器里合并拆分 PDF:能做什么,做不到什么
把 PDF 合并做成网页工具,最大的疑问通常是「不上传真的能处理吗」。
能。PDF 是一种结构化的容器格式,页面内容可以被复制进新文档,整个过程不需要渲染。但有几条边界必须先说清楚,否则用户会在最需要的时候撞墙。
用什么
pdf-lib:纯 JavaScript、无 WASM、无原生依赖。它不负责渲染,只负责读写 PDF 的对象结构 —— 这正是合并拆分需要的能力。
体积上,它作为独立分包是 412 KB(压缩后),只在打开 PDF 工具时才下载,不影响其他页面。
合并:把页面搬进新文档
const out = await PDFDocument.create();
for (const bytes of sources) {
const doc = await PDFDocument.load(bytes);
const pages = await out.copyPages(doc, doc.getPageIndices());
pages.forEach((page) => out.addPage(page));
}
const result = await out.save();
核心是 copyPages:它把源文档的页面对象(内容流、资源字典、字体引用)复制进目标文档。
这意味着画质不会下降 —— 没有重新渲染、没有重新压缩,矢量还是矢量。也意味着文件体积大致等于各页之和,合并本身不会"变胖"或"变瘦"。
拆分:按范围复制
页码输入支持 1-3,5,8-10 这种写法,逗号、空格、中文逗号都认。解析逻辑很短:
for (const part of text.split(/[,,\s]+/)) {
const m = part.match(/^(\d+)(?:[-–](\d+))?$/);
// 校验 1 <= start <= end <= 总页数
}
越界就直接报错,不会静默返回空文档 —— 后者是最难排查的一类 bug。
而「每页单独导出」不是弹 N 次下载框,而是打成一个 ZIP。这里复用了手写的 store 模式打包器(见另一篇关于 ICO 与 ZIP 的文章),避免为了一个下载动作再引入依赖。
三条明确的边界
1. 加密 PDF 会失败
PDFDocument.load() 遇到需要密码的文档会抛错。工具会明确提示「这个 PDF 有密码保护,无法处理」,而不是加载出一个空白文档。
这是有意为之:静默失败比报错更糟。
2. 全部在内存里
没有流式处理。一个文件要完整读进 ArrayBuffer,再完整生成输出。几十页的文档没问题,几百页的大图册会明显变慢,甚至把移动端浏览器拖到卡死。
目前的策略是不做限制也不做假装:慢就是慢,用户能看到进度条。真要做大文件,得上分片 + Worker,复杂度会翻几倍。
3. 数字签名会失效
PDF 的数字签名覆盖整个文档字节。任何修改 —— 包括只增加一页 —— 都会让签名失效。这是格式本身的设计,不是工具的缺陷。
同理,表单字段(AcroForm)在页面复制后可能对不上,因为字段定义属于文档级结构而不是页面级。要处理这些,得做专门的字段合并逻辑。
验证方式
纯前端工具没人替你做回归测试,只能自己造样本:
- 用
pdf-lib生成测试文件(2 页 + 1 页) - 在浏览器里上传、合并、下载
- 用 Node 再读回来,断言页数 = 3
- 拆分输入
2,断言输出 = 1 页
这套流程跑下来,比「点一下看起来没报错」可靠得多。第一次跑的时候还真的发现了问题 —— 不过那次是我的测试脚本选错了输入框,不是工具本身(另一个故事了)。
小结
纯前端 PDF 工具的定位应该是:处理几十页以内的日常文档,而不是替代桌面软件。
把「做不到什么」写在界面上,比硬撑一个会崩的实现更负责。
