//! 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, } impl Font { /// Parse a TTF/OTF font from raw bytes. Bytes are owned by the [`Font`]. pub fn from_bytes(bytes: Vec) -> Result { 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) -> Result { 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 { 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`](crate::asset::AssetRef) resolved to a [`Handle`](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::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, by_descriptor: HashMap, } 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 { 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 { 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::(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 is constructible (the field type the UI canvas uses). let _ = AssetRef::::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"); } } }