纯前端实现PDF合并、拆分、压缩:技术方案与踩坑记录

本文介绍如何基于 Vue 3 + pdf-lib + pdfjs-dist,在浏览器端实现PDF的合并、拆分、压缩等功能,文件无需上传服务器。

背景

最近做了一个PDF工具站,核心需求是:所有操作都在浏览器完成,文件不离开用户设备

这个需求看似简单,但实现起来有不少坑。本文记录技术选型、核心实现和踩坑过程,给有类似需求的同学参考。


技术选型

为什么不用后端?

传统的PDF处理流程:用户上传文件 → 服务器处理 → 返回结果。这个流程有几个问题:

  1. 隐私风险:用户的敏感文件(合同、简历、证件)上传到第三方服务器
  2. 网络依赖:大文件上传慢,处理完还要下载
  3. 服务器成本:PDF处理是CPU密集型操作,并发高了服务器扛不住

核心库选择

用途 体积 特点
pdf-lib PDF创建、修改、合并、拆分 ~500KB 功能全面,API友好
pdfjs-dist PDF解析、预览、提取页面 ~1.5MB Mozilla出品,解析能力强
file-saver 浏览器端文件下载 ~5KB 兼容性好

pdf-lib 负责"写"PDF(合并、拆分、添加水印等),pdfjs-dist 负责"读"PDF(获取页数、生成预览图)。两者配合覆盖大部分需求。


核心功能实现

1. PDF合并

最简单的场景:把多个PDF按顺序合并成一个。

javascript 复制代码
import { PDFDocument } from 'pdf-lib'
​
async function mergePDFs(files: File[]): Promise<Uint8Array> {
  const mergedDoc = await PDFDocument.create()
  
  for (const file of files) {
    const bytes = await file.arrayBuffer()
    const pdf = await PDFDocument.load(bytes)
    const pages = await mergedDoc.copyPages(pdf, pdf.getPageIndices())
    pages.forEach(page => mergedDoc.addPage(page))
  }
  
  return mergedDoc.save()
}

关键点:

  • copyPages 而不是直接 addPage,因为 addPage 要求页面来自同一个文档实例
  • file.arrayBuffer() 是浏览器原生API,无需读取文件系统

2. PDF拆分

按页面范围提取,支持语法如 1-3, 5, 7-9

typescript 复制代码
import { PDFDocument } from 'pdf-lib'
​
function parsePageRanges(input: string, totalPages: number): number[] {
  const pages = new Set<number>()
  
  input.split(',').forEach(part => {
    const [start, end] = part.trim().split('-').map(Number)
    if (end === undefined) {
      pages.add(start - 1) // 用户输入从1开始,pdf-lib从0开始
    } else {
      for (let i = start; i <= Math.min(end, totalPages); i++) {
        pages.add(i - 1)
      }
    }
  })
  
  return Array.from(pages).sort((a, b) => a - b)
}
​
async function splitPDF(file: File, rangeInput: string): Promise<Uint8Array[]> {
  const bytes = await file.arrayBuffer()
  const pdf = await PDFDocument.load(bytes)
  const totalPages = pdf.getPageCount()
  const pageIndices = parsePageRanges(rangeInput, totalPages)
  
  const newDoc = await PDFDocument.create()
  const pages = await newDoc.copyPages(pdf, pageIndices)
  pages.forEach(page => newDoc.addPage(page))
  
  return [await newDoc.save()]
}

关键点:

  • 页码转换:用户习惯从1开始,pdf-lib从0开始
  • 连续范围一次性提取,减少内存操作

3. PDF压缩

浏览器端压缩和服务器端不同,没有Ghostscript这种利器,主要依靠以下策略:

javascript 复制代码
import { PDFDocument } from 'pdf-lib'
​
async function compressPDF(file: File, quality: 'low' | 'medium' | 'high'): Promise<Uint8Array> {
  const bytes = await file.arrayBuffer()
  const pdf = await PDFDocument.load(bytes)
  
  // 策略1:移除未使用的对象和流
  // 策略2:降低图片质量(需要pdfjs-dist提取图片后重新编码)
  // 策略3:清理注释和元数据
  
  const options: any = {
    useObjectStreams: true, // 使用对象流,减小体积
    addDefaultPage: false,
    preserveExistingEncryption: false
  }
  
  if (quality === 'low') {
    // 激进压缩:降低图片DPI(需要配合pdfjs-dist处理图片流)
    options.objectsBeforeEOF = 1
  }
  
  return pdf.save(options)
}

实际压缩效果:

  • 轻度压缩(清理元数据 + 对象流优化):通常减少 5%-15%
  • 中度压缩(降低图片质量):通常减少 30%-60%
  • 重度压缩(降低分辨率):通常减少 50%-80%,但可能影响阅读

局限: 浏览器端无法像Ghostscript那样做深度压缩,因为涉及复杂的图像重编码。对于扫描件PDF(全是图片),效果有限。


踩坑记录

坑1:大文件内存溢出

问题: 用户上传一个 200MB 的扫描件PDF,浏览器直接崩溃。

原因: pdf-lib 会把整个PDF加载到内存中,200MB的PDF解压后可能占用 1GB+ 内存。

解决:

  1. 文件大小限制:前端做100MB软限制,超过时提示"文件过大,建议使用桌面软件"
  2. 分块处理:拆分功能可以逐页读取,而不是一次性加载全部
  3. 进度提示:大文件处理时显示进度条,避免用户以为卡死了
