diff --git a/solitaire_engine/src/assets/svg_loader.rs b/solitaire_engine/src/assets/svg_loader.rs index 97c7db0..65fb1ce 100644 --- a/solitaire_engine/src/assets/svg_loader.rs +++ b/solitaire_engine/src/assets/svg_loader.rs @@ -107,12 +107,11 @@ 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(), + // The bundled fontdb only contains FiraMono and the resolver + // routes every named-family request to it; this is a default + // for SVGs that don't specify a family at all. + font_family: "Fira Mono".to_string(), + font_resolver: bundled_font_resolver(), ..Default::default() }; let tree = usvg::Tree::from_data(svg_bytes, &opt)?; @@ -152,100 +151,46 @@ pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result Arc { static DB: OnceLock> = OnceLock::new(); DB.get_or_init(|| { let mut db = fontdb::Database::new(); - db.load_system_fonts(); - // The bundled FiraMono lives at the workspace root, so the - // include_bytes! path goes up three levels from this source - // file (assets → src → solitaire_engine → workspace root). - db.load_font_data(include_bytes!("../../../assets/fonts/main.ttf").to_vec()); - // Pin the CSS generics to the bundled face as the resolution - // target. Named-family lookups (Bitstream Vera Sans, Arial) - // still try the system db first; only when those miss does - // the resolver fall through to SansSerif / Serif, and now - // those are guaranteed to land on FiraMono. - db.set_sans_serif_family("Fira Mono"); - db.set_serif_family("Fira Mono"); - db.set_monospace_family("Fira Mono"); - db.set_cursive_family("Fira Mono"); - db.set_fantasy_family("Fira Mono"); + db.load_font_data(BUNDLED_FONT_BYTES.to_vec()); + assert!( + db.faces().next().is_some(), + "bundled FiraMono failed to parse — binary is corrupt" + ); Arc::new(db) }) .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}; +/// Resolver that ignores the SVG's `font-family` request and always +/// returns the single bundled FiraMono face. Bundled card SVGs ask for +/// fonts by name (Arial, Bitstream Vera Sans) that this binary +/// deliberately doesn't ship; routing every query to FiraMono keeps +/// rendering deterministic and removes the system-font path entirely. +fn bundled_font_resolver() -> usvg::FontResolver<'static> { + use usvg::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_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)), select_fallback: FontResolver::default_fallback_selector(), } } diff --git a/solitaire_engine/src/font_plugin.rs b/solitaire_engine/src/font_plugin.rs index 6bdfa95..e16e434 100644 --- a/solitaire_engine/src/font_plugin.rs +++ b/solitaire_engine/src/font_plugin.rs @@ -1,14 +1,23 @@ -// Register FontPlugin in solitaire_engine/src/lib.rs before use. - -//! Loads FiraMono-Medium via the Bevy `AssetServer` and exposes it via [`FontResource`]. +//! Embeds FiraMono-Medium into the binary and exposes it via [`FontResource`]. +//! +//! Bundling rather than runtime-loading guarantees the canonical UI face is +//! always available regardless of install or platform. The bytes are +//! validated at startup; a parse failure aborts the program with a clear +//! error because it means the binary is corrupt. use bevy::prelude::*; -/// Holds the project-wide [`Handle`] loaded at startup. +/// FiraMono-Medium bytes embedded at compile time. Single source of truth for +/// the project's UI face — `solitaire_engine::assets::svg_loader` embeds the +/// same path independently for SVG rasterisation so the two layers can't +/// drift. +const BUNDLED_FONT_BYTES: &[u8] = include_bytes!("../../assets/fonts/main.ttf"); + +/// Holds the project-wide [`Handle`] registered at startup. #[derive(Resource)] pub struct FontResource(pub Handle); -/// Loads FiraMono-Medium at startup and inserts [`FontResource`]. +/// Registers the bundled FiraMono with [`Assets`] at startup. pub struct FontPlugin; impl Plugin for FontPlugin { @@ -17,11 +26,13 @@ impl Plugin for FontPlugin { } } -fn load_font(asset_server: Option>, mut commands: Commands) { - let Some(asset_server) = asset_server else { - // AssetServer absent (e.g. MinimalPlugins in tests) — insert default. - commands.insert_resource(FontResource(Handle::default())); - return; - }; - commands.insert_resource(FontResource(asset_server.load("fonts/main.ttf"))); +fn load_font(fonts: Option>>, mut commands: Commands) { + // Headless test fixtures use MinimalPlugins (no AssetPlugin → no + // Assets). FontPlugin in that context is a no-op — consumers + // already query `Option>` and degrade cleanly. + let Some(mut fonts) = fonts else { return }; + let font = Font::try_from_bytes(BUNDLED_FONT_BYTES.to_vec()) + .expect("bundled FiraMono failed to parse — binary is corrupt"); + let handle = fonts.add(font); + commands.insert_resource(FontResource(handle)); }