gpui: Fix SVG renderer not rendering text when system fonts are unavailable (#51623)

João Soares created

Closes #51466

 Before you mark this PR as ready for review, make sure that you have:

- [x] Added a solid test coverage and/or screenshots from doing manual
testing

- [x] Done a self-review taking into account security and performance
aspects

- [ ] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

 Release Notes:

- Fixed mermaid diagrams not showing text in markdown preview by
bundling fallback fonts and fixing generic font family resolution in the
SVG renderer.

Change summary

crates/gpui/src/svg_renderer.rs | 127 +++++++++++++++++++++++++++++++++-
1 file changed, 120 insertions(+), 7 deletions(-)

Detailed changes

crates/gpui/src/svg_renderer.rs 🔗

@@ -105,18 +105,36 @@ pub enum SvgSize {
 impl SvgRenderer {
     /// Creates a new SVG renderer with the provided asset source.
     pub fn new(asset_source: Arc<dyn AssetSource>) -> Self {
-        static FONT_DB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
+        static SYSTEM_FONT_DB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
             let mut db = usvg::fontdb::Database::new();
             db.load_system_fonts();
             Arc::new(db)
         });
+
+        let fontdb = {
+            let mut db = (**SYSTEM_FONT_DB).clone();
+            load_bundled_fonts(&*asset_source, &mut db);
+            fix_generic_font_families(&mut db);
+            Arc::new(db)
+        };
+
         let default_font_resolver = usvg::FontResolver::default_font_selector();
         let font_resolver = Box::new(
             move |font: &usvg::Font, db: &mut Arc<usvg::fontdb::Database>| {
                 if db.is_empty() {
-                    *db = FONT_DB.clone();
+                    *db = fontdb.clone();
+                }
+                if let Some(id) = default_font_resolver(font, db) {
+                    return Some(id);
                 }
-                default_font_resolver(font, db)
+                // fontdb doesn't recognize CSS system font keywords like "system-ui"
+                // or "ui-sans-serif", so fall back to sans-serif before any face.
+                let sans_query = usvg::fontdb::Query {
+                    families: &[usvg::fontdb::Family::SansSerif],
+                    ..Default::default()
+                };
+                db.query(&sans_query)
+                    .or_else(|| db.faces().next().map(|f| f.id))
             },
         );
         let default_fallback_selection = usvg::FontResolver::default_fallback_selector();
@@ -226,14 +244,69 @@ impl SvgRenderer {
     }
 }
 
+fn load_bundled_fonts(asset_source: &dyn AssetSource, db: &mut usvg::fontdb::Database) {
+    let font_paths = [
+        "fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf",
+        "fonts/lilex/Lilex-Regular.ttf",
+    ];
+    for path in font_paths {
+        match asset_source.load(path) {
+            Ok(Some(data)) => db.load_font_data(data.into_owned()),
+            Ok(None) => log::warn!("Bundled font not found: {path}"),
+            Err(error) => log::warn!("Failed to load bundled font {path}: {error}"),
+        }
+    }
+}
+
+// fontdb defaults generic families to Microsoft fonts ("Arial", "Times New Roman")
+// which aren't installed on most Linux systems. fontconfig normally overrides these,
+// but when it fails the defaults remain and all generic family queries return None.
+fn fix_generic_font_families(db: &mut usvg::fontdb::Database) {
+    use usvg::fontdb::{Family, Query};
+
+    let families_and_fallbacks: &[(Family<'_>, &str)] = &[
+        (Family::SansSerif, "IBM Plex Sans"),
+        // No serif font bundled; use sans-serif as best available fallback.
+        (Family::Serif, "IBM Plex Sans"),
+        (Family::Monospace, "Lilex"),
+        (Family::Cursive, "IBM Plex Sans"),
+        (Family::Fantasy, "IBM Plex Sans"),
+    ];
+
+    for (family, fallback_name) in families_and_fallbacks {
+        let query = Query {
+            families: &[*family],
+            ..Default::default()
+        };
+        if db.query(&query).is_none() {
+            match family {
+                Family::SansSerif => db.set_sans_serif_family(*fallback_name),
+                Family::Serif => db.set_serif_family(*fallback_name),
+                Family::Monospace => db.set_monospace_family(*fallback_name),
+                Family::Cursive => db.set_cursive_family(*fallback_name),
+                Family::Fantasy => db.set_fantasy_family(*fallback_name),
+                _ => {}
+            }
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
+    use usvg::fontdb::{Database, Family, Query};
 
     const IBM_PLEX_REGULAR: &[u8] =
         include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf");
     const LILEX_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf");
 
+    fn db_with_bundled_fonts() -> Database {
+        let mut db = Database::new();
+        db.load_font_data(IBM_PLEX_REGULAR.to_vec());
+        db.load_font_data(LILEX_REGULAR.to_vec());
+        db
+    }
+
     #[test]
     fn test_is_emoji_presentation() {
         let cases = [
@@ -266,11 +339,33 @@ mod tests {
     }
 
     #[test]
-    fn test_select_emoji_font_skips_family_without_glyph() {
-        let mut db = usvg::fontdb::Database::new();
+    fn fix_generic_font_families_sets_all_families() {
+        let mut db = db_with_bundled_fonts();
+        fix_generic_font_families(&mut db);
+
+        let families = [
+            Family::SansSerif,
+            Family::Serif,
+            Family::Monospace,
+            Family::Cursive,
+            Family::Fantasy,
+        ];
 
-        db.load_font_data(IBM_PLEX_REGULAR.to_vec());
-        db.load_font_data(LILEX_REGULAR.to_vec());
+        for family in families {
+            let query = Query {
+                families: &[family],
+                ..Default::default()
+            };
+            assert!(
+                db.query(&query).is_some(),
+                "Expected generic family {family:?} to resolve after fix_generic_font_families"
+            );
+        }
+    }
+
+    #[test]
+    fn test_select_emoji_font_skips_family_without_glyph() {
+        let mut db = db_with_bundled_fonts();
 
         let ibm_plex_sans = db
             .query(&usvg::fontdb::Query {
@@ -294,4 +389,22 @@ mod tests {
         assert!(!font_has_char(&db, ibm_plex_sans, '│'));
         assert!(font_has_char(&db, selected, '│'));
     }
+
+    #[test]
+    fn fix_generic_font_families_monospace_resolves_to_lilex() {
+        let mut db = db_with_bundled_fonts();
+        fix_generic_font_families(&mut db);
+
+        let query = Query {
+            families: &[Family::Monospace],
+            ..Default::default()
+        };
+        let id = db.query(&query).expect("Monospace should resolve");
+        let face = db.face(id).expect("Face should exist");
+        assert!(
+            face.families.iter().any(|(name, _)| name.contains("Lilex")),
+            "Monospace should map to Lilex, got {:?}",
+            face.families
+        );
+    }
 }