From efa063fb8f0cfda72c17f9a9486cf9688f02b8d7 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 1 May 2026 18:41:02 +0000 Subject: [PATCH] fix(engine): fall through to system default font on unmatched family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous LogPlugin-filter approach (which suppresses the warn message) with a fix at the source: a custom usvg FontResolver that appends `sans-serif` and `serif` to every family-lookup query. usvg's default selector queries fontdb with [SVG-requested families, Serif] and emits `log::warn!("No match for '{family}'")` when the query returns None. On systems without the SVG's named family (Arial on Linux, etc.), every text node logs a warn even though the system has perfectly good fonts available — the warn is a false negative because fontdb's named-family lookup is exact-match only. The new resolver appends both `Family::SansSerif` and `Family::Serif` to the query, both resolved by fontdb (via fontconfig on Linux or built-in defaults elsewhere) to whatever the system has installed. The query now finds *some* face on any reasonably configured machine, so `id.is_none()` is never true and the warn branch never fires. The visible behaviour: SVGs that request unavailable named families now silently use the system's default sans-serif font. Reverts the LogPlugin filter from main.rs — silencing warns at the log level was the wrong layer; fixing the lookup is. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_app/src/main.rs | 13 ---- solitaire_engine/src/assets/svg_loader.rs | 76 +++++++++++++++++++++++ 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 47ff8d3..076c860 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -100,19 +100,6 @@ fn main() { .set(bevy::asset::AssetPlugin { file_path: "../assets".to_string(), ..default() - }) - // The bundled hayeah card SVGs declare `font-family="Arial"` - // for rank/suit text. usvg compares family names exactly, - // so on systems without Arial installed (every Linux - // distro by default) it bridges a `log::warn!` per text - // node into our tracing output — 50+ lines per game on - // launch. The substitution path in `svg_loader::shared_fontdb` - // already resolves the glyphs to whatever sans-serif the - // user does have; the warn is purely informational and - // dropping it leaves real errors visible. - .set(bevy::log::LogPlugin { - filter: format!("{},usvg::text=error", bevy::log::DEFAULT_FILTER), - ..default() }), ) .add_plugins(AssetSourcesPlugin) diff --git a/solitaire_engine/src/assets/svg_loader.rs b/solitaire_engine/src/assets/svg_loader.rs index ec977ab..fa1ed7e 100644 --- a/solitaire_engine/src/assets/svg_loader.rs +++ b/solitaire_engine/src/assets/svg_loader.rs @@ -107,6 +107,12 @@ impl AssetLoader for SvgLoader { pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result { let opt = usvg::Options { fontdb: shared_fontdb(), + // Default for SVG elements without an explicit `font-family` — + // resolved by fontdb's generic-family alias to whatever + // sans-serif the system has installed (DejaVu Sans on most + // Linux installs, Helvetica on macOS, Arial on Windows). + font_family: "sans-serif".to_string(), + font_resolver: lenient_font_resolver(), ..Default::default() }; let tree = usvg::Tree::from_data(svg_bytes, &opt)?; @@ -167,6 +173,54 @@ fn shared_fontdb() -> Arc { .clone() } +/// Builds a `usvg::FontResolver` that mirrors the upstream default +/// `select_font` but appends the CSS generics `sans-serif` and `serif` +/// to every query's family list. The upstream selector only appends +/// `serif` and emits a `log::warn!` when its `fontdb.query` returns +/// `None`; on systems without the named families requested by the +/// SVG (e.g. Arial on Linux), every text node bridges that warn into +/// our tracing output. By appending two generics — both resolved via +/// fontconfig (or fontdb's built-in defaults) to whatever sans-serif / +/// serif the user has installed — we guarantee the query finds *some* +/// face, so the warn branch is never taken. The visible behaviour is +/// "use the system's default font when the requested one isn't +/// installed", which is the intent here. +/// +/// The fallback `select_fallback` is kept as the upstream default — +/// per-character fallback (for combining marks, scripts the primary +/// face doesn't cover) doesn't have the same warn-spam pathology. +fn lenient_font_resolver() -> usvg::FontResolver<'static> { + use usvg::{FontFamily, FontResolver}; + + usvg::FontResolver { + select_font: Box::new(|font, db| { + let mut families: Vec = font + .families() + .iter() + .map(|f| match f { + FontFamily::Serif => fontdb::Family::Serif, + FontFamily::SansSerif => fontdb::Family::SansSerif, + FontFamily::Cursive => fontdb::Family::Cursive, + FontFamily::Fantasy => fontdb::Family::Fantasy, + FontFamily::Monospace => fontdb::Family::Monospace, + FontFamily::Named(s) => fontdb::Family::Name(s), + }) + .collect(); + families.push(fontdb::Family::SansSerif); + families.push(fontdb::Family::Serif); + + let query = fontdb::Query { + families: &families, + weight: fontdb::Weight(font.weight()), + stretch: font.stretch().into(), + style: font.style().into(), + }; + db.query(&query) + }), + select_fallback: FontResolver::default_fallback_selector(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -201,6 +255,28 @@ mod tests { assert!(matches!(err, SvgLoaderError::PixmapAlloc(0, 100))); } + /// SVG with a text node that requests an unlikely-installed family + /// ("FontThatProbablyDoesNotExist"). Exercises `lenient_font_resolver`'s + /// "fall through to system sans-serif/serif" behaviour: rasterising + /// must succeed, never panic, and the test runner's log output must + /// not contain `No match for ... font-family.` for the named family. + /// Catching the warn directly would require a tracing subscriber; we + /// rely on `cargo test`'s default behaviour of capturing stdout/stderr + /// and surfacing only failing tests' output, plus visual review of + /// the suite's log stream. + const TEST_SVG_WITH_TEXT: &[u8] = br##" + + A +"##; + + #[test] + fn rasterizes_svg_with_unmatched_font_family() { + let image = + rasterize_svg(TEST_SVG_WITH_TEXT, UVec2::new(64, 96)).expect("rasterisation"); + assert_eq!(image.size().x, 64); + assert_eq!(image.size().y, 96); + } + #[test] fn rejects_malformed_svg() { let err = rasterize_svg(b"not actually svg", UVec2::new(64, 96)).unwrap_err();