Skip to main content

rustmotion_core/engine/renderer/
google_fonts.rs

1//! Google Fonts download and disk-cache helpers.
2//!
3//! ## Cache layout
4//!
5//! ```text
6//! ~/.cache/rustmotion/fonts/
7//!     inter-400.ttf
8//!     inter-700.ttf
9//!     jetbrains-mono-400.ttf
10//! ```
11//!
12//! File names are derived from the family name by lower-casing and replacing
13//! spaces with hyphens: `"JetBrains Mono"` → `"jetbrains-mono"`.
14//!
15//! ## Network protocol
16//!
17//! 1. `GET https://fonts.googleapis.com/css2?family=<Family+Name>:wght@<w1>;<w2>`
18//!    with `ureq`'s default (non-browser) User-Agent.  Google returns a CSS
19//!    stylesheet containing `url(…)` entries pointing at `.ttf` files.
20//! 2. We extract every `url(…)` from the CSS and download each TTF.
21//!
22//! No new crate dependencies are introduced; `ureq` (v3) is already in
23//! `rustmotion-core`.
24
25use std::path::{Path, PathBuf};
26
27use crate::error::{Result, RustmotionError};
28
29// ─── Cache directory ────────────────────────────────────────────────────────
30
31/// Returns the platform-appropriate font cache directory.
32///
33/// - macOS/Linux: `$HOME/.cache/rustmotion/fonts`
34/// - Windows:     `%LOCALAPPDATA%\rustmotion\fonts`
35///
36/// The directory is created if it does not exist.
37pub 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
51// ─── Public entry point ─────────────────────────────────────────────────────
52
53/// Ensure TTFs for `family` + `weights` are present on disk (download if needed)
54/// and return their paths.
55///
56/// `cache_dir` is accepted as a parameter so tests can inject a temporary
57/// directory without touching `$HOME`.
58pub 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    // Check which weights are already cached.
70    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    // Fetch the CSS2 stylesheet from Google Fonts.
84    let url = build_css2_url(family, &missing_weights);
85    let css = fetch_css2(&url, family)?;
86
87    // Parse the TTF URLs out of the CSS.
88    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    // Download each TTF. We map each URL to a weight in order because
96    // Google Fonts returns one face per weight in the order requested.
97    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
109// ─── URL construction ────────────────────────────────────────────────────────
110
111/// Build the Google Fonts CSS2 API URL.
112///
113/// Spaces in the family name are replaced with `+`; weights are joined with
114/// `;` as per the CSS2 API spec.
115///
116/// # Examples
117///
118/// ```
119/// # use rustmotion_core::engine::renderer::google_fonts::build_css2_url;
120/// let url = build_css2_url("JetBrains Mono", &[400, 700]);
121/// assert_eq!(url, "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700");
122/// ```
123pub 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
133// ─── CSS parsing ─────────────────────────────────────────────────────────────
134
135/// Extract all `url(…)` values that end with `.ttf` from a Google Fonts CSS
136/// response.  No regex crate is used — we find `url(` markers and extract the
137/// inner content.
138///
139/// # Examples
140///
141/// ```
142/// # use rustmotion_core::engine::renderer::google_fonts::parse_ttf_urls;
143/// let css = r#"src: url(https://fonts.gstatic.com/s/inter/v13/foo-400.ttf) format('truetype');"#;
144/// let urls = parse_ttf_urls(css);
145/// assert_eq!(urls, vec!["https://fonts.gstatic.com/s/inter/v13/foo-400.ttf"]);
146/// ```
147pub 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        // Strip optional surrounding quotes.
153        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        // Advance past the closing paren.
160        if let Some(close) = search.find(')') {
161            search = &search[close + 1..];
162        } else {
163            break;
164        }
165    }
166    urls
167}
168
169// ─── File-name slug ──────────────────────────────────────────────────────────
170
171/// Convert a font family name to a filesystem-safe slug.
172///
173/// `"JetBrains Mono"` → `"jetbrains-mono"`
174///
175/// # Examples
176///
177/// ```
178/// # use rustmotion_core::engine::renderer::google_fonts::family_slug;
179/// assert_eq!(family_slug("JetBrains Mono"), "jetbrains-mono");
180/// assert_eq!(family_slug("Inter"), "inter");
181/// ```
182pub fn family_slug(family: &str) -> String {
183    family.to_lowercase().replace(' ', "-")
184}
185
186// ─── Network helpers ─────────────────────────────────────────────────────────
187
188fn 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// ─── Unit tests ──────────────────────────────────────────────────────────────
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    // --- URL construction ---
246
247    #[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    // --- CSS parsing ---
275
276    #[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        // Simulate a real (stripped) Google Fonts CSS2 response for Inter 400+700.
290        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    // --- File slug ---
319
320    #[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    // --- Cache hit (zero network) ---
328
329    /// Create a unique temp directory for the test (no tempfile crate needed).
330    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        // Pre-position the TTF so no network call is needed.
343        let pre_placed = cache_dir.join("inter-400.ttf");
344        std::fs::write(&pre_placed, b"fake ttf data").unwrap();
345
346        // resolve_google_font must return the pre-placed path without hitting
347        // the network (if it did hit the network and failed, the test would
348        // error — no mocking needed).
349        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    // Live network test — excluded from normal CI runs.
366    #[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}