"""Bitmap typography for the two device layouts, independent of application state.""" from dataclasses import dataclass from enum import StrEnum from pathlib import Path import hashlib import json import numpy as np from PIL import Image import pyray as rl class Profile(StrEnum): LARGE = "large" COMPACT = "compact" @property def size(self) -> tuple[int, int]: return (2160, 1080) if self == Profile.LARGE else (536, 240) @property def font_scale(self) -> float: return 1.242 if self == Profile.LARGE else 1.16 class FontRole(StrEnum): NORMAL = "normal" MEDIUM = "medium" BOLD = "bold" SEMI_BOLD = "semi_bold" BRAND = "brand" ROMAN = "roman" DISPLAY = "display" SPEED = "speed" SET_SPEED = "set_speed" FALLBACK = "fallback" @dataclass(frozen=True) class TextSize: width: float height: float def font_filename(profile: Profile, role: FontRole) -> str: if role == FontRole.NORMAL: return "Inter-Regular.fnt" if profile == Profile.LARGE else "Inter-Medium.fnt" return { FontRole.MEDIUM: "Inter-Medium.fnt", FontRole.BOLD: "Inter-Bold.fnt", FontRole.SEMI_BOLD: "Inter-SemiBold.fnt", FontRole.BRAND: "Sora-800.fnt", FontRole.ROMAN: "Inter-Regular.fnt", FontRole.DISPLAY: "Inter-Bold.fnt", FontRole.SET_SPEED: "DINish-Speed.fnt", FontRole.SPEED: "DINish-Speed.fnt" if profile == Profile.LARGE else "Inter-Bold.fnt", FontRole.FALLBACK: "unifont.fnt", }[role] def default_font_directory() -> Path: return Path(__file__).parent / "assets/fonts" def font_path(directory: Path, filename: str) -> Path: # Keep the supplied Sora wordmark pinned to its bundled asset. The other # fonts use the bundled directory by default or a validated caller override. return default_font_directory() / filename if filename in ("Sora-800.fnt", "DINish-Speed.fnt") else directory / filename def validate_bitmap_font(path: Path) -> None: """Accept only reviewed descriptor/atlas bytes before the native BMFont parser. This manifest pins appearance as well as parser inputs. Changing either file requires an explicit asset review; accepting a partial descriptor grammar is insufficient to protect the native parser from malformed input. """ manifest_name = {"Sora-800.fnt": "sora-brand-font.json", "DINish-Speed.fnt": "dinish-speed-font.json"}.get(path.name, "bitmap-fonts.json") manifest = json.loads(Path(__file__).with_name(manifest_name).read_text()) expected = {entry["file"]: entry for entry in manifest["files"]} try: if path.suffix != ".fnt" or path.name not in expected: raise ValueError("descriptor is not in the reviewed font manifest") for asset in (path, path.with_suffix(".png")): record = expected[asset.name] with asset.open("rb") as stream: data = stream.read(record["bytes"] + 1) if len(data) != record["bytes"] or hashlib.sha256(data).hexdigest() != record["sha256"]: raise ValueError(f"{asset.name} differs from the reviewed font manifest") except (OSError, KeyError, ValueError) as error: raise ValueError(f"Invalid bitmap font {path.name}: {error}") from error class BitmapFonts: """Own font textures without changing global text APIs or loading device services. Construct after creating a native graphics context and close before destroying it. A caller chooses the fallback explicitly; the brand always retains its own glyphs. Raw draw calls avoid a second scale if an application installed its own text wrapper. Text containing emoji is outside this component's contract. """ def __init__(self, profile: Profile, directory: Path, *, headless_context: bool = False): if not (rl.is_window_ready() or headless_context): raise RuntimeError("Bitmap fonts require an initialized graphics context") self._headless_context = headless_context self.profile = Profile(profile) self._directory = directory self._fonts: dict[str, rl.Font] = {} self._measurements: dict[tuple, TextSize] = {} self._vertical_ink: dict[tuple, tuple[float, float]] = {} self._horizontal_ink: dict[tuple, tuple[float, float]] = {} self._glyph_ink: dict[str, dict[int, tuple[int, int] | None]] = {} default_texture_id = rl.get_font_default().texture.id try: for filename in dict.fromkeys(font_filename(self.profile, role) for role in FontRole): path = font_path(directory, filename) if not path.is_file(): raise FileNotFoundError(path) validate_bitmap_font(path) font = rl.load_font(str(path)) # Failed font/atlas loads may return Raylib's shared default resource. # Never accept it as a requested bitmap or take ownership of its texture. if font.texture.id in (0, default_texture_id) or font.glyphCount == 0: raise RuntimeError(f"Unable to load bitmap font {filename}") self._fonts[filename] = font if filename != "unifont.fnt": rl.gen_texture_mipmaps(font.texture) rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR) except Exception: self.close() raise def font(self, role: FontRole, *, fallback: bool = False) -> rl.Font: if not self._fonts: raise RuntimeError("Bitmap font resources are closed") if fallback and role != FontRole.BRAND: role = FontRole.FALLBACK return self._fonts[font_filename(self.profile, role)] def _optical_style(self, role: FontRole, fallback: bool = False) -> tuple[float, float]: if fallback: return 1., 0. if self.profile == Profile.LARGE and role == FontRole.SPEED: return 1.04, .85 if self.profile == Profile.COMPACT and role == FontRole.SET_SPEED: return 1., .65 return 1., 0. def measure(self, text: str, role: FontRole, size: float, *, spacing: float = 0, fallback: bool = False) -> TextSize: font = self.font(role, fallback=fallback) spacing = round(spacing, 4) width_scale, weight = self._optical_style(role, fallback) key = font.texture.id, text, size, spacing, width_scale, weight if key not in self._measurements: measured = rl.measure_text_ex(font, text, size * self.profile.font_scale, spacing) # noqa: TID251 if len(self._measurements) >= 1024: self._measurements.clear() self._measurements[key] = TextSize(measured.x * width_scale + (weight if text else 0), measured.y) return self._measurements[key] def vertical_ink(self, text: str, role: FontRole, size: float) -> tuple[float, float]: """Visible glyph top/bottom, independent of the font's padded line box.""" key = (text, role, size) if key not in self._vertical_ink: font = self.font(role) glyphs = [rl.get_glyph_index(font, ord(char)) for char in text if not char.isspace()] scale = size * self.profile.font_scale / font.baseSize top = min((font.glyphs[index].offsetY for index in glyphs), default=0) bottom = max((font.glyphs[index].offsetY + font.recs[index].height for index in glyphs), default=0) if len(self._vertical_ink) >= 1024: self._vertical_ink.clear() self._vertical_ink[key] = (top * scale, bottom * scale) return self._vertical_ink[key] def horizontal_ink(self, text: str, role: FontRole, size: float) -> tuple[float, float]: """Visible glyph left/right from the draw x, without the font's side bearings.""" key = (text, role, size) if key not in self._horizontal_ink: font = self.font(role) width_scale, weight = self._optical_style(role) scale = size * self.profile.font_scale / font.baseSize left = right = None x = 0. columns = self._glyph_columns(role) for char in text: index = rl.get_glyph_index(font, ord(char)) glyph, rec = font.glyphs[index], font.recs[index] # Fixed-width fonts (DINish-Speed) pad each glyph cell, so use the atlas's inked columns. ink = None if char.isspace() else columns.get(index, (0, rec.width - 1) if rec.width > 0 else None) if ink is not None: left = x + glyph.offsetX + ink[0] if left is None else min(left, x + glyph.offsetX + ink[0]) right = x + glyph.offsetX + ink[1] + 1 if right is None else max(right, x + glyph.offsetX + ink[1] + 1) x += glyph.advanceX or rec.width if left is None: left = right = 0. if len(self._horizontal_ink) >= 1024: self._horizontal_ink.clear() # Match draw(): the optical width scale applies to the whole run and the weight pass shifts right. self._horizontal_ink[key] = (left * scale * width_scale, right * scale * width_scale + (weight if text else 0)) return self._horizontal_ink[key] def _glyph_columns(self, role: FontRole) -> dict[int, tuple[int, int] | None]: """Measure the reviewed PNG, without GPU readback during a widget render. Texture readback depends on the graphics backend and can disturb the active framebuffer used for Galaxy's widget previews. The source atlas has the same pixels and works independently of the current render target. """ filename = font_filename(self.profile, role) if filename not in self._glyph_ink: font = self.font(role) with Image.open(font_path(self._directory, filename).with_suffix('.png')) as atlas: alpha = np.asarray(atlas.convert('RGBA').getchannel('A')) columns = {} for index in range(font.glyphCount): rec = font.recs[index] cell = alpha[int(rec.y):int(rec.y + rec.height), int(rec.x):int(rec.x + rec.width)] inked = np.flatnonzero(cell.max(0) > 40) if cell.size else [] columns[index] = (int(inked[0]), int(inked[-1])) if len(inked) else None self._glyph_ink[filename] = columns return self._glyph_ink[filename] def draw(self, text: str, role: FontRole, size: float, x: float, y: float, color: rl.Color = rl.WHITE, *, spacing: float = 0, fallback: bool = False) -> None: draw = getattr(rl, "_orig_draw_text_ex", rl.draw_text_ex) width_scale, weight = self._optical_style(role, fallback) if width_scale == 1. and weight == 0.: draw(self.font(role, fallback=fallback), text, rl.Vector2(x, y), size * self.profile.font_scale, spacing, color) return rl.rl_push_matrix() try: rl.rl_translatef(x, y, 0) rl.rl_scalef(width_scale, 1., 1.) draw(self.font(role, fallback=fallback), text, rl.Vector2(0, 0), size * self.profile.font_scale, spacing, color) if weight: draw(self.font(role, fallback=fallback), text, rl.Vector2(weight / width_scale, 0), size * self.profile.font_scale, spacing, color) finally: rl.rl_pop_matrix() def close(self) -> None: if rl.is_window_ready() or self._headless_context: for font in self._fonts.values(): rl.unload_font(font) self._fonts.clear() self._vertical_ink.clear() self._horizontal_ink.clear() self._glyph_ink.clear() self._measurements.clear() def __enter__(self): return self def __exit__(self, *_): self.close()