欢迎转载文章
dompdf.js 是我开源的一个js库,他通过html2canvas+jspdf将html生成为矢量pdf(非截图式pdf),具体可以看我之前的文章一个AI都无法提供的html转PDF方案。对比一些前端pdf生成库(如html2pdf.js),dompdf.js的能力有了质的飞跃:
| 对比维度 | dompdf.js (v2.0) | html2pdf.js (传统方案) |
|---|---|---|
| 渲染原理 | 直接读取 DOM 几何与样式 ➔ 纯矢量直出 PDF | html2canvas 截图生成位图 ➔ 贴入 jsPDF |
| 生成质量 (清晰度) | 原生矢量,放大 500%+ 依然清晰锐利,文字可复制、可搜索 | 位图栅格化,放大即失真/模糊,文字不可选、无法搜索 |
| 生成速度 / 性能 | 极快(500 页约 2s),Rust/WASM + Web Worker 后台计算,不卡主线程 | 极慢,依赖主线程大量 Canvas 绘图与渲染,易导致页面假死 |
| 文件体积 | 极小(约传统方案的 1/5 ~ 1/10),内置 TTF 字体子集化与压缩 | 庞大,每页均为高分辨率位图,体积随页数急剧膨胀 |
| 长文档/大规模支持 | 极佳,轻松支撑成百上千页,极限规模可达上万页 | 较差,多页场景极易遭遇 Canvas 内存溢出 (OOM) 崩溃 |
| 排版与分页控制 | 智能跨页防截断、精准页眉页脚(支持总页数计算)、多层水印 | 基于图片高度简单裁切,文字/行高极易被生硬腰斩截断 |
| 高级特性 | 原生 PDF AcroForm 交互表单、超链接、文档权限与加密 | 仅为静态截图,不支持 PDF 原生交互与高级属性 |
在线对比体验
Git 仓库地址 (欢迎 Star⭐⭐⭐)
项目开源以来,得到了挺多的正向反馈,迭代了很多版本,功能也日趋完善,但我心里一直清楚有个地方不太体面:dompdf.js 的渲染管线里,还塞着 jsPDF
v1 时代的做法是"绕开 html2canvas,但继续用 jsPDF 画"------我已经不把页面截图了,而是把 DOM 节点一个个转成 jsPDF 的 text()、rect() 调用。这确实解决了模糊和文字不可选的问题,但 jsPDF 本身是给"手写代码画 PDF"设计的,不是给"DOM 转 PDF"设计的;另外V1版本同时依赖html2canvas和jsPDF,会产生很多不可控性,库的体积也达到将近2M,非常难以接受。
于是我把整个 PDF 生成层用 Rust 重写了一遍,编译成 WebAssembly,做成 v2.0.0 发布。这篇文章完整记录这次重写:为什么必须推倒、架构长什么样、踩了哪些坑、以及一个我至今觉得有点反直觉的技术决定。
一、jsPDF 的三个天花板
先说清楚为什么不是"继续优化 v1",而是"整体重写"。
1. jsPDF 的 API 是命令式的,而 DOM 转 PDF 需要的是批处理
v1 的主路径本质上还是:先把 DOM 解析成一棵树,然后逐页调用 jsPDF / jsPDF.context2d 去画。文字是一段段 text() / fillText(),边框和背景是一条条路径、rect()、line(),页眉页脚最后再补一遍 text()。渲染一万个节点,意味着海量 JS 函数调用、状态切换和 PDF 指令拼接,而且这些工作都发生在主线程上,页面该卡还是卡。
2. "总页数"这件事,迫使 v1 必须先完整分页一遍
页眉页脚里的 ${currentPage}/${totalPages} 看起来只是个小占位符,但它要求你在真正开始绘制前,就已经知道整个文档会被切成多少页。v1 的做法是先跑一次完整的 paginateNode(...),把所有页先切出来,拿到 pageRoots.length 之后,再回头逐页 renderPage(...)。这比"画到哪算哪"的命令式模型别扭得多:文档越大,这个"先把整篇文档完整分页一遍"的成本就越高。
3. 字体子集化放在 JS 里做,太慢也太重
中文 PDF 必须嵌入字体。而完整的中文字体是个庞然大物------仓库里那个已经裁剪过的思源黑体,都还有 4.5 MB ,全量嵌进 PDF 直接爆炸。所以必须做子集化:只保留文档里真正出现过的字形。v1 这条链路是把字体先塞进 jsPDF 的 VFS,再由 JS 侧去解析 TTF、筛字形、生成子集。也就是说,cmap、glyf、loca、hmtx 这些表的处理全都跑在 JavaScript 里,中间还夹着 base64、内存分配和字节拷贝。对几 MB 的字体来说,这套事情放在 JS 主线程上,天然就慢、也天然就重。
顺便说一句,v1 还有一个额外的现实问题:它虽然已经不再是"整页 html2canvas 截图再塞进 jsPDF",但图片、Canvas、SVG 这类内容仍然经常要走 canvas -> toDataURL -> addImage 的栅格回退链路。也就是说,真正拖慢导出的不只是 jsPDF 本身,还有围绕它的一整圈主线程图像处理。
这三点合起来,得出的结论很明确:PDF 生成这件事,需要一个能跑密集计算、能自己管内存、还不占主线程的地方。 那就是 Rust + WebAssembly + Web Worker。
二、新架构:四段式流水线
重写后的流程是这样的:
js
主线程 Web Worker
─────────────────────────────────────────────
① 遍历 DOM,读浏览器算好的布局
geometry / style / text / image
↓
② TS 编码成紧凑二进制快照
(Uint8Array, 不是 JSON)
↓
③ Transferable 零拷贝转移 ──────────→ ④ Rust/WASM:
parse 快照
paginate 分页
font subset 子集化
build_pdf 写 PDF 对象
←──────────────────────────────────── 返回 PDF 字节
⑤ 返回 Blob / Uint8Array / 触发下载
采集这一步是不能搬到 Rust 的 。只有浏览器知道某个元素最终落在屏幕上的 x/y 是多少------getBoundingClientRect、getComputedStyle、真实的换行位置,这些都是浏览器排版引擎的产物,WebAssembly 里没有 DOM,拿不到。
但采集完成之后,"把一堆几何数据变成 PDF 文件"这步,是纯粹的 CPU 密集计算。把它整体搬到 Worker 里的 Rust,主线程只负责采集和编码,UI 就再也不会因为导出而卡死。
快照协议也换成了二进制。JSON 序列化一万个节点的开销和体积都不可接受,现在是 TS 端手写的紧凑二进制编码(见 src/snapshot.ts,5000 多行),Rust 端对应一个解析器(wasm/src/snapshot.rs)。整个 Uint8Array 通过 Transferable 转移进 Worker,零拷贝。
三、不用 wasm-bindgen
这是整个重写里我最想聊的部分。
Rust 写 WASM,绝大多数项目的标准答案是 wasm-bindgen + wasm-pack。我一开始也这么打算,但最后一行都没用。
看看最终的 wasm/Cargo.toml:
toml
[package]
name = "dom2pdf-wasm"
version = "0.1.0"
edition = "2021"
description = "DOM snapshot -> PDF writer (pure std, no third-party PDF libs)"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
# Intentionally none: pure std, no wasm-bindgen, and the design
# forbids third-party PDF libs. JS glue is hand-written.
[profile.release]
opt-level = "s"
lto = true
panic = "abort"
codegen-units = 1
strip = "symbols"
依赖列表是空的。 不是"少",是完全没有。
为什么
因为我想要的产品形态是:一个 JS 文件,一行 <script> 就能用,不需要额外去 fetch 一个 .wasm 文件。
wasm-bindgen 会生成一个 .js + 一个 .wasm 的配对产物,浏览器加载时再发一次网络请求去取 wasm。这在 Vite/Webpack 里能配,但会带来一串琐碎的坑:wasm 文件要不要放进 public、MIME type 对不对、CDN 上路径怎么拼、UMD 版本怎么办、file:// 打开能不能跑......
所以我的做法是:用 --target wasm32-unknown-unknown 直接产出裸 wasm,然后把它 base64 内联进 TypeScript,运行时 atob 解码 + WebAssembly.instantiate 加载。
结果是:
| 数值 | |
|---|---|
Rust 编译出的 .wasm |
312 KB |
| base64 内联后的 TS 文件 | 426 KB |
最终 dist/dompdf.min.js |
490 KB |
| gzip 后 | 178 KB |
| 运行时依赖 | 0 |
178KB gzip 之后,一个包含了完整 PDF 引擎(分页、字体子集化、DEFLATE、加密)的单文件库,CDN 一行引入,不需要再拉 jsPDF、不需要再拉 html2canvas、不需要额外的 wasm 请求。
这么做有什么代价呢
- 内存要自己管。 没有
wasm-bindgen的自动封装,进出 WASM 的所有数据都得手动alloc/dealloc。 - 错误只能靠返回码。 Rust 的
Result出不了 C ABI,我的做法是出错时返回空指针 + 长度 0,JS 侧抛异常。 - 类型安全没了。 手写的 JS glue 和 Rust 导出之间没有编译器帮你对齐签名,只能自己写对。
导出接口就这几个,全靠 #[no_mangle] extern "C":
rust
// wasm/src/lib.rs --- C ABI 导出(依赖为零,无 wasm-bindgen)
#[no_mangle] pub extern "C" fn alloc(n: usize) -> *mut u8; // 分配输入缓冲
#[no_mangle] pub extern "C" fn dealloc(ptr: *mut u8, n: usize); // 释放输入缓冲
#[no_mangle] pub extern "C" fn render_pdf(ptr: *const u8, len: usize) -> usize; // 快照 → PDF
#[no_mangle] pub extern "C" fn render_pdf_encrypted(p: *const u8, l: usize,
e: *const u8, el: usize) -> usize;
#[no_mangle] pub extern "C" fn render_pdf_len() -> usize; // 上次输出的长度
#[no_mangle] pub extern "C" fn free_pdf(ptr: usize, len: usize); // 释放输出
#[no_mangle] pub extern "C" fn count_pages(ptr: *const u8, len: usize) -> u32; // 先数总页数
#[no_mangle] pub extern "C" fn inspect(ptr: *const u8, len: usize) -> usize; // 诊断快照
JS 侧的 glue 也比较薄(src/wasm-glue.ts),核心就是"拷进去、跑、拷回来、全都释放掉":
ts
export async function renderPdf(snapshot: Uint8Array): Promise<Uint8Array> {
await initWasm();
const wasm = exports();
const inPtr = copyIn(wasm, snapshot); // 拷进 WASM 内存
try {
const outPtr = wasm.render_pdf(inPtr, snapshot.length);
const outLen = wasm.render_pdf_len();
if (outPtr === 0 || outLen === 0) throw new Error("render_pdf failed");
// 必须先拷出来再释放:渲染过程中内存可能已经增长,buffer 被换掉了
const out = new Uint8Array(outLen);
out.set(new Uint8Array(wasm.memory.buffer, outPtr, outLen));
wasm.free_pdf(outPtr, outLen);
return out;
} finally {
wasm.dealloc(inPtr, snapshot.length); // 无论成功失败都要释放
}
}
上面这行注释是我踩过的坑:WASM 内存增长后
memory.buffer会换成一个新的 ArrayBuffer,之前拿到的视图全部失效。所以必须重新读一次wasm.memory.buffer,不能缓存。
还有一个细节:WASM 反过来会回调 JS 上报进度。 Rust 侧声明了一个 env.report_progress 导入,分页完成一页就调一次,JS 这边映射成 onProgress 的 rendering 阶段。所以导出 2000 页的时候,进度条是真实推进的,不是假的动画。
四、那些绕不开的基础模块
既然决定零依赖,很多基础能力就得直接在仓库里实现。Rust 侧一共约 6400 行,分布是这样的:
| 模块 | 行数 | 干什么 |
|---|---|---|
paginate.rs |
3029 | 分页算法、内容流生成、页面组装 |
snapshot.rs |
938 | 二进制快照协议解析 |
ttf.rs |
779 | TTF 字体解析 + 子集化(cmap/glyf/loca/hmtx) |
font.rs |
532 | CID 字体编码、字形映射、字体回退 |
deflate.rs |
417 | DEFLATE 压缩实现(LZ77 + 固定 Huffman) |
encrypt.rs |
246 | PDF 标准安全加密 + 权限位 |
lib.rs |
233 | C ABI 导出层 |
pdf.rs |
221 | 极简 PDF 1.4 writer(对象表 + xref + trailer) |
几个我觉得值得一提的:
PDF 文件格式这一层是直接实现的。 pdf.rs 只有 221 行------维护一个输出缓冲区、一张对象偏移表,最后吐 xref 表和 trailer。到这里我才发现 PDF 格式本身没那么可怕,可怕的是"正确渲染 CSS"。第三方 PDF 库能省的不是文件格式,而是排版逻辑,而排版逻辑我这里本来就要自己掌控。
DEFLATE 也放在仓库内实现。 不引入 flate2(它会带进 miniz_oxide),417 行实现了 RFC 1951:32K 窗口的 hash-chain LZ77 匹配 + 固定 Huffman 编码,压缩没效果时自动回退到 stored block。开启 compress: true 时,PDF 的内容流就靠它瘦身。
字体子集化也在这一层完成。 ttf.rs 会扫描 TTF 的表目录,提取 head / maxp / hhea / hmtx / cmap(支持 format 0/4/6/12)/ loca / glyf,只保留文档里实际用到的字形,重写 loca 和 hmtx,再用 Identity-H 编码 + ToUnicode CMap 嵌成 CIDFontType2。这样做的直接收益是:可以放心用完整的思源黑体,导出的 PDF 里只会有文档用到的那些字。
五、分页,比我想的难得多
paginate.rs 是最大的单个文件,3029 行,占了整个 Rust 代码的一半。这不是因为我写得啰嗦,而是分页本身确实复杂。
先说页面怎么切。一页 A4 在 PDF 坐标里被切成五条横带(PDF 原点在左下角):
rust
// wasm/src/paginate.rs --- 页面纵向分区
// margin_top band
// header band (height = header_h_pt)
// content band (height = content_h_pt) <- 节点画在这里
// footer band (height = footer_h_pt)
// margin_bottom band
//
// content_h_pt = pageHeight - margins - header_h_pt - footer_h_pt
// 文档 y 是 CSS px(左上角原点),每页覆盖 content_h_px 高度的一段
页边距、页眉页脚各自吃掉一块,中间的 content band 才是真正能放内容的高度------这也是为什么"保证导出容器宽度接近纸宽"是分页准确的前提。
然后是那些让分页器变复杂的真实需求:
-
pageBreak属性:元素前强制换页 -
divisionDisable属性:整块尽量不跨页,放不下才前推到下一页;如果元素本身比一页还高,那就只能拆 -
函数式
pageConfig:要动态生成页眉页脚,就必须先知道总页数 。所以导出接口里专门有个count_pages------先跑一遍纯分页把总页数算出来,再跑第二遍真正绘制 -
跨页偏移累计:长文档里如果每页的偏移不累计修正,几百页之后误差会很可观
六、效果
重写之后的核心指标(README 里的实测数据):
| 指标 | v2(Rust + WASM) |
|---|---|
| 典型长文档 | 约 2 秒生成 500 页 |
| 极限规模 | 单次导出可上万页 |
| 产物形态 | 单文件 JS,gzip 后 178 KB |
| 运行时依赖 | 0(无 jsPDF、无 html2canvas) |
| 文字 | 矢量、可搜索、可选中、可复制 |
| 主线程阻塞 | 无(渲染全在 Worker 里) |
| 中文字体 | 运行时子集化,只嵌用到的字形 |
除了性能,v2 还补齐了不少原来做不动的能力,因为它们现在都是 Rust 侧的原生实现:
- PDF 加密:用户密码 / 所有者密码 + 打印、复制、修改、注释表单四项权限
- 交互式表单 :能生成真正的 AcroForm 字段(文本、多行、下拉、多选、单选),
static/interactive/hybrid三种模式 - 超链接注释 :HTML 里的
<a>会被写成 PDF annotation,点了能跳 - 页眉页脚 slots :同一行放多个文本块,位置支持语义值(
leftTop)和坐标对象({ x: '100%-24', anchor: 'rightTop' }) - 水印 :文字/图片、上下层叠、逐页控制、支持
${currentPage}/${totalPages}占位符
七、PDF产物和页面差异对比系统
这是一个比较有用处的工具
是一个完整的 PDF 视觉回归系统,代码在 scripts/pdf-diff/ 目录下,如果你在你的项目里用到了 dompdf.js,你可以用它来对比 PDF 产物和html节点的差异,发现并修复问题。
js
生成 PDF → 光栅化成图 → 三条独立比对线
├── pixeldiff 像素级差异
├── textdiff 文本提取差异(查文字丢失/错位)
└── visualdiff 视觉感知差异
↓
classify 差异分类
↓
md-report 生成 Markdown 报告
顺着这套东西,我还做了两个额外的东西:fix-loop.mjs 能拿着差异报告进自动修复循环,mcp-server.mjs 把整个差异系统包成 MCP 服务------这样 AI 编程工具可以直接看到类似"改完这行,第 17 页的表格边框偏了 2px"的反馈,精准的去修复pdf渲染引擎,以达到不断完善dompdf.js质量目的。
写在最后
这次重构给我最大的感受是:有些性能问题不是"优化"问题,是"架构"问题。
v1 时代我试过各种办法让 jsPDF 快一点,但每次优化都会撞到同一堵墙------JS 主线程 + 命令式绘图 API + 无法脱离 DOM 环境。而在 v2 里,同样的三个约束,分别被 Rust、批量二进制协议、Web Worker 解决。
如果你也在被前端生成 PDF 的问题困扰,欢迎试一下dompdf.js:
在线体验 :dompdfjs.lisky.com.cn
GitHub :github.com/lmn1919/dom...
从 v1.3 升级的话记得看一眼迁移说明,绝大多数 API 不用改,兼容层做了兜底。
如果这篇文章或者这个库对你有帮助,去 GitHub 点个 Star 支持一下 ⭐⭐⭐ 你的每一个 Star 都是我继续维护的动力。