260 lines
15 KiB
Python
260 lines
15 KiB
Python
#!/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()
|