From b58197ff82967a3a57e2f5d557163dc7bb9325da Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 18:21:20 +0200 Subject: [PATCH] #874: scan Flatpak host, XDG and fontconfig font folders on Linux Problem: on Linux the font list only scanned /usr/share/fonts, /usr/local/share/fonts, ~/.local/share/fonts and ~/.fonts. The ArtCraft Launcher installs EffectCraft as a Flatpak, where those folders hold only the runtime's fonts and the host's fonts live under /run/host, so installed fonts were missing (25 families instead of 320 on the reporter's setup). $XDG_DATA_HOME/fonts, $XDG_DATA_DIRS/*/fonts and fontconfig entries were never scanned either. Fix: a new font_dirs module lists the usual folders, the Flatpak host folders (/run/host/fonts, local-fonts, user-fonts), the XDG data folders and the / entries of the fontconfig configuration (FONTCONFIG_FILE or /etc/fonts/fonts.conf, /usr/local/etc/fonts/fonts.conf, and ~/.config/fontconfig), following s with prefix="xdg", "relative" and ~ expansion. It uses a small tag scanner (no new dependency), bounds the include depth, the number and size of files read, and reads each file once. Folders are deduplicated before the walk, and the walk depth goes from 4 to 8. docs/agents.md lists the folders and how to grant the Flatpak an extra one. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/font_dirs.rs | 374 +++++++++++++++++++++++++++++++++++ crates/text/src/fonts.rs | 13 +- crates/text/src/lib.rs | 2 + docs/agents.md | 6 + 4 files changed, 388 insertions(+), 7 deletions(-) create mode 100644 crates/text/src/font_dirs.rs diff --git a/crates/text/src/font_dirs.rs b/crates/text/src/font_dirs.rs new file mode 100644 index 00000000..39714cad --- /dev/null +++ b/crates/text/src/font_dirs.rs @@ -0,0 +1,374 @@ +//! The font folders of a Linux/BSD system, found the way the desktop finds them: the usual +//! folders, the XDG data folders (`$XDG_DATA_HOME/fonts`, `$XDG_DATA_DIRS/*/fonts`), the host font +//! folders a Flatpak sandbox exposes under `/run/host`, and the `` entries of the fontconfig +//! configuration (`/etc/fonts/fonts.conf` with its includes, `~/.config/fontconfig/fonts.conf`). +//! +//! The fontconfig files are read with a small tag scanner (no fontconfig library, no XML crate): +//! only ``, `` and `` matter here. The work is bounded: include depth, +//! number and size of the files read, and every file is read at most once. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +/// The environment the folders depend on, passed in so tests don't touch the process environment. +#[derive(Clone, Debug, Default)] +pub(crate) struct FontEnv { + pub home: Option, + pub xdg_data_home: Option, + pub xdg_data_dirs: Option, + pub xdg_config_home: Option, + /// `FONTCONFIG_FILE`: the main configuration file instead of the standard ones. + pub fontconfig_file: Option, + /// The main configuration files tried when `FONTCONFIG_FILE` is unset (the first that exists). + pub fontconfig_roots: Vec, + /// Folders scanned if they exist (the usual system folders and the Flatpak host folders). + pub fixed: Vec, +} + +impl FontEnv { + pub fn from_process() -> Self { + let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()); + Self { + home: var("HOME").map(PathBuf::from), + xdg_data_home: var("XDG_DATA_HOME").map(PathBuf::from), + xdg_data_dirs: var("XDG_DATA_DIRS"), + xdg_config_home: var("XDG_CONFIG_HOME").map(PathBuf::from), + fontconfig_file: var("FONTCONFIG_FILE").map(PathBuf::from), + fontconfig_roots: ["/etc/fonts/fonts.conf", "/usr/local/etc/fonts/fonts.conf"].map(PathBuf::from).to_vec(), + fixed: [ + "/usr/share/fonts", + "/usr/local/share/fonts", + // Flatpak: the host's system, local and user fonts (no filesystem permission needed). + "/run/host/fonts", + "/run/host/local-fonts", + "/run/host/user-fonts", + ] + .map(PathBuf::from) + .to_vec(), + } + } + + fn data_home(&self) -> Option { + self.xdg_data_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".local/share"))) + } + + fn config_home(&self) -> Option { + self.xdg_config_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".config"))) + } +} + +/// Every font folder for `env`, without duplicates, in a stable order. Folders that don't exist +/// are kept (the scan skips them). +pub(crate) fn unix_font_dirs(env: &FontEnv) -> Vec { + let mut dirs = env.fixed.clone(); + if let Some(h) = &env.home { + dirs.push(h.join(".local/share/fonts")); + dirs.push(h.join(".fonts")); + } + if let Some(d) = env.data_home() { + dirs.push(d.join("fonts")); + } + let data_dirs = env.xdg_data_dirs.clone().unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + dirs.extend(std::env::split_paths(&data_dirs).filter(|p| p.is_absolute()).map(|p| p.join("fonts"))); + dirs.extend(fontconfig_dirs(env)); + dedup_dirs(dirs) +} + +/// Drop repeated folders (also the same folder reached through a symlink), keeping the first. +fn dedup_dirs(dirs: Vec) -> Vec { + let mut seen = HashSet::new(); + dirs.into_iter().filter(|d| seen.insert(std::fs::canonicalize(d).unwrap_or_else(|_| d.clone()))).collect() +} + +const MAX_INCLUDE_DEPTH: u32 = 8; +const MAX_FILES: usize = 512; +const MAX_FILE_BYTES: u64 = 1 << 20; + +/// The font folders named by the fontconfig configuration. +pub(crate) fn fontconfig_dirs(env: &FontEnv) -> Vec { + let mut st = Reader { env, out: Vec::new(), seen: HashSet::new() }; + let root = match &env.fontconfig_file { + Some(f) => Some(f.clone()), + None => env.fontconfig_roots.iter().find(|p| p.is_file()).cloned(), + }; + if let Some(root) = root { + st.file(&root, 0); + } + // The user's own configuration, in case the system one doesn't include it. + if let Some(c) = env.config_home() { + st.include(&c.join("fontconfig/fonts.conf"), 0); + st.include(&c.join("fontconfig/conf.d"), 0); + } + st.out +} + +struct Reader<'a> { + env: &'a FontEnv, + out: Vec, + seen: HashSet, +} + +impl Reader<'_> { + /// Read a configuration file or, for a folder, its `NN-name.conf` files in name order. + fn include(&mut self, path: &Path, depth: u32) { + if path.is_dir() { + let Ok(rd) = std::fs::read_dir(path) else { return }; + let mut files: Vec = rd + .flatten() + .map(|e| e.path()) + .filter(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(".conf") && n.starts_with(|c: char| c.is_ascii_digit()))) + .collect(); + files.sort(); + for f in files { + self.file(&f, depth); + } + } else { + self.file(path, depth); + } + } + + fn file(&mut self, path: &Path, depth: u32) { + if depth > MAX_INCLUDE_DEPTH || self.seen.len() >= MAX_FILES || !path.is_file() { + return; + } + let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + if !self.seen.insert(key) { + return; + } + if std::fs::metadata(path).map(|m| m.len() > MAX_FILE_BYTES).unwrap_or(true) { + return; + } + let Ok(text) = std::fs::read_to_string(path) else { return }; + let base = path.parent().map(Path::to_path_buf).unwrap_or_default(); + for el in elements(&text) { + match el.name { + "dir" | "remap-dir" => { + if let Some(p) = self.resolve(&el, &base, false) { + self.out.push(p); + } + } + "include" => { + if let Some(p) = self.resolve(&el, &base, true) { + self.include(&p, depth.saturating_add(1)); + } + } + _ => {} + } + } + } + + /// The path an element names: `~` is the home folder, `prefix="xdg"` the XDG data (folders) + /// or config (includes) home, `prefix="relative"` and plain relative paths the including + /// file's folder, `prefix="default"`/`"cwd"` the working folder. + fn resolve(&self, el: &Element, base: &Path, include: bool) -> Option { + let v = el.text.trim(); + if v.is_empty() { + return None; + } + if let Some(rest) = v.strip_prefix('~') { + return Some(self.env.home.as_ref()?.join(rest.trim_start_matches('/'))); + } + let p = Path::new(v); + if p.is_absolute() { + return Some(p.to_path_buf()); + } + match el.attr("prefix") { + Some("xdg") => Some(if include { self.env.config_home()? } else { self.env.data_home()? }.join(p)), + Some("default" | "cwd") => std::env::current_dir().ok().map(|c| c.join(p)), + _ => Some(base.join(p)), + } + } +} + +struct Element<'a> { + name: &'a str, + attrs: &'a str, + text: String, +} + +impl Element<'_> { + fn attr(&self, key: &str) -> Option<&str> { + let mut rest = self.attrs; + while let Some(eq) = rest.find('=') { + let k = rest.get(..eq)?.trim(); + let after = rest.get(eq + 1..)?.trim_start(); + let q = after.chars().next()?; + if q != '"' && q != '\'' { + return None; + } + let body = after.get(1..)?; + let end = body.find(q)?; + if k == key { + return body.get(..end); + } + rest = body.get(end + 1..)?; + } + None + } +} + +/// The ``, `` and `` elements of a fontconfig file, comments skipped. +fn elements(src: &str) -> Vec> { + let mut out = Vec::new(); + let mut i = 0; + while let Some(off) = src.get(i..).and_then(|s| s.find('<')) { + let start = i + off; + let Some(tail) = src.get(start..) else { break }; + if tail.starts_with("") { + Some(e) => { + i = start + e + 3; + continue; + } + None => break, + } + } + let Some(close) = tail.find('>') else { break }; + let inner = tail.get(1..close).unwrap_or_default(); + i = start + close + 1; + let name_len = inner.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(inner.len()); + let name = inner.get(..name_len).unwrap_or_default(); + if !matches!(name, "dir" | "remap-dir" | "include") || inner.ends_with('/') { + continue; + } + let attrs = inner.get(name_len..).unwrap_or_default(); + let end_tag = format!(""); + let Some(body_len) = src.get(i..).and_then(|s| s.find(&end_tag)) else { break }; + let body = src.get(i..i + body_len).unwrap_or_default(); + i += body_len + end_tag.len(); + out.push(Element { name, attrs, text: decode_entities(body) }); + } + out +} + +fn decode_entities(s: &str) -> String { + s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("ec-font-dirs-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + fn env_in(root: &Path) -> FontEnv { + FontEnv { + home: Some(root.join("home")), + fontconfig_roots: vec![root.join("etc/fonts/fonts.conf")], + xdg_data_dirs: Some("/nonexistent-share".into()), + ..Default::default() + } + } + + fn write(p: &Path, s: &str) { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, s).unwrap(); + } + + #[test] + fn fontconfig_dirs_follow_includes_prefixes_and_skip_the_rest() { + let root = temp("conf"); + let etc = root.join("etc/fonts"); + write( + &etc.join("fonts.conf"), + r#" + + + + /sys/fonts + fonts + ~/.fonts + /var/cache/fontconfig + fontconfig + local-fonts + /a&b/fonts + conf.d + missing.conf + fontconfig/fonts.conf + fonts.conf +"#, + ); + write( + &etc.join("conf.d/50-flatpak.conf"), + "/run/host/fonts/run/host/font-dirs.xml", + ); + write(&etc.join("conf.d/60-remap.conf"), "/remapped/fonts"); + write(&etc.join("conf.d/README"), "/not/a/conf/file"); + write(&etc.join("conf.d/nodigit.conf"), "/no/digit"); + write(&root.join("home/.config/fontconfig/fonts.conf"), "/user/conf/fonts"); + let dirs = fontconfig_dirs(&env_in(&root)); + let home = root.join("home"); + let want = vec![ + PathBuf::from("/sys/fonts"), + home.join(".local/share/fonts"), + home.join(".fonts"), + etc.join("local-fonts"), + PathBuf::from("/a&b/fonts"), + PathBuf::from("/run/host/fonts"), + PathBuf::from("/remapped/fonts"), + PathBuf::from("/user/conf/fonts"), + ]; + assert_eq!(dirs, want); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn include_cycles_and_unclosed_tags_terminate() { + let root = temp("cycle"); + let etc = root.join("etc/fonts"); + write(&etc.join("fonts.conf"), "/oneb.conffonts.conf"); + write(&etc.join("b.conf"), "fonts.conf/two/unclosed"); + let dirs = fontconfig_dirs(&env_in(&root)); + assert_eq!(dirs, vec![PathBuf::from("/one"), PathBuf::from("/two")]); + assert!(fontconfig_dirs(&FontEnv::default()).is_empty()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn xdg_and_flatpak_folders_are_listed_once() { + let root = temp("xdg"); + let env = FontEnv { + home: Some(root.join("home")), + xdg_data_home: Some(root.join("data")), + xdg_data_dirs: Some(std::env::join_paths([root.join("share"), "relative/ignored".into()]).unwrap()), + fixed: vec![PathBuf::from("/run/host/fonts")], + fontconfig_file: Some(root.join("fonts.conf")), + ..Default::default() + }; + write(&root.join("fonts.conf"), "/run/host/fontsfonts"); + let dirs = unix_font_dirs(&env); + assert_eq!( + dirs, + vec![ + PathBuf::from("/run/host/fonts"), + root.join("home/.local/share/fonts"), + root.join("home/.fonts"), + root.join("data/fonts"), + root.join("share/fonts"), + ] + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// A font in a folder that only the fontconfig configuration names (as in a Flatpak sandbox, + /// where the host's fonts are under /run/host) is found by the scan. + #[test] + fn a_font_named_only_by_fontconfig_is_scanned() { + use crate::fonts::FontSource; + let root = temp("e2e"); + let host = root.join("run/host/fonts/TTF"); + std::fs::create_dir_all(&host).unwrap(); + std::fs::write(host.join("NOTOSERIF.TTF"), crate::fonts::NOTO_SERIF_REGULAR).unwrap(); + write(&root.join("etc/fonts/conf.d/50-flatpak.conf"), &format!("{}", root.join("run/host/fonts").display())); + write(&root.join("etc/fonts/fonts.conf"), "conf.d"); + let src = crate::fonts::DirectorySource { dirs: unix_font_dirs(&env_in(&root)) }; + let faces = src.faces(); + assert!(faces.iter().any(|f| f.family == "Noto Serif" && f.origin == "system"), "{:?}", faces.iter().map(|f| &f.family).collect::>()); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 2f7311c5..44cbb52e 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -87,13 +87,11 @@ impl DirectorySource { if let Some(l) = std::env::var_os("LOCALAPPDATA") { dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); } - } else if cfg!(unix) && !cfg!(target_arch = "wasm32") { - dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(PathBuf::from)); - if let Some(h) = &home { - dirs.push(h.join(".local/share/fonts")); - dirs.push(h.join(".fonts")); - } } + // Linux/BSD: the usual folders, the XDG data folders, a Flatpak sandbox's host font + // folders (/run/host/…) and the folders the fontconfig configuration names. + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + dirs.extend(crate::font_dirs::unix_font_dirs(&crate::font_dirs::FontEnv::from_process())); dirs.extend(extra_dirs(std::env::var_os("EFFECTCRAFT_FONT_DIRS").as_deref())); Self { dirs } } @@ -110,7 +108,8 @@ fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec) { for e in rd.flatten() { let p = e.path(); if p.is_dir() { - if depth < 4 { + // Bounded, so a symlink loop ends. + if depth < 8 { walk(&p, depth + 1, out); } } else if p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 21bb12b1..8b7ca771 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -7,6 +7,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +mod font_dirs; pub mod fonts; pub mod layout; pub mod path_text; diff --git a/docs/agents.md b/docs/agents.md index 6a3b9e85..7d21e1ce 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -497,6 +497,12 @@ one of its `styles` to `layer.setText {"font", "style"}`; `text.fontFeatures` sa options a font draws with its own glyphs. A tool with its own font library can hand it over without installing it: folders listed in `EFFECTCRAFT_FONT_DIRS` (a path list, `:`-separated, `;` on Windows) are scanned like the system font folders. +On Linux and the BSDs the system font folders are `/usr/share/fonts`, `/usr/local/share/fonts`, +`~/.local/share/fonts`, `~/.fonts`, `$XDG_DATA_HOME/fonts`, `fonts` in each `$XDG_DATA_DIRS` folder, +every `` the fontconfig configuration names (`/etc/fonts/fonts.conf` with its includes, +`~/.config/fontconfig/fonts.conf`) and, in the Flatpak, the host's fonts under `/run/host`. The +Flatpak can't see other folders; grant one with +`flatpak override --user --filesystem=:ro --env=EFFECTCRAFT_FONT_DIRS= ai.storyteller.effectcraft`. In expressions, `text.sourceText.style` / `getStyleAt(i, t)` read styles and the setters (`setFontSize(v, start?, count?)`, `setFillColor`, `setText`, `setJustification`…) return a