Files
story/firmware/echoear-platform-agent/tools/generate_call_status_font.py
T

260 lines
15 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Extract the fixed call-status glyphs into the GFX compatibility font ABI.
No font converter, network, LVGL headers, or rasterizer is used. The source is
the checked-in uncompressed 1bpp font; Pillow is needed only with --preview.
Run --check to verify the committed generated files without modifying them.
"""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "components/application/platform/src/espressif/lvgl_source/echoear_font.c"
SOURCE_SHA256 = "abfffdf0cd809cf2e3cd9425988b8945786fb5166bf4c5e91d2b6e1fb3839f74"
FONT_C = ROOT / "components/application/platform/src/espressif/lvgl_source/echoear_call_status_font.c"
FONT_H = ROOT / "components/application/platform/src/espressif/lvgl_source/echoear_call_status_font.h"
HOST_TEST = ROOT / "tools/test_call_status_font.c"
CHARACTERS = "?待机正在连接通话中结束失败断"
STATUSES = ["待机", "正在连接", "通话中", "正在结束", "通话结束", "连接失败", "连接中断"]
SCALE = 2
def array_body(source: str, name: str) -> str:
match = re.search(r"\b" + re.escape(name) + r"\[\]\s*=\s*\{(.*?)\};", source, re.S)
if not match:
raise ValueError(f"missing source array {name}")
return match.group(1)
def numbers(body: str) -> list[int]:
body = re.sub(r"/\*.*?\*/", "", body, flags=re.S)
return [int(value, 0) for value in re.findall(r"0[xX][0-9a-fA-F]+|\b\d+\b", body)]
def fields(body: str) -> dict[str, str]:
return dict(re.findall(r"\.(\w+)\s*=\s*([^,}\s]+)", body))
def bitmap_pixels(data: bytes, width: int, height: int) -> list[int]:
return [(data[index // 8] >> (7 - index % 8)) & 1 for index in range(width * height)]
def pack_pixels(pixels: list[int]) -> bytes:
packed = bytearray((len(pixels) + 7) // 8)
for index, bit in enumerate(pixels):
packed[index // 8] |= bit << (7 - index % 8)
return bytes(packed)
def pixel_hash(pixels: list[int]) -> int:
value = 2166136261
for bit in pixels:
value = ((value ^ (255 if bit else 0)) * 16777619) & 0xFFFFFFFF
return value
def extract() -> tuple[str, list[dict], int, int]:
raw = SOURCE.read_bytes()
digest = hashlib.sha256(raw).hexdigest()
if digest != SOURCE_SHA256:
raise ValueError(f"source font changed: {digest}; review before updating the pinned hash")
source = raw.decode("utf-8").replace("\r\n", "\n")
original_header = re.match(r"/\*.*?\*/", source, re.S).group(0)
if not re.search(r"\.bpp\s*=\s*1\s*,", source) or not re.search(r"\.bitmap_format\s*=\s*0\s*,", source):
raise ValueError("expected uncompressed 1bpp source font")
bitmap_body = array_body(source, "glyph_bitmap")
bitmap = bytes(numbers(bitmap_body))
descriptors = [{key: int(value, 0) for key, value in fields(body).items()}
for body in re.findall(r"\{([^{}]*)\}", array_body(source, "glyph_dsc"))]
cmaps = [fields(body) for body in re.findall(r"\{([^{}]*)\}", array_body(source, "cmaps"))]
tables: dict[str, list[int]] = {}
def table(name: str) -> list[int]:
if name not in tables:
tables[name] = numbers(array_body(source, name))
return tables[name]
def glyph_id(codepoint: int) -> int:
for cmap in cmaps:
offset = codepoint - int(cmap["range_start"])
if not 0 <= offset < int(cmap["range_length"]):
continue
start = int(cmap["glyph_id_start"])
kind = cmap["type"].removeprefix("LV_FONT_FMT_TXT_CMAP_")
if kind == "FORMAT0_TINY":
return start + offset
if kind == "FORMAT0_FULL":
return start + table(cmap["glyph_id_ofs_list"])[offset]
offsets = table(cmap["unicode_list"])
if offset not in offsets:
continue
index = offsets.index(offset)
if kind == "SPARSE_TINY":
return start + index
if kind == "SPARSE_FULL":
return start + table(cmap["glyph_id_ofs_list"])[index]
raise ValueError(f"unsupported source cmap {kind}")
raise ValueError(f"missing source glyph U+{codepoint:04X}")
# Independently cross-check the cmap result against converter bitmap comments.
labelled_offsets = {}
cursor = 0
for match in re.finditer(r"/\* U\+([0-9A-Fa-f]+).*?\*/(.*?)(?=/\* U\+|\Z)", bitmap_body, re.S):
labelled_offsets[int(match.group(1), 16)] = cursor
cursor += len(numbers(match.group(2)))
assert cursor == len(bitmap)
glyphs = []
for char in sorted(set(CHARACTERS)):
codepoint = ord(char)
gid = glyph_id(codepoint)
original = descriptors[gid]
assert original["bitmap_index"] == labelled_offsets[codepoint], f"cmap/comment mismatch for {char}"
width, height = original["box_w"], original["box_h"]
size = (width * height + 7) // 8
assert 0 < size and original["bitmap_index"] + size <= len(bitmap)
original_pixels = bitmap_pixels(bitmap[original["bitmap_index"]:][:size], width, height)
pixels = [original_pixels[(y // SCALE) * width + x // SCALE]
for y in range(height * SCALE) for x in range(width * SCALE)]
packed = pack_pixels(pixels)
assert bitmap_pixels(packed, width * SCALE, height * SCALE) == pixels
assert sum(pixels) == sum(original_pixels) * SCALE * SCALE > 0
glyphs.append({"char": char, "codepoint": codepoint, "source_glyph_id": gid,
"width": width * SCALE, "height": height * SCALE,
"ofs_x": original["ofs_x"] * SCALE, "ofs_y": original["ofs_y"] * SCALE,
# GFX reads adv_w as 1/256 px, unlike source LVGL's 1/16 px.
"advance": original["adv_w"] * SCALE * 16,
"bitmap": packed, "pixels": pixels, "pixel_hash": pixel_hash(pixels)})
line_height = int(re.search(r"\.line_height\s*=\s*(\d+)", source).group(1)) * SCALE
baseline = int(re.search(r"\.base_line\s*=\s*(\d+)", source).group(1)) * SCALE
assert line_height <= 42
for glyph in glyphs:
top = line_height - baseline - glyph["height"] - glyph["ofs_y"]
assert 0 <= top and top + glyph["height"] <= line_height
by_char = {glyph["char"]: glyph for glyph in glyphs}
for status in STATUSES:
assert len(status) <= 4
assert sum(max(by_char[c]["advance"] >> 8, by_char[c]["width"] + by_char[c]["ofs_x"]) for c in status) <= 240
return original_header, glyphs, line_height, baseline
def generated_files(original_header: str, glyphs: list[dict], line_height: int, baseline: int) -> dict[Path, str]:
comment = ("/* Generated by tools/generate_call_status_font.py; do not hand edit.\n"
f" * Source SHA256: {SOURCE_SHA256}\n"
" * 2x nearest-neighbor glyphs; packed 1bpp, GFX 1/256-pixel advance.\n"
" * The original source font's leading notice is preserved below.\n */\n")
c = [comment, original_header, '\n#ifdef LVGL_VERSION_MAJOR\n#error "Build this font in its own GFX-only translation unit"\n#endif\n#include "widget/gfx_font_lvgl.h"\n',
'#if LVGL_VERSION_MAJOR != 8 || LV_FONT_FMT_TXT_LARGE != 1\n#error "Unexpected GFX font compatibility ABI"\n#endif\n_Static_assert(sizeof(lv_font_fmt_txt_glyph_dsc_t) == 16, "GFX LARGE glyph ABI required");\n',
'static const uint8_t status_bitmap[] = {']
cursor = 0
for glyph in glyphs:
glyph["bitmap_index"] = cursor
c.append(f' /* U+{glyph["codepoint"]:04X} {glyph["char"]} */')
values = list(glyph["bitmap"])
for start in range(0, len(values), 24):
c.append(' ' + ','.join(str(byte) for byte in values[start:start + 24]) + ',')
cursor += len(values)
c += ['};\n', 'static const lv_font_fmt_txt_glyph_dsc_t status_glyphs[] = {', ' {0}, /* glyph zero is reserved */']
for g in glyphs:
c.append(f' {{{g["bitmap_index"]},{g["advance"]},{g["width"]},{g["height"]},{g["ofs_x"]},{g["ofs_y"]}}}, /* {g["char"]} */')
c += ['};\n', '#define STATUS_CMAP(cp, id) { .range_start=(cp), .range_length=1, .glyph_id_start=(id), .unicode_list=NULL, .glyph_id_ofs_list=NULL, .list_length=0, .type=LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY }',
'static const lv_font_fmt_txt_cmap_t status_cmaps[] = {']
c += [f' STATUS_CMAP(0x{g["codepoint"]:04X}, {index}),' for index, g in enumerate(glyphs, 1)]
c += ['};', '#undef STATUS_CMAP\n',
'static const lv_font_fmt_txt_dsc_t status_font_dsc = {',
' .glyph_bitmap=status_bitmap, .glyph_dsc=status_glyphs, .cmaps=status_cmaps,',
f' .kern_dsc=NULL, .kern_scale=0, .cmap_num={len(glyphs)}, .bpp=1,',
' .kern_classes=0, .bitmap_format=0, .cache=NULL', '};\n',
'static const lv_font_t status_font = {',
' .get_glyph_dsc=lv_font_get_glyph_dsc_fmt_txt, .get_glyph_bitmap=lv_font_get_bitmap_fmt_txt,',
f' .line_height={line_height}, .base_line={baseline}, .subpx=LV_FONT_SUBPX_NONE, .static_bitmap=1,',
' .underline_position=-6, .underline_thickness=2, .dsc=&status_font_dsc, .fallback=NULL, .user_data=NULL',
'};\n', 'const void *echoear_call_status_font(void) { return &status_font; }\n']
header = comment + '#pragma once\n#ifdef __cplusplus\nextern "C" {\n#endif\n/* GFX compatibility font; deliberately exposes no LVGL types. */\nconst void *echoear_call_status_font(void);\n#ifdef __cplusplus\n}\n#endif\n'
host = [comment, '#include <assert.h>\n#include <stdint.h>\n#include <stdio.h>\n#include "widget/gfx_font_internal.h"\n#include "echoear_call_status_font.h"\n',
'static const struct { uint32_t cp; int w, h, x, y, advance; uint32_t hash; } expected[] = {']
for g in glyphs:
host.append(f' {{0x{g["codepoint"]:04X},{g["width"]},{g["height"]},{g["ofs_x"]},{g["ofs_y"]},{g["advance"]},0x{g["pixel_hash"]:08X}}},')
host += ['};\n', 'int main(void) {', ' const void *font=echoear_call_status_font(); assert(gfx_is_lvgl_font(font));',
' gfx_font_ctx_t ctx={0}; gfx_font_lv_init_context(&ctx,font);',
f' assert(ctx.get_line_height(&ctx)=={line_height} && ctx.get_base_line(&ctx)=={baseline});',
' for (unsigned i=0;i<sizeof(expected)/sizeof(expected[0]);i++) {',
' gfx_glyph_dsc_t g={0}; assert(ctx.get_glyph_dsc(&ctx,&g,expected[i].cp,0));',
' assert(g.box_w==expected[i].w && g.box_h==expected[i].h);',
' assert(g.ofs_x==expected[i].x && g.ofs_y==expected[i].y && g.adv_w==(uint32_t)expected[i].advance);',
f' assert(g.bitmap_index+(g.box_w*g.box_h+7)/8 <= {cursor});',
' int top=ctx.adjust_baseline_offset(&ctx,&g); assert(top>=0 && top+g.box_h<=ctx.get_line_height(&ctx));',
' assert(ctx.get_advance_width(&ctx,&g)==32 || expected[i].cp==0x3F);',
' const uint8_t *bits=ctx.get_glyph_bitmap(&ctx,expected[i].cp,&g); assert(bits); uint32_t hash=2166136261u;',
' for(int y=0;y<g.box_h;y++) for(int x=0;x<g.box_w;x++) hash=(hash^ctx.get_pixel_value(&ctx,bits,x,y,g.box_w))*16777619u;',
' assert(hash==expected[i].hash);', ' }',
' /* Exhaustive Unicode lookup also exercises every cmap range-end boundary. */',
' for(uint32_t cp=0;cp<=0x10FFFF;cp++) {',
' int present=0; for(unsigned i=0;i<sizeof(expected)/sizeof(expected[0]);i++) present |= expected[i].cp==cp;',
' gfx_glyph_dsc_t g={0}; assert(ctx.get_glyph_dsc(&ctx,&g,cp,0)==(present!=0));', ' }',
' puts("GFX call-status font: ABI, coverage, exact pixels, metrics and Unicode boundaries PASS");', ' return 0;', '}\n']
return {FONT_C: '\n'.join(c), FONT_H: header, HOST_TEST: '\n'.join(host)}
def preview(path: Path, glyphs: list[dict], line_height: int, baseline: int) -> None:
from PIL import Image, ImageDraw
by_char = {g["char"]: g for g in glyphs}
panel = 360 # CONFIG_EXAMPLE_LCD_INTERFACE_QSPI: HW_LCD_H_RES / V_RES.
image = Image.new('RGB', (panel * 3, panel * 3), '#24272b')
draw = ImageDraw.Draw(image)
labels = STATUSES + ['?', '正在连接']
for index, label in enumerate(labels):
left, top = index % 3 * panel, index // 3 * panel
draw.ellipse((left, top, left + panel - 1, top + panel - 1), fill='#080b10', outline='#64707b')
draw.rectangle((left + 60, top + 58, left + 299, top + 99), outline='#28313b')
width = sum(max(by_char[c]['advance'] >> 8, by_char[c]['width'] + by_char[c]['ofs_x']) for c in label)
x, line_top = left + (panel - width) // 2, top + 58 + (42 - line_height) // 2
for char in label:
glyph = by_char[char]
y = line_top + line_height - baseline - glyph['height'] - glyph['ofs_y']
for py in range(glyph['height']):
for px in range(glyph['width']):
if glyph['pixels'][py * glyph['width'] + px]:
image.putpixel((x + glyph['ofs_x'] + px, y + py), (255, 255, 255))
x += max(glyph['advance'] >> 8, glyph['width'] + glyph['ofs_x'])
draw.text((left + 70, top + 286), f'status {index + 1} / 360x360 / 1:1 px', fill='#9faab6')
draw.text((left + 61, top + 306), 'GLYPH PREVIEW - NOT A SCREEN PHOTO', fill='#9faab6')
path.parent.mkdir(parents=True, exist_ok=True)
image.save(path)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true')
parser.add_argument('--preview', type=Path)
parser.add_argument('--report', type=Path)
args = parser.parse_args()
notice, glyphs, line_height, baseline = extract()
outputs = generated_files(notice, glyphs, line_height, baseline)
for path, text in outputs.items():
if args.check:
assert path.read_text(encoding='utf-8') == text, f'generated file differs: {path}'
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding='utf-8', newline='\n')
if args.preview:
preview(args.preview, glyphs, line_height, baseline)
report = {'source': str(SOURCE.relative_to(ROOT)), 'source_sha256': SOURCE_SHA256,
'glyph_count': len(glyphs), 'characters': ''.join(g['char'] for g in glyphs),
'statuses': STATUSES, 'bitmap_bytes': sum(len(g['bitmap']) for g in glyphs),
'line_height': line_height, 'base_line': baseline, 'scale': SCALE,
'files': {str(path.relative_to(ROOT)): {'bytes': path.stat().st_size, 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} for path in outputs},
'glyphs': [{key: value for key, value in g.items() if key not in ('pixels', 'bitmap')} for g in glyphs]}
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()