Files
wandering1 2e0f66a07e 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 中文仅在注释与打印, 无字面量改动
2026-10-25 03:23:52 +08:00

557 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成中文子集位图字体 ui_font_cn_12.c(LVGL 格式,输出到 Drivers/BSP/UI/)。
前置依赖:
npm i -g lv_font_conv # 离线 TTF/OTF -> LVGL 字体 转换器
pip install fonttools # 仅当字体源是 .ttc 集合时需要(用于抽取单字重)
后处理(本脚本自动完成,均幂等;产物可直接入库):
merge_arrow_glyph 补回宋体缺失的 ▶ 箭头字形
merge_extra_symbols 补回 lv_font_conv 静默丢弃的低码位符号(全角逗号)
verify_coverage 硬校验:ui_cn_chars.txt 全部字符必须进字体
fix_trailing_newline 规范化文件末尾为单个换行
normalize_project_conventions 工程约定规范化(文件头 / 包含路径 / 版本守卫压平)
用法:
python tools/font_gen/make_font.py # 默认:宋体, 12px, 1bpp
python tools/font_gen/make_font.py --bpp 2 # 宋体细笔画发虚时改用 2bpp
python tools/font_gen/make_font.py --font C:/path/to/font.ttf
"""
import os
import re
import sys
import argparse
import tempfile
import subprocess
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OUT = os.path.join(ROOT, 'Drivers', 'BSP', 'UI', 'ui_font_cn_12.c')
CHARS_FILE = os.path.join(ROOT, 'ui_cn_chars.txt')
FONT_NAME = 'ui_font_cn_12'
def resolve_lv_font_conv():
# 1) 全局 node 模块路径(npm i -g)
try:
groot = subprocess.check_output(['npm', 'root', '-g'], text=True).strip()
p = os.path.join(groot, 'lv_font_conv', 'lv_font_conv.js')
if os.path.exists(p):
return ['node', p]
except Exception:
pass
# 2) PATH 上的可执行
for cmd in ('lv_font_conv.cmd', 'lv_font_conv'):
if subprocess.run(['where', cmd], capture_output=True, shell=True).returncode == 0:
return [cmd]
sys.exit('ERROR: 找不到 lv_font_conv。请先执行: npm i -g lv_font_conv')
def resolve_font(path):
if path:
if not os.path.exists(path):
sys.exit('ERROR: --font 指定的文件不存在: ' + path)
return path
# 默认字体:Windows 宋体集合 simsun.ttc,由 WINDIR 环境变量的系统字体目录推导
# (兼容非 C 盘系统盘);lv_font_conv 不支持 .ttc,需先抽取 face0
windir = os.environ.get('WINDIR') or r'C:\Windows'
ttc = os.path.join(windir, 'Fonts', 'simsun.ttc')
if os.path.exists(ttc):
try:
from fontTools.ttLib import TTCollection
coll = TTCollection(ttc)
tmp = os.path.join(tempfile.gettempdir(), 'simsun_regular.ttf')
coll.fonts[0].save(tmp)
print('extracted SimSun regular (face0) ->', tmp)
return tmp
except Exception as e:
sys.exit('ERROR: 抽取 simsun.ttc 失败,请 pip install fonttools,或用 --font 指定 ttf: ' + str(e))
sys.exit('ERROR: 未找到 %s,请用 --font 指定字体 ttf/otf 路径' % ttc)
# ---------------------------------------------------------------------------
# 箭头字形合并
# 宋体(simsun.ttc)没有 U+25B6(▶)字形,lv_font_conv 会静默跳过 → 真机显示成方框。
# 该字形从 Segoe UI Symbol(seguisym.ttf,Windows 自带)提取,全量重生成字体后
# 自动补回,避免"重生成字体箭头又变方框"。
# ---------------------------------------------------------------------------
ARROW_CP_DEC = 9654 # U+25B6 ▶
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 _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)
end_bm = src.find('\n};', bm.end())
body_bm = re.sub(r'/\*.*?\*/', '', src[bm.end():end_bm], flags=re.S)
total = len(re.findall(r'0x[0-9a-fA-F]+', body_bm))
glyph_count = len(re.findall(r'\.bitmap_index\s*=\s*\d+', src))
# 1) 追加位图
bmp = ',\n\n /* U+25B6 "▶"(来源:Segoe UI Symbol;宋体无此字形) */\n ' + \
', '.join('0x%02x' % b for b in ARROW_BITMAP)
src = src[:end_bm] + bmp + src[end_bm:]
# 2) 追加 glyph_dsc
gd = re.search(r'glyph_dsc\[\]\s*=\s*\{', src)
end_gd = src.find('\n};', gd.end())
entry = ',\n {.bitmap_index = %d, .adv_w = %d, .box_w = %d, .box_h = %d, .ofs_x = %d, .ofs_y = %d} /* U+25B6 */' % (
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(单码位映射)
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 = _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)
out = b'\xef\xbb\xbf' if has_bom else b''
open(font_path, 'wb').write(out + src.encode('utf-8'))
print('已合并箭头 U+25B6:位图+%d 字节,glyph_id=%d' % (len(ARROW_BITMAP), glyph_count))
def fix_trailing_newline(path):
"""规范化文件末尾为恰好一个 \n(pre-commit end-of-file-fixer 要求)。
lv_font_conv 产物末尾多一个空行(`#endif` 之后 `\n\n`),统一在此收敛为单个换行,
避免每次重生成字体都触发 end-of-file-fixer 拦截提交。
"""
with open(path, 'rb') as fp:
data = fp.read()
body = data
while body.endswith(b'\r\n') or body.endswith(b'\n'):
body = body[:-2] if body.endswith(b'\r\n') else body[:-1]
fixed = body + b'\n'
if fixed != data:
with open(path, 'wb') as fp:
fp.write(fixed)
# ---------------------------------------------------------------------------
# 工程约定规范化(lv_font_conv 产物后处理最后一步,幂等)
# 1) lv_font_conv 头注释(内嵌本机绝对路径)替换为工程文件头(模块名称/作者)
# 2) LV_LVGL_H_INCLUDE_SIMPLE 双分支包含收敛为单一 #include "lvgl.h"
# (规范 2.3.5 禁止相对/绝对路径 include,如 "lvgl/lvgl.h")
# 3) 版本守卫(#if LVGL_VERSION_*/LV_VERSION_CHECK)压平:仓库 LVGL 固定 9.3.0,
# 守卫可静态求值,保留真分支、删除守卫(同时消除 cppcheck syntaxError)
# ---------------------------------------------------------------------------
LVGL_VERSION = (9, 3, 0)
def _eval_cond(cond, symbols):
"""静态求值 #if 条件(仅支持 lv_font_conv 产出的版本表达式)。
返回 True/False;无法求值时返回 None(调用方保留原块不压平)。
"""
def ver_check(m):
a, b, c = (int(x) for x in m.groups())
return '1' if LVGL_VERSION >= (a, b, c) else '0'
e = re.sub(r'LV_VERSION_CHECK\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)', ver_check, cond)
for name, val in (('LVGL_VERSION_MAJOR', LVGL_VERSION[0]),
('LVGL_VERSION_MINOR', LVGL_VERSION[1]),
('LVGL_VERSION_PATCH', LVGL_VERSION[2])):
e = re.sub(r'\b%s\b' % name, str(val), e)
for name, val in symbols.items():
e = re.sub(r'\b%s\b' % name, str(val), e)
if re.search(r'[A-Za-z_]\w*', e): # 仍有标识符 → 无法静态求值(先于 &&/|| 转换,避免误判 and/or)
return None
e = e.replace('&&', ' and ').replace('||', ' or ')
e = re.sub(r'!(?!=)', ' not ', e)
try:
return bool(eval(e, {'__builtins__': {}}))
except Exception:
return None
def _active(stack):
"""当前行是否处于"保留分支"(不可求值块视为整体保留)。"""
for cond, in_else, evalable in stack:
if evalable and in_else == cond:
return False
return True
def _flatten_guards(lines, symbols):
"""压平可静态求值的版本守卫;不可求值的块原样保留(并告警)。"""
out = []
stack = [] # [cond, in_else, evalable];cond 为 None = 不可求值
for raw in lines:
s = raw.strip()
if not s.startswith('#'):
if _active(stack):
out.append(raw)
continue
mi = re.match(r'#\s*if\s+(\S.*)$', s)
mid = re.match(r'#\s*ifdef\s+(\S+)', s)
minn = re.match(r'#\s*ifndef\s+(\S+)', s)
if mi or mid or minn:
if mid:
cond, evalable = mid.group(1) in symbols, True
elif minn:
cond, evalable = minn.group(1) not in symbols, True
else:
cond = _eval_cond(mi.group(1), symbols)
evalable = cond is not None
if not evalable:
print('WARNING: 无法静态求值,原样保留: %s' % s)
stack.append([cond, False, evalable])
if not evalable:
out.append(raw) # 不可求值块保留 #if 行
continue
if re.match(r'#\s*else\b', s):
if stack and stack[-1][2]:
stack[-1][1] = not stack[-1][1]
continue # 可求值:丢弃 #else 行
out.append(raw) # 不可求值(或无配对 #if):原样保留
continue
if re.match(r'#\s*endif\b', s):
if stack:
if stack[-1][2]:
stack.pop()
continue # 可求值:丢弃 #endif 行
stack.pop()
out.append(raw)
continue
if s.startswith('#define'):
m = re.match(r'#\s*define\s+([A-Za-z_]\w*)\s+(\S.*)$', s)
if m:
try:
symbols[m.group(1)] = int(m.group(2).strip())
except ValueError:
pass
if _active(stack):
out.append(raw)
continue
if _active(stack): # 其余指令(#include 等)
out.append(raw)
return out
def _drop_unused_guard(lines, guard_name):
"""压平后 lv_font_conv 遗留的 #define 守卫宏(如 UI_FONT_CN_12)若无人再引用则删除
(.c 文件不需要头文件保护宏)。"""
n = len(re.findall(r'\b%s\b' % re.escape(guard_name), '\n'.join(lines)))
if n != 1:
return lines
return [l for l in lines
if not re.match(r'#\s*define\s+%s\b' % re.escape(guard_name), l.strip())]
def normalize_project_conventions(font_path, size, bpp):
"""lv_font_conv 产物对齐工程约定(在 verify_coverage / fix_trailing_newline 之后调用)。"""
raw = open(font_path, 'rb').read()
has_bom = raw.startswith(b'\xef\xbb\xbf')
text = raw.decode('utf-8-sig')
eol = '\r\n' if '\r\n' in text else '\n'
lines = [l.rstrip('\r') for l in text.split('\n')]
while lines and lines[-1] == '':
lines.pop()
# 1) lv_font_conv 头注释(内嵌本机绝对路径)替换为工程文件头
if lines and lines[0].startswith('/**'):
end = None
for i in range(1, len(lines)):
if re.fullmatch(r'\s*\*+/\s*', lines[i]):
end = i
break
if end is not None:
header = [
'/*',
' * 模块名称:UI 中文位图字体(%s,自动生成)' % FONT_NAME,
' * 模块功能:中文子集 %dpx 位图字体(宋体;含 ▶ 箭头与全角逗号补回字形)' % size,
' * 适用平台:STM32F407ZGTx / PC 模拟器(共用源码)',
' * 作者:王建锋',
' * 创建日期:2026-08-30',
' * 修改记录:',
' * 2026-09-13 王建锋 工程约定规范化(文件头/包含路径/版本守卫压平),生成器同步更新',
' * 自动生成:本文件由 tools/font_gen/make_font.py(lv_font_conv --format lvgl)自动生成,请勿手改。',
' * 字符集:ui_cn_chars.txt(extract_cn.py);改字符集或字体参数后重跑 make_font.py。',
' * 生成参数:宋体 (simsun.ttc face0), size=%d, bpp=%d, --no-compress。' % (size, bpp),
' */',
]
lines = header + lines[end + 1:]
# 2) 双分支包含收敛为单一 #include "lvgl.h"
for i, l in enumerate(lines):
if l.strip() == '#ifdef LV_LVGL_H_INCLUDE_SIMPLE':
j = i
while j < len(lines) and lines[j].strip() != '#endif':
j += 1
lines[i:j + 1] = ['#include "lvgl.h"']
break
# 3) 压平版本守卫(保留真分支;本仓库 LVGL 为 9.3.0)
symbols = {}
lines = _flatten_guards(lines, symbols)
lines = _drop_unused_guard(lines, FONT_NAME.upper())
while lines and lines[-1] == '':
lines.pop()
data = eol.join(lines) + eol
open(font_path, 'wb').write((b'\xef\xbb\xbf' if has_bom else b'') + data.encode('utf-8'))
print('工程约定规范化完成:文件头/包含路径/版本守卫压平(LVGL %d.%d.%d)'
% LVGL_VERSION)
# ---------------------------------------------------------------------------
# 低码位符号补回(与箭头字形合并同一模式)
# lv_font_conv 的稀疏 cmap 从符号集中最小码位起算,低于该起点的符号被静默丢弃
# (实测:U+3001 全角逗号 < 0x4E2D 范围起点,未进字体 → 页脚","长期渲染为空白)。
# 处理:对每个此类符号单独生成迷你字体(真实宋体位图),再并入主字体
# (位图 + glyph_dsc + 单码位 cmap,幂等)。
# ---------------------------------------------------------------------------
EXTRA_SYMBOLS = [','] # U+3001 全角逗号(页脚"第 X 页,共 Y 页")
def merge_extra_symbols(font_path, size, bpp):
"""把 lv_font_conv 丢失的低码位符号从迷你字体并入主字体(逐符号、幂等)。"""
import re
for ch in EXTRA_SYMBOLS:
cp = ord(ch)
raw = open(font_path, 'rb').read()
has_bom = raw.startswith(b'\xef\xbb\xbf')
src = raw.decode('utf-8-sig')
if _cmap_lookup_gid(src, cp) != 0:
print('U+%04X %r 已可经既有 cmap 解析,跳过合并' % (cp, ch))
continue
# 1) 单独生成仅含该符号的迷你字体(真实宋体位图/度量)
tmp = os.path.join(tempfile.gettempdir(), 'font_extra_%04x.c' % cp)
cmd = resolve_lv_font_conv() + ['--font', resolve_font(None),
'--size', str(size), '--bpp', str(bpp),
'--symbols', ch, '--no-compress',
'--format', 'lvgl', '--lv-font-name', 'tmp_extra',
'-o', tmp]
r = subprocess.run(cmd)
if r.returncode != 0:
sys.exit('ERROR: 生成 U+%04X 迷你字体失败' % cp)
mini = open(tmp, encoding='utf-8-sig').read()
# 2) 从迷你字体提取位图字节 / glyph_dsc 字段 / 码位
bm = re.search(r'glyph_bitmap\[\]\s*=\s*\{', mini)
end_bm = mini.find('\n};', bm.end())
body_bm = re.sub(r'/\*.*?\*/', '', mini[bm.end():end_bm], flags=re.S)
bytes_ = re.findall(r'0x[0-9a-fA-F]+', body_bm)
gd = re.search(r'glyph_dsc\[\]\s*=\s*\{', mini)
end_gd = mini.find('\n};', gd.end())
entries = re.findall(r'\{\.bitmap_index = (\d+), \.adv_w = (\d+), \.box_w = (\d+), '
r'\.box_h = (\d+), \.ofs_x = (-?\d+), \.ofs_y = (-?\d+)\}',
mini[gd.end():end_gd])
if len(entries) < 2:
sys.exit('ERROR: 无法解析 U+%04X 迷你字体 glyph_dsc' % cp)
# 取第 1 项(下标 0 是保留空字形)
bmp_ofs, adv, bw, bh, ox, oy = (int(v) for v in entries[1])
cm = re.search(r'cmaps\[\]\s*=\s*\{', mini)
m = re.search(r'\.range_start\s*=\s*(\d+)\s*,\s*\.range_length\s*=\s*(\d+)',
mini[cm.end():])
if not m or int(m.group(2)) != 1:
sys.exit('ERROR: U+%04X 迷你字体 cmap 非单码位,无法并入' % cp)
sym_cp, sym_len = int(m.group(1)), 1
# 3) 并入主字体:位图追加(偏移平移)+ glyph_dsc 追加 + 单码位 cmap 追加
body = re.search(r'glyph_bitmap\[\]\s*=\s*\{', src)
end_main_bm = src.find('\n};', body.end())
main_bytes = len(re.findall(r'0x[0-9a-fA-F]+',
re.sub(r'/\*.*?\*/', '', src[body.end():end_main_bm], flags=re.S)))
glyph_count = len(re.findall(r'\.bitmap_index\s*=\s*\d+', src))
bmp = ',\n\n /* U+%04X %r(来源:宋体迷你字体并入;lv_font_conv 丢低码位符号) */\n ' % (cp, ch) \
+ ', '.join(bytes_)
src = src[:end_main_bm] + bmp + src[end_main_bm:]
gd = re.search(r'glyph_dsc\[\]\s*=\s*\{', src)
end_main_gd = src.find('\n};', gd.end())
entry = ',\n {.bitmap_index = %d, .adv_w = %d, .box_w = %d, .box_h = %d, ' \
'.ofs_x = %d, .ofs_y = %d} /* U+%04X */' % (
main_bytes + bmp_ofs, adv, bw, bh, ox, oy, cp)
src = src[:end_main_gd] + entry + src[end_main_gd:]
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)
out = b'\xef\xbb\xbf' if has_bom else b''
open(font_path, 'wb').write(out + src.encode('utf-8'))
print('已合并 U+%04X %r:%d 字节位图,glyph_id=%d' % (cp, ch, len(bytes_), glyph_count))
def verify_coverage(font_path):
"""生成后校验:ui_cn_chars.txt 的全部码位必须进字体 cmap(防静默丢字)。
lv_font_conv 对某些码位会静默丢弃(低于稀疏范围起点、或源字体缺字形),
静默缺字在 UI 上表现为空白,极难发现。此校验把"缺字"变成生成期硬错误。
须在 merge_arrow_glyph / merge_extra_symbols 之后调用(合并的符号也要计入)。
"""
import re
src = open(font_path, encoding='utf-8-sig').read()
# 稀疏 cmap 的 unicode_list_N:十六进制偏移(相对 range_start)
ulists = {}
for m in re.finditer(r'static const uint16_t (unicode_list_\d+)\[\] = \{(.*?)\};', src, re.S):
ulists[m.group(1)] = [int(x, 16) for x in re.findall(r'0x[0-9a-fA-F]+', m.group(2))]
cmaps = [] # (range_start, range_length, sparse_offsets_or_None)
for m in re.finditer(r'\.range_start = (\d+), \.range_length = (\d+), \.glyph_id_start = (\d+),\s*'
r'\.unicode_list = ([^,]+), \.glyph_id_ofs_list = ([^,]+), \.list_length = (\d+), '
r'\.type = (\w+)', src):
ul = m.group(4).strip()
cmaps.append((int(m.group(1)), int(m.group(2)), ulists.get(ul)))
def covered(cp):
for rs, rl, offs in cmaps:
if offs is None:
if rs <= cp < rs + rl:
return True
elif (cp - rs) in offs:
return True
return False
chars = sorted(set(open(CHARS_FILE, encoding='utf-8').read().strip()))
# 判定必须复刻 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)))
print('coverage OK: %d 个字符全部进字体' % len(chars))
def main():
# Windows GBK 控制台无法打印 ▶ 等符号:降级为替换字符,避免 UnicodeEncodeError 崩溃
try:
sys.stdout.reconfigure(errors='replace')
except Exception:
pass
ap = argparse.ArgumentParser()
ap.add_argument('--bpp', type=int, default=1, help='字体位深 1/2/3(默认 1)')
ap.add_argument('--size', type=int, default=12, help='字号(默认 12)')
ap.add_argument('--font', default=None, help='自定义字体 ttf/otf 路径(默认宋体)')
args = ap.parse_args()
if not os.path.exists(CHARS_FILE):
sys.exit('ERROR: 请先运行 extract_cn.py 生成 ui_cn_chars.txt')
chars = open(CHARS_FILE, encoding='utf-8').read().strip()
cmd = resolve_lv_font_conv()
font = resolve_font(args.font)
cmd += ['--font', font,
'--size', str(args.size),
'--bpp', str(args.bpp),
'-r', '0x20-0x7E', # 含 ASCII,使字体自包含
'--symbols', chars, # 本工程实际显示的汉字子集
'--no-compress', # 关闭 RLE 压缩,保证兼容
'--format', 'lvgl',
'--lv-font-name', FONT_NAME,
'-o', OUT]
print('RUN:', ' '.join(cmd))
r = subprocess.run(cmd)
if r.returncode != 0:
sys.exit('FAILED rc=%d' % r.returncode)
merge_arrow_glyph(OUT) # 补回宋体缺失的 ▶ 字形(幂等)
merge_extra_symbols(OUT, args.size, args.bpp) # 补回 lv_font_conv 丢弃的低码位符号(幂等)
verify_coverage(OUT) # 生成后校验:字符集全部码位必须进字体(防静默丢字)
fix_trailing_newline(OUT) # 规范化末尾换行(pre-commit end-of-file-fixer)
normalize_project_conventions(OUT, args.size, args.bpp) # 对齐工程约定(文件头/包含路径/版本守卫压平)
print('OK %d bytes -> %s' % (os.path.getsize(OUT), OUT))
if __name__ == '__main__':
main()