Files
STM32F4-Base/scripts/check_font_charset.py
T
wandering1 dafc18f5ed feat(UI): W6 第一波——设计令牌层、页面注册表生成器与 UI 门禁补强
启动 docs/任务规划/README.md 的 W6 UI 扩展簇(用户裁定,覆盖 2026-10-11
的"本轮不排期")。四个工作包,全部验证通过。

## U1 设计令牌层(零行为变化)
新建 Drivers/BSP/UI/ui_tokens.h(20 个宏),框架 + 7 个页面 + App/task/
ui_task.c 全部接入。屏幕几何**复用 ui_port.h 既有 UI_SCREEN_W/H**,不重复
定义;令牌头自身只 include ui_port.h,不把 LVGL 拖进全部消费者。
补收了报告没列但代码里真重复的量:UI_TEXT_BAND_H 15(7 次)、列表左右边界
与每页行数(各 4 个页面逐字复制)、推流协议几何与显示几何同源(此前硬编码
3200 且注释写着"须与 UI_FB_SIZE 一致"——典型漂移隐患)、UI_TASK_TICK_MS
(ui_port_step 与 osDelay 必须同值,原先靠注释维系)。
故意不收页面私有几何与"同形不同义"的值(device_info 的 pitch=15 与列表
UI_ROW_PITCH=14 合并即改像素)。

