前端2秒生成500页矢量PDF,rust真的强到没朋友

欢迎转载文章

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 原生交互与高级属性

在线对比体验

dompdfjs.lisky.com.cn

Git 仓库地址 (欢迎 Star⭐⭐⭐)

github.com/lmn1919/dom...

项目开源以来,得到了挺多的正向反馈,迭代了很多版本,功能也日趋完善,但我心里一直清楚有个地方不太体面: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、筛字形、生成子集。也就是说,cmapglyflocahmtx 这些表的处理全都跑在 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 是多少------getBoundingClientRectgetComputedStyle、真实的换行位置,这些都是浏览器排版引擎的产物,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 请求。

这么做有什么代价呢

  1. 内存要自己管。 没有 wasm-bindgen 的自动封装,进出 WASM 的所有数据都得手动 alloc / dealloc
  2. 错误只能靠返回码。 Rust 的 Result 出不了 C ABI,我的做法是出错时返回空指针 + 长度 0,JS 侧抛异常。
  3. 类型安全没了。 手写的 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 这边映射成 onProgressrendering 阶段。所以导出 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,只保留文档里实际用到的字形,重写 locahmtx,再用 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
GitHubgithub.com/lmn1919/dom...

从 v1.3 升级的话记得看一眼迁移说明,绝大多数 API 不用改,兼容层做了兜底。

如果这篇文章或者这个库对你有帮助,去 GitHub 点个 Star 支持一下 ⭐⭐⭐ 你的每一个 Star 都是我继续维护的动力。

相关推荐
郑州光合科技余经理2 小时前
国际版外卖系统:税率字段怎么和订单主流程解耦
android·java·开发语言·前端·后端·php·ai编程
梦想平凡2 小时前
百游棋牌源代码开发搭建教程(十):隔离部署、备份恢复与双端验收
java·前端·javascript·数据库·源代码管理
yume_sibai3 小时前
06-Rust Web 开发实战(Axum 框架 + 数据库 + JWT 认证 + 中间件 + 部署)
前端·数据库·rust
计算机魔术师5 小时前
特朗普上台打给黄仁勋:AI末日论是骗局,我们绝不让它发生
前端
troy1285 小时前
Python 基础语法(八):Web 后端开发、数据分析与可视化、网络爬虫、人工智能 / 大模型应用
前端·python·数据分析
计算机魔术师5 小时前
CEO说要慢下来,黑客说别做梦了——同一篇论文,两种命运
前端
kyriewen6 小时前
我花3天抓了一个幽灵bug,凶手藏在第4层
前端·javascript·程序员
IT_陈寒6 小时前
Java里用Stream.parallel()翻车实录,这性能还不如单线程
前端·人工智能·后端
wangchunyu1146 小时前
JavaScript 零基础入门教程
前端