From fd56a050063e1dbdf36b2f4ea03c50ce730767c6 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 25 Feb 2026 06:48:14 -0500 Subject: [PATCH 01/12] feat: add vl-convert-fontsource crate Add a new crate for downloading and caching font files from the Fontsource catalog (which includes Google Fonts and other open-source fonts). Features include: - HTTP-based font file download with Fontsource API integration - LRU-evicting disk cache with configurable size limit - File-level locking for safe concurrent access - Font metadata caching and validation --- Cargo.lock | 62 +- Cargo.toml | 7 +- vl-convert-fontsource/Cargo.toml | 23 + vl-convert-fontsource/src/cache.rs | 985 ++++++++++++++++++++++ vl-convert-fontsource/src/error.rs | 22 + vl-convert-fontsource/src/lib.rs | 7 + vl-convert-fontsource/src/types.rs | 320 +++++++ vl-convert-fontsource/tests/test_cache.rs | 158 ++++ 8 files changed, 1579 insertions(+), 5 deletions(-) create mode 100644 vl-convert-fontsource/Cargo.toml create mode 100644 vl-convert-fontsource/src/cache.rs create mode 100644 vl-convert-fontsource/src/error.rs create mode 100644 vl-convert-fontsource/src/lib.rs create mode 100644 vl-convert-fontsource/src/types.rs create mode 100644 vl-convert-fontsource/tests/test_cache.rs diff --git a/Cargo.lock b/Cargo.lock index d985e192..e91b42a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1604,6 +1604,20 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -2218,7 +2232,7 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09af756abf2663ff667f496cfd739481dea10f190b7fd75a7a9ab9bffd444bd7" dependencies = [ - "dashmap", + "dashmap 5.5.3", ] [[package]] @@ -2559,7 +2573,7 @@ dependencies = [ "boxed_error", "capacity_builder", "chrono", - "dashmap", + "dashmap 5.5.3", "deno_cache_dir", "deno_config", "deno_error", @@ -3688,6 +3702,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-subset" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47922b9d737f16d425c10ec7b9b9751590cf8908270b5f9647bdad960b4b979" +dependencies = [ + "brotli 8.0.2", +] + [[package]] name = "font-types" version = "0.10.1" @@ -3783,6 +3806,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix 1.1.3", + "tokio", + "windows-sys 0.59.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -5771,7 +5805,7 @@ dependencies = [ "async-trait", "boxed_error", "capacity_builder", - "dashmap", + "dashmap 5.5.3", "deno_error", "deno_maybe_sync", "deno_media_type", @@ -8596,7 +8630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49b3eba5fd24fb4cf7b5092474711a40e47e4cff973b839a7c1c69c1557b272d" dependencies = [ "bytes-str", - "dashmap", + "dashmap 5.5.3", "indexmap 2.13.0", "once_cell", "par-core", @@ -9782,6 +9816,24 @@ dependencies = [ "vl-convert-canvas2d", ] +[[package]] +name = "vl-convert-fontsource" +version = "2.0.0-rc1" +dependencies = [ + "backon", + "dashmap 6.1.0", + "dirs", + "filetime", + "fs4", + "log", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "vl-convert-python" version = "2.0.0-rc1" @@ -9811,6 +9863,7 @@ dependencies = [ "dssim", "env_logger", "escape8259", + "font-subset", "fontdb", "futures", "futures-util", @@ -9836,6 +9889,7 @@ dependencies = [ "usvg", "vl-convert-canvas2d", "vl-convert-canvas2d-deno", + "vl-convert-fontsource", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f0ce7c57..46653777 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,8 @@ members = [ "vl-convert-python", "vl-convert-vendor", "vl-convert-canvas2d", - "vl-convert-canvas2d-deno" + "vl-convert-canvas2d-deno", + "vl-convert-fontsource", ] [profile.release] @@ -33,10 +34,14 @@ deno_ast = { version = "0.52.0", features = ["bundler", "codegen", "transforms", deno_semver = "0.9.1" sys_traits = { version = "0.1.22", features = ["real", "libc"] } +dashmap = "6" dircpy = "0.3" +dirs = "6" dssim = "3.2.4" env_logger = "0.11.8" +filetime = "0.2" fontdb = { version = "0.23.0", features = ["fontconfig"] } +fs4 = { version = "0.13", features = ["tokio", "sync"] } futures = "0.3.30" futures-util = "0.3.30" image = { version = "0.25", default-features = false, features = ["jpeg"] } diff --git a/vl-convert-fontsource/Cargo.toml b/vl-convert-fontsource/Cargo.toml new file mode 100644 index 00000000..150a87b4 --- /dev/null +++ b/vl-convert-fontsource/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "vl-convert-fontsource" +version = "2.0.0-rc1" +edition = "2021" +description = "Fontsource font downloading and caching for vl-convert" +license = "BSD-3-Clause" +repository = "https://github.com/vega/vl-convert" + +[dependencies] +backon = { workspace = true } +dashmap = { workspace = true } +dirs = { workspace = true } +filetime = { workspace = true } +fs4 = { workspace = true } +log = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = "2" +tokio = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/vl-convert-fontsource/src/cache.rs b/vl-convert-fontsource/src/cache.rs new file mode 100644 index 00000000..c68ec6bf --- /dev/null +++ b/vl-convert-fontsource/src/cache.rs @@ -0,0 +1,985 @@ +use crate::error::FontsourceError; +use crate::types::{family_to_id, FetchOutcome, FontsourceFont, FontsourceMarker, MARKER_FILENAME}; +use backon::{ExponentialBuilder, Retryable}; +use dashmap::DashMap; +use filetime::FileTime; +use fs4::fs_std::FileExt; +use log::{debug, info, warn}; +use reqwest::StatusCode; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +const FONTSOURCE_API: &str = "https://api.fontsource.org/v1/fonts"; + +/// A concurrent, disk-backed cache for Fontsource font files. +/// +/// Downloads TTF files from the Fontsource API and caches them on disk. +/// Thread-safe: all methods take `&self` and use file locks + per-font +/// mutexes for coordination. +pub struct FontsourceCache { + cache_dir: PathBuf, + client: reqwest::Client, + max_cache_bytes: AtomicU64, // 0 = unbounded + download_gates: DashMap>>, + known_fonts: DashMap, +} + +impl FontsourceCache { + /// Create a new `FontsourceCache`. + /// + /// # Arguments + /// * `cache_dir` - Directory for cached fonts. Defaults to the platform + /// cache directory under `vl-convert/fonts`. + /// * `max_cache_bytes` - Optional maximum cache size. `None` means unbounded. + pub fn new( + cache_dir: Option, + max_cache_bytes: Option, + ) -> Result { + let cache_dir = match cache_dir { + Some(dir) => dir, + None => dirs::cache_dir() + .map(|d| d.join("vl-convert").join("fonts")) + .ok_or(FontsourceError::NoCacheDir)?, + }; + + let client = reqwest::Client::builder() + .user_agent("vl-convert") + .build() + .map_err(FontsourceError::Http)?; + + Ok(Self { + cache_dir, + client, + max_cache_bytes: AtomicU64::new(max_cache_bytes.unwrap_or(0)), + download_gates: DashMap::new(), + known_fonts: DashMap::new(), + }) + } + + /// Set the maximum cache size in bytes. 0 means unbounded. + pub fn set_max_cache_bytes(&self, max_bytes: u64) { + self.max_cache_bytes.store(max_bytes, Ordering::Relaxed); + } + + /// Get the maximum cache size in bytes. 0 means unbounded. + pub fn max_cache_bytes(&self) -> u64 { + self.max_cache_bytes.load(Ordering::Relaxed) + } + + /// Return the on-disk directory for a given font ID. + pub fn font_dir(&self, font_id: &str) -> PathBuf { + self.cache_dir.join(font_id) + } + + /// Fetch font metadata from the Fontsource API. + /// + /// Maps HTTP 404 to [`FontsourceError::FontNotFound`]. Retries transient + /// errors with exponential backoff. + pub async fn fetch_metadata(&self, font_id: &str) -> Result { + let url = format!("{}/{}", FONTSOURCE_API, font_id); + let client = self.client.clone(); + let font_id_owned = font_id.to_string(); + + let response = (|| { + let client = client.clone(); + let url = url.clone(); + async move { + let resp = client.get(&url).send().await?.error_for_status()?; + Ok::<_, reqwest::Error>(resp) + } + }) + .retry(ExponentialBuilder::default()) + .when(|e: &reqwest::Error| { + // Don't retry 404s + if let Some(status) = e.status() { + if status == StatusCode::NOT_FOUND { + return false; + } + // Retry server errors and rate limiting + return status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS; + } + // Retry connection/timeout errors + true + }) + .await; + + match response { + Ok(resp) => { + let bytes = resp.bytes().await?; + let font: FontsourceFont = serde_json::from_slice(&bytes)?; + Ok(font) + } + Err(e) => { + if e.status() == Some(StatusCode::NOT_FOUND) { + Err(FontsourceError::FontNotFound(font_id_owned)) + } else { + Err(FontsourceError::Http(e)) + } + } + } + } + + /// Download a TTF file from `url` to `path`. + /// + /// If `!force` and `path` already exists, returns immediately. + /// Downloads to a temporary file first, then atomically renames. + /// Retries transient errors with exponential backoff. + async fn download_ttf( + &self, + url: &str, + path: &Path, + force: bool, + ) -> Result<(), FontsourceError> { + if !force && path.exists() { + return Ok(()); + } + + let client = self.client.clone(); + let url_owned = url.to_string(); + let path_owned = path.to_path_buf(); + + (|| { + let client = client.clone(); + let url = url_owned.clone(); + let path = path_owned.clone(); + async move { + let bytes = client + .get(&url) + .send() + .await? + .error_for_status()? + .bytes() + .await?; + + // Write to a unique temp file in the same directory + let parent = path.parent().unwrap_or(&path); + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("font"); + let temp_name = format!( + "{}.{}.{}.tmp", + file_name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() + ); + let temp_path = parent.join(&temp_name); + + tokio::fs::write(&temp_path, &bytes) + .await + .inspect_err(|_e| { + // Clean up temp file on write error + let _ = std::fs::remove_file(&temp_path); + })?; + + if let Err(e) = atomic_rename(&temp_path, &path) { + // Clean up temp file on rename error + let _ = std::fs::remove_file(&temp_path); + return Err(FontsourceError::Io(e)); + } + + Ok::<_, FontsourceError>(()) + } + }) + .retry(ExponentialBuilder::default()) + .when(|e: &FontsourceError| { + matches!(e, FontsourceError::Http(re) if { + if let Some(status) = re.status() { + status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS + } else { + // Retry connection/timeout errors + true + } + }) + }) + .await + } + + /// Fetch a font by family name, downloading if not already cached. + /// + /// Returns a [`FetchOutcome`] indicating whether a download occurred + /// and the path to the font directory. + /// + /// # Fast path + /// If the `.fontsource.json` marker exists and at least one `.ttf` file + /// is present, the font is considered cached. The marker's mtime is + /// touched for LRU bookkeeping. + /// + /// # Slow path + /// Fetches metadata from the Fontsource API, downloads all TTF files + /// (all subsets, weights, and styles), then writes the marker. + pub async fn fetch(&self, family: &str) -> Result { + let font_id = family_to_id(family) + .ok_or_else(|| FontsourceError::InvalidFontId(family.to_string()))?; + let font_dir = self.font_dir(&font_id); + + // ---- Fast path: marker exists + TTFs present ---- + if self.check_cache_hit(&font_dir).await? { + let font_type = self.read_marker(&font_dir).await.and_then(|m| m.font_type); + return Ok(FetchOutcome { + path: font_dir, + font_id, + downloaded: false, + font_type, + }); + } + + // ---- Slow path: acquire per-font gate ---- + let gate = self + .download_gates + .entry(font_id.clone()) + .or_default() + .clone(); + let _guard = gate.lock().await; + + // Re-check after acquiring gate (another task may have completed the download) + if self.check_cache_hit(&font_dir).await? { + let font_type = self.read_marker(&font_dir).await.and_then(|m| m.font_type); + return Ok(FetchOutcome { + path: font_dir, + font_id, + downloaded: false, + font_type, + }); + } + + // Acquire shared file lock for the mutation sequence + let font_type = self.do_download(&font_id, family, &font_dir, false).await?; + + Ok(FetchOutcome { + path: font_dir, + font_id, + downloaded: true, + font_type: Some(font_type), + }) + } + + /// Re-fetch a font, forcing re-download even if cached. + /// + /// Deletes existing marker and TTF files, then re-downloads everything. + /// File deletion and re-download both happen under an exclusive cache lock + /// inside `do_download` (when `force` is true) to prevent races with + /// concurrent registration, eviction, or clear operations. + pub async fn refetch(&self, family: &str) -> Result { + let font_id = family_to_id(family) + .ok_or_else(|| FontsourceError::InvalidFontId(family.to_string()))?; + let font_dir = self.font_dir(&font_id); + + // Acquire per-font gate + let gate = self + .download_gates + .entry(font_id.clone()) + .or_default() + .clone(); + let _guard = gate.lock().await; + + // Delete + re-download under exclusive cache lock (force=true triggers delete) + let font_type = self.do_download(&font_id, family, &font_dir, true).await?; + + Ok(FetchOutcome { + path: font_dir, + font_id, + downloaded: true, + font_type: Some(font_type), + }) + } + + /// Clear cached files for a specific font family. + /// + /// Acquires an exclusive file lock to prevent concurrent reads/writes. + pub fn clear(&self, family: &str) -> Result<(), FontsourceError> { + let font_id = family_to_id(family) + .ok_or_else(|| FontsourceError::InvalidFontId(family.to_string()))?; + let font_dir = self.font_dir(&font_id); + + self.with_exclusive_lock(|| { + if font_dir.exists() { + std::fs::remove_dir_all(&font_dir)?; + } + Ok(()) + }) + } + + /// Clear the entire font cache. + /// + /// Acquires an exclusive file lock to prevent concurrent reads/writes. + pub fn clear_all(&self) -> Result<(), FontsourceError> { + self.with_exclusive_lock(|| { + if self.cache_dir.exists() { + // Remove all subdirectories but preserve the lock file + let entries = std::fs::read_dir(&self.cache_dir)?; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + std::fs::remove_dir_all(&path)?; + } else if path.file_name().and_then(|n| n.to_str()) != Some(".cache-lock") { + std::fs::remove_file(&path)?; + } + } + } + Ok(()) + }) + } + + /// Run a closure while holding a shared file lock on `.cache-lock`. + pub fn with_cache_lock(&self, f: F) -> Result + where + F: FnOnce() -> R, + { + std::fs::create_dir_all(&self.cache_dir)?; + let lock_path = self.cache_dir.join(".cache-lock"); + let lock_file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&lock_path)?; + lock_file.lock_shared()?; + let result = f(); + // lock released when lock_file is dropped + Ok(result) + } + + /// Check whether a font ID is known to Fontsource. + /// + /// Results are cached in-memory. Transport errors are propagated without + /// caching (so subsequent calls can retry). + pub async fn is_known_font(&self, font_id: &str) -> Result { + // Check in-memory cache first + if let Some(entry) = self.known_fonts.get(font_id) { + return Ok(*entry); + } + + let url = format!("{}/{}", FONTSOURCE_API, font_id); + let response = self.client.get(&url).send().await?; + + match response.status() { + StatusCode::OK => { + self.known_fonts.insert(font_id.to_string(), true); + Ok(true) + } + StatusCode::NOT_FOUND => { + self.known_fonts.insert(font_id.to_string(), false); + Ok(false) + } + _status => { + // Transport/server error: propagate without caching + Err(FontsourceError::Http( + response.error_for_status().unwrap_err(), + )) + } + } + } + + /// Calculate the total size of all cached files in bytes. + pub fn calculate_cache_size_bytes(&self) -> Result { + let mut total: u64 = 0; + if !self.cache_dir.exists() { + return Ok(0); + } + + let entries = std::fs::read_dir(&self.cache_dir)?; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + let sub_entries = std::fs::read_dir(&path)?; + for sub_entry in sub_entries { + let sub_entry = sub_entry?; + if sub_entry.path().is_file() { + total += sub_entry.metadata()?.len(); + } + } + } + } + + Ok(total) + } + + /// Evict least-recently-used fonts until the cache size is at or below + /// `target_bytes`. + /// + /// Acquires an exclusive file lock. Fonts in `exempt` are never evicted + /// (used to protect fonts just downloaded in the current batch). + pub fn evict_lru_until_size( + &self, + target_bytes: u64, + exempt: &HashSet, + ) -> Result<(), FontsourceError> { + self.with_exclusive_lock(|| { + if !self.cache_dir.exists() { + return Ok(()); + } + + // Collect font directories with their sizes and mtime + let mut font_entries: Vec<(String, PathBuf, u64, std::time::SystemTime)> = Vec::new(); + let mut total_size: u64 = 0; + + let dir_entries = std::fs::read_dir(&self.cache_dir)?; + for entry in dir_entries { + let entry = entry?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let font_id = match path.file_name().and_then(|n| n.to_str()) { + Some(name) => name.to_string(), + None => continue, + }; + + // Calculate directory size + let mut dir_size: u64 = 0; + let sub_entries = std::fs::read_dir(&path)?; + for sub_entry in sub_entries { + let sub_entry = sub_entry?; + if sub_entry.path().is_file() { + dir_size += sub_entry.metadata()?.len(); + } + } + + // Get mtime of .fontsource.json marker (LRU key) + let marker_path = path.join(MARKER_FILENAME); + let mtime = if marker_path.exists() { + marker_path + .metadata()? + .modified() + .unwrap_or(std::time::UNIX_EPOCH) + } else { + std::time::UNIX_EPOCH + }; + + total_size += dir_size; + font_entries.push((font_id, path, dir_size, mtime)); + } + + if total_size <= target_bytes { + return Ok(()); + } + + // Sort by mtime ascending (oldest first) for LRU eviction + font_entries.sort_by(|a, b| a.3.cmp(&b.3)); + + for (font_id, path, dir_size, _) in &font_entries { + if total_size <= target_bytes { + break; + } + + if exempt.contains(font_id) { + continue; + } + + info!("Evicting cached font '{}' ({} bytes)", font_id, dir_size); + if let Err(e) = std::fs::remove_dir_all(path) { + warn!("Failed to evict font '{}': {}", font_id, e); + continue; + } + + total_size = total_size.saturating_sub(*dir_size); + } + + if total_size > target_bytes { + warn!( + "Cache size ({} bytes) still exceeds target ({} bytes) \ + after evicting all non-exempt fonts", + total_size, target_bytes + ); + } + + Ok(()) + }) + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + /// Check if the cache has a valid entry for the given font directory. + /// + /// Valid means: marker file exists AND at least one `.ttf` file is present. + /// On cache hit, touches the marker mtime for LRU bookkeeping. + async fn check_cache_hit(&self, font_dir: &Path) -> Result { + let marker_path = font_dir.join(MARKER_FILENAME); + + if !marker_path.exists() { + return Ok(false); + } + + // Acquire shared lock to verify TTF files + let lock_path = self.cache_dir.join(".cache-lock"); + std::fs::create_dir_all(&self.cache_dir)?; + + use fs4::tokio::AsyncFileExt; + let lock_file = tokio::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock_path) + .await?; + lock_file.lock_shared()?; + + let has_ttf = self.has_ttf_files(font_dir).await; + + // lock released when lock_file is dropped + drop(lock_file); + + if has_ttf { + // Touch marker mtime for LRU bookkeeping + if let Err(e) = filetime::set_file_mtime(&marker_path, FileTime::now()) { + warn!( + "Failed to touch marker mtime for {}: {}", + marker_path.display(), + e + ); + } + Ok(true) + } else { + // Stale marker: directory exists but no TTFs + debug!( + "Stale cache marker at {} (no TTF files found)", + marker_path.display() + ); + Ok(false) + } + } + + /// Check if a directory contains at least one `.ttf` file. + async fn has_ttf_files(&self, dir: &Path) -> bool { + let mut entries = match tokio::fs::read_dir(dir).await { + Ok(entries) => entries, + Err(_) => return false, + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + if entry + .path() + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("ttf")) + .unwrap_or(false) + { + return true; + } + } + false + } + + /// Read the marker file from a font directory, if it exists. + async fn read_marker(&self, font_dir: &Path) -> Option { + let marker_path = font_dir.join(MARKER_FILENAME); + let data = tokio::fs::read_to_string(&marker_path).await.ok()?; + serde_json::from_str(&data).ok() + } + + /// Perform the full download sequence for a font. + /// + /// When `force` is false (normal fetch), acquires a **shared** file lock + /// so multiple concurrent downloads of different fonts can proceed. + /// + /// When `force` is true (refetch), acquires an **exclusive** file lock + /// to prevent readers (e.g. registration via `with_cache_lock`) from + /// seeing a partially-deleted font directory. + /// + /// Returns the `font_type` string (`"google"` or `"other"`) from the API. + async fn do_download( + &self, + font_id: &str, + family: &str, + font_dir: &Path, + force: bool, + ) -> Result { + let lock_path = self.cache_dir.join(".cache-lock"); + std::fs::create_dir_all(&self.cache_dir)?; + + use fs4::tokio::AsyncFileExt; + let lock_file = tokio::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock_path) + .await?; + + if force { + // Exclusive lock: block readers while we delete + re-download + lock_file.lock_exclusive()?; + self.delete_font_files(font_dir).await?; + } else { + // Shared lock: compatible with other downloads and registrations + lock_file.lock_shared()?; + } + + // Create font directory + tokio::fs::create_dir_all(font_dir).await?; + + // Fetch metadata + info!("Fetching metadata for font '{}'", font_id); + let metadata = self.fetch_metadata(font_id).await?; + + // Download all TTF files (all subsets, all weights, all styles) + for (weight_key, styles) in &metadata.variants { + for (style_key, subsets) in styles { + for (subset, urls) in subsets { + if let Some(ref ttf_url) = urls.url.ttf { + let filename = format!("{}-{}-{}.ttf", subset, weight_key, style_key); + let file_path = font_dir.join(&filename); + + debug!("Downloading {}", filename); + self.download_ttf(ttf_url, &file_path, force).await?; + } + } + } + } + + let font_type = metadata.font_type.clone(); + + // Write marker via atomic rename + let marker = FontsourceMarker { + id: font_id.to_string(), + family: family.to_string(), + version: metadata.version.clone(), + fetched_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + font_type: Some(font_type.clone()), + }; + let marker_json = serde_json::to_string_pretty(&marker)?; + let marker_path = font_dir.join(MARKER_FILENAME); + let temp_marker = font_dir.join(format!( + ".fontsource.json.{}.{}.tmp", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() + )); + std::fs::write(&temp_marker, marker_json)?; + atomic_rename(&temp_marker, &marker_path)?; + + info!( + "Font '{}' ({}) cached at {}", + family, + font_id, + font_dir.display() + ); + + // lock released when lock_file is dropped + Ok(font_type) + } + + /// Delete marker and TTF files from a font directory. + async fn delete_font_files(&self, font_dir: &Path) -> Result<(), FontsourceError> { + if !font_dir.exists() { + return Ok(()); + } + + let marker_path = font_dir.join(MARKER_FILENAME); + if marker_path.exists() { + tokio::fs::remove_file(&marker_path).await?; + } + + let mut entries = tokio::fs::read_dir(font_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("ttf")) + .unwrap_or(false) + { + tokio::fs::remove_file(&path).await?; + } + } + + Ok(()) + } + + /// Run a closure while holding an exclusive file lock on `.cache-lock`. + fn with_exclusive_lock(&self, f: F) -> Result + where + F: FnOnce() -> Result, + { + std::fs::create_dir_all(&self.cache_dir)?; + let lock_path = self.cache_dir.join(".cache-lock"); + let lock_file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&lock_path)?; + lock_file.lock_exclusive()?; + // lock_file is held until end of scope, ensuring f() runs under the lock + f() + } +} + +/// Atomically rename `src` to `dst`. +/// +/// If the rename fails with `AlreadyExists`, treats it as success +/// (a concurrent download wrote the same file) and deletes the temp file. +fn atomic_rename(src: &Path, dst: &Path) -> Result<(), std::io::Error> { + match std::fs::rename(src, dst) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + // Concurrent writer already placed the file — clean up our temp + let _ = std::fs::remove_file(src); + Ok(()) + } + Err(e) => Err(e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + + /// Create a fake font directory with a marker and a TTF file of the given size. + fn create_fake_font(cache_dir: &Path, font_id: &str, ttf_size: usize, age_secs: i64) { + let font_dir = cache_dir.join(font_id); + std::fs::create_dir_all(&font_dir).unwrap(); + + // Write a fake TTF file + let ttf_path = font_dir.join("latin-400-normal.ttf"); + let data = vec![0u8; ttf_size]; + std::fs::write(&ttf_path, &data).unwrap(); + + // Write marker + let marker = FontsourceMarker { + id: font_id.to_string(), + family: font_id.to_string(), + version: "1.0.0".to_string(), + fetched_at: 1000000, + font_type: Some("google".to_string()), + }; + let marker_path = font_dir.join(MARKER_FILENAME); + std::fs::write(&marker_path, serde_json::to_string(&marker).unwrap()).unwrap(); + + // Set marker mtime to control LRU ordering + let base = filetime::FileTime::from_unix_time(1_700_000_000, 0); + let mtime = filetime::FileTime::from_unix_time(1_700_000_000 + age_secs, 0); + filetime::set_file_mtime(&marker_path, mtime).unwrap(); + filetime::set_file_mtime(&ttf_path, base).unwrap(); + } + + #[test] + fn test_calculate_cache_size_empty() { + let tmp = tempfile::tempdir().unwrap(); + let cache = FontsourceCache::new(Some(tmp.path().to_path_buf()), None).unwrap(); + assert_eq!(cache.calculate_cache_size_bytes().unwrap(), 0); + } + + #[test] + fn test_calculate_cache_size() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path(); + + create_fake_font(cache_dir, "roboto", 1000, 0); + create_fake_font(cache_dir, "open-sans", 2000, 10); + + let cache = FontsourceCache::new(Some(cache_dir.to_path_buf()), None).unwrap(); + let size = cache.calculate_cache_size_bytes().unwrap(); + + // Each font has a TTF file + marker file. Marker is ~80-100 bytes JSON. + // TTF sizes: 1000 + 2000 = 3000, plus two markers + assert!(size >= 3000, "Expected at least 3000 bytes, got {}", size); + assert!( + size < 4000, + "Expected less than 4000 bytes (markers are small), got {}", + size + ); + } + + #[test] + fn test_evict_lru_oldest_first() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path(); + + // Create three fonts with different ages (age_secs controls mtime) + // oldest (age_secs=0) → middle (age_secs=100) → newest (age_secs=200) + create_fake_font(cache_dir, "font-old", 1000, 0); + create_fake_font(cache_dir, "font-mid", 1000, 100); + create_fake_font(cache_dir, "font-new", 1000, 200); + + let cache = FontsourceCache::new(Some(cache_dir.to_path_buf()), None).unwrap(); + + // Set target that requires evicting at least one font + // Total is ~3000 + markers, target of 2500 should evict the oldest + let exempt = HashSet::new(); + cache.evict_lru_until_size(2500, &exempt).unwrap(); + + // Oldest font should be evicted + assert!( + !cache_dir.join("font-old").exists(), + "Oldest font should be evicted" + ); + assert!( + cache_dir.join("font-mid").exists(), + "Middle font should remain" + ); + assert!( + cache_dir.join("font-new").exists(), + "Newest font should remain" + ); + } + + #[test] + fn test_evict_respects_exempt_set() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path(); + + create_fake_font(cache_dir, "font-old", 1000, 0); + create_fake_font(cache_dir, "font-mid", 1000, 100); + create_fake_font(cache_dir, "font-new", 1000, 200); + + let cache = FontsourceCache::new(Some(cache_dir.to_path_buf()), None).unwrap(); + + // Exempt the oldest font — eviction should skip it + let mut exempt = HashSet::new(); + exempt.insert("font-old".to_string()); + + // Target requires evicting one font + cache.evict_lru_until_size(2500, &exempt).unwrap(); + + // Oldest is exempt, so middle (next oldest) should be evicted + assert!( + cache_dir.join("font-old").exists(), + "Exempt font should not be evicted" + ); + assert!( + !cache_dir.join("font-mid").exists(), + "Next oldest non-exempt font should be evicted" + ); + assert!( + cache_dir.join("font-new").exists(), + "Newest font should remain" + ); + } + + #[test] + fn test_evict_no_op_when_under_limit() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path(); + + create_fake_font(cache_dir, "roboto", 1000, 0); + + let cache = FontsourceCache::new(Some(cache_dir.to_path_buf()), None).unwrap(); + + // Target is larger than current size — nothing should be evicted + cache + .evict_lru_until_size(1_000_000, &HashSet::new()) + .unwrap(); + + assert!( + cache_dir.join("roboto").exists(), + "Font should remain when under limit" + ); + } + + #[test] + fn test_evict_all_exempt_logs_warning() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path(); + + create_fake_font(cache_dir, "font-a", 2000, 0); + create_fake_font(cache_dir, "font-b", 2000, 100); + + let cache = FontsourceCache::new(Some(cache_dir.to_path_buf()), None).unwrap(); + + // Exempt both fonts, target is tiny — should warn but not crash + let mut exempt = HashSet::new(); + exempt.insert("font-a".to_string()); + exempt.insert("font-b".to_string()); + + // This should not error, just log a warning + cache.evict_lru_until_size(100, &exempt).unwrap(); + + // Both fonts should still exist + assert!(cache_dir.join("font-a").exists()); + assert!(cache_dir.join("font-b").exists()); + } + + #[test] + fn test_atomic_rename_basic() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("src.txt"); + let dst = tmp.path().join("dst.txt"); + std::fs::write(&src, "hello").unwrap(); + + atomic_rename(&src, &dst).unwrap(); + + assert!(!src.exists()); + assert_eq!(std::fs::read_to_string(&dst).unwrap(), "hello"); + } + + #[test] + fn test_atomic_rename_concurrent() { + let tmp = tempfile::tempdir().unwrap(); + let dst = tmp.path().join("target.ttf"); + let num_threads = 8; + + // Create all temp files first + let mut temp_paths = Vec::new(); + for i in 0..num_threads { + let src = tmp.path().join(format!("target.ttf.{}.tmp", i)); + std::fs::write(&src, format!("content-{}", i)).unwrap(); + temp_paths.push(src); + } + + // Spawn threads that all try to rename to the same target + let handles: Vec<_> = temp_paths + .into_iter() + .map(|src| { + let dst = dst.clone(); + thread::spawn(move || atomic_rename(&src, &dst)) + }) + .collect(); + + for handle in handles { + // All should succeed (no errors) + handle.join().unwrap().unwrap(); + } + + // Exactly one file at target + assert!(dst.exists()); + // All temp files should be cleaned up + for i in 0..num_threads { + let src = tmp.path().join(format!("target.ttf.{}.tmp", i)); + assert!(!src.exists(), "Temp file {} should be cleaned up", i); + } + } + + #[test] + fn test_evict_multiple_to_reach_target() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path(); + + // Create 5 fonts, each 1000 bytes, with increasing mtime + for i in 0..5 { + create_fake_font(cache_dir, &format!("font-{}", i), 1000, i * 100); + } + + let cache = FontsourceCache::new(Some(cache_dir.to_path_buf()), None).unwrap(); + + // Target: keep ~2 fonts worth of data (2500 bytes including markers) + cache.evict_lru_until_size(2500, &HashSet::new()).unwrap(); + + // The 3 oldest should be evicted + assert!( + !cache_dir.join("font-0").exists(), + "Oldest should be evicted" + ); + assert!( + !cache_dir.join("font-1").exists(), + "Second oldest should be evicted" + ); + assert!( + !cache_dir.join("font-2").exists(), + "Third oldest should be evicted" + ); + // The 2 newest should remain + assert!(cache_dir.join("font-3").exists(), "Fourth should remain"); + assert!(cache_dir.join("font-4").exists(), "Newest should remain"); + } +} diff --git a/vl-convert-fontsource/src/error.rs b/vl-convert-fontsource/src/error.rs new file mode 100644 index 00000000..354a1998 --- /dev/null +++ b/vl-convert-fontsource/src/error.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum FontsourceError { + #[error("Font not found: \"{0}\"")] + FontNotFound(String), + + #[error("Invalid font ID: \"{0}\". Must match [a-z0-9][a-z0-9_-]*")] + InvalidFontId(String), + + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Failed to determine cache directory")] + NoCacheDir, +} diff --git a/vl-convert-fontsource/src/lib.rs b/vl-convert-fontsource/src/lib.rs new file mode 100644 index 00000000..e28496f1 --- /dev/null +++ b/vl-convert-fontsource/src/lib.rs @@ -0,0 +1,7 @@ +pub mod cache; +pub mod error; +pub mod types; + +pub use cache::FontsourceCache; +pub use error::FontsourceError; +pub use types::{FetchOutcome, FontsourceMarker}; diff --git a/vl-convert-fontsource/src/types.rs b/vl-convert-fontsource/src/types.rs new file mode 100644 index 00000000..6bd18d12 --- /dev/null +++ b/vl-convert-fontsource/src/types.rs @@ -0,0 +1,320 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Marker file written to each font directory after successful download. +pub const MARKER_FILENAME: &str = ".fontsource.json"; + +/// CSS font style. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FontStyle { + Normal, + Italic, +} + +impl FontStyle { + pub fn as_str(&self) -> &'static str { + match self { + FontStyle::Normal => "normal", + FontStyle::Italic => "italic", + } + } +} + +/// A request for a specific weight + style combination. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct VariantRequest { + pub weight: u16, + pub style: FontStyle, +} + +/// Default variants to download: 400/700 normal, 400/700 italic. +pub fn default_variants() -> Vec { + vec![ + VariantRequest { + weight: 400, + style: FontStyle::Normal, + }, + VariantRequest { + weight: 700, + style: FontStyle::Normal, + }, + VariantRequest { + weight: 400, + style: FontStyle::Italic, + }, + VariantRequest { + weight: 700, + style: FontStyle::Italic, + }, + ] +} + +/// A cached TTF file with parsed metadata from its filename. +#[derive(Debug, Clone)] +pub struct CachedFontFile { + pub subset: String, + pub weight: u16, + pub style: FontStyle, +} + +/// Convert a font family name to a Fontsource font ID. +/// +/// Rules: +/// 1. Trim whitespace +/// 2. Lowercase +/// 3. Replace spaces with hyphens +/// +/// Returns `None` if the resulting ID doesn't match `^[a-z0-9][a-z0-9_-]*$`. +pub fn family_to_id(family: &str) -> Option { + let id = family.trim().to_lowercase().replace(' ', "-"); + if is_valid_font_id(&id) { + Some(id) + } else { + None + } +} + +/// Check if a string is a valid Fontsource font ID. +pub fn is_valid_font_id(id: &str) -> bool { + if id.is_empty() { + return false; + } + let bytes = id.as_bytes(); + // First char must be lowercase alphanumeric + if !(bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) { + return false; + } + // Remaining chars must be lowercase alphanumeric, hyphen, or underscore + bytes[1..] + .iter() + .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') +} + +/// Parse a cached TTF filename into its components. +/// +/// Filenames follow the pattern: `{subset}-{weight}-{style}.ttf` +/// Since subsets can contain hyphens (e.g. `latin-ext`), we split from the right. +pub fn parse_cached_filename(filename: &str) -> Option { + let stem = filename.strip_suffix(".ttf")?; + let parts: Vec<&str> = stem.rsplitn(3, '-').collect(); + if parts.len() < 3 { + return None; + } + // rsplitn gives [style, weight, subset] (reversed) + let style_str = parts[0]; + let weight_str = parts[1]; + let subset = parts[2]; + + let style = match style_str { + "normal" => FontStyle::Normal, + "italic" => FontStyle::Italic, + _ => return None, + }; + + let weight: u16 = weight_str.parse().ok()?; + + Some(CachedFontFile { + subset: subset.to_string(), + weight, + style, + }) +} + +/// Top-level response from `GET /v1/fonts/{id}` +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FontsourceFont { + pub id: String, + pub family: String, + pub subsets: Vec, + pub weights: Vec, + pub styles: Vec, + pub version: String, + /// `"google"` or `"other"` — from the Fontsource API `type` field. + #[serde(rename = "type")] + pub font_type: String, + /// weight (string) -> style -> subset -> urls + pub variants: HashMap>>, +} + +#[derive(Debug, Deserialize)] +pub struct FontsourceUrls { + pub url: FontsourceFileUrls, +} + +#[derive(Debug, Deserialize)] +pub struct FontsourceFileUrls { + pub ttf: Option, + pub woff2: Option, + pub woff: Option, +} + +/// Marker data written to `.fontsource.json` in each font directory. +#[derive(Debug, Serialize, Deserialize)] +pub struct FontsourceMarker { + pub id: String, + pub family: String, + pub version: String, + pub fetched_at: u64, // Unix timestamp + /// `"google"` or `"other"`. `None` for markers written before this field existed. + #[serde(default)] + pub font_type: Option, +} + +/// Outcome of a fetch or refetch operation. +#[derive(Debug)] +pub struct FetchOutcome { + /// Path to the font directory. + pub path: std::path::PathBuf, + /// Normalized font ID. + pub font_id: String, + /// `true` if a fresh download occurred, `false` if cache hit. + pub downloaded: bool, + /// `"google"` or `"other"`. `None` for old cached markers without this field. + pub font_type: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_family_to_id() { + assert_eq!(family_to_id("Roboto"), Some("roboto".to_string())); + assert_eq!( + family_to_id("Playfair Display"), + Some("playfair-display".to_string()) + ); + assert_eq!( + family_to_id("IBM Plex Sans"), + Some("ibm-plex-sans".to_string()) + ); + assert_eq!( + family_to_id("Noto Sans JP"), + Some("noto-sans-jp".to_string()) + ); + assert_eq!(family_to_id(" Roboto "), Some("roboto".to_string())); + // Invalid: starts with hyphen + assert_eq!(family_to_id("-invalid"), None); + // Invalid: empty + assert_eq!(family_to_id(""), None); + assert_eq!(family_to_id(" "), None); + } + + #[test] + fn test_is_valid_font_id() { + assert!(is_valid_font_id("roboto")); + assert!(is_valid_font_id("playfair-display")); + assert!(is_valid_font_id("ibm-plex-sans")); + assert!(is_valid_font_id("wf_standard-font")); + assert!(is_valid_font_id("123abc")); + assert!(!is_valid_font_id("")); + assert!(!is_valid_font_id("-starts-with-hyphen")); + assert!(!is_valid_font_id("_starts-with-underscore")); + assert!(!is_valid_font_id("has spaces")); + assert!(!is_valid_font_id("HAS-CAPS")); + } + + #[test] + fn test_parse_cached_filename() { + let f = parse_cached_filename("latin-400-normal.ttf").unwrap(); + assert_eq!(f.subset, "latin"); + assert_eq!(f.weight, 400); + assert_eq!(f.style, FontStyle::Normal); + + let f = parse_cached_filename("latin-ext-700-italic.ttf").unwrap(); + assert_eq!(f.subset, "latin-ext"); + assert_eq!(f.weight, 700); + assert_eq!(f.style, FontStyle::Italic); + + let f = parse_cached_filename("cyrillic-ext-400-normal.ttf").unwrap(); + assert_eq!(f.subset, "cyrillic-ext"); + assert_eq!(f.weight, 400); + assert_eq!(f.style, FontStyle::Normal); + + // Invalid cases + assert!(parse_cached_filename("not-a-ttf.woff2").is_none()); + assert!(parse_cached_filename("400-normal.ttf").is_none()); + assert!(parse_cached_filename("latin-400-bold.ttf").is_none()); + } + + #[test] + fn test_fontsource_font_deserializes_type_field() { + let json = r#"{ + "id": "roboto", + "family": "Roboto", + "subsets": ["latin"], + "weights": [400, 700], + "styles": ["normal", "italic"], + "version": "v30", + "type": "google", + "variants": {} + }"#; + let font: FontsourceFont = serde_json::from_str(json).unwrap(); + assert_eq!(font.font_type, "google"); + } + + #[test] + fn test_fontsource_font_deserializes_other_type() { + let json = r#"{ + "id": "custom-font", + "family": "Custom Font", + "subsets": ["latin"], + "weights": [400], + "styles": ["normal"], + "version": "v1", + "type": "other", + "variants": {} + }"#; + let font: FontsourceFont = serde_json::from_str(json).unwrap(); + assert_eq!(font.font_type, "other"); + } + + #[test] + fn test_fontsource_marker_backward_compat() { + // Old markers don't have font_type — should deserialize with None + let json = r#"{ + "id": "roboto", + "family": "Roboto", + "version": "v30", + "fetched_at": 1700000000 + }"#; + let marker: FontsourceMarker = serde_json::from_str(json).unwrap(); + assert_eq!(marker.font_type, None); + } + + #[test] + fn test_fontsource_marker_with_font_type() { + let json = r#"{ + "id": "roboto", + "family": "Roboto", + "version": "v30", + "fetched_at": 1700000000, + "font_type": "google" + }"#; + let marker: FontsourceMarker = serde_json::from_str(json).unwrap(); + assert_eq!(marker.font_type, Some("google".to_string())); + } + + #[test] + fn test_default_variants() { + let variants = default_variants(); + assert_eq!(variants.len(), 4); + assert!(variants.contains(&VariantRequest { + weight: 400, + style: FontStyle::Normal + })); + assert!(variants.contains(&VariantRequest { + weight: 700, + style: FontStyle::Normal + })); + assert!(variants.contains(&VariantRequest { + weight: 400, + style: FontStyle::Italic + })); + assert!(variants.contains(&VariantRequest { + weight: 700, + style: FontStyle::Italic + })); + } +} diff --git a/vl-convert-fontsource/tests/test_cache.rs b/vl-convert-fontsource/tests/test_cache.rs new file mode 100644 index 00000000..b15e8a37 --- /dev/null +++ b/vl-convert-fontsource/tests/test_cache.rs @@ -0,0 +1,158 @@ +use std::collections::HashSet; +use vl_convert_fontsource::FontsourceCache; + +/// Helper to create a cache in a temp directory. +fn temp_cache() -> (tempfile::TempDir, FontsourceCache) { + let tmp = tempfile::tempdir().unwrap(); + let cache = FontsourceCache::new(Some(tmp.path().to_path_buf()), None).unwrap(); + (tmp, cache) +} + +#[tokio::test] +async fn test_fetch_roboto() { + let (tmp, cache) = temp_cache(); + + // First fetch should download + let outcome = cache.fetch("Roboto").await.unwrap(); + assert!(outcome.downloaded); + assert_eq!(outcome.font_id, "roboto"); + assert!(outcome.path.exists()); + + // Marker should exist + let marker_path = outcome.path.join(".fontsource.json"); + assert!(marker_path.exists()); + + // Should have TTF files + let ttf_count = std::fs::read_dir(&outcome.path) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("ttf")) + .unwrap_or(false) + }) + .count(); + assert!(ttf_count > 0, "Expected at least one TTF file"); + + // Second fetch should be a cache hit + let outcome2 = cache.fetch("Roboto").await.unwrap(); + assert!(!outcome2.downloaded); + assert_eq!(outcome2.font_id, "roboto"); + + drop(cache); + drop(tmp); +} + +#[tokio::test] +async fn test_font_not_found() { + let (_tmp, cache) = temp_cache(); + + let result = cache.fetch("definitely-not-a-real-font-name-xyz").await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!( + matches!(err, vl_convert_fontsource::FontsourceError::FontNotFound(_)), + "Expected FontNotFound, got: {:?}", + err + ); +} + +#[tokio::test] +async fn test_is_known_font() { + let (_tmp, cache) = temp_cache(); + + // Roboto should be known + assert!(cache.is_known_font("roboto").await.unwrap()); + + // Nonsense should not be known + assert!(!cache + .is_known_font("definitely-not-a-font-xyz") + .await + .unwrap()); + + // Second call should hit in-memory cache + assert!(cache.is_known_font("roboto").await.unwrap()); +} + +#[tokio::test] +async fn test_fetch_and_refetch() { + let (tmp, cache) = temp_cache(); + + // Initial fetch + let outcome = cache.fetch("Open Sans").await.unwrap(); + assert!(outcome.downloaded); + assert_eq!(outcome.font_id, "open-sans"); + + // Refetch should force re-download + let outcome2 = cache.refetch("Open Sans").await.unwrap(); + assert!(outcome2.downloaded); + + drop(cache); + drop(tmp); +} + +#[tokio::test] +async fn test_eviction_during_fetch() { + let (tmp, cache) = temp_cache(); + + // Fetch a font + let outcome1 = cache.fetch("Roboto").await.unwrap(); + assert!(outcome1.downloaded); + + // Fetch another font + let outcome2 = cache.fetch("Open Sans").await.unwrap(); + assert!(outcome2.downloaded); + + // Calculate current size + let size = cache.calculate_cache_size_bytes().unwrap(); + assert!(size > 0); + + // Set cache limit to just above one font's size (force eviction of one) + // Use half the current size as the limit + let target = size / 2; + + // Evict — oldest (roboto, fetched first) should be evicted + let exempt: HashSet = HashSet::from(["open-sans".to_string()]); + cache.evict_lru_until_size(target, &exempt).unwrap(); + + // Open Sans (exempt) should remain + assert!( + tmp.path().join("open-sans").exists(), + "Exempt font should not be evicted" + ); + + drop(cache); + drop(tmp); +} + +#[tokio::test] +async fn test_parallel_same_font_dedup() { + let (_tmp, cache) = temp_cache(); + let cache = std::sync::Arc::new(cache); + + // Spawn two concurrent fetches for the same font + let cache1 = cache.clone(); + let cache2 = cache.clone(); + + let (r1, r2) = tokio::join!(async move { cache1.fetch("Roboto").await }, async move { + cache2.fetch("Roboto").await + },); + + // Both should succeed + let o1 = r1.unwrap(); + let o2 = r2.unwrap(); + + // At most one should report downloaded=true (the other gets dedup'd) + let download_count = [o1.downloaded, o2.downloaded] + .iter() + .filter(|&&d| d) + .count(); + assert!( + download_count <= 1, + "Expected at most 1 download, got {}", + download_count + ); +} From e08c9b2537c81b6221ff3e7d483d77418f5eb142 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 25 Feb 2026 06:49:07 -0500 Subject: [PATCH 02/12] feat: add font install and configuration to vl-convert-rs - Add fontsource dependency to vl-convert-rs - Add FONTSOURCE_CACHE global, font registration, install_font(), and configure_font_cache() functions in text module - Re-export install_font and configure_font_cache from crate root - Add AutoInstallFonts enum and auto_install_fonts config field to VlConverterConfig (dormant until auto-download is implemented) --- vl-convert-rs/Cargo.toml | 1 + vl-convert-rs/src/converter.rs | 19 +++++ vl-convert-rs/src/lib.rs | 1 + vl-convert-rs/src/text.rs | 139 +++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+) diff --git a/vl-convert-rs/Cargo.toml b/vl-convert-rs/Cargo.toml index d3b18f49..2e14634a 100644 --- a/vl-convert-rs/Cargo.toml +++ b/vl-convert-rs/Cargo.toml @@ -12,6 +12,7 @@ keywords = ["Visualization", "Vega", "Vega-Lite"] [dependencies] vl-convert-canvas2d = { path = "../vl-convert-canvas2d", version = "2.0.0-rc1" } vl-convert-canvas2d-deno = { path = "../vl-convert-canvas2d-deno", version = "2.0.0-rc1", features = ["svg"] } +vl-convert-fontsource = { path = "../vl-convert-fontsource", version = "2.0.0-rc1" } deno_runtime = { workspace = true } deno_core = { workspace = true } diff --git a/vl-convert-rs/src/converter.rs b/vl-convert-rs/src/converter.rs index 45655a06..6eefc545 100644 --- a/vl-convert-rs/src/converter.rs +++ b/vl-convert-rs/src/converter.rs @@ -441,6 +441,22 @@ impl ValueOrString { } } +/// Controls automatic font downloading from the Fontsource catalog. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AutoInstallFonts { + /// Disabled (default). + #[default] + Off, + /// Only examines the first font in each CSS font-family string. + /// If it is not on the system and not in the Fontsource catalog, + /// the conversion fails with an error. + Strict, + /// Same first-font-only logic as `Strict`, but logs warnings for + /// unavailable fonts instead of failing. + BestEffort, +} + +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] pub struct VlConverterConfig { pub num_workers: usize, @@ -450,6 +466,8 @@ pub struct VlConverterConfig { /// values override this default when provided. Must be non-empty when set. /// When configured, HTTP redirects are denied instead of followed. pub allowed_base_urls: Option>, + /// Controls automatic font downloading from the Fontsource catalog. + pub auto_install_fonts: AutoInstallFonts, } impl Default for VlConverterConfig { @@ -459,6 +477,7 @@ impl Default for VlConverterConfig { allow_http_access: true, filesystem_root: None, allowed_base_urls: None, + auto_install_fonts: AutoInstallFonts::Off, } } } diff --git a/vl-convert-rs/src/lib.rs b/vl-convert-rs/src/lib.rs index c5e7052c..ceb21fe3 100644 --- a/vl-convert-rs/src/lib.rs +++ b/vl-convert-rs/src/lib.rs @@ -18,6 +18,7 @@ pub use converter::VlConverter; pub use deno_core::anyhow; pub use module_loader::import_map::VlVersion; pub use serde_json; +pub use text::{configure_font_cache, install_font}; /// V8 snapshot containing the pre-compiled deno_runtime extensions plus our /// vl_convert_runtime extension. Generated at build time for container diff --git a/vl-convert-rs/src/text.rs b/vl-convert-rs/src/text.rs index 5447a127..34a9943d 100644 --- a/vl-convert-rs/src/text.rs +++ b/vl-convert-rs/src/text.rs @@ -11,6 +11,7 @@ use usvg::{ ImageHrefResolver, }; use vl_convert_canvas2d::font_config::{font_config_to_fontdb, CustomFont, FontConfig}; +use vl_convert_fontsource::FontsourceCache; /// Monotonically increasing version counter for font configuration changes. /// Incremented each time `register_font_directory` is called. @@ -19,6 +20,8 @@ pub static FONT_CONFIG_VERSION: AtomicU64 = AtomicU64::new(0); lazy_static! { pub static ref USVG_OPTIONS: Mutex> = Mutex::new(init_usvg_options()); pub static ref FONT_CONFIG: Mutex = Mutex::new(build_default_font_config()); + pub static ref FONTSOURCE_CACHE: FontsourceCache = + FontsourceCache::new(None, None).expect("Failed to initialize FontsourceCache"); } const LIBERATION_SANS_REGULAR: &[u8] = @@ -291,3 +294,139 @@ pub fn register_font_directory(dir: &str) -> Result<(), anyhow::Error> { Ok(()) } + +/// Result of attempting to register a font directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RegisterResult { + /// The directory was newly registered. + Registered, + /// The directory was already registered. + AlreadyRegistered, + /// The directory does not exist or contains no `.ttf` files. + DirectoryMissing, +} + +/// Register a font directory if it has not already been registered and +/// contains at least one `.ttf` file. +/// +/// This function acquires the `FONT_CONFIG` and `USVG_OPTIONS` locks +/// internally. It is intended to be called from within +/// `FONTSOURCE_CACHE.with_cache_lock(...)` so that the filesystem state +/// is stable while we check for TTF files. +pub fn register_font_directory_if_new(dir: &str) -> Result { + let path = PathBuf::from(dir); + + // Hold FONT_CONFIG lock for the entire check+register sequence to prevent + // duplicate entries from concurrent callers. + let mut font_config = FONT_CONFIG + .lock() + .map_err(|err| anyhow!("Failed to acquire font config lock: {err}"))?; + + if font_config.font_dirs.contains(&path) { + return Ok(RegisterResult::AlreadyRegistered); + } + + // Check directory exists and has at least one .ttf file + if !path.is_dir() { + return Ok(RegisterResult::DirectoryMissing); + } + let has_ttf = std::fs::read_dir(&path) + .map(|entries| { + entries.filter_map(|e| e.ok()).any(|e| { + e.path() + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("ttf")) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + if !has_ttf { + return Ok(RegisterResult::DirectoryMissing); + } + + // Register the directory (still under the same lock) + font_config.font_dirs.push(path); + drop(font_config); + + { + let mut opts = USVG_OPTIONS + .lock() + .map_err(|err| anyhow!("Failed to acquire usvg options lock: {err}"))?; + let font_db = Arc::make_mut(&mut opts.fontdb); + font_db.load_fonts_dir(dir); + setup_default_fonts(font_db); + } + + FONT_CONFIG_VERSION.fetch_add(1, Ordering::Release); + + Ok(RegisterResult::Registered) +} + +/// Fetch a font from Fontsource, register it with fontdb, and handle stale-cache recovery. +/// +/// Returns the `FetchOutcome` on success. If the cache directory is missing after +/// the initial fetch (stale cache), performs a forced re-download and retries registration. +pub(crate) async fn fetch_and_register_font( + family: &str, +) -> Result { + let mut outcome = FONTSOURCE_CACHE.fetch(family).await?; + let dir_str = outcome + .path + .to_str() + .ok_or_else(|| anyhow!("Font path is not valid UTF-8"))? + .to_string(); + + let result = FONTSOURCE_CACHE.with_cache_lock(|| register_font_directory_if_new(&dir_str))?; + let result = result?; + + if result == RegisterResult::DirectoryMissing { + // Stale cache — force re-download + outcome = FONTSOURCE_CACHE.refetch(family).await?; + let dir_str = outcome + .path + .to_str() + .ok_or_else(|| anyhow!("Font path is not valid UTF-8"))? + .to_string(); + + let result = + FONTSOURCE_CACHE.with_cache_lock(|| register_font_directory_if_new(&dir_str))?; + let result = result?; + + if result == RegisterResult::DirectoryMissing { + return Err(anyhow!( + "Font directory for '{}' is missing after re-download", + family + )); + } + } + + Ok(outcome) +} + +/// Download and install a font by family name from Fontsource. +/// +/// Uses the global `FONTSOURCE_CACHE` to fetch the font, then registers +/// the font directory in the fontdb. If the directory appears missing +/// after a cache hit (stale cache), performs a forced re-download. +pub async fn install_font(family: &str) -> Result<(), anyhow::Error> { + let outcome = fetch_and_register_font(family).await?; + + // Evict LRU fonts if cache limit is set and a download occurred + if outcome.downloaded { + let max_bytes = FONTSOURCE_CACHE.max_cache_bytes(); + if max_bytes > 0 { + let exempt = HashSet::from([outcome.font_id.clone()]); + FONTSOURCE_CACHE.evict_lru_until_size(max_bytes, &exempt)?; + } + } + + Ok(()) +} + +/// Configure the maximum size of the Fontsource font cache. +/// +/// Pass `None` or `Some(0)` to disable cache size limits (unbounded). +pub fn configure_font_cache(max_cache_bytes: Option) { + FONTSOURCE_CACHE.set_max_cache_bytes(max_cache_bytes.unwrap_or(0)); +} From 060fda95de56f75b8f08ba523f0086e8004da0a4 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 25 Feb 2026 06:49:33 -0500 Subject: [PATCH 03/12] feat: add font management to Python bindings and CLI Python bindings: - Add install_font() and install_font_asyncio() functions - Add auto_install_fonts and font_cache_size_mb to configure_converter() - Add AutoInstallFonts type and ConverterConfig updates to .pyi stubs CLI: - Add --install-font flag for explicit font installation - Add --auto-install-fonts flag (strict/best-effort modes) - Pass auto_install_fonts config through build_converter to all commands --- vl-convert-python/src/lib.rs | 111 +++++++++++++++++++++-- vl-convert-python/vl_convert.pyi | 39 ++++++++ vl-convert/src/main.rs | 147 +++++++++++++++++++++++++------ 3 files changed, 265 insertions(+), 32 deletions(-) diff --git a/vl-convert-python/src/lib.rs b/vl-convert-python/src/lib.rs index 9cfa2d8d..eedc31bc 100644 --- a/vl-convert-python/src/lib.rs +++ b/vl-convert-python/src/lib.rs @@ -11,15 +11,17 @@ use std::future::Future; use std::path::PathBuf; use std::str::FromStr; use std::sync::{Arc, RwLock}; +use vl_convert_rs::configure_font_cache as configure_font_cache_rs; use vl_convert_rs::converter::{ - FormatLocale, Renderer, TimeFormatLocale, ValueOrString, VgOpts, VlConverterConfig, VlOpts, - ACCESS_DENIED_MARKER, + AutoInstallFonts, FormatLocale, Renderer, TimeFormatLocale, ValueOrString, VgOpts, + VlConverterConfig, VlOpts, ACCESS_DENIED_MARKER, }; use vl_convert_rs::module_loader::import_map::{ VlVersion, VEGA_EMBED_VERSION, VEGA_THEMES_VERSION, VEGA_VERSION, VL_VERSIONS, }; use vl_convert_rs::module_loader::{FORMATE_LOCALE_MAP, TIME_FORMATE_LOCALE_MAP}; use vl_convert_rs::serde_json; +use vl_convert_rs::text::install_font as install_font_rs; use vl_convert_rs::text::register_font_directory as register_font_directory_rs; use vl_convert_rs::VlConverter as VlConverterRs; @@ -51,6 +53,11 @@ fn converter_config() -> Result } fn converter_config_json(config: &VlConverterConfig) -> serde_json::Value { + let auto_install_fonts_value = match &config.auto_install_fonts { + AutoInstallFonts::Off => serde_json::Value::Bool(false), + AutoInstallFonts::Strict => serde_json::Value::String("strict".to_string()), + AutoInstallFonts::BestEffort => serde_json::Value::String("best_effort".to_string()), + }; serde_json::json!({ "num_workers": config.num_workers, "allow_http_access": config.allow_http_access, @@ -59,6 +66,7 @@ fn converter_config_json(config: &VlConverterConfig) -> serde_json::Value { .as_ref() .map(|root| root.to_string_lossy().to_string()), "allowed_base_urls": config.allowed_base_urls, + "auto_install_fonts": auto_install_fonts_value, }) } @@ -70,6 +78,8 @@ struct ConverterConfigOverrides { filesystem_root: Option>, // None => no change, Some(None) => clear, Some(Some(urls)) => set allowed_base_urls: Option>>, + auto_install_fonts: Option, + font_cache_size_mb: Option, } fn parse_config_overrides( @@ -128,6 +138,42 @@ fn parse_config_overrides( })?)); } } + "auto_install_fonts" => { + if value.is_none() { + // None → no change (keep current) + } else if let Ok(b) = value.extract::() { + // True → Strict (backwards compat), False → Off + overrides.auto_install_fonts = Some(if b { + AutoInstallFonts::Strict + } else { + AutoInstallFonts::Off + }); + } else if let Ok(s) = value.extract::() { + overrides.auto_install_fonts = Some(match s.as_str() { + "strict" => AutoInstallFonts::Strict, + "best_effort" => AutoInstallFonts::BestEffort, + other => { + return Err(vl_convert_rs::anyhow::anyhow!( + "Invalid auto_install_fonts value: '{other}'. \ + Expected 'strict', 'best_effort', True, False, or None" + )); + } + }); + } else { + return Err(vl_convert_rs::anyhow::anyhow!( + "Invalid auto_install_fonts value: expected bool, str, or None" + )); + } + } + "font_cache_size_mb" => { + if !value.is_none() { + overrides.font_cache_size_mb = Some(value.extract::().map_err(|err| { + vl_convert_rs::anyhow::anyhow!( + "Invalid font_cache_size_mb value for configure_converter: {err}" + ) + })?); + } + } other => { return Err(vl_convert_rs::anyhow::anyhow!( "Unknown configure_converter argument: {other}" @@ -139,32 +185,44 @@ fn parse_config_overrides( Ok(overrides) } -fn apply_config_overrides(config: &mut VlConverterConfig, overrides: ConverterConfigOverrides) { +fn apply_config_overrides(config: &mut VlConverterConfig, overrides: &ConverterConfigOverrides) { if let Some(num_workers) = overrides.num_workers { config.num_workers = num_workers; } if let Some(allow_http_access) = overrides.allow_http_access { config.allow_http_access = allow_http_access; } - if let Some(filesystem_root) = overrides.filesystem_root { + if let Some(filesystem_root) = overrides.filesystem_root.clone() { config.filesystem_root = filesystem_root; } - if let Some(allowed_base_urls) = overrides.allowed_base_urls { + if let Some(allowed_base_urls) = overrides.allowed_base_urls.clone() { config.allowed_base_urls = allowed_base_urls; } + if let Some(auto_install_fonts) = overrides.auto_install_fonts { + config.auto_install_fonts = auto_install_fonts; + } } fn configure_converter_with_config_overrides( overrides: ConverterConfigOverrides, ) -> Result<(), vl_convert_rs::anyhow::Error> { + // Reconfigure the converter first — only apply cache size if this succeeds let mut guard = VL_CONVERTER.write().map_err(|e| { vl_convert_rs::anyhow::anyhow!("Failed to acquire converter write lock: {e}") })?; let mut config = guard.config(); - apply_config_overrides(&mut config, overrides); + apply_config_overrides(&mut config, &overrides); let converter = VlConverterRs::with_config(config)?; *guard = Arc::new(converter); + drop(guard); + + // Apply cache size after successful reconfiguration + if let Some(font_cache_size_mb) = overrides.font_cache_size_mb { + let bytes = font_cache_size_mb.saturating_mul(1024 * 1024); + configure_font_cache_rs(Some(bytes)); + } + Ok(()) } @@ -1217,6 +1275,29 @@ fn register_font_directory(font_dir: &str) -> PyResult<()> { Ok(()) } +/// Download, cache, and register a font by family name. +/// +/// Downloads font files from the Fontsource catalog (which includes +/// Google Fonts and other open-source fonts) and registers them for +/// use in subsequent conversions. +/// +/// Args: +/// font_family (str): Font family name (e.g. "Roboto", "Playfair Display") +/// +/// Returns: +/// None +#[pyfunction] +#[pyo3(signature = (font_family))] +fn install_font(font_family: &str) -> PyResult<()> { + let font_family = font_family.to_string(); + run_converter_future(move |_converter| { + let font_family = font_family.clone(); + async move { install_font_rs(&font_family).await } + }) + .map_err(|err| prefixed_py_error("Font installation failed", err))?; + Ok(()) +} + /// Configure converter options for subsequent requests #[pyfunction] #[pyo3(signature = (**kwargs))] @@ -2104,6 +2185,22 @@ fn register_font_directory_asyncio<'py>( }) } +#[doc = async_variant_doc!("install_font")] +#[pyfunction(name = "install_font")] +#[pyo3(signature = (font_family))] +fn install_font_asyncio<'py>(py: Python<'py>, font_family: &str) -> PyResult> { + let font_family = font_family.to_string(); + run_converter_future_async( + py, + move |_converter| { + let font_family = font_family.clone(); + async move { install_font_rs(&font_family).await } + }, + "Font installation failed", + |py, ()| Ok(py.None().into()), + ) +} + #[doc = async_variant_doc!("configure_converter")] #[pyfunction(name = "configure_converter")] #[pyo3(signature = (**kwargs))] @@ -2350,6 +2447,7 @@ fn add_asyncio_submodule(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<() asyncio.add_function(wrap_pyfunction!(svg_to_jpeg_asyncio, &asyncio)?)?; asyncio.add_function(wrap_pyfunction!(svg_to_pdf_asyncio, &asyncio)?)?; asyncio.add_function(wrap_pyfunction!(register_font_directory_asyncio, &asyncio)?)?; + asyncio.add_function(wrap_pyfunction!(install_font_asyncio, &asyncio)?)?; asyncio.add_function(wrap_pyfunction!(configure_converter_asyncio, &asyncio)?)?; asyncio.add_function(wrap_pyfunction!(get_converter_config_asyncio, &asyncio)?)?; asyncio.add_function(wrap_pyfunction!(warm_up_workers_asyncio, &asyncio)?)?; @@ -2392,6 +2490,7 @@ fn vl_convert(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(svg_to_jpeg, m)?)?; m.add_function(wrap_pyfunction!(svg_to_pdf, m)?)?; m.add_function(wrap_pyfunction!(register_font_directory, m)?)?; + m.add_function(wrap_pyfunction!(install_font, m)?)?; m.add_function(wrap_pyfunction!(configure_converter, m)?)?; m.add_function(wrap_pyfunction!(get_converter_config, m)?)?; m.add_function(wrap_pyfunction!(warm_up_workers, m)?)?; diff --git a/vl-convert-python/vl_convert.pyi b/vl-convert-python/vl_convert.pyi index fcf0600e..cc024818 100644 --- a/vl-convert-python/vl_convert.pyi +++ b/vl-convert-python/vl_convert.pyi @@ -128,11 +128,14 @@ if TYPE_CHECKING: TimeFormatLocale: TypeAlias = TimeFormatLocaleName | dict[str, Any] VlSpec: TypeAlias = str | dict[str, Any] + AutoInstallFonts: TypeAlias = Literal["strict", "best_effort"] | bool + class ConverterConfig(TypedDict): num_workers: int allow_http_access: bool filesystem_root: str | None allowed_base_urls: list[str] | None + auto_install_fonts: Literal["strict", "best_effort"] | bool __all__ = [ "asyncio", @@ -167,6 +170,7 @@ __all__ = [ "get_vega_themes_version", "get_vega_embed_version", "get_vegalite_versions", + "install_font", ] def get_format_locale(name: FormatLocaleName) -> dict[str, Any]: @@ -267,11 +271,32 @@ def register_font_directory(font_dir: str) -> None: """ ... +def install_font(font_family: str) -> None: + """ + Download, cache, and register a font by family name. + + Downloads font files from the Fontsource catalog (which includes + Google Fonts and other open-source fonts) and registers them for + use in subsequent conversions. + + Parameters + ---------- + font_family + Font family name (e.g. "Roboto", "Playfair Display") + + Returns + ------- + None + """ + ... + def configure_converter( num_workers: int | None = None, allow_http_access: bool | None = None, filesystem_root: str | None = None, allowed_base_urls: list[str] | None = None, + auto_install_fonts: AutoInstallFonts | None = None, + font_cache_size_mb: int | None = None, ) -> None: """ Configure converter worker/access settings used by subsequent conversions. @@ -292,6 +317,15 @@ def configure_converter( When configured, HTTP redirects are denied instead of followed. Per-call ``allowed_base_urls`` arguments on conversion functions override this converter-level default when provided. + auto_install_fonts + Controls automatic font downloading from the Fontsource catalog. + ``"strict"`` examines only the first font in each CSS font-family string + and raises an error if it is not on the system or Fontsource. + ``"best_effort"`` logs warnings for unavailable fonts instead of erroring. + ``True`` is an alias for ``"strict"``. ``False`` or ``None`` disables. + Default is ``False``. + font_cache_size_mb + Maximum font cache size in megabytes. If ``None``, keep current value. """ ... @@ -941,12 +975,17 @@ if TYPE_CHECKING: async def register_font_directory(self, font_dir: str) -> None: """Async version of ``register_font_directory``. See sync function for full documentation.""" ... + async def install_font(self, font_family: str) -> None: + """Async version of ``install_font``. See sync function for full documentation.""" + ... async def configure_converter( self, num_workers: int | None = None, allow_http_access: bool | None = None, filesystem_root: str | None = None, allowed_base_urls: list[str] | None = None, + auto_install_fonts: AutoInstallFonts | None = None, + font_cache_size_mb: int | None = None, ) -> None: """Async version of ``configure_converter``. See sync function for full documentation.""" ... diff --git a/vl-convert/src/main.rs b/vl-convert/src/main.rs index 4adb63a2..414e222c 100644 --- a/vl-convert/src/main.rs +++ b/vl-convert/src/main.rs @@ -1,4 +1,5 @@ #![allow(clippy::uninlined_format_args)] +#![allow(clippy::too_many_arguments)] #![doc = include_str!("../README.md")] use clap::{Parser, Subcommand}; @@ -7,12 +8,27 @@ use std::io::{self, IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; use std::str::FromStr; use vl_convert_rs::converter::{ - vega_to_url, vegalite_to_url, FormatLocale, Renderer, TimeFormatLocale, VgOpts, VlConverter, - VlConverterConfig, VlOpts, + vega_to_url, vegalite_to_url, AutoInstallFonts, FormatLocale, Renderer, TimeFormatLocale, + VgOpts, VlConverter, VlConverterConfig, VlOpts, }; use vl_convert_rs::module_loader::import_map::VlVersion; use vl_convert_rs::text::register_font_directory; -use vl_convert_rs::{anyhow, anyhow::bail}; +use vl_convert_rs::{anyhow, anyhow::bail, install_font}; + +#[derive(Debug, Clone, clap::ValueEnum)] +enum AutoInstallFontsArg { + Strict, + BestEffort, +} + +impl AutoInstallFontsArg { + fn to_auto_install_fonts(&self) -> AutoInstallFonts { + match self { + AutoInstallFontsArg::Strict => AutoInstallFonts::Strict, + AutoInstallFontsArg::BestEffort => AutoInstallFonts::BestEffort, + } + } +} const DEFAULT_VL_VERSION: &str = "6.4"; const DEFAULT_CONFIG_PATH: &str = "~/.config/vl-convert/config.json"; @@ -33,6 +49,16 @@ struct Cli { #[arg(long, global = true)] filesystem_root: Option, + /// Install a font by family name from the Fontsource catalog before conversion. + /// May be specified multiple times. + #[arg(long, global = true)] + install_font: Vec, + + /// Automatically download fonts referenced in specs from the Fontsource catalog. + /// "strict" errors if a font is unavailable; "best-effort" warns and continues. + #[arg(long, global = true, value_enum)] + auto_install_fonts: Option, + #[command(subcommand)] command: Commands, } @@ -570,11 +596,17 @@ async fn main() -> Result<(), anyhow::Error> { mut allow_http_access, no_http_access, filesystem_root, + install_font: install_font_families, + auto_install_fonts: auto_install_fonts_arg, command, } = Cli::parse(); if no_http_access { allow_http_access = false; } + let auto_install_fonts = auto_install_fonts_arg + .as_ref() + .map(|a| a.to_auto_install_fonts()) + .unwrap_or(AutoInstallFonts::Off); use crate::Commands::*; match command { Vl2vg { @@ -586,6 +618,7 @@ async fn main() -> Result<(), anyhow::Error> { pretty, show_warnings, } => { + install_fonts(&install_font_families).await?; vl_2_vg( input_vegalite_file.as_deref(), output_vega_file.as_deref(), @@ -596,6 +629,7 @@ async fn main() -> Result<(), anyhow::Error> { show_warnings, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -612,6 +646,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vl_2_svg( input.as_deref(), output.as_deref(), @@ -624,6 +659,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -642,6 +678,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vl_2_png( input.as_deref(), output.as_deref(), @@ -656,6 +693,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -674,6 +712,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vl_2_jpeg( input.as_deref(), output.as_deref(), @@ -688,6 +727,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -704,6 +744,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vl_2_pdf( input.as_deref(), output.as_deref(), @@ -716,6 +757,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -724,6 +766,7 @@ async fn main() -> Result<(), anyhow::Error> { output, fullscreen, } => { + install_fonts(&install_font_families).await?; let vl_str = read_input_string(input.as_deref())?; let vl_spec = serde_json::from_str(&vl_str)?; let url = vegalite_to_url(&vl_spec, fullscreen)?; @@ -740,6 +783,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, renderer, } => { + install_fonts(&install_font_families).await?; // Initialize converter let vl_str = read_input_string(input.as_deref())?; let vl_spec: serde_json::Value = serde_json::from_str(&vl_str)?; @@ -750,7 +794,12 @@ async fn main() -> Result<(), anyhow::Error> { parse_time_format_locale_option(time_format_locale.as_deref())?; let renderer = renderer.unwrap_or_else(|| "svg".to_string()); - let converter = VlConverter::new(); + let converter = build_converter( + allow_http_access, + filesystem_root.clone(), + None, + auto_install_fonts, + )?; let html = converter .vegalite_to_html( vl_spec, @@ -778,6 +827,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vg_2_svg( input.as_deref(), output.as_deref(), @@ -786,6 +836,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -800,6 +851,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vg_2_png( input.as_deref(), output.as_deref(), @@ -810,6 +862,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -824,6 +877,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vg_2_jpeg( input.as_deref(), output.as_deref(), @@ -834,6 +888,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -846,6 +901,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vg_2_pdf( input.as_deref(), output.as_deref(), @@ -854,6 +910,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -862,6 +919,7 @@ async fn main() -> Result<(), anyhow::Error> { output, fullscreen, } => { + install_fonts(&install_font_families).await?; let vg_str = read_input_string(input.as_deref())?; let vg_spec = serde_json::from_str(&vg_str)?; let url = vega_to_url(&vg_spec, fullscreen)?; @@ -875,6 +933,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, renderer, } => { + install_fonts(&install_font_families).await?; // Initialize converter let vg_str = read_input_string(input.as_deref())?; let vg_spec: serde_json::Value = serde_json::from_str(&vg_str)?; @@ -885,7 +944,12 @@ async fn main() -> Result<(), anyhow::Error> { let renderer = renderer.unwrap_or_else(|| "svg".to_string()); - let converter = VlConverter::new(); + let converter = build_converter( + allow_http_access, + filesystem_root.clone(), + None, + auto_install_fonts, + )?; let html = converter .vega_to_html( vg_spec, @@ -909,9 +973,14 @@ async fn main() -> Result<(), anyhow::Error> { allowed_base_url, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; let svg = read_input_string(input.as_deref())?; - let converter = - build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url)?; + let converter = build_converter( + allow_http_access, + filesystem_root.clone(), + allowed_base_url, + auto_install_fonts, + )?; let png_data = converter.svg_to_png(&svg, scale, Some(ppi))?; write_output_binary(output.as_deref(), &png_data, "PNG")?; } @@ -924,9 +993,14 @@ async fn main() -> Result<(), anyhow::Error> { allowed_base_url, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; let svg = read_input_string(input.as_deref())?; - let converter = - build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url)?; + let converter = build_converter( + allow_http_access, + filesystem_root.clone(), + allowed_base_url, + auto_install_fonts, + )?; let jpeg_data = converter.svg_to_jpeg(&svg, scale, Some(quality))?; write_output_binary(output.as_deref(), &jpeg_data, "JPEG")?; } @@ -937,9 +1011,14 @@ async fn main() -> Result<(), anyhow::Error> { allowed_base_url, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; let svg = read_input_string(input.as_deref())?; - let converter = - build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url)?; + let converter = build_converter( + allow_http_access, + filesystem_root.clone(), + allowed_base_url, + auto_install_fonts, + )?; let pdf_data = converter.svg_to_pdf(&svg)?; write_output_binary(output.as_deref(), &pdf_data, "PDF")?; } @@ -957,6 +1036,13 @@ fn register_font_dir(dir: Option) -> Result<(), anyhow::Error> { Ok(()) } +async fn install_fonts(fonts: &[String]) -> Result<(), anyhow::Error> { + for family in fonts { + install_font(family).await?; + } + Ok(()) +} + fn parse_vl_version(vl_version: &str) -> Result { VlVersion::from_str(vl_version) .map_err(|_| anyhow::anyhow!("Invalid or unsupported Vega-Lite version: {vl_version}")) @@ -966,13 +1052,13 @@ fn build_converter( allow_http_access: bool, filesystem_root: Option, allowed_base_urls: Option>, + auto_install_fonts: AutoInstallFonts, ) -> Result { - let config = VlConverterConfig { - allow_http_access, - filesystem_root: filesystem_root.map(PathBuf::from), - allowed_base_urls, - ..Default::default() - }; + let mut config = VlConverterConfig::default(); + config.allow_http_access = allow_http_access; + config.filesystem_root = filesystem_root.map(PathBuf::from); + config.allowed_base_urls = allowed_base_urls; + config.auto_install_fonts = auto_install_fonts; VlConverter::with_config(config) .map_err(|err| anyhow::anyhow!("Failed to configure converter: {err}")) @@ -1262,6 +1348,7 @@ async fn vl_2_vg( show_warnings: bool, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1276,7 +1363,7 @@ async fn vl_2_vg( let config = read_config_json(config)?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let vega_json = match converter @@ -1325,6 +1412,7 @@ async fn vg_2_svg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1336,7 +1424,7 @@ async fn vg_2_svg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let svg = match converter @@ -1373,6 +1461,7 @@ async fn vg_2_png( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1384,7 +1473,7 @@ async fn vg_2_png( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let png_data = match converter @@ -1423,6 +1512,7 @@ async fn vg_2_jpeg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1434,7 +1524,7 @@ async fn vg_2_jpeg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let jpeg_data = match converter @@ -1470,6 +1560,7 @@ async fn vg_2_pdf( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1481,7 +1572,7 @@ async fn vg_2_pdf( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let pdf_data = match converter @@ -1520,6 +1611,7 @@ async fn vl_2_svg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1537,7 +1629,7 @@ async fn vl_2_svg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let svg = match converter @@ -1582,6 +1674,7 @@ async fn vl_2_png( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1599,7 +1692,7 @@ async fn vl_2_png( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let png_data = match converter @@ -1646,6 +1739,7 @@ async fn vl_2_jpeg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1663,7 +1757,7 @@ async fn vl_2_jpeg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let jpeg_data = match converter @@ -1708,6 +1802,7 @@ async fn vl_2_pdf( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1725,7 +1820,7 @@ async fn vl_2_pdf( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let pdf_data = match converter From 0f9833ecd73e2880283ef4d1eec5922537e7be0c Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 25 Feb 2026 08:15:01 -0500 Subject: [PATCH 04/12] chore: regenerate bundled license files Co-Authored-By: Claude Opus 4.6 --- thirdparty_rust.yaml | 64 +++++++++++++++++++++++++- vl-convert-python/thirdparty_rust.yaml | 64 +++++++++++++++++++++++++- vl-convert-rs/thirdparty_rust.yaml | 64 +++++++++++++++++++++++++- vl-convert/thirdparty_rust.yaml | 64 +++++++++++++++++++++++++- 4 files changed, 252 insertions(+), 4 deletions(-) diff --git a/thirdparty_rust.yaml b/thirdparty_rust.yaml index b210d407..5082ca19 100644 --- a/thirdparty_rust.yaml +++ b/thirdparty_rust.yaml @@ -1,4 +1,4 @@ -root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert, vl-convert-python, vl-convert-vendor +root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert-fontsource, vl-convert, vl-convert-python, vl-convert-vendor third_party_libraries: - package_name: adler2 package_version: 2.0.1 @@ -18317,6 +18317,34 @@ third_party_libraries: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +- package_name: dashmap + package_version: 6.1.0 + repository: https://github.com/xacrimon/dashmap + license: MIT + licenses: + - license: MIT + text: | + MIT License + + Copyright (c) 2019 Acrimon + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -25590,6 +25618,40 @@ third_party_libraries: DEALINGS IN THE SOFTWARE. - license: Apache-2.0 text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" +- package_name: fs4 + package_version: 0.13.1 + repository: https://github.com/al8n/fs4-rs + license: MIT OR Apache-2.0 + licenses: + - license: MIT + text: | + Copyright (c) 2015 The Rust Project Developers + + Permission is hereby granted, free of charge, to any + person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the + Software without restriction, including without + limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software + is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice + shall be included in all copies or substantial portions + of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + - license: Apache-2.0 + text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" - package_name: fsevent-sys package_version: 4.1.0 repository: https://github.com/octplane/fsevent-rust/tree/master/fsevent-sys diff --git a/vl-convert-python/thirdparty_rust.yaml b/vl-convert-python/thirdparty_rust.yaml index b210d407..5082ca19 100644 --- a/vl-convert-python/thirdparty_rust.yaml +++ b/vl-convert-python/thirdparty_rust.yaml @@ -1,4 +1,4 @@ -root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert, vl-convert-python, vl-convert-vendor +root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert-fontsource, vl-convert, vl-convert-python, vl-convert-vendor third_party_libraries: - package_name: adler2 package_version: 2.0.1 @@ -18317,6 +18317,34 @@ third_party_libraries: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +- package_name: dashmap + package_version: 6.1.0 + repository: https://github.com/xacrimon/dashmap + license: MIT + licenses: + - license: MIT + text: | + MIT License + + Copyright (c) 2019 Acrimon + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -25590,6 +25618,40 @@ third_party_libraries: DEALINGS IN THE SOFTWARE. - license: Apache-2.0 text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" +- package_name: fs4 + package_version: 0.13.1 + repository: https://github.com/al8n/fs4-rs + license: MIT OR Apache-2.0 + licenses: + - license: MIT + text: | + Copyright (c) 2015 The Rust Project Developers + + Permission is hereby granted, free of charge, to any + person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the + Software without restriction, including without + limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software + is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice + shall be included in all copies or substantial portions + of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + - license: Apache-2.0 + text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" - package_name: fsevent-sys package_version: 4.1.0 repository: https://github.com/octplane/fsevent-rust/tree/master/fsevent-sys diff --git a/vl-convert-rs/thirdparty_rust.yaml b/vl-convert-rs/thirdparty_rust.yaml index b210d407..5082ca19 100644 --- a/vl-convert-rs/thirdparty_rust.yaml +++ b/vl-convert-rs/thirdparty_rust.yaml @@ -1,4 +1,4 @@ -root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert, vl-convert-python, vl-convert-vendor +root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert-fontsource, vl-convert, vl-convert-python, vl-convert-vendor third_party_libraries: - package_name: adler2 package_version: 2.0.1 @@ -18317,6 +18317,34 @@ third_party_libraries: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +- package_name: dashmap + package_version: 6.1.0 + repository: https://github.com/xacrimon/dashmap + license: MIT + licenses: + - license: MIT + text: | + MIT License + + Copyright (c) 2019 Acrimon + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -25590,6 +25618,40 @@ third_party_libraries: DEALINGS IN THE SOFTWARE. - license: Apache-2.0 text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" +- package_name: fs4 + package_version: 0.13.1 + repository: https://github.com/al8n/fs4-rs + license: MIT OR Apache-2.0 + licenses: + - license: MIT + text: | + Copyright (c) 2015 The Rust Project Developers + + Permission is hereby granted, free of charge, to any + person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the + Software without restriction, including without + limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software + is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice + shall be included in all copies or substantial portions + of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + - license: Apache-2.0 + text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" - package_name: fsevent-sys package_version: 4.1.0 repository: https://github.com/octplane/fsevent-rust/tree/master/fsevent-sys diff --git a/vl-convert/thirdparty_rust.yaml b/vl-convert/thirdparty_rust.yaml index b210d407..5082ca19 100644 --- a/vl-convert/thirdparty_rust.yaml +++ b/vl-convert/thirdparty_rust.yaml @@ -1,4 +1,4 @@ -root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert, vl-convert-python, vl-convert-vendor +root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert-fontsource, vl-convert, vl-convert-python, vl-convert-vendor third_party_libraries: - package_name: adler2 package_version: 2.0.1 @@ -18317,6 +18317,34 @@ third_party_libraries: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +- package_name: dashmap + package_version: 6.1.0 + repository: https://github.com/xacrimon/dashmap + license: MIT + licenses: + - license: MIT + text: | + MIT License + + Copyright (c) 2019 Acrimon + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -25590,6 +25618,40 @@ third_party_libraries: DEALINGS IN THE SOFTWARE. - license: Apache-2.0 text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" +- package_name: fs4 + package_version: 0.13.1 + repository: https://github.com/al8n/fs4-rs + license: MIT OR Apache-2.0 + licenses: + - license: MIT + text: | + Copyright (c) 2015 The Rust Project Developers + + Permission is hereby granted, free of charge, to any + person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the + Software without restriction, including without + limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software + is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice + shall be included in all copies or substantial portions + of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + - license: Apache-2.0 + text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" - package_name: fsevent-sys package_version: 4.1.0 repository: https://github.com/octplane/fsevent-rust/tree/master/fsevent-sys From 814f2188a10471314c640617c0a89fb00d8761d5 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 25 Feb 2026 09:13:12 -0500 Subject: [PATCH 05/12] refactor: move auto_install_fonts plumbing to PR2 The AutoInstallFonts enum, --auto-install-fonts CLI flag, and auto_install_fonts Python parameter are moved to PR2 where the actual auto-download logic lives. Co-Authored-By: Claude Opus 4.6 --- vl-convert-python/src/lib.rs | 102 ++++++++------------------- vl-convert-python/vl_convert.pyi | 14 +--- vl-convert-rs/src/converter.rs | 19 ----- vl-convert/src/main.rs | 117 +++++++------------------------ 4 files changed, 54 insertions(+), 198 deletions(-) diff --git a/vl-convert-python/src/lib.rs b/vl-convert-python/src/lib.rs index eedc31bc..adf9ca8c 100644 --- a/vl-convert-python/src/lib.rs +++ b/vl-convert-python/src/lib.rs @@ -11,16 +11,16 @@ use std::future::Future; use std::path::PathBuf; use std::str::FromStr; use std::sync::{Arc, RwLock}; -use vl_convert_rs::configure_font_cache as configure_font_cache_rs; use vl_convert_rs::converter::{ - AutoInstallFonts, FormatLocale, Renderer, TimeFormatLocale, ValueOrString, VgOpts, - VlConverterConfig, VlOpts, ACCESS_DENIED_MARKER, + FormatLocale, Renderer, TimeFormatLocale, ValueOrString, VgOpts, VlConverterConfig, VlOpts, + ACCESS_DENIED_MARKER, }; use vl_convert_rs::module_loader::import_map::{ VlVersion, VEGA_EMBED_VERSION, VEGA_THEMES_VERSION, VEGA_VERSION, VL_VERSIONS, }; use vl_convert_rs::module_loader::{FORMATE_LOCALE_MAP, TIME_FORMATE_LOCALE_MAP}; use vl_convert_rs::serde_json; +use vl_convert_rs::configure_font_cache as configure_font_cache_rs; use vl_convert_rs::text::install_font as install_font_rs; use vl_convert_rs::text::register_font_directory as register_font_directory_rs; use vl_convert_rs::VlConverter as VlConverterRs; @@ -53,11 +53,6 @@ fn converter_config() -> Result } fn converter_config_json(config: &VlConverterConfig) -> serde_json::Value { - let auto_install_fonts_value = match &config.auto_install_fonts { - AutoInstallFonts::Off => serde_json::Value::Bool(false), - AutoInstallFonts::Strict => serde_json::Value::String("strict".to_string()), - AutoInstallFonts::BestEffort => serde_json::Value::String("best_effort".to_string()), - }; serde_json::json!({ "num_workers": config.num_workers, "allow_http_access": config.allow_http_access, @@ -66,7 +61,6 @@ fn converter_config_json(config: &VlConverterConfig) -> serde_json::Value { .as_ref() .map(|root| root.to_string_lossy().to_string()), "allowed_base_urls": config.allowed_base_urls, - "auto_install_fonts": auto_install_fonts_value, }) } @@ -78,7 +72,6 @@ struct ConverterConfigOverrides { filesystem_root: Option>, // None => no change, Some(None) => clear, Some(Some(urls)) => set allowed_base_urls: Option>>, - auto_install_fonts: Option, font_cache_size_mb: Option, } @@ -138,33 +131,6 @@ fn parse_config_overrides( })?)); } } - "auto_install_fonts" => { - if value.is_none() { - // None → no change (keep current) - } else if let Ok(b) = value.extract::() { - // True → Strict (backwards compat), False → Off - overrides.auto_install_fonts = Some(if b { - AutoInstallFonts::Strict - } else { - AutoInstallFonts::Off - }); - } else if let Ok(s) = value.extract::() { - overrides.auto_install_fonts = Some(match s.as_str() { - "strict" => AutoInstallFonts::Strict, - "best_effort" => AutoInstallFonts::BestEffort, - other => { - return Err(vl_convert_rs::anyhow::anyhow!( - "Invalid auto_install_fonts value: '{other}'. \ - Expected 'strict', 'best_effort', True, False, or None" - )); - } - }); - } else { - return Err(vl_convert_rs::anyhow::anyhow!( - "Invalid auto_install_fonts value: expected bool, str, or None" - )); - } - } "font_cache_size_mb" => { if !value.is_none() { overrides.font_cache_size_mb = Some(value.extract::().map_err(|err| { @@ -185,44 +151,36 @@ fn parse_config_overrides( Ok(overrides) } -fn apply_config_overrides(config: &mut VlConverterConfig, overrides: &ConverterConfigOverrides) { +fn apply_config_overrides(config: &mut VlConverterConfig, overrides: ConverterConfigOverrides) { if let Some(num_workers) = overrides.num_workers { config.num_workers = num_workers; } if let Some(allow_http_access) = overrides.allow_http_access { config.allow_http_access = allow_http_access; } - if let Some(filesystem_root) = overrides.filesystem_root.clone() { + if let Some(filesystem_root) = overrides.filesystem_root { config.filesystem_root = filesystem_root; } - if let Some(allowed_base_urls) = overrides.allowed_base_urls.clone() { + if let Some(allowed_base_urls) = overrides.allowed_base_urls { config.allowed_base_urls = allowed_base_urls; } - if let Some(auto_install_fonts) = overrides.auto_install_fonts { - config.auto_install_fonts = auto_install_fonts; + if let Some(mb) = overrides.font_cache_size_mb { + let bytes = mb.saturating_mul(1024 * 1024); + configure_font_cache_rs(Some(bytes)); } } fn configure_converter_with_config_overrides( overrides: ConverterConfigOverrides, ) -> Result<(), vl_convert_rs::anyhow::Error> { - // Reconfigure the converter first — only apply cache size if this succeeds let mut guard = VL_CONVERTER.write().map_err(|e| { vl_convert_rs::anyhow::anyhow!("Failed to acquire converter write lock: {e}") })?; let mut config = guard.config(); - apply_config_overrides(&mut config, &overrides); + apply_config_overrides(&mut config, overrides); let converter = VlConverterRs::with_config(config)?; *guard = Arc::new(converter); - drop(guard); - - // Apply cache size after successful reconfiguration - if let Some(font_cache_size_mb) = overrides.font_cache_size_mb { - let bytes = font_cache_size_mb.saturating_mul(1024 * 1024); - configure_font_cache_rs(Some(bytes)); - } - Ok(()) } @@ -1275,27 +1233,22 @@ fn register_font_directory(font_dir: &str) -> PyResult<()> { Ok(()) } -/// Download, cache, and register a font by family name. -/// /// Downloads font files from the Fontsource catalog (which includes /// Google Fonts and other open-source fonts) and registers them for /// use in subsequent conversions. -/// -/// Args: -/// font_family (str): Font family name (e.g. "Roboto", "Playfair Display") -/// -/// Returns: -/// None #[pyfunction] #[pyo3(signature = (font_family))] fn install_font(font_family: &str) -> PyResult<()> { let font_family = font_family.to_string(); - run_converter_future(move |_converter| { - let font_family = font_family.clone(); - async move { install_font_rs(&font_family).await } + Python::with_gil(|py| { + py.allow_threads(move || { + PYTHON_RUNTIME + .block_on(async move { install_font_rs(&font_family).await }) + .map_err(|err| { + PyValueError::new_err(format!("Failed to install font: {}", err)) + }) + }) }) - .map_err(|err| prefixed_py_error("Font installation failed", err))?; - Ok(()) } /// Configure converter options for subsequent requests @@ -2190,15 +2143,16 @@ fn register_font_directory_asyncio<'py>( #[pyo3(signature = (font_family))] fn install_font_asyncio<'py>(py: Python<'py>, font_family: &str) -> PyResult> { let font_family = font_family.to_string(); - run_converter_future_async( - py, - move |_converter| { - let font_family = font_family.clone(); - async move { install_font_rs(&font_family).await } - }, - "Font installation failed", - |py, ()| Ok(py.None().into()), - ) + future_into_py_object(py, async move { + tokio::task::spawn_blocking(move || { + PYTHON_RUNTIME + .block_on(async move { install_font_rs(&font_family).await }) + }) + .await + .map_err(|err| PyValueError::new_err(format!("Task join error: {err}")))? + .map_err(|err| PyValueError::new_err(format!("Failed to install font: {err}")))?; + Python::with_gil(|py| Ok(py.None().into())) + }) } #[doc = async_variant_doc!("configure_converter")] diff --git a/vl-convert-python/vl_convert.pyi b/vl-convert-python/vl_convert.pyi index cc024818..32949247 100644 --- a/vl-convert-python/vl_convert.pyi +++ b/vl-convert-python/vl_convert.pyi @@ -128,14 +128,11 @@ if TYPE_CHECKING: TimeFormatLocale: TypeAlias = TimeFormatLocaleName | dict[str, Any] VlSpec: TypeAlias = str | dict[str, Any] - AutoInstallFonts: TypeAlias = Literal["strict", "best_effort"] | bool - class ConverterConfig(TypedDict): num_workers: int allow_http_access: bool filesystem_root: str | None allowed_base_urls: list[str] | None - auto_install_fonts: Literal["strict", "best_effort"] | bool __all__ = [ "asyncio", @@ -147,6 +144,7 @@ __all__ = [ "get_time_format_locale", "javascript_bundle", "register_font_directory", + "install_font", "warm_up_workers", "svg_to_jpeg", "svg_to_pdf", @@ -170,7 +168,6 @@ __all__ = [ "get_vega_themes_version", "get_vega_embed_version", "get_vegalite_versions", - "install_font", ] def get_format_locale(name: FormatLocaleName) -> dict[str, Any]: @@ -295,7 +292,6 @@ def configure_converter( allow_http_access: bool | None = None, filesystem_root: str | None = None, allowed_base_urls: list[str] | None = None, - auto_install_fonts: AutoInstallFonts | None = None, font_cache_size_mb: int | None = None, ) -> None: """ @@ -317,13 +313,6 @@ def configure_converter( When configured, HTTP redirects are denied instead of followed. Per-call ``allowed_base_urls`` arguments on conversion functions override this converter-level default when provided. - auto_install_fonts - Controls automatic font downloading from the Fontsource catalog. - ``"strict"`` examines only the first font in each CSS font-family string - and raises an error if it is not on the system or Fontsource. - ``"best_effort"`` logs warnings for unavailable fonts instead of erroring. - ``True`` is an alias for ``"strict"``. ``False`` or ``None`` disables. - Default is ``False``. font_cache_size_mb Maximum font cache size in megabytes. If ``None``, keep current value. """ @@ -984,7 +973,6 @@ if TYPE_CHECKING: allow_http_access: bool | None = None, filesystem_root: str | None = None, allowed_base_urls: list[str] | None = None, - auto_install_fonts: AutoInstallFonts | None = None, font_cache_size_mb: int | None = None, ) -> None: """Async version of ``configure_converter``. See sync function for full documentation.""" diff --git a/vl-convert-rs/src/converter.rs b/vl-convert-rs/src/converter.rs index 6eefc545..45655a06 100644 --- a/vl-convert-rs/src/converter.rs +++ b/vl-convert-rs/src/converter.rs @@ -441,22 +441,6 @@ impl ValueOrString { } } -/// Controls automatic font downloading from the Fontsource catalog. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum AutoInstallFonts { - /// Disabled (default). - #[default] - Off, - /// Only examines the first font in each CSS font-family string. - /// If it is not on the system and not in the Fontsource catalog, - /// the conversion fails with an error. - Strict, - /// Same first-font-only logic as `Strict`, but logs warnings for - /// unavailable fonts instead of failing. - BestEffort, -} - -#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] pub struct VlConverterConfig { pub num_workers: usize, @@ -466,8 +450,6 @@ pub struct VlConverterConfig { /// values override this default when provided. Must be non-empty when set. /// When configured, HTTP redirects are denied instead of followed. pub allowed_base_urls: Option>, - /// Controls automatic font downloading from the Fontsource catalog. - pub auto_install_fonts: AutoInstallFonts, } impl Default for VlConverterConfig { @@ -477,7 +459,6 @@ impl Default for VlConverterConfig { allow_http_access: true, filesystem_root: None, allowed_base_urls: None, - auto_install_fonts: AutoInstallFonts::Off, } } } diff --git a/vl-convert/src/main.rs b/vl-convert/src/main.rs index 414e222c..174fb17d 100644 --- a/vl-convert/src/main.rs +++ b/vl-convert/src/main.rs @@ -8,28 +8,13 @@ use std::io::{self, IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; use std::str::FromStr; use vl_convert_rs::converter::{ - vega_to_url, vegalite_to_url, AutoInstallFonts, FormatLocale, Renderer, TimeFormatLocale, - VgOpts, VlConverter, VlConverterConfig, VlOpts, + vega_to_url, vegalite_to_url, FormatLocale, Renderer, TimeFormatLocale, VgOpts, VlConverter, + VlConverterConfig, VlOpts, }; use vl_convert_rs::module_loader::import_map::VlVersion; use vl_convert_rs::text::register_font_directory; use vl_convert_rs::{anyhow, anyhow::bail, install_font}; -#[derive(Debug, Clone, clap::ValueEnum)] -enum AutoInstallFontsArg { - Strict, - BestEffort, -} - -impl AutoInstallFontsArg { - fn to_auto_install_fonts(&self) -> AutoInstallFonts { - match self { - AutoInstallFontsArg::Strict => AutoInstallFonts::Strict, - AutoInstallFontsArg::BestEffort => AutoInstallFonts::BestEffort, - } - } -} - const DEFAULT_VL_VERSION: &str = "6.4"; const DEFAULT_CONFIG_PATH: &str = "~/.config/vl-convert/config.json"; @@ -54,11 +39,6 @@ struct Cli { #[arg(long, global = true)] install_font: Vec, - /// Automatically download fonts referenced in specs from the Fontsource catalog. - /// "strict" errors if a font is unavailable; "best-effort" warns and continues. - #[arg(long, global = true, value_enum)] - auto_install_fonts: Option, - #[command(subcommand)] command: Commands, } @@ -597,16 +577,11 @@ async fn main() -> Result<(), anyhow::Error> { no_http_access, filesystem_root, install_font: install_font_families, - auto_install_fonts: auto_install_fonts_arg, command, } = Cli::parse(); if no_http_access { allow_http_access = false; } - let auto_install_fonts = auto_install_fonts_arg - .as_ref() - .map(|a| a.to_auto_install_fonts()) - .unwrap_or(AutoInstallFonts::Off); use crate::Commands::*; match command { Vl2vg { @@ -629,7 +604,6 @@ async fn main() -> Result<(), anyhow::Error> { show_warnings, allow_http_access, filesystem_root.clone(), - auto_install_fonts, ) .await? } @@ -659,7 +633,6 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), - auto_install_fonts, ) .await? } @@ -693,7 +666,6 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), - auto_install_fonts, ) .await? } @@ -727,7 +699,6 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), - auto_install_fonts, ) .await? } @@ -757,7 +728,6 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), - auto_install_fonts, ) .await? } @@ -766,7 +736,6 @@ async fn main() -> Result<(), anyhow::Error> { output, fullscreen, } => { - install_fonts(&install_font_families).await?; let vl_str = read_input_string(input.as_deref())?; let vl_spec = serde_json::from_str(&vl_str)?; let url = vegalite_to_url(&vl_spec, fullscreen)?; @@ -794,12 +763,7 @@ async fn main() -> Result<(), anyhow::Error> { parse_time_format_locale_option(time_format_locale.as_deref())?; let renderer = renderer.unwrap_or_else(|| "svg".to_string()); - let converter = build_converter( - allow_http_access, - filesystem_root.clone(), - None, - auto_install_fonts, - )?; + let converter = VlConverter::new(); let html = converter .vegalite_to_html( vl_spec, @@ -836,7 +800,6 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), - auto_install_fonts, ) .await? } @@ -862,7 +825,6 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), - auto_install_fonts, ) .await? } @@ -888,7 +850,6 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), - auto_install_fonts, ) .await? } @@ -910,7 +871,6 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), - auto_install_fonts, ) .await? } @@ -919,7 +879,6 @@ async fn main() -> Result<(), anyhow::Error> { output, fullscreen, } => { - install_fonts(&install_font_families).await?; let vg_str = read_input_string(input.as_deref())?; let vg_spec = serde_json::from_str(&vg_str)?; let url = vega_to_url(&vg_spec, fullscreen)?; @@ -944,12 +903,7 @@ async fn main() -> Result<(), anyhow::Error> { let renderer = renderer.unwrap_or_else(|| "svg".to_string()); - let converter = build_converter( - allow_http_access, - filesystem_root.clone(), - None, - auto_install_fonts, - )?; + let converter = VlConverter::new(); let html = converter .vega_to_html( vg_spec, @@ -975,12 +929,8 @@ async fn main() -> Result<(), anyhow::Error> { register_font_dir(font_dir)?; install_fonts(&install_font_families).await?; let svg = read_input_string(input.as_deref())?; - let converter = build_converter( - allow_http_access, - filesystem_root.clone(), - allowed_base_url, - auto_install_fonts, - )?; + let converter = + build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url)?; let png_data = converter.svg_to_png(&svg, scale, Some(ppi))?; write_output_binary(output.as_deref(), &png_data, "PNG")?; } @@ -995,12 +945,8 @@ async fn main() -> Result<(), anyhow::Error> { register_font_dir(font_dir)?; install_fonts(&install_font_families).await?; let svg = read_input_string(input.as_deref())?; - let converter = build_converter( - allow_http_access, - filesystem_root.clone(), - allowed_base_url, - auto_install_fonts, - )?; + let converter = + build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url)?; let jpeg_data = converter.svg_to_jpeg(&svg, scale, Some(quality))?; write_output_binary(output.as_deref(), &jpeg_data, "JPEG")?; } @@ -1013,12 +959,8 @@ async fn main() -> Result<(), anyhow::Error> { register_font_dir(font_dir)?; install_fonts(&install_font_families).await?; let svg = read_input_string(input.as_deref())?; - let converter = build_converter( - allow_http_access, - filesystem_root.clone(), - allowed_base_url, - auto_install_fonts, - )?; + let converter = + build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url)?; let pdf_data = converter.svg_to_pdf(&svg)?; write_output_binary(output.as_deref(), &pdf_data, "PDF")?; } @@ -1052,13 +994,13 @@ fn build_converter( allow_http_access: bool, filesystem_root: Option, allowed_base_urls: Option>, - auto_install_fonts: AutoInstallFonts, ) -> Result { - let mut config = VlConverterConfig::default(); - config.allow_http_access = allow_http_access; - config.filesystem_root = filesystem_root.map(PathBuf::from); - config.allowed_base_urls = allowed_base_urls; - config.auto_install_fonts = auto_install_fonts; + let config = VlConverterConfig { + allow_http_access, + filesystem_root: filesystem_root.map(PathBuf::from), + allowed_base_urls, + ..Default::default() + }; VlConverter::with_config(config) .map_err(|err| anyhow::anyhow!("Failed to configure converter: {err}")) @@ -1348,7 +1290,6 @@ async fn vl_2_vg( show_warnings: bool, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1363,7 +1304,7 @@ async fn vl_2_vg( let config = read_config_json(config)?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter(allow_http_access, filesystem_root, None)?; // Perform conversion let vega_json = match converter @@ -1412,7 +1353,6 @@ async fn vg_2_svg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1424,7 +1364,7 @@ async fn vg_2_svg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter(allow_http_access, filesystem_root, None)?; // Perform conversion let svg = match converter @@ -1461,7 +1401,6 @@ async fn vg_2_png( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1473,7 +1412,7 @@ async fn vg_2_png( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter(allow_http_access, filesystem_root, None)?; // Perform conversion let png_data = match converter @@ -1512,7 +1451,6 @@ async fn vg_2_jpeg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1524,7 +1462,7 @@ async fn vg_2_jpeg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter(allow_http_access, filesystem_root, None)?; // Perform conversion let jpeg_data = match converter @@ -1560,7 +1498,6 @@ async fn vg_2_pdf( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1572,7 +1509,7 @@ async fn vg_2_pdf( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter(allow_http_access, filesystem_root, None)?; // Perform conversion let pdf_data = match converter @@ -1611,7 +1548,6 @@ async fn vl_2_svg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1629,7 +1565,7 @@ async fn vl_2_svg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter(allow_http_access, filesystem_root, None)?; // Perform conversion let svg = match converter @@ -1674,7 +1610,6 @@ async fn vl_2_png( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1692,7 +1627,7 @@ async fn vl_2_png( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter(allow_http_access, filesystem_root, None)?; // Perform conversion let png_data = match converter @@ -1739,7 +1674,6 @@ async fn vl_2_jpeg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1757,7 +1691,7 @@ async fn vl_2_jpeg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter(allow_http_access, filesystem_root, None)?; // Perform conversion let jpeg_data = match converter @@ -1802,7 +1736,6 @@ async fn vl_2_pdf( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1820,7 +1753,7 @@ async fn vl_2_pdf( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter(allow_http_access, filesystem_root, None)?; // Perform conversion let pdf_data = match converter From 903179c704751b2b314fe7c447f7b9c6f234ee2a Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 25 Feb 2026 09:27:09 -0500 Subject: [PATCH 06/12] fix: correct Cargo.lock and formatting Remove font-subset from Cargo.lock (PR3-only dependency) and fix rustfmt formatting in Python bindings. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 10 ---------- vl-convert-python/src/lib.rs | 9 +++------ 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e91b42a4..09972bc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3702,15 +3702,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "font-subset" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47922b9d737f16d425c10ec7b9b9751590cf8908270b5f9647bdad960b4b979" -dependencies = [ - "brotli 8.0.2", -] - [[package]] name = "font-types" version = "0.10.1" @@ -9863,7 +9854,6 @@ dependencies = [ "dssim", "env_logger", "escape8259", - "font-subset", "fontdb", "futures", "futures-util", diff --git a/vl-convert-python/src/lib.rs b/vl-convert-python/src/lib.rs index adf9ca8c..ec2fd46b 100644 --- a/vl-convert-python/src/lib.rs +++ b/vl-convert-python/src/lib.rs @@ -11,6 +11,7 @@ use std::future::Future; use std::path::PathBuf; use std::str::FromStr; use std::sync::{Arc, RwLock}; +use vl_convert_rs::configure_font_cache as configure_font_cache_rs; use vl_convert_rs::converter::{ FormatLocale, Renderer, TimeFormatLocale, ValueOrString, VgOpts, VlConverterConfig, VlOpts, ACCESS_DENIED_MARKER, @@ -20,7 +21,6 @@ use vl_convert_rs::module_loader::import_map::{ }; use vl_convert_rs::module_loader::{FORMATE_LOCALE_MAP, TIME_FORMATE_LOCALE_MAP}; use vl_convert_rs::serde_json; -use vl_convert_rs::configure_font_cache as configure_font_cache_rs; use vl_convert_rs::text::install_font as install_font_rs; use vl_convert_rs::text::register_font_directory as register_font_directory_rs; use vl_convert_rs::VlConverter as VlConverterRs; @@ -1244,9 +1244,7 @@ fn install_font(font_family: &str) -> PyResult<()> { py.allow_threads(move || { PYTHON_RUNTIME .block_on(async move { install_font_rs(&font_family).await }) - .map_err(|err| { - PyValueError::new_err(format!("Failed to install font: {}", err)) - }) + .map_err(|err| PyValueError::new_err(format!("Failed to install font: {}", err))) }) }) } @@ -2145,8 +2143,7 @@ fn install_font_asyncio<'py>(py: Python<'py>, font_family: &str) -> PyResult Date: Wed, 25 Feb 2026 06:50:09 -0500 Subject: [PATCH 07/12] feat: add font extraction and resolution module Add extract module for parsing font-family strings from Vega specs and resolving them against system fonts and the Fontsource catalog: - CSS font-family string parser handling quoted/unquoted names - Vega spec traversal extracting fonts from axes, titles, marks, legends, transforms, and config - First-font resolution classifying fonts as available, downloadable, generic, or unavailable - FontForHtml type for tracking font source (Google vs other) --- vl-convert-rs/src/extract.rs | 1199 +++++++++++++++++++++++++++ vl-convert-rs/src/lib.rs | 1 + vl-convert-rs/tests/test_extract.rs | 266 ++++++ 3 files changed, 1466 insertions(+) create mode 100644 vl-convert-rs/src/extract.rs create mode 100644 vl-convert-rs/tests/test_extract.rs diff --git a/vl-convert-rs/src/extract.rs b/vl-convert-rs/src/extract.rs new file mode 100644 index 00000000..057ecaed --- /dev/null +++ b/vl-convert-rs/src/extract.rs @@ -0,0 +1,1199 @@ +use serde_json::Value; +use std::collections::HashSet; + +/// Metadata for a font that should be loaded via CDN in HTML output. +#[derive(Debug, Clone)] +pub struct FontForHtml { + /// The font family name (e.g., "Roboto", "Playfair Display"). + pub family: String, + /// The Fontsource font ID (e.g., "roboto", "playfair-display"). + pub font_id: String, + /// Whether this is a Google font ("google") or other ("other"). + pub font_type: String, +} + +// --------------------------------------------------------------------------- +// CSS generic family keywords (case-sensitive, per CSS spec) +// --------------------------------------------------------------------------- + +const GENERIC_FAMILIES: &[&str] = &[ + "serif", + "sans-serif", + "monospace", + "cursive", + "fantasy", + "system-ui", + "ui-serif", + "ui-sans-serif", + "ui-monospace", + "ui-rounded", + "emoji", + "math", + "fangsong", +]; + +/// A single entry from a parsed CSS `font-family` string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FontFamilyEntry { + /// A concrete font family name (e.g. "Roboto", "Playfair Display"). + Named(String), + /// A CSS generic family keyword (e.g. "serif", "sans-serif"). + Generic(String), +} + +// --------------------------------------------------------------------------- +// 1. CSS font-family parser +// --------------------------------------------------------------------------- + +/// Parse a CSS `font-family` string into a list of [`FontFamilyEntry`] values. +/// +/// The input is a comma-separated list of family names, each optionally +/// enclosed in single or double quotes. Generic family keywords are +/// recognised case-sensitively. +/// +/// # Examples +/// +/// ``` +/// use vl_convert_rs::extract::{parse_css_font_family, FontFamilyEntry}; +/// +/// let entries = parse_css_font_family("Roboto, sans-serif"); +/// assert_eq!(entries, vec![ +/// FontFamilyEntry::Named("Roboto".into()), +/// FontFamilyEntry::Generic("sans-serif".into()), +/// ]); +/// ``` +pub fn parse_css_font_family(s: &str) -> Vec { + s.split(',') + .filter_map(|segment| { + let trimmed = segment.trim(); + if trimmed.is_empty() { + return None; + } + + // Strip matching outer quotes (single or double). + let unquoted = strip_quotes(trimmed); + + if unquoted.is_empty() { + return None; + } + + if GENERIC_FAMILIES.contains(&unquoted) { + Some(FontFamilyEntry::Generic(unquoted.to_string())) + } else { + Some(FontFamilyEntry::Named(unquoted.to_string())) + } + }) + .collect() +} + +/// Strip matching outer single or double quotes from a string. +fn strip_quotes(s: &str) -> &str { + if s.len() >= 2 { + let bytes = s.as_bytes(); + if (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'') + || (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"') + { + return &s[1..s.len() - 1]; + } + } + s +} + +// --------------------------------------------------------------------------- +// 2. Font extraction from compiled Vega specs +// --------------------------------------------------------------------------- + +/// Extract all font-family CSS strings from a compiled Vega specification. +/// +/// Returns a deduplicated set of raw CSS font-family strings found in static +/// positions throughout the spec (config, marks, axes, legends, title, +/// data transforms). Dynamic references (signal/field) are skipped. +pub fn extract_fonts_from_vega(spec: &Value) -> HashSet { + let mut fonts = HashSet::new(); + + // Config + if let Some(config) = spec.get("config") { + extract_config_fonts(config, &mut fonts); + } + + // Top-level marks (recursive) + if let Some(marks) = spec.get("marks") { + extract_marks_fonts(marks, &mut fonts); + } + + // Top-level axes + if let Some(axes) = spec.get("axes") { + extract_axes_fonts(axes, &mut fonts); + } + + // Top-level legends + if let Some(legends) = spec.get("legends") { + extract_legends_fonts(legends, &mut fonts); + } + + // Top-level title + if let Some(title) = spec.get("title") { + extract_title_fonts(title, &mut fonts); + } + + // Data transforms (e.g. wordcloud) + if let Some(data) = spec.get("data").and_then(Value::as_array) { + for dataset in data { + if let Some(transforms) = dataset.get("transform").and_then(Value::as_array) { + for transform in transforms { + extract_transform_fonts(transform, &mut fonts); + } + } + } + } + + fonts +} + +// ---- Config extraction ---------------------------------------------------- + +/// Axis config key variants (per Vega's AxisConfigKeys type). +const AXIS_CONFIG_KEYS: &[&str] = &[ + "axis", + "axisX", + "axisY", + "axisTop", + "axisBottom", + "axisLeft", + "axisRight", + "axisBand", +]; + +/// Vega mark types whose config can carry a `font` property. +/// Only `text` renders text; other native marks (arc, area, etc.) ignore `font`. +const MARK_TYPE_KEYS: &[&str] = &["text"]; + +fn extract_config_fonts(config: &Value, fonts: &mut HashSet) { + // Title + if let Some(title) = config.get("title") { + collect_if_string(title, "font", fonts); + collect_if_string(title, "subtitleFont", fonts); + } + + // Axis variants + for &key in AXIS_CONFIG_KEYS { + if let Some(axis) = config.get(key) { + collect_if_string(axis, "labelFont", fonts); + collect_if_string(axis, "titleFont", fonts); + } + } + + // Legend + if let Some(legend) = config.get("legend") { + collect_if_string(legend, "labelFont", fonts); + collect_if_string(legend, "titleFont", fonts); + } + + // Mark type defaults + for &key in MARK_TYPE_KEYS { + if let Some(mark_cfg) = config.get(key) { + collect_if_string(mark_cfg, "font", fonts); + } + } + + // Named styles: config.style is an object { styleName: { font, ... } } + if let Some(style) = config.get("style").and_then(Value::as_object) { + for (_style_name, style_obj) in style { + collect_if_string(style_obj, "font", fonts); + collect_if_string(style_obj, "labelFont", fonts); + collect_if_string(style_obj, "titleFont", fonts); + } + } +} + +// ---- Mark extraction (recursive) ------------------------------------------ + +fn extract_marks_fonts(marks: &Value, fonts: &mut HashSet) { + let arr = match marks.as_array() { + Some(a) => a, + None => return, + }; + + for mark in arr { + // Encode blocks: enter, update, hover, exit + if let Some(encode) = mark.get("encode") { + for &state in &[ + "enter", "update", "hover", "exit", "leave", "select", "release", + ] { + if let Some(font_val) = encode + .get(state) + .and_then(|s| s.get("font")) + .and_then(|f| f.get("value")) + .and_then(Value::as_str) + { + fonts.insert(font_val.to_string()); + } + } + } + + // Group marks: recurse into nested marks, axes, legends + if let Some(nested_marks) = mark.get("marks") { + extract_marks_fonts(nested_marks, fonts); + } + if let Some(nested_axes) = mark.get("axes") { + extract_axes_fonts(nested_axes, fonts); + } + if let Some(nested_legends) = mark.get("legends") { + extract_legends_fonts(nested_legends, fonts); + } + // Also check for nested title within group marks + if let Some(nested_title) = mark.get("title") { + extract_title_fonts(nested_title, fonts); + } + } +} + +// ---- Axis extraction ------------------------------------------------------ + +fn extract_axes_fonts(axes: &Value, fonts: &mut HashSet) { + let arr = match axes.as_array() { + Some(a) => a, + None => return, + }; + + for axis in arr { + // Direct properties + collect_if_string(axis, "labelFont", fonts); + collect_if_string(axis, "titleFont", fonts); + + // Encode paths: encode.labels.update.font.value, encode.title.update.font.value + if let Some(encode) = axis.get("encode") { + if let Some(font_val) = encode + .get("labels") + .and_then(|l| l.get("update")) + .and_then(|u| u.get("font")) + .and_then(|f| f.get("value")) + .and_then(Value::as_str) + { + fonts.insert(font_val.to_string()); + } + if let Some(font_val) = encode + .get("title") + .and_then(|t| t.get("update")) + .and_then(|u| u.get("font")) + .and_then(|f| f.get("value")) + .and_then(Value::as_str) + { + fonts.insert(font_val.to_string()); + } + } + } +} + +// ---- Legend extraction ----------------------------------------------------- + +fn extract_legends_fonts(legends: &Value, fonts: &mut HashSet) { + let arr = match legends.as_array() { + Some(a) => a, + None => return, + }; + + for legend in arr { + // Direct properties + collect_if_string(legend, "labelFont", fonts); + collect_if_string(legend, "titleFont", fonts); + + // Encode paths: encode.labels.update.font.value, encode.title.update.font.value + if let Some(encode) = legend.get("encode") { + for &part in &["labels", "title"] { + if let Some(font_val) = encode + .get(part) + .and_then(|l| l.get("update")) + .and_then(|u| u.get("font")) + .and_then(|f| f.get("value")) + .and_then(Value::as_str) + { + fonts.insert(font_val.to_string()); + } + } + } + } +} + +// ---- Title extraction ----------------------------------------------------- + +fn extract_title_fonts(title: &Value, fonts: &mut HashSet) { + // Title can be a string (no font info) or an object. + if title.is_string() { + return; + } + collect_if_string(title, "font", fonts); + collect_if_string(title, "subtitleFont", fonts); + + // Encode paths: encode.title.update.font.value, encode.subtitle.update.font.value + if let Some(encode) = title.get("encode") { + for &part in &["title", "subtitle"] { + if let Some(font_val) = encode + .get(part) + .and_then(|l| l.get("update")) + .and_then(|u| u.get("font")) + .and_then(|f| f.get("value")) + .and_then(Value::as_str) + { + fonts.insert(font_val.to_string()); + } + } + } +} + +// ---- Transform extraction ------------------------------------------------- + +fn extract_transform_fonts(transform: &Value, fonts: &mut HashSet) { + // Wordcloud transforms: { "type": "wordcloud", "font": "..." } + let is_wordcloud = transform + .get("type") + .and_then(Value::as_str) + .map(|t| t == "wordcloud") + .unwrap_or(false); + + if is_wordcloud { + collect_if_string(transform, "font", fonts); + } +} + +// ---- Shared helper -------------------------------------------------------- + +/// If `obj[key]` is a JSON string, insert it into `fonts`. +fn collect_if_string(obj: &Value, key: &str, fonts: &mut HashSet) { + if let Some(val) = obj.get(key).and_then(Value::as_str) { + if !val.is_empty() { + fonts.insert(val.to_string()); + } + } +} + +// --------------------------------------------------------------------------- +// 3. Resolution: determine which fonts to download +// --------------------------------------------------------------------------- + +/// Classification of the first font in a CSS `font-family` string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FirstFontStatus { + /// First entry is a CSS generic keyword (serif, sans-serif, etc.) — + /// always satisfied by the system font configuration. + Generic, + /// First entry is already registered in fontdb. + Available { name: String }, + /// First entry is downloadable from Fontsource. + NeedsDownload { name: String }, + /// First entry is not on the system and not on Fontsource. + Unavailable { name: String }, +} + +/// Classify each font-family string by examining only the **first** entry. +/// +/// For each CSS font-family string, the first entry is checked: +/// +/// 1. **Generic** keyword (serif, sans-serif, etc.) → [`FirstFontStatus::Generic`] +/// 2. **Named** family already in `available` → [`FirstFontStatus::Available`] +/// 3. **Named** family for which `downloadable(family)` returns `true` → +/// [`FirstFontStatus::NeedsDownload`] +/// 4. **Named** family that is neither available nor downloadable → +/// [`FirstFontStatus::Unavailable`] +/// +/// Only the first entry matters — the rest of the fallback chain is ignored. +/// Results are deduplicated by CSS string. +pub fn resolve_first_fonts( + font_strings: &[String], + available: &HashSet, + downloadable: impl Fn(&str) -> bool, +) -> Vec<(String, FirstFontStatus)> { + let mut results: Vec<(String, FirstFontStatus)> = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + for font_string in font_strings { + if !seen.insert(font_string.clone()) { + continue; + } + + let entries = parse_css_font_family(font_string); + let status = match entries.first() { + None => continue, // empty/whitespace-only string + Some(FontFamilyEntry::Generic(_)) => FirstFontStatus::Generic, + Some(FontFamilyEntry::Named(name)) => { + if is_available(name, available) { + FirstFontStatus::Available { name: name.clone() } + } else if downloadable(name) { + FirstFontStatus::NeedsDownload { name: name.clone() } + } else { + FirstFontStatus::Unavailable { name: name.clone() } + } + } + }; + + results.push((font_string.clone(), status)); + } + + results +} + +/// Case-insensitive membership check against the available font set. +/// +/// The `available` set is expected to contain font names in their original +/// casing (as reported by fontdb). We check both the exact name and a +/// lowercased version. +fn is_available(name: &str, available: &HashSet) -> bool { + if available.contains(name) { + return true; + } + let lower = name.to_lowercase(); + available.iter().any(|a| a.to_lowercase() == lower) +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // ----------------------------------------------------------------------- + // 1. CSS parser tests + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_single_named() { + assert_eq!( + parse_css_font_family("Roboto"), + vec![FontFamilyEntry::Named("Roboto".into())] + ); + } + + #[test] + fn test_parse_named_and_generic() { + assert_eq!( + parse_css_font_family("Roboto, sans-serif"), + vec![ + FontFamilyEntry::Named("Roboto".into()), + FontFamilyEntry::Generic("sans-serif".into()), + ] + ); + } + + #[test] + fn test_parse_single_quoted() { + assert_eq!( + parse_css_font_family("'Playfair Display', Georgia, serif"), + vec![ + FontFamilyEntry::Named("Playfair Display".into()), + FontFamilyEntry::Named("Georgia".into()), + FontFamilyEntry::Generic("serif".into()), + ] + ); + } + + #[test] + fn test_parse_double_quoted() { + assert_eq!( + parse_css_font_family("\"IBM Plex Sans\""), + vec![FontFamilyEntry::Named("IBM Plex Sans".into())] + ); + } + + #[test] + fn test_parse_all_generics() { + for &generic in GENERIC_FAMILIES { + let entries = parse_css_font_family(generic); + assert_eq!( + entries, + vec![FontFamilyEntry::Generic(generic.into())], + "failed for generic: {}", + generic + ); + } + } + + #[test] + fn test_parse_empty_string() { + assert!(parse_css_font_family("").is_empty()); + } + + #[test] + fn test_parse_only_commas() { + assert!(parse_css_font_family(",,,").is_empty()); + } + + #[test] + fn test_parse_whitespace_around_commas() { + assert_eq!( + parse_css_font_family(" Roboto , Arial , monospace "), + vec![ + FontFamilyEntry::Named("Roboto".into()), + FontFamilyEntry::Named("Arial".into()), + FontFamilyEntry::Generic("monospace".into()), + ] + ); + } + + #[test] + fn test_parse_mixed_quotes() { + assert_eq!( + parse_css_font_family("'Times New Roman', \"Courier New\", monospace"), + vec![ + FontFamilyEntry::Named("Times New Roman".into()), + FontFamilyEntry::Named("Courier New".into()), + FontFamilyEntry::Generic("monospace".into()), + ] + ); + } + + #[test] + fn test_parse_unquoted_multi_word() { + assert_eq!( + parse_css_font_family("Segoe UI"), + vec![FontFamilyEntry::Named("Segoe UI".into())] + ); + } + + #[test] + fn test_parse_power_bi_chain() { + assert_eq!( + parse_css_font_family("wf_standard-font, helvetica, arial, sans-serif"), + vec![ + FontFamilyEntry::Named("wf_standard-font".into()), + FontFamilyEntry::Named("helvetica".into()), + FontFamilyEntry::Named("arial".into()), + FontFamilyEntry::Generic("sans-serif".into()), + ] + ); + } + + // ----------------------------------------------------------------------- + // 2. Extraction tests + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_config_fonts() { + let spec = json!({ + "config": { + "title": { + "font": "Playfair Display, Georgia, serif", + "subtitleFont": "Source Sans Pro" + }, + "axis": { + "labelFont": "Fira Code, monospace", + "titleFont": "Roboto" + }, + "legend": { + "labelFont": "Noto Sans", + "titleFont": "Noto Serif" + }, + "text": { + "font": "IBM Plex Mono" + }, + "style": { + "guide-label": { + "font": "Lato" + }, + "group-title": { + "font": "Oswald" + } + } + } + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Playfair Display, Georgia, serif")); + assert!(fonts.contains("Source Sans Pro")); + assert!(fonts.contains("Fira Code, monospace")); + assert!(fonts.contains("Roboto")); + assert!(fonts.contains("Noto Sans")); + assert!(fonts.contains("Noto Serif")); + assert!(fonts.contains("IBM Plex Mono")); + assert!(fonts.contains("Lato")); + assert!(fonts.contains("Oswald")); + } + + #[test] + fn test_extract_mark_fonts() { + let spec = json!({ + "marks": [ + { + "type": "text", + "encode": { + "enter": { + "font": { "value": "Merriweather" } + }, + "update": { + "font": { "value": "Roboto Mono" } + } + } + }, + { + "type": "text", + "encode": { + "update": { + "font": { "signal": "dynamicFont" } + } + } + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Merriweather")); + assert!(fonts.contains("Roboto Mono")); + // Signal-driven font should NOT be extracted. + assert_eq!(fonts.len(), 2); + } + + #[test] + fn test_extract_nested_group_marks() { + let spec = json!({ + "marks": [ + { + "type": "group", + "marks": [ + { + "type": "text", + "encode": { + "update": { + "font": { "value": "Cabin" } + } + } + } + ], + "axes": [ + { "labelFont": "Inconsolata" } + ], + "legends": [ + { "titleFont": "Open Sans" } + ] + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Cabin")); + assert!(fonts.contains("Inconsolata")); + assert!(fonts.contains("Open Sans")); + } + + #[test] + fn test_extract_axes_fonts() { + let spec = json!({ + "axes": [ + { + "labelFont": "Fira Sans", + "titleFont": "Fira Sans Bold" + }, + { + "encode": { + "labels": { + "update": { + "font": { "value": "Droid Sans" } + } + }, + "title": { + "update": { + "font": { "value": "Droid Serif" } + } + } + } + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Fira Sans")); + assert!(fonts.contains("Fira Sans Bold")); + assert!(fonts.contains("Droid Sans")); + assert!(fonts.contains("Droid Serif")); + } + + #[test] + fn test_extract_legends_fonts() { + let spec = json!({ + "legends": [ + { + "labelFont": "PT Sans", + "titleFont": "PT Serif" + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("PT Sans")); + assert!(fonts.contains("PT Serif")); + } + + #[test] + fn test_extract_legends_encode_fonts() { + let spec = json!({ + "legends": [ + { + "encode": { + "labels": { + "update": { + "font": { "value": "Droid Sans" } + } + }, + "title": { + "update": { + "font": { "value": "Droid Serif" } + } + } + } + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Droid Sans")); + assert!(fonts.contains("Droid Serif")); + assert_eq!(fonts.len(), 2); + } + + #[test] + fn test_extract_title_fonts() { + let spec = json!({ + "title": { + "text": "My Chart", + "font": "Montserrat, sans-serif", + "subtitleFont": "Lora" + } + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Montserrat, sans-serif")); + assert!(fonts.contains("Lora")); + } + + #[test] + fn test_extract_title_encode_fonts() { + let spec = json!({ + "title": { + "text": "My Chart", + "encode": { + "title": { + "update": { + "font": { "value": "Montserrat" } + } + }, + "subtitle": { + "update": { + "font": { "value": "Lora" } + } + } + } + } + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Montserrat")); + assert!(fonts.contains("Lora")); + assert_eq!(fonts.len(), 2); + } + + #[test] + fn test_extract_title_string_only() { + // When title is just a string, there's no font info. + let spec = json!({ + "title": "My Chart" + }); + + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.is_empty()); + } + + #[test] + fn test_extract_wordcloud_transform() { + let spec = json!({ + "data": [ + { + "name": "table", + "transform": [ + { + "type": "wordcloud", + "font": "Pacifico" + }, + { + "type": "formula", + "as": "weight" + } + ] + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Pacifico")); + assert_eq!(fonts.len(), 1); + } + + #[test] + fn test_extract_wordcloud_signal_font_skipped() { + let spec = json!({ + "data": [ + { + "name": "table", + "transform": [ + { + "type": "wordcloud", + "font": { "signal": "fontChoice" } + } + ] + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + // Signal-based font in wordcloud → not a string, skipped. + assert!(fonts.is_empty()); + } + + #[test] + fn test_extract_axis_orientation_overrides() { + let spec = json!({ + "config": { + "axisX": { "labelFont": "Barlow" }, + "axisY": { "titleFont": "Barlow Condensed" }, + "axisTop": { "labelFont": "Rubik" }, + "axisBottom": { "titleFont": "Ubuntu" }, + "axisLeft": { "labelFont": "Quicksand" }, + "axisRight": { "titleFont": "Karla" }, + "axisBand": { "labelFont": "Manrope" } + } + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Barlow")); + assert!(fonts.contains("Barlow Condensed")); + assert!(fonts.contains("Rubik")); + assert!(fonts.contains("Ubuntu")); + assert!(fonts.contains("Quicksand")); + assert!(fonts.contains("Karla")); + assert!(fonts.contains("Manrope")); + } + + #[test] + fn test_extract_text_mark_config() { + let spec = json!({ + "config": { + "text": { "font": "IBM Plex Mono" } + } + }); + + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("IBM Plex Mono")); + assert_eq!(fonts.len(), 1); + } + + #[test] + fn test_extract_config_style_label_title_fonts() { + let spec = json!({ + "config": { + "style": { + "guide-label": { + "labelFont": "Asap", + "titleFont": "Assistant" + } + } + } + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Asap")); + assert!(fonts.contains("Assistant")); + } + + #[test] + fn test_extract_deduplicates() { + let spec = json!({ + "config": { + "axis": { + "labelFont": "Roboto", + "titleFont": "Roboto" + } + }, + "axes": [ + { "labelFont": "Roboto" } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Roboto")); + assert_eq!(fonts.len(), 1); + } + + #[test] + fn test_extract_comprehensive_fixture() { + let spec = json!({ + "config": { + "title": { "font": "Playfair Display, serif" }, + "axis": { "labelFont": "Fira Code, monospace" }, + "text": { "font": "IBM Plex Sans" }, + "style": { + "guide-label": { "font": "Lato" } + } + }, + "marks": [ + { + "type": "text", + "encode": { + "update": { + "font": { "value": "Merriweather" } + } + } + }, + { + "type": "group", + "marks": [ + { + "type": "text", + "encode": { + "enter": { + "font": { "value": "Cabin" } + } + } + } + ], + "axes": [ + { "labelFont": "Inconsolata" } + ] + } + ], + "axes": [ + { "titleFont": "Source Sans Pro" } + ], + "legends": [ + { "labelFont": "Noto Sans" } + ], + "title": { + "text": "Chart Title", + "font": "Montserrat", + "subtitleFont": "Lora" + }, + "data": [ + { + "name": "words", + "transform": [ + { "type": "wordcloud", "font": "Pacifico" } + ] + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + let expected: HashSet = [ + "Playfair Display, serif", + "Fira Code, monospace", + "IBM Plex Sans", + "Lato", + "Merriweather", + "Cabin", + "Inconsolata", + "Source Sans Pro", + "Noto Sans", + "Montserrat", + "Lora", + "Pacifico", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + + assert_eq!(fonts, expected); + } + + // ----------------------------------------------------------------------- + // 3. Resolution tests (first-font-only semantics) + // ----------------------------------------------------------------------- + + #[test] + fn test_resolve_first_font_generic() { + // "serif" → first entry is generic → Generic + let font_strings = vec!["serif".to_string()]; + let available: HashSet = HashSet::new(); + let downloadable = |_: &str| true; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].1, FirstFontStatus::Generic); + } + + #[test] + fn test_resolve_first_font_available() { + // "Arial, sans-serif" → first entry is Arial, which is available + let font_strings = vec!["Arial, sans-serif".to_string()]; + let available: HashSet = ["Arial".to_string()].into(); + let downloadable = |_: &str| false; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].1, + FirstFontStatus::Available { + name: "Arial".into() + } + ); + } + + #[test] + fn test_resolve_first_font_downloadable() { + // "Roboto, sans-serif" → first entry is Roboto, downloadable + let font_strings = vec!["Roboto, sans-serif".to_string()]; + let available: HashSet = HashSet::new(); + let downloadable = |name: &str| name == "Roboto"; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].1, + FirstFontStatus::NeedsDownload { + name: "Roboto".into() + } + ); + } + + #[test] + fn test_resolve_first_font_unavailable() { + // "Benton Gothic, Roboto, sans-serif" + // First entry is Benton Gothic: not available, not downloadable → Unavailable + // Roboto (second in chain) is NOT considered. + let font_strings = vec!["Benton Gothic, Roboto, sans-serif".to_string()]; + let available: HashSet = HashSet::new(); + let downloadable = |name: &str| name == "Roboto"; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].1, + FirstFontStatus::Unavailable { + name: "Benton Gothic".into() + } + ); + } + + #[test] + fn test_resolve_first_font_case_insensitive_available() { + // fontdb might report "arial" but the spec has "Arial" + let font_strings = vec!["Arial, sans-serif".to_string()]; + let available: HashSet = ["arial".to_string()].into(); + let downloadable = |_: &str| true; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].1, + FirstFontStatus::Available { + name: "Arial".into() + } + ); + } + + #[test] + fn test_resolve_deduplicates() { + // Same CSS string appears twice — only one result entry + let font_strings = vec![ + "Roboto, sans-serif".to_string(), + "Roboto, sans-serif".to_string(), + ]; + let available: HashSet = HashSet::new(); + let downloadable = |name: &str| name == "Roboto"; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].1, + FirstFontStatus::NeedsDownload { + name: "Roboto".into() + } + ); + } + + #[test] + fn test_resolve_multiple_different_fonts() { + let font_strings = vec![ + "Inter".to_string(), + "Playfair Display, Georgia, serif".to_string(), + "Fira Code, Courier New, monospace".to_string(), + ]; + let available: HashSet = HashSet::new(); + let downloadable = |name: &str| matches!(name, "Inter" | "Playfair Display" | "Fira Code"); + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 3); + assert_eq!( + result[0].1, + FirstFontStatus::NeedsDownload { + name: "Inter".into() + } + ); + assert_eq!( + result[1].1, + FirstFontStatus::NeedsDownload { + name: "Playfair Display".into() + } + ); + assert_eq!( + result[2].1, + FirstFontStatus::NeedsDownload { + name: "Fira Code".into() + } + ); + } + + #[test] + fn test_resolve_empty_input() { + let font_strings: Vec = vec![]; + let available: HashSet = HashSet::new(); + let downloadable = |_: &str| true; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert!(result.is_empty()); + } + + #[test] + fn test_resolve_wordcloud_font() { + let spec = json!({ + "data": [{ + "name": "words", + "transform": [{ "type": "wordcloud", "font": "Pacifico" }] + }] + }); + + let fonts = extract_fonts_from_vega(&spec); + let font_strings: Vec = fonts.into_iter().collect(); + let available: HashSet = HashSet::new(); + let downloadable = |name: &str| name == "Pacifico"; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].1, + FirstFontStatus::NeedsDownload { + name: "Pacifico".into() + } + ); + } +} diff --git a/vl-convert-rs/src/lib.rs b/vl-convert-rs/src/lib.rs index ceb21fe3..1229e1bd 100644 --- a/vl-convert-rs/src/lib.rs +++ b/vl-convert-rs/src/lib.rs @@ -3,6 +3,7 @@ pub mod converter; pub mod deno_emit; pub mod deno_stubs; +pub mod extract; pub mod html; pub mod image_loading; pub mod module_loader; diff --git a/vl-convert-rs/tests/test_extract.rs b/vl-convert-rs/tests/test_extract.rs new file mode 100644 index 00000000..4982fb78 --- /dev/null +++ b/vl-convert-rs/tests/test_extract.rs @@ -0,0 +1,266 @@ +use std::collections::HashSet; +use vl_convert_rs::extract::{ + extract_fonts_from_vega, parse_css_font_family, resolve_first_fonts, FirstFontStatus, + FontFamilyEntry, +}; + +#[test] +fn test_css_parser_edge_cases() { + // Empty string + assert!(parse_css_font_family("").is_empty()); + + // Only whitespace + assert!(parse_css_font_family(" ").is_empty()); + + // Only commas + assert!(parse_css_font_family(",,,").is_empty()); + + // Quoted font with commas inside — parser splits on commas naively, + // which is acceptable since real font families never contain commas. + // This test documents the behavior rather than asserting ideal parsing. + let entries = parse_css_font_family("'Font, With Comma', serif"); + assert_eq!(entries.len(), 3); // 'Font | With Comma' | serif + + // Double-quoted + let entries = parse_css_font_family(r#""Playfair Display", sans-serif"#); + assert_eq!(entries.len(), 2); + assert!(matches!(&entries[0], FontFamilyEntry::Named(n) if n == "Playfair Display")); + assert!(matches!(&entries[1], FontFamilyEntry::Generic(g) if g == "sans-serif")); + + // wf_standard-font (real-world non-standard name) + let entries = parse_css_font_family("wf_standard-font, sans-serif"); + assert_eq!(entries.len(), 2); + assert!(matches!(&entries[0], FontFamilyEntry::Named(n) if n == "wf_standard-font")); +} + +#[test] +fn test_extraction_from_vega_fixture() { + // A comprehensive Vega spec with fonts in multiple locations + let spec: serde_json::Value = serde_json::json!({ + "config": { + "title": {"font": "Playfair Display"}, + "axis": {"labelFont": "Open Sans", "titleFont": "Lato"}, + "legend": {"labelFont": "Merriweather"}, + "text": {"font": "Roboto"} + }, + "marks": [ + { + "type": "text", + "encode": { + "enter": { + "font": {"value": "Source Sans Pro"} + } + } + }, + { + "type": "group", + "marks": [ + { + "type": "text", + "encode": { + "update": { + "font": {"value": "Montserrat"} + } + } + } + ] + } + ], + "axes": [ + {"orient": "bottom", "labelFont": "Inter"} + ], + "legends": [ + {"titleFont": "Oswald"} + ], + "title": {"text": "My Chart", "font": "Raleway", "subtitleFont": "PT Sans"} + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Roboto"), "Text mark config font missing"); + assert!(fonts.contains("Playfair Display"), "Title font missing"); + assert!(fonts.contains("Open Sans"), "Axis labelFont missing"); + assert!(fonts.contains("Lato"), "Axis titleFont missing"); + assert!(fonts.contains("Merriweather"), "Legend labelFont missing"); + assert!(fonts.contains("Source Sans Pro"), "Mark enter font missing"); + assert!( + fonts.contains("Montserrat"), + "Nested group mark font missing" + ); + assert!(fonts.contains("Inter"), "Axes labelFont missing"); + assert!(fonts.contains("Oswald"), "Legends titleFont missing"); + assert!(fonts.contains("Raleway"), "Title font missing"); + assert!(fonts.contains("PT Sans"), "Subtitle font missing"); +} + +#[test] +fn test_extraction_with_css_fallback_chains() { + // Fonts specified as CSS fallback chains + let spec: serde_json::Value = serde_json::json!({ + "config": { + "text": { "font": "Benton Gothic, Roboto, sans-serif" } + } + }); + + let fonts = extract_fonts_from_vega(&spec); + + // Should contain the raw font string with the full chain + assert!(fonts.contains("Benton Gothic, Roboto, sans-serif")); +} + +#[test] +fn test_resolve_first_font_unavailable() { + // First font is not available or downloadable → Unavailable + let font_strings = vec!["Benton Gothic, Roboto, sans-serif".to_string()]; + let available: HashSet = HashSet::new(); + let downloadable = |family: &str| -> bool { family == "Roboto" }; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + ( + "Benton Gothic, Roboto, sans-serif".to_string(), + FirstFontStatus::Unavailable { + name: "Benton Gothic".to_string() + } + ) + ); +} + +#[test] +fn test_resolve_first_font_available() { + // First font is locally available → Available + let font_strings = vec!["Arial, Roboto, sans-serif".to_string()]; + let available: HashSet = HashSet::from(["Arial".to_string()]); + let downloadable = |_family: &str| -> bool { true }; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + ( + "Arial, Roboto, sans-serif".to_string(), + FirstFontStatus::Available { + name: "Arial".to_string() + } + ) + ); +} + +#[test] +fn test_resolve_first_font_needs_download() { + // First font is downloadable → NeedsDownload + let font_strings = vec!["Roboto, sans-serif".to_string()]; + let available: HashSet = HashSet::new(); + let downloadable = |family: &str| -> bool { family == "Roboto" }; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + ( + "Roboto, sans-serif".to_string(), + FirstFontStatus::NeedsDownload { + name: "Roboto".to_string() + } + ) + ); +} + +#[test] +fn test_resolve_first_font_generic() { + // First font is a generic keyword → Generic + let font_strings = vec!["sans-serif".to_string()]; + let available: HashSet = HashSet::new(); + let downloadable = |_family: &str| -> bool { false }; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + ("sans-serif".to_string(), FirstFontStatus::Generic) + ); +} + +#[test] +fn test_resolve_deduplicates_font_strings() { + // Duplicate font strings should be deduplicated + let font_strings = vec![ + "Roboto, sans-serif".to_string(), + "Roboto, sans-serif".to_string(), + "Open Sans, serif".to_string(), + ]; + let available: HashSet = HashSet::new(); + let downloadable = |family: &str| -> bool { family == "Roboto" || family == "Open Sans" }; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 2); + assert_eq!( + result[0], + ( + "Roboto, sans-serif".to_string(), + FirstFontStatus::NeedsDownload { + name: "Roboto".to_string() + } + ) + ); + assert_eq!( + result[1], + ( + "Open Sans, serif".to_string(), + FirstFontStatus::NeedsDownload { + name: "Open Sans".to_string() + } + ) + ); +} + +#[test] +fn test_resolve_nothing_downloadable() { + // First font is not available or downloadable → Unavailable + let font_strings = vec!["Benton Gothic, Proprietary Font, sans-serif".to_string()]; + let available: HashSet = HashSet::new(); + let downloadable = |_family: &str| -> bool { false }; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + ( + "Benton Gothic, Proprietary Font, sans-serif".to_string(), + FirstFontStatus::Unavailable { + name: "Benton Gothic".to_string() + } + ) + ); +} + +#[test] +fn test_extraction_wordcloud_transform() { + let spec: serde_json::Value = serde_json::json!({ + "data": [ + { + "name": "table", + "transform": [ + { + "type": "wordcloud", + "font": "Lobster" + } + ] + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + assert!( + fonts.contains("Lobster"), + "Wordcloud font should be extracted" + ); +} From e24910ef508b6f0992c35309f86d3873673407e2 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 25 Feb 2026 06:53:19 -0500 Subject: [PATCH 08/12] feat: add auto font download to converter --- vl-convert-rs/src/converter.rs | 271 ++++++++++++++++++++++++++++++--- 1 file changed, 249 insertions(+), 22 deletions(-) diff --git a/vl-convert-rs/src/converter.rs b/vl-convert-rs/src/converter.rs index 45655a06..29bfb39c 100644 --- a/vl-convert-rs/src/converter.rs +++ b/vl-convert-rs/src/converter.rs @@ -42,8 +42,12 @@ use image::codecs::jpeg::JpegEncoder; use image::ImageReader; use resvg::render; -use crate::text::{FONT_CONFIG, FONT_CONFIG_VERSION, USVG_OPTIONS}; +use crate::extract::{extract_fonts_from_vega, resolve_first_fonts, FirstFontStatus, FontForHtml}; +use crate::text::{ + fetch_and_register_font, FONTSOURCE_CACHE, FONT_CONFIG, FONT_CONFIG_VERSION, USVG_OPTIONS, +}; use std::sync::atomic::{AtomicUsize, Ordering}; +use vl_convert_fontsource::types::family_to_id; // Extension with our custom ops - MainWorker provides all Web APIs (URL, fetch, etc.) // Canvas 2D ops are now in the separate vl_convert_canvas2d extension from vl-convert-canvas2d-deno @@ -2297,6 +2301,149 @@ impl VlConvertCommand { /// /// println!("{}", vega_spec) /// ``` +/// Automatically download missing fonts referenced in a compiled Vega spec. +/// +/// This function extracts font-family strings from the spec, classifies the +/// first font in each string, then downloads any that need it. +/// +/// In `Strict` mode, returns an error listing any fonts that are neither on +/// the system nor in the Fontsource catalog — before any downloads begin. +/// In `BestEffort` mode, logs warnings for unavailable fonts and continues. +async fn auto_download_fonts( + vega_spec: &serde_json::Value, + mode: &AutoInstallFonts, +) -> Result, AnyError> { + let font_strings = extract_fonts_from_vega(vega_spec); + if font_strings.is_empty() { + return Ok(Vec::new()); + } + + // Get currently available font families from fontdb + let available: HashSet = USVG_OPTIONS + .lock() + .map_err(|e| anyhow!("auto_install_fonts: failed to lock USVG_OPTIONS: {e}"))? + .fontdb + .faces() + .flat_map(|face| face.families.iter().map(|(name, _)| name.clone())) + .collect(); + + let font_string_vec: Vec = font_strings.into_iter().collect(); + + // Check which first-fonts are known to Fontsource (only those not already available) + let mut downloadable_set: HashSet = HashSet::new(); + for font_string in &font_string_vec { + let entries = crate::extract::parse_css_font_family(font_string); + if let Some(crate::extract::FontFamilyEntry::Named(ref name)) = entries.first() { + if !is_available_in_fontdb(name, &available) { + if let Some(font_id) = family_to_id(name) { + match FONTSOURCE_CACHE.is_known_font(&font_id).await { + Ok(true) => { + downloadable_set.insert(name.clone()); + } + Ok(false) => {} + Err(e) => { + log::warn!( + "auto_install_fonts: failed to check if '{}' is known: {e}", + name + ); + } + } + } + } + } + } + + // Classify each font string by its first entry + let statuses = resolve_first_fonts(&font_string_vec, &available, |family| { + downloadable_set.contains(family) + }); + + // Collect unavailable fonts — report before any downloads + let unavailable: Vec<(&str, &str)> = statuses + .iter() + .filter_map(|(css_string, status)| match status { + FirstFontStatus::Unavailable { name } => Some((name.as_str(), css_string.as_str())), + _ => None, + }) + .collect(); + + if !unavailable.is_empty() { + match mode { + AutoInstallFonts::Strict => { + let details: Vec = unavailable + .iter() + .map(|(name, css)| { + if *name == *css { + format!("'{name}'") + } else { + format!("'{name}' (from \"{css}\")") + } + }) + .collect(); + return Err(anyhow!( + "auto_install_fonts: the following fonts are not available on the system \ + and not found in the Fontsource catalog: {}", + details.join(", ") + )); + } + AutoInstallFonts::BestEffort => { + for (name, _css) in &unavailable { + log::warn!( + "auto_install_fonts: font '{name}' is not available on the system \ + and not found in the Fontsource catalog, skipping" + ); + } + } + AutoInstallFonts::Off => unreachable!(), + } + } + + // Download and register fonts that need it + let mut downloaded_font_ids: HashSet = HashSet::new(); + let mut html_fonts: Vec = Vec::new(); + for (_css_string, status) in &statuses { + if let FirstFontStatus::NeedsDownload { name } = status { + match fetch_and_register_font(name).await { + Ok(outcome) => { + if outcome.downloaded { + downloaded_font_ids.insert(outcome.font_id.clone()); + } + html_fonts.push(FontForHtml { + family: name.clone(), + font_id: outcome.font_id, + font_type: outcome.font_type.unwrap_or_else(|| "google".to_string()), + }); + } + Err(e) => { + log::warn!("auto_install_fonts: failed to install '{name}': {e}"); + } + } + } + } + + // Evict LRU fonts if cache limit is set + let max_bytes = FONTSOURCE_CACHE.max_cache_bytes(); + if max_bytes > 0 && !downloaded_font_ids.is_empty() { + if let Err(e) = FONTSOURCE_CACHE.evict_lru_until_size(max_bytes, &downloaded_font_ids) { + log::warn!("auto_install_fonts: cache eviction failed: {e}"); + } + } + + // Sort for deterministic output + html_fonts.sort_by(|a, b| a.family.cmp(&b.family)); + + Ok(html_fonts) +} + +/// Case-insensitive check if a font name is available in fontdb. +fn is_available_in_fontdb(name: &str, available: &HashSet) -> bool { + if available.contains(name) { + return true; + } + let lower = name.to_lowercase(); + available.iter().any(|a| a.to_lowercase() == lower) +} + struct VlConverterInner { vegaembed_bundles: Mutex>, pool: Mutex>, @@ -2453,6 +2600,41 @@ impl VlConverter { .await } + /// If `auto_install_fonts` is enabled, compile VL→Vega and download any missing fonts. + /// + /// Returns `Ok((vega_spec, vg_opts))` with the compiled Vega spec and options + /// for the caller to render directly, or `Err(vl_spec)` returning the original + /// spec if auto-install is disabled. + async fn maybe_compile_vl_with_auto_fonts( + &self, + vl_spec: ValueOrString, + vl_opts: &VlOpts, + ) -> Result, AnyError> { + if self.inner.config.auto_install_fonts == AutoInstallFonts::Off { + return Ok(Err(vl_spec)); + } + let vg_opts = VgOpts { + allowed_base_urls: vl_opts.allowed_base_urls.clone(), + format_locale: vl_opts.format_locale.clone(), + time_format_locale: vl_opts.time_format_locale.clone(), + }; + let vega_spec = self.vegalite_to_vega(vl_spec, vl_opts.clone()).await?; + let _ = auto_download_fonts(&vega_spec, &self.inner.config.auto_install_fonts).await?; + Ok(Ok((vega_spec, vg_opts))) + } + + /// If `auto_install_fonts` is enabled, parse the Vega spec and download any missing fonts. + async fn maybe_auto_download_vega(&self, spec: &ValueOrString) -> Result<(), AnyError> { + if self.inner.config.auto_install_fonts != AutoInstallFonts::Off { + let spec_value: serde_json::Value = match spec { + ValueOrString::JsonString(s) => serde_json::from_str(s)?, + ValueOrString::Value(v) => v.clone(), + }; + let _ = auto_download_fonts(&spec_value, &self.inner.config.auto_install_fonts).await?; + } + Ok(()) + } + pub async fn vega_to_svg( &self, vg_spec: impl Into, @@ -2461,6 +2643,8 @@ impl VlConverter { vg_opts.allowed_base_urls = self.effective_allowed_base_urls(vg_opts.allowed_base_urls.take())?; let vg_spec = vg_spec.into(); + self.maybe_auto_download_vega(&vg_spec).await?; + self.request( move |responder| VlConvertCommand::VgToSvg { vg_spec, @@ -2518,15 +2702,35 @@ impl VlConverter { vl_opts.allowed_base_urls = self.effective_allowed_base_urls(vl_opts.allowed_base_urls.take())?; let vl_spec = vl_spec.into(); - self.request( - move |responder| VlConvertCommand::VlToSvg { - vl_spec, - vl_opts, - responder, - }, - "Vega-Lite to SVG conversion", - ) - .await + + match self + .maybe_compile_vl_with_auto_fonts(vl_spec, &vl_opts) + .await? + { + Ok((vega_spec, vg_opts)) => { + let vg_spec: ValueOrString = vega_spec.into(); + self.request( + move |responder| VlConvertCommand::VgToSvg { + vg_spec, + vg_opts, + responder, + }, + "Vega to SVG conversion", + ) + .await + } + Err(vl_spec) => { + self.request( + move |responder| VlConvertCommand::VlToSvg { + vl_spec, + vl_opts, + responder, + }, + "Vega-Lite to SVG conversion", + ) + .await + } + } } pub async fn vegalite_to_scenegraph( @@ -2581,6 +2785,8 @@ impl VlConverter { let effective_scale = scale * ppi / 72.0; let vg_spec = vg_spec.into(); + self.maybe_auto_download_vega(&vg_spec).await?; + self.request( move |responder| VlConvertCommand::VgToPng { vg_spec, @@ -2603,22 +2809,43 @@ impl VlConverter { ) -> Result, AnyError> { vl_opts.allowed_base_urls = self.effective_allowed_base_urls(vl_opts.allowed_base_urls.take())?; + let vl_spec = vl_spec.into(); let scale = scale.unwrap_or(1.0); let ppi = ppi.unwrap_or(72.0); let effective_scale = scale * ppi / 72.0; - let vl_spec = vl_spec.into(); - self.request( - move |responder| VlConvertCommand::VlToPng { - vl_spec, - vl_opts, - scale: effective_scale, - ppi, - responder, - }, - "Vega-Lite to PNG conversion", - ) - .await + match self + .maybe_compile_vl_with_auto_fonts(vl_spec, &vl_opts) + .await? + { + Ok((vega_spec, vg_opts)) => { + let vg_spec: ValueOrString = vega_spec.into(); + self.request( + move |responder| VlConvertCommand::VgToPng { + vg_spec, + vg_opts, + scale: effective_scale, + ppi, + responder, + }, + "Vega to PNG conversion", + ) + .await + } + Err(vl_spec) => { + self.request( + move |responder| VlConvertCommand::VlToPng { + vl_spec, + vl_opts, + scale: effective_scale, + ppi, + responder, + }, + "Vega-Lite to PNG conversion", + ) + .await + } + } } pub async fn vega_to_jpeg( From d437728684f5dca4fd6bb821bc95083650b014d9 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 25 Feb 2026 09:23:06 -0500 Subject: [PATCH 09/12] feat: add auto_install_fonts plumbing to PR2 Add AutoInstallFonts enum and configuration to converter, CLI, Python bindings, and type stubs. Moved from PR1 where it was a no-op. Co-Authored-By: Claude Opus 4.6 --- vl-convert-python/src/lib.rs | 32 +++++++++++++- vl-convert-python/vl_convert.pyi | 6 +++ vl-convert-rs/src/converter.rs | 18 ++++++++ vl-convert/src/main.rs | 73 ++++++++++++++++++++++++++------ 4 files changed, 113 insertions(+), 16 deletions(-) diff --git a/vl-convert-python/src/lib.rs b/vl-convert-python/src/lib.rs index ec2fd46b..f8ab281f 100644 --- a/vl-convert-python/src/lib.rs +++ b/vl-convert-python/src/lib.rs @@ -13,8 +13,8 @@ use std::str::FromStr; use std::sync::{Arc, RwLock}; use vl_convert_rs::configure_font_cache as configure_font_cache_rs; use vl_convert_rs::converter::{ - FormatLocale, Renderer, TimeFormatLocale, ValueOrString, VgOpts, VlConverterConfig, VlOpts, - ACCESS_DENIED_MARKER, + AutoInstallFonts, FormatLocale, Renderer, TimeFormatLocale, ValueOrString, VgOpts, + VlConverterConfig, VlOpts, ACCESS_DENIED_MARKER, }; use vl_convert_rs::module_loader::import_map::{ VlVersion, VEGA_EMBED_VERSION, VEGA_THEMES_VERSION, VEGA_VERSION, VL_VERSIONS, @@ -61,6 +61,11 @@ fn converter_config_json(config: &VlConverterConfig) -> serde_json::Value { .as_ref() .map(|root| root.to_string_lossy().to_string()), "allowed_base_urls": config.allowed_base_urls, + "auto_install_fonts": match config.auto_install_fonts { + AutoInstallFonts::Off => "off", + AutoInstallFonts::Strict => "strict", + AutoInstallFonts::BestEffort => "best-effort", + }, }) } @@ -73,6 +78,7 @@ struct ConverterConfigOverrides { // None => no change, Some(None) => clear, Some(Some(urls)) => set allowed_base_urls: Option>>, font_cache_size_mb: Option, + auto_install_fonts: Option, } fn parse_config_overrides( @@ -140,6 +146,25 @@ fn parse_config_overrides( })?); } } + "auto_install_fonts" => { + if !value.is_none() { + let s = value.extract::().map_err(|err| { + vl_convert_rs::anyhow::anyhow!( + "Invalid auto_install_fonts value for configure_converter: {err}" + ) + })?; + overrides.auto_install_fonts = Some(match s.as_str() { + "off" => AutoInstallFonts::Off, + "strict" => AutoInstallFonts::Strict, + "best-effort" => AutoInstallFonts::BestEffort, + _ => { + return Err(vl_convert_rs::anyhow::anyhow!( + "Invalid auto_install_fonts value: {s}. Expected 'off', 'strict', or 'best-effort'" + )); + } + }); + } + } other => { return Err(vl_convert_rs::anyhow::anyhow!( "Unknown configure_converter argument: {other}" @@ -168,6 +193,9 @@ fn apply_config_overrides(config: &mut VlConverterConfig, overrides: ConverterCo let bytes = mb.saturating_mul(1024 * 1024); configure_font_cache_rs(Some(bytes)); } + if let Some(auto_install) = overrides.auto_install_fonts { + config.auto_install_fonts = auto_install; + } } fn configure_converter_with_config_overrides( diff --git a/vl-convert-python/vl_convert.pyi b/vl-convert-python/vl_convert.pyi index 32949247..23e46ee7 100644 --- a/vl-convert-python/vl_convert.pyi +++ b/vl-convert-python/vl_convert.pyi @@ -133,6 +133,7 @@ if TYPE_CHECKING: allow_http_access: bool filesystem_root: str | None allowed_base_urls: list[str] | None + auto_install_fonts: str __all__ = [ "asyncio", @@ -293,6 +294,7 @@ def configure_converter( filesystem_root: str | None = None, allowed_base_urls: list[str] | None = None, font_cache_size_mb: int | None = None, + auto_install_fonts: str | None = None, ) -> None: """ Configure converter worker/access settings used by subsequent conversions. @@ -315,6 +317,9 @@ def configure_converter( this converter-level default when provided. font_cache_size_mb Maximum font cache size in megabytes. If ``None``, keep current value. + auto_install_fonts + Automatic font downloading mode. One of ``"off"``, ``"strict"``, or ``"best-effort"``. + If ``None``, keep current value. """ ... @@ -974,6 +979,7 @@ if TYPE_CHECKING: filesystem_root: str | None = None, allowed_base_urls: list[str] | None = None, font_cache_size_mb: int | None = None, + auto_install_fonts: str | None = None, ) -> None: """Async version of ``configure_converter``. See sync function for full documentation.""" ... diff --git a/vl-convert-rs/src/converter.rs b/vl-convert-rs/src/converter.rs index 29bfb39c..71443a3c 100644 --- a/vl-convert-rs/src/converter.rs +++ b/vl-convert-rs/src/converter.rs @@ -445,6 +445,21 @@ impl ValueOrString { } } +/// Controls automatic font downloading from the Fontsource catalog. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AutoInstallFonts { + /// Disabled (default). + #[default] + Off, + /// Only examines the first font in each CSS font-family string. + /// If it is not on the system and not in the Fontsource catalog, + /// the conversion fails with an error. + Strict, + /// Same first-font-only logic as `Strict`, but logs warnings for + /// unavailable fonts instead of failing. + BestEffort, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct VlConverterConfig { pub num_workers: usize, @@ -454,6 +469,8 @@ pub struct VlConverterConfig { /// values override this default when provided. Must be non-empty when set. /// When configured, HTTP redirects are denied instead of followed. pub allowed_base_urls: Option>, + /// Controls automatic font downloading from the Fontsource catalog. + pub auto_install_fonts: AutoInstallFonts, } impl Default for VlConverterConfig { @@ -463,6 +480,7 @@ impl Default for VlConverterConfig { allow_http_access: true, filesystem_root: None, allowed_base_urls: None, + auto_install_fonts: AutoInstallFonts::Off, } } } diff --git a/vl-convert/src/main.rs b/vl-convert/src/main.rs index 174fb17d..a3b9f825 100644 --- a/vl-convert/src/main.rs +++ b/vl-convert/src/main.rs @@ -8,13 +8,28 @@ use std::io::{self, IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; use std::str::FromStr; use vl_convert_rs::converter::{ - vega_to_url, vegalite_to_url, FormatLocale, Renderer, TimeFormatLocale, VgOpts, VlConverter, - VlConverterConfig, VlOpts, + vega_to_url, vegalite_to_url, AutoInstallFonts, FormatLocale, Renderer, TimeFormatLocale, + VgOpts, VlConverter, VlConverterConfig, VlOpts, }; use vl_convert_rs::module_loader::import_map::VlVersion; use vl_convert_rs::text::register_font_directory; use vl_convert_rs::{anyhow, anyhow::bail, install_font}; +#[derive(Debug, Clone, clap::ValueEnum)] +enum AutoInstallFontsArg { + Strict, + BestEffort, +} + +impl AutoInstallFontsArg { + fn to_auto_install_fonts(&self) -> AutoInstallFonts { + match self { + AutoInstallFontsArg::Strict => AutoInstallFonts::Strict, + AutoInstallFontsArg::BestEffort => AutoInstallFonts::BestEffort, + } + } +} + const DEFAULT_VL_VERSION: &str = "6.4"; const DEFAULT_CONFIG_PATH: &str = "~/.config/vl-convert/config.json"; @@ -39,6 +54,11 @@ struct Cli { #[arg(long, global = true)] install_font: Vec, + /// Automatically download fonts referenced in specs from the Fontsource catalog. + /// "strict" errors if a font is unavailable; "best-effort" warns and continues. + #[arg(long, global = true, value_enum)] + auto_install_fonts: Option, + #[command(subcommand)] command: Commands, } @@ -577,11 +597,16 @@ async fn main() -> Result<(), anyhow::Error> { no_http_access, filesystem_root, install_font: install_font_families, + auto_install_fonts: auto_install_fonts_arg, command, } = Cli::parse(); if no_http_access { allow_http_access = false; } + let auto_install_fonts = auto_install_fonts_arg + .as_ref() + .map(|a| a.to_auto_install_fonts()) + .unwrap_or(AutoInstallFonts::Off); use crate::Commands::*; match command { Vl2vg { @@ -604,6 +629,7 @@ async fn main() -> Result<(), anyhow::Error> { show_warnings, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -633,6 +659,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -666,6 +693,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -699,6 +727,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -728,6 +757,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -800,6 +830,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -825,6 +856,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -850,6 +882,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -871,6 +904,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, ) .await? } @@ -930,7 +964,7 @@ async fn main() -> Result<(), anyhow::Error> { install_fonts(&install_font_families).await?; let svg = read_input_string(input.as_deref())?; let converter = - build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url)?; + build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url, auto_install_fonts)?; let png_data = converter.svg_to_png(&svg, scale, Some(ppi))?; write_output_binary(output.as_deref(), &png_data, "PNG")?; } @@ -946,7 +980,7 @@ async fn main() -> Result<(), anyhow::Error> { install_fonts(&install_font_families).await?; let svg = read_input_string(input.as_deref())?; let converter = - build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url)?; + build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url, auto_install_fonts)?; let jpeg_data = converter.svg_to_jpeg(&svg, scale, Some(quality))?; write_output_binary(output.as_deref(), &jpeg_data, "JPEG")?; } @@ -960,7 +994,7 @@ async fn main() -> Result<(), anyhow::Error> { install_fonts(&install_font_families).await?; let svg = read_input_string(input.as_deref())?; let converter = - build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url)?; + build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url, auto_install_fonts)?; let pdf_data = converter.svg_to_pdf(&svg)?; write_output_binary(output.as_deref(), &pdf_data, "PDF")?; } @@ -994,11 +1028,13 @@ fn build_converter( allow_http_access: bool, filesystem_root: Option, allowed_base_urls: Option>, + auto_install_fonts: AutoInstallFonts, ) -> Result { let config = VlConverterConfig { allow_http_access, filesystem_root: filesystem_root.map(PathBuf::from), allowed_base_urls, + auto_install_fonts, ..Default::default() }; @@ -1290,6 +1326,7 @@ async fn vl_2_vg( show_warnings: bool, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1304,7 +1341,7 @@ async fn vl_2_vg( let config = read_config_json(config)?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let vega_json = match converter @@ -1353,6 +1390,7 @@ async fn vg_2_svg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1364,7 +1402,7 @@ async fn vg_2_svg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let svg = match converter @@ -1401,6 +1439,7 @@ async fn vg_2_png( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1412,7 +1451,7 @@ async fn vg_2_png( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let png_data = match converter @@ -1451,6 +1490,7 @@ async fn vg_2_jpeg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1462,7 +1502,7 @@ async fn vg_2_jpeg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let jpeg_data = match converter @@ -1498,6 +1538,7 @@ async fn vg_2_pdf( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1509,7 +1550,7 @@ async fn vg_2_pdf( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let pdf_data = match converter @@ -1548,6 +1589,7 @@ async fn vl_2_svg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1565,7 +1607,7 @@ async fn vl_2_svg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let svg = match converter @@ -1610,6 +1652,7 @@ async fn vl_2_png( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1627,7 +1670,7 @@ async fn vl_2_png( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let png_data = match converter @@ -1674,6 +1717,7 @@ async fn vl_2_jpeg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1691,7 +1735,7 @@ async fn vl_2_jpeg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let jpeg_data = match converter @@ -1736,6 +1780,7 @@ async fn vl_2_pdf( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: AutoInstallFonts, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1753,7 +1798,7 @@ async fn vl_2_pdf( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; // Perform conversion let pdf_data = match converter From 12d80feabb23cdc2e9372028649dbd70aa2b76e8 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 25 Feb 2026 11:55:19 -0500 Subject: [PATCH 10/12] fix: address 13 code review recommendations for auto-fonts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make extract::is_available pub, remove duplicated is_available_in_fontdb - Strict mode now returns hard errors on download failures and API errors - Network errors distinguished from "not in catalog" in error messages - Replace unreachable!() for Off with return Ok(vec![]) - Change maybe_compile_vl_with_auto_fonts to return Option instead of nested Result - Add auto-font hooks to all 4 scenegraph methods (vega/vegalite × json/msgpack) - Add missing config keys: config.font, config.mark.font, header variants, axisDiscrete/Point/Quantitative/Temporal - Fix axis/legend/title encode traversal to iterate all states (not just update) - Case-insensitive generic family matching (Sans-Serif → Generic) - Python type stubs use Literal["off", "strict", "best-effort"] - Add comments: allow_http_access independence, FontForHtml scaffolding, doc separator - Add 9 new tests for config keys, encode states, and case-insensitive generics Co-Authored-By: Claude Opus 4.6 --- vl-convert-python/vl_convert.pyi | 6 +- vl-convert-rs/src/converter.rs | 257 +++++++++++++++++++------------ vl-convert-rs/src/extract.rs | 245 ++++++++++++++++++++++++----- 3 files changed, 368 insertions(+), 140 deletions(-) diff --git a/vl-convert-python/vl_convert.pyi b/vl-convert-python/vl_convert.pyi index 23e46ee7..aec97f4b 100644 --- a/vl-convert-python/vl_convert.pyi +++ b/vl-convert-python/vl_convert.pyi @@ -133,7 +133,7 @@ if TYPE_CHECKING: allow_http_access: bool filesystem_root: str | None allowed_base_urls: list[str] | None - auto_install_fonts: str + auto_install_fonts: Literal["off", "strict", "best-effort"] __all__ = [ "asyncio", @@ -294,7 +294,7 @@ def configure_converter( filesystem_root: str | None = None, allowed_base_urls: list[str] | None = None, font_cache_size_mb: int | None = None, - auto_install_fonts: str | None = None, + auto_install_fonts: Literal["off", "strict", "best-effort"] | None = None, ) -> None: """ Configure converter worker/access settings used by subsequent conversions. @@ -979,7 +979,7 @@ if TYPE_CHECKING: filesystem_root: str | None = None, allowed_base_urls: list[str] | None = None, font_cache_size_mb: int | None = None, - auto_install_fonts: str | None = None, + auto_install_fonts: Literal["off", "strict", "best-effort"] | None = None, ) -> None: """Async version of ``configure_converter``. See sync function for full documentation.""" ... diff --git a/vl-convert-rs/src/converter.rs b/vl-convert-rs/src/converter.rs index 71443a3c..94ade634 100644 --- a/vl-convert-rs/src/converter.rs +++ b/vl-convert-rs/src/converter.rs @@ -42,7 +42,9 @@ use image::codecs::jpeg::JpegEncoder; use image::ImageReader; use resvg::render; -use crate::extract::{extract_fonts_from_vega, resolve_first_fonts, FirstFontStatus, FontForHtml}; +use crate::extract::{ + extract_fonts_from_vega, is_available, resolve_first_fonts, FirstFontStatus, FontForHtml, +}; use crate::text::{ fetch_and_register_font, FONTSOURCE_CACHE, FONT_CONFIG, FONT_CONFIG_VERSION, USVG_OPTIONS, }; @@ -2319,6 +2321,7 @@ impl VlConvertCommand { /// /// println!("{}", vega_spec) /// ``` +/// /// Automatically download missing fonts referenced in a compiled Vega spec. /// /// This function extracts font-family strings from the spec, classifies the @@ -2349,10 +2352,11 @@ async fn auto_download_fonts( // Check which first-fonts are known to Fontsource (only those not already available) let mut downloadable_set: HashSet = HashSet::new(); + let mut api_errors: Vec<(String, String)> = Vec::new(); for font_string in &font_string_vec { let entries = crate::extract::parse_css_font_family(font_string); if let Some(crate::extract::FontFamilyEntry::Named(ref name)) = entries.first() { - if !is_available_in_fontdb(name, &available) { + if !is_available(name, &available) { if let Some(font_id) = family_to_id(name) { match FONTSOURCE_CACHE.is_known_font(&font_id).await { Ok(true) => { @@ -2360,10 +2364,7 @@ async fn auto_download_fonts( } Ok(false) => {} Err(e) => { - log::warn!( - "auto_install_fonts: failed to check if '{}' is known: {e}", - name - ); + api_errors.push((name.clone(), e.to_string())); } } } @@ -2371,6 +2372,30 @@ async fn auto_download_fonts( } } + // Report API errors (network issues) distinctly from "not in catalog" + if !api_errors.is_empty() { + match mode { + AutoInstallFonts::Strict => { + let details: Vec = api_errors + .iter() + .map(|(name, err)| format!("'{name}': {err}")) + .collect(); + return Err(anyhow!( + "auto_install_fonts: could not reach the Fontsource API to check \ + the following fonts: {}", + details.join(", ") + )); + } + _ => { + for (name, err) in &api_errors { + log::warn!( + "auto_install_fonts: could not reach Fontsource API for '{name}': {err}" + ); + } + } + } + } + // Classify each font string by its first entry let statuses = resolve_first_fonts(&font_string_vec, &available, |family| { downloadable_set.contains(family) @@ -2412,7 +2437,7 @@ async fn auto_download_fonts( ); } } - AutoInstallFonts::Off => unreachable!(), + AutoInstallFonts::Off => return Ok(vec![]), } } @@ -2432,9 +2457,16 @@ async fn auto_download_fonts( font_type: outcome.font_type.unwrap_or_else(|| "google".to_string()), }); } - Err(e) => { - log::warn!("auto_install_fonts: failed to install '{name}': {e}"); - } + Err(e) => match mode { + AutoInstallFonts::Strict => { + return Err(anyhow!( + "auto_install_fonts: failed to download '{name}': {e}" + )); + } + _ => { + log::warn!("auto_install_fonts: failed to install '{name}': {e}"); + } + }, } } } @@ -2453,15 +2485,6 @@ async fn auto_download_fonts( Ok(html_fonts) } -/// Case-insensitive check if a font name is available in fontdb. -fn is_available_in_fontdb(name: &str, available: &HashSet) -> bool { - if available.contains(name) { - return true; - } - let lower = name.to_lowercase(); - available.iter().any(|a| a.to_lowercase() == lower) -} - struct VlConverterInner { vegaembed_bundles: Mutex>, pool: Mutex>, @@ -2620,34 +2643,40 @@ impl VlConverter { /// If `auto_install_fonts` is enabled, compile VL→Vega and download any missing fonts. /// - /// Returns `Ok((vega_spec, vg_opts))` with the compiled Vega spec and options - /// for the caller to render directly, or `Err(vl_spec)` returning the original - /// spec if auto-install is disabled. + /// Returns `Some((vega_spec, vg_opts))` with the compiled Vega spec and options + /// for the caller to render directly, or `None` if auto-install is disabled. async fn maybe_compile_vl_with_auto_fonts( &self, - vl_spec: ValueOrString, + vl_spec: &ValueOrString, vl_opts: &VlOpts, - ) -> Result, AnyError> { + ) -> Result, AnyError> { if self.inner.config.auto_install_fonts == AutoInstallFonts::Off { - return Ok(Err(vl_spec)); + return Ok(None); } let vg_opts = VgOpts { allowed_base_urls: vl_opts.allowed_base_urls.clone(), format_locale: vl_opts.format_locale.clone(), time_format_locale: vl_opts.time_format_locale.clone(), }; - let vega_spec = self.vegalite_to_vega(vl_spec, vl_opts.clone()).await?; + let vega_spec = self.vegalite_to_vega(vl_spec.clone(), vl_opts.clone()).await?; + // Return value (Vec) will be consumed in PR #247 (HTML font embedding) let _ = auto_download_fonts(&vega_spec, &self.inner.config.auto_install_fonts).await?; - Ok(Ok((vega_spec, vg_opts))) + Ok(Some((vega_spec, vg_opts))) } /// If `auto_install_fonts` is enabled, parse the Vega spec and download any missing fonts. + /// + /// Note: font downloads are governed solely by `auto_install_fonts`, independently + /// of `allow_http_access`. The two settings control different concerns: + /// `allow_http_access` governs data-fetching URLs in specs, while `auto_install_fonts` + /// governs on-demand font installation from Fontsource. async fn maybe_auto_download_vega(&self, spec: &ValueOrString) -> Result<(), AnyError> { if self.inner.config.auto_install_fonts != AutoInstallFonts::Off { let spec_value: serde_json::Value = match spec { ValueOrString::JsonString(s) => serde_json::from_str(s)?, ValueOrString::Value(v) => v.clone(), }; + // Return value (Vec) will be consumed in PR #247 (HTML font embedding) let _ = auto_download_fonts(&spec_value, &self.inner.config.auto_install_fonts).await?; } Ok(()) @@ -2682,6 +2711,7 @@ impl VlConverter { vg_opts.allowed_base_urls = self.effective_allowed_base_urls(vg_opts.allowed_base_urls.take())?; let vg_spec = vg_spec.into(); + self.maybe_auto_download_vega(&vg_spec).await?; self.request( move |responder| VlConvertCommand::VgToSg { vg_spec, @@ -2701,6 +2731,7 @@ impl VlConverter { vg_opts.allowed_base_urls = self.effective_allowed_base_urls(vg_opts.allowed_base_urls.take())?; let vg_spec = vg_spec.into(); + self.maybe_auto_download_vega(&vg_spec).await?; self.request( move |responder| VlConvertCommand::VgToSgMsgpack { vg_spec, @@ -2721,33 +2752,30 @@ impl VlConverter { self.effective_allowed_base_urls(vl_opts.allowed_base_urls.take())?; let vl_spec = vl_spec.into(); - match self - .maybe_compile_vl_with_auto_fonts(vl_spec, &vl_opts) + if let Some((vega_spec, vg_opts)) = self + .maybe_compile_vl_with_auto_fonts(&vl_spec, &vl_opts) .await? { - Ok((vega_spec, vg_opts)) => { - let vg_spec: ValueOrString = vega_spec.into(); - self.request( - move |responder| VlConvertCommand::VgToSvg { - vg_spec, - vg_opts, - responder, - }, - "Vega to SVG conversion", - ) - .await - } - Err(vl_spec) => { - self.request( - move |responder| VlConvertCommand::VlToSvg { - vl_spec, - vl_opts, - responder, - }, - "Vega-Lite to SVG conversion", - ) - .await - } + let vg_spec: ValueOrString = vega_spec.into(); + self.request( + move |responder| VlConvertCommand::VgToSvg { + vg_spec, + vg_opts, + responder, + }, + "Vega to SVG conversion", + ) + .await + } else { + self.request( + move |responder| VlConvertCommand::VlToSvg { + vl_spec, + vl_opts, + responder, + }, + "Vega-Lite to SVG conversion", + ) + .await } } @@ -2759,15 +2787,32 @@ impl VlConverter { vl_opts.allowed_base_urls = self.effective_allowed_base_urls(vl_opts.allowed_base_urls.take())?; let vl_spec = vl_spec.into(); - self.request( - move |responder| VlConvertCommand::VlToSg { - vl_spec, - vl_opts, - responder, - }, - "Vega-Lite to Scenegraph conversion", - ) - .await + + if let Some((vega_spec, vg_opts)) = self + .maybe_compile_vl_with_auto_fonts(&vl_spec, &vl_opts) + .await? + { + let vg_spec: ValueOrString = vega_spec.into(); + self.request( + move |responder| VlConvertCommand::VgToSg { + vg_spec, + vg_opts, + responder, + }, + "Vega to Scenegraph conversion", + ) + .await + } else { + self.request( + move |responder| VlConvertCommand::VlToSg { + vl_spec, + vl_opts, + responder, + }, + "Vega-Lite to Scenegraph conversion", + ) + .await + } } pub async fn vegalite_to_scenegraph_msgpack( @@ -2778,15 +2823,32 @@ impl VlConverter { vl_opts.allowed_base_urls = self.effective_allowed_base_urls(vl_opts.allowed_base_urls.take())?; let vl_spec = vl_spec.into(); - self.request( - move |responder| VlConvertCommand::VlToSgMsgpack { - vl_spec, - vl_opts, - responder, - }, - "Vega-Lite to Scenegraph conversion", - ) - .await + + if let Some((vega_spec, vg_opts)) = self + .maybe_compile_vl_with_auto_fonts(&vl_spec, &vl_opts) + .await? + { + let vg_spec: ValueOrString = vega_spec.into(); + self.request( + move |responder| VlConvertCommand::VgToSgMsgpack { + vg_spec, + vg_opts, + responder, + }, + "Vega to Scenegraph conversion", + ) + .await + } else { + self.request( + move |responder| VlConvertCommand::VlToSgMsgpack { + vl_spec, + vl_opts, + responder, + }, + "Vega-Lite to Scenegraph conversion", + ) + .await + } } pub async fn vega_to_png( @@ -2832,37 +2894,34 @@ impl VlConverter { let ppi = ppi.unwrap_or(72.0); let effective_scale = scale * ppi / 72.0; - match self - .maybe_compile_vl_with_auto_fonts(vl_spec, &vl_opts) + if let Some((vega_spec, vg_opts)) = self + .maybe_compile_vl_with_auto_fonts(&vl_spec, &vl_opts) .await? { - Ok((vega_spec, vg_opts)) => { - let vg_spec: ValueOrString = vega_spec.into(); - self.request( - move |responder| VlConvertCommand::VgToPng { - vg_spec, - vg_opts, - scale: effective_scale, - ppi, - responder, - }, - "Vega to PNG conversion", - ) - .await - } - Err(vl_spec) => { - self.request( - move |responder| VlConvertCommand::VlToPng { - vl_spec, - vl_opts, - scale: effective_scale, - ppi, - responder, - }, - "Vega-Lite to PNG conversion", - ) - .await - } + let vg_spec: ValueOrString = vega_spec.into(); + self.request( + move |responder| VlConvertCommand::VgToPng { + vg_spec, + vg_opts, + scale: effective_scale, + ppi, + responder, + }, + "Vega to PNG conversion", + ) + .await + } else { + self.request( + move |responder| VlConvertCommand::VlToPng { + vl_spec, + vl_opts, + scale: effective_scale, + ppi, + responder, + }, + "Vega-Lite to PNG conversion", + ) + .await } } diff --git a/vl-convert-rs/src/extract.rs b/vl-convert-rs/src/extract.rs index 057ecaed..3184ddbe 100644 --- a/vl-convert-rs/src/extract.rs +++ b/vl-convert-rs/src/extract.rs @@ -77,7 +77,8 @@ pub fn parse_css_font_family(s: &str) -> Vec { return None; } - if GENERIC_FAMILIES.contains(&unquoted) { + let lower = unquoted.to_lowercase(); + if GENERIC_FAMILIES.iter().any(|g| g.to_lowercase() == lower) { Some(FontFamilyEntry::Generic(unquoted.to_string())) } else { Some(FontFamilyEntry::Named(unquoted.to_string())) @@ -162,6 +163,10 @@ const AXIS_CONFIG_KEYS: &[&str] = &[ "axisLeft", "axisRight", "axisBand", + "axisDiscrete", + "axisPoint", + "axisQuantitative", + "axisTemporal", ]; /// Vega mark types whose config can carry a `font` property. @@ -196,6 +201,22 @@ fn extract_config_fonts(config: &Value, fonts: &mut HashSet) { } } + // Top-level default font + collect_if_string(config, "font", fonts); + + // Mark default: config.mark.font + if let Some(mark) = config.get("mark") { + collect_if_string(mark, "font", fonts); + } + + // Header variants + for &key in &["header", "headerColumn", "headerRow", "headerFacet"] { + if let Some(header) = config.get(key) { + collect_if_string(header, "labelFont", fonts); + collect_if_string(header, "titleFont", fonts); + } + } + // Named styles: config.style is an object { styleName: { font, ... } } if let Some(style) = config.get("style").and_then(Value::as_object) { for (_style_name, style_obj) in style { @@ -261,25 +282,23 @@ fn extract_axes_fonts(axes: &Value, fonts: &mut HashSet) { collect_if_string(axis, "labelFont", fonts); collect_if_string(axis, "titleFont", fonts); - // Encode paths: encode.labels.update.font.value, encode.title.update.font.value + // Encode paths: encode.{labels,title}.{state}.font.value if let Some(encode) = axis.get("encode") { - if let Some(font_val) = encode - .get("labels") - .and_then(|l| l.get("update")) - .and_then(|u| u.get("font")) - .and_then(|f| f.get("value")) - .and_then(Value::as_str) - { - fonts.insert(font_val.to_string()); - } - if let Some(font_val) = encode - .get("title") - .and_then(|t| t.get("update")) - .and_then(|u| u.get("font")) - .and_then(|f| f.get("value")) - .and_then(Value::as_str) - { - fonts.insert(font_val.to_string()); + for &part in &["labels", "title"] { + if let Some(part_obj) = encode.get(part) { + for &state in &[ + "enter", "update", "hover", "exit", "leave", "select", "release", + ] { + if let Some(font_val) = part_obj + .get(state) + .and_then(|s| s.get("font")) + .and_then(|f| f.get("value")) + .and_then(Value::as_str) + { + fonts.insert(font_val.to_string()); + } + } + } } } } @@ -298,17 +317,22 @@ fn extract_legends_fonts(legends: &Value, fonts: &mut HashSet) { collect_if_string(legend, "labelFont", fonts); collect_if_string(legend, "titleFont", fonts); - // Encode paths: encode.labels.update.font.value, encode.title.update.font.value + // Encode paths: encode.{labels,title}.{state}.font.value if let Some(encode) = legend.get("encode") { for &part in &["labels", "title"] { - if let Some(font_val) = encode - .get(part) - .and_then(|l| l.get("update")) - .and_then(|u| u.get("font")) - .and_then(|f| f.get("value")) - .and_then(Value::as_str) - { - fonts.insert(font_val.to_string()); + if let Some(part_obj) = encode.get(part) { + for &state in &[ + "enter", "update", "hover", "exit", "leave", "select", "release", + ] { + if let Some(font_val) = part_obj + .get(state) + .and_then(|s| s.get("font")) + .and_then(|f| f.get("value")) + .and_then(Value::as_str) + { + fonts.insert(font_val.to_string()); + } + } } } } @@ -325,17 +349,22 @@ fn extract_title_fonts(title: &Value, fonts: &mut HashSet) { collect_if_string(title, "font", fonts); collect_if_string(title, "subtitleFont", fonts); - // Encode paths: encode.title.update.font.value, encode.subtitle.update.font.value + // Encode paths: encode.{title,subtitle}.{state}.font.value if let Some(encode) = title.get("encode") { for &part in &["title", "subtitle"] { - if let Some(font_val) = encode - .get(part) - .and_then(|l| l.get("update")) - .and_then(|u| u.get("font")) - .and_then(|f| f.get("value")) - .and_then(Value::as_str) - { - fonts.insert(font_val.to_string()); + if let Some(part_obj) = encode.get(part) { + for &state in &[ + "enter", "update", "hover", "exit", "leave", "select", "release", + ] { + if let Some(font_val) = part_obj + .get(state) + .and_then(|s| s.get("font")) + .and_then(|f| f.get("value")) + .and_then(Value::as_str) + { + fonts.insert(font_val.to_string()); + } + } } } } @@ -437,7 +466,7 @@ pub fn resolve_first_fonts( /// The `available` set is expected to contain font names in their original /// casing (as reported by fontdb). We check both the exact name and a /// lowercased version. -fn is_available(name: &str, available: &HashSet) -> bool { +pub fn is_available(name: &str, available: &HashSet) -> bool { if available.contains(name) { return true; } @@ -1172,6 +1201,146 @@ mod tests { assert!(result.is_empty()); } + // ----------------------------------------------------------------------- + // Case-insensitive generic matching tests + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_generic_case_insensitive() { + // "Sans-Serif" (title-case) should be classified as Generic + let entries = parse_css_font_family("Sans-Serif"); + assert_eq!( + entries, + vec![FontFamilyEntry::Generic("Sans-Serif".into())] + ); + } + + #[test] + fn test_parse_generic_uppercase() { + let entries = parse_css_font_family("MONOSPACE"); + assert_eq!( + entries, + vec![FontFamilyEntry::Generic("MONOSPACE".into())] + ); + } + + // ----------------------------------------------------------------------- + // Missing config keys tests + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_config_font_top_level() { + let spec = json!({ + "config": { + "font": "Global Font" + } + }); + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("Global Font")); + } + + #[test] + fn test_extract_config_mark_font() { + let spec = json!({ + "config": { + "mark": { "font": "Mark Default Font" } + } + }); + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("Mark Default Font")); + } + + #[test] + fn test_extract_config_header_fonts() { + let spec = json!({ + "config": { + "header": { "labelFont": "Header Label", "titleFont": "Header Title" }, + "headerColumn": { "labelFont": "ColHeader Label" }, + "headerRow": { "titleFont": "RowHeader Title" }, + "headerFacet": { "labelFont": "FacetHeader Label" } + } + }); + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("Header Label")); + assert!(fonts.contains("Header Title")); + assert!(fonts.contains("ColHeader Label")); + assert!(fonts.contains("RowHeader Title")); + assert!(fonts.contains("FacetHeader Label")); + } + + #[test] + fn test_extract_config_axis_discrete_point_quantitative_temporal() { + let spec = json!({ + "config": { + "axisDiscrete": { "labelFont": "Discrete Font" }, + "axisPoint": { "titleFont": "Point Font" }, + "axisQuantitative": { "labelFont": "Quant Font" }, + "axisTemporal": { "titleFont": "Temporal Font" } + } + }); + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("Discrete Font")); + assert!(fonts.contains("Point Font")); + assert!(fonts.contains("Quant Font")); + assert!(fonts.contains("Temporal Font")); + } + + // ----------------------------------------------------------------------- + // Encode traversal: non-update states + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_axis_encode_enter_state() { + let spec = json!({ + "axes": [{ + "encode": { + "labels": { + "enter": { + "font": { "value": "Enter Font" } + } + } + } + }] + }); + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("Enter Font")); + } + + #[test] + fn test_extract_legend_encode_hover_state() { + let spec = json!({ + "legends": [{ + "encode": { + "title": { + "hover": { + "font": { "value": "Hover Font" } + } + } + } + }] + }); + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("Hover Font")); + } + + #[test] + fn test_extract_title_encode_enter_state() { + let spec = json!({ + "title": { + "text": "Chart", + "encode": { + "subtitle": { + "enter": { + "font": { "value": "Subtitle Enter Font" } + } + } + } + } + }); + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("Subtitle Enter Font")); + } + #[test] fn test_resolve_wordcloud_font() { let spec = json!({ From dc280efc4c8f79b69a3d1a7021e027ac9eadf336 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 25 Feb 2026 12:51:26 -0500 Subject: [PATCH 11/12] Refactor font handling to auto-install bool and missing-fonts policy --- vl-convert-python/src/lib.rs | 39 ++-- vl-convert-python/tests/test_access_policy.py | 4 + vl-convert-python/tests/test_asyncio.py | 10 +- vl-convert-python/tests/test_workers.py | 12 +- vl-convert-python/vl_convert.pyi | 13 +- vl-convert-rs/src/converter.rs | 192 ++++++++++-------- vl-convert/src/main.rs | 179 ++++++++++++---- 7 files changed, 305 insertions(+), 144 deletions(-) diff --git a/vl-convert-python/src/lib.rs b/vl-convert-python/src/lib.rs index f8ab281f..5350028a 100644 --- a/vl-convert-python/src/lib.rs +++ b/vl-convert-python/src/lib.rs @@ -13,7 +13,7 @@ use std::str::FromStr; use std::sync::{Arc, RwLock}; use vl_convert_rs::configure_font_cache as configure_font_cache_rs; use vl_convert_rs::converter::{ - AutoInstallFonts, FormatLocale, Renderer, TimeFormatLocale, ValueOrString, VgOpts, + FormatLocale, MissingFontsPolicy, Renderer, TimeFormatLocale, ValueOrString, VgOpts, VlConverterConfig, VlOpts, ACCESS_DENIED_MARKER, }; use vl_convert_rs::module_loader::import_map::{ @@ -61,10 +61,11 @@ fn converter_config_json(config: &VlConverterConfig) -> serde_json::Value { .as_ref() .map(|root| root.to_string_lossy().to_string()), "allowed_base_urls": config.allowed_base_urls, - "auto_install_fonts": match config.auto_install_fonts { - AutoInstallFonts::Off => "off", - AutoInstallFonts::Strict => "strict", - AutoInstallFonts::BestEffort => "best-effort", + "auto_install_fonts": config.auto_install_fonts, + "missing_fonts": match config.missing_fonts { + MissingFontsPolicy::Fallback => "fallback", + MissingFontsPolicy::Warn => "warn", + MissingFontsPolicy::Error => "error", }, }) } @@ -78,7 +79,8 @@ struct ConverterConfigOverrides { // None => no change, Some(None) => clear, Some(Some(urls)) => set allowed_base_urls: Option>>, font_cache_size_mb: Option, - auto_install_fonts: Option, + auto_install_fonts: Option, + missing_fonts: Option, } fn parse_config_overrides( @@ -147,19 +149,29 @@ fn parse_config_overrides( } } "auto_install_fonts" => { + if !value.is_none() { + overrides.auto_install_fonts = + Some(value.extract::().map_err(|err| { + vl_convert_rs::anyhow::anyhow!( + "Invalid auto_install_fonts value for configure_converter: {err}" + ) + })?); + } + } + "missing_fonts" => { if !value.is_none() { let s = value.extract::().map_err(|err| { vl_convert_rs::anyhow::anyhow!( - "Invalid auto_install_fonts value for configure_converter: {err}" + "Invalid missing_fonts value for configure_converter: {err}" ) })?; - overrides.auto_install_fonts = Some(match s.as_str() { - "off" => AutoInstallFonts::Off, - "strict" => AutoInstallFonts::Strict, - "best-effort" => AutoInstallFonts::BestEffort, + overrides.missing_fonts = Some(match s.as_str() { + "fallback" => MissingFontsPolicy::Fallback, + "warn" => MissingFontsPolicy::Warn, + "error" => MissingFontsPolicy::Error, _ => { return Err(vl_convert_rs::anyhow::anyhow!( - "Invalid auto_install_fonts value: {s}. Expected 'off', 'strict', or 'best-effort'" + "Invalid missing_fonts value: {s}. Expected 'fallback', 'warn', or 'error'" )); } }); @@ -196,6 +208,9 @@ fn apply_config_overrides(config: &mut VlConverterConfig, overrides: ConverterCo if let Some(auto_install) = overrides.auto_install_fonts { config.auto_install_fonts = auto_install; } + if let Some(missing_fonts) = overrides.missing_fonts { + config.missing_fonts = missing_fonts; + } } fn configure_converter_with_config_overrides( diff --git a/vl-convert-python/tests/test_access_policy.py b/vl-convert-python/tests/test_access_policy.py index 36ae62d8..5651c33b 100644 --- a/vl-convert-python/tests/test_access_policy.py +++ b/vl-convert-python/tests/test_access_policy.py @@ -191,6 +191,8 @@ def reset_converter_config(): allow_http_access=True, filesystem_root=None, allowed_base_urls=None, + auto_install_fonts=False, + missing_fonts="fallback", ) try: yield @@ -200,6 +202,8 @@ def reset_converter_config(): allow_http_access=True, filesystem_root=None, allowed_base_urls=None, + auto_install_fonts=False, + missing_fonts="fallback", ) diff --git a/vl-convert-python/tests/test_asyncio.py b/vl-convert-python/tests/test_asyncio.py index 7ca8c5e9..ba97b05d 100644 --- a/vl-convert-python/tests/test_asyncio.py +++ b/vl-convert-python/tests/test_asyncio.py @@ -30,7 +30,11 @@ def public_callable_names(module): @pytest.fixture(autouse=True) def reset_worker_count(): original = vlc.get_converter_config() - vlc.configure_converter(num_workers=1) + vlc.configure_converter( + num_workers=1, + auto_install_fonts=False, + missing_fonts="fallback", + ) try: yield finally: @@ -109,11 +113,15 @@ async def scenario(): num_workers=2, allow_http_access=False, filesystem_root=str(root), + auto_install_fonts=True, + missing_fonts="error", ) config = await vlca.get_converter_config() assert config["num_workers"] == 2 assert config["allow_http_access"] is False assert config["filesystem_root"] == str(root.resolve()) + assert config["auto_install_fonts"] is True + assert config["missing_fonts"] == "error" run(scenario()) diff --git a/vl-convert-python/tests/test_workers.py b/vl-convert-python/tests/test_workers.py index 742e776b..0a9738f9 100644 --- a/vl-convert-python/tests/test_workers.py +++ b/vl-convert-python/tests/test_workers.py @@ -17,7 +17,11 @@ @pytest.fixture(autouse=True) def reset_worker_count(): original = vlc.get_converter_config() - vlc.configure_converter(num_workers=1) + vlc.configure_converter( + num_workers=1, + auto_install_fonts=False, + missing_fonts="fallback", + ) try: yield finally: @@ -92,6 +96,8 @@ def test_configure_converter_round_trip(tmp_path): allow_http_access=False, filesystem_root=str(root), allowed_base_urls=None, + auto_install_fonts=True, + missing_fonts="error", ) config = vlc.get_converter_config() @@ -99,6 +105,8 @@ def test_configure_converter_round_trip(tmp_path): assert config["allow_http_access"] is False assert config["filesystem_root"] == str(root.resolve()) assert config["allowed_base_urls"] is None + assert config["auto_install_fonts"] is True + assert config["missing_fonts"] == "error" def test_configure_converter_num_workers_preserves_access_policy(tmp_path): @@ -125,6 +133,8 @@ def test_configure_converter_noop_when_called_without_args(): allow_http_access=True, filesystem_root=None, allowed_base_urls=["https://example.com/"], + auto_install_fonts=True, + missing_fonts="fallback", ) before = vlc.get_converter_config() vlc.configure_converter() diff --git a/vl-convert-python/vl_convert.pyi b/vl-convert-python/vl_convert.pyi index aec97f4b..5310f840 100644 --- a/vl-convert-python/vl_convert.pyi +++ b/vl-convert-python/vl_convert.pyi @@ -133,7 +133,8 @@ if TYPE_CHECKING: allow_http_access: bool filesystem_root: str | None allowed_base_urls: list[str] | None - auto_install_fonts: Literal["off", "strict", "best-effort"] + auto_install_fonts: bool + missing_fonts: Literal["fallback", "warn", "error"] __all__ = [ "asyncio", @@ -294,7 +295,8 @@ def configure_converter( filesystem_root: str | None = None, allowed_base_urls: list[str] | None = None, font_cache_size_mb: int | None = None, - auto_install_fonts: Literal["off", "strict", "best-effort"] | None = None, + auto_install_fonts: bool | None = None, + missing_fonts: Literal["fallback", "warn", "error"] | None = None, ) -> None: """ Configure converter worker/access settings used by subsequent conversions. @@ -318,7 +320,9 @@ def configure_converter( font_cache_size_mb Maximum font cache size in megabytes. If ``None``, keep current value. auto_install_fonts - Automatic font downloading mode. One of ``"off"``, ``"strict"``, or ``"best-effort"``. + Whether missing fonts may be downloaded from Fontsource. If ``None``, keep current value. + missing_fonts + Missing-font policy: ``"fallback"``, ``"warn"``, or ``"error"``. If ``None``, keep current value. """ ... @@ -979,7 +983,8 @@ if TYPE_CHECKING: filesystem_root: str | None = None, allowed_base_urls: list[str] | None = None, font_cache_size_mb: int | None = None, - auto_install_fonts: Literal["off", "strict", "best-effort"] | None = None, + auto_install_fonts: bool | None = None, + missing_fonts: Literal["fallback", "warn", "error"] | None = None, ) -> None: """Async version of ``configure_converter``. See sync function for full documentation.""" ... diff --git a/vl-convert-rs/src/converter.rs b/vl-convert-rs/src/converter.rs index 94ade634..bbe6aaa4 100644 --- a/vl-convert-rs/src/converter.rs +++ b/vl-convert-rs/src/converter.rs @@ -447,19 +447,12 @@ impl ValueOrString { } } -/// Controls automatic font downloading from the Fontsource catalog. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum AutoInstallFonts { - /// Disabled (default). +pub enum MissingFontsPolicy { #[default] - Off, - /// Only examines the first font in each CSS font-family string. - /// If it is not on the system and not in the Fontsource catalog, - /// the conversion fails with an error. - Strict, - /// Same first-font-only logic as `Strict`, but logs warnings for - /// unavailable fonts instead of failing. - BestEffort, + Fallback, + Warn, + Error, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -471,8 +464,10 @@ pub struct VlConverterConfig { /// values override this default when provided. Must be non-empty when set. /// When configured, HTTP redirects are denied instead of followed. pub allowed_base_urls: Option>, - /// Controls automatic font downloading from the Fontsource catalog. - pub auto_install_fonts: AutoInstallFonts, + /// Whether to auto-download missing fonts from the Fontsource catalog. + pub auto_install_fonts: bool, + /// How to handle missing fonts: silently fallback, warn, or error. + pub missing_fonts: MissingFontsPolicy, } impl Default for VlConverterConfig { @@ -482,7 +477,8 @@ impl Default for VlConverterConfig { allow_http_access: true, filesystem_root: None, allowed_base_urls: None, - auto_install_fonts: AutoInstallFonts::Off, + auto_install_fonts: false, + missing_fonts: MissingFontsPolicy::Fallback, } } } @@ -2322,18 +2318,20 @@ impl VlConvertCommand { /// println!("{}", vega_spec) /// ``` /// -/// Automatically download missing fonts referenced in a compiled Vega spec. +/// Validate font availability and optionally auto-install missing fonts from +/// the Fontsource catalog. /// /// This function extracts font-family strings from the spec, classifies the -/// first font in each string, then downloads any that need it. -/// -/// In `Strict` mode, returns an error listing any fonts that are neither on -/// the system nor in the Fontsource catalog — before any downloads begin. -/// In `BestEffort` mode, logs warnings for unavailable fonts and continues. -async fn auto_download_fonts( +/// first font in each string, and optionally downloads missing fonts. +async fn preprocess_fonts( vega_spec: &serde_json::Value, - mode: &AutoInstallFonts, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result, AnyError> { + if !auto_install_fonts && missing_fonts == MissingFontsPolicy::Fallback { + return Ok(Vec::new()); + } + let font_strings = extract_fonts_from_vega(vega_spec); if font_strings.is_empty() { return Ok(Vec::new()); @@ -2342,7 +2340,7 @@ async fn auto_download_fonts( // Get currently available font families from fontdb let available: HashSet = USVG_OPTIONS .lock() - .map_err(|e| anyhow!("auto_install_fonts: failed to lock USVG_OPTIONS: {e}"))? + .map_err(|e| anyhow!("font_preprocessing: failed to lock USVG_OPTIONS: {e}"))? .fontdb .faces() .flat_map(|face| face.families.iter().map(|(name, _)| name.clone())) @@ -2350,32 +2348,32 @@ async fn auto_download_fonts( let font_string_vec: Vec = font_strings.into_iter().collect(); - // Check which first-fonts are known to Fontsource (only those not already available) let mut downloadable_set: HashSet = HashSet::new(); - let mut api_errors: Vec<(String, String)> = Vec::new(); - for font_string in &font_string_vec { - let entries = crate::extract::parse_css_font_family(font_string); - if let Some(crate::extract::FontFamilyEntry::Named(ref name)) = entries.first() { - if !is_available(name, &available) { - if let Some(font_id) = family_to_id(name) { - match FONTSOURCE_CACHE.is_known_font(&font_id).await { - Ok(true) => { - downloadable_set.insert(name.clone()); - } - Ok(false) => {} - Err(e) => { - api_errors.push((name.clone(), e.to_string())); + if auto_install_fonts { + // Check which first-fonts are known to Fontsource (only those not already available) + let mut api_errors: Vec<(String, String)> = Vec::new(); + for font_string in &font_string_vec { + let entries = crate::extract::parse_css_font_family(font_string); + if let Some(crate::extract::FontFamilyEntry::Named(ref name)) = entries.first() { + if !is_available(name, &available) { + if let Some(font_id) = family_to_id(name) { + match FONTSOURCE_CACHE.is_known_font(&font_id).await { + Ok(true) => { + downloadable_set.insert(name.clone()); + } + Ok(false) => {} + Err(e) => { + api_errors.push((name.clone(), e.to_string())); + } } } } } } - } - // Report API errors (network issues) distinctly from "not in catalog" - if !api_errors.is_empty() { - match mode { - AutoInstallFonts::Strict => { + // Report API errors (network issues) distinctly from "not in catalog" + if !api_errors.is_empty() { + if missing_fonts == MissingFontsPolicy::Error { let details: Vec = api_errors .iter() .map(|(name, err)| format!("'{name}': {err}")) @@ -2385,8 +2383,7 @@ async fn auto_download_fonts( the following fonts: {}", details.join(", ") )); - } - _ => { + } else if missing_fonts == MissingFontsPolicy::Warn { for (name, err) in &api_errors { log::warn!( "auto_install_fonts: could not reach Fontsource API for '{name}': {err}" @@ -2398,7 +2395,7 @@ async fn auto_download_fonts( // Classify each font string by its first entry let statuses = resolve_first_fonts(&font_string_vec, &available, |family| { - downloadable_set.contains(family) + auto_install_fonts && downloadable_set.contains(family) }); // Collect unavailable fonts — report before any downloads @@ -2411,36 +2408,49 @@ async fn auto_download_fonts( .collect(); if !unavailable.is_empty() { - match mode { - AutoInstallFonts::Strict => { - let details: Vec = unavailable - .iter() - .map(|(name, css)| { - if *name == *css { - format!("'{name}'") - } else { - format!("'{name}' (from \"{css}\")") - } - }) - .collect(); + let details: Vec = unavailable + .iter() + .map(|(name, css)| { + if *name == *css { + format!("'{name}'") + } else { + format!("'{name}' (from \"{css}\")") + } + }) + .collect(); + if missing_fonts == MissingFontsPolicy::Error { + if auto_install_fonts { return Err(anyhow!( "auto_install_fonts: the following fonts are not available on the system \ and not found in the Fontsource catalog: {}", details.join(", ") )); + } else { + return Err(anyhow!( + "missing_fonts=error: the following fonts are not available on the system: {}. \ + Install them with install_font() or enable auto_install_fonts.", + details.join(", ") + )); } - AutoInstallFonts::BestEffort => { - for (name, _css) in &unavailable { + } + if missing_fonts == MissingFontsPolicy::Warn { + for (name, _css) in &unavailable { + if auto_install_fonts { log::warn!( "auto_install_fonts: font '{name}' is not available on the system \ and not found in the Fontsource catalog, skipping" ); + } else { + log::warn!("missing_fonts=warn: font '{name}' is not available on the system"); } } - AutoInstallFonts::Off => return Ok(vec![]), } } + if !auto_install_fonts { + return Ok(Vec::new()); + } + // Download and register fonts that need it let mut downloaded_font_ids: HashSet = HashSet::new(); let mut html_fonts: Vec = Vec::new(); @@ -2457,16 +2467,15 @@ async fn auto_download_fonts( font_type: outcome.font_type.unwrap_or_else(|| "google".to_string()), }); } - Err(e) => match mode { - AutoInstallFonts::Strict => { + Err(e) => { + if missing_fonts == MissingFontsPolicy::Error { return Err(anyhow!( "auto_install_fonts: failed to download '{name}': {e}" )); - } - _ => { + } else if missing_fonts == MissingFontsPolicy::Warn { log::warn!("auto_install_fonts: failed to install '{name}': {e}"); } - }, + } } } } @@ -2641,16 +2650,21 @@ impl VlConverter { .await } - /// If `auto_install_fonts` is enabled, compile VL→Vega and download any missing fonts. + fn should_preprocess_fonts(&self) -> bool { + self.inner.config.auto_install_fonts + || self.inner.config.missing_fonts != MissingFontsPolicy::Fallback + } + + /// If font preprocessing is enabled, compile VL→Vega and process referenced fonts. /// /// Returns `Some((vega_spec, vg_opts))` with the compiled Vega spec and options - /// for the caller to render directly, or `None` if auto-install is disabled. - async fn maybe_compile_vl_with_auto_fonts( + /// for the caller to render directly, or `None` when both font options are disabled. + async fn maybe_compile_vl_with_preprocessed_fonts( &self, vl_spec: &ValueOrString, vl_opts: &VlOpts, ) -> Result, AnyError> { - if self.inner.config.auto_install_fonts == AutoInstallFonts::Off { + if !self.should_preprocess_fonts() { return Ok(None); } let vg_opts = VgOpts { @@ -2658,26 +2672,38 @@ impl VlConverter { format_locale: vl_opts.format_locale.clone(), time_format_locale: vl_opts.time_format_locale.clone(), }; - let vega_spec = self.vegalite_to_vega(vl_spec.clone(), vl_opts.clone()).await?; + let vega_spec = self + .vegalite_to_vega(vl_spec.clone(), vl_opts.clone()) + .await?; // Return value (Vec) will be consumed in PR #247 (HTML font embedding) - let _ = auto_download_fonts(&vega_spec, &self.inner.config.auto_install_fonts).await?; + let _ = preprocess_fonts( + &vega_spec, + self.inner.config.auto_install_fonts, + self.inner.config.missing_fonts, + ) + .await?; Ok(Some((vega_spec, vg_opts))) } - /// If `auto_install_fonts` is enabled, parse the Vega spec and download any missing fonts. + /// If font preprocessing is enabled, parse the Vega spec and process missing fonts. /// /// Note: font downloads are governed solely by `auto_install_fonts`, independently /// of `allow_http_access`. The two settings control different concerns: /// `allow_http_access` governs data-fetching URLs in specs, while `auto_install_fonts` /// governs on-demand font installation from Fontsource. - async fn maybe_auto_download_vega(&self, spec: &ValueOrString) -> Result<(), AnyError> { - if self.inner.config.auto_install_fonts != AutoInstallFonts::Off { + async fn maybe_preprocess_vega_fonts(&self, spec: &ValueOrString) -> Result<(), AnyError> { + if self.should_preprocess_fonts() { let spec_value: serde_json::Value = match spec { ValueOrString::JsonString(s) => serde_json::from_str(s)?, ValueOrString::Value(v) => v.clone(), }; // Return value (Vec) will be consumed in PR #247 (HTML font embedding) - let _ = auto_download_fonts(&spec_value, &self.inner.config.auto_install_fonts).await?; + let _ = preprocess_fonts( + &spec_value, + self.inner.config.auto_install_fonts, + self.inner.config.missing_fonts, + ) + .await?; } Ok(()) } @@ -2690,7 +2716,7 @@ impl VlConverter { vg_opts.allowed_base_urls = self.effective_allowed_base_urls(vg_opts.allowed_base_urls.take())?; let vg_spec = vg_spec.into(); - self.maybe_auto_download_vega(&vg_spec).await?; + self.maybe_preprocess_vega_fonts(&vg_spec).await?; self.request( move |responder| VlConvertCommand::VgToSvg { @@ -2711,7 +2737,7 @@ impl VlConverter { vg_opts.allowed_base_urls = self.effective_allowed_base_urls(vg_opts.allowed_base_urls.take())?; let vg_spec = vg_spec.into(); - self.maybe_auto_download_vega(&vg_spec).await?; + self.maybe_preprocess_vega_fonts(&vg_spec).await?; self.request( move |responder| VlConvertCommand::VgToSg { vg_spec, @@ -2731,7 +2757,7 @@ impl VlConverter { vg_opts.allowed_base_urls = self.effective_allowed_base_urls(vg_opts.allowed_base_urls.take())?; let vg_spec = vg_spec.into(); - self.maybe_auto_download_vega(&vg_spec).await?; + self.maybe_preprocess_vega_fonts(&vg_spec).await?; self.request( move |responder| VlConvertCommand::VgToSgMsgpack { vg_spec, @@ -2753,7 +2779,7 @@ impl VlConverter { let vl_spec = vl_spec.into(); if let Some((vega_spec, vg_opts)) = self - .maybe_compile_vl_with_auto_fonts(&vl_spec, &vl_opts) + .maybe_compile_vl_with_preprocessed_fonts(&vl_spec, &vl_opts) .await? { let vg_spec: ValueOrString = vega_spec.into(); @@ -2789,7 +2815,7 @@ impl VlConverter { let vl_spec = vl_spec.into(); if let Some((vega_spec, vg_opts)) = self - .maybe_compile_vl_with_auto_fonts(&vl_spec, &vl_opts) + .maybe_compile_vl_with_preprocessed_fonts(&vl_spec, &vl_opts) .await? { let vg_spec: ValueOrString = vega_spec.into(); @@ -2825,7 +2851,7 @@ impl VlConverter { let vl_spec = vl_spec.into(); if let Some((vega_spec, vg_opts)) = self - .maybe_compile_vl_with_auto_fonts(&vl_spec, &vl_opts) + .maybe_compile_vl_with_preprocessed_fonts(&vl_spec, &vl_opts) .await? { let vg_spec: ValueOrString = vega_spec.into(); @@ -2865,7 +2891,7 @@ impl VlConverter { let effective_scale = scale * ppi / 72.0; let vg_spec = vg_spec.into(); - self.maybe_auto_download_vega(&vg_spec).await?; + self.maybe_preprocess_vega_fonts(&vg_spec).await?; self.request( move |responder| VlConvertCommand::VgToPng { @@ -2895,7 +2921,7 @@ impl VlConverter { let effective_scale = scale * ppi / 72.0; if let Some((vega_spec, vg_opts)) = self - .maybe_compile_vl_with_auto_fonts(&vl_spec, &vl_opts) + .maybe_compile_vl_with_preprocessed_fonts(&vl_spec, &vl_opts) .await? { let vg_spec: ValueOrString = vega_spec.into(); diff --git a/vl-convert/src/main.rs b/vl-convert/src/main.rs index a3b9f825..502287f8 100644 --- a/vl-convert/src/main.rs +++ b/vl-convert/src/main.rs @@ -8,24 +8,27 @@ use std::io::{self, IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; use std::str::FromStr; use vl_convert_rs::converter::{ - vega_to_url, vegalite_to_url, AutoInstallFonts, FormatLocale, Renderer, TimeFormatLocale, + vega_to_url, vegalite_to_url, FormatLocale, MissingFontsPolicy, Renderer, TimeFormatLocale, VgOpts, VlConverter, VlConverterConfig, VlOpts, }; use vl_convert_rs::module_loader::import_map::VlVersion; use vl_convert_rs::text::register_font_directory; use vl_convert_rs::{anyhow, anyhow::bail, install_font}; -#[derive(Debug, Clone, clap::ValueEnum)] -enum AutoInstallFontsArg { - Strict, - BestEffort, +#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)] +enum MissingFontsArg { + #[default] + Fallback, + Warn, + Error, } -impl AutoInstallFontsArg { - fn to_auto_install_fonts(&self) -> AutoInstallFonts { +impl MissingFontsArg { + fn to_missing_fonts_policy(self) -> MissingFontsPolicy { match self { - AutoInstallFontsArg::Strict => AutoInstallFonts::Strict, - AutoInstallFontsArg::BestEffort => AutoInstallFonts::BestEffort, + MissingFontsArg::Fallback => MissingFontsPolicy::Fallback, + MissingFontsArg::Warn => MissingFontsPolicy::Warn, + MissingFontsArg::Error => MissingFontsPolicy::Error, } } } @@ -54,10 +57,13 @@ struct Cli { #[arg(long, global = true)] install_font: Vec, - /// Automatically download fonts referenced in specs from the Fontsource catalog. - /// "strict" errors if a font is unavailable; "best-effort" warns and continues. - #[arg(long, global = true, value_enum)] - auto_install_fonts: Option, + /// Automatically download missing fonts from the Fontsource catalog. + #[arg(long, global = true)] + auto_install_fonts: bool, + + /// Missing-font behavior: fallback silently, warn, or error. + #[arg(long, global = true, value_enum, default_value_t = MissingFontsArg::Fallback)] + missing_fonts: MissingFontsArg, #[command(subcommand)] command: Commands, @@ -597,16 +603,14 @@ async fn main() -> Result<(), anyhow::Error> { no_http_access, filesystem_root, install_font: install_font_families, - auto_install_fonts: auto_install_fonts_arg, + auto_install_fonts, + missing_fonts: missing_fonts_arg, command, } = Cli::parse(); if no_http_access { allow_http_access = false; } - let auto_install_fonts = auto_install_fonts_arg - .as_ref() - .map(|a| a.to_auto_install_fonts()) - .unwrap_or(AutoInstallFonts::Off); + let missing_fonts = missing_fonts_arg.to_missing_fonts_policy(); use crate::Commands::*; match command { Vl2vg { @@ -630,6 +634,7 @@ async fn main() -> Result<(), anyhow::Error> { allow_http_access, filesystem_root.clone(), auto_install_fonts, + missing_fonts, ) .await? } @@ -660,6 +665,7 @@ async fn main() -> Result<(), anyhow::Error> { allow_http_access, filesystem_root.clone(), auto_install_fonts, + missing_fonts, ) .await? } @@ -694,6 +700,7 @@ async fn main() -> Result<(), anyhow::Error> { allow_http_access, filesystem_root.clone(), auto_install_fonts, + missing_fonts, ) .await? } @@ -728,6 +735,7 @@ async fn main() -> Result<(), anyhow::Error> { allow_http_access, filesystem_root.clone(), auto_install_fonts, + missing_fonts, ) .await? } @@ -758,6 +766,7 @@ async fn main() -> Result<(), anyhow::Error> { allow_http_access, filesystem_root.clone(), auto_install_fonts, + missing_fonts, ) .await? } @@ -831,6 +840,7 @@ async fn main() -> Result<(), anyhow::Error> { allow_http_access, filesystem_root.clone(), auto_install_fonts, + missing_fonts, ) .await? } @@ -857,6 +867,7 @@ async fn main() -> Result<(), anyhow::Error> { allow_http_access, filesystem_root.clone(), auto_install_fonts, + missing_fonts, ) .await? } @@ -883,6 +894,7 @@ async fn main() -> Result<(), anyhow::Error> { allow_http_access, filesystem_root.clone(), auto_install_fonts, + missing_fonts, ) .await? } @@ -905,6 +917,7 @@ async fn main() -> Result<(), anyhow::Error> { allow_http_access, filesystem_root.clone(), auto_install_fonts, + missing_fonts, ) .await? } @@ -963,8 +976,13 @@ async fn main() -> Result<(), anyhow::Error> { register_font_dir(font_dir)?; install_fonts(&install_font_families).await?; let svg = read_input_string(input.as_deref())?; - let converter = - build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url, auto_install_fonts)?; + let converter = build_converter( + allow_http_access, + filesystem_root.clone(), + allowed_base_url, + auto_install_fonts, + missing_fonts, + )?; let png_data = converter.svg_to_png(&svg, scale, Some(ppi))?; write_output_binary(output.as_deref(), &png_data, "PNG")?; } @@ -979,8 +997,13 @@ async fn main() -> Result<(), anyhow::Error> { register_font_dir(font_dir)?; install_fonts(&install_font_families).await?; let svg = read_input_string(input.as_deref())?; - let converter = - build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url, auto_install_fonts)?; + let converter = build_converter( + allow_http_access, + filesystem_root.clone(), + allowed_base_url, + auto_install_fonts, + missing_fonts, + )?; let jpeg_data = converter.svg_to_jpeg(&svg, scale, Some(quality))?; write_output_binary(output.as_deref(), &jpeg_data, "JPEG")?; } @@ -993,8 +1016,13 @@ async fn main() -> Result<(), anyhow::Error> { register_font_dir(font_dir)?; install_fonts(&install_font_families).await?; let svg = read_input_string(input.as_deref())?; - let converter = - build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url, auto_install_fonts)?; + let converter = build_converter( + allow_http_access, + filesystem_root.clone(), + allowed_base_url, + auto_install_fonts, + missing_fonts, + )?; let pdf_data = converter.svg_to_pdf(&svg)?; write_output_binary(output.as_deref(), &pdf_data, "PDF")?; } @@ -1028,13 +1056,15 @@ fn build_converter( allow_http_access: bool, filesystem_root: Option, allowed_base_urls: Option>, - auto_install_fonts: AutoInstallFonts, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result { let config = VlConverterConfig { allow_http_access, filesystem_root: filesystem_root.map(PathBuf::from), allowed_base_urls, auto_install_fonts, + missing_fonts, ..Default::default() }; @@ -1326,7 +1356,8 @@ async fn vl_2_vg( show_warnings: bool, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1341,7 +1372,13 @@ async fn vl_2_vg( let config = read_config_json(config)?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let vega_json = match converter @@ -1390,7 +1427,8 @@ async fn vg_2_svg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1402,7 +1440,13 @@ async fn vg_2_svg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let svg = match converter @@ -1439,7 +1483,8 @@ async fn vg_2_png( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1451,7 +1496,13 @@ async fn vg_2_png( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let png_data = match converter @@ -1490,7 +1541,8 @@ async fn vg_2_jpeg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1502,7 +1554,13 @@ async fn vg_2_jpeg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let jpeg_data = match converter @@ -1538,7 +1596,8 @@ async fn vg_2_pdf( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1550,7 +1609,13 @@ async fn vg_2_pdf( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let pdf_data = match converter @@ -1589,7 +1654,8 @@ async fn vl_2_svg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1607,7 +1673,13 @@ async fn vl_2_svg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let svg = match converter @@ -1652,7 +1724,8 @@ async fn vl_2_png( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1670,7 +1743,13 @@ async fn vl_2_png( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let png_data = match converter @@ -1717,7 +1796,8 @@ async fn vl_2_jpeg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1735,7 +1815,13 @@ async fn vl_2_jpeg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let jpeg_data = match converter @@ -1780,7 +1866,8 @@ async fn vl_2_pdf( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, - auto_install_fonts: AutoInstallFonts, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1798,7 +1885,13 @@ async fn vl_2_pdf( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None, auto_install_fonts)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let pdf_data = match converter From 93a76b34a85e872c638822d4f77be417c50ae3b5 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 25 Feb 2026 13:34:49 -0500 Subject: [PATCH 12/12] fix: apply cargo fmt to extract.rs test assertions Co-Authored-By: Claude Opus 4.6 --- vl-convert-rs/src/extract.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/vl-convert-rs/src/extract.rs b/vl-convert-rs/src/extract.rs index 3184ddbe..09e796b4 100644 --- a/vl-convert-rs/src/extract.rs +++ b/vl-convert-rs/src/extract.rs @@ -1209,19 +1209,13 @@ mod tests { fn test_parse_generic_case_insensitive() { // "Sans-Serif" (title-case) should be classified as Generic let entries = parse_css_font_family("Sans-Serif"); - assert_eq!( - entries, - vec![FontFamilyEntry::Generic("Sans-Serif".into())] - ); + assert_eq!(entries, vec![FontFamilyEntry::Generic("Sans-Serif".into())]); } #[test] fn test_parse_generic_uppercase() { let entries = parse_css_font_family("MONOSPACE"); - assert_eq!( - entries, - vec![FontFamilyEntry::Generic("MONOSPACE".into())] - ); + assert_eq!(entries, vec![FontFamilyEntry::Generic("MONOSPACE".into())]); } // -----------------------------------------------------------------------