#!/usr/bin/env python3 # -*- coding: utf-8 -*- """check_font_charset.py - UI 字符集差集门禁(防"缺字显示白框",原报告 §6.5 的 UI 字符集与像素回归门禁残余项)。 背景: 本工程唯一中文字体 `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,提交期**无任何门禁** 能拦住——缺字上机渲染为空白(白框),无日志无报错,只能靠肉眼截图发现。 本钩子把这条约定机械化(UI 字符集与像素回归门禁三钩子之二,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(UI 字符集与像素回归门禁残余项,报告 §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 写入,落盘持久)", } # 2026-10-25 道E task-15 补充:**成组保留**的历史落盘记录用字(35 字)。 # 发现方式:本钩子的「未知冗余」WARN 清单(txt 有、源码无、且不在 KEEP_EXTRA)实测正好 # 35 字——全部是 2026-09 窗口固件写进 /log/selftest.log(与 /log/run.log)定长 64 B 记录 # 里的中文描述用字。那批源码文案后来被改写(串口侧改 ASCII),**逐字出处已不可靠复原**: # 能确认来源是落盘记录用字(不是美术字、也不是手工修 cmap),但指不到「这个字出自哪条 # commit」。两难取其下——字形不可再生(删了就永久白框)而保留成本极低(35 字 ≈ 1 KB RO) # → 整组保留并在此**点名成组**,而不是给每个字编一条看起来可信的理由。 # 删除前置条件:确认设备上已无该窗口的落盘记录(或已接受历史记录部分渲染白框)。 # ⚠ 本组真正的价值是把一条路径钉死:**「按 extract_cn 输出重写 ui_cn_chars.txt」= 洗字形** # (extract 只认当前源码字面量,实测 144 字,而 txt 当时 183 字(2026-10-28 实测已 218 字))——谁按它的输出覆盖 # txt,就会把这 35 字连同上面 4 字一起洗掉,且只在渲染历史日志时才暴露、无日志无报错。 # 成因与判据详述 → docs/门禁说明/font-charset.md §6.1 _KEEP_HIST_SELFTEST = ("历史落盘记录用字(2026-09 窗口固件写入 /log/selftest.log 与" " /log/run.log 的定长 64 B 中文描述;源码文案已改写、逐字出处不可靠" " 复原,故整组保留——删任一即让对应历史行永久渲染空白且不可逆;" " 成因与删除前置条件见 docs/门禁说明/font-charset.md §6.1)") KEEP_EXTRA.update({c: _KEEP_HIST_SELFTEST for c in "一位公分合外已年度延态总接显最月未求温状电示秒请跳载近道部配重长间闸障"}) def load_tool(name): """按文件路径加载 tools/font_gen/.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())