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!("{name}>");
+ 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 1df8b337..4091344d 100644
--- a/crates/text/src/fonts.rs
+++ b/crates/text/src/fonts.rs
@@ -89,13 +89,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 }
}
@@ -112,7 +110,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 590d0619..928ddd26 100644
--- a/docs/agents.md
+++ b/docs/agents.md
@@ -499,6 +499,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