Source code for simvx.core.text.font

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


from __future__ import annotations

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 self._glyph_cache: dict[str, GlyphMetrics] = {} 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)
[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._face.get_char_index(ord(char)) != 0
[docs] def get_kerning(self, left: str, right: str) -> float: """Get kerning adjustment between two characters in pixels.""" import freetype if not (self._face.has_kerning and left and right): return 0.0 left_idx = self._face.get_char_index(ord(left)) right_idx = self._face.get_char_index(ord(right)) kerning = self._face.get_kerning(left_idx, right_idx, freetype.FT_KERNING_DEFAULT) return kerning.x / 64.0
[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
@property def line_height(self) -> float: """Line height in pixels.""" return self._face.size.height / 64.0 @property def ascender(self) -> float: """Ascender in pixels.""" return self._face.size.ascender / 64.0 @property def descender(self) -> float: """Descender in pixels (negative).""" return self._face.size.descender / 64.0
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