〖共创稿事节〗HarmonyOS 7 新特性实战(67):生僻字要不要内置子集字体——cmap 逐字对账 4898 用字,抽样实测的真相是系统回退
# 〖共创稿事节〗HarmonyOS 7 新特性实战(67):生僻字要不要内置子集字体——cmap 逐字对账 4898 用字,抽样实测的真相是系统回退
> 本篇是系列第 67 篇,兑现第 62 篇结尾记下的已知欠账:「生僻字覆盖结论基于中山经抽样,更大范围字表抽样后再定是否内置子集字体」。结论先说:不用内置,但答案不是「零缺失」——是 94 个字依赖系统回退,而这件事此前没人知道。 写作当天这件事落成了套件第 54 项门禁,下面把探测、反转、登记、立门禁的完整过程写清楚。
一、欠账与字体链路盘点
第 62 篇写《中山经》合卷真机复验时,扩展 B 区字「𤝻」(U+2477B)在真机上正常出字形——不是豆腐块。当时结论基于抽样:一个字出字形,不能证明内置字体覆盖了整个扩展 B 区,也不能证明系统回退可靠,所以欠账挂起,等「更大范围字表抽样」。
这笔欠账真正的问句是:阅读正文里那些两万码位开外的字,到底是内置字体在显示,还是系统在兜底? 要回答它,先得把应用的字体链路盘点清楚。
应用注册了两个自定义字体,官方 Font 模块的 Font.registerFont 在启动时挂载,各管一段渲染:
| 注册 family | 字体文件 | 渲染对象 | 语料里的典型内容 |
|---|---|---|---|
| ShanhaiTextSerif | NotoSerifSC-Regular.ttf(14.8 MB) | 阅读正文、经文、夹注、异兽文案 | 白话译文 + 繁体原文(含扩展 B/C-F 区生僻字) |
| ShanhaiTitleSerif | ZCOOLXiaoWei-Regular.ttf(6.3 MB) | 异兽展示名、章节标题、卡片题 | 简体常用字为主,偶含生僻异兽名 |
分工看起来清晰:正文走思源宋体,标题走站酷小薇。但「看起来清晰」和「逐字可对账」是两回事——两个 ttf 各自覆盖多少码位、语料里有没有字落在覆盖之外,从未有人算过。这次写作顺手把账算了,结果第一个口径就翻车。
二、探测实做:手写 cmap 解析与口径翻车
探测要回答三个问题:语料里到底用了哪些字;两个字体的 cmap 各覆盖哪些码位;两者的差集是什么。
字符抽取:一个引号引发的翻车
语料不是散的——篇章层经文、异兽四层文案、学习卡都在应用侧的数据 ArkTS 文件里(common/data/ 下 13 个文件)。抽取器按 JavaScript 字符串语法扫文件、跳过注释、收集字符串字面量。第一版我只收了单引号和模板串,双引号做了「防御性跳过」:
function extractStrings(src) {
const out = [];
let i = 0;
const n = src.length;
while (i < n) {
const c = src[i];
if (c === '/' && src[i + 1] === '/') { while (i < n && src[i] !== '\n') i++; continue; }
if (c === '/' && src[i + 1] === '*') { i += 2; while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++; i += 2; continue; }
if (c === "'" || c === '"' || c === '`') { // 三种引号都必须收
let j = i + 1;
let s = '';
while (j < n && src[j] !== c) {
if (src[j] === '\\') { j++; s += src[j] === 'n' ? '\n' : src[j]; } else s += src[j];
j++;
}
out.push(s);
i = j + 1;
continue;
}
i++;
}
return out;
}
第一版跑出来「全语料 651 字、正文字体零缺失」——一个近乎完美的结论。但对着篇章层数据文件多看一眼就露馅了:经文原文全在双引号里(生成管线的代码风格),而篇章层恰是扩展 B 区字的唯一来源。三种引号一起收之后,语料从 651 字涨到 4898 字,「零缺失」变成了「缺 94 字」。这次翻车救了这篇文章:如果按第一版口径写「零缺失、无需任何动作」,欠账就算「收」在一个错误结论上。
cmap 解析:不引三方库,直接读 sfnt
TTF 的 cmap 表结构是公开的:表目录定位 cmap,编码记录里选 (platformID=3, encodingID=10) 的 UCS-4 子表优先,按 format 4(BMP 分段)或 format 12(分段覆盖)展开。判断「有无字形」不需要 glyph 轮廓,只需要映射结果非零:
} else if (fmt === 12) {
const nGroups = dv.getUint32(best + 12);
for (let gI = 0; gI < nGroups; gI++) {
const rec = best + 16 + gI * 12;
const start = dv.getUint32(rec);
const end = dv.getUint32(rec + 4);
const startGid = dv.getUint32(rec + 8);
for (let c = start; c <= end; c++) {
if (startGid + (c - start) !== 0) covered.add(c); // 组内映射到 glyph 0 视为无字形
}
}
}
两个字体合计三万多个码位、十几个分段,纯 DataView 展开,毫秒级跑完,零依赖。这也意味着同一段解析代码可以直接长在门禁里,不用给工程添任何包。

