#!/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 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 已存在,跳过合并') 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(单码位映射) 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 }' % ( ARROW_CP_DEC, glyph_count) src = src[:end_cm] + cmap + src[end_cm:] # 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 re.search(r'\.range_start\s*=\s*%d\s*,' % cp, src): print('U+%04X %r 已存在,跳过合并' % (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:] 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:] 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())) 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()