乐于分享
好东西不私藏

用 Rust 实现文档排版引擎:Typst 编译器源码深度解析

用 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 只生成中间的 typst AST(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

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 用 Rust 实现文档排版引擎:Typst 编译器源码深度解析

评论 抢沙发

7 + 5 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