三、结果反转:𤝻 出字形靠的是系统回退
对账结果先看分布:
| 区块 | 语料字数 | 正文 NotoSerifSC | 标题 ZCOOLXiaoWei |
|---|---|---|---|
| 基本区(URO,含繁体) | 4706 | 0 缺失 | 缺 1547(繁体经文用字为主) |
| 扩展 A 区 | 98 | 0 缺失 | 缺 98 |
| 扩展 B 区 | 90 | 缺 90 | 缺 90 |
| 扩展 C-F 区 | 4 | 缺 4 | 缺 4 |
| 合计 | 4898 | 缺 94 | 缺 1739 |
反转在这里:正文内置的 NotoSerifSC 并不覆盖扩展 B/C-F 区,𤝻 就在那 94 个字里。 第 62 篇真机实测「𤝻 出字形」是真的,但归因错了——出字形的不是内置字体,是 HarmonyOS 渲染栈的缺字回退:内置字体查无此字,渲染落到系统字体栈继续找,系统 CJK 字体接住了它。
同一个实测事实,两种解读,差之毫厘:
- 抽样解读(当时):系统有字形,内置字体覆盖待抽样确认 → 欠账挂起;
- 全量对账(本篇):内置字体本体不含 𤝻,出字形 = 系统回退兜底 → 不需要内置子集字体,需要把 94 字登记为回退依赖并设门禁。

「回退可靠」不只有一次实测:B6 真机复验的整页在屏截图里,𤝻 同时出现在经文原文与译文中,字形完整;篇章层上线以来也没有收到过豆腐块反馈。运行时证据与静态对账在这里咬合上了。

