#!/usr/bin/env python3 import os from contextlib import contextmanager from pathlib import Path import json @contextmanager def _quiet_fds(*fds): # mute the raylib load banner (printed on first native use, before any log level applies) saved = [os.dup(fd) for fd in fds] devnull = os.open(os.devnull, os.O_WRONLY) try: for fd in fds: os.dup2(devnull, fd) yield finally: for fd, s in zip(fds, saved): os.dup2(s, fd) os.close(s) os.close(devnull) with _quiet_fds(1, 2): import pyray as rl rl.set_trace_log_level(rl.TraceLogLevel.LOG_ERROR) # first native call: triggers the banner 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 = {"ar", "th", "zh-CHT", "zh-CHS", "ko", "ja"} def _load_font_data_arity() -> int: # The C signature of LoadFontData differs between raylib builds; the pyray wrapper is a # *args shim, so ask cffi for the real function type instead of catching arity errors. import raylib as _raylib return len(_raylib.ffi.typeof(_raylib.rl.LoadFontData).args) 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): entries = [] min_offset_y, max_extent = None, 0 for idx in range(glyph_count): glyph = glyphs[idx] rect = rects[idx] # Take the codepoint from the glyph itself: the 7-arg LoadFontData returns a # compacted array of only the glyphs it found, so positional pairing with the # requested codepoint list would misattribute (and previously overran) entries. codepoint = glyph.value 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": codepoint, "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: 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 _looks_like_lfs_pointer(data: bytes) -> bool: head = data[:256] return b"git-lfs.github.com/spec/v1" in head and b"oid sha256:" in head def _can_reuse_prebuilt(font_path: Path) -> bool: return (FONT_DIR / f"{font_path.stem}.fnt").exists() and (FONT_DIR / f"{font_path.stem}.png").exists() def _process_font(font_path: Path, codepoints: tuple[int, ...]): 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) args = [rl.ffi.cast("unsigned char *", file_buf), len(data), font_size, cp_ptr, len(codepoints), rl.FontType.FONT_DEFAULT] glyph_count_ptr = None if _load_font_data_arity() == 7: # IQ.OS >= 4.1 wheel: LoadFontData grew an int *glyphCount out-param AND returns # only the glyphs it actually loaded (fewer than codepointCount when some are # missing). We MUST read that count — using len(codepoints) below would index # past the glyph/rect arrays and segfault. The PC pypi wheel (raylib <= 5.5) is # the 6-arg version that returns codepointCount glyphs. glyph_count_ptr = rl.ffi.new("int *", 0) args.append(glyph_count_ptr) glyphs = rl.load_font_data(*args) if glyphs == rl.ffi.NULL: if _can_reuse_prebuilt(font_path): reason = "raylib failed to load font data" if _looks_like_lfs_pointer(data): reason += " (file looks like a Git LFS pointer, not real font data)" print(f"WARNING: {reason}; reusing prebuilt atlas for {font_path.name}") return reason = f"raylib failed to load font data for {font_path.name}" if _looks_like_lfs_pointer(data): reason += " (file looks like a Git LFS pointer, not real font data)" raise RuntimeError(reason) # Actual number of glyphs in the returned array (out-param on 7-arg raylib, else # codepointCount). Everything downstream must use this, not len(codepoints). glyph_count = glyph_count_ptr[0] if glyph_count_ptr is not None else len(codepoints) rects_ptr = rl.ffi.new("Rectangle **") image = rl.gen_image_font_atlas(glyphs, rects_ptr, glyph_count, font_size, GLYPH_PADDING, 0) if image.width == 0 or image.height == 0: if _can_reuse_prebuilt(font_path): print(f"WARNING: raylib returned an empty atlas; reusing prebuilt atlas for {font_path.name}") return raise RuntimeError(f"raylib returned an empty atlas for {font_path.name}") 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) 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())