From 17f9b518f10282da9c1963ceb28b4515375208d3 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 2 May 2026 18:33:54 +0000 Subject: [PATCH] fix(engine): bundle fonts only and drop system-font fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review feedback: the SVG rasteriser mixed three font-resolution layers (load_system_fonts + bundled FiraMono + lenient resolver appending CSS generics), which made card text rendering depend on which fonts the host machine happened to have. The Bevy UI face loaded separately at runtime via AssetServer. Picking option (a) from the review and applying it consistently: bundle FiraMono via include_bytes!() in BOTH layers, no system fallback anywhere. solitaire_engine/src/font_plugin.rs now embeds main.ttf at compile time and registers it with Assets. A parse failure aborts with "bundled FiraMono failed to parse — binary is corrupt"; the MinimalPlugins early-return stays as a "this plugin doesn't apply in headless tests" check (consumers query Option> and degrade cleanly), not a production fallback. solitaire_engine/src/assets/svg_loader.rs drops load_system_fonts entirely, drops the lenient_font_resolver, and drops the five set_*_family pins. The new bundled_font_resolver ignores the SVG's font-family request and always returns the single bundled face — the bundled card SVGs reference Arial / Bitstream Vera Sans by name and we deliberately don't ship those, so routing every query to FiraMono keeps rasterisation deterministic. shared_fontdb asserts the embedded bytes parsed. The two layers now embed the same path (assets/fonts/main.ttf) independently, so they can't drift. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_engine/src/assets/svg_loader.rs | 119 ++++++---------------- solitaire_engine/src/font_plugin.rs | 35 ++++--- 2 files changed, 55 insertions(+), 99 deletions(-) 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)); }