顺带一提第 62 篇记过的取证坑在这里又露了一手:非 BMP 字符在设备端 grep 里匹配不到,当时的绕行是换同段等效特征串检索。静态对账恰好补上了这个盲区——cmap 层面逐字核对,不依赖设备端字符串工具对扩展区的支持。
四、标题字体的设计边界与展示名钉点
标题字体的 1739 个缺失是另一回事。站酷小薇是简体常用字集,繁体经文用字不在它的设计范围内——而这些繁体字全在正文语料字段里,不经过标题字体渲染,所以是设计边界而非缺陷,不需要逐字登记。
真正要盯的是异兽展示名:图鉴卡标题用 Text(beast.name) 走标题字体,而「猼訑」「讙」三个字恰好不在站酷小薇的 cmap 里(「狌」实测在,无需钉)。这三个字的展示名一直显示正常,靠的同样是系统回退,与正文 94 字同源。给它们立了三重断言的钉点:
| 展示名用字 | 码点 | 标题字体 | 正文字体 | 断言口径 |
|---|---|---|---|---|
| 猼 | U+733C | 缺失(依赖回退) | 覆盖 | 在语料 + 标题缺 + 正文覆盖,三条件同时锁 |
| 訑 | U+8A11 | 缺失(依赖回退) | 覆盖 | 同上 |
| 讙 | U+8B99 | 缺失(依赖回退) | 覆盖 | 同上 |
三条件为什么缺一不可:展示名换了字体,「标题缺」失效;语料改名,「在语料」失效;某天有人把正文字体裁成子集,「正文覆盖」失效——任何一条断言变红,都说明展示名的回退依赖被动过了。
五、门禁落地:verify-glyph-coverage.mjs
以上全部进了 scripts/verify-glyph-coverage.mjs,套件第 54 项。三层断言:
第一层:回退依赖清单双向对账。 94 字逐字登记(字 + 码点),正文缺失与清单双向对账——新增缺失字未登记直接报红(新字必须先真机验证字形再入清单),清单里的字从语料消失也报红:
const textMiss = [...corpus.keys()].filter((ch) => !cmaps.ShanhaiTextSerif.has(ch.codePointAt(0)));
const textPinned = new Set(TEXT_FALLBACK_PINS.map((p) => p.ch));
const textUnreg = textMiss.filter((ch) => !textPinned.has(ch));
const textStale = TEXT_FALLBACK_PINS.filter((p) => !corpus.has(p.ch)).map((p) => p.ch);
report(textUnreg.length === 0, `正文缺失 ${textMiss.length} 字全部已登记(未登记 ${textUnreg.length})`);
report(textStale.length === 0, `回退依赖钉点无过期(过期 ${textStale.length})`);
report(textMiss.length === textPinned.size, `缺失数 ${textMiss.length} == 钉点数 ${textPinned.size}`);
第二层:字体漂移对账。 两个 ttf 的 cmap 码点数钉住(30928 / 7016)。字体文件替换是静默操作——构建绿、门禁绿、渲染默默变脸,码点数是覆盖面唯一的指纹:
for (const f of FONTS) {
cmaps[f.family] = parseCmap(fs.readFileSync(path.join(ROOT, f.file)));
report(cmaps[f.family].size === f.pinnedCmapPoints,
`${f.family} cmap 码点 ${cmaps[f.family].size} == 钉点 ${f.pinnedCmapPoints}`);
}
第三层:展示名三重断言。 前表的三个字,每字三条件同时校验。
反向验证照惯例做了两组,改临时副本不动源文件:
| 反向操作 | 期望 | 实测 |
|---|---|---|
| 删掉 𣗳(U+235F3)的钉点 | 未登记缺失 + 数量对账双双报红 | 2 FAIL,吻合 |
| 把猼的码点篡改成标题字体覆盖的 U+732C | 「标题字体缺失」断言转红 | 1 FAIL,吻合 |

全量复跑 54/54 PASS,HAP 构建同日通过(BUILD SUCCESSFUL in 40 s,33 tasks:25 executed / 8 up-to-date):
| 复跑项 | 结果 |
|---|---|
| verify-glyph-coverage.mjs(第 54 项,8 断言) | 首跑 ALL PASS |
| 全量门禁套件 | 54/54 PASS |
| HAP 构建 | BUILD SUCCESSFUL in 40 s(25 executed / 8 up-to-date) |
| 反向验证 | 2 组报红均成立 |
收个尾
这笔欠账从挂起到收口,中间隔了二十五天、四篇稿。回头看,62 篇留下的问题句式是「要不要内置子集字体」,但全量对账把问题换掉了:真正要回答的是「生僻字出字形的责任在谁」。责任查清是系统回退之后,动作也跟着换——不是去裁一版子集字体,而是把回退依赖登记下来、用门禁守住「新增缺失必须先验证再入清单」和「字体文件不许静默替换」两条线。
抽样给结论,全量给责任。下一篇见。
证据清单
- 门禁脚本:
app-harmony/scripts/verify-glyph-coverage.mjs(套件第 54 项,8 断言) - 语料对账范围:
app-harmony/entry/src/main/ets/common/data/13 个数据文件,用字去重 4898 - 全量复跑:54/54 PASS(2026-11-10);HAP 构建 BUILD SUCCESSFUL in 40 s
- 真机实证截图:
docs/development/acceptance/2026-10-16-shanhaijing-zhongshan-b6/(05 整页在屏 / 12 放大特写,B6 复验 9/9 HIT 留证) - 欠账出处:第 62 篇《山海经》篇章层稿「已知欠账」与第十六轮任务板「仍欠」条目
- 系列前置:第 62 篇(篇章层怎么验)· 第 65 篇(观感三刀与门禁套件 52 项)· 第 66 篇(术语钉点门禁,第 53 项)
更多推荐



所有评论(0)