Files
STM32F4-Base/tools/font_gen/make_font.py
T
2026-09-10 11:36:33 +08:00

298 lines
14 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 -*-
"""生成中文子集位图字体 ui_font_cn_12.c(LVGL 格式,输出到 Drivers/BSP/UI/)。
前置依赖:
npm i -g lv_font_conv # 离线 TTF/OTF -> LVGL 字体 转换器
pip install fonttools # 仅当字体源是 .ttc 集合时需要(用于抽取单字重)
用法:
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 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(lv_font_conv 不支持 .ttc,需先抽取 face0)
ttc = r'C:\Windows\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: 未找到 C:\\Windows\\Fonts\\simsun.ttc,请用 --font 指定字体 ttf/otf 路径')
# ---------------------------------------------------------------------------
# 箭头字形合并
# 宋体(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 的稀疏 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)
print('OK %d bytes -> %s' % (os.path.getsize(OUT), OUT))
if __name__ == '__main__':
main()