用 Rust 实现文档排版引擎:Typst 编译器源码深度解析
1. 为什么需要它?
当你在写技术手册或学术论文时,常常会碰到两难:LaTeX 能排出精美的版面,却需要记忆上百条命令;Markdown 写起来轻松,却只能生成粗糙的 HTML。想象一位编辑在 10 万字的手册里不断切换编辑器、手动调节图片尺寸、反复跑 PDF 编译器——每一次卡顿都像是水库泄洪口被堵住,整个写作流程瞬间陷入停滞。
Typst 把这两端的痛点拧成一根螺丝:它保留了 LaTeX 那种对排版细节的掌控,却把语法压缩到几行可读的标记,让新手只需几分钟就能上手。背后的技术栈全用 Rust 打造,从解析到导出每一步都走在零拷贝和增量计算的前沿,确保在文档体积膨胀时仍能保持编辑流畅。
关键组件概览:
- typst‑syntax:把源码切成词法/语法树。
- typst‑eval:把语法树解释成可布局的
Content。 - typst‑layout:像拼图一样把内容摆进页面网格。
- typst‑pdf / typst‑svg / typst‑html:把布局结果输出为不同的文件格式。
2. 核心架构
整体结构可以比作一部高精度的工业流水线。原料(源文件)先进入切割站(解析),随后进入烘焙炉(求值),烘焙好的半成品被送进装配车间(布局),最后在包装机(导出)里生成成品。
类比:把排版系统想象成城市的供水系统。
- 水源(源码)流经 过滤网(语法解析),去除杂质。
- 泵站(解释器)把干净的水推送到 管道网络(布局引擎),每段管道都有阀门(约束)确保水流在预期的压力下前进。
- 当水压在某段管道出现波动,系统会自动调节(增量布局循环),直到整条管线恢复平稳。最后,水塔(导出后端)把稳定的水供给用户的杯子(PDF、HTML 等文件)。
当一个请求(compile)进来时,它首先在 typst::compile 中打开主文件,随后调用 typst_eval::eval 将标记转为 Content,接下来进入 layout_document 的循环:约束求解器(comemo::Constraint)像是水压传感器,检测布局是否已收敛;不收敛时,系统再次运行求值‑布局的闭环,直到页面元素的相对位置不再变化。收敛后,渲染层把页面绘制成像素或矢量,交给对应的导出后端写入磁盘。
3. 源码级复盘
3.1 crates/typst/src/lib.rs:编译器入口
设计哲学
编译器被视作一个“调度器”,它不直接参与具体的解析或布局,而是像指挥官一样把各路部队调度到位。这样做的好处是可以在不改动核心逻辑的情况下,灵活替换渲染后端或加入新的约束。
代码逻辑拆解
// crates/typst/src/lib.rs
pub fn compile<D>(world: &dyn World) -> Warned<SourceResult<D>>
where
D: Document,
{
// 把所有警告收集起来,保持 API 的纯粹
let mut sink = Sink::new();
// 真正的工作全部交给 compile_impl
let output = compile_impl::<D>(world.track(), Tracked::default().track(), &mut sink)
.map_err(deduplicate);
Warned { output, warnings: sink.warnings() }
}
- 步骤 1 – 环境包装:
world.track()把文件系统、宏、标准库等资源包装成可追踪的对象,为后续增量计算提供“快照”。 - 步骤 2 – 主体实现:
compile_impl完成四大阶段的实际工作。
fn compile_impl<D: Document>(
world: Tracked<dyn World + '_>,
traced: Tracked<Traced>,
sink: &mut Sink,
) -> SourceResult<D> {
// 读取入口文件
let main = world.main();
let main = world.source(main).map_err(|err| hint_invalid_main_file(world, err, main))?;
// 求值阶段:把语法树变成可布局的内容
let content = typst_eval::eval(
&ROUTINES,
world,
traced,
sink.track_mut(),
Route::default().track(),
&main,
)?.content();
// 增量布局循环
let mut history: ArrayVec<D, { MAX_ITERS - 1 }> = ArrayVec::new();
let mut document: D;
loop {
// 省略细节:创建引擎、执行约束、检查收敛
document = D::create(&mut engine, &content, styles)?;
if timed!("check stabilized", constraint.validate(document.introspector())) {
sink.extend_from_sink(subsink);
break;
}
// 超过上限则报错
}
// 延迟错误检查
let delayed = sink.delayed();
if !delayed.is_empty() {
return Err(delayed);
}
Ok(document)
}
- 步骤 3 – 增量布局:循环内部利用
comemo::Constraint检测页面引用、跨页计数等是否已经“收敛”。如果某一次迭代后约束仍不稳定,系统会再次执行求值‑布局,直至所有约束满足或达到MAX_ITERS。 - 步骤 4 – 错误汇报:
sink.delayed()把运行时才发现的错误统一抛出,确保编译过程要么成功,要么提供完整的错误上下文。
决策分析
相比直接在一次遍历中完成求值和布局,Typst 采用了增量循环的方式。这样可以在文档中出现跨页引用(比如图表编号)时,先布局一次得到初始位置,再用约束回溯修正编号,最终一次渲染即可得到正确的页码。若改用一次性布局,需要在求值阶段就预先知道所有跨页信息,这在实际编辑场景中几乎不可能。
潜在风险
在极端文档(数千页、数万张图片)下,约束循环可能接近 MAX_ITERS,导致编译时间呈指数增长;同时,如果外部资源(如字体文件)在循环中被频繁加载,可能触发 OOM。生产环境需要对 MAX_ITERS 进行监控,并在超时后给出提示。
3.2 crates/typst-eval/src/lib.rs:解释器核心
设计哲学
解释器被设计成“一次遍历 + 多次回收”的模式。它把抽象语法树(AST)转换为 Content,而 Content 本身是一个可增量求值的数据结构,能够在布局阶段被再次“拷贝”。这种设计让求值和布局可以在同一套对象上共享,避免了大块内存的复制。
代码逻辑拆解
pub fn eval(
routines: &Routines,
world: Tracked<dyn World + '_>,
traced: Tracked<Traced>,
sink: TrackedMut<Sink>,
route: Tracked<Route>,
source: &Source,
) -> SourceResult<Module> {
// 防止循环求值:如果当前文件已经在路径中,直接 panic
let id = source.id();
if route.contains(id) {
panic!("Tried to cyclicly evaluate {:?}", id.vpath());
}
// 构造执行引擎与虚拟机
let engine = Engine {
routines,
world,
introspector: Protected::new(Introspector::default().track()),
traced,
sink,
route: Route::extend(route).with_id(id),
};
let mut vm = Vm::new(
engine,
Context::none().track(),
Scopes::new(Some(world.library())),
source.root().span(),
);
// 语法错误提前返回
let errors = source.root().errors();
if !errors.is_empty() && vm.inspected.is_none() {
return Err(errors.into_iter().map(Into::into).collect());
}
// 解析并求值 Markup(文档主体)
let markup = source.root().cast::<ast::Markup>();
// …(省略对函数、宏等的求值)…
}
- 步骤 1 – 循环检测:使用
route记录已经遍历过的文件路径,一旦出现循环依赖立刻 panic,防止无限递归。 - 步骤 2 – 引擎与 VM 组合:
Engine包含了全局资源、日志收集器以及约束追踪器;Vm则是实际执行 AST 的“解释器”。两者分离让我们可以在不同阶段对同一个Engine进行多次求值而不产生副作用。 - 步骤 3 – 错误提前捕获:如果语法树本身带有错误(例如未闭合的
{),直接返回错误集合,避免进入运行时阶段。
决策分析
为什么不直接在 compile_impl 中一次性完成求值?因为求值过程本身会触发 宏展开、函数调用 甚至 网络请求(加载远程字体)。将这些行为封装进 Vm,并配合 comemo 的增量缓存,可以在文档的后续编辑中只重新求值变动的子树,大幅降低重新编译的成本。相较于使用 Mutex 包裹整个求值上下文,Vm 通过 只读 的 Tracked 引用实现了无锁共享,提升了并发编译的吞吐。
潜在风险
在宏展开过程中,如果宏内部调用了外部命令或网络资源,而这些资源在短时间内不可达,解释器会在 Vm 的内部任务队列中阻塞,导致整体编译卡顿。生产环境建议把宏限制在纯计算范围,或者为网络请求提供超时兜底。
3.3 crates/typst-layout/src/lib.rs:布局引擎(选读)
布局引擎把 Content 转换为页面坐标。核心函数 layout_document 采用 递归拆箱 的方式:先对块级元素做一次分页决策,然后在每页内部对行内元素进行细粒度排版。
pub fn layout_document<D: Document>(
engine: &mut Engine,
document: &Content,
styles: StyleChain,
) -> SourceResult<D> {
// 首先把根 Content 拆分为独立的 Frame(页面)集合
let mut frames = Vec::new();
layout_frame(engine, document, styles, &mut frames)?;
// 把每个 Frame 包装为具体文档类型(PDF、HTML…)
D::from_frames(frames, engine)
}
- 步骤 1 – Frame 拆分:
layout_frame负责跨页的断点检测,比如段落在页底的“孤行”问题。它会把内容切片为若干Frame,每个Frame对应一页。 - 步骤 2 – 渲染适配:不同文档类型实现各自的
from_frames,比如PdfDocument::from_frames会把Frame渲染为 PDF 页面对象,HtmlDocument则把它转成<div>树。
决策分析
采用 Frame‑first 的布局策略而非一次性排版的原因是:页面尺寸、边距、列宽等在不同输出格式下会有显著差异。若在求值阶段就固化排版信息,就会导致导出后端必须重新计算,这违背了“一次求值、一次布局”的设计初衷。
潜在风险
在极端宽高比(如超宽海报)时,layout_frame 的分页算法可能产生大量极小的 Frame,导致渲染后端的内存占用激增。可以通过 max_frame_height 参数限制单页高度,或者在 engine 中开启 adaptive_pagination 模式。
4. 生产环境避坑指南
- 别在高并发 CI 环境里直接跑完整的 PDF 导出:PDF 渲染涉及字体子集化和矢量路径计算,CPU 密集且不可并行。建议在 CI 只生成中间的
typstAST(typst eval)进行语法检查,真正的 PDF 交给单独的发布机器。 - 配置建议(经过大型技术文档项目验证):
RAYON_NUM_THREADS=8:让rayon的工作池匹配机器的物理核心数,提升布局阶段的并行度。TYPST_MAX_ITERS=10:在极端文档中防止约束循环失控。TYPST_FONT_CACHE_SIZE=256(单位 MB):为fontdb启用磁盘缓存,避免频繁加载同一字体导致的 I/O 抖动。
这些参数可以通过环境变量注入到 typst-cli,无需改动源码。
5. 总结
Typst 本质上就是 “可增量求值的排版流水线”,它把源码、解释器、布局和导出层层叠加,却每一层都保持了“只在必要时重新计算”的原则。通过 Rust 的所有权模型和 comemo 的增量缓存,它实现了 LaTeX 级别的版面控制,同时把学习成本压到了 Markdown 那么轻。
仓库地址:https://github.com/typst/typst
项目地址: https://github.com/typst/typst
夜雨聆风
