#!/usr/bin/env python3 from pathlib import Path import gettext 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 _dragonpilot_chars(code: str) -> set[str]: """Characters used by dragonpilot's own translations (dragonpilot_{code}.po). dp settings translate via a separate catalog from openpilot's app_{code}.po, so their glyphs must be baked too — otherwise translated dp settings render as '?'. We parse the .po file directly to get translated strings, avoiding .mo encoding issues with GNUTranslations.""" po_path = TRANSLATIONS_DIR / f"dragonpilot_{code}.po" if not po_path.exists(): return set() import re text = po_path.read_text(encoding="utf-8") chars: set[str] = set() # Extract all msgstr values for m in re.finditer(r'^msgstr\s+"(.*)"', text, re.MULTILINE): chars |= set(m.group(1)) # Also handle multi-line msgstr in_msgstr = False for line in text.splitlines(): if line.startswith('msgstr "') or (in_msgstr and line.startswith('"')): in_msgstr = True content = line.removeprefix('msgstr "').removesuffix('"') if line.startswith('msgstr "') else line.strip('"') content = content.replace('\\\\n', '\n').replace('\\n', '\n').replace('\\"', '"') chars |= set(content) else: in_msgstr = False return chars def _char_sets(): base = set(map(chr, range(32, 127))) | set(EXTRA_CHARS) labels = set(base) per_lang: dict[str, tuple[int, ...]] = {} for language, code in _languages().items(): labels.update(language) po_path = TRANSLATIONS_DIR / f"app_{code}.po" try: chars = set(po_path.read_text(encoding="utf-8")) except FileNotFoundError: continue chars |= _dragonpilot_chars(code) 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) 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, 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 _init_window(): """Initialize raylib window (required for font rendering with GPU).""" # Reuse the lib loaded once if not hasattr(_init_window, '_done'): lib = rl.ffi.dlopen(None) lib.InitWindow(64, 64, b'fontgen') lib.SetWindowState(0x00000080) # FLAG_WINDOW_HIDDEN lib.SetTargetFPS(0) _init_window._lib = lib _init_window._done = True return _init_window._lib 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)...") lib = _init_window() load_font_ex = rl.ffi.cast('struct Font(*)(const char *, int, int *, int)', lib.LoadFontEx) gen_atlas = rl.ffi.cast('struct Image(*)(struct GlyphInfo *, struct Rectangle **, int, int, int, int)', lib.GenImageFontAtlas) export_img = rl.ffi.cast('bool(*)(struct Image, const char *)', lib.ExportImage) unload_font = rl.ffi.cast('void(*)(struct Font)', lib.UnloadFont) fnt_path = font_path.as_posix() cp_buffer = rl.ffi.new("int[]", codepoints) glyph_count = len(codepoints) font = load_font_ex(fnt_path.encode('utf-8'), font_size, cp_buffer, glyph_count) if font.glyphCount == 0: raise RuntimeError("raylib failed to load font data") rects_ptr = rl.ffi.new("struct Rectangle **") image = gen_atlas(font.glyphs, rects_ptr, font.glyphCount, font_size, GLYPH_PADDING, 0) if image.width == 0 or image.height == 0: unload_font(font) raise RuntimeError("raylib returned an empty atlas") rects = rects_ptr[0] atlas_name = f"{stem}.png" atlas_path = FONT_DIR / atlas_name entries, line_height, base = _glyph_metrics(font.glyphs, rects, font.glyphCount) if not export_img(image, atlas_path.as_posix().encode('utf-8')): unload_font(font) raise RuntimeError("Failed to export atlas image") _write_bmfont(FONT_DIR / f"{stem}.fnt", font_size, stem, atlas_name, line_height, base, (image.width, image.height), entries) unload_font(font) def main(): 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() or font.name == "unifont.otf": continue 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 if __name__ == "__main__": raise SystemExit(main())