纯前端实现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,上千页)
  • ❌ 专业印刷级处理(需要精确色彩管理)
  • ❌ 批量自动化处理(需要服务器定时任务)

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

相关推荐
weixin_382395232 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__2 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.2 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~3 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华4 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子6 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅6 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni7 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学8 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端