Repository Wiki
Naptie/endfield-docmaker

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)的下载与解码同样昂贵。

因此该子系统采用如下设计:

  1. 重活下沉 Worker:编译、渲染、PDF 导出全部在 src/lib/typst-worker/worker.ts 中执行;主线程只保留一个 Promise 化的 RPC 代理(typstProxy),其方法签名与旧的直接集成 $typst 默认导出保持一致,使调用方改动最小。
  2. 字体在主线程加载:字体下载走主线程的 fetch + IndexedDB 缓存(stores/fonts),因为进度条 UI 需要 downloadProgress 响应式状态实时更新;下载完成后通过 postMessage 的 transfer 列表把 ArrayBuffer 零拷贝移交给 Worker。
  3. DOM 依赖留在主线程:Logo 的 SVG 重定位与 PNG 着色依赖 Image/Canvas 等 DOM API,无法在 Worker 中执行,因此在发送 init 前于主线程完成,产物同样以虚拟文件路径(/stamp-*.svg、/watermark-*.png 等)映射后转移给 Worker。
  4. 构建期字体指纹: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)

Loading diagram...

架构解读:

  • 分层边界清晰:构建期(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 编译器/渲染器的正常工作。

ts
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 包

ts
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 生命周期与单例管理

ts
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 分发为两类:

推送类(写响应式状态,无对应请求)

ts
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)

ts
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 请求封装

ts
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:可转移性的保证

ts
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)

Loading diagram...

逐步解读(对应源码 initializeTypst,第 205–312 行):

  1. 幂等门闩:initializationPromise 已存在则直接返回同一 Promise(并发调用共享一次初始化);isInitialized 为真则短路;dev 模式优先复用 globalThis.__typstWorkerInit。
  2. 字体下载与缓存:状态置 loading_fonts、进度归零后调用 loadFontsWithCache(DEFAULT_FONTS, __FONTS_VERSION__, cb)。回调同步写 downloadProgress,令进度条 0→1 且展示当前活跃文件名;完成后立即复位,避免残留。__FONTS_VERSION__ 是 Vite 注入的字体目录哈希——字体文件内容一变,缓存键随之改变,旧缓存自然失效,无需手动清库。
  3. 合并自定义字体:getAllFonts() 取回 IndexedDB 中全部字体记录,filter(f => f.custom) 得到用户上传的字体,与默认字体拼接为 fontData(全部先经 detachBuffer 复制)。用户上传字体因此对每次编译天然可用,无需单独的注册 API。
  4. Logo 预处理(必须在主线程):对 ISSUERS 中每个签发机构,按 svg/位图两类分别生成"公章"(红色重定位版)与"水印"(黑色 25% 透明度版)两份虚拟文件,路径形如 /stamp-<key>.svg、/watermark-<key>.png;Promise.all 并行处理;setLogoScales 把每个 Logo 的缩放比例写回全局常量表供模板换算尺寸。
  5. 一次性 init 转移:transfer = [...fontData, ...logoMappings.map(m => m.data)],单条 postMessage 携带全部二进制并零拷贝转移。随后用 initResolve/initReject 把 Worker 的 initDone/initError 回调桥接为 Promise。
  6. 完成:initDone 置 isInitialized = true;此后 waitForTypst() 立即返回,typstProxy 各方法可正常调用。

使用示例

示例 1:组件侧初始化与文档编译

ts
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:把二进制资源映射进编译器虚拟文件系统

ts
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 配置)

ts
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
assetsIncludeVite 配置['**/*.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,不会 reject pending 中未完成的请求。调用方需意识到: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 让用户能看到"卡在哪个文件",而不是无反馈长等待。
  • .otf 404 陷阱:新增字体资产必须以 .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 编译器项目

Sources

(1 files)