在银行、制造业这类强监管行业里,PDF 不是“导出按钮”的附属品,而是合同、账单、审计记录和生产文档的最终形态。Erik Steiger 在分享中谈到的痛点很典型:传统 PDF 生成链路依赖 Puppeteer、LaTeX 这类重量级引擎,机器吃得多、冷启动慢、排障困难;一旦模板版本和业务数据对不上,合规团队很难回答“当时到底生成了什么”。
他的方向是把文档生成当成现代基础设施来做:Rust 负责服务端执行与隔离,Typst 负责高性能排版,模板注册表借鉴 Git 和 Docker 的版本化思路。目标不是把 PDF 做得更花哨,而是让它更快、更可追溯、更容易在事故发生时复现。
为什么 Puppeteer 和 LaTeX 会拖慢文档系统
很多团队最初选择 Puppeteer,是因为 HTML/CSS 人才多,预览也方便。但当 PDF 生成成为高频后台任务时,问题会逐渐暴露:
- 浏览器进程重,容器镜像大,冷启动成本高。
- 字体、分页、打印 CSS 在不同环境里容易漂移。
- 并发扩容时,CPU 和内存压力非常明显。
- 生成失败时,定位问题常常要同时看 HTML、CSS、浏览器日志和业务数据。
LaTeX 在学术和复杂排版上很强,但在企业后台里也有类似负担:依赖链长、编译模型偏重、错误信息对业务工程师不友好。对于银行账单、保单、发票、制造工单这类结构化文档,很多场景并不需要一个完整浏览器或传统 TeX 发行版。
Typst 的吸引力正在这里:它把排版能力和现代工程体验结合起来,语法更轻,编译路径更适合嵌入服务。配合 Rust,文档渲染可以从“启动一个庞大外部进程”变成“调用一个可控的库或轻量服务”。分享中提到,迁移到基于 Rust 与 Typst 的 serverless 架构后,渲染延迟可以降到 2ms 以下;这个数字背后的关键不是单点优化,而是整体架构减重。
把模板当成镜像,而不是散落的文件
受监管行业最怕的不是模板改错,而是改错以后说不清楚。一个现代文档平台至少要回答四个问题:
- 这份 PDF 使用了哪个模板版本?
- 模板依赖了哪些字体、组件和局部片段?
- 生成时输入数据是什么 schema?
- 如果客户或审计要求复现,能否得到同一份输出?
Erik 提到的模板注册表思路,很像 Git 与 Docker 的结合:模板不只是一个文件,而是一个带版本、摘要、依赖和发布状态的 artifact。业务系统不应该引用“最新版合同模板”,而应该引用一个不可变的版本,例如 contract@sha256:... 或 invoice:v2024-10-01。
可以这样设计模板元数据:
name: monthly-statement
version: 2024.10.3
engine: typst
entrypoint: main.typ
schema: schema.json
fonts:
- Inter
- Noto Sans CJK SC
labels:
domain: banking
owner: document-platform
compliance_review: approved
这类元数据的价值在事故排查时会放大:如果某批客户账单出现分页异常,平台可以快速定位对应模板版本、输入 schema、字体依赖和发布时间,而不是在共享目录里翻找“final_v7_fixed_new.typ”。
一个可改造的 Typst 模板与渲染命令
下面是一个最小 Typst 模板示例,适合用来理解“结构化数据 + 模板版本 + 可重复渲染”的工作流。运行前需要安装 Typst CLI,并把示例保存为 main.typ。
#set page(margin: 18mm)
#set text(font: "Noto Sans CJK SC", size: 10pt)
#let statement(customer, period, items) = [
= 月度账单
客户:#customer \
账期:#period
#table(
columns: (1fr, 80pt),
inset: 8pt,
[项目], [金额],
..items.map(item => (
[#item.name], [#item.amount]
)).flatten()
)
]
#statement(
customer: "上海某制造有限公司",
period: "2024-10",
items: (
(name: "设备租赁", amount: "¥12,000"),
(name: "维护服务", amount: "¥3,500"),
(name: "税费", amount: "¥930"),
),
)
本地渲染:
typst compile main.typ statement.pdf
在真实系统里,业务数据通常不会直接写进模板文件,而是由服务端注入。可以先用一个简单目录结构模拟模板注册表:
mkdir -p registry/monthly-statement/2024.10.3
cp main.typ registry/monthly-statement/2024.10.3/main.typ
sha256sum registry/monthly-statement/2024.10.3/main.typ > registry/monthly-statement/2024.10.3/SHA256SUMS
cat registry/monthly-statement/2024.10.3/SHA256SUMS
这并不是完整平台,但它体现了核心习惯:模板发布后生成摘要,业务侧记录版本和摘要,出问题时用同一份 artifact 复现。
Rust 服务层应该负责什么
Rust 不只是为了“快”。在文档平台里,它更适合承担边界清晰、资源敏感、需要稳定并发的部分:
- 接收渲染请求并校验输入 schema。
- 根据模板名和版本从注册表拉取 artifact。
- 管理字体、缓存和沙箱执行环境。
- 调用 Typst 渲染并返回 PDF 字节流。
- 写入审计日志,包括模板摘要、输入摘要、耗时和调用方。
可以把服务接口设计得非常朴素。例如一个后端渲染 API 可以长这样:
curl -X POST http://localhost:8080/render \
-H 'content-type: application/json' \
-d '{
"template": "monthly-statement",
"version": "2024.10.3",
"data": {
"customer": "上海某制造有限公司",
"period": "2024-10",
"items": [
{"name": "设备租赁", "amount": "¥12,000"},
{"name": "维护服务", "amount": "¥3,500"}
]
}
}' \
--output statement.pdf
即使底层实现未来从 CLI 调用改成嵌入式库,API 也不必变化。对上游业务系统来说,重要的是模板版本、输入数据和输出 PDF 的契约稳定。
采用前的检查清单
这类迁移最容易失败在“只替换渲染引擎”。如果仍然没有版本化、没有审计日志、没有可复现构建,那么从 Puppeteer 换到 Typst 只能解决一部分性能问题。
落地时可以按这份清单推进:
- 先选高频、结构稳定、排版复杂度中等的 PDF 作为试点。
- 为模板建立不可变版本,不允许生产系统引用浮动的
latest。 - 把字体纳入 artifact 或基础镜像,避免环境漂移。
- 为每次渲染记录模板摘要、输入摘要、耗时和调用方。
- 保留旧链路一段时间,用双跑结果比较分页、金额和关键字段。
- 明确 Typst 的边界:极端复杂的浏览器特性、动态前端组件和特殊交互式 PDF 可能仍需其他方案。
Rust 与 Typst 的组合真正改变的不是“PDF 怎么画”,而是“文档系统怎么运营”。当模板像镜像一样发布,渲染像函数一样轻量,审计像日志一样自然,PDF 生成就从脆弱的后台脚本变成了可治理的基础设施。