Web Worker 与字体加载
src/lib/typst.svelte.ts 是 Typst WASM 引擎的主线程客户端:它把所有重量级编译/渲染操作移入 Web Worker,并在初始化阶段统一完成默认字体、用户自定义字体、机构 Logo 与内联 Typst 包的加载、缓存与零拷贝转移(transfer)。
目的与范围(Purpose and Scope)
本页覆盖"Web Worker 与字体加载"这一子系统的完整客户端侧实现:
- 主线程客户端
typst.svelte.ts的 Worker 生命周期管理(创建、dev 模式 HMR 复用、单例约束) - 主线程 ⇄ Worker 的消息协议(请求关联、响应分发、进度与状态上报)
initializeTypst()的端到端初始化流程:字体下载(IndexedDB 缓存)→ 自定义字体合并 → Logo 处理 →init消息与 transfer- 默认字体清单(10 个
.ttf资产)、.ttf扩展名约束与 Bilibili Toy 静态层的兼容性取舍 - 构建期字体目录哈希
__FONTS_VERSION__(vite.config.ts)及其缓存失效作用 - 对外暴露的代理 API(
typstProxy:addSource/mapShadow/unmapShadow/pdf/svg)
不在本页范围(留给兄弟页面):
- Typst 模板(
official-doc.typ等)的排版语法与编译产物渲染细节 —— 参见 Typst 编译/渲染相关页面 - Logo 着色与 SVG 重定位的算法实现(
src/lib/utils/image.ts中的tintSvg/tintImage/recenterSvg)—— 本页仅描述其在初始化流程中的调用时机 - 字体 IndexedDB 缓存存储的内部实现(
src/lib/stores/fonts.ts)—— 本页仅从调用方视角描述loadFontsWithCache/getAllFonts的契约 - pdf.js 自身的 Worker 配置(仅涉及
vite.config.ts中worker: { format: 'es' }的构建约束)
说明:Worker 端实现(
src/lib/typst-worker/worker.ts)与协议定义(src/lib/typst-worker/protocol.ts)的源码未在本页读取预算内展开,本文对其行为的描述均由客户端侧调用代码推导得出,均已标注依据。
概述(Overview)
endfield-docmaker 在浏览器内通过 typst.ts(@myriaddreamin/typst.ts、@myriaddreamin/typst-ts-web-compiler、@myriaddreamin/typst-ts-renderer,均为 0.8.0-rc3)运行 Typst 编译器与渲染器(WebAssembly)。WASM 编译与 PDF/SVG 生成是 CPU 密集且可能耗时数十秒的操作,若在主线程执行会阻塞 UI;同时,中文字体(Noto CJK 单文件约 16/24 MB)的下载与解码同样昂贵。
因此该子系统采用如下设计:
- 重活下沉 Worker:编译、渲染、PDF 导出全部在
src/lib/typst-worker/worker.ts中执行;主线程只保留一个 Promise 化的 RPC 代理(typstProxy),其方法签名与旧的直接集成$typst默认导出保持一致,使调用方改动最小。 - 字体在主线程加载:字体下载走主线程的
fetch+ IndexedDB 缓存(stores/fonts),因为进度条 UI 需要downloadProgress响应式状态实时更新;下载完成后通过postMessage的 transfer 列表把ArrayBuffer零拷贝移交给 Worker。 - DOM 依赖留在主线程:Logo 的 SVG 重定位与 PNG 着色依赖
Image/Canvas等 DOM API,无法在 Worker 中执行,因此在发送init前于主线程完成,产物同样以虚拟文件路径(/stamp-*.svg、/watermark-*.png等)映射后转移给 Worker。 - 构建期字体指纹:
vite.config.ts对src/lib/assets/fonts/目录所有文件内容计算 SHA-256,取前 12 位十六进制作为fontsVersion,注入为全局常量__FONTS_VERSION__,用作 IndexedDB 缓存键,实现"字体文件一变、缓存自动失效"。
关键术语:
| 术语 | 含义 |
|---|---|
typstProxy | 主线程默认导出的代理对象,转发 addSource/mapShadow/unmapShadow/pdf/svg 到 Worker |
pending Map | 以自增 id 关联"请求 ↔ Promise"的表,是 RPC 的核心 |
detachBuffer() | 把 Uint8Array 复制为独立 ArrayBuffer,保证可安全 transfer 而不破坏调用方数据 |
VENDORED_PACKAGES | 构建期内联(?inline)的 Typst 包(<name>-<version>.tar.gz),免去网络拉取 |
__FONTS_VERSION__ | 构建期注入的字体目录内容哈希,IndexedDB 缓存版本键 |
架构(Architecture)
架构解读:
- 分层边界清晰:构建期(Vite 插件/配置)只负责产出"资产 URL + 版本指纹 + 内联包";主线程负责一切需要 DOM、进度 UI 与网络缓存的工作;Worker 只负责 WASM 计算。三者通过
postMessage的结构化克隆与 transfer 列表衔接。 - 单一 Worker 实例:
getWorker()是模块级懒单例;dev 模式额外把实例挂到globalThis.__typstWorker,使 Vite HMR 替换模块后仍复用同一个(代价高昂的)WASM Worker。 - 响应式状态是唯一的"广播通道":Worker 主动推送的
status/packageLoading不走 RPC,而是直接写入 Svelte 5$state对象,供任意组件订阅渲染进度 UI。
核心实现详解
1. 字体资产与 .ttf 扩展名约束
DEFAULT_FONTS 以 Vite 的 ?url 导入方式引用 10 个字体文件,构建后得到带哈希的资源 URL。选型注释(源码第 13–21 行)记录了三条硬性工程约束,这是理解本子系统为何"长成这样"的关键:
- Bilibili Toy 静态层会对
.otf文件返回 404,因此所有字体资产必须使用.ttf/.TTF扩展名。- 数学字体(STIX Two Math、TeX Gyre Termes Math)是通过
scripts/otf2ttf.py从上游 OTF 转换出的真 TrueType 构建,且保留 MATH 表(Typst 排版数学公式必需)。- Noto CJK 两个文件实际上是官方静态版 CFF/OTTO(
NotoSansCJKsc-Regular.otf/NotoSerifCJKsc-Regular.otf,来自 noto-cjk 仓库的 Sans|Serif/OTF/SimplifiedChinese),仅重命名为.ttf。源码明确记录了取舍:完整可变 TTF 构建分别为 36/60 MB,会使 Toy 沙箱预览首屏渲染耗时约 1 分钟;改用重命名的静态 OTF 后负载降至约 16/24 MB。
即:.ttf 扩展名在这里首先是部署平台的约束,其次才是体积优化手段——文件内部格式是否为 TrueType 并不影响 Typst 编译器/渲染器的正常工作。
1import fontXiaoBiaoSong from '$lib/assets/fonts/FZXIAOBIAOSONG-B05.TTF?url';
2import fontSimFang from '$lib/assets/fonts/SIMFANG.TTF?url';
3import fontSimHei from '$lib/assets/fonts/SIMHEI.TTF?url';
4import fontSimKai from '$lib/assets/fonts/SIMKAI.TTF?url';
5import fontTimesNewRoman from '$lib/assets/fonts/times.ttf?url';
6import fontNotoSans from '$lib/assets/fonts/NotoSansCJKsc-Regular.ttf?url';
7import fontNotoSerif from '$lib/assets/fonts/NotoSerifCJKsc-Regular.ttf?url';
8import fontSTIXTwoMath from '$lib/assets/fonts/STIXTwoMath-Regular.ttf?url';
9import fontTeXGyreTermes from '$lib/assets/fonts/texgyretermes-math.ttf?url';
10import fontJBMono from '$lib/assets/fonts/JetBrainsMono-VariableFont_wght.ttf?url';
11
12export const DEFAULT_FONTS: { name: string; url: string }[] = [
13 { name: 'FZXIAOBIAOSONG-B05.TTF', url: fontXiaoBiaoSong },
14 { name: 'SIMFANG.TTF', url: fontSimFang },
15 { name: 'SIMHEI.TTF', url: fontSimHei },
16 { name: 'SIMKAI.TTF', url: fontSimKai },
17 { name: 'times.ttf', url: fontTimesNewRoman },
18 { name: 'NotoSansCJKsc-Regular.ttf', url: fontNotoSans },
19 { name: 'NotoSerifCJKsc-Regular.ttf', url: fontNotoSerif },
20 { name: 'STIXTwoMath-Regular.ttf', url: fontSTIXTwoMath },
21 { name: 'texgyretermes-math.ttf', url: fontTeXGyreTermes },
22 { name: 'JetBrainsMono-VariableFont_wght.ttf', url: fontJBMono }
23];Source: typst.svelte.ts
字体清单覆盖了红头公文模板所需的全部字体族:仿宋(SIMFANG)、黑体(SIMHEI)、楷体(SIMKAI)、方正小标宋(红头标题)、Times New Roman(西文衬线)、Noto Sans/Serif CJK SC(泛用中英混排)、两款数学字体(Typst 数学排版)、JetBrains Mono 可变字重(代码/等宽)。
2. 构建期内联的 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
设计意图(源码第 35–43 行注释):任何放入 src/lib/assets/typst-packages/、遵循 <name>-<version>.tar.gz 命名的包都会在构建时被 Vite 以 ?inline 内联为 base64 data URL;此处剥掉 data:application/gzip;base64, 前缀(slice(dataUrl.indexOf(',') + 1))得到裸 base64 载荷交给编译器。命名不匹配会在构建产物加载时直接抛错(fail-fast),避免编译期静默缺包。该设计让编译器完全不需要在运行时从网络拉取 Typst Universe 包——这对无法伺服任意静态路径的 Bilibili Toy 沙箱至关重要。
3. Worker 生命周期与单例管理
1let worker: Worker | null = null;
2let nextId = 1;
3const pending = new Map<
4 number,
5 { resolve: (data?: ArrayBuffer) => void; reject: (e: Error) => void }
6>();
7let initResolve: (() => void) | null = null;
8let initReject: ((e: Error) => void) | null = null;
9let initializationPromise: Promise<void> | null = null;
10let isInitialized = false;
11
12function getWorker(): Worker {
13 if (worker) return worker;
14
15 // In dev mode, reuse existing worker across HMR updates
16 if (dev) {
17 const g = globalThis as typeof globalThis & { __typstWorker?: Worker };
18 if (g.__typstWorker) {
19 worker = g.__typstWorker;
20 return worker;
21 }
22 }
23
24 worker = new Worker(new URL('./typst-worker/worker.ts', import.meta.url), {
25 type: 'module'
26 });
27 // ... onmessage / onerror 绑定,见下文
28 return worker;
29}Source: typst.svelte.ts
要点:
- Worker 以
type: 'module'创建(ESM Worker)。这与vite.config.ts中的注释相互印证:worker: { format: 'es' }—— pdf.js 的 worker 仅提供 ESM 构建,默认的 IIFE 打包会使其静默失败,因此构建器必须以 ESM 格式输出 worker。两条约束(pdf.js 的 ESM worker、本处 module worker)共同决定了这一配置。 new Worker(new URL(...), import.meta.url)是 Vite 官方推荐的 worker 打包方式:Vite 会把该 worker 作为独立入口打包并产出正确的 URL。- dev 模式下把实例缓存到
globalThis.__typstWorker:HMR 替换typst.svelte.ts模块时,模块级变量worker会被重置为null,若无此全局缓存,每次热更新都会新建一个加载了数十 MB WASM 的 Worker。同理,initializeTypst()在 dev 下也复用globalThis.__typstWorkerInit,防止重复走整个字体加载/初始化流程。
4. 消息协议:RPC + 推送双通道
Worker 侧响应统一封装为 WorkerResponse(类型来自 $lib/typst-worker/protocol),在 worker.onmessage 中按 type 分发为两类:
推送类(写响应式状态,无对应请求)
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 case 'packageLoading':
8 packageLoadingState.name = msg.name;
9 packageLoadingState.downloaded = msg.downloaded;
10 break;Source: typst.svelte.ts
status→loadingState.status:编译器初始化阶段的状态机标签(如loading_fonts由主线程自己写入,WASM 阶段状态由 Worker 回报)。packageLoading→{ name, downloaded }:内联包解压/装载进度。
RPC 应答类(匹配 pending 中的 id)
1 case 'initDone':
2 isInitialized = true;
3 initResolve?.();
4 initResolve = null;
5 initReject = null;
6 break;
7 case 'initError':
8 initReject?.(new Error(msg.error));
9 initResolve = null;
10 initReject = null;
11 initializationPromise = null; // 允许下次重试
12 break;
13 case 'result': {
14 const p = pending.get(msg.id);
15 if (p) { pending.delete(msg.id); p.resolve(msg.data); }
16 break;
17 }
18 case 'svgResult': {
19 const p = pending.get(msg.id);
20 if (p) {
21 pending.delete(msg.id);
22 // SVG payloads travel as strings, not ArrayBuffers – resolve the
23 // pending request with the string the worker sent back.
24 p.resolve(msg.svg as unknown as ArrayBuffer);
25 }
26 break;
27 }
28 case 'error': {
29 const p = pending.get(msg.id);
30 if (p) { pending.delete(msg.id); p.reject(new Error(msg.error)); }
31 break;
32 }
33 }
34};
35worker.onerror = (e) => { console.error('Typst worker error:', e); };Source: typst.svelte.ts
注意 svgResult 的类型技巧:pending 的 resolve 声明为 (data?: ArrayBuffer) => void,但 SVG 整文档渲染结果以字符串传输,因此用 as unknown as ArrayBuffer 双重断言穿透类型系统,再由 svg() 一侧用 typeof res === 'string' 收窄还原。这是在"单一 pending 表"与"两种载荷类型"之间做出的最小改动折中。
initError 分支刻意将 initializationPromise 置空:初始化失败后调用方再次调用 initializeTypst() 即可整体重试(字体已入 IndexedDB 缓存,重试成本主要是重发 init),而不是永久卡死在失败的 Promise 上。
5. RPC 请求封装
1function request(
2 msg: Record<string, unknown>,
3 transfer?: Transferable[]
4): Promise<ArrayBuffer | undefined> {
5 const id = nextId++;
6 return new Promise((resolve, reject) => {
7 pending.set(id, { resolve, reject });
8 getWorker().postMessage({ ...msg, id }, { transfer: transfer ?? [] });
9 });
10}Source: typst.svelte.ts
- 每次调用分配自增
id,与响应中的msg.id配对完成 Promise 落地。 postMessage第二参数显式传{ transfer }(即使为空数组),配合下面的detachBuffer实现大缓冲区零拷贝转移。- 若 Worker 在请求未完成时崩溃,
worker.onerror仅打日志,pending 中的 Promise 会悬挂(不 resolve 也不 reject)——这是一个已知的边界行为,调用方不应假设必然返回。
6. 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
为什么需要它:postMessage transfer 会**分离(detach)**被转移的 ArrayBuffer,原视图随即不可用。IndexedDB 读回的数据与 Logo 处理产物常是 Uint8Array,其底层 buffer 可能带有 byteOffset 或长度大于视图(例如来自更大的池化缓冲),直接转移会(a)分离别处仍在用的缓冲、(b)把无关字节一起送走。buffer.slice(byteOffset, byteOffset + byteLength) 精确复制出一份独立且恰好等长的 ArrayBuffer,安全转移后主线程与 Worker 各持一份完整数据、互不干扰——代价是一次 memcpy(对 24 MB 的 Noto Serif 来说在主线程上仍远快于一次网络下载)。
初始化核心流程(Core Flow)
逐步解读(对应源码 initializeTypst,第 205–312 行):
- 幂等门闩:
initializationPromise已存在则直接返回同一 Promise(并发调用共享一次初始化);isInitialized为真则短路;dev 模式优先复用globalThis.__typstWorkerInit。 - 字体下载与缓存:状态置
loading_fonts、进度归零后调用loadFontsWithCache(DEFAULT_FONTS, __FONTS_VERSION__, cb)。回调同步写downloadProgress,令进度条 0→1 且展示当前活跃文件名;完成后立即复位,避免残留。__FONTS_VERSION__是 Vite 注入的字体目录哈希——字体文件内容一变,缓存键随之改变,旧缓存自然失效,无需手动清库。 - 合并自定义字体:
getAllFonts()取回 IndexedDB 中全部字体记录,filter(f => f.custom)得到用户上传的字体,与默认字体拼接为fontData(全部先经detachBuffer复制)。用户上传字体因此对每次编译天然可用,无需单独的注册 API。 - Logo 预处理(必须在主线程):对
ISSUERS中每个签发机构,按svg/位图两类分别生成"公章"(红色重定位版)与"水印"(黑色 25% 透明度版)两份虚拟文件,路径形如/stamp-<key>.svg、/watermark-<key>.png;Promise.all并行处理;setLogoScales把每个 Logo 的缩放比例写回全局常量表供模板换算尺寸。 - 一次性
init转移:transfer = [...fontData, ...logoMappings.map(m => m.data)],单条postMessage携带全部二进制并零拷贝转移。随后用initResolve/initReject把 Worker 的initDone/initError回调桥接为 Promise。 - 完成:
initDone置isInitialized = true;此后waitForTypst()立即返回,typstProxy各方法可正常调用。
使用示例
示例 1:组件侧初始化与文档编译
1import typst, { initializeTypst, loadingState, downloadProgress } from '$lib/typst.svelte';
2
3// 首次进入编辑页:触发字体下载 + WASM 初始化(内部幂等,可随处调用)
4await initializeTypst();
5
6// 写入/更新文档源码(虚拟文件路径)
7await typst.addSource('/main.typ', source);
8
9// 增量渲染整篇文档为 SVG 字符串
10const svg = await typst.svg();
11
12// 导出 PDF(Uint8Array,未初始化完成时返回 undefined)
13const pdfBytes = await typst.pdf();以上调用形态依据 typst.svelte.ts 中 waitForTypst、typstProxy 的真实签名编写(typstProxy = { addSource, mapShadow, unmapShadow, pdf, svg } 默认导出)。
示例 2:把二进制资源映射进编译器虚拟文件系统
1async function mapShadow(path: string, data: Uint8Array): Promise<void> {
2 const buf = detachBuffer(data);
3 await request({ type: 'mapShadow', path, data: buf }, [buf]);
4}
5
6async function unmapShadow(path: string): Promise<void> {
7 await request({ type: 'unmapShadow', path });
8}Source: typst.svelte.ts
mapShadow 用于把图片/附件等资源以虚拟路径挂载进 Typst 编译器的 shadow 文件系统,模板中即可通过该路径引用。注意 detachBuffer 先复制再转移——若直接 transfer data.buffer,调用方的 Uint8Array 会被分离而失效。
示例 3:构建期字体版本指纹(Vite 配置)
1// Compute a hash of the fonts directory for cache invalidation.
2const fontsDir = join('src', 'lib', 'assets', 'fonts');
3const fontFiles = readdirSync(fontsDir).sort();
4const fontsHash = createHash('sha256');
5for (const file of fontFiles) {
6 fontsHash.update(file);
7 fontsHash.update(readFileSync(join(fontsDir, file)));
8}
9const fontsVersion = fontsHash.digest('hex').slice(0, 12);Source: vite.config.ts
对目录内排序后的每个文件,把文件名与文件内容一并喂入 SHA-256:文件名参与哈希意味着"仅重命名(例如把 .otf 改成 .ttf)"同样会触发缓存失效;内容参与哈希意味着"同名字体换内容"也会失效。取 12 位十六进制(48 bit)在碰撞概率与缓存键长度之间折中。同时该文件中 worker: { format: 'es' } 是 pdf.js ESM-only worker 的必要约束。
配置项
| 配置 / 常量 | 类型 | 默认值 | 说明 |
|---|---|---|---|
DEFAULT_FONTS | { name: string; url: string }[] | 10 项固定清单 | 随 init 一次性发送给 Worker 的默认字体集;name 同时是虚拟文件系统内的文件名 |
__FONTS_VERSION__ | string | 构建期计算 | 字体目录 SHA-256 前 12 位;loadFontsWithCache 的缓存版本键 |
VENDORED_PACKAGES | { name, version, data }[] | 由 src/lib/assets/typst-packages/*.tar.gz 推导 | 内联 Typst 包;文件名不匹配 <name>-<semver>.tar.gz 时抛错 |
| Worker 构建格式 | Vite 配置 | 'es' | vite.config.ts 中 worker: { format: 'es' };pdf.js worker ESM-only |
assetsInclude | Vite 配置 | ['**/*.tar.gz'] | 使 .tar.gz 被视为可内联/引用的资产 |
| 自定义字体 | IndexedDB 记录 | 无 | getAllFonts() 中 custom === true 的记录会在每次初始化时与默认字体合并下发 |
| dev 复用开关 | 布尔 | $app/environment 的 dev | 为真时把 Worker 与初始化 Promise 挂到 globalThis.__typstWorker / __typstWorkerInit |
API 参考
以下签名均摘自 src/lib/typst.svelte.ts(模块级导出)。方法名与旧 $typst 默认导出保持一致。
initializeTypst(): Promise<void>
完成字体下载(带缓存)、Logo 预处理并向 Worker 发送 init。幂等:并发调用共享同一 Promise;已初始化则立即返回;初始化失败(收到 initError)后可再次调用重试。
Throws: Worker 回报 initError 时 reject,错误信息为 msg.error 字符串。
waitForTypst(): Promise<void>
等待初始化完成:已初始化直接返回;否则复用或触发 initializeTypst()。适合在编译/导出前作为轻量门槛调用。
default export: typstProxy
| 方法 | 签名 | 返回 / 行为 |
|---|---|---|
addSource | (path: string, content: string) => Promise<void> | 写入/覆盖虚拟文件系统中的文档源码 |
mapShadow | (path: string, data: Uint8Array) => Promise<void> | 将二进制挂到虚拟路径(buffer 转移后调用方视图失效,见示例 2) |
unmapShadow | (path: string) => Promise<void> | 解除虚拟路径映射 |
pdf | () => Promise<Uint8Array | undefined> | 返回 PDF 字节;request 无数据时为 undefined |
svg | () => Promise<string | undefined> | 整文档 SVG 字符串;载荷为字符串而非 ArrayBuffer(见 svgResult 分支说明) |
并发语义: 所有代理方法共用一个自增 id 空间与一个 pending Map,天然支持并发调用与乱序完成;nextId 单调递增,不存在 id 复用冲突。
Throws: Worker 回报 error 类型时,对应 pending 条目 reject(new Error(msg.error))。
响应式状态(Svelte 5 $state 导出)
| 导出 | 形状 | 写入来源 |
|---|---|---|
loadingState | { status: LoadingStatus } | 主线程('loading_fonts')与 Worker status 推送 |
packageLoadingState | { name: string | null; downloaded: number } | Worker packageLoading 推送 |
downloadProgress | { progress: number; activeFiles: string[] } | loadFontsWithCache 进度回调;加载完成后归零 |
失败模式、边界与并发
- 初始化失败可重试:
initError清空initializationPromise,下一次initializeTypst()会重走流程;由于字体已入 IndexedDB,重试的主要成本是重发init消息与 WASM 重装载。 - 未识别的内联包名 fail-fast:
VENDORED_PACKAGES构建映射阶段对不匹配<name>-<semver>.tar.gz的文件直接throw,而不是静默跳过——避免编译时才发现缺包、难以定位。 - Worker 崩溃的悬挂 Promise:
worker.onerror只console.error,不会 rejectpending中未完成的请求。调用方需意识到:Worker 级故障下代理方法可能永不返回(页面通常表现为进度停滞,而非异常弹窗)。 - dev/HMR 稳定性:模块级单例 +
globalThis双缓存,保证热更新不重建昂贵的 WASM Worker、不重复初始化;代价是 dev 模式下关闭页面标签前 Worker 不会因模块替换被回收。 - transfer 的分离语义:
fontData/logoMappings/mapShadow的缓冲转移后主线程即失去访问权,所有"主线程仍需保留"的数据(IndexedDB 中的字体记录、Logo 原始 SVG)都不作为转移对象,转移的永远是detachBuffer的独立副本。 - 大字体与首屏体验:静态 OTF 重命名的 Noto CJK(约 16/24 MB)是为了把 Toy 沙箱首屏渲染从约 1 分钟拉回可用区间而做的取舍;
downloadProgress.activeFiles让用户能看到"卡在哪个文件",而不是无反馈长等待。 .otf404 陷阱:新增字体资产必须以.ttf/.TTF扩展名存放,否则 Bilibili Toy 静态层返回 404,导致运行时字体加载失败(构建期不报错)。
性能与运维要点
- 零拷贝传输:约 40+ MB 的字体 + Logo 载荷通过一次
postMessage的 transfer 列表移交,避免结构化克隆对大二进制的全量复制(克隆 24 MB 缓冲在低端设备上可达数十毫秒甚至更高)。 - 缓存命中路径:字体仅在版本变化时下载一次;
__FONTS_VERSION__不变时loadFontsWithCache走 IndexedDB,初始化耗时主要剩 WASM 装载与字体注册。 - 主线程占用控制:主线程仅做 fetch/IndexedDB/Canvas 着色;编译、渲染、PDF 序列化全部在 Worker,编辑期间的输入与 UI 动画不被阻塞。
- 包体积权衡:内联 Typst 包增加 JS bundle 体积,但换得"零网络依赖",且对沙箱环境(无任意静态路径)是唯一可行方案。
- 扩展点:新增默认字体 = 放入
src/lib/assets/fonts/(.ttf扩展名)+ 在DEFAULT_FONTS增加一项 +?url导入,版本指纹与缓存失效自动生效;新增内联包 = 放入src/lib/assets/typst-packages/并按命名规范命名,无需改代码;新增签发机构 Logo 走constants.ts的ISSUERS(公章/水印虚拟文件由初始化流程自动生成)。
相关链接
- src/lib/typst.svelte.ts — 本子系统主线程客户端(Worker 管理、字体清单、初始化流程、代理 API)
- vite.config.ts — 字体版本哈希、ESM worker 格式、
.tar.gz资产包含 - README.md — 项目结构(
typst.svelte.ts职责、assets/fonts/目录)与 Typst WASM 技术栈说明 - package.json —
@myriaddreamin/typst.ts/typst-ts-web-compiler/typst-ts-renderer依赖版本(0.8.0-rc3) - Worker 端与协议实现(
src/lib/typst-worker/worker.ts、src/lib/typst-worker/protocol.ts)与字体缓存存储(src/lib/stores/fonts.ts)属兄弟页面主题,本文未展开其源码。 - typst.ts — 上游 Typst WebAssembly 编译器项目