本文介绍如何基于 Vue 3 + pdf-lib + pdfjs-dist,在浏览器端实现PDF的合并、拆分、压缩等功能,文件无需上传服务器。
背景
最近做了一个PDF工具站,核心需求是:所有操作都在浏览器完成,文件不离开用户设备。
这个需求看似简单,但实现起来有不少坑。本文记录技术选型、核心实现和踩坑过程,给有类似需求的同学参考。
技术选型
为什么不用后端?
传统的PDF处理流程:用户上传文件 → 服务器处理 → 返回结果。这个流程有几个问题:
- 隐私风险:用户的敏感文件(合同、简历、证件)上传到第三方服务器
- 网络依赖:大文件上传慢,处理完还要下载
- 服务器成本: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+ 内存。
解决:
- 文件大小限制:前端做100MB软限制,超过时提示"文件过大,建议使用桌面软件"
- 分块处理:拆分功能可以逐页读取,而不是一次性加载全部
- 进度提示:大文件处理时显示进度条,避免用户以为卡死了
javascript
// 处理前检查文件大小
const MAX_SIZE = 100 * 1024 * 1024 // 100MB
if (file.size > MAX_SIZE) {
throw new Error(`文件过大(${formatSize(file.size)}),建议压缩后重试`)
}
坑2:中文字体显示问题
问题: 合并后的PDF,中文字体变成方块或乱码。
原因: pdf-lib 默认不支持中文字体嵌入,如果原PDF使用了系统字体(如宋体、微软雅黑),在新文档中无法正确显示。
解决:
- 如果只是合并/拆分(不修改内容),用
copyPages可以保留原始字体 - 如果需要编辑文本,必须嵌入中文字体文件(如思源黑体,体积 +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,上千页)
- ❌ 专业印刷级处理(需要精确色彩管理)
- ❌ 批量自动化处理(需要服务器定时任务)
如果你也在做类似项目,欢迎在评论区交流踩坑经验。