use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, serde::Serialize)]
pub struct FontInfo {
pub id: String,
pub name: String,
}
#[derive(Debug)]
pub struct FontDirectory {
root: PathBuf,
by_id: BTreeMap<String, FontEntry>,
}
#[derive(Debug)]
struct FontEntry {
path: PathBuf,
display_name: String,
}
#[derive(Debug, thiserror::Error)]
pub enum FontDirectoryError {
#[error("error opening fonts directory: {0}")]
Io(#[from] std::io::Error),
#[error(
"fonts directory `{0}` contains no .ttf files; \
add at least one font (DejaVuSans.ttf is checked in at the workspace root)"
)]
Empty(PathBuf),
}
impl FontDirectory {
pub fn scan(root: PathBuf) -> Result<Self, FontDirectoryError> {
let mut by_id: BTreeMap<String, FontEntry> = BTreeMap::new();
for entry in fs_err::read_dir(&root)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
continue;
};
if !ext.eq_ignore_ascii_case("ttf") {
continue;
}
let Some(id) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let display_name = match fs_err::read(&path) {
Ok(bytes) => sl_map_apis::text::embedded_font_name(&bytes).map_or_else(
|| sl_map_apis::text::display_name_from_file_name(id),
|name| format!("{name} ({id})"),
),
Err(err) => {
tracing::warn!(font = id, %err, "could not read font for name extraction");
sl_map_apis::text::display_name_from_file_name(id)
}
};
by_id.insert(id.to_owned(), FontEntry { path, display_name });
}
if by_id.is_empty() {
return Err(FontDirectoryError::Empty(root));
}
Ok(Self { root, by_id })
}
#[must_use]
pub fn list(&self) -> Vec<FontInfo> {
self.by_id
.iter()
.map(|(id, entry)| FontInfo {
id: id.to_owned(),
name: entry.display_name.clone(),
})
.collect()
}
#[must_use]
pub fn path_for(&self, font_id: &str) -> Option<&Path> {
if font_id.is_empty()
|| font_id.contains('/')
|| font_id.contains('\\')
|| font_id.contains("..")
{
return None;
}
self.by_id.get(font_id).map(|entry| entry.path.as_path())
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
const DEJAVU: &[u8] = include_bytes!("../../DejaVuSans.ttf");
#[test]
fn list_uses_embedded_name_with_id() -> Result<(), Box<dyn std::error::Error>> {
let tmp = tempfile::tempdir()?;
fs_err::write(tmp.path().join("DejaVuSans.ttf"), DEJAVU)?;
fs_err::write(tmp.path().join("bad.ttf"), b"not a font")?;
let fd = FontDirectory::scan(tmp.path().to_owned())?;
let fonts = fd.list();
let dejavu = fonts
.iter()
.find(|f| f.id == "DejaVuSans.ttf")
.ok_or("DejaVuSans.ttf missing from list")?;
assert_eq!(dejavu.name, "DejaVu Sans (DejaVuSans.ttf)");
let bad = fonts
.iter()
.find(|f| f.id == "bad.ttf")
.ok_or("bad.ttf missing from list")?;
assert_eq!(bad.name, "bad");
Ok(())
}
#[test]
fn path_for_rejects_path_traversal() -> Result<(), Box<dyn std::error::Error>> {
let tmp = tempfile::tempdir()?;
fs_err::write(tmp.path().join("one.ttf"), b"fake")?;
let fd = FontDirectory::scan(tmp.path().to_owned())?;
assert!(fd.path_for("one.ttf").is_some());
assert!(fd.path_for("../one.ttf").is_none());
assert!(fd.path_for("subdir/one.ttf").is_none());
assert!(fd.path_for("missing.ttf").is_none());
Ok(())
}
#[test]
fn scan_empty_directory_fails() -> Result<(), Box<dyn std::error::Error>> {
let tmp = tempfile::tempdir()?;
let result = FontDirectory::scan(tmp.path().to_owned());
assert!(
matches!(result, Err(FontDirectoryError::Empty(_))),
"expected Empty, got {result:?}",
);
Ok(())
}
}