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>
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user