最近在整理一个 Vue 3 的 PDF 工具站,做到"PDF 合并"功能时踩了几个坑。写出来分享一下,也顺便把三个方案做了一个对比,希望能帮到你。
在线体验:sotool.top/merge
为什么 PDF 合并比看起来复杂?
如果你只是拼接几张图片,前端很容易就搞定了。但 PDF 是文档格式,每个文件里都有:
- 交叉引用表(xref table)
- 对象流(object streams)
- 页面树(page tree)
- 字体和资源引用
直接把两个 PDF 的字节拼在一起,得到的文件一定是损坏的。所以必须用专门的库来操作 PDF 结构。
方案一:FileReader 读取 + 后端合并(不推荐)
最直观的想法:前端读取文件,传给后端,后端用 Python/Java 的 PDF 库合并,再返回文件。
优点:
- 后端库功能强,支持复杂 PDF。
- 实现简单,前端只做上传下载。
缺点:
- 用户文件要上服务器,隐私风险大。
- 服务器带宽和计算成本。
- 离线场景用不了。
如果你的文件涉密,这个方案可以直接 pass。
方案二:pdf-lib 纯前端合并(推荐)
这是目前我找到的最平衡的方案。pdf-lib 是一个纯 JS 的 PDF 操作库,支持在浏览器里创建、修改、合并 PDF。
核心代码就这几行:
import { PDFDocument } from 'pdf-lib'
async function mergePdfs(files: File[]) {
const merged = await PDFDocument.create()
for (const file of files) {
const bytes = await file.arrayBuffer()
const pdf = await PDFDocument.load(bytes)
const pages = await merged.copyPages(pdf, pdf.getPageIndices())
pages.forEach(p => merged.addPage(p))
}
const mergedBytes = await merged.save()
return new Blob([mergedBytes], { type: 'application/pdf' })
}
关键点:
PDFDocument.create()创建新文档。copyPages把源文档页面复制到新文档,避免资源冲突。merged.save()生成最终 PDF 字节。
优点:
- 不上传服务器,纯浏览器运行。
- 支持拖拽排序、限制页数、文件大小校验。
- 离线可用。
缺点:
- 大文件(100MB+)可能卡 tab。
- 某些加密/特殊 PDF 加载会失败。
方案三:pdf.js 预览 + 重新打包(重绘方案)
这个方案不用 pdf-lib,而是把每页渲染成 canvas,再用 html2pdf.js 或 jsPDF 重新打包成 PDF。
优点:
- 对扫描件、图片型 PDF 兼容性好。
- 可以预览每一页后再合并。
缺点:
- 文字 PDF 会变成图片,失去可选中、可搜索能力。
- 文件体积可能变大。
- 处理速度慢,高清页面尤其耗时。
适合:扫描件、图片 PDF、需要预览确认的场景。
我踩过的 3 个坑
1. 不要直接 push 页面,要用 copyPages
错误写法:
for (const pdf of pdfs) {
merged.addPage(pdf.getPage(0)) // 错误!页面属于原文档
}
正确写法:
const pages = await merged.copyPages(pdf, pdf.getPageIndices())
pages.forEach(p => merged.addPage(p))
addPage 只能接收属于目标文档的页面,直接拿源文档页面会报错或产生异常文件。
2. 注意内存,特别是 ArrayBuffer
多个大文件同时读进内存,很容易把 tab 搞崩。建议做两层限制:
const MAX_SIZE = 100 * 1024 * 1024 // 100 MB
const MAX_PAGES = 500
超出限制时给用户明确提示,而不是直接崩溃。
3. Blob URL 记得 revoke
下载完成后别忘了清理:
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'merged.pdf'
a.click()
URL.revokeObjectURL(url)
否则内存泄漏会慢慢累积。
完整的最小实现
<script setup lang="ts">
import { ref } from 'vue'
import { PDFDocument } from 'pdf-lib'
const files = ref<File[]>([])
const merging = ref(false)
const result = ref<Blob | null>(null)
async function mergePdfs() {
if (files.value.length < 2) return
merging.value = true
result.value = null
try {
const merged = await PDFDocument.create()
for (const file of files.value) {
const bytes = await file.arrayBuffer()
const pdf = await PDFDocument.load(bytes)
const pages = await merged.copyPages(pdf, pdf.getPageIndices())
pages.forEach(p => merged.addPage(p))
}
const mergedBytes = await merged.save()
result.value = new Blob([mergedBytes], { type: 'application/pdf' })
} finally {
merging.value = false
}
}
function downloadResult() {
if (!result.value) return
const url = URL.createObjectURL(result.value)
const a = document.createElement('a')
a.href = url
a.download = 'merged.pdf'
a.click()
URL.revokeObjectURL(url)
}
</script>
总结
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 后端合并 | 功能强,实现快 | 隐私、成本、离线不行 | 内部系统、对安全不敏感 |
| pdf-lib 前端合并 | 隐私、离线、可排序 | 大文件受限 | 日常办公、合同/发票合并 |
| pdf.js 重绘 | 预览友好、兼容扫描件 | 文字变图、慢、体积大 | 扫描件、图片型 PDF |
如果做的是一个面向普通用户的免费工具,我推荐 pdf-lib 方案。它既保护了隐私,又能在移动端运行,体验足够好。
我的开源项目: github.com/sunshey/pdf-tool
工具地址: sotool.top/merge
#前端 #Vue #PDF #JavaScript #pdf-lib