From 96ca1f8ed54354a6b7bdff4f3680e6cb5c48c70e Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Fri, 7 Aug 2026 13:10:32 -0700 Subject: [PATCH] ui: load fonts on the fly (#38567) --- openpilot/selfdrive/assets/fonts/process.py | 136 -------------------- openpilot/selfdrive/ui/SConscript | 17 +-- openpilot/system/ui/lib/application.py | 33 +++-- 3 files changed, 23 insertions(+), 163 deletions(-) delete mode 100755 openpilot/selfdrive/assets/fonts/process.py diff --git a/openpilot/selfdrive/assets/fonts/process.py b/openpilot/selfdrive/assets/fonts/process.py deleted file mode 100755 index 62ae6ec684..0000000000 --- a/openpilot/selfdrive/assets/fonts/process.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import json - -import pyray as rl - -FONT_DIR = Path(__file__).resolve().parent -SELFDRIVE_DIR = FONT_DIR.parents[1] -TRANSLATIONS_DIR = SELFDRIVE_DIR / "ui" / "translations" -LANGUAGES_FILE = TRANSLATIONS_DIR / "languages.json" - -GLYPH_PADDING = 6 -EXTRA_CHARS = "–‑✓×°§•X⚙✕◀▶✔⌫⇧␣○●↳çêüñ–‑✓×°§•€£¥" -UNIFONT_LANGUAGES = {"th", "zh-CHT", "zh-CHS", "ko", "ja"} - - -def _languages(): - if not LANGUAGES_FILE.exists(): - return {} - with LANGUAGES_FILE.open(encoding="utf-8") as f: - return json.load(f) - - -def _char_sets(): - base = set(map(chr, range(32, 127))) | set(EXTRA_CHARS) - unifont = set(base) - - for language, code in _languages().items(): - unifont.update(language) - po_path = TRANSLATIONS_DIR / f"app_{code}.po" - try: - chars = set(po_path.read_text(encoding="utf-8")) - except FileNotFoundError: - continue - (unifont if code in UNIFONT_LANGUAGES else base).update(chars) - - return tuple(sorted(ord(c) for c in base)), tuple(sorted(ord(c) for c in unifont)) - - -def _glyph_metrics(glyphs, rects, glyph_count: int): - entries = [] - min_offset_y, max_extent = None, 0 - for idx in range(glyph_count): - glyph = glyphs[idx] - rect = rects[idx] - width = int(round(rect.width)) - height = int(round(rect.height)) - offset_y = int(round(glyph.offsetY)) - min_offset_y = offset_y if min_offset_y is None else min(min_offset_y, offset_y) - max_extent = max(max_extent, offset_y + height) - entries.append({ - "id": glyph.value, - "x": int(round(rect.x)), - "y": int(round(rect.y)), - "width": width, - "height": height, - "xoffset": int(round(glyph.offsetX)), - "yoffset": offset_y, - "xadvance": int(round(glyph.advanceX)), - }) - - if min_offset_y is None: - raise RuntimeError("No glyphs were generated") - - line_height = int(round(max_extent - min_offset_y)) - base = int(round(max_extent)) - return entries, line_height, base - - -def _write_bmfont(path: Path, font_size: int, face: str, atlas_name: str, line_height: int, base: int, atlas_size, entries): - # TODO: why doesn't raylib calculate these metrics correctly? - if line_height != font_size: - print("using font size for line height", atlas_name) - line_height = font_size - lines = [ - f"info face=\"{face}\" size=-{font_size} bold=0 italic=0 charset=\"\" unicode=1 stretchH=100 smooth=0 aa=1 padding=0,0,0,0 spacing=0,0 outline=0", - f"common lineHeight={line_height} base={base} scaleW={atlas_size[0]} scaleH={atlas_size[1]} pages=1 packed=0 alphaChnl=0 redChnl=4 greenChnl=4 blueChnl=4", - f"page id=0 file=\"{atlas_name}\"", - f"chars count={len(entries)}", - ] - for entry in entries: - lines.append( - ("char id={id:<4} x={x:<5} y={y:<5} width={width:<5} height={height:<5} " + - "xoffset={xoffset:<5} yoffset={yoffset:<5} xadvance={xadvance:<5} page=0 chnl=15").format(**entry) - ) - path.write_text("\n".join(lines) + "\n") - - -def _process_font(font_path: Path, codepoints: tuple[int, ...]): - print(f"Processing {font_path.name}...") - - font_size = { - "unifont.otf": 16, # unifont is only 16x8 or 16x16 pixels per glyph - }.get(font_path.name, 200) - - data = font_path.read_bytes() - file_buf = rl.ffi.new("unsigned char[]", data) - cp_buffer = rl.ffi.new("int[]", codepoints) - cp_ptr = rl.ffi.cast("int *", cp_buffer) - glyph_count = rl.ffi.new("int *", len(codepoints)) - glyphs = rl.load_font_data( - rl.ffi.cast("unsigned char *", file_buf), len(data), font_size, cp_ptr, len(codepoints), - rl.FontType.FONT_DEFAULT, glyph_count - ) - if glyphs == rl.ffi.NULL: - raise RuntimeError("raylib failed to load font data") - - rects_ptr = rl.ffi.new("Rectangle **") - image = rl.gen_image_font_atlas(glyphs, rects_ptr, glyph_count[0], font_size, GLYPH_PADDING, 0) - if image.width == 0 or image.height == 0: - raise RuntimeError("raylib returned an empty atlas") - - rects = rects_ptr[0] - atlas_name = f"{font_path.stem}.png" - atlas_path = FONT_DIR / atlas_name - entries, line_height, base = _glyph_metrics(glyphs, rects, glyph_count[0]) - - if not rl.export_image(image, atlas_path.as_posix()): - raise RuntimeError("Failed to export atlas image") - - _write_bmfont(FONT_DIR / f"{font_path.stem}.fnt", font_size, font_path.stem, atlas_name, line_height, base, (image.width, image.height), entries) - - -def main(): - base_cp, unifont_cp = _char_sets() - fonts = sorted(FONT_DIR.glob("*.ttf")) + sorted(FONT_DIR.glob("*.otf")) - for font in fonts: - if "emoji" in font.name.lower(): - continue - glyphs = unifont_cp if font.stem.lower().startswith("unifont") else base_cp - _process_font(font, glyphs) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/openpilot/selfdrive/ui/SConscript b/openpilot/selfdrive/ui/SConscript index d187b4ca3c..2b02759687 100644 --- a/openpilot/selfdrive/ui/SConscript +++ b/openpilot/selfdrive/ui/SConscript @@ -1,23 +1,8 @@ -from pathlib import Path import importlib.util +from pathlib import Path Import('env', 'arch', 'common') -# build the fonts -generator = File("#openpilot/selfdrive/assets/fonts/process.py") -source_files = Glob("#openpilot/selfdrive/assets/fonts/*.ttf") + Glob("#openpilot/selfdrive/assets/fonts/*.otf") -output_files = [ - (f"#{Path(f.path).with_suffix('.fnt')}", f"#{Path(f.path).with_suffix('.png')}") - for f in source_files - if "NotoColor" not in f.name -] -env.Command( - target=output_files, - source=[generator, source_files], - action=f"python3 {generator}", -) - - if GetOption('extras') and arch == "larch64": # build installers raylib_dir = Path(importlib.util.find_spec("raylib").submodule_search_locations[0]) / "install" diff --git a/openpilot/system/ui/lib/application.py b/openpilot/system/ui/lib/application.py index 3fa47ebd20..e294b02eaf 100644 --- a/openpilot/system/ui/lib/application.py +++ b/openpilot/system/ui/lib/application.py @@ -19,7 +19,7 @@ from typing import NamedTuple from importlib.resources import as_file, files from openpilot.common.swaglog import cloudlog from openpilot.common.hardware import HARDWARE, PC -from openpilot.system.ui.lib.multilang import multilang +from openpilot.system.ui.lib.multilang import TRANSLATIONS_DIR, UNIFONT_LANGUAGES, multilang from openpilot.common.realtime import Ratekeeper _DEFAULT_FPS = int(os.getenv("FPS", {'tizi': 20}.get(HARDWARE.get_device_type(), 60))) @@ -92,19 +92,20 @@ FONT_SCALE = 1.242 if BIG_UI else 1.16 ASSETS_DIR = files("openpilot.selfdrive").joinpath("assets") FONT_DIR = ASSETS_DIR.joinpath("fonts") +EXTRA_FONT_CHARS = "–‑✓×°§•X⚙✕◀▶✔⌫⇧␣○●↳çêüñ–‑✓×°§•€£¥" class FontWeight(StrEnum): - NORMAL = "Inter-Regular.fnt" if BIG_UI else "Inter-Medium.fnt" - MEDIUM = "Inter-Medium.fnt" - BOLD = "Inter-Bold.fnt" - SEMI_BOLD = "Inter-SemiBold.fnt" - UNIFONT = "unifont.fnt" + NORMAL = "Inter-Regular.ttf" if BIG_UI else "Inter-Medium.ttf" + MEDIUM = "Inter-Medium.ttf" + BOLD = "Inter-Bold.ttf" + SEMI_BOLD = "Inter-SemiBold.ttf" + UNIFONT = "unifont.otf" # Small UI fonts - DISPLAY_REGULAR = "Inter-Regular.fnt" - ROMAN = "Inter-Regular.fnt" - DISPLAY = "Inter-Bold.fnt" + DISPLAY_REGULAR = "Inter-Regular.ttf" + ROMAN = "Inter-Regular.ttf" + DISPLAY = "Inter-Bold.ttf" def font_fallback(font: rl.Font) -> rl.Font: @@ -684,10 +685,20 @@ class GuiApplication: return self._height def _load_fonts(self): + base_chars = set(map(chr, range(32, 127))) | set(EXTRA_FONT_CHARS) + unifont_chars = set(base_chars) + for language, code in multilang.languages.items(): + unifont_chars.update(language) + chars = set(TRANSLATIONS_DIR.joinpath(f"app_{code}.po").read_text(encoding="utf-8")) + (unifont_chars if code in UNIFONT_LANGUAGES else base_chars).update(chars) + for font_weight_file in FontWeight: with as_file(FONT_DIR) as fspath: - fnt_path = fspath / font_weight_file - font = rl.load_font(fnt_path.as_posix()) + unifont = font_weight_file == FontWeight.UNIFONT + codepoints = sorted(map(ord, unifont_chars if unifont else base_chars)) + codepoint_buffer = rl.ffi.new("int[]", codepoints) + font = rl.load_font_ex((fspath / font_weight_file).as_posix(), 16 if unifont else 200, + rl.ffi.cast("int *", codepoint_buffer), len(codepoints)) if font_weight_file != FontWeight.UNIFONT: rl.gen_texture_mipmaps(font.texture) rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR)