feat(UI字体): 扩字60字为页面扩充预置, 修复生成器cmap插入顺序缺陷

字库扩充(批2页面预置, task-4/道B):
- ui_cn_chars.txt 119→179 字(CJK 176 + 符号 ▶/,/:),含 ASCII 共 274 字形
- +60 字: Ⅰ Ⅱ Ⅲ 段 延 投 退 跳 闸 零 频 荷 负 低 过 电 一 二 次 位 公
  出 回 外 部 度 温 态 状 总 接 显 示 最 近 未 标 欠 闭 锁 长 间 障 配
  道 重 载 返 年 月 分 秒 合 已 请 求 确 取 消 越
  依据 = 页面补齐规划/页面扩充详细方案两篇规划中出现的页面显示串;
  待拍板的厂家设置支用字(厂/家/恢/调/试)不入
- Ⅰ/Ⅱ/Ⅲ 宋体有字形(simsun face0 cmap 实测 HAS)且正常入字体,罗马数字
  无需任何工具链兜底

make_font.py 修复(本次扩字暴露的真缺陷):
- LVGL get_glyph_dsc_id() 线性扫描 cmaps[]、首个包含码位的 range 即返回:
  Ⅰ(U+2160) 使稀疏 range 起点 0x4E09→0x2160,把 U+25B6 ▶ 单码位补回条目
  吞进宽 range → 模拟器箭头全变方框(菜单 658 px 差异);旧 verify_coverage
  按"进了任一 range 即覆盖"判定,全绿照不出解析路径倒置
- 修复: _insert_cmap_entry() 把补回条目插到包含它的 range 之前;
  _cmap_lookup_gid() 可达性守卫,可达即跳合并(全角逗号走稀疏列表,不再
  追加永远不可达的死条目); verify_coverage() 改按首命中语义

验证(全绿):
- font-regen 四步(extract→并回 KEEP_EXTRA→make_font→diff);仓库外沙箱
  dry-run + 确定性: 沙箱/仓库产物 sha256 一致(26e519db…)
- 逐码位结构对账: added=60 / removed=0 / 保留字形位图字节零差异 /
  度量一致(multiset 差 = 新字身)
- font-charset PASS(60 未知冗余 WARN = 预置字,批2接线后自然转在用)
- 模拟器菜单 8 状态 + 内容页 11 页黄金参考与旧字体逐字节一致(箭头恢复);
  ref/ 基线无变化,故无黄金参考更新提交
- @build 0 Error 0 Warning; A/B 同树构建: RO-data +1528 B, RW/ZI(RAM) 0

gate: only mem-budget/mem-numbers pending lead ledger backfill
- pre-commit --all-files 实测: 仅这两条失败(台账受管区/内存文档数字 vs
  .map 漂移; 根因是 RO-data +1528 B 的 Flash 增量, RAM 无变化);
  其余 38 钩子全绿(font-charset/ui-golden/cppcheck/编码规范/单测族均过)
