fix(engine): bundle fonts only and drop system-font fallback

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<Font>. 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<Res<FontResource>>
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) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-02 18:33:54 +00:00
parent 61d891fb76
commit 17f9b518f1
2 changed files with 55 additions and 99 deletions
+32 -87
View File
@@ -107,12 +107,11 @@ impl AssetLoader for SvgLoader {
pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoaderError> { pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoaderError> {
let opt = usvg::Options { let opt = usvg::Options {
fontdb: shared_fontdb(), fontdb: shared_fontdb(),
// Default for SVG elements without an explicit `font-family` — // The bundled fontdb only contains FiraMono and the resolver
// resolved by fontdb's generic-family alias to whatever // routes every named-family request to it; this is a default
// sans-serif the system has installed (DejaVu Sans on most // for SVGs that don't specify a family at all.
// Linux installs, Helvetica on macOS, Arial on Windows). font_family: "Fira Mono".to_string(),
font_family: "sans-serif".to_string(), font_resolver: bundled_font_resolver(),
font_resolver: lenient_font_resolver(),
..Default::default() ..Default::default()
}; };
let tree = usvg::Tree::from_data(svg_bytes, &opt)?; let tree = usvg::Tree::from_data(svg_bytes, &opt)?;
@@ -152,100 +151,46 @@ pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoader
)) ))
} }
/// Returns a process-wide font database populated with the OS-installed /// FiraMono-Medium bytes embedded at compile time. Mirrors the embed in
/// fonts plus the bundled FiraMono-Medium face. Initialised lazily on /// `solitaire_engine::font_plugin` so the SVG rasteriser and the Bevy UI
/// first SVG that references text, then shared (via `Arc`) across every /// share the same canonical face.
/// subsequent rasterisation. const BUNDLED_FONT_BYTES: &[u8] = include_bytes!("../../../assets/fonts/main.ttf");
/// Returns a process-wide font database holding only the bundled
/// FiraMono-Medium face. Initialised lazily on first SVG that references
/// text, then shared (via `Arc`) across every subsequent rasterisation.
/// ///
/// `usvg::Options::default()` ships an empty `fontdb`, so without this /// The bundled card SVGs reference families like `Arial` and
/// call any text glyph in an SVG renders with no font match — the /// `Bitstream Vera Sans` by name; [`bundled_font_resolver`] maps every
/// visible symptom on the bundled hayeah artwork is the "No match for /// such request directly to FiraMono so rasterisation is deterministic
/// Arial font-family" warn spam plus glyphs that fall through to /// across machines and the system font path is never consulted.
/// whatever shape-only path usvg uses for missing fonts.
/// ///
/// **Bundled font as last-resort fallback.** Loading only system fonts /// Aborts the program if the embedded bytes don't parse — bundled at
/// breaks on minimal Linux installs, fresh Wayland sessions, and /// compile time, so a parse failure means the binary is corrupt.
/// chroots where fontconfig has nothing usable to serve as
/// `sans-serif`. The cards on the bundled hayeah theme reference
/// `Bitstream Vera Sans` and `Arial` by name — if neither is installed
/// AND the resolver's CSS-generic fallbacks (`SansSerif`/`Serif`) also
/// don't resolve, the rank/suit text vanishes entirely. Loading the
/// project's bundled FiraMono via `include_bytes!()` and pinning it as
/// the generic-family target guarantees a working last-resort glyph
/// source on every machine. This was the cause of "card font didn't
/// carry over" on a fresh second-machine pull.
///
/// `load_system_fonts` is comparatively expensive (~50200 ms on a
/// typical desktop) so we only pay it once for the lifetime of the
/// process, gated by `OnceLock`.
fn shared_fontdb() -> Arc<fontdb::Database> { fn shared_fontdb() -> Arc<fontdb::Database> {
static DB: OnceLock<Arc<fontdb::Database>> = OnceLock::new(); static DB: OnceLock<Arc<fontdb::Database>> = OnceLock::new();
DB.get_or_init(|| { DB.get_or_init(|| {
let mut db = fontdb::Database::new(); let mut db = fontdb::Database::new();
db.load_system_fonts(); db.load_font_data(BUNDLED_FONT_BYTES.to_vec());
// The bundled FiraMono lives at the workspace root, so the assert!(
// include_bytes! path goes up three levels from this source db.faces().next().is_some(),
// file (assets → src → solitaire_engine → workspace root). "bundled FiraMono failed to parse — binary is corrupt"
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");
Arc::new(db) Arc::new(db)
}) })
.clone() .clone()
} }
/// Builds a `usvg::FontResolver` that mirrors the upstream default /// Resolver that ignores the SVG's `font-family` request and always
/// `select_font` but appends the CSS generics `sans-serif` and `serif` /// returns the single bundled FiraMono face. Bundled card SVGs ask for
/// to every query's family list. The upstream selector only appends /// fonts by name (Arial, Bitstream Vera Sans) that this binary
/// `serif` and emits a `log::warn!` when its `fontdb.query` returns /// deliberately doesn't ship; routing every query to FiraMono keeps
/// `None`; on systems without the named families requested by the /// rendering deterministic and removes the system-font path entirely.
/// SVG (e.g. Arial on Linux), every text node bridges that warn into fn bundled_font_resolver() -> usvg::FontResolver<'static> {
/// our tracing output. By appending two generics — both resolved via use usvg::FontResolver;
/// 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 { usvg::FontResolver {
select_font: Box::new(|font, db| { select_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)),
let mut families: Vec<fontdb::Family> = 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(), select_fallback: FontResolver::default_fallback_selector(),
} }
} }
+23 -12
View File
@@ -1,14 +1,23 @@
// Register FontPlugin in solitaire_engine/src/lib.rs before use. //! Embeds FiraMono-Medium into the binary and exposes it via [`FontResource`].
//!
//! Loads FiraMono-Medium via the Bevy `AssetServer` 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::*; use bevy::prelude::*;
/// Holds the project-wide [`Handle<Font>`] 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<Font>`] registered at startup.
#[derive(Resource)] #[derive(Resource)]
pub struct FontResource(pub Handle<Font>); pub struct FontResource(pub Handle<Font>);
/// Loads FiraMono-Medium at startup and inserts [`FontResource`]. /// Registers the bundled FiraMono with [`Assets<Font>`] at startup.
pub struct FontPlugin; pub struct FontPlugin;
impl Plugin for FontPlugin { impl Plugin for FontPlugin {
@@ -17,11 +26,13 @@ impl Plugin for FontPlugin {
} }
} }
fn load_font(asset_server: Option<Res<AssetServer>>, mut commands: Commands) { fn load_font(fonts: Option<ResMut<Assets<Font>>>, mut commands: Commands) {
let Some(asset_server) = asset_server else { // Headless test fixtures use MinimalPlugins (no AssetPlugin → no
// AssetServer absent (e.g. MinimalPlugins in tests) — insert default. // Assets<Font>). FontPlugin in that context is a no-op — consumers
commands.insert_resource(FontResource(Handle::default())); // already query `Option<Res<FontResource>>` and degrade cleanly.
return; let Some(mut fonts) = fonts else { return };
}; let font = Font::try_from_bytes(BUNDLED_FONT_BYTES.to_vec())
commands.insert_resource(FontResource(asset_server.load("fonts/main.ttf"))); .expect("bundled FiraMono failed to parse — binary is corrupt");
let handle = fonts.add(font);
commands.insert_resource(FontResource(handle));
} }