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