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 <dir> 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 <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 21:16:23 +03:00
co-authored by Cursor
parent 457406a7a5
commit eec67560df
4 changed files with 168 additions and 0 deletions
+25
View File
@@ -13,6 +13,8 @@ use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
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<Value> {
"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<Value> {
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<Value> {
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()}))
}
+10
View File
@@ -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}");
}
+127
View File
@@ -488,10 +488,87 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
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
}
/// `<data dir>/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<std::path::PathBuf> {
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<std::path::PathBuf> {
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 `<dir>` 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<std::path::PathBuf> {
let mut v = vec![];
let mut rest = text;
while let Some(start) = rest.find("<dir") {
let after = &rest[start + 4..];
// `<dir>` or `<dir …>`, not `<dirname>`.
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("</dir>") 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#"<?xml version="1.0"?>
<fontconfig>
<dir>/usr/share/fonts</dir>
<dir prefix="xdg">fonts</dir>
<dir>~/.fonts</dir>
<dir>relative/fonts</dir>
<cachedir>/var/cache/fontconfig</cachedir>
<dirname>ignored</dirname>
<dir>/nix/store/abc-fonts/share/fonts</dir>
</fontconfig>"#;
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("<dir>/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());
}
}
+6
View File
@@ -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| {