字体处理与子集压缩
Mizuki 构建工具链中的字体子系统:在 astro build 之后,基于站点实际渲染文本动态生成 CJK/ASCII 字符集,用 Fontmin 对本地字体做字形子集化并转码为 WOFF2,输出到 dist/assets/font;另含一个用于仓库内置 UI 字体无损 TTF→WOFF2 转换的维护脚本 prepare-fonts.mjs。
Purpose and Scope
本页覆盖 Mizuki 构建工具链中与"字体处理与子集压缩"相关的完整机制:
- 压缩主管线:scripts/compress-fonts/font-compressor.js 导出的
compressFonts(),即"读取配置 → 采集字符集 → Fontmin 子集压缩 → 输出统计/错误报告"的全流程。 - 配置解析层:scripts/compress-fonts/config-parser.js,用正则从
src/config/siteConfig.ts提取font.asciiFont/font.cjkFont、SITE_LANG、音乐播放器等构建期配置。 - 字符集采集层:scripts/compress-fonts/text-collector.js,从
src/data、src/config、i18n、内容目录、音乐常量与远程 Meting API 汇总出子集字符表。 - 仓库内置字体预处理:scripts/prepare-fonts.mjs,对
src/assets/fonts下的 UI 字体做无损 TTF→WOFF2 转换并断言等价性。
有意留给兄弟页面的内容(本页不展开):
- 运行时字体模式选择与加载行为(
src/utils/fontMode.ts)——属于前端运行时主题,详见字体加载相关页面。 - 字体加载校验脚本
scripts/check-font-loading.mjs与tests/font-loading.test.mjs的运行时断言细节——本页仅在测试小节提及其存在与定位。
Overview
CJK 字体动辄数 MB 到数十 MB,直接随站点分发会显著拖慢首屏。Mizuki 的解法是构建期子集化:在站点内容已构建完成后,扫描所有"会被渲染出来的文本",把这些字符汇总成一张字符表,再用 Fontmin 的 glyph 插件从完整字体中裁剪出仅包含这些字符的字形,最后转码为浏览器友好的 WOFF2。核心概念:
| 概念 | 含义 |
|---|---|
asciiFont / cjkFont | siteConfig.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
分层设计意图:配置解析、字符采集、压缩执行三者解耦。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 运行时依赖:
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() 等多个导出函数共享同一次磁盘读取——这是构建脚本中典型的"读一次、多处查询"优化。
字体槽位提取的核心逻辑:
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
三个值得注意的设计决策:
- 外层先锚定
font: { ... }块(以\n\t},作为右边界,匹配 siteConfig 中的缩进结构),再在内层按asciiFont/cjkFont逐槽位解析,避免同名键在其他配置段(如导航、主题)中被误匹配。 enableCompress默认为false——即配置缺失时选择"不压缩"这一保守路径,宁可字体大也不产出错误子集。- 返回值带
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 字符总量数万,但站点实际只用到其中一小部分,这正是子集化收益最大的地方。
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 采集覆盖五类本地来源 + 一类远程来源:
| 采集函数 | 扫描目标 | 依赖的配置信号 |
|---|---|---|
collectFromDataDir | src/data 递归 .ts/.js | 无(目录存在即扫) |
collectFromConfig | src/config 递归 .ts/.js(siteConfig、navBarConfig、profileConfig、musicConfig 等) | 无 |
collectFromI18n | src/i18n/languages/{SITE_LANG}.ts | getLang() |
collectFromContent | src/content 或外部 CONTENT_DIR 下的 .md/.mdx/.ts/.js | 环境变量 ENABLE_CONTENT_SYNC + CONTENT_DIR |
collectFromMusicConstants | src/components/widgets/music-player/constants.ts | 无 |
fetchMetingPlaylistText | Meting API 远程歌单曲目名/艺术家 | getMusicConfig()(mode === "meting" 时才发起) |
内容目录的采集逻辑体现了对"外部内容同步"工作流的支持:
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) 组合执行三分支调度:
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 / .woff2 | fs.copyFileSync 原样拷贝到 dist/assets/font/ | 已是 Web 优化格式,再压缩无收益且有损 |
.ttf / .otf | Fontmin glyph 子集化 + ttf2woff2 转码 | 唯一能显著减小体积的路径 |
| 其他 | 打印警告并跳过 | 避免未知格式进入产物 |
真正的压缩发生在 Fontmin 管道中。Fontmin 是基于流(Buffer)的插件式 API,fontmin.run() 是回调风格,因此这里用 new Promise 包裹以融入 async/await 流程:
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:端到端构建时序
时序中的关键顺序约束:
- 先检查
dist/再采集文本——若还没跑astro build,直接提示并返回,避免白做一次全站扫描。 collectText()只调用一次,结果复用于所有cjkFont文件;getAsciiCharset()同样只算一次。多个字体文件共享同一字符表,这是构建耗时上的重要节约。- 错误不立即中断循环:某个字体文件缺失只记录并
continue,其余文件继续处理;循环结束后统一汇总错误并以process.exit(1)退出,让 CI 能感知失败。
实现解析:prepare-fonts.mjs(内置字体无损转换)
与构建管线并列的维护脚本,目标不同:不裁剪任何字形,只把 src/assets/fonts/ 下两个 UI 字体转成 WOFF2 以减小仓库与前端加载体积:
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
它内置三重断言,把"无损"变成可验证的硬约束而非口头承诺:
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 摘要比对:
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.enableCompress | boolean | false(解析缺失时) | 是否对 asciiFont 槽位启用子集压缩 |
font.asciiFont.localFonts | string[] | [] | asciiFont 的源字体文件名(相对 public/assets/font/) |
font.cjkFont.enableCompress | boolean | false(解析缺失时) | 是否对 cjkFont 槽位启用子集压缩 |
font.cjkFont.localFonts | string[] | [] | cjkFont 的源字体文件名(相对 public/assets/font/) |
SITE_LANG(siteConfig.ts) | string | "zh_CN"(解析缺失时) | 决定 collectFromI18n 扫描哪个语言文件,间接影响 CJK 子集 |
ENABLE_CONTENT_SYNC | env string | 未设置 | 为 "true" 且设置了 CONTENT_DIR 时,内容改从外部目录采集 |
CONTENT_DIR | env 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 失败:
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 的等价性断言。对子集"是否缺字"的验证,实际依赖内容扫描的完备性而非测试断言——这是该子系统已知的质量边界。
Related Links
- 运行时字体模式与加载:src/utils/fontMode.ts(前端运行时行为,属于字体加载主题)
- 字体加载校验:scripts/check-font-loading.mjs、tests/font-loading.test.mjs
- 压缩管线入口:scripts/compress-fonts/font-compressor.js
- 配置解析:scripts/compress-fonts/config-parser.js
- 字符采集:scripts/compress-fonts/text-collector.js
- 内置字体预处理:scripts/prepare-fonts.mjs