- 修复手段 ram_baseline.py --write / check_mem_numbers.py --write 均写
  docs/内存管理/**(Lead 单写者域), 本分支按裁决不代跑, 请合并后回填
- 手工自查: 改动 8 文件 BOM/CR/U+FFFD 与各自原状一致(全 LF 无 BOM),
  字体产物纯 ASCII, make_font.py 中文仅在注释与打印, 无字面量改动
This commit is contained in:
2026-10-25 03:23:52 +08:00
parent 15aa08552c
commit 2e0f66a07e
3 changed files with 590 additions and 165 deletions
+99 -17
View File
@@ -80,14 +80,95 @@ ARROW_BITMAP = [0x80, 0xe0, 0xf0, 0xfc, 0xff, 0xfe, 0xf8, 0xf0, 0xc0, 0x0]
ARROW_ADV_W, ARROW_BOX_W, ARROW_BOX_H, ARROW_OFS_X, ARROW_OFS_Y = 165, 8, 10, 1, -1
def _parse_cmap_entries(src):
"""解析 cmaps[] 为 [(range_start, range_length, glyph_id_start,
unicode_list名或None, glyph_id_ofs_list名或None, list_length, type名)],
以及所有 static const uint8/16 数组表。"""
arrays = {}
for mm in re.finditer(r'static const (uint8_t|uint16_t) (\w+)\[\] = \{(.*?)\};', src, re.S):
arrays[mm.group(2)] = [int(x, 16) for x in re.findall(r'0x[0-9a-fA-F]+', mm.group(3) if mm.group(1) == 'uint16_t' else mm.group(3))]
cm = re.search(r'cmaps\[\]\s*=\s*\{', src)
if not cm:
return [], arrays, None, -1
end_cm = src.find('\n};', cm.end())
entries = []
for mm in re.finditer(r'\{\s*\.range_start\s*=\s*(\d+),\s*\.range_length\s*=\s*(\d+),\s*\.glyph_id_start\s*=\s*(\d+),\s*\.unicode_list\s*=\s*([^,]+),\s*\.glyph_id_ofs_list\s*=\s*([^,]+),\s*\.list_length\s*=\s*(\d+),\s*\.type\s*=\s*(\w+)', src[cm.end():end_cm]):
ul = mm.group(4).strip()
ol = mm.group(5).strip()
entries.append((int(mm.group(1)), int(mm.group(2)), int(mm.group(3)),
None if ul == 'NULL' else ul,
None if ol == 'NULL' else ol,
int(mm.group(6)), mm.group(7)))
return entries, arrays, cm, end_cm
def _resolve_in_entry(e, arrays, cp):
rs, rl, gs, ul, ol, ll, typ = e
rcp = cp - rs
if typ.endswith('FORMAT0_TINY'):
return gs + rcp
if typ.endswith('FORMAT0_FULL'):
ofs = arrays.get(ol, [])
if rcp >= len(ofs):
return 0
v = ofs[rcp]
if v == 0 and rcp != 0:
return 0
return gs + v
if typ.endswith('SPARSE_TINY'):
lst = arrays.get(ul, [])
try:
i = lst.index(rcp)
except ValueError:
return 0
return gs + i
if typ.endswith('SPARSE_FULL'):
lst = arrays.get(ul, [])
try:
i = lst.index(rcp)
except ValueError:
return 0
ofs = arrays.get(ol, [])
return gs + ofs[i]
return 0
def _cmap_lookup_gid(src, cp):
"""复刻 LVGL get_glyph_dsc_id:线性扫描,第一个包含码位的 range 决定
结果(找不到也直接返回 0,不会继续看后面的 range)。"""
entries, arrays, cm, _end = _parse_cmap_entries(src)
for e in entries:
rs, rl = e[0], e[1]
if rs <= cp < rs + rl:
return _resolve_in_entry(e, arrays, cp)
return 0
def _insert_cmap_entry(src, cp, entry_text):
"""把单码位 cmap 条目插到「第一个包含该码位的既有 range」之前;
无包含者则追加到末尾。(LVGL 线性首命中语义,详见 _cmap_lookup_gid)"""
entries, arrays, cm, end_cm = _parse_cmap_entries(src)
block = src[cm.end():end_cm]
pos = None
for mm in re.finditer(r'\{\s*\.range_start\s*=\s*(\d+),\s*\.range_length\s*=\s*(\d+)', block):
rs, rl = int(mm.group(1)), int(mm.group(2))
if rs <= cp < rs + rl:
pos = mm.start()
break
if pos is None:
return src[:end_cm] + ',\n' + entry_text + src[end_cm:]
blk_ins = cm.end() + pos
return src[:blk_ins] + entry_text + ',\n ' + src[blk_ins:]
def merge_arrow_glyph(font_path):
"""若字体缺 U+25B6(▶),把预提取的箭头字形合并进去(位图 + glyph_dsc + cmap)。"""
import re
raw = open(font_path, 'rb').read()
has_bom = raw.startswith(b'\xef\xbb\xbf')
src = raw.decode('utf-8-sig')
if re.search(r'\.range_start\s*=\s*9654\s*,', src):
print('arrow U+25B6 已存在,跳过合并')
if _cmap_lookup_gid(src, ARROW_CP_DEC) != 0:
print('arrow U+25B6 已可经既有 cmap 解析,跳过合并')
return
# 当前位图总字节数(新字形 bitmap_index 的取值)
bm = re.search(r'glyph_bitmap\[\]\s*=\s*\{', src)
@@ -106,12 +187,10 @@ def merge_arrow_glyph(font_path):
total, ARROW_ADV_W, ARROW_BOX_W, ARROW_BOX_H, ARROW_OFS_X, ARROW_OFS_Y)
src = src[:end_gd] + entry + src[end_gd:]
# 3) 追加 cmap(单码位映射)
cm = re.search(r'cmaps\[\]\s*=\s*\{', src)
end_cm = src.find('\n};', cm.end())
cmap = ',\n {\n .range_start = %d, .range_length = 1, .glyph_id_start = %d,\n' \
' .unicode_list = NULL, .glyph_id_ofs_list = NULL, .list_length = 0, .type = LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY\n }' % (
cmap_entry = '{\n .range_start = %d, .range_length = 1, .glyph_id_start = %d,\n' \
' .unicode_list = NULL, .glyph_id_ofs_list = NULL, .list_length = 0, .type = LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY\n }' % (
ARROW_CP_DEC, glyph_count)
src = src[:end_cm] + cmap + src[end_cm:]
src = _insert_cmap_entry(src, ARROW_CP_DEC, cmap_entry)
# 4) cmap_num +1
src = re.sub(r'\.cmap_num = (\d+),',
lambda m: '.cmap_num = %d,' % (int(m.group(1)) + 1), src, count=1)
@@ -321,8 +400,8 @@ def merge_extra_symbols(font_path, size, bpp):
raw = open(font_path, 'rb').read()
has_bom = raw.startswith(b'\xef\xbb\xbf')
src = raw.decode('utf-8-sig')
if re.search(r'\.range_start\s*=\s*%d\s*,' % cp, src):
print('U+%04X %r 已存在,跳过合并' % (cp, ch))
if _cmap_lookup_gid(src, cp) != 0:
print('U+%04X %r 已可经既有 cmap 解析,跳过合并' % (cp, ch))
continue
# 1) 单独生成仅含该符号的迷你字体(真实宋体位图/度量)
@@ -376,13 +455,10 @@ def merge_extra_symbols(font_path, size, bpp):
main_bytes + bmp_ofs, adv, bw, bh, ox, oy, cp)
src = src[:end_main_gd] + entry + src[end_main_gd:]
cm = re.search(r'cmaps\[\]\s*=\s*\{', src)
end_main_cm = src.find('\n};', cm.end())
cmap = ',\n {\n .range_start = %d, .range_length = %d, ' \
'.glyph_id_start = %d,\n' \
' .unicode_list = NULL, .glyph_id_ofs_list = NULL, .list_length = 0, ' \
'.type = LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY\n }' % (sym_cp, sym_len, glyph_count)
src = src[:end_main_cm] + cmap + src[end_main_cm:]
cmap_entry = '{\n .range_start = %d, .range_length = %d, .glyph_id_start = %d,\n' \
' .unicode_list = NULL, .glyph_id_ofs_list = NULL, .list_length = 0, ' \
'.type = LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY\n }' % (sym_cp, sym_len, glyph_count)
src = _insert_cmap_entry(src, sym_cp, cmap_entry)
src = re.sub(r'\.cmap_num = (\d+),',
lambda m: '.cmap_num = %d,' % (int(m.group(1)) + 1), src, count=1)
@@ -422,7 +498,13 @@ def verify_coverage(font_path):
return False
chars = sorted(set(open(CHARS_FILE, encoding='utf-8').read().strip()))
missing = [c for c in chars if not covered(ord(c))]
# 判定必须复刻 LVGL 的「首个包含即返回」语义:宽稀疏 range 会把后面的
# 单码位补回条目吞掉(2026-10-25 Ⅰ 段扩字实测:旧 covered() 判据下
# coverage 全绿,实际 ▶ 上屏全是方框)。_cmap_lookup_gid 不可用时退回 covered。
try:
missing = [c for c in chars if _cmap_lookup_gid(src, ord(c)) == 0]
except NameError:
missing = [c for c in chars if not covered(ord(c))]
if missing:
sys.exit('ERROR: 字体缺字 %d 个(UI 将显示空白): %s'
% (len(missing), ' '.join('U+%04X' % ord(c) for c in missing)))