From 11d844f6973368376de54eab9e4c7f4a399f702c Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 19 Jul 2026 01:25:24 +0200 Subject: [PATCH] feat(fonts): Google Fonts fetching with local disk cache fonts: [{family: "Inter", source: "google", weights: [400,700]}] (and in the HTML dialect) downloads TTFs via the css2 endpoint with a non-browser UA and caches them in ~/.cache/rustmotion/fonts (LOCALAPPDATA on Windows). Cache hits never touch the network (tested with injected cache dirs); the offline error names the family, the URL tried and the exact cache path for manual placement. path stays required for local entries (path+source conflict and missing-both are dedicated errors); existing {path, family} entries deserialize and load unchanged. No new dependencies (ureq was already there for icons). Clippy warnings from the agent pass fixed during integration review. --- .../src/engine/renderer/fonts.rs | 186 ++++++++- .../src/engine/renderer/google_fonts.rs | 375 ++++++++++++++++++ .../src/engine/renderer/mod.rs | 1 + crates/rustmotion-core/src/error.rs | 35 ++ crates/rustmotion-core/src/schema/scenario.rs | 21 +- crates/rustmotion-html/src/lib.rs | 106 ++++- 6 files changed, 696 insertions(+), 28 deletions(-) create mode 100644 crates/rustmotion-core/src/engine/renderer/google_fonts.rs diff --git a/crates/rustmotion-core/src/engine/renderer/fonts.rs b/crates/rustmotion-core/src/engine/renderer/fonts.rs index d7e3a63..9d590f0 100644 --- a/crates/rustmotion-core/src/engine/renderer/fonts.rs +++ b/crates/rustmotion-core/src/engine/renderer/fonts.rs @@ -3,6 +3,8 @@ use skia_safe::{FontMgr, FontStyle, Typeface}; use crate::error::{Result, RustmotionError}; use crate::schema::FontEntry; +use super::google_fonts::{font_cache_dir, resolve_google_font}; + // Thread-local FontMgr instance, created once per thread and reused thread_local! { static THREAD_FONT_MGR: FontMgr = FontMgr::default(); @@ -12,36 +14,90 @@ pub fn font_mgr() -> FontMgr { THREAD_FONT_MGR.with(|mgr| mgr.clone()) } +/// Validate a `FontEntry` and resolve it to a list of TTF file paths. +/// +/// - Local entry (`path` set, `source` absent): returns `[path]` as-is. +/// - Google Fonts entry (`source = "google"`, `path` absent): downloads +/// (or reads from cache) and returns one path per requested weight. +/// - Conflict (`source` and `path` both set): returns an error. +/// - Neither (`path` absent and `source` absent): returns an error. +pub fn resolve_font_entry(entry: &FontEntry) -> Result> { + match (&entry.source, &entry.path) { + // Conflict: both path and source set. + (Some(_), Some(_)) => Err(RustmotionError::FontSourceAndPathConflict { + family: entry.family.clone(), + }), + // Google Fonts. + (Some(source), None) if source == "google" => { + let weights = entry + .weights + .as_deref() + .filter(|w| !w.is_empty()) + .unwrap_or(&[400]); + let cache_dir = font_cache_dir(); + resolve_google_font(&entry.family, weights, &cache_dir) + } + // Unknown source value — treat as a user error. + (Some(other), None) => Err(RustmotionError::Generic(format!( + "FontEntry for '{}': unknown source value '{}' (only \"google\" is supported)", + entry.family, other + ))), + // Local file. + (None, Some(path)) => Ok(vec![std::path::PathBuf::from(path)]), + // Neither path nor source. + (None, None) => Err(RustmotionError::FontMissingPath { + family: entry.family.clone(), + }), + } +} + /// Load custom fonts from FontEntry definitions. Emits a single warning per /// missing or unreadable file so the user notices broken paths up-front. pub fn load_custom_fonts(fonts: &[FontEntry]) { let font_mgr = font_mgr(); for entry in fonts { - let path = std::path::Path::new(&entry.path); - if !path.exists() { - eprintln!( - "Warning: custom font '{}' not found at '{}' — falling back to system fonts", - entry.family, entry.path - ); - continue; - } - match std::fs::read(path) { - Ok(data) => { - let sk_data = skia_safe::Data::new_copy(&data); - if font_mgr.new_from_data(&sk_data, None).is_none() { - eprintln!( - "Warning: failed to register custom font '{}' from '{}'", - entry.family, entry.path - ); + match resolve_font_entry(entry) { + Err(e) => { + eprintln!("Warning: {e}"); + } + Ok(paths) => { + for path in paths { + register_font_file(&font_mgr, &entry.family, &path); } } - Err(e) => { + } + } +} + +/// Register a single TTF/OTF file into the given FontMgr. +fn register_font_file(font_mgr: &FontMgr, family: &str, path: &std::path::Path) { + if !path.exists() { + eprintln!( + "Warning: custom font '{}' not found at '{}' — falling back to system fonts", + family, + path.display() + ); + return; + } + match std::fs::read(path) { + Ok(data) => { + let sk_data = skia_safe::Data::new_copy(&data); + if font_mgr.new_from_data(&sk_data, None).is_none() { eprintln!( - "Warning: failed to read custom font '{}' from '{}': {}", - entry.family, entry.path, e + "Warning: failed to register custom font '{}' from '{}'", + family, + path.display() ); } } + Err(e) => { + eprintln!( + "Warning: failed to read custom font '{}' from '{}': {}", + family, + path.display(), + e + ); + } } } @@ -80,3 +136,95 @@ pub fn emoji_typeface() -> Option { } EMOJI_TF.with(|tf| tf.clone()) } + +// ─── Unit tests ────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn local_entry(path: &str) -> FontEntry { + FontEntry { + path: Some(path.to_string()), + family: "TestFamily".to_string(), + source: None, + weights: None, + } + } + + fn google_entry(family: &str, weights: Option>) -> FontEntry { + FontEntry { + path: None, + family: family.to_string(), + source: Some("google".to_string()), + weights, + } + } + + fn neither_entry() -> FontEntry { + FontEntry { + path: None, + family: "Broken".to_string(), + source: None, + weights: None, + } + } + + fn conflict_entry() -> FontEntry { + FontEntry { + path: Some("fonts/Inter.ttf".to_string()), + family: "Inter".to_string(), + source: Some("google".to_string()), + weights: None, + } + } + + #[test] + fn local_entry_resolves_to_path() { + let entry = local_entry("fonts/Inter.ttf"); + let paths = resolve_font_entry(&entry).unwrap(); + assert_eq!(paths.len(), 1); + assert_eq!(paths[0].to_str().unwrap(), "fonts/Inter.ttf"); + } + + #[test] + fn neither_path_nor_source_is_error() { + let entry = neither_entry(); + let err = resolve_font_entry(&entry).unwrap_err(); + assert!( + matches!(err, RustmotionError::FontMissingPath { .. }), + "expected FontMissingPath, got: {err}" + ); + } + + #[test] + fn path_and_source_conflict_is_error() { + let entry = conflict_entry(); + let err = resolve_font_entry(&entry).unwrap_err(); + assert!( + matches!(err, RustmotionError::FontSourceAndPathConflict { .. }), + "expected FontSourceAndPathConflict, got: {err}" + ); + } + + #[test] + fn google_entry_with_cached_file_resolves() { + // Build a pre-warmed cache dir and inject it via resolve_google_font directly. + let cache_dir = std::env::temp_dir() + .join("rustmotion-test-fonts") + .join("fonts-rs-google-cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + std::fs::write(cache_dir.join("inter-400.ttf"), b"fake ttf").unwrap(); + + let entry = google_entry("Inter", None); + // We call resolve_google_font directly with the injected dir to avoid + // any real network in unit tests. + let paths = crate::engine::renderer::google_fonts::resolve_google_font( + &entry.family, + &[400], + &cache_dir, + ) + .unwrap(); + assert_eq!(paths.len(), 1); + } +} diff --git a/crates/rustmotion-core/src/engine/renderer/google_fonts.rs b/crates/rustmotion-core/src/engine/renderer/google_fonts.rs new file mode 100644 index 0000000..6f34a01 --- /dev/null +++ b/crates/rustmotion-core/src/engine/renderer/google_fonts.rs @@ -0,0 +1,375 @@ +//! Google Fonts download and disk-cache helpers. +//! +//! ## Cache layout +//! +//! ```text +//! ~/.cache/rustmotion/fonts/ +//! inter-400.ttf +//! inter-700.ttf +//! jetbrains-mono-400.ttf +//! ``` +//! +//! File names are derived from the family name by lower-casing and replacing +//! spaces with hyphens: `"JetBrains Mono"` → `"jetbrains-mono"`. +//! +//! ## Network protocol +//! +//! 1. `GET https://fonts.googleapis.com/css2?family=:wght@;` +//! with `ureq`'s default (non-browser) User-Agent. Google returns a CSS +//! stylesheet containing `url(…)` entries pointing at `.ttf` files. +//! 2. We extract every `url(…)` from the CSS and download each TTF. +//! +//! No new crate dependencies are introduced; `ureq` (v3) is already in +//! `rustmotion-core`. + +use std::path::{Path, PathBuf}; + +use crate::error::{Result, RustmotionError}; + +// ─── Cache directory ──────────────────────────────────────────────────────── + +/// Returns the platform-appropriate font cache directory. +/// +/// - macOS/Linux: `$HOME/.cache/rustmotion/fonts` +/// - Windows: `%LOCALAPPDATA%\rustmotion\fonts` +/// +/// The directory is created if it does not exist. +pub fn font_cache_dir() -> PathBuf { + #[cfg(target_os = "windows")] + let base = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + + #[cfg(not(target_os = "windows"))] + let base = std::env::var_os("HOME") + .map(|h| PathBuf::from(h).join(".cache")) + .unwrap_or_else(|| PathBuf::from(".cache")); + + base.join("rustmotion").join("fonts") +} + +// ─── Public entry point ───────────────────────────────────────────────────── + +/// Ensure TTFs for `family` + `weights` are present on disk (download if needed) +/// and return their paths. +/// +/// `cache_dir` is accepted as a parameter so tests can inject a temporary +/// directory without touching `$HOME`. +pub fn resolve_google_font( + family: &str, + weights: &[u16], + cache_dir: &Path, +) -> Result> { + std::fs::create_dir_all(cache_dir).map_err(RustmotionError::Io)?; + + let slug = family_slug(family); + let mut paths = Vec::with_capacity(weights.len()); + let mut missing_weights: Vec = Vec::new(); + + // Check which weights are already cached. + for &weight in weights { + let dest = cache_dir.join(format!("{slug}-{weight}.ttf")); + if dest.exists() { + paths.push(dest); + } else { + missing_weights.push(weight); + } + } + + if missing_weights.is_empty() { + return Ok(paths); + } + + // Fetch the CSS2 stylesheet from Google Fonts. + let url = build_css2_url(family, &missing_weights); + let css = fetch_css2(&url, family)?; + + // Parse the TTF URLs out of the CSS. + let ttf_urls = parse_ttf_urls(&css); + if ttf_urls.is_empty() { + return Err(RustmotionError::GoogleFontsNoUrls { + family: family.to_string(), + }); + } + + // Download each TTF. We map each URL to a weight in order because + // Google Fonts returns one face per weight in the order requested. + for (i, &weight) in missing_weights.iter().enumerate() { + let ttf_url = ttf_urls + .get(i) + .unwrap_or_else(|| &ttf_urls[ttf_urls.len() - 1]); + let dest = cache_dir.join(format!("{slug}-{weight}.ttf")); + fetch_ttf(ttf_url, &dest, family, weight)?; + paths.push(dest); + } + + Ok(paths) +} + +// ─── URL construction ──────────────────────────────────────────────────────── + +/// Build the Google Fonts CSS2 API URL. +/// +/// Spaces in the family name are replaced with `+`; weights are joined with +/// `;` as per the CSS2 API spec. +/// +/// # Examples +/// +/// ``` +/// # use rustmotion_core::engine::renderer::google_fonts::build_css2_url; +/// let url = build_css2_url("JetBrains Mono", &[400, 700]); +/// assert_eq!(url, "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700"); +/// ``` +pub fn build_css2_url(family: &str, weights: &[u16]) -> String { + let family_encoded = family.replace(' ', "+"); + let weights_str = weights + .iter() + .map(|w| w.to_string()) + .collect::>() + .join(";"); + format!("https://fonts.googleapis.com/css2?family={family_encoded}:wght@{weights_str}") +} + +// ─── CSS parsing ───────────────────────────────────────────────────────────── + +/// Extract all `url(…)` values that end with `.ttf` from a Google Fonts CSS +/// response. No regex crate is used — we find `url(` markers and extract the +/// inner content. +/// +/// # Examples +/// +/// ``` +/// # use rustmotion_core::engine::renderer::google_fonts::parse_ttf_urls; +/// let css = r#"src: url(https://fonts.gstatic.com/s/inter/v13/foo-400.ttf) format('truetype');"#; +/// let urls = parse_ttf_urls(css); +/// assert_eq!(urls, vec!["https://fonts.gstatic.com/s/inter/v13/foo-400.ttf"]); +/// ``` +pub fn parse_ttf_urls(css: &str) -> Vec { + let mut urls = Vec::new(); + let mut search = css; + while let Some(start) = search.find("url(") { + search = &search[start + 4..]; + // Strip optional surrounding quotes. + let inner = search.trim_start_matches(['\'', '"']); + let end = inner.find([')', '\'', '"']).unwrap_or(inner.len()); + let url = &inner[..end]; + if url.ends_with(".ttf") || url.ends_with(".ttf)") { + urls.push(url.trim_end_matches(')').to_string()); + } + // Advance past the closing paren. + if let Some(close) = search.find(')') { + search = &search[close + 1..]; + } else { + break; + } + } + urls +} + +// ─── File-name slug ────────────────────────────────────────────────────────── + +/// Convert a font family name to a filesystem-safe slug. +/// +/// `"JetBrains Mono"` → `"jetbrains-mono"` +/// +/// # Examples +/// +/// ``` +/// # use rustmotion_core::engine::renderer::google_fonts::family_slug; +/// assert_eq!(family_slug("JetBrains Mono"), "jetbrains-mono"); +/// assert_eq!(family_slug("Inter"), "inter"); +/// ``` +pub fn family_slug(family: &str) -> String { + family.to_lowercase().replace(' ', "-") +} + +// ─── Network helpers ───────────────────────────────────────────────────────── + +fn fetch_css2(url: &str, family: &str) -> Result { + let response = ureq::get(url).call().map_err(|e| { + let cache_hint = font_cache_dir() + .join(format!("{}-.ttf", family_slug(family))) + .display() + .to_string(); + RustmotionError::GoogleFontsFetch { + family: family.to_string(), + url: url.to_string(), + reason: e.to_string(), + cache_hint, + } + })?; + + response + .into_body() + .read_to_string() + .map_err(|e| RustmotionError::GoogleFontsFetch { + family: family.to_string(), + url: url.to_string(), + reason: e.to_string(), + cache_hint: font_cache_dir() + .join(format!("{}-.ttf", family_slug(family))) + .display() + .to_string(), + }) +} + +fn fetch_ttf(url: &str, dest: &Path, family: &str, weight: u16) -> Result<()> { + let response = ureq::get(url) + .call() + .map_err(|e| RustmotionError::GoogleFontsTtfFetch { + family: family.to_string(), + weight, + reason: e.to_string(), + })?; + + let bytes = + response + .into_body() + .read_to_vec() + .map_err(|e| RustmotionError::GoogleFontsTtfFetch { + family: family.to_string(), + weight, + reason: e.to_string(), + })?; + + std::fs::write(dest, &bytes).map_err(RustmotionError::Io)?; + Ok(()) +} + +// ─── Unit tests ────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // --- URL construction --- + + #[test] + fn url_single_weight() { + let url = build_css2_url("Inter", &[400]); + assert_eq!( + url, + "https://fonts.googleapis.com/css2?family=Inter:wght@400" + ); + } + + #[test] + fn url_multi_weight() { + let url = build_css2_url("Inter", &[400, 700]); + assert_eq!( + url, + "https://fonts.googleapis.com/css2?family=Inter:wght@400;700" + ); + } + + #[test] + fn url_spaces_become_plus() { + let url = build_css2_url("JetBrains Mono", &[400]); + assert_eq!( + url, + "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400" + ); + } + + // --- CSS parsing --- + + #[test] + fn parse_single_ttf_url() { + let css = + r#"src: url(https://fonts.gstatic.com/s/inter/v13/foo-400.ttf) format('truetype');"#; + let urls = parse_ttf_urls(css); + assert_eq!( + urls, + vec!["https://fonts.gstatic.com/s/inter/v13/foo-400.ttf"] + ); + } + + #[test] + fn parse_multiple_ttf_urls_from_embedded_css() { + // Simulate a real (stripped) Google Fonts CSS2 response for Inter 400+700. + let css = r#" +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + src: url(https://fonts.gstatic.com/s/inter/v13/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7W0Q5nw.ttf) format('truetype'); +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + src: url(https://fonts.gstatic.com/s/inter/v13/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7W0Q5nw.ttf) format('truetype'); +} +"#; + let urls = parse_ttf_urls(css); + assert_eq!(urls.len(), 2); + assert!(urls[0].ends_with(".ttf")); + assert!(urls[1].ends_with(".ttf")); + assert_ne!(urls[0], urls[1]); + } + + #[test] + fn parse_css_with_no_ttf_returns_empty() { + let css = r#"src: url(https://fonts.gstatic.com/foo.woff2) format('woff2');"#; + let urls = parse_ttf_urls(css); + assert!(urls.is_empty()); + } + + // --- File slug --- + + #[test] + fn slug_lowercase_spaces_to_hyphens() { + assert_eq!(family_slug("JetBrains Mono"), "jetbrains-mono"); + assert_eq!(family_slug("Inter"), "inter"); + assert_eq!(family_slug("Noto Sans SC"), "noto-sans-sc"); + } + + // --- Cache hit (zero network) --- + + /// Create a unique temp directory for the test (no tempfile crate needed). + fn make_test_cache(test_name: &str) -> PathBuf { + let base = std::env::temp_dir() + .join("rustmotion-test-fonts") + .join(test_name); + std::fs::create_dir_all(&base).expect("create test cache dir"); + base + } + + #[test] + fn cache_hit_returns_path_without_network() { + let cache_dir = make_test_cache("cache-hit-single"); + + // Pre-position the TTF so no network call is needed. + let pre_placed = cache_dir.join("inter-400.ttf"); + std::fs::write(&pre_placed, b"fake ttf data").unwrap(); + + // resolve_google_font must return the pre-placed path without hitting + // the network (if it did hit the network and failed, the test would + // error — no mocking needed). + let paths = resolve_google_font("Inter", &[400], &cache_dir).unwrap(); + assert_eq!(paths.len(), 1); + assert_eq!(paths[0], pre_placed); + } + + #[test] + fn cache_hit_multi_weight_all_present() { + let cache_dir = make_test_cache("cache-hit-multi"); + + std::fs::write(cache_dir.join("inter-400.ttf"), b"fake400").unwrap(); + std::fs::write(cache_dir.join("inter-700.ttf"), b"fake700").unwrap(); + + let paths = resolve_google_font("Inter", &[400, 700], &cache_dir).unwrap(); + assert_eq!(paths.len(), 2); + } + + // Live network test — excluded from normal CI runs. + #[test] + #[ignore = "requires network access"] + fn live_fetch_inter_400() { + let cache_dir = make_test_cache("live-inter-400"); + let paths = resolve_google_font("Inter", &[400], &cache_dir).unwrap(); + assert_eq!(paths.len(), 1); + let size = std::fs::metadata(&paths[0]).unwrap().len(); + assert!(size > 1000, "expected a real TTF, got {size} bytes"); + } +} diff --git a/crates/rustmotion-core/src/engine/renderer/mod.rs b/crates/rustmotion-core/src/engine/renderer/mod.rs index 997075f..5684413 100644 --- a/crates/rustmotion-core/src/engine/renderer/mod.rs +++ b/crates/rustmotion-core/src/engine/renderer/mod.rs @@ -1,6 +1,7 @@ mod assets; mod colors; mod fonts; +pub mod google_fonts; mod shapes; mod text; mod yuv; diff --git a/crates/rustmotion-core/src/error.rs b/crates/rustmotion-core/src/error.rs index d0f2f8a..55e4a3f 100644 --- a/crates/rustmotion-core/src/error.rs +++ b/crates/rustmotion-core/src/error.rs @@ -69,6 +69,41 @@ pub enum RustmotionError { #[error("No fonts available on this system")] FontNotFound, + // --- Google Fonts --- + #[error( + "FontEntry for '{family}' has source=\"google\" but also sets 'path' — use one or the other" + )] + FontSourceAndPathConflict { family: String }, + + #[error( + "FontEntry for '{family}' requires either 'path' (local file) or 'source' (e.g. \"google\")" + )] + FontMissingPath { family: String }, + + #[error( + "Google Fonts: failed to fetch CSS for '{family}' (url: {url}): {reason}\n\ + Tip: if you are offline, manually place a TTF for each weight at:\n {cache_hint}" + )] + GoogleFontsFetch { + family: String, + url: String, + reason: String, + cache_hint: String, + }, + + #[error( + "Google Fonts: no font URLs found in CSS response for '{family}' — \ + the family name may be misspelled or unavailable" + )] + GoogleFontsNoUrls { family: String }, + + #[error("Google Fonts: failed to download TTF for '{family}' weight {weight}: {reason}")] + GoogleFontsTtfFetch { + family: String, + weight: u16, + reason: String, + }, + // --- Include system --- #[error("Include depth limit ({limit}) exceeded while resolving '{path}'")] IncludeDepthExceeded { limit: u8, path: String }, diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index 28eec58..def55ac 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -204,11 +204,28 @@ pub struct IncludeDirective { pub config: Option>, } -/// Font file to load at startup +/// Font file to load at startup. +/// +/// Two mutually exclusive modes: +/// - **Local**: `path` (required) + `family`. Loads a `.ttf`/`.otf` file directly. +/// - **Google Fonts**: `source = "google"` + `family` + optional `weights` (default [400]). +/// The font is downloaded from the Google Fonts CSS2 API and cached in +/// `~/.cache/rustmotion/fonts` (or `%LOCALAPPDATA%\rustmotion\fonts` on Windows). +/// Subsequent renders with a warm cache make zero network calls. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct FontEntry { - pub path: String, + /// Local file path (.ttf/.otf). Required when `source` is absent. + #[serde(default)] + pub path: Option, + /// Font family name (e.g. "Inter", "JetBrains Mono"). pub family: String, + /// Font source. Currently the only recognised value is `"google"`. + /// When set, `path` must be absent. + #[serde(default)] + pub source: Option, + /// Font weights to download (Google Fonts only). Defaults to `[400]`. + #[serde(default)] + pub weights: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] diff --git a/crates/rustmotion-html/src/lib.rs b/crates/rustmotion-html/src/lib.rs index d918928..28ca81b 100644 --- a/crates/rustmotion-html/src/lib.rs +++ b/crates/rustmotion-html/src/lib.rs @@ -59,8 +59,14 @@ pub enum HtmlError { InvalidAnimDsl(String), #[error("transition-duration and transition-easing require a transition attribute")] TransitionParamsWithoutTransition, - #[error(" requires both family and path (or src) attributes")] + /// Emitted when `` has neither `path`/`src` nor `source`. + #[error( + " requires either 'path'/'src' (local file) or 'source' (e.g. source=\"google\")" + )] MissingFontAttributes, + /// Emitted when `` sets both `path`/`src` and `source` — they are mutually exclusive. + #[error(": 'path'/'src' and 'source' are mutually exclusive")] + FontPathAndSourceConflict { family: String }, } /// Transpile an HTML-dialect document into the scenario `serde_json::Value` that @@ -112,18 +118,48 @@ pub(crate) fn parse_background_attr(raw: &str) -> Result { } } -/// Map a `` element to a `{"family": ..., "path": ...}` JSON object. +/// Map a `` element to a FontEntry JSON object. +/// +/// Two modes: +/// - **Local**: `` or `src=` alias. +/// - **Google Fonts**: ``. +/// +/// `path`/`src` and `source` are mutually exclusive — an error is returned if +/// both are present. fn font_to_value(handle: &Handle) -> Result { let attrs = element_attrs(handle); let get = |k: &str| attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()); let family = get("family").ok_or(HtmlError::MissingFontAttributes)?; - // Accept both `path` and `src` attribute names. - let path = get("path") - .or_else(|| get("src")) - .ok_or(HtmlError::MissingFontAttributes)?; + let path = get("path").or_else(|| get("src")); + let source = get("source"); + + match (path, source) { + // Conflict: both local path and remote source set. + (Some(_), Some(_)) => Err(HtmlError::FontPathAndSourceConflict { family }), + + // Google Fonts mode. + (None, Some(source_val)) => { + let mut obj = serde_json::json!({ "family": family, "source": source_val }); + // Parse optional weights="400,700" CSV into a JSON array of integers. + if let Some(weights_raw) = get("weights") { + let parsed: Vec = weights_raw + .split(',') + .filter_map(|w| w.trim().parse::().ok()) + .collect(); + if !parsed.is_empty() { + obj["weights"] = Value::Array(parsed.into_iter().map(Value::from).collect()); + } + } + Ok(obj) + } + + // Local file mode. + (Some(p), None) => Ok(serde_json::json!({ "family": family, "path": p })), - Ok(serde_json::json!({ "family": family, "path": path })) + // Neither path nor source. + (None, None) => Err(HtmlError::MissingFontAttributes), + } } /// Walk the immediate children of `parent`, collecting `` and `` @@ -495,6 +531,62 @@ mod lib_tests { ); } + // --- Google Fonts HTML tests --- + + #[test] + fn font_with_source_google_transpiles_correctly() { + let html = r##" + +

