Typst WASM 编译流水线
endfield-docmaker 使用 Typst 排版引擎在浏览器中以 WebAssembly 方式完成公文(红头文件)的编译与渲染。整条流水线采用「主线程客户端 + Web Worker」架构:字体下载、Logo 图像处理、加载进度上报在主线程完成,而 WASM 编译与 SVG/PDF 渲染全部在 Worker 线程执行,保证编辑界面的 UI 始终流畅。
目的与范围
本页覆盖 Typst WASM 编译流水线的端到端机制,以主线程客户端 typst.svelte.ts 为核心:
- 字体资产清单、IndexedDB 缓存加载与下载进度
- 内嵌(vendored)Typst 包的构建期打包机制
- Logo 图像(公章 / 水印)的主线程预处理与阴影文件映射
- Worker 的创建、生命周期与开发模式 HMR 复用
- 主线程 ↔ Worker 的消息协议与请求关联(
pendingMap) - 初始化流水线
initializeTypst()的完整控制流 - 对外公共 API(
typstProxy:addSource/mapShadow/unmapShadow/pdf/svg) - 失败模式、并发与性能权衡
有意留给兄弟页面的内容:Worker 线程内部的编译器装配细节(typst-worker/worker.ts)与协议类型定义(typst-worker/protocol.ts)属于独立的 Worker 侧主题;红头文件模板 src/lib/typst/official-doc.typ 的标记语法、字体仓库的持久化细节(src/lib/stores/fonts)、以及图像着色算法(src/lib/utils/image)同样各自成页。本页仅在流水线需要时引用它们。
概述
为什么需要这条流水线? Typst 是一个完整的排版引擎(可执行编译 + 布局 + 渲染),在浏览器中通过 @myriaddreamin 系列的 WASM 构建运行。这些重计算如果发生在主线程,任何一次文档编译都会阻塞 UI。因此项目把 typst.ts 集成重构为 Worker 化架构——文件头部的注释明确说明了这一点:
Main-thread client that communicates with the Typst Web Worker. Provides the same public API surface as the previous direct typst.ts integration but all heavy operations run off the main thread.
(来源:typst.svelte.ts)
关键概念:
| 概念 | 含义 |
|---|---|
| 主线程客户端 | typst.svelte.ts,负责资产准备、Worker 管理、对外 API 代理 |
| Worker | typst-worker/worker.ts,承载 WASM 编译器与渲染器 |
| 阴影文件 | 通过 mapShadow 写入虚拟文件系统的文件(字体、Logo 图片),供 Typst 源码引用 |
| 内嵌包 | 构建期以 base64 内联进 bundle 的 *.tar.gz Typst 包,编译器无需联网取包 |
| 可转移缓冲区 | detachBuffer 生成的独立 ArrayBuffer,可零拷贝转移给 Worker |
使用场景:用户打开应用后,initializeTypst() 完成一次性初始化(下载约 40+ MB 的 CJK 字体并缓存);随后每次内容编辑通过 typstProxy 增量更新源码并请求 SVG 预览或 PDF 输出,全部经由消息通道往返于 Worker。
架构
图中每一条边都对应源码中的真实依赖:主线程客户端导入 stores/fonts(第 31 行)、utils/image(第 28 行)、constants(第 30 行)以及字体/包资产(第 8-48 行);与 Worker 的双向消息分别由 request()(第 191-200 行)和 worker.onmessage(第 120-176 行)承担;Worker 内部装配 @myriaddreamin/typst-ts-web-compiler 与 @myriaddreamin/typst-ts-renderer 两个 WASM 模块(见 package.json)。
设计意图:这种分层让「需要 DOM API 的准备工作」(Image、Canvas、IndexedDB)留在主线程,而「纯计算」全部下放 Worker。文件内注释也点明了这一点——Logo 处理标注为 "main thread – needs DOM APIs like Image/Canvas"(typst.svelte.ts)。
初始化流水线(Core Flow)
initializeTypst() 是整条流水线的入口,它做了幂等保护:如果 initializationPromise 已存在就直接返回该 Promise,避免并发初始化;如果已初始化完成则直接返回。
1export const initializeTypst = async () => {
2 if (initializationPromise) return initializationPromise;
3
4 if (dev) {
5 const g = globalThis as typeof globalThis & { __typstWorkerInit?: Promise<void> };
6 if (g.__typstWorkerInit) {
7 initializationPromise = g.__typstWorkerInit;
8 return initializationPromise;
9 }
10 }
11
12 if (isInitialized) return;
13
14 initializationPromise = (async () => {
15 // 1. Load fonts (main thread, with IndexedDB caching + progress tracking)
16 loadingState.status = 'loading_fonts';
17 downloadProgress.progress = 0;
18 downloadProgress.activeFiles = [];
19 ...Source: typst.svelte.ts
完整初始化共三个阶段:
阶段 1:字体加载与缓存
默认字体清单 DEFAULT_FONTS 定义了 10 个字体文件,全部通过 ?url 导入静态资产路径:
1export const DEFAULT_FONTS: { name: string; url: string }[] = [
2 { name: 'FZXIAOBIAOSONG-B05.TTF', url: fontXiaoBiaoSong },
3 { name: 'SIMFANG.TTF', url: fontSimFang },
4 { name: 'SIMHEI.TTF', url: fontSimHei },
5 { name: 'SIMKAI.TTF', url: fontSimKai },
6 { name: 'times.ttf', url: fontTimesNewRoman },
7 { name: 'NotoSansCJKsc-Regular.ttf', url: fontNotoSans },
8 { name: 'NotoSerifCJKsc-Regular.ttf', url: fontNotoSerif },
9 { name: 'STIXTwoMath-Regular.ttf', url: fontSTIXTwoMath },
10 { name: 'texgyretermes-math.ttf', url: fontTeXGyreTermes },
11 { name: 'JetBrainsMono-VariableFont_wght.ttf', url: fontJBMono }
12];Source: typst.svelte.ts
为什么必须用 .ttf 扩展名? 源码注释给出了非常具体的部署约束(Bilibili Toy 静态托管层对 .otf 文件返回 404),并记录了字体选型权衡——官方 Noto CJK「静态版」CFF/OTTO 文件直接改名为 .ttf,体积约 16/24 MB,而完整可变 TTF 版本(36/60 MB)曾导致沙箱中首次渲染耗时约 1 分钟(typst.svelte.ts)。这是一个用体积换首屏性能的明确取舍。
字体下载走 loadFontsWithCache(IndexedDB 缓存 + 版本号 __FONTS_VERSION__ 控制失效),进度通过回调写入响应式状态 downloadProgress,供 UI 显示 0→1 的进度与当前活跃文件名(typst.svelte.ts)。此外还会读取用户自定义字体(getAllFonts() 中带 custom 标记的条目)并合并进传输列表。
阶段 2:Logo 处理(公章与水印)
对每个 ISSUERS(发文机关)条目并行生成两套图像资产:
- 公章(stamp):SVG 类型走
recenterSvg(重定中心)+tintSvg(recentered, [220, 0, 0])红色染色;位图类型走tintImage(url, [210, 0, 0], 1, true) - 水印(watermark):黑色低透明度染色(
tintSvg(raw, [0, 0, 0], 0.25)/tintImage(url, [0, 0, 0], 0.25))
1await Promise.all(
2 ISSUERS.map(async (issuer) => {
3 if (issuer.type === 'svg') {
4 const { svg: recentered, scale } = await recenterSvg(issuer.raw);
5 logoScales[issuer.key] = scale;
6 const redTinted = tintSvg(recentered, [220, 0, 0]);
7 const blackTinted = tintSvg(issuer.raw, [0, 0, 0], 0.25);
8 logoMappings.push(
9 { path: `/stamp-${issuer.key}.svg`, data: detachBuffer(redTinted) },
10 { path: `/watermark-${issuer.key}.svg`, data: detachBuffer(blackTinted) }
11 );
12 } else {
13 ...
14 }
15 })
16);
17setLogoScales({ ...logoScales });Source: typst.svelte.ts
产物以虚拟路径(如 /stamp-endfield.svg)写入 logoMappings,随后作为阴影文件映射进 Worker 的虚拟文件系统,模板中的 Typst 代码即可按这些路径引用图片。scale 值通过 setLogoScales 回写全局常量,供模板排版使用。
阶段 3:向 Worker 发送 init 消息
1const w = getWorker();
2const transfer = [...fontData, ...logoMappings.map((m) => m.data)];
3
4await new Promise<void>((resolve, reject) => {
5 initResolve = resolve;
6 initReject = reject;
7 w.postMessage(
8 { type: 'init', fontData, logoMappings, packages: VENDORED_PACKAGES },
9 { transfer }
10 );
11});Source: typst.svelte.ts
这里用了一个手工 Promise:initResolve/initReject 被闭包捕获,等 Worker 回发 initDone 或 initError 时在 onmessage 中触发(typst.svelte.ts)。transfer 列表让数十 MB 的字体与图像数据零拷贝转移到 Worker,避免结构化克隆带来的双倍内存峰值。
内嵌 Typst 包机制
1const packageModules = import.meta.glob<string>('./assets/typst-packages/*.tar.gz', {
2 query: '?inline',
3 import: 'default',
4 eager: true
5});
6
7const VENDORED_PACKAGES: { name: string; version: string; data: string }[] = Object.entries(
8 packageModules
9).map(([path, dataUrl]) => {
10 const match = path.match(/\/([^/]+)-(\d+\.\d+\.\d+)\.tar\.gz$/);
11 if (!match) throw new Error(`Unrecognized vendored package file name: ${path}`);
12 return { name: match[1], version: match[2], data: dataUrl.slice(dataUrl.indexOf(',') + 1) };
13});Source: typst.svelte.ts
设计意图:任何放在 src/lib/assets/typst-packages/ 且符合 <name>-<version>.tar.gz 命名的包会被 Vite 以 ?inline 打成 base64 数据 URL 内嵌进 bundle,模块加载即解析(eager: true)。dataUrl.slice(dataUrl.indexOf(',') + 1) 剥掉 data:application/gzip;base64, 前缀只留 base64 载荷。这样编译器永远不需要网络取包——源码注释明确说明这是为了兼容 Bilibili Toy 这类不提供任意静态路径的沙箱环境(typst.svelte.ts)。命名不合规会在模块求值时立即抛错(fail-fast),把错误暴露在构建/加载期而非运行期。
Worker 管理与消息协议
Worker 的创建与 HMR 复用
1function getWorker(): Worker {
2 if (worker) return worker;
3
4 // In dev mode, reuse existing worker across HMR updates
5 if (dev) {
6 const g = globalThis as typeof globalThis & { __typstWorker?: Worker };
7 if (g.__typstWorker) {
8 worker = g.__typstWorker;
9 return worker;
10 }
11 }
12
13 worker = new Worker(new URL('./typst-worker/worker.ts', import.meta.url), {
14 type: 'module'
15 });
16 ...
17 if (dev) {
18 (globalThis as typeof globalThis & { __typstWorker?: Worker }).__typstWorker = worker;
19 }
20
21 return worker;
22}Source: typst.svelte.ts
为什么有这段逻辑? Vite 的 HMR 会重新执行 typst.svelte.ts 模块,模块级 worker 变量被重置。若不做处理,每次热更新都会新建一个 Worker 并重新初始化 WASM 编译器(数秒级开销 + 重复加载体积巨大的字体传输)。因此开发模式下把 Worker 挂到 globalThis.__typstWorker 上跨模块重载存活;初始化 Promise 同理挂在 globalThis.__typstWorkerInit(typst.svelte.ts、L306-L309)。生产构建不受影响(dev 为 false 时完全跳过)。
Worker 通过 Vite 原生的 new Worker(new URL(...), { type: 'module' }) 构造,获得打包、代码分割与按需加载支持。
消息协议:WorkerResponse
主线程从 $lib/typst-worker/protocol 导入 WorkerResponse 与 LoadingStatus 类型(typst.svelte.ts)。从 onmessage 的 switch 分支可以反推出协议的完整消息集合:
| Worker → 主线程消息 | 字段 | 用途 |
|---|---|---|
status | status: LoadingStatus | 更新 loadingState.status(如 loading_fonts) |
packageLoading | name, downloaded | 更新 packageLoadingState,展示内嵌包解压/下载进度 |
initDone | — | 置 isInitialized = true 并 resolve init Promise |
initError | error: string | reject init Promise,并清空 initializationPromise 以允许重试 |
result | id, data: ArrayBuffer | 常规请求响应(如 PDF 字节) |
svgResult | id, svg: string | SVG 渲染结果(字符串而非 ArrayBuffer) |
error | id, error: string | 拒绝指定请求 |
1worker.onmessage = (e: MessageEvent<WorkerResponse>) => {
2 const msg = e.data;
3 switch (msg.type) {
4 case 'status':
5 loadingState.status = msg.status;
6 break;
7 ...
8 case 'svgResult': {
9 const p = pending.get(msg.id);
10 if (p) {
11 pending.delete(msg.id);
12 // SVG payloads travel as strings, not ArrayBuffers – resolve the
13 // pending request with the string the worker sent back.
14 p.resolve(msg.svg as unknown as ArrayBuffer);
15 }
16 break;
17 }
18 case 'error': {
19 const p = pending.get(msg.id);
20 if (p) {
21 pending.delete(msg.id);
22 p.reject(new Error(msg.error));
23 }
24 break;
25 }
26 }
27};Source: typst.svelte.ts
注意 svgResult 分支的类型断言 msg.svg as unknown as ArrayBuffer:request() 的统一返回类型是 ArrayBuffer,但 SVG 以字符串传输,因此用双重断言穿透类型系统,实际消费者 svg() 再以 typeof res === 'string' 收窄。这是为保持旧 typst 默认导出接口不变而做的妥协。
请求-响应关联
1function request(
2 msg: Record<string, unknown>,
3 transfer?: Transferable[]
4): Promise<ArrayBuffer | undefined> {
5 const id = nextId++,
6 ...
7}(准确签名见下方 API Reference。)pending 被显式标注了 eslint 注释 -- internal bookkeeping, not reactive state(typst.svelte.ts),说明作者刻意区分「需要驱动 UI 的 $state」与「纯内部簿记数据」,避免 Svelte 5 编译器对高变动 Map 做无谓的响应式追踪。
可转移缓冲区:detachBuffer
1/** Create a standalone ArrayBuffer copy of a typed array, safe for transfer. */
2function detachBuffer(data: Uint8Array): ArrayBuffer {
3 return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
4}Source: typst.svelte.ts
为什么要复制? Uint8Array 可能只是其底层 ArrayBuffer 的一个视图(非零 byteOffset,或多个视图共享同一 buffer)。结构化克隆/转移传输要求独立的 buffer;slice 按视图范围裁出一个新 buffer,保证 transfer 后源数据可用且不连带转移无关内存。所有字体数据、Logo 图像以及 mapShadow 的文件内容都经过这层处理。代价是一次内存拷贝——对一次性初始化数据可接受,换来的是 Worker 侧零拷贝接收。
公共 API(typstProxy)
文件尾部的代理对象复刻了旧版 $typst 默认导出的方法面,使迁移到 Worker 架构时调用点改动最小(typst.svelte.ts):
const typstProxy = { addSource, mapShadow, unmapShadow, pdf, svg };Source: typst.svelte.ts
各方法实现
1async function addSource(path: string, content: string): Promise<void> {
2 await request({ type: 'addSource', path, content });
3}
4
5async function mapShadow(path: string, data: Uint8Array): Promise<void> {
6 const buf = detachBuffer(data);
7 await request({ type: 'mapShadow', path, data: buf }, [buf]);
8}
9
10async function unmapShadow(path: string): Promise<void> {
11 await request({ type: 'unmapShadow', path });
12}
13
14async function pdf(): Promise<Uint8Array | undefined> {
15 const buf = await request({ type: 'pdf' });
16 return buf ? new Uint8Array(buf) : undefined;
17}
18
19/** Render the current document to a whole-document SVG string. */
20async function svg(): Promise<string | undefined> {
21 const res = await request({ type: 'svg' });
22 return typeof res === 'string' ? res : undefined;
23}Source: typst.svelte.ts
addSource写入/更新虚拟文件系统中的 Typst 源文件(字符串内容,无需 transfer)mapShadow/unmapShadow映射/解除二进制阴影文件(字体、图片),传输时转移 buffer 所有权pdf把 Worker 回传的ArrayBuffer包装为Uint8Array(PDF 字节流)svg渲染整篇文档为单个 SVG 字符串,用于实时预览
另外还有一个配套入口 waitForTypst():已初始化则立即返回,否则等待或触发现有的初始化 Promise(typst.svelte.ts)——供需要确保编译器就绪但不负责启动初始化的调用方使用。
API Reference
initializeTypst(): Promise<void>
初始化 Typst 编译流水线:加载并缓存字体、处理 Logo、创建 Worker 并传输全部资产。幂等——并发调用共享同一个 Promise;已初始化后调用立即返回。
Returns:初始化完成后 resolve 的 Promise;若 Worker 回报 initError 则 reject。
Behavior on failure:initError 会把 initializationPromise 置为 null(typst.svelte.ts),因此下次调用会重新走完整初始化流程——失败可重试,而不是永久卡死。
waitForTypst(): Promise<void>
等待编译器就绪。若 isInitialized 为 true 直接返回;否则等待现有 initializationPromise,或在其不存在时调用 initializeTypst() 触发初始化。
addSource(path: string, content: string): Promise<void>
向 Worker 的虚拟文件系统写入 Typst 源文件。path 为虚拟路径,content 为源码文本。
mapShadow(path: string, data: Uint8Array): Promise<void>
将二进制数据映射为虚拟文件(字体、图片等)。内部先 detachBuffer 再以 transfer 列表发送,零拷贝。
unmapShadow(path: string): Promise<void>
解除指定路径的阴影文件映射。
pdf(): Promise<Uint8Array | undefined>
编译当前文档并返回 PDF 字节流;无结果时返回 undefined。
svg(): Promise<string | undefined>
渲染整篇文档为单个 SVG 字符串(实时预览用);无结果时返回 undefined。
导出的响应式状态
| 状态 | 类型 | 说明 |
|---|---|---|
loadingState | { status: LoadingStatus } | 编译器生命周期状态(如 'loading_fonts'),对应 UI 文案「正在加载 Typst 编译器...」(messages/zh.json) |
packageLoadingState | { name: string | null, downloaded: number } | 内嵌包加载进度 |
downloadProgress | { progress: number, activeFiles: string[] } | 字体批量下载进度(0→1)与活跃文件名 |
失败模式、边界情况与并发
初始化失败可重试:initError 分支同时清空 initResolve/initReject/initializationPromise,保证后续 initializeTypst() 能重新发起。这是典型的「失败不缓存」模式。
重复响应/未知 id 防御:result / svgResult / error 分支都用 if (p) 守卫——Worker 回发未知或重复的 id 时静默忽略(pending.get 返回 undefined),不会抛异常。
Worker 崩溃兜底:worker.onerror 仅打印 console.error('Typst worker error:', e)(typst.svelte.ts)。源码中未见对 pending 请求的整体冲刷或 Worker 重建逻辑——若 Worker 在有未决请求时崩溃,这些 Promise 将保持 pending(既不 resolve 也不 reject)。这是当前实现的一个已知边界,扩展时值得注意。
并发请求:request() 使用自增 id + pending Map,天然支持多个编译请求在途并发;每个响应按 id 精确路由回对应 Promise。Svelte 5 的 $state(模块级导出)让三个进度状态可被任意组件直接订阅。
数据所有权边界:所有经 transfer 发送的 buffer 在主线程即被 detach(detachBuffer),主线程不再保留可变引用,杜绝了双线程同时访问同一内存的竞态。
性能与运维要点
- 首屏体积与速度的取舍:选择 Noto CJK 静态版(16/24 MB)而非完整可变版(36/60 MB),源码注释记录了后者曾让沙箱首渲染耗时约 1 分钟。字体经 IndexedDB 缓存,版本号
__FONTS_VERSION__控制失效重建。 - 零拷贝传输:初始化的
transfer列表一次性转移全部字体与 Logo buffer,避免结构化克隆造成的瞬时双倍内存。 - 重计算全部离主线程:编译与渲染不阻塞 UI;主线程只做必要的 DOM 相关准备工作。
- 无网络依赖:字体来自打包资产、Typst 包以 base64 内嵌,应用在无外网/受限沙箱(Bilibili Toy)中可完整工作。
- HMR 友好:开发模式下 Worker 与初始化 Promise 挂在
globalThis上跨模块热更新存活,避免开发时反复重建 WASM 实例。
扩展点
- 新增字体:把
.ttf(必须是 ttf 扩展名)放入src/lib/assets/fonts/,并在DEFAULT_FONTS增加一行{ name, url };更新__FONTS_VERSION__使缓存失效。 - 新增 Typst 包:按
<name>-<version>.tar.gz命名放入src/lib/assets/typst-packages/即自动内嵌;命名不合规会在加载期抛Unrecognized vendored package file name。 - 新增发文机关 Logo:在
constants的ISSUERS中登记(svg或位图类型),初始化时自动生成红章/黑水印两套虚拟文件(/stamp-<key>.*与/watermark-<key>.*)。 - 新增编译操作:仿照
pdf/svg,在主线程加一个request({ type: '...' })包装函数并在 Worker 侧协议中处理该 type;typstProxy保持与旧接口对齐即可。
相关链接
- Worker 侧实现:typst-worker/worker.ts(本页未展开,属兄弟主题)
- 协议类型定义:typst-worker/protocol.ts
- 字体仓库与缓存:stores/fonts.ts
- 图像着色工具:utils/image.ts
- WASM 依赖声明:package.json
- 项目概览(中文 README,含目录结构说明):README.md