Skip to main content

rustmotion_core/engine/renderer/
google_fonts.rs

1use std::path::{Path, PathBuf};
2
3use crate::error::{Result, RustmotionError};
4
5pub fn font_cache_dir() -> PathBuf {
6    #[cfg(target_os = "windows")]
7    let base = std::env::var_os("LOCALAPPDATA")
8        .map(PathBuf::from)
9        .unwrap_or_else(|| PathBuf::from("."));
10
11    #[cfg(not(target_os = "windows"))]
12    let base = std::env::var_os("HOME")
13        .map(|h| PathBuf::from(h).join(".cache"))
14        .unwrap_or_else(|| PathBuf::from(".cache"));
15
16    base.join("rustmotion").join("fonts")
17}
18
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20pub enum RemoteFontPolicy {
21    #[default]
22    Deny,
23    Allow,
24}
25
26static REMOTE_FONT_POLICY: std::sync::atomic::AtomicBool =
27    std::sync::atomic::AtomicBool::new(false);
28
29pub fn set_remote_font_policy(policy: RemoteFontPolicy) {
30    REMOTE_FONT_POLICY.store(
31        policy == RemoteFontPolicy::Allow,
32        std::sync::atomic::Ordering::Release,
33    );
34}
35
36pub fn remote_font_policy() -> RemoteFontPolicy {
37    if REMOTE_FONT_POLICY.load(std::sync::atomic::Ordering::Acquire) {
38        RemoteFontPolicy::Allow
39    } else {
40        RemoteFontPolicy::Deny
41    }
42}
43
44pub fn resolve_google_font(
45    family: &str,
46    weights: &[u16],
47    cache_dir: &Path,
48) -> Result<Vec<PathBuf>> {
49    std::fs::create_dir_all(cache_dir).map_err(RustmotionError::Io)?;
50
51    let slug = family_slug(family);
52    let mut paths = Vec::with_capacity(weights.len());
53    let mut missing_weights: Vec<u16> = Vec::new();
54
55    for &weight in weights {
56        let dest = cache_dir.join(format!("{slug}-{weight}.ttf"));
57        if dest.exists() {
58            paths.push(dest);
59        } else {
60            missing_weights.push(weight);
61        }
62    }
63
64    if missing_weights.is_empty() {
65        return Ok(paths);
66    }
67
68    let url = build_css2_url(family, &missing_weights);
69    if remote_font_policy() != RemoteFontPolicy::Allow {
70        return Err(RustmotionError::RemoteFontDenied {
71            family: family.to_string(),
72            weights: missing_weights
73                .iter()
74                .map(u16::to_string)
75                .collect::<Vec<_>>()
76                .join(", "),
77            url: url.clone(),
78            cache_hint: cache_dir.display().to_string(),
79        });
80    }
81    let css = fetch_css2(&url, family)?;
82
83    let ttf_urls = parse_ttf_urls(&css);
84    if ttf_urls.is_empty() {
85        return Err(RustmotionError::GoogleFontsNoUrls {
86            family: family.to_string(),
87        });
88    }
89
90    for (i, &weight) in missing_weights.iter().enumerate() {
91        let ttf_url = ttf_urls
92            .get(i)
93            .unwrap_or_else(|| &ttf_urls[ttf_urls.len() - 1]);
94        let dest = cache_dir.join(format!("{slug}-{weight}.ttf"));
95        fetch_ttf(ttf_url, &dest, family, weight)?;
96        paths.push(dest);
97    }
98
99    Ok(paths)
100}
101
102pub fn build_css2_url(family: &str, weights: &[u16]) -> String {
103    let family_encoded = family.replace(' ', "+");
104    let weights_str = weights
105        .iter()
106        .map(|w| w.to_string())
107        .collect::<Vec<_>>()
108        .join(";");
109    format!("https://fonts.googleapis.com/css2?family={family_encoded}:wght@{weights_str}")
110}
111
112pub fn parse_ttf_urls(css: &str) -> Vec<String> {
113    let mut urls = Vec::new();
114    let mut search = css;
115    while let Some(start) = search.find("url(") {
116        search = &search[start + 4..];
117        let inner = search.trim_start_matches(['\'', '"']);
118        let end = inner.find([')', '\'', '"']).unwrap_or(inner.len());
119        let url = &inner[..end];
120        if url.ends_with(".ttf") || url.ends_with(".ttf)") {
121            urls.push(url.trim_end_matches(')').to_string());
122        }
123        if let Some(close) = search.find(')') {
124            search = &search[close + 1..];
125        } else {
126            break;
127        }
128    }
129    urls
130}
131
132pub fn family_slug(family: &str) -> String {
133    family.to_lowercase().replace(' ', "-")
134}
135
136fn fetch_css2(url: &str, family: &str) -> Result<String> {
137    let response = ureq::get(url).call().map_err(|e| {
138        let cache_hint = font_cache_dir()
139            .join(format!("{}-<weight>.ttf", family_slug(family)))
140            .display()
141            .to_string();
142        RustmotionError::GoogleFontsFetch {
143            family: family.to_string(),
144            url: url.to_string(),
145            reason: e.to_string(),
146            cache_hint,
147        }
148    })?;
149
150    response
151        .into_body()
152        .read_to_string()
153        .map_err(|e| RustmotionError::GoogleFontsFetch {
154            family: family.to_string(),
155            url: url.to_string(),
156            reason: e.to_string(),
157            cache_hint: font_cache_dir()
158                .join(format!("{}-<weight>.ttf", family_slug(family)))
159                .display()
160                .to_string(),
161        })
162}
163
164fn fetch_ttf(url: &str, dest: &Path, family: &str, weight: u16) -> Result<()> {
165    let response = ureq::get(url)
166        .call()
167        .map_err(|e| RustmotionError::GoogleFontsTtfFetch {
168            family: family.to_string(),
169            weight,
170            reason: e.to_string(),
171        })?;
172
173    let bytes =
174        response
175            .into_body()
176            .read_to_vec()
177            .map_err(|e| RustmotionError::GoogleFontsTtfFetch {
178                family: family.to_string(),
179                weight,
180                reason: e.to_string(),
181            })?;
182
183    std::fs::write(dest, &bytes).map_err(RustmotionError::Io)?;
184    Ok(())
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn url_single_weight() {
193        let url = build_css2_url("Inter", &[400]);
194        assert_eq!(
195            url,
196            "https://fonts.googleapis.com/css2?family=Inter:wght@400"
197        );
198    }
199
200    #[test]
201    fn url_multi_weight() {
202        let url = build_css2_url("Inter", &[400, 700]);
203        assert_eq!(
204            url,
205            "https://fonts.googleapis.com/css2?family=Inter:wght@400;700"
206        );
207    }
208
209    #[test]
210    fn url_spaces_become_plus() {
211        let url = build_css2_url("JetBrains Mono", &[400]);
212        assert_eq!(
213            url,
214            "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400"
215        );
216    }
217
218    #[test]
219    fn parse_single_ttf_url() {
220        let css =
221            r#"src: url(https://fonts.gstatic.com/s/inter/v13/foo-400.ttf) format('truetype');"#;
222        let urls = parse_ttf_urls(css);
223        assert_eq!(
224            urls,
225            vec!["https://fonts.gstatic.com/s/inter/v13/foo-400.ttf"]
226        );
227    }
228
229    #[test]
230    fn parse_multiple_ttf_urls_from_embedded_css() {
231        let css = r#"
232@font-face {
233  font-family: 'Inter';
234  font-style: normal;
235  font-weight: 400;
236  src: url(https://fonts.gstatic.com/s/inter/v13/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7W0Q5nw.ttf) format('truetype');
237}
238@font-face {
239  font-family: 'Inter';
240  font-style: normal;
241  font-weight: 700;
242  src: url(https://fonts.gstatic.com/s/inter/v13/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7W0Q5nw.ttf) format('truetype');
243}
244"#;
245        let urls = parse_ttf_urls(css);
246        assert_eq!(urls.len(), 2);
247        assert!(urls[0].ends_with(".ttf"));
248        assert!(urls[1].ends_with(".ttf"));
249        assert_ne!(urls[0], urls[1]);
250    }
251
252    #[test]
253    fn parse_css_with_no_ttf_returns_empty() {
254        let css = r#"src: url(https://fonts.gstatic.com/foo.woff2) format('woff2');"#;
255        let urls = parse_ttf_urls(css);
256        assert!(urls.is_empty());
257    }
258
259    #[test]
260    fn slug_lowercase_spaces_to_hyphens() {
261        assert_eq!(family_slug("JetBrains Mono"), "jetbrains-mono");
262        assert_eq!(family_slug("Inter"), "inter");
263        assert_eq!(family_slug("Noto Sans SC"), "noto-sans-sc");
264    }
265
266    fn make_test_cache(test_name: &str) -> PathBuf {
267        let base = std::env::temp_dir()
268            .join("rustmotion-test-fonts")
269            .join(test_name);
270        std::fs::create_dir_all(&base).expect("create test cache dir");
271        base
272    }
273
274    #[test]
275    fn cache_hit_returns_path_without_network() {
276        let cache_dir = make_test_cache("cache-hit-single");
277
278        let pre_placed = cache_dir.join("inter-400.ttf");
279        std::fs::write(&pre_placed, b"fake ttf data").unwrap();
280
281        let paths = resolve_google_font("Inter", &[400], &cache_dir).unwrap();
282        assert_eq!(paths.len(), 1);
283        assert_eq!(paths[0], pre_placed);
284    }
285
286    #[test]
287    fn cache_hit_multi_weight_all_present() {
288        let cache_dir = make_test_cache("cache-hit-multi");
289
290        std::fs::write(cache_dir.join("inter-400.ttf"), b"fake400").unwrap();
291        std::fs::write(cache_dir.join("inter-700.ttf"), b"fake700").unwrap();
292
293        let paths = resolve_google_font("Inter", &[400, 700], &cache_dir).unwrap();
294        assert_eq!(paths.len(), 2);
295    }
296
297    #[test]
298    #[ignore = "requires network access"]
299    fn live_fetch_inter_400() {
300        let cache_dir = make_test_cache("live-inter-400");
301        let paths = resolve_google_font("Inter", &[400], &cache_dir).unwrap();
302        assert_eq!(paths.len(), 1);
303        let size = std::fs::metadata(&paths[0]).unwrap().len();
304        assert!(size > 1000, "expected a real TTF, got {size} bytes");
305    }
306
307    #[test]
308    fn build_css2_url_joins_weights_with_semicolons() {
309        assert_eq!(
310            build_css2_url("JetBrains Mono", &[400, 700]),
311            "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700"
312        );
313    }
314
315    #[test]
316    fn parse_ttf_urls_extracts_the_truetype_source() {
317        let css =
318            r#"src: url(https://fonts.gstatic.com/s/inter/v13/foo-400.ttf) format('truetype');"#;
319        assert_eq!(
320            parse_ttf_urls(css),
321            vec!["https://fonts.gstatic.com/s/inter/v13/foo-400.ttf"]
322        );
323    }
324
325    #[test]
326    fn family_slug_lowercases_and_hyphenates() {
327        assert_eq!(family_slug("JetBrains Mono"), "jetbrains-mono");
328        assert_eq!(family_slug("Inter"), "inter");
329    }
330}
331
332#[cfg(test)]
333mod remote_font_policy_tests {
334    use super::*;
335
336    struct Restore(RemoteFontPolicy);
337    impl Drop for Restore {
338        fn drop(&mut self) {
339            set_remote_font_policy(self.0);
340        }
341    }
342
343    fn a_cache_dir(name: &str) -> PathBuf {
344        let dir = std::env::temp_dir().join(format!(
345            "rm_font_policy_{}_{}_{name}",
346            std::process::id(),
347            std::time::SystemTime::now()
348                .duration_since(std::time::UNIX_EPOCH)
349                .unwrap()
350                .as_nanos()
351        ));
352        std::fs::create_dir_all(&dir).expect("cache dir");
353        dir
354    }
355
356    #[test]
357    fn the_default_is_deny() {
358        assert_eq!(
359            RemoteFontPolicy::default(),
360            RemoteFontPolicy::Deny,
361            "a scenario is untrusted input and it chooses the target of the request"
362        );
363    }
364
365    #[test]
366    fn a_family_that_is_not_cached_is_refused_by_name_without_touching_the_network() {
367        let _restore = Restore(remote_font_policy());
368        set_remote_font_policy(RemoteFontPolicy::Deny);
369        let dir = a_cache_dir("denied");
370
371        let err = resolve_google_font("Zilla Slab Highlight", &[700], &dir)
372            .expect_err("a missing weight would have to be fetched");
373        let message = err.to_string();
374        assert!(
375            message.contains("Zilla Slab Highlight") && message.contains("700"),
376            "the refusal has to name the family and the weights: {message}"
377        );
378        assert!(
379            message.contains("fonts.googleapis.com"),
380            "and the target it declined to reach: {message}"
381        );
382        assert!(
383            message.contains("--allow-remote-fonts"),
384            "and the one flag that opts in: {message}"
385        );
386        std::fs::remove_dir_all(&dir).ok();
387    }
388
389    #[test]
390    fn a_family_already_in_the_cache_needs_no_flag() {
391        let _restore = Restore(remote_font_policy());
392        set_remote_font_policy(RemoteFontPolicy::Deny);
393        let dir = a_cache_dir("cached");
394        let slug = family_slug("Zilla Slab Highlight");
395        std::fs::write(dir.join(format!("{slug}-700.ttf")), b"not a real face").expect("seed");
396
397        let paths = resolve_google_font("Zilla Slab Highlight", &[700], &dir)
398            .expect("nothing has to be fetched, so nothing is denied");
399        assert_eq!(
400            paths.len(),
401            1,
402            "denying the network must not break an offline render that already has its fonts"
403        );
404        std::fs::remove_dir_all(&dir).ok();
405    }
406
407    #[test]
408    fn only_the_weights_that_are_missing_are_named_in_the_refusal() {
409        let _restore = Restore(remote_font_policy());
410        set_remote_font_policy(RemoteFontPolicy::Deny);
411        let dir = a_cache_dir("partial");
412        let slug = family_slug("Inter");
413        std::fs::write(dir.join(format!("{slug}-400.ttf")), b"not a real face").expect("seed");
414
415        let message = resolve_google_font("Inter", &[400, 700], &dir)
416            .expect_err("700 is missing")
417            .to_string();
418        assert!(
419            message.contains("700") && !message.contains("400, 700"),
420            "400 is already on disk and needs no request, so it has no business in the \
421             refusal: {message}"
422        );
423        std::fs::remove_dir_all(&dir).ok();
424    }
425}