pdf-lib源码架构解析:纯JavaScript如何实现PDF的创建、修改与合并
为什么选择 pdf-lib?
在浏览器端处理 PDF,选择库的标准很严格:
| 库 | 大小 | 创建 | 修改 | 合并 | 字体 | 维护 |
|---|---|---|---|---|---|---|
| pdf-lib | ~350KB | ✅ | ✅ | ✅ | ✅ | 活跃 |
| pdfjs-dist | ~2MB | ❌ | ❌ | ❌ | ❌ | Mozilla |
| jsPDF | ~300KB | ✅ | ❌ | ❌ | 部分 | 活跃 |
| PDFKit | ~1MB | ✅ | ❌ | ❌ | ✅ | Node优先 |
pdf-lib 是唯一能在浏览器端同时创建和修改 PDF 的纯 JS 库。
PDF 文件格式基础
PDF 的内部结构
%PDF-1.7 ← 版本头
1 0 obj ← 对象 1
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj ← 对象 2
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj ← 对象 3
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
endobj
xref ← 交叉引用表
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
trailer
<< /Size 4 /Root 1 0 R >>
startxref
190
%%EOF
核心概念
| 概念 | 说明 |
|---|---|
| 间接对象 | 1 0 obj ... endobj,通过编号引用 |
| 字典 | << /Key /Value >>,类似 JSON 对象 |
| 流 | stream ... endstream,二进制数据(通常 FlateDecode 压缩) |
| 交叉引用表 (xref) | 记录每个对象的字节偏移,实现 O(1) 随机访问 |
| 页面树 | 嵌套的 /Pages 节点,形成树结构 |
pdf-lib 架构解析
模块层次
PDFDocument (顶层 API)
├── PDFPage (页面操作)
├── PDFFont (字体管理)
├── PDFImage (图片嵌入)
└── PDFCatalog (文档结构)
└── PDFContext (底层对象模型)
├── PDFObject (基础对象)
├── PDFDict (字典对象)
├── PDFStream (流对象)
├── PDFRef (间接引用)
└── PDFCrossRefSection (交叉引用)
对象模型
pdf-lib 的核心是 PDFContext,它维护整个文档的对象图:
class PDFContext {
// 所有间接对象的注册表
objects: Map<PDFRef, PDFObject>;
// 分配新的对象编号
assign(ref: PDFRef, object: PDFObject): void;
// 查找对象
lookup(ref: PDFRef): PDFObject;
// 删除对象
delete(ref: PDFRef): void;
}
PDF 合并的实现原理
// PDFDocument.copyPages 的核心逻辑
copyPages(srcDoc: PDFDocument, indices: number[]): PDFPage[] {
const pages: PDFPage[] = [];
for (const index of indices) {
// 1. 从源文档获取页面对象
const srcPage = srcDoc.getPage(index);
// 2. 深拷贝页面对象及其所有引用的对象
const copiedPage = this.context.copy(srcPage.node);
// 3. 在目标文档注册所有拷贝的对象
// 包括:页面字典、内容流、资源字典、字体等
// 关键:更新所有 PDFRef 指向新的对象编号
pages.push(PDFPage.of(copiedPage));
}
return pages;
}
核心难点:深拷贝时必须重映射所有间接引用。对象 A 引用了对象 B(通过 2 0 R),在目标文档中 B 可能分配到不同编号,需要维护一个映射表。
流压缩
PDF 中的内容流通常使用 FlateDecode(即 zlib/deflate)压缩:
class PDFStream {
dictionary: PDFDict;
contents: Uint8Array;
// 编码方法
getContentsString(): string;
getContentsSize(): number;
}
// 压缩写入
const compressed = pako.deflate(rawBytes);
stream.dictionary.set(PDFName.of('Filter'), PDFName.of('FlateDecode'));
stream.contents = compressed;
pdf-lib 使用 pako 库(纯 JS 的 zlib 实现)进行压缩/解压。
字体嵌入
标准字体 vs 自定义字体
PDF 定义了 14 种标准字体(如 Helvetica、Times-Roman),无需嵌入即可显示。但中文字体必须嵌入。
工具库的字体策略
// 工具库预置的中文字体
const fonts = {
sourceHanSans: await pdfDoc.embedFont(
await fetch('/fonts/CN/SourceHanSansCN-Regular.otf')
),
sourceHanSansBold: await pdfDoc.embedFont(
await fetch('/fonts/CN/SourceHanSansCN-Bold.otf')
),
};
字体子集化:pdf-lib 支持字体子集化,只嵌入文档中实际使用的字符,大幅减小文件体积。
| 情况 | 全量字体 | 子集化字体 | 减少 |
|---|---|---|---|
| 10 个中文字 | ~7MB | ~15KB | 99.8% |
| 100 个中文字 | ~7MB | ~80KB | 98.9% |
工具库 PDF 工具链的实现
20+ PDF 工具的实现映射
| 工具 | pdf-lib API | 补充库 |
|---|---|---|
| 合并 | copyPages() + addPage() |
- |
| 拆分 | 新建 doc + copyPages() |
- |
| 旋转 | page.setRotation() |
- |
| 水印 | page.drawText() 透明度 |
- |
| 页码 | page.drawText() 遍历 |
- |
| 加密 | - | @pdfsmaller/pdf-encrypt-lite |
| 提取文本 | - | pdfjs-dist |
| PDF转图片 | - | pdfjs-dist + canvas |
| 压缩 | 移除元数据 + 优化流 | - |
加密:pdf-lib 之外
pdf-lib 不支持 PDF 加密,工具库使用 @pdfsmaller/pdf-encrypt-lite:
import { encrypt } from '@pdfsmaller/pdf-encrypt-lite';
const encryptedPdf = await encrypt(pdfBytes, {
userPassword: 'user123',
ownerPassword: 'owner456',
permissions: {
printing: true,
copying: false,
modifying: false,
},
});
性能优化实践
大文件处理
// 对于 100+ 页的 PDF,逐页处理避免内存峰值
async function processLargePdf(file: File) {
const pdfDoc = await PDFDocument.load(await file.arrayBuffer());
const totalPages = pdfDoc.getPageCount();
// 显示进度
for (let i = 0; i < totalPages; i++) {
const page = pdfDoc.getPage(i);
// 逐页操作...
updateProgress(i / totalPages);
}
}
流式加载
pdf-lib 目前不支持流式加载——必须将整个 PDF 读入内存。对于超大文件(100MB+),这可能导致内存压力。
应对:工具库在处理大文件时显示进度提示,并建议用户在桌面端处理超大文件。
总结
pdf-lib 以 ~350KB 的体积实现了浏览器端 PDF 的创建和修改,这是非常了不起的工程。其核心设计——基于 PDFContext 的对象图模型、间接引用重映射、流压缩——体现了对 PDF 规范的深刻理解。
工具库基于 pdf-lib 构建了 PDF合并、拆分、旋转、水印、页码 等 20+ 种工具,全部浏览器本地处理。结合 pdfjs-dist(渲染/提取文本)和 pdf-encrypt-lite(加密),形成了完整的 PDF 工具链。
📝 开发手记:选择 pdf-lib 而不是 PDFKit 是我们做过最正确的技术决策之一。PDFKit 虽然功能更全,但它依赖 Node.js 的 stream API,在浏览器端需要大量 polyfill(增加约 1MB 体积)。pdf-lib 从设计之初就面向浏览器,Tree-shaking 友好,最终我们只需要 ~180KB 的核心模块就能实现合并+拆分+旋转。第一次看到 200 页的 PDF 在 3 秒内完成合并时,我们整个团队都很兴奋。
本站提供浏览器本地工具,免注册即可试用 →