k2f_docx 0.2.3

K2F lock → DOCX exporter
Documentation
use std::collections::BTreeMap;
use ttf_parser::{name_id, Face, Language};

pub(crate) struct FontCtx {
    default_family: String,
    families: BTreeMap<String, String>,
    bytes: BTreeMap<String, Vec<u8>>,
}

impl FontCtx {
    pub(crate) fn new(fonts: &BTreeMap<String, Vec<u8>>) -> Self {
        let bytes: BTreeMap<String, Vec<u8>> = fonts
            .iter()
            .filter(|(k, _)| k2f_core::is_font_face_path(k))
            .map(|(k, v)| (k.clone(), v.clone()))
            .collect();
        let mut families = BTreeMap::new();
        for (key, data) in &bytes {
            if let Some(name) = family_from_bytes(data) {
                families.insert(key.clone(), name);
            }
        }
        let default_family = bytes
            .get("default")
            .and_then(|b| family_from_bytes(b))
            .or_else(|| {
                bytes
                    .iter()
                    .find(|(k, _)| k.contains("Roboto") || *k == "default")
                    .and_then(|(_, b)| family_from_bytes(b))
            })
            .or_else(|| families.values().next().cloned())
            .unwrap_or_else(|| "Roboto".into());
        if let Some(data) = bytes.get("default") {
            if let Some(name) = family_from_bytes(data) {
                families.insert("default".into(), name);
            }
        }
        families
            .entry("default".into())
            .or_insert_with(|| default_family.clone());
        Self {
            default_family,
            families,
            bytes,
        }
    }

    pub(crate) fn typeface(&self, family: &str) -> String {
        if let Some(data) = self.bytes_for(family) {
            if let Some(name) = family_from_bytes(data) {
                return name;
            }
        }
        if let Some(name) = self.families.get(family) {
            return name.clone();
        }
        if family == "default" {
            return self.default_family.clone();
        }
        family.to_string()
    }

    pub(crate) fn bytes_for(&self, family: &str) -> Option<&[u8]> {
        if let Some(b) = self.bytes.get(family) {
            return Some(b.as_slice());
        }
        for (path, b) in &self.bytes {
            let p = std::path::Path::new(path);
            if p.file_stem().is_some_and(|s| s == family) {
                return Some(b.as_slice());
            }
            if p.file_name().is_some_and(|s| s == family) {
                return Some(b.as_slice());
            }
        }
        for b in self.bytes.values() {
            if family_from_bytes(b).as_deref() == Some(family) {
                return Some(b.as_slice());
            }
        }
        if family == "default" {
            if let Some(b) = self.bytes.get("default") {
                return Some(b.as_slice());
            }
            if let Some((_, b)) = self.bytes.iter().find(|(k, _)| k.contains("Roboto")) {
                return Some(b.as_slice());
            }
            return self
                .bytes
                .values()
                .min_by_key(|b| b.len())
                .map(|b| b.as_slice());
        }
        None
    }
}

fn family_from_bytes(data: &[u8]) -> Option<String> {
    let face = Face::parse(data, 0).ok()?;
    let mut fallback = None;
    for name in face.names() {
        if name.name_id != name_id::FAMILY || !name.is_unicode() {
            continue;
        }
        let Some(s) = name.to_string() else {
            continue;
        };
        if name.language() == Language::English_UnitedStates {
            return Some(s);
        }
        if fallback.is_none() {
            fallback = Some(s);
        }
    }
    fallback
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::BTreeMap;
    use std::path::PathBuf;

    fn roboto() -> Vec<u8> {
        let path =
            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Roboto-Regular.ttf");
        std::fs::read(path).expect("Roboto-Regular.ttf")
    }

    #[test]
    fn alias_stem_resolves_to_ttf_family_and_bytes() {
        let mut fonts = BTreeMap::new();
        fonts.insert("assets/fonts/Roboto-Regular.ttf".into(), roboto());
        let ctx = FontCtx::new(&fonts);
        assert_eq!(ctx.typeface("Roboto-Regular"), "Roboto");
        assert_eq!(ctx.typeface("default"), "Roboto");
        assert!(ctx.bytes_for("Roboto-Regular").is_some());
        assert!(ctx.bytes_for("default").is_some());
    }

    #[test]
    fn license_sidecar_is_not_default_font_bytes() {
        let mut fonts = BTreeMap::new();
        fonts.insert(
            "assets/fonts/licenses/Roboto-Apache.txt".into(),
            b"Apache-2.0".to_vec(),
        );
        fonts.insert("assets/fonts/Roboto-Regular.ttf".into(), roboto());
        let ctx = FontCtx::new(&fonts);
        let bytes = ctx.bytes_for("default").expect("face");
        assert!(
            bytes.len() > 1000,
            "must be the TTF, not the shorter license txt"
        );
        assert_eq!(ctx.typeface("default"), "Roboto");
        assert!(ctx
            .bytes_for("assets/fonts/licenses/Roboto-Apache.txt")
            .is_none());
    }
}