diff --git a/selfdrive/assets/fonts/OpFont-Bold.otf b/selfdrive/assets/fonts/OpFont-Bold.otf new file mode 100644 index 000000000..5a9a53480 Binary files /dev/null and b/selfdrive/assets/fonts/OpFont-Bold.otf differ diff --git a/selfdrive/assets/fonts/OpFont-Medium.otf b/selfdrive/assets/fonts/OpFont-Medium.otf new file mode 100644 index 000000000..3b7be21c2 Binary files /dev/null and b/selfdrive/assets/fonts/OpFont-Medium.otf differ diff --git a/selfdrive/assets/fonts/OpFont-Regular.otf b/selfdrive/assets/fonts/OpFont-Regular.otf new file mode 100644 index 000000000..a67cf65f8 Binary files /dev/null and b/selfdrive/assets/fonts/OpFont-Regular.otf differ diff --git a/selfdrive/assets/fonts/OpFont-SemiBold.otf b/selfdrive/assets/fonts/OpFont-SemiBold.otf new file mode 100644 index 000000000..39953f2bd Binary files /dev/null and b/selfdrive/assets/fonts/OpFont-SemiBold.otf differ diff --git a/selfdrive/assets/fonts/process.py b/selfdrive/assets/fonts/process.py index e6783fd1b..7825b0667 100755 --- a/selfdrive/assets/fonts/process.py +++ b/selfdrive/assets/fonts/process.py @@ -11,7 +11,7 @@ LANGUAGES_FILE = TRANSLATIONS_DIR / "languages.json" GLYPH_PADDING = 6 EXTRA_CHARS = "–‑✓×°§•X⚙✕◀▶✔⌫⇧␣○●↳çêüñ–‑✓×°§•€£¥" -UNIFONT_LANGUAGES = {"ar", "th", "zh-CHT", "zh-CHS", "ko", "ja"} +UNIFONT_LANGUAGES = {"th", "zh-CHT", "zh-CHS", "ko", "ja"} def _languages(): @@ -23,19 +23,25 @@ def _languages(): def _char_sets(): base = set(map(chr, range(32, 127))) | set(EXTRA_CHARS) - unifont = set(base) + labels = set(base) + per_lang: dict[str, tuple[int, ...]] = {} for language, code in _languages().items(): - unifont.update(language) - for prefix in ("app_", "dragonpilot_"): - po_path = TRANSLATIONS_DIR / f"{prefix}{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) + labels.update(language) + po_path = TRANSLATIONS_DIR / f"app_{code}.po" + try: + chars = set(po_path.read_text(encoding="utf-8")) + except FileNotFoundError: + continue + if code in UNIFONT_LANGUAGES: + lang_chars = set(base) | chars + per_lang[code] = tuple(sorted(ord(c) for c in lang_chars)) + else: + base.update(chars) - return tuple(sorted(ord(c) for c in base)), tuple(sorted(ord(c) for c in unifont)) + base_cp = tuple(sorted(ord(c) for c in base)) + labels_cp = tuple(sorted(ord(c) for c in labels)) + return base_cp, labels_cp, per_lang def _glyph_metrics(glyphs, rects, codepoints): @@ -87,12 +93,10 @@ def _write_bmfont(path: Path, font_size: int, face: str, atlas_name: str, line_h 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) +def _process_font(font_path: Path, codepoints: tuple[int, ...], output_name: str | None = None): + stem = output_name or font_path.stem + font_size = 48 if font_path.stem.lower().startswith("opfont") else 200 + print(f"Processing {font_path.name} -> {stem} ({len(codepoints)} glyphs @ {font_size}px)...") data = font_path.read_bytes() file_buf = rl.ffi.new("unsigned char[]", data) @@ -108,24 +112,42 @@ def _process_font(font_path: Path, codepoints: tuple[int, ...]): raise RuntimeError("raylib returned an empty atlas") rects = rects_ptr[0] - atlas_name = f"{font_path.stem}.png" + atlas_name = f"{stem}.png" atlas_path = FONT_DIR / atlas_name entries, line_height, base = _glyph_metrics(glyphs, rects, codepoints) 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) + _write_bmfont(FONT_DIR / f"{stem}.fnt", font_size, stem, atlas_name, line_height, base, (image.width, image.height), entries) def main(): - base_cp, unifont_cp = _char_sets() + base_cp, labels_cp, per_lang = _char_sets() fonts = sorted(FONT_DIR.glob("*.ttf")) + sorted(FONT_DIR.glob("*.otf")) + opfonts: list[Path] = [] + for font in fonts: - if "emoji" in font.name.lower(): + if "emoji" in font.name.lower() or font.name == "unifont.otf": continue - glyphs = unifont_cp if font.stem.lower().startswith("unifont") else base_cp - _process_font(font, glyphs) + if font.stem.lower().startswith("opfont"): + opfonts.append(font) + continue + _process_font(font, base_cp) + + if not opfonts: + raise RuntimeError("OpFont not found (expected OpFont-*.otf in fonts dir)") + + for opfont_path in opfonts: + weight = opfont_path.stem # e.g. "OpFont-Regular" + + # Labels atlas: language display names + ASCII (for language selector) + _process_font(opfont_path, labels_cp, output_name=f"{weight}-Labels") + + # Per-language atlases: ASCII + that language's .po chars + for lang_code, lang_cp in per_lang.items(): + _process_font(opfont_path, lang_cp, output_name=f"{weight}-{lang_code}") + return 0 diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py index 41ec9d67d..3e615e9f2 100644 --- a/selfdrive/ui/layouts/settings/device.py +++ b/selfdrive/ui/layouts/settings/device.py @@ -97,6 +97,7 @@ class DeviceLayout(Widget): if result == 1 and self._select_language_dialog: selected_language = multilang.languages[self._select_language_dialog.selection] multilang.change_language(selected_language) + gui_app.on_language_changed(selected_language) self._update_calib_description() self._select_language_dialog = None diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index 9da0b8396..fd2e61c51 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -100,19 +100,45 @@ class FontWeight(StrEnum): MEDIUM = "Inter-Medium.fnt" BOLD = "Inter-Bold.fnt" SEMI_BOLD = "Inter-SemiBold.fnt" - UNIFONT = "unifont.fnt" + UNIFONT = "OpFont-Regular-Labels.fnt" # Small UI fonts DISPLAY_REGULAR = "Inter-Regular.fnt" ROMAN = "Inter-Regular.fnt" DISPLAY = "Inter-Bold.fnt" +_OPFONT_WEIGHT = { + "Inter-Light.fnt": "Regular", + "Inter-Regular.fnt": "Regular", + "Inter-Medium.fnt": "Medium", + "Inter-SemiBold.fnt": "SemiBold", + "Inter-Bold.fnt": "Bold", +} + + +def _opfont_filename(inter_filename: str, lang_code: str) -> str: + """Map an Inter font filename to the equivalent OpFont filename for a language.""" + weight_name = _OPFONT_WEIGHT.get(inter_filename, "Regular") + return f"OpFont-{weight_name}-{lang_code}.fnt" + def font_fallback(font: rl.Font) -> rl.Font: - """Fall back to unifont for languages that require it.""" - if multilang.requires_unifont(): - return gui_app.font(FontWeight.UNIFONT) - return font + """Ensure the font is from the current language's font set. + + Widgets may cache rl.Font references. After a language switch, those references + are stale (freed GPU texture). This catches them and returns the current equivalent. + """ + if not gui_app._font_remap: + return font # no language switch has occurred + # Check if this is a currently loaded font (handles texture ID reuse) + for f in gui_app._fonts.values(): + if font.texture.id == f.texture.id: + return f + # Stale reference — look up the original weight and return current font for it + weight = gui_app._font_remap.get(font.texture.id) + if weight: + return gui_app.font(weight) + return gui_app.font(FontWeight.NORMAL) @dataclass @@ -204,6 +230,8 @@ class GuiApplication: dp_ui_mici = False self._width = width if width is not None else GuiApplication._default_width(dp_ui_mici) self._height = height if height is not None else GuiApplication._default_height(dp_ui_mici) + self._active_lang_code: str = "" + self._font_remap: dict[int, FontWeight] = {} # old texture ID → weight (for stale references) if PC and os.getenv("SCALE") is None: self._scale = self._calculate_auto_scale() @@ -439,6 +467,7 @@ class GuiApplication: for font in self._fonts.values(): rl.unload_font(font) self._fonts = {} + self._active_lang_code = "" if self._render_texture is not None: rl.unload_render_texture(self._render_texture) @@ -548,6 +577,21 @@ class GuiApplication: pass def font(self, font_weight: FontWeight = FontWeight.NORMAL) -> rl.Font: + if font_weight not in self._fonts: + # For languages need unifont, load OpFont instead of Inter (except labels font) + if multilang.requires_unifont() and font_weight != FontWeight.UNIFONT: + filename = _opfont_filename(font_weight.value, self._active_lang_code) + else: + filename = font_weight.value + with as_file(FONT_DIR) as fspath: + fnt_path = fspath / filename + # Fall back to Regular weight if requested weight doesn't exist + if not fnt_path.exists() and multilang.requires_unifont(): + filename = f"OpFont-Regular-{self._active_lang_code}.fnt" + fnt_path = fspath / filename + font = rl.load_font(fnt_path.as_posix()) + rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) + self._fonts[font_weight] = font return self._fonts[font_weight] @property @@ -586,14 +630,27 @@ class GuiApplication: return False def _load_fonts(self): - 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()) - if font_weight_file != FontWeight.UNIFONT: - rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) - self._fonts[font_weight_file] = font - rl.gui_set_font(self._fonts[FontWeight.NORMAL]) + self._active_lang_code = multilang.language + rl.gui_set_font(self.font(FontWeight.NORMAL)) + + def on_language_changed(self, lang_code: str): + # Map old texture IDs → weights so we can remap stale references + old_weight_by_texture = {f.texture.id: w for w, f in self._fonts.items()} + old_fonts = list(self._fonts.values()) + self._fonts = {} + self._active_lang_code = lang_code + # Load new fonts for all weights that were active + for weight in old_weight_by_texture.values(): + self.font(weight) + # Carry forward existing remap + add new entries (weights are stable across switches) + self._font_remap = dict(self._font_remap) | {tid: w for tid, w in old_weight_by_texture.items()} + # Now safe to unload old fonts + for f in old_fonts: + rl.unload_font(f) + rl.gui_set_font(self.font(FontWeight.NORMAL)) + from openpilot.system.ui.lib import text_measure, wrap_text + text_measure._cache.clear() + wrap_text._cache.clear() def _set_styles(self): rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BORDER_WIDTH, 0)