use std::path::{Path, PathBuf};
use ttf_parser::name_id;
use super::FontStyle;
const MAX_COLLECTION_FACES: u32 = 64;
const MAX_SCAN_DEPTH: u32 = 8;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalFontEntry {
pub path: PathBuf,
pub family: String,
pub weight: u16,
pub style: FontStyle,
pub index: u32,
}
#[must_use]
pub fn scan_font_dirs(dirs: &[PathBuf]) -> Vec<LocalFontEntry> {
let mut files: Vec<PathBuf> = Vec::new();
let mut worklist: Vec<(PathBuf, u32)> = dirs.iter().map(|d| (d.clone(), 0u32)).collect();
while let Some((dir, depth)) = worklist.pop() {
let read = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in read {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
let path = entry.path();
if is_dir {
if depth < MAX_SCAN_DEPTH {
worklist.push((path, depth + 1));
}
} else if has_font_extension(&path) {
files.push(path);
}
}
}
files.sort();
let mut entries: Vec<LocalFontEntry> = Vec::new();
for path in files {
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => continue,
};
for index in 0..MAX_COLLECTION_FACES {
let face = match ttf_parser::Face::parse(&bytes, index) {
Ok(f) => f,
Err(_) => break,
};
let family = match best_family_name(&face) {
Some(f) => f,
None => continue,
};
let weight = face.weight().to_number();
let style = if face.is_italic() {
FontStyle::Italic
} else {
FontStyle::Normal
};
entries.push(LocalFontEntry {
path: path.clone(),
family,
weight,
style,
index,
});
}
}
entries.sort_by(|a, b| {
a.family
.cmp(&b.family)
.then(a.weight.cmp(&b.weight))
.then(a.style.cmp(&b.style))
.then(a.path.cmp(&b.path))
.then(a.index.cmp(&b.index))
});
entries
}
fn has_font_extension(path: &Path) -> bool {
match path.extension().and_then(|e| e.to_str()) {
Some(ext) => {
let ext = ext.to_ascii_lowercase();
ext == "ttf" || ext == "otf" || ext == "ttc"
}
None => false,
}
}
fn best_family_name(face: &ttf_parser::Face<'_>) -> Option<String> {
let mut typo_family: Option<String> = None;
let mut family: Option<String> = None;
for name in face.names() {
if name.name_id == name_id::TYPOGRAPHIC_FAMILY
&& typo_family.is_none()
&& let Some(s) = name.to_string()
{
typo_family = Some(s);
} else if name.name_id == name_id::FAMILY
&& family.is_none()
&& let Some(s) = name.to_string()
{
family = Some(s);
}
}
typo_family.or(family)
}
#[cfg(test)]
mod tests {
use super::*;
fn bundled_fonts_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/fonts")
}
#[test]
fn scans_bundled_fonts_dir_for_noto_sans() {
let entries = scan_font_dirs(&[bundled_fonts_dir()]);
assert!(
!entries.is_empty(),
"scanning the bundled fonts dir must yield faces"
);
assert!(
entries.iter().any(|e| e.family.contains("Noto Sans")),
"expected a 'Noto Sans' family among scanned faces, got: {:?}",
entries.iter().map(|e| &e.family).collect::<Vec<_>>()
);
assert!(
entries
.iter()
.any(|e| e.weight == 400 && e.style == FontStyle::Normal),
"expected at least one 400/Normal face"
);
}
#[test]
fn extracts_weight_and_style_variants() {
let entries = scan_font_dirs(&[bundled_fonts_dir()]);
assert!(
entries.iter().any(|e| e.weight == 700),
"expected a 700-weight face among scanned bundled fonts"
);
assert!(
entries.iter().any(|e| e.style == FontStyle::Italic),
"expected an italic face among scanned bundled fonts"
);
}
#[test]
fn nonexistent_dir_yields_empty() {
let entries = scan_font_dirs(&[PathBuf::from("/this/path/does/not/exist/zenith")]);
assert!(entries.is_empty(), "a missing dir must yield no entries");
}
#[test]
fn empty_dir_list_yields_empty() {
let entries = scan_font_dirs(&[]);
assert!(
entries.is_empty(),
"an empty dir list must yield no entries"
);
}
#[test]
fn output_is_sorted_and_deterministic() {
let a = scan_font_dirs(&[bundled_fonts_dir()]);
let b = scan_font_dirs(&[bundled_fonts_dir()]);
assert_eq!(a, b, "two scans of the same dir must be identical");
let mut sorted = a.clone();
sorted.sort_by(|x, y| {
x.family
.cmp(&y.family)
.then(x.weight.cmp(&y.weight))
.then(x.style.cmp(&y.style))
.then(x.path.cmp(&y.path))
.then(x.index.cmp(&y.index))
});
assert_eq!(a, sorted, "scan output must already be sorted");
}
#[test]
fn non_font_extensions_are_skipped() {
let entries = scan_font_dirs(&[bundled_fonts_dir()]);
for e in &entries {
assert!(
has_font_extension(&e.path),
"scanned a non-font file: {}",
e.path.display()
);
}
}
}