"""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