From eec67560dfa67eb7f8ceed8d3aec7be5d3a94a71 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 21:16:23 +0300 Subject: [PATCH] Fonts: find fonts where Linux and Flatpak keep them; add and rescan commands (#277) The font menu lists every family the scan of the system font folders finds, but on Linux the scan only looked in /usr/share/fonts, /usr/local/share/fonts, ~/.fonts and ~/.local/share/fonts. Fonts under XDG data dirs, in folders fontconfig is configured with (NixOS, Guix, custom setups) and, inside Flatpak, the host's fonts never showed. - system_font_dirs adds $XDG_DATA_HOME/fonts and each $XDG_DATA_DIRS/fonts, Flatpak's /run/host/{fonts,local-fonts,user-fonts}, and the entries of /etc/fonts/fonts.conf, local.conf and conf.d; duplicates are dropped. - font.add {data} adds a font from its bytes for the session; font.rescan catalogs the installed fonts again, and the font menu has a "Rescan Installed Fonts" item for fonts installed while DeckCraft runs. Co-authored-by: Cursor --- crates/engine/src/cmd/format.rs | 25 +++++++ crates/engine/src/tests.rs | 10 +++ crates/fonts/src/fontdb.rs | 127 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 6 ++ 4 files changed, 168 insertions(+) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index c68d1242..86870206 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -13,6 +13,8 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ + cmd!(noundo "font.add", "Add Font", [], None, "{data: base64 TTF/OTF/TTC} → {faces} added for this session", always, font_add), + cmd!(noundo "font.rescan", "Rescan Installed Fonts", ["Home", "Font"], None, "{} → {faces, families} (fonts installed since start-up)", always, font_rescan), cmd!("format.bold", "Bold", ["Home", "Font"], Some("Cmd+B"), "{on?: bool}", has_text_or_shapes, bold), cmd!("format.italic", "Italic", ["Home", "Font"], Some("Cmd+I"), "{on?: bool}", has_text_or_shapes, italic), cmd!( @@ -682,3 +684,26 @@ fn state(s: &mut Session, _p: &Value) -> Result { "painter": s.painter.is_some(), })) } + +/// Add a font from its bytes for this session (it then shows in the font menu and draws). +fn font_add(_s: &mut Session, p: &Value) -> Result { + let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("font.add", "missing or invalid base64 `data`"))?; + if data.len() > 64 << 20 { + return Err(bad("font.add", "font file larger than 64 MB")); + } + let faces = deckcraft_fonts::FontDb::global().add_font(data); + if faces == 0 { + return Err(bad("font.add", "not a font DeckCraft can read (or already added)")); + } + Ok(json!({"faces": faces})) +} + +/// Catalog the installed fonts again, so fonts installed while DeckCraft runs appear. +fn font_rescan(_s: &mut Session, _p: &Value) -> Result { + let db = deckcraft_fonts::FontDb::global(); + #[cfg(not(target_arch = "wasm32"))] + let faces = db.load_system_fonts(); + #[cfg(target_arch = "wasm32")] + let faces = 0usize; + Ok(json!({"faces": faces, "families": db.families().len()})) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..15f4df41 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,13 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn font_add_refuses_non_fonts_and_rescan_reports_the_catalog() { + let mut s = session(); + let not_font = crate::cmd::base64_encode(b"definitely not a font file"); + assert!(s.execute("font.add", &json!({"data": not_font})).is_err()); + assert!(s.execute("font.add", &json!({})).is_err()); + let r = s.execute("font.rescan", &json!({})).unwrap(); + assert!(r["families"].as_u64().unwrap() >= 1, "{r}"); +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 5425c022..af04dfcb 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -488,10 +488,87 @@ pub fn system_font_dirs() -> Vec { dirs.push(h.join(".fonts")); dirs.push(h.join(".local/share/fonts")); } + let env = |k: &str| std::env::var_os(k).map(|v| v.to_string_lossy().into_owned()); + dirs.extend(xdg_font_dirs(env("XDG_DATA_HOME").as_deref(), env("XDG_DATA_DIRS").as_deref(), home.as_deref())); + // Inside Flatpak the host's fonts are mounted here. + dirs.extend(["/run/host/fonts", "/run/host/local-fonts", "/run/host/user-fonts"].map(Into::into)); + // Folders fontconfig is configured with (NixOS, Guix, custom setups). + for conf in fontconfig_files() { + if let Ok(text) = std::fs::read_to_string(&conf) { + dirs.extend(fontconfig_dirs(&text, env("XDG_DATA_HOME").as_deref(), home.as_deref())); + } + } } + let mut seen = std::collections::HashSet::new(); + dirs.retain(|d| seen.insert(d.clone())); dirs } +/// `/fonts` for `XDG_DATA_HOME` (default `~/.local/share`) and each `XDG_DATA_DIRS` +/// entry (default `/usr/local/share:/usr/share`). +fn xdg_font_dirs(data_home: Option<&str>, data_dirs: Option<&str>, home: Option<&std::path::Path>) -> Vec { + let mut v = vec![]; + match data_home.filter(|d| !d.is_empty()) { + Some(d) => v.push(std::path::Path::new(d).join("fonts")), + None => { + if let Some(h) = home { + v.push(h.join(".local/share/fonts")); + } + } + } + let dirs = data_dirs.filter(|d| !d.is_empty()).unwrap_or("/usr/local/share:/usr/share"); + v.extend(dirs.split(':').filter(|d| d.starts_with('/')).take(64).map(|d| std::path::Path::new(d).join("fonts"))); + v +} + +/// The fontconfig configuration files: `/etc/fonts/fonts.conf` and `/etc/fonts/conf.d/*.conf`. +fn fontconfig_files() -> Vec { + let mut v = vec![std::path::PathBuf::from("/etc/fonts/fonts.conf"), std::path::PathBuf::from("/etc/fonts/local.conf")]; + if let Ok(entries) = std::fs::read_dir("/etc/fonts/conf.d") { + let mut files: Vec<_> = entries.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "conf")).collect(); + files.sort(); + files.truncate(512); + v.extend(files); + } + v +} + +/// The `` folders of a fontconfig file (`prefix="xdg"` is under `XDG_DATA_HOME`, a leading +/// `~` under home; relative and `cachedir` entries are skipped). +fn fontconfig_dirs(text: &str, data_home: Option<&str>, home: Option<&std::path::Path>) -> Vec { + let mut v = vec![]; + let mut rest = text; + while let Some(start) = rest.find("` or ``, not ``. + if !(after.starts_with('>') || after.starts_with(' ')) { + rest = after; + continue; + } + let Some(gt) = after.find('>') else { break }; + let attrs = &after[..gt]; + let body = &after[gt + 1..]; + let Some(end) = body.find("") else { break }; + let path = body[..end].trim(); + rest = &body[end..]; + if attrs.trim_end().ends_with('/') || path.is_empty() || v.len() >= 64 { + continue; + } + let p = if attrs.contains("prefix=\"xdg\"") { + let base = data_home.filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| home.map(|h| h.join(".local/share"))); + base.map(|b| b.join(path)) + } else if let Some(r) = path.strip_prefix("~/") { + home.map(|h| h.join(r)) + } else if path.starts_with('/') { + Some(std::path::PathBuf::from(path)) + } else { + None + }; + v.extend(p); + } + v +} + /// One face found in a font file: index, family, style and (variable fonts) the named instance's /// axis settings. type Found = (u32, String, String, Vec<([u8; 4], f32)>); @@ -1241,3 +1318,53 @@ impl OutlinePen for FlipPen { self.0.close_path(); } } + +#[cfg(test)] +mod font_dir_tests { + use std::path::{Path, PathBuf}; + + use super::*; + + #[test] + fn xdg_data_dirs_contribute_their_fonts_folders() { + let home = Path::new("/home/ann"); + assert_eq!( + xdg_font_dirs(None, None, Some(home)), + [home.join(".local/share/fonts"), "/usr/local/share/fonts".into(), "/usr/share/fonts".into()] + ); + let v = xdg_font_dirs(Some("/data/ann"), Some("/nix/profile/share:relative:/var/lib/flatpak/exports/share"), Some(home)); + assert_eq!(v, [PathBuf::from("/data/ann/fonts"), "/nix/profile/share/fonts".into(), "/var/lib/flatpak/exports/share/fonts".into()]); + } + + #[test] + fn fontconfig_dir_entries_are_read() { + let conf = r#" + + /usr/share/fonts + fonts + ~/.fonts + relative/fonts + /var/cache/fontconfig + ignored + /nix/store/abc-fonts/share/fonts +"#; + let home = Path::new("/home/ann"); + let v = fontconfig_dirs(conf, None, Some(home)); + assert_eq!( + v, + [PathBuf::from("/usr/share/fonts"), home.join(".local/share/fonts"), home.join(".fonts"), "/nix/store/abc-fonts/share/fonts".into()] + ); + assert!(fontconfig_dirs("/never closed", None, Some(home)).is_empty()); + } + + #[test] + #[cfg(target_os = "linux")] + fn linux_dirs_include_flatpak_host_fonts_without_duplicates() { + let d = system_font_dirs(); + assert!(d.contains(&PathBuf::from("/run/host/user-fonts"))); + let mut u = d.clone(); + u.sort(); + u.dedup(); + assert_eq!(u.len(), d.len()); + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..c13c1a3d 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -437,6 +437,12 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { ui.separator(); } } + ui.separator(); + // Fonts installed while DeckCraft runs (e.g. into ~/.local/share/fonts). + if ui.button("Rescan Installed Fonts").clicked() { + run(app, "font.rescan", json!({})); + ui.close(); + } }); let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default(); dropdown(ui, "size", &size_text, 52.0, en, |ui| {