javascript 复制代码
// 处理前检查文件大小
const MAX_SIZE = 100 * 1024 * 1024 // 100MB
if (file.size > MAX_SIZE) {
  throw new Error(`文件过大(${formatSize(file.size)}),建议压缩后重试`)
}

坑2:中文字体显示问题

问题: 合并后的PDF,中文字体变成方块或乱码。

原因: pdf-lib 默认不支持中文字体嵌入,如果原PDF使用了系统字体(如宋体、微软雅黑),在新文档中无法正确显示。

解决:

  1. 如果只是合并/拆分(不修改内容),用 copyPages 可以保留原始字体
  2. 如果需要编辑文本,必须嵌入中文字体文件(如思源黑体,体积 +10MB)
javascript 复制代码
// 正确做法:copyPages 保留原字体
const pages = await mergedDoc.copyPages(sourcePdf, indices)
pages.forEach(p => mergedDoc.addPage(p))
​
// 错误做法:创建新页面再写入文本,会丢失字体
const page = mergedDoc.addPage()
page.drawText('中文', { x: 50, y: 50 }) // 可能显示为方块

坑3:PDF.js 和 pdf-lib 的版本冲突

问题: pdfjs-dist 和 pdf-lib 同时使用时,某些PDF解析报错。

原因: 两者都依赖 pako(zlib的JS实现),但版本不同可能导致冲突。

解决: Vite 项目中配置 resolve.alias,确保使用同一版本:

php 复制代码
// vite.config.ts
export default defineConfig({
  resolve: {
    alias: {
      'pako': 'pako/dist/pako.es5.js'
    }
  },
  optimizeDeps: {
    include: ['pdf-lib', 'pdfjs-dist']
  }
})

坑4:Safari 下载文件名乱码

问题:URL.createObjectURL 下载时,Safari 中文文件名显示乱码。

解决: 使用 file-saver 库,或者手动处理:

go 复制代码
import { saveAs } from 'file-saver'
​
const blob = new Blob([pdfBytes], { type: 'application/pdf' })
saveAs(blob, '合并后的文件.pdf') // Safari兼容

性能优化

1. Web Worker 处理大文件

PDF处理会阻塞主线程,导致UI卡顿。用 Web Worker 把处理逻辑移到后台:

typescript 复制代码
// pdf.worker.ts
self.onmessage = async (e) => {
  const { type, files, options } = e.data
  
  if (type === 'merge') {
    const result = await mergePDFs(files)
    self.postMessage({ type: 'complete', data: result })
  }
}
​
// 组件中调用
const worker = new Worker(new URL('./pdf.worker.ts', import.meta.url))
worker.postMessage({ type: 'merge', files })
worker.onmessage = (e) => {
  if (e.data.type === 'complete') {
    downloadBlob(e.data.data, 'merged.pdf')
  }
}

2. 延迟加载 pdfjs-dist

pdfjs-dist 体积较大(~1.5MB),按需加载:

javascript 复制代码
// 只在需要预览时加载
async function loadPDFPreview(file: File) {
  const pdfjs = await import('pdfjs-dist')
  pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js'
  
  const pdf = await pdfjs.getDocument({ data: await file.arrayBuffer() }).promise
  return pdf
}

最终效果

目前上线的功能:

功能 实现方式 文件大小限制
合并PDF pdf-lib copyPages < 100MB
拆分PDF pdf-lib 提取指定页 < 100MB
压缩PDF pdf-lib save优化 + 图片降级 < 50MB
PDF转图片 pdfjs-dist 渲染canvas < 50MB
图片转PDF pdf-lib 嵌入图片 < 50MB

在线体验: sotool.top


总结

纯前端PDF处理的核心优势是隐私 (文件不上传),代价是性能受限(浏览器内存和CPU有限)。

适合场景:

  • ✅ 日常办公文档(几十页,几MB到几十MB)
  • ✅ 隐私敏感文件(合同、简历、证件)
  • ✅ 临时使用(不想安装软件)

不适合场景:

  • ❌ 超大扫描件(几百MB,上千页)
  • ❌ 专业印刷级处理(需要精确色彩管理)
  • ❌ 批量自动化处理(需要服务器定时任务)

如果你也在做类似项目,欢迎在评论区交流踩坑经验。

相关推荐
工会代表1 小时前
frps配置,以linux服务器以及windows客户端进行远程桌面内网穿透为例。
前端
用户713874229001 小时前
Promise 与 Async Await 深度解析
前端
董董灿是个攻城狮1 小时前
5分钟入门卷积算法
前端
用户58048170029281 小时前
我用 MCP 给小程序开发做了个 AI 副驾驶,开源了
前端
雨季mo浅忆1 小时前
记录利用Cursor快速实现Excel共享编辑
前端·excel
皮皮大人1 小时前
Vue 3 响应式内核完全解密:reactive & effect 与 Vue 2 Watcher 史诗对决
前端·vue.js
LaughingZhu1 小时前
Product Hunt 每日热榜 | 2026-05-31
前端·人工智能·经验分享·搜索引擎·chatgpt·html
陆枫Larry1 小时前
CSS 中「深色 + 不透明度」vs 直接设浅色的区别
前端
Din1 小时前
使用AI从 27 秒到秒开:一次 Web 首屏加载优化实战
前端