rustmotion_core/engine/renderer/
google_fonts.rs1use std::path::{Path, PathBuf};
26
27use crate::error::{Result, RustmotionError};
28
29pub fn font_cache_dir() -> PathBuf {
38 #[cfg(target_os = "windows")]
39 let base = std::env::var_os("LOCALAPPDATA")
40 .map(PathBuf::from)
41 .unwrap_or_else(|| PathBuf::from("."));
42
43 #[cfg(not(target_os = "windows"))]
44 let base = std::env::var_os("HOME")
45 .map(|h| PathBuf::from(h).join(".cache"))
46 .unwrap_or_else(|| PathBuf::from(".cache"));
47
48 base.join("rustmotion").join("fonts")
49}
50
51pub fn resolve_google_font(
59 family: &str,
60 weights: &[u16],
61 cache_dir: &Path,
62) -> Result<Vec<PathBuf>> {
63 std::fs::create_dir_all(cache_dir).map_err(RustmotionError::Io)?;
64
65 let slug = family_slug(family);
66 let mut paths = Vec::with_capacity(weights.len());
67 let mut missing_weights: Vec<u16> = Vec::new();
68
69 for &weight in weights {
71 let dest = cache_dir.join(format!("{slug}-{weight}.ttf"));
72 if dest.exists() {
73 paths.push(dest);
74 } else {
75 missing_weights.push(weight);
76 }
77 }
78
79 if missing_weights.is_empty() {
80 return Ok(paths);
81 }
82
83 let url = build_css2_url(family, &missing_weights);
85 let css = fetch_css2(&url, family)?;
86
87 let ttf_urls = parse_ttf_urls(&css);
89 if ttf_urls.is_empty() {
90 return Err(RustmotionError::GoogleFontsNoUrls {
91 family: family.to_string(),
92 });
93 }
94
95 for (i, &weight) in missing_weights.iter().enumerate() {
98 let ttf_url = ttf_urls
99 .get(i)
100 .unwrap_or_else(|| &ttf_urls[ttf_urls.len() - 1]);
101 let dest = cache_dir.join(format!("{slug}-{weight}.ttf"));
102 fetch_ttf(ttf_url, &dest, family, weight)?;
103 paths.push(dest);
104 }
105
106 Ok(paths)
107}
108
109pub fn build_css2_url(family: &str, weights: &[u16]) -> String {
124 let family_encoded = family.replace(' ', "+");
125 let weights_str = weights
126 .iter()
127 .map(|w| w.to_string())
128 .collect::<Vec<_>>()
129 .join(";");
130 format!("https://fonts.googleapis.com/css2?family={family_encoded}:wght@{weights_str}")
131}
132
133pub fn parse_ttf_urls(css: &str) -> Vec<String> {
148 let mut urls = Vec::new();
149 let mut search = css;
150 while let Some(start) = search.find("url(") {
151 search = &search[start + 4..];
152 let inner = search.trim_start_matches(['\'', '"']);
154 let end = inner.find([')', '\'', '"']).unwrap_or(inner.len());
155 let url = &inner[..end];
156 if url.ends_with(".ttf") || url.ends_with(".ttf)") {
157 urls.push(url.trim_end_matches(')').to_string());
158 }
159 if let Some(close) = search.find(')') {
161 search = &search[close + 1..];
162 } else {
163 break;
164 }
165 }
166 urls
167}
168
169pub fn family_slug(family: &str) -> String {
183 family.to_lowercase().replace(' ', "-")
184}
185
186fn fetch_css2(url: &str, family: &str) -> Result<String> {
189 let response = ureq::get(url).call().map_err(|e| {
190 let cache_hint = font_cache_dir()
191 .join(format!("{}-<weight>.ttf", family_slug(family)))
192 .display()
193 .to_string();
194 RustmotionError::GoogleFontsFetch {
195 family: family.to_string(),
196 url: url.to_string(),
197 reason: e.to_string(),
198 cache_hint,
199 }
200 })?;
201
202 response
203 .into_body()
204 .read_to_string()
205 .map_err(|e| RustmotionError::GoogleFontsFetch {
206 family: family.to_string(),
207 url: url.to_string(),
208 reason: e.to_string(),
209 cache_hint: font_cache_dir()
210 .join(format!("{}-<weight>.ttf", family_slug(family)))
211 .display()
212 .to_string(),
213 })
214}
215
216fn fetch_ttf(url: &str, dest: &Path, family: &str, weight: u16) -> Result<()> {
217 let response = ureq::get(url)
218 .call()
219 .map_err(|e| RustmotionError::GoogleFontsTtfFetch {
220 family: family.to_string(),
221 weight,
222 reason: e.to_string(),
223 })?;
224
225 let bytes =
226 response
227 .into_body()
228 .read_to_vec()
229 .map_err(|e| RustmotionError::GoogleFontsTtfFetch {
230 family: family.to_string(),
231 weight,
232 reason: e.to_string(),
233 })?;
234
235 std::fs::write(dest, &bytes).map_err(RustmotionError::Io)?;
236 Ok(())
237}
238
239#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
248 fn url_single_weight() {
249 let url = build_css2_url("Inter", &[400]);
250 assert_eq!(
251 url,
252 "https://fonts.googleapis.com/css2?family=Inter:wght@400"
253 );
254 }
255
256 #[test]
257 fn url_multi_weight() {
258 let url = build_css2_url("Inter", &[400, 700]);
259 assert_eq!(
260 url,
261 "https://fonts.googleapis.com/css2?family=Inter:wght@400;700"
262 );
263 }
264
265 #[test]
266 fn url_spaces_become_plus() {
267 let url = build_css2_url("JetBrains Mono", &[400]);
268 assert_eq!(
269 url,
270 "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400"
271 );
272 }
273
274 #[test]
277 fn parse_single_ttf_url() {
278 let css =
279 r#"src: url(https://fonts.gstatic.com/s/inter/v13/foo-400.ttf) format('truetype');"#;
280 let urls = parse_ttf_urls(css);
281 assert_eq!(
282 urls,
283 vec!["https://fonts.gstatic.com/s/inter/v13/foo-400.ttf"]
284 );
285 }
286
287 #[test]
288 fn parse_multiple_ttf_urls_from_embedded_css() {
289 let css = r#"
291@font-face {
292 font-family: 'Inter';
293 font-style: normal;
294 font-weight: 400;
295 src: url(https://fonts.gstatic.com/s/inter/v13/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7W0Q5nw.ttf) format('truetype');
296}
297@font-face {
298 font-family: 'Inter';
299 font-style: normal;
300 font-weight: 700;
301 src: url(https://fonts.gstatic.com/s/inter/v13/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7W0Q5nw.ttf) format('truetype');
302}
303"#;
304 let urls = parse_ttf_urls(css);
305 assert_eq!(urls.len(), 2);
306 assert!(urls[0].ends_with(".ttf"));
307 assert!(urls[1].ends_with(".ttf"));
308 assert_ne!(urls[0], urls[1]);
309 }
310
311 #[test]
312 fn parse_css_with_no_ttf_returns_empty() {
313 let css = r#"src: url(https://fonts.gstatic.com/foo.woff2) format('woff2');"#;
314 let urls = parse_ttf_urls(css);
315 assert!(urls.is_empty());
316 }
317
318 #[test]
321 fn slug_lowercase_spaces_to_hyphens() {
322 assert_eq!(family_slug("JetBrains Mono"), "jetbrains-mono");
323 assert_eq!(family_slug("Inter"), "inter");
324 assert_eq!(family_slug("Noto Sans SC"), "noto-sans-sc");
325 }
326
327 fn make_test_cache(test_name: &str) -> PathBuf {
331 let base = std::env::temp_dir()
332 .join("rustmotion-test-fonts")
333 .join(test_name);
334 std::fs::create_dir_all(&base).expect("create test cache dir");
335 base
336 }
337
338 #[test]
339 fn cache_hit_returns_path_without_network() {
340 let cache_dir = make_test_cache("cache-hit-single");
341
342 let pre_placed = cache_dir.join("inter-400.ttf");
344 std::fs::write(&pre_placed, b"fake ttf data").unwrap();
345
346 let paths = resolve_google_font("Inter", &[400], &cache_dir).unwrap();
350 assert_eq!(paths.len(), 1);
351 assert_eq!(paths[0], pre_placed);
352 }
353
354 #[test]
355 fn cache_hit_multi_weight_all_present() {
356 let cache_dir = make_test_cache("cache-hit-multi");
357
358 std::fs::write(cache_dir.join("inter-400.ttf"), b"fake400").unwrap();
359 std::fs::write(cache_dir.join("inter-700.ttf"), b"fake700").unwrap();
360
361 let paths = resolve_google_font("Inter", &[400, 700], &cache_dir).unwrap();
362 assert_eq!(paths.len(), 2);
363 }
364
365 #[test]
367 #[ignore = "requires network access"]
368 fn live_fetch_inter_400() {
369 let cache_dir = make_test_cache("live-inter-400");
370 let paths = resolve_google_font("Inter", &[400], &cache_dir).unwrap();
371 assert_eq!(paths.len(), 1);
372 let size = std::fs::metadata(&paths[0]).unwrap().len();
373 assert!(size > 1000, "expected a real TTF, got {size} bytes");
374 }
375}