cryosnap-core 0.2.11

Core rendering library for cryosnap.
Documentation
use super::constants::{
    AUTO_FALLBACK_CJK, AUTO_FALLBACK_EMOJI, AUTO_FALLBACK_GLOBAL, AUTO_FALLBACK_NF,
};
use super::dirs::resolve_font_dirs;
use super::models::{FontFallbackNeeds, FontPlan};
use crate::{Config, FontSystemFallback, Result};
use once_cell::sync::Lazy;
use std::collections::{HashSet, VecDeque};
use std::path::PathBuf;
use std::sync::Mutex;

#[cfg(test)]
use std::collections::HashMap;

const FONTDB_CACHE_CAPACITY: usize = 8;
const APP_FAMILIES_CACHE_CAPACITY: usize = 8;

#[cfg(test)]
static FONTDB_BUILD_MISSES: Lazy<Mutex<HashMap<FontDbCacheKey, usize>>> =
    Lazy::new(|| Mutex::new(HashMap::new()));

#[cfg(test)]
pub(crate) fn reset_fontdb_build_miss_count_for_tests() {
    FONTDB_BUILD_MISSES.lock().expect("font miss lock").clear();
}

#[cfg(test)]
pub(crate) fn fontdb_build_miss_count_for_config(
    config: &Config,
    needs_system_fonts: bool,
) -> usize {
    let Ok(dirs) = resolve_font_dirs(config) else {
        return 0;
    };
    let key = FontDbCacheKey {
        dirs_key: dirs_cache_key(&dirs),
        font_file: config.font.file.as_ref().map(|v| font_file_key(v)),
        needs_system_fonts,
    };
    *FONTDB_BUILD_MISSES
        .lock()
        .expect("font miss lock")
        .get(&key)
        .unwrap_or(&0)
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct FontFileKey {
    path: String,
    len: u64,
    modified_ns: Option<u128>,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct FontDbCacheKey {
    dirs_key: String,
    font_file: Option<FontFileKey>,
    needs_system_fonts: bool,
}

#[derive(Default)]
struct FontCacheState {
    fontdb: VecDeque<(FontDbCacheKey, usvg::fontdb::Database)>,
    app_families: VecDeque<(String, HashSet<String>)>,
}

static FONT_CACHE: Lazy<Mutex<FontCacheState>> =
    Lazy::new(|| Mutex::new(FontCacheState::default()));

pub(crate) fn invalidate_font_caches() {
    let mut cache = FONT_CACHE.lock().expect("font cache lock");
    cache.fontdb.clear();
    cache.app_families.clear();
}

fn dirs_cache_key(dirs: &[PathBuf]) -> String {
    dirs.iter()
        .map(|dir| dir.to_string_lossy())
        .collect::<Vec<_>>()
        .join("\n")
}

fn font_file_key(path: &str) -> FontFileKey {
    let metadata = std::fs::metadata(path).ok();
    let len = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
    let modified_ns = metadata
        .and_then(|m| m.modified().ok())
        .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
        .map(|d| d.as_nanos());
    FontFileKey {
        path: path.to_string(),
        len,
        modified_ns,
    }
}

fn fontdb_cache_get(key: &FontDbCacheKey) -> Option<usvg::fontdb::Database> {
    let mut cache = FONT_CACHE.lock().expect("font cache lock");
    let pos = cache.fontdb.iter().position(|(k, _)| k == key)?;
    let (k, db) = cache.fontdb.remove(pos)?;
    let out = db.clone();
    cache.fontdb.push_back((k, db));
    Some(out)
}

fn fontdb_cache_put(key: FontDbCacheKey, db: usvg::fontdb::Database) {
    let mut cache = FONT_CACHE.lock().expect("font cache lock");
    if let Some(pos) = cache.fontdb.iter().position(|(k, _)| k == &key) {
        let _ = cache.fontdb.remove(pos);
    }
    cache.fontdb.push_back((key, db));
    while cache.fontdb.len() > FONTDB_CACHE_CAPACITY {
        cache.fontdb.pop_front();
    }
}

fn app_families_cache_get(key: &str) -> Option<HashSet<String>> {
    let mut cache = FONT_CACHE.lock().expect("font cache lock");
    let pos = cache.app_families.iter().position(|(k, _)| k == key)?;
    let (k, families) = cache.app_families.remove(pos)?;
    let out = families.clone();
    cache.app_families.push_back((k, families));
    Some(out)
}

fn app_families_cache_put(key: String, families: HashSet<String>) {
    let mut cache = FONT_CACHE.lock().expect("font cache lock");
    if let Some(pos) = cache.app_families.iter().position(|(k, _)| k == &key) {
        let _ = cache.app_families.remove(pos);
    }
    cache.app_families.push_back((key, families));
    while cache.app_families.len() > APP_FAMILIES_CACHE_CAPACITY {
        cache.app_families.pop_front();
    }
}

pub(crate) fn push_family(out: &mut Vec<String>, seen: &mut HashSet<String>, name: &str) {
    let trimmed = name.trim();
    if trimmed.is_empty() {
        return;
    }
    let key = trimmed.to_ascii_lowercase();
    if seen.insert(key) {
        out.push(trimmed.to_string());
    }
}

pub(crate) fn family_key(name: &str) -> String {
    name.trim().to_ascii_lowercase()
}

pub(crate) fn is_generic_family(name: &str) -> bool {
    matches!(
        name.trim().to_ascii_lowercase().as_str(),
        "serif" | "sans-serif" | "sans" | "monospace" | "cursive" | "fantasy"
    )
}

pub(crate) fn build_font_families(
    config: &Config,
    needs: &FontFallbackNeeds,
    script_families: &[String],
) -> Vec<String> {
    let mut families = Vec::new();
    let mut seen = HashSet::new();
    push_family(&mut families, &mut seen, &config.font.family);
    for name in &config.font.fallbacks {
        push_family(&mut families, &mut seen, name);
    }
    for name in script_families {
        push_family(&mut families, &mut seen, name);
    }

    if needs.needs_nf {
        for name in AUTO_FALLBACK_NF {
            push_family(&mut families, &mut seen, name);
        }
    }
    if needs.needs_cjk {
        for name in AUTO_FALLBACK_CJK {
            push_family(&mut families, &mut seen, name);
        }
    }
    if needs.needs_unicode {
        for name in AUTO_FALLBACK_GLOBAL {
            push_family(&mut families, &mut seen, name);
        }
    }
    if needs.needs_emoji {
        for name in AUTO_FALLBACK_EMOJI {
            push_family(&mut families, &mut seen, name);
        }
    }

    families
}

pub(crate) fn family_requires_system(name: &str, app_families: &HashSet<String>) -> bool {
    if is_generic_family(name) {
        return true;
    }
    let key = family_key(name);
    !app_families.contains(&key)
}

pub(crate) fn needs_system_fonts(
    config: &Config,
    app_families: &HashSet<String>,
    families: &[String],
) -> bool {
    match config.font.system_fallback {
        FontSystemFallback::Never => return false,
        FontSystemFallback::Always => return true,
        FontSystemFallback::Auto => {}
    }
    let mut needs = false;
    if config.font.file.is_none() && family_requires_system(&config.font.family, app_families) {
        needs = true;
    }
    if !needs {
        for name in families {
            if config.font.file.is_some() && name.eq_ignore_ascii_case(&config.font.family) {
                continue;
            }
            if family_requires_system(name, app_families) {
                needs = true;
                break;
            }
        }
    }
    needs
}

pub(crate) fn build_font_plan(
    config: &Config,
    needs: &FontFallbackNeeds,
    app_families: &HashSet<String>,
    script_families: &[String],
) -> FontPlan {
    let families = build_font_families(config, needs, script_families);
    let font_family = families.join(", ");
    let needs_system_fonts = needs_system_fonts(config, app_families, &families);
    FontPlan {
        font_family,
        needs_system_fonts,
    }
}

pub(crate) fn build_fontdb(
    config: &Config,
    needs_system_fonts: bool,
) -> Result<usvg::fontdb::Database> {
    let dirs = resolve_font_dirs(config)?;
    let dirs_key = dirs_cache_key(&dirs);
    let font_file = config.font.file.as_ref().map(|v| font_file_key(v));
    let key = FontDbCacheKey {
        dirs_key,
        font_file,
        needs_system_fonts,
    };
    if let Some(db) = fontdb_cache_get(&key) {
        return Ok(db);
    }

    #[cfg(test)]
    {
        let mut misses = FONTDB_BUILD_MISSES.lock().expect("font miss lock");
        *misses.entry(key.clone()).or_insert(0) += 1;
    }

    let mut fontdb = usvg::fontdb::Database::new();
    if let Some(font_file) = &config.font.file {
        let bytes = std::fs::read(font_file)?;
        fontdb.load_font_data(bytes);
    }
    for dir in dirs {
        if dir.is_dir() {
            fontdb.load_fonts_dir(dir);
        }
    }
    if needs_system_fonts {
        fontdb.load_system_fonts();
    }
    fontdb_cache_put(key, fontdb.clone());
    Ok(fontdb)
}

pub(crate) fn collect_font_families(db: &usvg::fontdb::Database) -> HashSet<String> {
    let mut families = HashSet::new();
    for face in db.faces() {
        for (family, _) in &face.families {
            families.insert(family_key(family));
        }
    }
    families
}

pub(crate) fn load_app_font_families(config: &Config) -> Result<HashSet<String>> {
    let dirs = resolve_font_dirs(config)?;
    let key = dirs_cache_key(&dirs);
    if let Some(families) = app_families_cache_get(&key) {
        return Ok(families);
    }

    let mut fontdb = usvg::fontdb::Database::new();
    for dir in dirs {
        if dir.is_dir() {
            fontdb.load_fonts_dir(dir);
        }
    }
    let families = collect_font_families(&fontdb);
    app_families_cache_put(key, families.clone());
    Ok(families)
}

pub(crate) fn load_system_font_families() -> HashSet<String> {
    static SYSTEM_FAMILIES: Lazy<HashSet<String>> = Lazy::new(|| {
        let mut fontdb = usvg::fontdb::Database::new();
        fontdb.load_system_fonts();
        collect_font_families(&fontdb)
    });
    SYSTEM_FAMILIES.clone()
}