hi

+
"##; + let v = crate::html_to_scenario_value(html).unwrap(); + let fonts = &v["fonts"]; + assert_eq!(fonts[0]["family"], json!("Inter")); + assert_eq!(fonts[0]["source"], json!("google")); + assert!( + fonts[0].get("path").is_none(), + "path must be absent for google source" + ); + } + + #[test] + fn font_with_source_google_and_weights_transpiles_correctly() { + let html = r##" + +

hi

+
"##; + let v = crate::html_to_scenario_value(html).unwrap(); + let fonts = &v["fonts"]; + assert_eq!(fonts[0]["source"], json!("google")); + assert_eq!(fonts[0]["weights"], json!([400, 700])); + } + + #[test] + fn font_with_path_and_source_is_error() { + let html = r##" + +

hi

+
"##; + let err = crate::html_to_scenario_value(html).unwrap_err(); + assert!( + matches!(err, crate::HtmlError::FontPathAndSourceConflict { .. }), + "expected FontPathAndSourceConflict, got: {err:?}" + ); + } + + #[test] + fn font_with_src_and_source_is_error() { + let html = r##" + +

hi

+
"##; + let err = crate::html_to_scenario_value(html).unwrap_err(); + assert!( + matches!(err, crate::HtmlError::FontPathAndSourceConflict { .. }), + "expected FontPathAndSourceConflict, got: {err:?}" + ); + } + // --- root-level background JSON --- #[test]