#!/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 \n#include \n#include \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=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 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()