# 〖共创稿事节〗HarmonyOS 7 新特性实战(67):生僻字要不要内置子集字体——cmap 逐字对账 4898 用字,抽样实测的真相是系统回退

> 本篇是系列第 67 篇,兑现第 62 篇结尾记下的已知欠账:「生僻字覆盖结论基于中山经抽样,更大范围字表抽样后再定是否内置子集字体」。结论先说:不用内置,但答案不是「零缺失」——是 94 个字依赖系统回退,而这件事此前没人知道。 写作当天这件事落成了套件第 54 项门禁,下面把探测、反转、登记、立门禁的完整过程写清楚。

一、欠账与字体链路盘点

第 62 篇写《中山经》合卷真机复验时,扩展 B 区字「𤝻」(U+2477B)在真机上正常出字形——不是豆腐块。当时结论基于抽样:一个字出字形,不能证明内置字体覆盖了整个扩展 B 区,也不能证明系统回退可靠,所以欠账挂起,等「更大范围字表抽样」。

这笔欠账真正的问句是:阅读正文里那些两万码位开外的字,到底是内置字体在显示,还是系统在兜底? 要回答它,先得把应用的字体链路盘点清楚。

应用注册了两个自定义字体,官方 Font 模块的 Font.registerFont 在启动时挂载,各管一段渲染:

注册 family字体文件渲染对象语料里的典型内容
ShanhaiTextSerifNotoSerifSC-Regular.ttf(14.8 MB)阅读正文、经文、夹注、异兽文案白话译文 + 繁体原文(含扩展 B/C-F 区生僻字)
ShanhaiTitleSerifZCOOLXiaoWei-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 展开,毫秒级跑完,零依赖。这也意味着同一段解析代码可以直接长在门禁里,不用给工程添任何包。

解析 cmap 对账 4898 个用字:两个字体的缺失全景与三次教训

三、结果反转:𤝻 出字形靠的是系统回退

对账结果先看分布:

区块语料字数正文 NotoSerifSC标题 ZCOOLXiaoWei
基本区(URO,含繁体)47060 缺失缺 1547(繁体经文用字为主)
扩展 A 区980 缺失缺 98
扩展 B 区90缺 90缺 90
扩展 C-F 区4缺 4缺 4
合计4898缺 94缺 1739

反转在这里:正文内置的 NotoSerifSC 并不覆盖扩展 B/C-F 区,𤝻 就在那 94 个字里。 第 62 篇真机实测「𤝻 出字形」是真的,但归因错了——出字形的不是内置字体,是 HarmonyOS 渲染栈的缺字回退:内置字体查无此字,渲染落到系统字体栈继续找,系统 CJK 字体接住了它。

同一个实测事实,两种解读,差之毫厘:

  • 抽样解读(当时):系统有字形,内置字体覆盖待抽样确认 → 欠账挂起;
  • 全量对账(本篇):内置字体本体不含 𤝻,出字形 = 系统回退兜底 → 不需要内置子集字体,需要把 94 字登记为回退依赖并设门禁。

𤝻 出字形的真相:抽样解读与全量对账的结论对照

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

𤝻(U+2477B)真机整页在屏:经文与译文同时出字形

顺带一提第 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 项)
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