use fontdb::{Database, Family, ID};
use std::path::PathBuf;
use std::sync::Mutex;
#[derive(Debug, thiserror::Error)]
pub enum FontResolveError {
#[error(
"no system font matched any of the requested families: {requested:?} \
(weight {weight}, italic {italic})"
)]
NoMatch {
requested: Vec<String>,
weight: u16,
italic: bool,
},
#[error("matched font has no on-disk path: {family}")]
NoPath {
family: String,
},
#[error("failed to read font file {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FontStyle {
#[default]
Normal,
Italic,
}
#[derive(Debug, Clone)]
pub struct ResolvedFont {
pub matched_family: String,
pub path: PathBuf,
pub bytes: Vec<u8>,
pub postscript_family: String,
}
pub struct SystemFontDb {
inner: Mutex<Option<Database>>,
}
impl Default for SystemFontDb {
fn default() -> Self {
Self::new()
}
}
impl SystemFontDb {
pub fn new() -> Self {
Self {
inner: Mutex::new(None),
}
}
pub fn ensure_loaded(&self) {
let mut guard = self.inner.lock().expect("system font db mutex");
if guard.is_none() {
let mut db = Database::new();
db.load_system_fonts();
*guard = Some(db);
}
}
pub fn resolve(
&self,
families: &[&str],
weight: u16,
style: FontStyle,
) -> Result<ResolvedFont, FontResolveError> {
self.ensure_loaded();
let style_db = match style {
FontStyle::Normal => fontdb::Style::Normal,
FontStyle::Italic => fontdb::Style::Italic,
};
let weight_db = fontdb::Weight(weight);
let stretch_db = fontdb::Stretch::Normal;
let resolved_meta: Option<(String, PathBuf, String)> = {
let guard = self.inner.lock().expect("system font db mutex");
let db = guard
.as_ref()
.expect("ensure_loaded populated the database");
let mut found: Option<(String, PathBuf, String)> = None;
for &family in families {
let family_norm = family.trim().trim_matches(['"', '\'']);
let fontdb_family = match family_norm.to_ascii_lowercase().as_str() {
"serif" => Family::Serif,
"sans-serif" => Family::SansSerif,
"monospace" => Family::Monospace,
"cursive" => Family::Cursive,
"fantasy" => Family::Fantasy,
"system-ui" => Family::SansSerif,
_ => Family::Name(family_norm),
};
let query = fontdb::Query {
families: &[fontdb_family],
weight: weight_db,
stretch: stretch_db,
style: style_db,
};
let Some(id) = db.query(&query) else {
continue;
};
match face_metadata(db, id, family_norm) {
Ok((path, postscript_family)) => {
found = Some((family_norm.to_string(), path, postscript_family));
break;
},
Err(_) => continue,
}
}
found
};
let Some((matched_family, path, postscript_family)) = resolved_meta else {
return Err(FontResolveError::NoMatch {
requested: families.iter().map(|s| s.to_string()).collect(),
weight,
italic: matches!(style, FontStyle::Italic),
});
};
let bytes = std::fs::read(&path).map_err(|e| FontResolveError::Io {
path: path.clone(),
source: e,
})?;
Ok(ResolvedFont {
matched_family,
path,
bytes,
postscript_family,
})
}
}
fn face_metadata(
db: &Database,
id: ID,
matched_family: &str,
) -> Result<(PathBuf, String), FontResolveError> {
let face = db
.face(id)
.expect("fontdb returned an ID it doesn't recognise");
let path = match &face.source {
fontdb::Source::File(p) => p.clone(),
fontdb::Source::SharedFile(p, _) => p.clone(),
fontdb::Source::Binary(_) => {
return Err(FontResolveError::NoPath {
family: matched_family.to_string(),
});
},
};
let postscript_family = face
.families
.iter()
.find(|(_, lang)| lang.primary_language() == "English")
.or_else(|| face.families.first())
.map(|(name, _)| name.clone())
.unwrap_or_else(|| matched_family.to_string());
Ok((path, postscript_family))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_generic_sans_serif() {
let db = SystemFontDb::new();
let resolved = db.resolve(&["sans-serif"], 400, FontStyle::Normal);
match resolved {
Ok(r) => {
assert!(!r.bytes.is_empty(), "resolved font had empty bytes");
assert!(r.path.exists(), "resolved path must exist");
},
Err(FontResolveError::NoMatch { .. }) => {
eprintln!("no system sans-serif on this host; skipping (CI sandbox?)");
},
Err(other) => panic!("unexpected resolve error: {other:?}"),
}
}
#[test]
fn resolves_specific_family_with_fallback() {
let db = SystemFontDb::new();
let resolved =
db.resolve(&["NonExistentFontFromTheVoid", "sans-serif"], 400, FontStyle::Normal);
if let Ok(r) = resolved {
assert!(!r.bytes.is_empty());
assert_ne!(r.matched_family, "NonExistentFontFromTheVoid");
}
}
#[test]
fn empty_family_list_errors() {
let db = SystemFontDb::new();
let err = db
.resolve(&[], 400, FontStyle::Normal)
.expect_err("empty family list must not match");
assert!(matches!(err, FontResolveError::NoMatch { .. }));
}
}