kroki_rs/utils/
font_manager.rs1use 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
9pub 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 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; 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}