mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
203 lines
8.0 KiB
Python
203 lines
8.0 KiB
Python
"""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 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._fonts: dict[str, rl.Font] = {}
|
|
self._measurements: dict[tuple, TextSize] = {}
|
|
self._vertical_ink: dict[tuple, tuple[float, float]] = {}
|
|
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 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._measurements.clear()
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
self.close()
|