use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Default)]
pub struct FontConfig {
mappings: HashMap<String, PathBuf>,
}
impl FontConfig {
pub fn new() -> Self {
Self {
mappings: HashMap::new(),
}
}
pub fn add_mapping(&mut self, name: &str, path: PathBuf) {
self.mappings.insert(name.to_lowercase(), path);
}
pub fn find_font(&self, family: &str) -> Option<&PathBuf> {
self.mappings.get(&family.to_lowercase())
}
pub fn with_system_fonts() -> Self {
let mut config = Self::new();
for dir in system_font_dirs() {
if dir.is_dir() {
scan_font_dir(&dir, &mut config);
}
}
config
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &PathBuf)> {
self.mappings.iter().map(|(k, v)| (k.as_str(), v))
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.mappings.len()
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.mappings.is_empty()
}
}
fn system_font_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
#[cfg(target_os = "linux")]
{
dirs.push(PathBuf::from("/usr/share/fonts"));
dirs.push(PathBuf::from("/usr/local/share/fonts"));
if let Some(home) = home_dir() {
dirs.push(home.join(".fonts"));
dirs.push(home.join(".local/share/fonts"));
}
}
#[cfg(target_os = "macos")]
{
dirs.push(PathBuf::from("/Library/Fonts"));
dirs.push(PathBuf::from("/System/Library/Fonts"));
if let Some(home) = home_dir() {
dirs.push(home.join("Library/Fonts"));
}
}
#[cfg(target_os = "windows")]
{
dirs.push(PathBuf::from(r"C:\Windows\Fonts"));
if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
let mut p = PathBuf::from(local_app_data);
p.push("Microsoft");
p.push("Windows");
p.push("Fonts");
dirs.push(p);
}
}
dirs
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
fn scan_font_dir(dir: &std::path::Path, config: &mut FontConfig) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_font_dir(&path, config);
continue;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase());
if !matches!(ext.as_deref(), Some("ttf") | Some("otf")) {
continue;
}
register_font_file(&path, config);
}
}
fn register_font_file(path: &std::path::Path, config: &mut FontConfig) {
let data = match std::fs::read(path) {
Ok(d) => d,
Err(_) => return,
};
let name = extract_font_family_name(&data).or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
});
if let Some(family) = name {
config.add_mapping(&family, path.to_path_buf());
}
}
pub fn extract_font_family_name(font_data: &[u8]) -> Option<String> {
use ttf_parser::name_id;
let face = ttf_parser::Face::parse(font_data, 0).ok()?;
let preferred_ids = [
name_id::TYPOGRAPHIC_FAMILY, name_id::FAMILY, name_id::POST_SCRIPT_NAME, ];
for &id in &preferred_ids {
if let Some(name) = face
.names()
.into_iter()
.find(|n| n.name_id == id)
.and_then(|n| n.to_string())
{
return Some(name);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_font_config_new_is_empty() {
let cfg = FontConfig::new();
assert!(cfg.is_empty());
assert_eq!(cfg.len(), 0);
}
#[test]
fn test_add_and_find_mapping() {
let mut cfg = FontConfig::new();
cfg.add_mapping("Noto Sans", PathBuf::from("/usr/share/fonts/NotoSans.ttf"));
assert_eq!(cfg.len(), 1);
assert!(cfg.find_font("noto sans").is_some());
assert!(cfg.find_font("Noto Sans").is_some());
assert!(cfg.find_font("NOTO SANS").is_some());
}
#[test]
fn test_find_missing_font_returns_none() {
let cfg = FontConfig::new();
assert!(cfg.find_font("NonExistentFont").is_none());
}
#[test]
fn test_add_mapping_overwrites_existing() {
let mut cfg = FontConfig::new();
cfg.add_mapping("Arial", PathBuf::from("/path/a.ttf"));
cfg.add_mapping("Arial", PathBuf::from("/path/b.ttf"));
assert_eq!(cfg.len(), 1);
assert_eq!(cfg.find_font("arial"), Some(&PathBuf::from("/path/b.ttf")));
}
#[test]
fn test_with_system_fonts_does_not_panic() {
let _cfg = FontConfig::with_system_fonts();
}
#[test]
fn test_iter() {
let mut cfg = FontConfig::new();
cfg.add_mapping("FontA", PathBuf::from("/a.ttf"));
cfg.add_mapping("FontB", PathBuf::from("/b.ttf"));
let names: Vec<&str> = cfg.iter().map(|(n, _)| n).collect();
assert_eq!(names.len(), 2);
assert!(names.contains(&"fonta"));
assert!(names.contains(&"fontb"));
}
}
#[cfg(test)]
mod tests_extended {
use super::*;
#[test]
fn test_font_config_multiple_fonts() {
let mut cfg = FontConfig::new();
cfg.add_mapping("Font A", PathBuf::from("/a.ttf"));
cfg.add_mapping("Font B", PathBuf::from("/b.ttf"));
cfg.add_mapping("Font C", PathBuf::from("/c.ttf"));
assert_eq!(cfg.len(), 3);
assert!(!cfg.is_empty());
}
#[test]
fn test_font_config_lookup_is_case_insensitive() {
let mut cfg = FontConfig::new();
cfg.add_mapping("Arial Bold", PathBuf::from("/arial-bold.ttf"));
assert!(cfg.find_font("arial bold").is_some());
assert!(cfg.find_font("ARIAL BOLD").is_some());
assert!(cfg.find_font("Arial Bold").is_some());
assert!(cfg.find_font("ArIaL bOlD").is_some());
}
#[test]
fn test_font_config_path_is_preserved() {
let mut cfg = FontConfig::new();
let path = PathBuf::from("/usr/share/fonts/truetype/NotoSans.ttf");
cfg.add_mapping("Noto Sans", path.clone());
assert_eq!(cfg.find_font("noto sans"), Some(&path));
}
#[test]
fn test_font_config_iter_count() {
let mut cfg = FontConfig::new();
cfg.add_mapping("F1", PathBuf::from("/f1.ttf"));
cfg.add_mapping("F2", PathBuf::from("/f2.ttf"));
let count = cfg.iter().count();
assert_eq!(count, 2);
}
#[test]
fn test_extract_font_family_name_invalid_data() {
let bad_data = b"not a font";
let result = extract_font_family_name(bad_data);
assert!(result.is_none());
}
#[test]
fn test_extract_font_family_name_empty_data() {
let result = extract_font_family_name(b"");
assert!(result.is_none());
}
#[test]
fn test_font_config_default_is_empty() {
let cfg = FontConfig::default();
assert!(cfg.is_empty());
}
}