kroki_rs/utils/
font_manager.rs

1use anyhow::{Context, Result};
2use hex::encode as hex_encode;
3use reqwest::Client;
4use sha2::{Digest, Sha256};
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7use tokio::fs;
8
9/// Manages downloading and caching of custom TTF/OTF fonts.
10pub struct FontManager {
11    cache_dir: PathBuf,
12    client: Client,
13}
14
15impl FontManager {
16    pub fn new(cache_dir: Option<&Path>) -> Result<Self> {
17        let dir = if let Some(d) = cache_dir {
18            d.join("fonts")
19        } else {
20            crate::config::Config::resolve_cache_dir(None)
21                .map(|d| d.join("fonts"))
22                .unwrap_or_else(|| PathBuf::from(".kroki-fonts"))
23        };
24
25        std::fs::create_dir_all(&dir)?;
26
27        Ok(Self {
28            cache_dir: dir,
29            client: Client::builder()
30                .timeout(Duration::from_secs(15))
31                .build()
32                .context("Failed to build font HTTP client")?,
33        })
34    }
35
36    /// Takes a list of URLs (or local paths) and ensures they are available in the cache directory.
37    /// Returns the path to the font cache directory.
38    pub async fn prepare_fonts(&self, urls: &[String]) -> Result<PathBuf> {
39        if urls.is_empty() {
40            return Ok(self.cache_dir.clone());
41        }
42
43        let mut futures = Vec::new();
44
45        for url in urls {
46            let file_name = Self::safe_file_name(url);
47            let local_path = self.cache_dir.join(&file_name);
48
49            if !local_path.exists() {
50                let url_clone = url.clone();
51                let client = self.client.clone();
52
53                futures.push(tokio::spawn(async move {
54                    tracing::info!("Downloading custom font: {}", url_clone);
55                    if Self::is_remote(&url_clone) {
56                        let resp = client.get(&url_clone).send().await?.error_for_status()?;
57                        let content_length = resp.content_length();
58                        let bytes = resp.bytes().await?;
59                        Self::validate_font_size(content_length, bytes.len())?;
60                        fs::write(&local_path, &bytes).await?;
61                    } else {
62                        let path = PathBuf::from(&url_clone);
63                        let metadata = fs::metadata(&path).await?;
64                        Self::validate_font_size(Some(metadata.len()), metadata.len() as usize)?;
65                        let data = fs::read(&path).await?;
66                        fs::write(&local_path, &data).await?;
67                    }
68                    Ok::<_, anyhow::Error>(())
69                }));
70            }
71        }
72
73        for f in futures {
74            f.await??;
75        }
76
77        Ok(self.cache_dir.clone())
78    }
79
80    fn is_remote(url: &str) -> bool {
81        url.starts_with("http://") || url.starts_with("https://")
82    }
83
84    fn safe_file_name(source: &str) -> String {
85        let hash = Sha256::digest(source.as_bytes());
86        let mut name = hex_encode(hash);
87
88        let segment = source
89            .rsplit('/')
90            .next()
91            .and_then(|segment| segment.split('?').next())
92            .and_then(|segment| segment.split('#').next())
93            .filter(|segment| !segment.is_empty());
94
95        if let Some(segment) = segment {
96            if let Some(ext) = Path::new(segment).extension().and_then(|e| e.to_str()) {
97                name.push('.');
98                name.push_str(&ext.to_lowercase());
99                return name;
100            }
101        }
102
103        name.push_str(".ttf");
104        name
105    }
106
107    fn validate_font_size(content_length: Option<u64>, actual: usize) -> Result<()> {
108        const MAX_FONT_BYTES: usize = 5 * 1024 * 1024; // 5 MiB
109
110        if actual > MAX_FONT_BYTES {
111            anyhow::bail!(
112                "Font payload too large ({} bytes). Maximum allowed: {} bytes",
113                actual,
114                MAX_FONT_BYTES
115            );
116        }
117        if let Some(expected) = content_length {
118            if expected as usize > MAX_FONT_BYTES {
119                anyhow::bail!(
120                    "Font payload too large based on Content-Length ({} bytes)",
121                    expected
122                );
123            }
124        }
125
126        Ok(())
127    }
128}