Repository Wiki
LyraVoid/Mizuki

字体处理与子集压缩

Mizuki 构建工具链中的字体子系统:在 astro build 之后,基于站点实际渲染文本动态生成 CJK/ASCII 字符集,用 Fontmin 对本地字体做字形子集化并转码为 WOFF2,输出到 dist/assets/font;另含一个用于仓库内置 UI 字体无损 TTF→WOFF2 转换的维护脚本 prepare-fonts.mjs。

Purpose and Scope

本页覆盖 Mizuki 构建工具链中与"字体处理与子集压缩"相关的完整机制:

有意留给兄弟页面的内容(本页不展开):

  • 运行时字体模式选择与加载行为(src/utils/fontMode.ts)——属于前端运行时主题,详见字体加载相关页面。
  • 字体加载校验脚本 scripts/check-font-loading.mjs 与 tests/font-loading.test.mjs 的运行时断言细节——本页仅在测试小节提及其存在与定位。

Overview

CJK 字体动辄数 MB 到数十 MB,直接随站点分发会显著拖慢首屏。Mizuki 的解法是构建期子集化:在站点内容已构建完成后,扫描所有"会被渲染出来的文本",把这些字符汇总成一张字符表,再用 Fontmin 的 glyph 插件从完整字体中裁剪出仅包含这些字符的字形,最后转码为浏览器友好的 WOFF2。核心概念:

概念含义
asciiFont / cjkFontsiteConfig.ts 中 font 配置的两类字体槽位;asciiFont 使用固定 ASCII 字符集,cjkFont 使用动态采集的 CJK 字符集
enableCompress每类字体的压缩开关;为 false 或 localFonts 为空时该字体不进入压缩列表
localFonts相对于 public/assets/font/ 的字体文件名数组,支持 .ttf/.otf(可压缩)与 .woff/.woff2(直接拷贝)
collectText()异步采集站点全部 CJK 字符,产出 cjkFont 的子集字符表
getAsciiCharset()生成 32–126 号码位 + 常见符号/数字/字母的固定字符集,供 asciiFont 使用
prepare-fonts.mjs与构建无关的维护脚本:把仓库自带的 UI 字体(ZenMaruGothic、loli)转为 WOFF2 并校验无损

两条管线职责边界清晰:compressFonts() 是有损子集化(丢弃未使用字形),prepare-fonts.mjs 是无损格式转换(仅重新编码,逐字形校验等价)。

Architecture

Loading diagram...

分层设计意图:配置解析、字符采集、压缩执行三者解耦。config-parser.js 只负责"该压哪些字体"和"该扫哪些语言/功能";text-collector.js 只负责"站点到底用到了哪些字符";font-compressor.js 把两者组合并处理 IO 与错误报告。这样新增文本来源(如新的数据目录或远程 API)不需要触碰压缩逻辑,新增字体槽位也只需改配置解析的正则与 siteConfig.ts。

另一条独立支线是 prepare-fonts.mjs:它不走上述任何一层,直接读取 src/assets/fonts/ 下的 TTF,用 fonteditor-core 的 ttftowoff2 做纯格式转换,并以 SHA-256 字形摘要断言转换前后字形数据一致——因为这两个字体是主题 UI 自用(随源码分发),必须无损。

实现解析:配置解析层

config-parser.js 是纯 Node 脚本,但解析对象是 TypeScript 配置文件,因此采用了正则文本解析而非 TS 编译加载——避免在 Node 脚本中引入 TS 运行时依赖:

javascript
1/** 2 * 一次性读取 siteConfig.ts,缓存原始内容 3 * 所有配置解析共享同一次文件读取 4 */ 5let _cachedContent = null; 6 7function readSiteConfig() { 8 if (_cachedContent) return _cachedContent; 9 const configPath = path.join(ROOT_DIR, "src/config/siteConfig.ts"); 10 _cachedContent = fs.readFileSync(configPath, "utf-8"); 11 12 return _cachedContent; 13}

Source: config-parser.js

模块级 _cachedContent 缓存保证 getLang()、getFontConfigs()、isAnimePageEnabled() 等多个导出函数共享同一次磁盘读取——这是构建脚本中典型的"读一次、多处查询"优化。

字体槽位提取的核心逻辑:

javascript
1export function getFontConfigs() { 2 const content = readSiteConfig(); 3 4 const fontConfigMatch = content.match(/font:\s*\{([\s\S]*?)\n\t\},/); 5 if (!fontConfigMatch) { 6 console.log("⚠ Font config not found, using default settings"); 7 return []; 8 } 9 10 const fontConfigStr = fontConfigMatch[1]; 11 const fonts = []; 12 const fontTypes = ["asciiFont", "cjkFont"]; 13 14 for (const fontType of fontTypes) { 15 const regex = new RegExp(`${fontType}:\\s*\\{([\\s\\S]*?)\\}`, "m"); 16 const match = fontConfigStr.match(regex); 17 if (!match) continue; 18 19 const config = match[1]; 20 21 const compressMatch = config.match(/enableCompress:\s*(true|false)/); 22 const enableCompress = compressMatch ? compressMatch[1] === "true" : false; 23 24 const localFontsMatch = config.match(/localFonts:\s*\[(.*?)\]/s); 25 let localFonts = []; 26 if (localFontsMatch?.[1].trim()) { 27 localFonts = 28 localFontsMatch[1] 29 .match(/["']([^"']+)["']/g) 30 ?.map((s) => s.replace(/["']/g, "")) || []; 31 } 32 33 if (enableCompress && localFonts.length > 0) { 34 fonts.push({ type: fontType, files: localFonts, enableCompress }); 35 } 36 } 37 38 return fonts; 39}

Source: config-parser.js

三个值得注意的设计决策:

  1. 外层先锚定 font: { ... } 块(以 \n\t}, 作为右边界,匹配 siteConfig 中的缩进结构),再在内层按 asciiFont/cjkFont 逐槽位解析,避免同名键在其他配置段(如导航、主题)中被误匹配。
  2. enableCompress 默认为 false——即配置缺失时选择"不压缩"这一保守路径,宁可字体大也不产出错误子集。
  3. 返回值带 type 字段("asciiFont" 或 "cjkFont"),下游据此决定使用 ASCII 还是 CJK 字符集,这是压缩器选择子集文本的唯一开关。

该文件还导出 getLang()(默认 "zh_CN")、isAnimePageEnabled()、getAnimeMode()、getBangumiUserId()、getMusicConfig() 等函数,它们并非字体功能本身,而是为字符采集层决定"要扫描哪些内容源"提供开关信号。

实现解析:字符集采集层

text-collector.js 解决"子集该保留哪些字符"。它为两类字体产出两种字符表:

  • getAsciiCharset():确定性字符集——码位 32–126 全量(含空格、标点、数字、大小写字母),供 asciiFont 使用。因为英文内容无法可靠枚举(用户名、代码、URL 等),干脆保留完整 ASCII 区段。
  • collectText():异步采集站点全部 CJK 字符,供 cjkFont 使用。CJK 字符总量数万,但站点实际只用到其中一小部分,这正是子集化收益最大的地方。
javascript
1export function getAsciiCharset() { 2 const chars = new Set(); 3 for (let i = 32; i <= 126; i++) { 4 chars.add(String.fromCharCode(i)); 5 } 6 const common = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"; 7 for (const char of common) chars.add(char); 8 for (let i = 0; i <= 9; i++) chars.add(String(i)); 9 const alphabet = 10 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; 11 for (const char of alphabet) chars.add(char); 12 return Array.from(chars).sort().join(""); 13}

Source: text-collector.js

用 Set 去重后 sort().join("") 产出稳定排序的字符串,保证相同站点内容每次构建得到相同的字符表——这是子集可复现性的基础。

CJK 采集覆盖五类本地来源 + 一类远程来源:

采集函数扫描目标依赖的配置信号
collectFromDataDirsrc/data 递归 .ts/.js无(目录存在即扫)
collectFromConfigsrc/config 递归 .ts/.js(siteConfig、navBarConfig、profileConfig、musicConfig 等)无
collectFromI18nsrc/i18n/languages/{SITE_LANG}.tsgetLang()
collectFromContentsrc/content 或外部 CONTENT_DIR 下的 .md/.mdx/.ts/.js环境变量 ENABLE_CONTENT_SYNC + CONTENT_DIR
collectFromMusicConstantssrc/components/widgets/music-player/constants.ts无
fetchMetingPlaylistTextMeting API 远程歌单曲目名/艺术家getMusicConfig()(mode === "meting" 时才发起)

内容目录的采集逻辑体现了对"外部内容同步"工作流的支持:

javascript
1function collectFromContent(textSet) { 2 let contentDir; 3 if ( 4 process.env.ENABLE_CONTENT_SYNC === "true" && 5 process.env.CONTENT_DIR 6 ) { 7 contentDir = path.join(ROOT_DIR, process.env.CONTENT_DIR); 8 console.log( 9 `ℹ Using external content directory: ${process.env.CONTENT_DIR}`, 10 ); 11 } else { 12 contentDir = path.join(ROOT_DIR, "src/content"); 13 } 14 15 if (!fs.existsSync(contentDir)) { 16 console.log(`⚠ Content directory does not exist: ${contentDir}`); 17 return; 18 } 19 20 const files = readFilesRecursively(contentDir); 21 for (const file of files) { 22 const ext = path.extname(file); 23 if ([".md", ".mdx", ".ts", ".js"].includes(ext)) { 24 const content = fs.readFileSync(file, "utf-8"); 25 const text = extractMarkdownText(content, ext); 26 for (const char of text) { 27 if (CJK_REGEX.test(char)) { 28 textSet.add(char); 29 } 30 } 31 } 32 } 33}

Source: text-collector.js

Markdown 文件先经 extractMarkdownText(位于 utils.js)剥离语法噪声,再逐字符用 CJK_REGEX 过滤——只收 CJK 字符意味着 Markdown 标记、URL、代码块里的英文不会膨胀 cjkFont 子集。

采集完成后还有一个兜底步骤 addCommonChars,注入全角标点(,。!?;:""''()【】《》、·—…「」『』)、字母表和示例词("示例", "歌曲", "艺术家")。设计意图:即便某数据源为空或解析遗漏,页面上最常见的中文标点与占位词仍能正常渲染,避免"缺字方块"。

远程 Meting 采集带有完整的跳过与容错语义——音乐播放器禁用或模式不是 meting 时直接返回空集合并打印提示,只有真正启用时才发起网络请求,失败也不会让构建崩溃。

实现解析:压缩执行层

font-compressor.js 的 compressFonts() 是整个子系统的编排入口。它对每个 (fontConfig, fontFile) 组合执行三分支调度:

javascript
1const text = 2 fontConfig.type === "asciiFont" ? asciiText : cjkText; 3 4for (const fontFile of fontConfig.files) { 5 const fontSrc = path.join( 6 ROOT_DIR, 7 "public/assets/font", 8 fontFile, 9 ); 10 const ext = path.extname(fontFile).toLowerCase(); 11 const baseName = path.basename(fontFile, ext); 12 ... 13 if (ext === ".woff2" || ext === ".woff") { 14 console.log( 15 `⚠ Skipping ${fontFile} (already web-optimized format)`, 16 ); 17 fs.copyFileSync(fontSrc, path.join(distFontDir, fontFile)); 18 totalCompressedSize += originalSize; 19 } else if (ext === ".ttf" || ext === ".otf") { 20 console.log(`Compressing ${fontFile}...`); 21 ... 22 } else { 23 console.log( 24 `⚠ Unsupported font format, skipping: ${fontFile}`, 25 ); 26 } 27}

Source: font-compressor.js

分支调度规则:

扩展名处理方式原因
.woff / .woff2fs.copyFileSync 原样拷贝到 dist/assets/font/已是 Web 优化格式,再压缩无收益且有损
.ttf / .otfFontmin glyph 子集化 + ttf2woff2 转码唯一能显著减小体积的路径
其他打印警告并跳过避免未知格式进入产物

真正的压缩发生在 Fontmin 管道中。Fontmin 是基于流(Buffer)的插件式 API,fontmin.run() 是回调风格,因此这里用 new Promise 包裹以融入 async/await 流程:

javascript
1const fontmin = new Fontmin() 2 .src(fontSrc) 3 .use( 4 Fontmin.glyph({ 5 text, 6 hinting: false, 7 }), 8 ) 9 .use( 10 Fontmin.ttf2woff2({ 11 clone: false, 12 deflate: true, 13 }), 14 ) 15 .dest(distFontDir); 16 17await new Promise((resolve, reject) => { 18 fontmin.run((err, files) => { 19 if (err) reject(err); 20 else resolve(files); 21 }); 22});

Source: font-compressor.js

两个插件的参数各有取舍:

  • Fontmin.glyph({ text, hinting: false }):按 text 字符表裁剪字形;hinting: false 丢弃 hinting 指令。现代浏览器渲染引擎(FreeType/DirectWrite/CoreText)已普遍自动 hint,去掉它能在 Web 场景换来更小体积——这是"体积优先"的明确取舍。
  • Fontmin.ttf2woff2({ clone: false, deflate: true }):clone: false 表示直接覆写流中的文件而非派生副本,保证 .dest() 写出的是压缩后的 baseName.woff2;deflate: true 对 WOFF2 内部表启用 deflate 压缩。

Core Flow:端到端构建时序

Loading diagram...

时序中的关键顺序约束:

  1. 先检查 dist/ 再采集文本——若还没跑 astro build,直接提示并返回,避免白做一次全站扫描。
  2. collectText() 只调用一次,结果复用于所有 cjkFont 文件;getAsciiCharset() 同样只算一次。多个字体文件共享同一字符表,这是构建耗时上的重要节约。
  3. 错误不立即中断循环:某个字体文件缺失只记录并 continue,其余文件继续处理;循环结束后统一汇总错误并以 process.exit(1) 退出,让 CI 能感知失败。

实现解析:prepare-fonts.mjs(内置字体无损转换)

与构建管线并列的维护脚本,目标不同:不裁剪任何字形,只把 src/assets/fonts/ 下两个 UI 字体转成 WOFF2 以减小仓库与前端加载体积:

javascript
1import { createHash } from "node:crypto"; 2import { readFile, writeFile } from "node:fs/promises"; 3import { dirname, resolve } from "node:path"; 4import { fileURLToPath } from "node:url"; 5import { Font, ttftowoff2, woff2 } from "fonteditor-core"; 6 7const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); 8const fonts = [ 9 ["ZenMaruGothic-Medium.ttf", "ZenMaruGothic-Medium.woff2"], 10 ["loli.ttf", "loli.woff2"], 11];

Source: prepare-fonts.mjs

它内置三重断言,把"无损"变成可验证的硬约束而非口头承诺:

javascript
1if (output.subarray(0, 4).toString("ascii") !== "wOF2") { 2 throw new Error(`${targetName} does not have a valid WOFF2 signature`); 3} 4if (output.length >= source.length) { 5 throw new Error(`${targetName} is not smaller than its TTF source`); 6} 7 8assertEquivalentFont(source, output, targetName);

Source: prepare-fonts.mjs

assertEquivalentFont 用 fonteditor-core 把源 TTF 与产物 WOFF2 分别解析回字体对象,比对字形数量、unitsPerEm、name 表,并对每个 glyph 做 SHA-256 摘要比对:

javascript
1function glyphDigest(font) { 2 const hash = createHash("sha256"); 3 for (const glyph of font.glyf) hash.update(JSON.stringify(glyph)); 4 return hash.digest("hex"); 5} 6 7function assertEquivalentFont(source, output, targetName) { 8 const readOptions = { hinting: true, kerning: true }; 9 const original = Font.create(source, { ...readOptions, type: "ttf" }).get(); 10 const compressed = Font.create(output, { 11 ...readOptions, 12 type: "woff2", 13 }).get(); 14 15 if ( 16 original.glyf.length !== compressed.glyf.length || 17 original.head.unitsPerEm !== compressed.head.unitsPerEm || 18 JSON.stringify(original.name) !== JSON.stringify(compressed.name) || 19 glyphDigest(original) !== glyphDigest(compressed) 20 ) { 21 throw new Error(`${targetName} changed font names, metrics, or glyph data`); 22 } 23}

Source: prepare-fonts.mjs

细节注意 exactArrayBuffer:ttftowoff2 需要 ArrayBuffer 而非 Node Buffer 视图,因此先按 byteOffset 切片取出精确区间再传入;产物再用 Buffer.from(...) 转回。await woff2.init() 在循环前初始化 WOFF2 编解码的 WASM 运行时。

Configuration Options

字体子系统的构建期配置集中在 src/config/siteConfig.ts 的 font 段(由 config-parser.js 以正则方式读取),另有少量环境变量影响内容扫描范围:

配置项类型默认值说明
font.asciiFont.enableCompressbooleanfalse(解析缺失时)是否对 asciiFont 槽位启用子集压缩
font.asciiFont.localFontsstring[][]asciiFont 的源字体文件名(相对 public/assets/font/)
font.cjkFont.enableCompressbooleanfalse(解析缺失时)是否对 cjkFont 槽位启用子集压缩
font.cjkFont.localFontsstring[][]cjkFont 的源字体文件名(相对 public/assets/font/)
SITE_LANG(siteConfig.ts)string"zh_CN"(解析缺失时)决定 collectFromI18n 扫描哪个语言文件,间接影响 CJK 子集
ENABLE_CONTENT_SYNCenv string未设置为 "true" 且设置了 CONTENT_DIR 时,内容改从外部目录采集
CONTENT_DIRenv string未设置外部内容目录(相对仓库根),用于内容同步工作流下的字符采集
音乐播放器 mode(musicConfig.ts)string—值为 "meting" 时,fetchMetingPlaylistText 才会请求远程歌单文本

目录约定(隐式配置):

路径角色
public/assets/font/源字体输入目录(TTF/OTF/WOFF/WOFF2)
dist/assets/font/压缩产物输出目录(WOFF2 子集)
src/assets/fonts/prepare-fonts.mjs 处理的内置 UI 字体目录

API Reference

compressFonts(): Promise<void> — font-compressor.js

压缩管线唯一入口。读取字体配置与字符集,遍历所有 (字体槽位, 字体文件) 组合执行压缩/拷贝,输出统计报告;配置错误时以 process.exit(1) 终止进程。

参数: 无(全部输入来自 siteConfig.ts、文件系统与环境变量)

返回: Promise<void>;失败路径通过 process.exit(1) 而非异常上抛

副作用: 在 dist/assets/font/ 写出 WOFF2 产物;向 stdout 打印进度与错误详情

getFontConfigs(): Array<{ type, files, enableCompress }> — config-parser.js

解析 siteConfig.ts 中 font 配置段,仅返回 enableCompress === true 且 localFonts 非空的字体槽位。

返回元素字段:

  • type ("asciiFont" | "cjkFont"):下游据此选择 ASCII 或 CJK 字符表
  • files (string[]):源字体文件名列表(相对 public/assets/font/)
  • enableCompress (boolean):恒为 true(已过滤)

collectText(): Promise<string> — text-collector.js

汇总站点全部会被渲染的 CJK 字符(本地数据/配置/i18n/内容目录/音乐常量 + Meting 远程数据 + 常用字符兜底)。

返回: 去重排序后的 CJK 字符表字符串,作为 cjkFont 的子集输入

getAsciiCharset(): string — text-collector.js

生成码位 32–126 的完整可打印 ASCII 字符集(含标点、数字、大小写字母)。

返回: 排序后的 ASCII 字符表字符串,作为 asciiFont 的子集输入

getLang(): string — config-parser.js

从 siteConfig.ts 提取 SITE_LANG,未匹配时返回默认值 "zh_CN"。影响 i18n 文件扫描路径。

Failure Modes, Edge Cases & Concurrency

字体文件缺失(配置与磁盘不一致):这是被专门设计的失败路径。压缩器不简单抛异常,而是生成一段带排查步骤的错误信息——指出配置里的文件名、期望路径,并列出三条检查建议(文件名大小写、目录位置、siteConfig.ts 对应配置项);循环结束后还会列出 public/assets/font/ 下实际存在的字体文件帮助定位,最终 process.exit(1) 让 CI 失败:

javascript
1if (!fs.existsSync(fontSrc)) { 2 const errorMsg = `❌ Config error [${fontConfig.type}]: Font file does not exist\n In config: "${fontFile}"\n Expected path: public/assets/font/${fontFile}\n\n Please check:\n 1. Is the filename correct (case sensitive)?\n 2. Is the file in public/assets/font/?\n 3. Is ${fontConfig.type}.localFonts in src/config/siteConfig.ts correct?`; 3 errors.push(errorMsg); 4 console.log(`\n${errorMsg}\n`); 5 continue; 6}

Source: font-compressor.js

dist/ 不存在:说明 astro build 尚未执行。压缩器不会提前压缩(此时还没有确定最终文本内容的意义),而是提示 ⚠ dist directory does not exist, please run astro build first 后直接返回。

dist/assets/font/ 不存在:与上一条不同,这是可自动修复的情况,fs.mkdirSync(distFontDir, { recursive: true }) 直接创建。

已优化格式直接透传:.woff/.woff2 输入不参与子集化,copyFileSync 原样拷贝。这保证用户提供的 Web 字体不被二次有损处理,同时保证产物目录中文件名完整。

不支持扩展名:既非 Web 字体也非 TTF/OTF 的文件打印 ⚠ Unsupported font format, skipping 后跳过,不影响其他文件。

Fontmin 执行失败:fontmin.run 回调错误会 reject 包裹它的 Promise,被外层 try/catch 捕获,打印 ❌ Font compression failed: 后 process.exit(1)。

远程采集失败:fetchMetingPlaylistText 对音乐播放器禁用/非 meting 模式返回空集;网络失败也不会中断构建(容错语义见上文)。

并发性:整个压缩循环是串行 for 循环,无并行度。Fontmin 本身是同步 CPU 密集操作,串行执行虽然慢但避免了多进程争抢内存(字形子集化时需在内存中持有完整字体表)。对单站点少量字体的场景这是务实取舍;若未来字体数量增长,files 维度是明显的并行化切入点。

正则解析的边界:getFontConfigs 依赖 font: { 块以 \n\t}, 收尾、槽位内 localFonts: [...] 单行书写等格式假设。这是用正则解析 TS 源文件的固有脆弱性——重排缩进或拆行书写可能使解析失败,但解析失败会走"返回空数组"的安全路径而非报错,代价是静默不压缩。这也是为什么文档在实现解析一节强调该设计"避免引入 TS 运行时依赖"的取舍。

Professional Notes

性能特征

  • 单次扫描复用:collectText() 与 getAsciiCharset() 在 compressFonts() 内各只调用一次,N 个字体文件共享同一字符表,避免 N 次全站递归扫描。
  • 配置单次读取:config-parser.js 的模块级缓存保证多个 getter 只读一次磁盘。
  • 体积收益:CJK 字体从全量(数 MB–数十 MB)裁剪到站点实际字符集(通常数千字),再叠加 WOFF2 压缩;控制台输出每个文件的 (KB, reduced %) 与总体 Overall reduction: x%,使收益可观测。
  • hinting: false:丢弃 hinting 在 Windows GDI 等传统渲染栈下可能轻微影响小字号清晰度,但在现代浏览器自动 hint 下是净收益;这是体积优先的明确决策。

运维要点

  • 压缩必须在 astro build 之后运行(依赖 dist/ 存在;同时字符采集的正确性以最终内容为准)。
  • CI 中若看到 process.exit(1),应检查错误块中列出的三处排查点,并对照脚本列出的 public/assets/font/ 实际文件清单。
  • 新增内容来源时记得确认是否需要加入 text-collector.js:任何绕过 src/data、src/config、i18n、内容目录、音乐常量的动态文本(例如新的远程 API)都不会自动进入 CJK 子集,渲染时会缺字。

扩展点

  • 新增字体槽位:在 siteConfig.ts 的 font 段加新键,并在 config-parser.js 的 fontTypes 数组中登记,即可获得相同压缩语义。
  • 新增文本来源:向 text-collector.js 增加一个 collectFromXxx(textSet) 函数并在 collectText() 中调用;ASCII 槽位因保留全量 ASCII 通常无需扩展。
  • 更换压缩引擎:压缩逻辑集中在 font-compressor.js 的 Fontmin 管道一处,替换为其他子集化工具(如 subset-font、harfbuzzjs)不影响上游两层。
  • 内置字体更新:修改 prepare-fonts.mjs 顶部的 fonts 数组即可加入新的 UI 字体;脚本自带的三重断言(WOFF2 签名、体积变小、字形等价)会自动校验新条目。

测试

仓库中与字体相关的测试是 tests/font-loading.test.mjs 与配套的 scripts/check-font-loading.mjs,它们关注运行时字体加载行为(配合 src/utils/fontMode.ts 的模式选择),而非构建期压缩管线的正确性。截至当前源码,scripts/compress-fonts/ 管线本身没有专门的单元测试;其正确性保障主要来自:compressFonts() 的显式错误汇总与退出码、prepare-fonts.mjs 的等价性断言。对子集"是否缺字"的验证,实际依赖内容扫描的完备性而非测试断言——这是该子系统已知的质量边界。