use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use fontdue::{ Font, FontSettings };
use super::fonts::FontFamilyDef;
use super::text_style::FontStyle;
#[ derive( Debug, Clone, PartialEq, Eq, Hash ) ]
pub struct FontKey
{
pub family: String,
pub weight: u16,
pub style: FontStyle,
}
#[ derive( Debug ) ]
pub enum FontLoadError
{
Io( PathBuf, std::io::Error ),
Parse( PathBuf, String ),
}
impl std::fmt::Display for FontLoadError
{
fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result
{
match self
{
FontLoadError::Io( p, e ) => write!( f, "reading font {}: {}", p.display(), e ),
FontLoadError::Parse( p, m ) => write!( f, "parsing font {}: {}", p.display(), m ),
}
}
}
impl std::error::Error for FontLoadError {}
#[ derive( Debug, Default ) ]
pub struct FontRegistry
{
by_key: HashMap<FontKey, Arc<Font>>,
fallbacks: HashMap<String, Vec<String>>,
}
impl FontRegistry
{
pub fn new() -> Self
{
Self { by_key: HashMap::new(), fallbacks: HashMap::new() }
}
pub fn insert
(
&mut self,
family: impl Into<String>,
weight: u16,
style: FontStyle,
font: Arc<Font>,
)
{
self.by_key.insert( FontKey { family: family.into(), weight, style }, font );
}
pub fn set_fallbacks( &mut self, family: impl Into<String>, chain: Vec<String> )
{
self.fallbacks.insert( family.into(), chain );
}
pub fn len( &self ) -> usize { self.by_key.len() }
pub fn is_empty( &self ) -> bool { self.by_key.is_empty() }
pub fn resolve( &self, family: &str, weight: u16, style: FontStyle ) -> Option<Arc<Font>>
{
let exact = FontKey { family: family.to_string(), weight, style };
if let Some( f ) = self.by_key.get( &exact )
{
return Some( Arc::clone( f ) );
}
let best_same_style = self.by_key.iter()
.filter( |( k, _ )| k.family == family && k.style == style )
.min_by_key( |( k, _ )| (k.weight as i32 - weight as i32).abs() );
if let Some( ( _, f ) ) = best_same_style
{
return Some( Arc::clone( f ) );
}
let best_same_family = self.by_key.iter()
.filter( |( k, _ )| k.family == family )
.min_by_key( |( k, _ )| (k.weight as i32 - weight as i32).abs() );
if let Some( ( _, f ) ) = best_same_family
{
return Some( Arc::clone( f ) );
}
if let Some( chain ) = self.fallbacks.get( family )
{
for fb in chain
{
if let Some( f ) = self.resolve( fb, weight, style )
{
return Some( f );
}
}
}
None
}
pub fn from_families
(
families: &HashMap<String, FontFamilyDef>,
) -> Result<Self, FontLoadError>
{
let mut reg = Self::new();
for ( id, family ) in families
{
if !family.fallbacks.is_empty()
{
reg.set_fallbacks( id.clone(), family.fallbacks.clone() );
}
for src in &family.sources
{
let bytes = std::fs::read( &src.path )
.map_err( |e| FontLoadError::Io( src.path.clone(), e ) )?;
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
.map_err( |e| FontLoadError::Parse( src.path.clone(), e.to_string() ) )?;
reg.insert( id.clone(), src.weight, src.style, Arc::new( font ) );
}
}
Ok( reg )
}
pub fn from_families_lenient
(
families: &HashMap<String, FontFamilyDef>,
) -> Self
{
let mut reg = Self::new();
for ( id, family ) in families
{
if !family.fallbacks.is_empty()
{
reg.set_fallbacks( id.clone(), family.fallbacks.clone() );
}
for src in &family.sources
{
let bytes = match std::fs::read( &src.path )
{
Ok( b ) => b,
Err( e ) =>
{
eprintln!
(
"[ltk] skipping font {} (weight {}, {:?}): {}",
src.path.display(), src.weight, src.style, e
);
continue;
}
};
let font = match Font::from_bytes( bytes.as_slice(), FontSettings::default() )
{
Ok( f ) => f,
Err( e ) =>
{
eprintln!
(
"[ltk] skipping font {} (weight {}, {:?}): parse error: {}",
src.path.display(), src.weight, src.style, e
);
continue;
}
};
reg.insert( id.clone(), src.weight, src.style, Arc::new( font ) );
}
}
reg
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
fn system_font() -> Option<Arc<Font>>
{
let path = crate::render::helpers::find_font_opt()?;
let bytes = std::fs::read( path ).ok()?;
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ).ok()?;
Some( Arc::new( font ) )
}
#[ test ]
fn empty_registry_resolves_to_none()
{
let reg = FontRegistry::new();
assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_none() );
assert!( reg.is_empty() );
}
#[ test ]
fn exact_match_wins()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 400, FontStyle::Normal, Arc::clone( &font ) );
reg.insert( "sora", 700, FontStyle::Normal, Arc::clone( &font ) );
assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_some() );
assert!( reg.resolve( "sora", 700, FontStyle::Normal ).is_some() );
assert_eq!( reg.len(), 2 );
}
#[ test ]
fn closest_weight_is_picked_when_exact_missing()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 300, FontStyle::Normal, Arc::clone( &font ) );
reg.insert( "sora", 700, FontStyle::Normal, Arc::clone( &font ) );
assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_some() );
assert!( reg.resolve( "sora", 800, FontStyle::Normal ).is_some() );
}
#[ test ]
fn fallback_chain_is_walked_when_family_unknown()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 400, FontStyle::Normal, Arc::clone( &font ) );
reg.set_fallbacks( "display", vec![ "sora".to_string() ] );
assert!( reg.resolve( "display", 400, FontStyle::Normal ).is_some() );
}
#[ test ]
fn unreachable_family_returns_none()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 400, FontStyle::Normal, font );
assert!( reg.resolve( "roboto", 400, FontStyle::Normal ).is_none() );
}
#[ test ]
fn from_families_reports_io_error_for_missing_path()
{
use crate::theme::fonts::FontSource;
let mut fams = HashMap::new();
fams.insert( "sora".to_string(), FontFamilyDef
{
name: "Sora".to_string(),
fallbacks: Vec::new(),
sources: vec!
[
FontSource
{
weight: 400,
style: FontStyle::Normal,
path: "/this/path/does/not/exist.ttf".into(),
},
],
});
match FontRegistry::from_families( &fams )
{
Err( FontLoadError::Io( p, _ ) ) =>
{
assert!( p.to_string_lossy().contains( "does/not/exist" ) );
}
other => panic!( "expected Io error, got {:?}", other ),
}
}
}