use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex};
use serde::Serialize;
use usvg::fontdb;
use crate::platform::adapters::file::FileAdapter;
use crate::platform::error::PlatformError;
pub const PROJECT_FONTS_DIR: &str = "static/fonts";
pub const DEFAULT_FONT_FAMILY: &str = "Inter";
pub const BUNDLED_WEIGHTS: &[u16] = &[400, 500, 600, 800];
const BUNDLED: &[(&str, &[u8])] = &[
("Inter-Regular.ttf", include_bytes!("../../../runtime/fonts/inter/Inter-Regular.ttf")),
("Inter-Medium.ttf", include_bytes!("../../../runtime/fonts/inter/Inter-Medium.ttf")),
("Inter-SemiBold.ttf", include_bytes!("../../../runtime/fonts/inter/Inter-SemiBold.ttf")),
("Inter-ExtraBold.ttf", include_bytes!("../../../runtime/fonts/inter/Inter-ExtraBold.ttf")),
];
const LOADABLE: &[&str] = &["ttf", "otf", "ttc"];
const LISTED: &[&str] = &["ttf", "otf", "ttc", "woff2"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FontSource {
Bundled,
Project,
}
#[derive(Debug, Clone, Serialize)]
pub struct FontFamily {
pub family: String,
pub source: FontSource,
pub weights: Vec<u16>,
pub files: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct FontRef {
pub family: String,
pub weight: u16,
pub source: FontSource,
pub id: fontdb::ID,
}
struct ProjectFonts {
fingerprint: String,
db: Arc<fontdb::Database>,
families: Vec<FontFamily>,
names: BTreeMap<String, String>,
files: Vec<String>,
}
pub struct FontService {
file: Arc<dyn FileAdapter>,
cache: Mutex<HashMap<String, Arc<ProjectFonts>>>,
}
impl FontService {
pub fn new(file: Arc<dyn FileAdapter>) -> Self {
Self { file, cache: Mutex::new(HashMap::new()) }
}
pub fn bundled_db() -> fontdb::Database {
let mut db = fontdb::Database::new();
for (_, bytes) in BUNDLED {
db.load_font_data(bytes.to_vec());
}
db
}
pub fn bundled_families() -> Vec<FontFamily> {
let db = Self::bundled_db();
describe(&db, 0, FontSource::Bundled, &BUNDLED.iter().map(|(n, _)| n.to_string()).collect::<Vec<_>>()).0
}
pub fn fontdb_for(&self, owner: &str, project: &str) -> Result<Arc<fontdb::Database>, PlatformError> {
Ok(self.project(owner, project)?.db.clone())
}
pub fn families(&self, owner: &str, project: &str) -> Result<Vec<FontFamily>, PlatformError> {
Ok(self.project(owner, project)?.families.clone())
}
pub fn resolve(&self, owner: &str, project: &str, family: &str, weight: u16) -> Result<Option<FontRef>, PlatformError> {
let fonts = self.project(owner, project)?;
Ok(resolve_in(&fonts, family, weight))
}
pub fn project_font_files(&self, owner: &str, project: &str) -> Result<Vec<String>, PlatformError> {
Ok(self.project(owner, project)?.files.clone())
}
pub fn invalidate(&self, owner: &str, project: &str) {
self.cache.lock().unwrap_or_else(|e| e.into_inner()).remove(&format!("{owner}/{project}"));
}
fn project(&self, owner: &str, project: &str) -> Result<Arc<ProjectFonts>, PlatformError> {
let layout = self.file.ensure_project_layout(owner, project)?;
let dir = layout.repo_source_dir().join(PROJECT_FONTS_DIR);
let mut entries: Vec<(String, u64, u64)> = Vec::new();
if let Ok(read) = std::fs::read_dir(&dir) {
for entry in read.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
if !LISTED.contains(&ext.as_str()) || !entry.path().is_file() {
continue;
}
let meta = entry.metadata().ok();
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
let mtime = meta
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
entries.push((name, size, mtime));
}
}
entries.sort();
let fingerprint = entries.iter().map(|(n, s, m)| format!("{n}:{s}:{m}")).collect::<Vec<_>>().join("|");
let key = format!("{owner}/{project}");
if let Some(hit) = self.cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key)
&& hit.fingerprint == fingerprint
{
return Ok(hit.clone());
}
let mut db = Self::bundled_db();
let bundled_count = db.len();
let bundled_names: Vec<String> = BUNDLED.iter().map(|(n, _)| n.to_string()).collect();
let (mut families, mut names) = describe(&db, 0, FontSource::Bundled, &bundled_names);
let mut loaded_files = Vec::new();
let mut files = Vec::new();
for (name, _, _) in &entries {
files.push(name.clone());
let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
if !LOADABLE.contains(&ext.as_str()) {
continue;
}
let Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
let before = db.len();
db.load_font_data(bytes);
if db.len() > before {
loaded_files.push(name.clone());
}
}
let (project_families, project_names) = describe(&db, bundled_count, FontSource::Project, &loaded_files);
families.extend(project_families);
names.extend(project_names);
let fonts = Arc::new(ProjectFonts { fingerprint, db: Arc::new(db), families, names, files });
self.cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, fonts.clone());
Ok(fonts)
}
}
fn describe(db: &fontdb::Database, from: usize, source: FontSource, files: &[String]) -> (Vec<FontFamily>, BTreeMap<String, String>) {
let mut by_family: BTreeMap<String, Vec<u16>> = BTreeMap::new();
for face in db.faces().skip(from) {
let Some((name, _)) = face.families.first() else { continue };
by_family.entry(name.clone()).or_default().push(face.weight.0);
}
let mut names = BTreeMap::new();
let mut families = Vec::new();
for (actual, mut weights) in by_family {
weights.sort_unstable();
weights.dedup();
names.insert(actual.clone(), actual.clone());
let short = short_alias(&actual);
if short != actual {
names.entry(short.clone()).or_insert_with(|| actual.clone());
}
families.push(FontFamily { family: short, source, weights, files: files.to_vec() });
}
(families, names)
}
fn short_alias(family: &str) -> String {
match family.rsplit_once(' ') {
Some((head, tail)) if tail.ends_with("pt") && tail.trim_end_matches("pt").chars().all(|c| c.is_ascii_digit()) && !head.is_empty() => head.to_string(),
_ => family.to_string(),
}
}
fn resolve_in(fonts: &ProjectFonts, family: &str, weight: u16) -> Option<FontRef> {
let actual = fonts.names.get(family.trim())?;
let query = fontdb::Query {
families: &[fontdb::Family::Name(actual)],
weight: fontdb::Weight(weight),
..Default::default()
};
let id = fonts.db.query(&query)?;
let face = fonts.db.face(id)?;
let source = if fonts.families.iter().any(|f| f.source == FontSource::Project && (f.family == family.trim() || short_alias(actual) == f.family)) {
FontSource::Project
} else {
FontSource::Bundled
};
Some(FontRef { family: actual.clone(), weight: face.weight.0, source, id })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::model::PlatformConfig;
use crate::platform::services::PlatformService;
fn platform() -> Arc<PlatformService> {
let tmp = tempfile::tempdir().expect("tempdir");
let mut config = PlatformConfig::default();
config.data_root = tmp.keep().join("platform");
config.default_password = "secret".to_string();
Arc::new(PlatformService::from_config(config).expect("platform"))
}
#[test]
fn the_bundled_set_is_inter_at_four_weights_under_its_short_name() {
let families = FontService::bundled_families();
assert_eq!(families.len(), 1);
assert_eq!(families[0].family, "Inter");
assert_eq!(families[0].weights, vec![400, 500, 600, 800]);
assert_eq!(families[0].source, FontSource::Bundled);
assert_eq!(short_alias("Source Serif 4 36pt"), "Source Serif 4");
assert_eq!(short_alias("Source Serif 4"), "Source Serif 4");
}
#[test]
fn a_project_font_joins_the_bundled_set_and_the_cache_follows_the_folder() {
let platform = platform();
let fonts = &platform.fonts;
let owner = "superadmin";
let project = "default";
let before = fonts.families(owner, project).unwrap();
assert_eq!(before.iter().map(|f| f.family.as_str()).collect::<Vec<_>>(), vec!["Inter"]);
let inter = fonts.resolve(owner, project, "Inter", 800).unwrap().expect("Inter 800");
assert_eq!(inter.weight, 800);
assert_eq!(inter.family, "Inter 24pt");
assert!(fonts.resolve(owner, project, "Fraunces", 400).unwrap().is_none());
let layout = platform.file.ensure_project_layout(owner, project).unwrap();
let dir = layout.repo_source_dir().join(PROJECT_FONTS_DIR);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Brand-Bold.ttf"), BUNDLED[3].1).unwrap();
std::fs::write(dir.join("Brand-Bold.woff2"), b"wOF2 not a font").unwrap();
std::fs::write(dir.join("notes.txt"), b"ignored").unwrap();
let after = fonts.families(owner, project).unwrap();
assert_eq!(after.len(), 2, "{after:?}");
assert_eq!(after[1].source, FontSource::Project);
assert_eq!(after[1].files, vec!["Brand-Bold.ttf"]);
assert_eq!(fonts.project_font_files(owner, project).unwrap(), vec!["Brand-Bold.ttf", "Brand-Bold.woff2"]);
assert_eq!(fonts.fontdb_for(owner, project).unwrap().len(), 5);
let inter = fonts.resolve(owner, project, "Inter", 700).unwrap().expect("Inter near 700");
assert!(inter.weight == 600 || inter.weight == 800);
std::fs::remove_file(dir.join("Brand-Bold.ttf")).unwrap();
assert_eq!(fonts.families(owner, project).unwrap().len(), 1);
}
}