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 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