f56a1eea3b
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>
430 lines
16 KiB
Rust
430 lines
16 KiB
Rust
//! 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");
|
||
}
|
||
}
|
||
}
|