## U3 页面注册表与生成器(删手工分发)
ui_page_registry.{c,h} 由生成器产出,**手工 menu_content_page_for() 已删
除**(仓库内仅存 2 处历史说明注释)。密码页按代码实况**单独出口**
(ui_page_registry_password())——它非节点绑定,任何 #P 叶子进同一页。
顺带落地此前判定"U1 阶段做不了"的共享 flat 样式 ui_style.{c,h}。
生成器除产注册表外**自动把新 .c 写进 uvprojx 与 CMakeLists.txt**,且
**双向对账**:加页打印 registered、删页打印 pruned,不留死条目;并双向
校验(#page 找不到实例报错、实例未被任何 #page 引用也报错)。

## U4 门禁补强(35 个钩子)
新增 font-charset 钩子(scripts/check_font_charset.py):复用
tools/font_gen 的同一套提取与 cmap 解析规则(importlib 直载,零第二套
规则),A 段查源码在用 vs 字符集、B 段查字符集 vs 字体 cmap。
registration-check 补第 ④ 类:实测原规则对"进了两端构建但没接描述符表"
的页面**全绿**——desc 被链接器剥离、编译链接全过、菜单永远打不开的死代码。
仍照不出的两项已如实写进文档(枚举漏加归 @build、菜单树漏挂归 golden)。

## 字体子集瘦身(D 案,4 留 3 删)
ui_cn_chars.txt 122→119 字(恰删 上/电/重,零新增)。异/常/片/芯 进
KEEP_EXTRA 带理由白名单:它们曾在 9eb1bd63 被写进中文自检描述落盘
/log/selftest.log(定长 64B 记录跨固件持久),删字形会使历史故障记录
渲染空白且不可逆。保留字按**必需**处理,反向有牙——谁只跑 extract_cn
把它们静默洗掉,钩子直接 FAIL 并逐字打印理由。
--strict 口径为"未知冗余 = 0"而非"冗余 = 0"。

## 验证
@build 0 错 0 警;ER_IROM1 232,052 B(较 HEAD −228 B)、RW_IRAM1
75,232 B **零增量**、RW_CCM 87.9% 未变。golden:菜单 8 状态全像素一致 +
内容页 11 页逐字节一致,**参考图 sha256 差异 0 条**(未用 --update 刷
基线);池水位菜单基线 5368 B、开页-关页往返 5448 B 与 U1 时相同。
38 个在途修改文件逐个核对 BOM/CRLF/U+FFFD 全部与 HEAD 一致。
L0 33 钩子 + L0.5 16 runner / 1104 断言全绿;check_doc_links --all
142 篇、无法判定 0;mem-budget / mem-numbers 已回填。

## 单位成本实测(U5 排期依据)
临时页 zzprobe 绑到"遥信量"叶子 → 只跑生成器 → 带页 @build 0/0 →
注册表出现 {5u, &ui_page_zzprobe_desc} → 回滚后 6 个产出文件 zzprobe
残留计数均 0、重编回基线。
**结论:新增一页 = 3 处人工(新 .c + 新 .h + 菜单树那一行加
 #page=<名>)+ 1 条生成器命令,手工编辑注册文件 0 处。**
据此修正报告原口径"8 处降到 1 处"——那个 1 只在"1 处对既有文件的修改"
意义下成立,页面自身 2 个文件本就要写,真实处数是 3。

## 文档同步与两处自我修正
§0.1 UI 行 B → B+;§6.1「内容页」「设计令牌」两行按实况回填;§6.5 U1/U3
状态更新;§6 顶部加"建议栏不可照抄"总警示(本轮四次证伪:ui_port.h 已有
同名宏、LV_COLOR_BLACK/WHITE 不存在且极性反了、27 页实为目标页数现存 7
个、生成器草图漏密码页非节点绑定)。
两处改口:① §6.5 U4 从我上一版**误写的 3/3 改回 2/3**——"golden 脚本早已
存在"不等于"已进自动门禁",两个 golden 脚本至今未挂 pre-commit,该缺口
单列为待办;② 原计划"分三笔提交"**作废**,U1 与 U3 改同一批文件、文件内
交叠,硬切会产生不可编译的中间态,故整波一笔。
2026-10-20 21:14:14 +08:00

243 lines
13 KiB
Python
Raw 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 -*-
"""check_font_charset.py - UI 字符集差集门禁(防"缺字显示白框",报告 §6.5 U4 残余)。
背景:
本工程唯一中文字体 `Drivers/BSP/UI/ui_font_cn_12.c` 是**子集**字体:只包含
`ui_cn_chars.txt` 列出的码位。生成链(font-regen skill 四步):
UI 源码 --extract_cn.py--> ui_cn_chars.txt --make_font.py--> ui_font_cn_12.c
`make_font.py` 的 `verify_coverage()` 只在**生成期**校验"txt ⊆ 字体 cmap";
若开发者改了 UI 字符串却忘了重跑 extract_cn / make_font,提交期**无任何门禁**
能拦住——缺字上机渲染为空白(白框),无日志无报错,只能靠肉眼截图发现。
本钩子把这条约定机械化(U4 三钩子之二,golden 已建、registration-check 见 §2.18)。
判定口径(两段链条,缺一环即白框):
A. 必需字符集(源码在用 ∪ KEEP_EXTRA 保留字) vs ui_cn_chars.txt
必需、txt 无 -> [GAP] FAIL(字体必然缺此字形 → 白框;保留字被剔同样 FAIL——
防未来 extract_cn 按"源码已不用"静默删掉已落盘记录的用字)
txt 有、非必需 -> [WARN] 不拦(未知冗余字形只费 Flash 不致白框;--strict 升级为
FAIL;KEEP_EXTRA 内的字不计入冗余——刻意保留 ≠ 未知冗余)
B. ui_cn_chars.txt vs ui_font_cn_12.c 的 cmap
txt 有、字体无 -> [GAP] FAIL(lv_font_conv 对缺字形/低码位会**静默丢字**)
A 通过(txt ⊇ 必需)+ B 通过(字体 ⊇ txt)⟹ 字体 ⊇ 源码在用与刻意保留的每个字。
刻意保留清单 KEEP_EXTRA(同构 check_registration.py 的 KNOWN_GAPS:存量 + 逐条理由):
extract_cn 是**静态扫描**,照不出运行期**已落盘数据**里的汉字——典型如
/log/selftest.log 的定长 64 B 记录(跨固件持久,自检页直接渲染 desc)。这类字
源码已不用、但删字形会让**历史审计记录**渲染空白且不可逆;收益(每字 ~200 B RO,
占 1 MB Flash 万分之几)与代价完全不对称(同 2026-10-15 内存优化簇"收益不抵风险"
整簇暂缓的裁定逻辑)。新增条目必须写理由与出处;确认相关落盘数据已不存在后方可
删条目(删条目后下次 font-regen 自然瘦身)。
⚠ font-regen 流程:extract_cn 重生成 txt 时**不会**带上保留字(源码已不用),须把
KEEP_EXTRA 的字并回 ui_cn_chars.txt 再跑 make_font.py,否则本钩子 A 段 FAIL 并
逐字给出理由——刻意设计(静默丢失 = 事故),不是误报。
单一规则源(关键设计):
本脚本**不自创**第二套解析规则——字符集提取复用 `tools/font_gen/extract_cn.py`
的 `SCAN` 清单与 `collect()` 状态机(跳过注释/字符常量、只取双引号串内非 ASCII),
cmap 覆盖判定复用 `tools/font_gen/make_font.py` 的 `verify_coverage()`(含稀疏
unicode_list 与补回字形的单码位 cmap 条目)。两套规则必然漂移,这正是本项目
反复在防的(见 pre-commit使用说明.md §5.5 教训)。
用法(仓库根目录):
& '.venv\\Scripts\\python.exe' scripts/check_font_charset.py
& '.venv\\Scripts\\python.exe' scripts/check_font_charset.py --strict
# 注入测试用覆盖(不改动仓库文件即可复现"缺字"场景):
# --chars <副本> 模拟字符集文件缺/多字
# --font <副本> 模拟字体 cmap 丢字形
退出码:
0 = 通过(必需字符——源码在用 ∪ KEEP_EXTRA——全部在字体里;未知冗余仅 WARN)
1 = 失败(存在白框方向的缺口:必需字不在 txt(含 KEEP_EXTRA 保留字被剔),或 txt 字不在字体;--strict 时未知冗余也算)
2 = 环境错误(字符集文件 / 字体文件 / 生成器脚本缺失)
创建日期:2026-10-15(U4 残余,报告 §6.5 / §7 交付物清单"字符集差集检查"行)
"""
import argparse
import importlib.util
import os
import re
import sys
# --- 仓库根(scripts/ 的上一级)---
REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
TOOLS_DIR = os.path.join(REPO_ROOT, "tools", "font_gen")
# --- 刻意保留的冗余字(同构 check_registration.py 的 KNOWN_GAPS:逐条理由 + 出处)---
# 这些字**源码已不用**,但 2026-09-14~09-20 窗口的固件把它们写进了 /log/selftest.log
# 定长 64 B 记录(跨固件持久,自检页渲染 desc)。删字形 = 历史故障行永久渲染空白,
# 而收益只有每字 ~200 B RO——不对称,故保留并登记(Lead 裁定 2026-10-15,task-6 D 案)。
# 钩子按"必需"对待:txt/字体里缺任一保留字 → FAIL(防下次 font-regen 静默再删)。
KEEP_EXTRA = {
"异": "9eb1bd63(2026-09-14) 中文自检描述 \"NAND异常/ADC异常/时钟芯片异常/通讯异常/"
"文件系统异常\" 曾落盘 /log/selftest.log;6f51c62a(2026-09-20 串口输出禁中文) "
"改 ASCII,但已落盘记录跨固件持久,删字形使历史故障行渲染空白",
"常": "同 \"异常\" 系记录(时钟芯片异常/文件系统异常 等,9eb1bd63 写入,落盘持久)",
"芯": "9eb1bd63 写入的 \"时钟芯片异常\" 落盘 /log/selftest.log(SD2506 时钟芯片故障"
"路径当时可触发,记录持久)",
"片": "同 \"时钟芯片异常\"(9eb1bd63 写入,落盘持久)",
}
def load_tool(name):
"""按文件路径加载 tools/font_gen/<name>.py(无包结构,importlib 直载)。
两个生成器模块级只有常量与函数定义(main 均有 __name__ 守卫),导入零副作用。
"""
path = os.path.join(TOOLS_DIR, name + ".py")
if not os.path.isfile(path):
print("[ENV-ERROR] 生成器缺失: %s" % path)
sys.exit(2)
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def fmt_char(c):
"""字符 -> `你(U+4F60)` 形态;GBK 控制台打不出的符号由 stdout replace 兜底。"""
return "%s(U+%04X)" % (c, ord(c))
def rel(path):
"""仓库相对路径;跨盘符(--font/--chars 指向临时目录)时退回原路径。"""
try:
return os.path.relpath(path, REPO_ROOT)
except ValueError:
return path
def fmt_chars(chars):
return " ".join(fmt_char(c) for c in sorted(chars))
def extract_source_chars(extract_cn):
"""复用 extract_cn 的 SCAN 清单 + collect() 状态机提取源码实际用到的字符集。"""
chars = set()
n = 0
for f in extract_cn.SCAN:
if not os.path.exists(f):
print(" skip (not found): %s" % os.path.relpath(f, REPO_ROOT))
continue
chars |= extract_cn.collect(open(f, encoding="utf-8-sig", errors="ignore").read())
n += 1
return chars, n
def check_font_coverage(make_font, font_path):
"""复用 make_font.verify_coverage 判定"字符集文件 ⊆ 字体 cmap"。
返回 None = 通过;返回 (缺字列表 | None, 原始报错) —— 缺字列表由报错里的
U+XXXX 码位反解而来(verify_coverage 只打印码位,这里补出字形便于定位)。
"""
try:
make_font.verify_coverage(font_path)
return None
except SystemExit as e:
msg = str(e)
codes = re.findall(r"U\+([0-9A-Fa-f]{4,6})", msg)
missing = [chr(int(x, 16)) for x in codes] if codes else None
return missing, msg
except OSError as e:
print("[ENV-ERROR] 读取字体失败: %s" % e)
sys.exit(2)
def main():
# Windows GBK 控制台打不出 ▶ 等符号:降级替换,避免 UnicodeEncodeError 崩溃
# (与 make_font.py 同款处理;pre-commit 经管道捕获时同理)
try:
sys.stdout.reconfigure(errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="UI 字符集差集门禁(源码 ↔ ui_cn_chars.txt ↔ ui_font_cn_12.c)")
ap.add_argument("--chars", default=None,
help="覆盖字符集文件路径(默认 ui_cn_chars.txt;注入测试用)")
ap.add_argument("--font", default=None,
help="覆盖字体 .c 路径(默认 Drivers/BSP/UI/ui_font_cn_12.c;注入测试用)")
ap.add_argument("--strict", action="store_true",
help="未知冗余字(txt 有、源码无、且不在 KEEP_EXTRA)也按 FAIL 处理(供审计/瘦身排期)")
args = ap.parse_args()
extract_cn = load_tool("extract_cn")
make_font = load_tool("make_font")
chars_path = args.chars or make_font.CHARS_FILE
font_path = args.font or make_font.OUT
for path, label in ((chars_path, "字符集文件"), (font_path, "字体文件")):
if not os.path.isfile(path):
print("[ENV-ERROR] %s缺失: %s" % (label, path))
return 2
# --- A 段:必需字符(源码在用 ∪ KEEP_EXTRA)vs 字符集文件 ---
src, nfiles = extract_source_chars(extract_cn)
txt = set(open(chars_path, encoding="utf-8").read().strip())
keep = set(KEEP_EXTRA)
missing_src = sorted(src - txt) # 白框方向 -> FAIL
missing_keep = sorted(keep - txt - src) # 保留字被静默剔除 -> FAIL(防历史落盘记录变白框)
extra_in_txt = sorted(txt - src - keep) # 未知冗余 -> WARN(--strict 升级)
kept_present = sorted((keep & txt) - src) # 保留字在位且源码不用 -> [KEEP] 信息行
# --- B 段:字符集文件 vs 字体 cmap(复用 verify_coverage,其事实源 = CHARS_FILE)---
make_font.CHARS_FILE = chars_path # 允许 --chars 注入;默认即仓库根 ui_cn_chars.txt
gaps = []
warns = []
keeps = []
keep_reasons = []
if missing_src:
gaps.append("[GAP] 字符集缺字 %d 个(UI 源码在用、不在 %s -> 字体必然无此字形,上机白框): %s"
% (len(missing_src), rel(chars_path), fmt_chars(missing_src)))
if missing_keep:
gaps.append("[GAP] 刻意保留字被剔除 %d 个(在 KEEP_EXTRA、不在 %s -> 历史落盘记录"
"(如 /log/selftest.log)将渲染空白,不可逆;把这些字并回字符集后重跑 make_font): %s"
% (len(missing_keep), rel(chars_path), fmt_chars(missing_keep)))
keep_reasons = [" KEEP_EXTRA 理由 %s: %s" % (fmt_char(c), KEEP_EXTRA[c])
for c in missing_keep]
if kept_present:
keeps.append("[KEEP] 刻意保留 %d 个(源码已不用但按 KEEP_EXTRA 保留,不计冗余;"
"理由见脚本头部与字典): %s" % (len(kept_present), fmt_chars(kept_present)))
if extra_in_txt:
line = ("[WARN] 字符集未知冗余 %d 个(在 txt、源码已不用且不在保留清单 -> 只费 Flash "
"不致白框;重跑 font-regen 可瘦身): %s" % (len(extra_in_txt), fmt_chars(extra_in_txt)))
if args.strict:
gaps.append(line.replace("[WARN]", "[GAP] (--strict)", 1))
else:
warns.append(line)
result = check_font_coverage(make_font, font_path)
if result is not None:
missing, msg = result
if missing:
gaps.append("[GAP] 字体缺字 %d 个(在字符集、不在 %s 的 cmap -> lv_font_conv 静默丢字,上机白框): %s"
% (len(missing), rel(font_path), fmt_chars(missing)))
else:
gaps.append("[GAP] 字体覆盖校验失败: %s" % msg)
# --- 输出 ---
print("== UI 字符集差集检查 ==")
print("扫描 UI 源码: %d 个文件 | 源码在用: %d 字 | 保留清单: %d 字 | 字符集: %d 字"
% (nfiles, len(src), len(keep), len(txt)))
for k in keeps:
print(k)
for w in warns:
print(w)
for g in gaps:
print(g)
for kr in keep_reasons:
print(kr)
if gaps:
print("修复:按 font-regen skill 四步重跑(extract_cn.py -> 并回 KEEP_EXTRA -> make_font.py -> diff 检查),"
"ui_cn_chars.txt 与 ui_font_cn_12.c 随代码一起提交")
print("结果: FAIL(%d 项缺口,%d 个 WARN)" % (len(gaps), len(warns)))
return 1
print("结果: PASS(必需字符(源码在用 ∪ 保留清单)全部在字体子集内%s%s)"
% (";%d 个保留字在位" % len(kept_present) if kept_present else "",
";%d 个未知冗余仅 WARN" % len(extra_in_txt) if warns else ""))
return 0
if __name__ == "__main__":
sys.exit(main())