2e0f66a07e
字库扩充(批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 中文仅在注释与打印, 无字面量改动
557 lines
26 KiB
Python
557 lines
26 KiB
Python
#!/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()
|