Files
Oxide/engine/src/ui/text/font.rs
T
Homer Simpson f56a1eea3b Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:41:02 +02:00

430 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Font loading and per-glyph metrics — thin wrapper over [`ab_glyph::FontVec`].
//!
//! The text system stays a layer above the font crate so it can swap
//! rasterizers later (an SDF generator, a different parser) without churning
//! the public Stage-8 API. Every text query a [`super::shape::shape`] or
//! [`super::atlas::GlyphAtlas`] call needs goes through [`Font`]'s methods —
//! `ab_glyph` is never visible to consumers of the engine.
use std::collections::HashMap;
use std::path::Path;
use ab_glyph::{Font as AbFont, FontVec, PxScale, ScaleFont};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::super::visual::FontRef;
/// Errors returned from font loading.
#[derive(Debug, Error)]
pub enum FontError {
/// Reading the font file from disk failed.
#[error("font file read failed: {0}")]
Io(#[from] std::io::Error),
/// The bytes were not a valid TTF / OTF font.
#[error("not a valid TTF/OTF font")]
InvalidFont,
}
/// Stable, opaque identifier for a font registered in a [`FontStore`].
///
/// Held in [`GlyphKey`](super::atlas::GlyphKey)s in the atlas and in
/// [`TextStyle`](super::shape::TextStyle)s passed to the shaper, so a font's
/// id never changes once registered. `Copy` + `Hash` so it indexes hash maps
/// cheaply.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FontId(pub u32);
/// One loaded font — a parsed TTF/OTF that can report metrics and rasterize
/// individual glyphs.
pub struct Font {
inner: FontVec,
}
impl std::fmt::Debug for Font {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Font").finish_non_exhaustive()
}
}
/// Result of rasterizing one glyph at a specific pixel size — the alpha mask
/// plus enough metrics to position it on a baseline.
#[derive(Debug, Clone, PartialEq)]
pub struct RasterizedGlyph {
/// Width of the alpha mask in pixels.
pub width: u32,
/// Height of the alpha mask in pixels.
pub height: u32,
/// X offset from the glyph's pen position to the mask's left edge.
pub bearing_x: f32,
/// Y offset from the glyph's baseline to the mask's top edge (negative
/// for glyphs that extend above the baseline, which is most of them).
pub bearing_y: f32,
/// How far to advance the pen along the baseline before the next glyph.
pub advance_x: f32,
/// Row-major alpha bytes (`width * height` bytes, `0 = transparent`,
/// `255 = opaque`).
pub bitmap: Vec<u8>,
}
impl Font {
/// Parse a TTF/OTF font from raw bytes. Bytes are owned by the [`Font`].
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, FontError> {
FontVec::try_from_vec(bytes)
.map(|inner| Self { inner })
.map_err(|_| FontError::InvalidFont)
}
/// Load and parse a TTF/OTF file from disk.
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, FontError> {
let bytes = std::fs::read(path.as_ref())?;
Self::from_bytes(bytes)
}
/// The glyph id for a `char`. Returns the font's `notdef` glyph (id `0`)
/// for characters the font does not contain — same behavior as
/// `ab_glyph`.
pub fn glyph_id(&self, ch: char) -> GlyphId {
GlyphId(self.inner.glyph_id(ch).0)
}
/// Horizontal advance for the next glyph at `size_px` logical pixels.
pub fn h_advance_px(&self, glyph: GlyphId, size_px: f32) -> f32 {
self.inner
.as_scaled(PxScale::from(size_px))
.h_advance(ab_glyph::GlyphId(glyph.0))
}
/// Ascender height in pixels at the given size.
pub fn ascent_px(&self, size_px: f32) -> f32 {
self.inner.as_scaled(PxScale::from(size_px)).ascent()
}
/// Descender depth in pixels at the given size. Negative for fonts where
/// the descender sits below the baseline (the common case).
pub fn descent_px(&self, size_px: f32) -> f32 {
self.inner.as_scaled(PxScale::from(size_px)).descent()
}
/// Line gap in pixels — extra leading the font recommends between lines.
pub fn line_gap_px(&self, size_px: f32) -> f32 {
self.inner.as_scaled(PxScale::from(size_px)).line_gap()
}
/// Total recommended line height at `size_px` (ascent descent +
/// line_gap). Multiplied by `TextStyle`'s line-height factor by the
/// shaper.
pub fn line_height_px(&self, size_px: f32) -> f32 {
let scaled = self.inner.as_scaled(PxScale::from(size_px));
scaled.ascent() - scaled.descent() + scaled.line_gap()
}
/// Rasterize a single glyph to an alpha bitmap. Returns `None` for
/// glyphs with no outline (e.g., the space character) — the caller still
/// gets the advance via [`Font::h_advance_px`] and should treat the
/// glyph as zero-area.
pub fn rasterize(&self, glyph: GlyphId, size_px: f32) -> Option<RasterizedGlyph> {
let scale = PxScale::from(size_px);
let scaled = self.inner.as_scaled(scale);
let advance_x = scaled.h_advance(ab_glyph::GlyphId(glyph.0));
let mut positioned = ab_glyph::GlyphId(glyph.0).with_scale(scale);
positioned.position = ab_glyph::point(0.0, 0.0);
let outlined = self.inner.outline_glyph(positioned)?;
let bounds = outlined.px_bounds();
let width = bounds.width().ceil().max(1.0) as u32;
let height = bounds.height().ceil().max(1.0) as u32;
let mut bitmap = vec![0u8; (width as usize) * (height as usize)];
outlined.draw(|x, y, coverage| {
if x < width && y < height {
let idx = (y as usize) * (width as usize) + (x as usize);
bitmap[idx] = (coverage * 255.0).round().clamp(0.0, 255.0) as u8;
}
});
Some(RasterizedGlyph {
width,
height,
bearing_x: bounds.min.x,
bearing_y: bounds.min.y,
advance_x,
bitmap,
})
}
}
/// [`AssetLoader`](crate::asset::AssetLoader) for TTF/OTF fonts.
///
/// Registered by default on every [`AssetServer`](crate::asset::AssetServer), so
/// a font file under a project's `assets/fonts/` can be loaded by path and an
/// [`AssetRef<Font>`](crate::asset::AssetRef) resolved to a [`Handle<Font>`](crate::asset::Handle)
/// — the link that lets the UI canvas pick a font asset and the runtime draw with it.
pub struct FontLoader;
impl crate::asset::AssetLoader for FontLoader {
type Asset = Font;
fn extensions(&self) -> &'static [&'static str] {
&["ttf", "otf"]
}
fn load(&self, path: &Path) -> Result<Font, crate::asset::AssetError> {
Font::from_path(path).map_err(|err| crate::asset::AssetError::Load {
path: path.to_path_buf(),
message: err.to_string(),
})
}
}
/// Opaque per-font glyph index. Mirrors `ab_glyph::GlyphId` but is the only
/// glyph type exposed by the engine, so consumers do not need an `ab_glyph`
/// dependency.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GlyphId(pub u16);
/// Registry of loaded fonts, indexed by [`FontId`] and (optionally) by
/// [`FontRef`] descriptor.
///
/// Why a descriptor index: piece-2 [`Theme`](super::super::theme::Theme)s
/// store fonts by family + weight + italic (`FontRef`), not by raw bytes.
/// `FontStore::resolve(&font_ref)` turns the descriptor into a [`FontId`] the
/// shaper can use, so a theme like `{ font: Some(FontRef::bold("Inter")) }`
/// works end-to-end as soon as the matching face has been registered.
#[derive(Default)]
pub struct FontStore {
fonts: Vec<Font>,
by_descriptor: HashMap<FontRef, FontId>,
}
impl std::fmt::Debug for FontStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FontStore")
.field("len", &self.fonts.len())
.field("descriptors", &self.by_descriptor.len())
.finish()
}
}
impl FontStore {
/// Create an empty store.
pub fn new() -> Self {
Self::default()
}
/// Register a font with no descriptor — accessible only by its returned
/// [`FontId`]. Useful for one-off uses where the font isn't part of a
/// theme cascade.
pub fn insert(&mut self, font: Font) -> FontId {
let id = FontId(self.fonts.len() as u32);
self.fonts.push(font);
id
}
/// Register a font and associate it with a descriptor.
///
/// Re-registering the same descriptor replaces the previous association
/// but does not free the previous [`FontId`] — both ids continue to
/// reference the now-distinct font. This matches Stage-7 `ActionMap`
/// re-registration semantics: ids are stable, names can be remapped.
pub fn insert_with_descriptor(&mut self, descriptor: FontRef, font: Font) -> FontId {
let id = self.insert(font);
self.by_descriptor.insert(descriptor, id);
id
}
/// Look up a font by `FontId`.
pub fn get(&self, id: FontId) -> Option<&Font> {
self.fonts.get(id.0 as usize)
}
/// Resolve a [`FontRef`] descriptor (piece-2 theme value) to a
/// [`FontId`], if the matching face has been registered.
pub fn resolve(&self, descriptor: &FontRef) -> Option<FontId> {
self.by_descriptor.get(descriptor).copied()
}
/// Number of registered fonts.
pub fn len(&self) -> usize {
self.fonts.len()
}
/// `true` if no fonts are registered.
pub fn is_empty(&self) -> bool {
self.fonts.is_empty()
}
}
/// Common system paths a Linux-style host is likely to have a sans-serif
/// TTF at. Used by tests (and the eventual editor "no theme font set" path)
/// to find *some* font without bundling one.
///
/// Returned in priority order; the first existing path is the one to try.
/// Empty on hosts the search doesn't know about — the caller must handle
/// "no candidate found" gracefully.
pub fn common_system_font_paths() -> &'static [&'static str] {
&[
// Linux distributions:
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf",
// macOS:
"/Library/Fonts/Arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
]
}
/// Try to load a sans-serif font from a well-known system path. Returns
/// `None` (and prints `SKIP:`) if no candidate exists — the same pattern
/// the Stage-4 GPU tests use for "no adapter".
///
/// Test-only helper shared between the `font`, `atlas`, and `shape` modules
/// so the same "skip when no system font" branch isn't duplicated.
#[cfg(test)]
pub(crate) fn try_load_system_font() -> Option<Font> {
for path in common_system_font_paths() {
if Path::new(path).exists() {
match Font::from_path(path) {
Ok(font) => return Some(font),
Err(err) => {
eprintln!("SKIP-candidate: {path} present but failed to load: {err}");
}
}
}
}
eprintln!("SKIP: no system font available at any common Linux/macOS path");
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_garbage_bytes() {
let err = Font::from_bytes(vec![0u8; 32]).unwrap_err();
assert!(matches!(err, FontError::InvalidFont));
}
#[test]
fn missing_file_returns_io_error() {
let err = Font::from_path("/nonexistent/font.ttf").unwrap_err();
assert!(matches!(err, FontError::Io(_)));
}
#[test]
fn font_loader_loads_through_the_asset_server() {
use crate::asset::{AssetRef, AssetServer, AssetUid};
// Find a real font file on disk; skip cleanly if the host has none.
let Some(path) = common_system_font_paths()
.iter()
.map(std::path::Path::new)
.find(|p| p.exists())
else {
eprintln!("SKIP: no system font path available");
return;
};
// The default-registered FontLoader makes `.ttf`/`.otf` loadable.
let server = AssetServer::new();
let handle = server.load::<Font>(path);
assert!(handle.is_loaded(), "font should load: {:?}", handle.error());
// An asset reference to a hypothetical uid resolves to a handle when the
// database hands back this path (proven in asset::database tests); here
// we just confirm the loaded Font is usable.
assert!(handle.get().unwrap().h_advance_px(GlyphId(0), 16.0) >= 0.0);
// AssetRef<Font> is constructible (the field type the UI canvas uses).
let _ = AssetRef::<Font>::new(AssetUid(1));
}
#[test]
fn store_assigns_distinct_ids() {
let Some(a) = try_load_system_font() else {
return;
};
let Some(b) = try_load_system_font() else {
return;
};
let mut store = FontStore::new();
let id_a = store.insert(a);
let id_b = store.insert(b);
assert_ne!(id_a, id_b);
assert_eq!(store.len(), 2);
assert!(store.get(id_a).is_some());
assert!(store.get(id_b).is_some());
assert!(store.get(FontId(99)).is_none());
}
#[test]
fn descriptor_resolves_to_registered_font() {
let Some(font) = try_load_system_font() else {
return;
};
let descriptor = FontRef::regular("System");
let mut store = FontStore::new();
let id = store.insert_with_descriptor(descriptor.clone(), font);
assert_eq!(store.resolve(&descriptor), Some(id));
// A different descriptor with no associated font is None.
assert_eq!(store.resolve(&FontRef::bold("System")), None);
}
#[test]
fn metrics_are_finite_and_non_zero() {
let Some(font) = try_load_system_font() else {
return;
};
let advance = font.h_advance_px(font.glyph_id('A'), 24.0);
assert!(advance.is_finite());
assert!(advance > 0.0);
let ascent = font.ascent_px(24.0);
let descent = font.descent_px(24.0);
assert!(ascent > 0.0);
// ab_glyph's `descent` is negative for descenders below the baseline.
assert!(descent <= 0.0);
assert!(font.line_height_px(24.0) > 0.0);
}
#[test]
fn rasterize_produces_bitmap_for_solid_glyph() {
let Some(font) = try_load_system_font() else {
return;
};
let raster = font
.rasterize(font.glyph_id('A'), 24.0)
.expect("'A' outlines");
assert!(raster.width > 0 && raster.height > 0);
assert_eq!(
raster.bitmap.len(),
(raster.width as usize) * (raster.height as usize)
);
// A capital A at 24px should have at least one fully-opaque pixel
// near its central stroke.
assert!(raster.bitmap.iter().any(|&p| p > 200));
// And some transparent pixels (it's not a solid square).
assert!(raster.bitmap.iter().any(|&p| p < 10));
}
#[test]
fn rasterize_space_returns_none_but_advance_works() {
let Some(font) = try_load_system_font() else {
return;
};
let space = font.glyph_id(' ');
// Space has no outline — rasterize returns None.
assert!(font.rasterize(space, 24.0).is_none());
// But the advance is still positive so the shaper can lay it out.
assert!(font.h_advance_px(space, 24.0) > 0.0);
}
#[test]
fn common_system_font_paths_returns_some_candidates() {
let paths = common_system_font_paths();
assert!(!paths.is_empty());
// Every entry should be an absolute path so the existence check is
// unambiguous on the host.
for p in paths {
assert!(p.starts_with('/'), "{p:?} should be an absolute path");
}
}
}