纯前端 PDF 合并避坑指南:3 个方案与 Vue 3 + pdf-lib 实战

3 阅读3分钟

最近在整理一个 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.jsjsPDF 重新打包成 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