Source code for simvx.core.text.font

"""Font loading via freetype-py with proper glyph metrics and outline access."""

import logging
from dataclasses import dataclass
from pathlib import Path

log = logging.getLogger(__name__)

[docs] @dataclass class GlyphMetrics: """Metrics for a single glyph.""" char: str advance_x: float # horizontal advance in pixels bearing_x: float # horizontal bearing (left side) bearing_y: float # vertical bearing (top side) width: int # bitmap/outline width in pixels height: int # bitmap/outline height in pixels contours: list[list[tuple[float, float, bool]]] | None = None # (x, y, on_curve)
[docs] class Font: """TrueType font with freetype-py for outlines and metrics.""" def __init__(self, path: str | Path, size: int = 64, face_index: int = 0): self.path = Path(path) self.size = size self._face = None # Per-glyph caches: populated lazily; FreeType FFI calls dominate the # text-rendering hot path (claustrowordia / TD menu both spend ~0.4- # 0.55 ms/frame in TextRenderer.draw_text driven by these lookups). self._glyph_cache: dict[str, GlyphMetrics] = {} self._char_index_cache: dict[str, int] = {} # (left, right) -> pixels; both keys are single characters. self._kerning_cache: dict[tuple[str, str], float] = {} if not self.path.exists(): raise FileNotFoundError(f"Font not found: {self.path}") try: import freetype except ImportError: raise ImportError("freetype-py required: pip install freetype-py") from None self._face = freetype.Face(str(self.path), index=face_index) self._face.set_pixel_sizes(0, size) # ``has_kerning`` reads from the face's flags: never changes for the # life of the face, but the freetype-py property still goes through # FFI on every call. Cache the bool once. self._has_kerning: bool = bool(self._face.has_kerning) # Size-dependent metrics: set by ``set_pixel_sizes`` and constant # until size changes. Read every frame by TextRenderer.draw_text for # baseline and line-wrap; resolve once. size_metrics = self._face.size self._line_height: float = size_metrics.height / 64.0 self._ascender: float = size_metrics.ascender / 64.0 self._descender: float = size_metrics.descender / 64.0 def _char_index(self, char: str) -> int: """Cached ``face.get_char_index(ord(char))``. The FFI call is the hottest part of ``has_glyph`` and ``get_kerning``.""" idx = self._char_index_cache.get(char) if idx is None: idx = self._face.get_char_index(ord(char)) self._char_index_cache[char] = idx return idx
[docs] def get_glyph(self, char: str) -> GlyphMetrics: """Get glyph metrics and outline contours.""" if char in self._glyph_cache: return self._glyph_cache[char] import freetype self._face.load_char(char, freetype.FT_LOAD_NO_BITMAP) glyph = self._face.glyph metrics = glyph.metrics # Extract contour outlines for MSDF generation outline = glyph.outline contours = _extract_contours(outline) if outline.n_points > 0 else None gm = GlyphMetrics( char=char, advance_x=metrics.horiAdvance / 64.0, bearing_x=metrics.horiBearingX / 64.0, bearing_y=metrics.horiBearingY / 64.0, width=metrics.width // 64, height=metrics.height // 64, contours=contours, ) self._glyph_cache[char] = gm return gm
[docs] def has_glyph(self, char: str) -> bool: """Return True if this font contains a glyph for *char*.""" return self._char_index(char) != 0
[docs] def get_kerning(self, left: str, right: str) -> float: """Get kerning adjustment between two characters in pixels.""" if not (self._has_kerning and left and right): return 0.0 key = (left, right) cached = self._kerning_cache.get(key) if cached is not None: return cached import freetype left_idx = self._char_index(left) right_idx = self._char_index(right) kerning = self._face.get_kerning(left_idx, right_idx, freetype.FT_KERNING_DEFAULT) result = kerning.x / 64.0 self._kerning_cache[key] = result return result
[docs] def render_bitmap(self, char: str) -> tuple: """Render a glyph using FreeType's hinted bitmap rasterizer. Returns (bitmap_hw_uint8, GlyphMetrics) where bitmap is an (H, W) uint8 array of coverage values. Uses auto-hinting for maximum crispness at small sizes. """ import freetype import numpy as np self._face.load_char(char, freetype.FT_LOAD_RENDER) glyph = self._face.glyph metrics = glyph.metrics bitmap = glyph.bitmap w, h = bitmap.width, bitmap.rows if w > 0 and h > 0: buf = np.array(bitmap.buffer, dtype=np.uint8).reshape(h, w) else: buf = np.zeros((0, 0), dtype=np.uint8) gm = GlyphMetrics( char=char, advance_x=metrics.horiAdvance / 64.0, bearing_x=metrics.horiBearingX / 64.0, bearing_y=metrics.horiBearingY / 64.0, width=w, height=h, ) return buf, gm
[docs] @property def line_height(self) -> float: """Line height in pixels (cached at construction).""" return self._line_height
[docs] @property def ascender(self) -> float: """Ascender in pixels (cached at construction).""" return self._ascender
[docs] @property def descender(self) -> float: """Descender in pixels, negative (cached at construction).""" return self._descender
def _extract_contours(outline) -> list[list[tuple[float, float, bool]]]: """Extract contour points from a FreeType outline. Returns list of contours, each a list of (x, y, on_curve) tuples. Coordinates are in 26.6 fixed-point converted to float pixels. """ points = outline.points tags = outline.tags contour_ends = list(outline.contours) contours = [] start = 0 for end in contour_ends: contour = [] for i in range(start, end + 1): x = points[i][0] / 64.0 y = points[i][1] / 64.0 on_curve = bool(tags[i] & 1) contour.append((x, y, on_curve)) contours.append(contour) start = end + 1 return contours