rustmotion_core/engine/renderer/
google_fonts.rs1use 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}