From 3a88ad085577c247389056c796c75224a84fa108 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 06:12:49 +0700 Subject: [PATCH] Fix #1529: Linux reads the font folders fontconfig is set up with The font scan read /usr/share/fonts, /usr/local/share/fonts, ~/.fonts and the XDG data folders. Packages add folders outside those to fontconfig's configuration (TeX Gyre under /usr/share/texmf/fonts), so fc-list and other apps showed fonts VectorCraft didn't have. The scan now also reads the folders of /etc/fonts/fonts.conf and the files it includes (conf.d, the user's own), as the fontconfig documentation describes them. The files are read as they are: no library is linked and no program is run. --- crates/text/src/fontconfig.rs | 253 ++++++++++++++++++++++++++++++++++ crates/text/src/fontdb.rs | 11 +- crates/text/src/lib.rs | 1 + docs/mcp.md | 5 +- 4 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 crates/text/src/fontconfig.rs diff --git a/crates/text/src/fontconfig.rs b/crates/text/src/fontconfig.rs new file mode 100644 index 00000000..61a69f9e --- /dev/null +++ b/crates/text/src/fontconfig.rs @@ -0,0 +1,253 @@ +//! The font folders fontconfig is set up with (Linux and the other Unix desktops): the `` +//! entries of its configuration, `/etc/fonts/fonts.conf` and what that includes (`conf.d`, the +//! user's own files). Distributions and packages add folders outside the usual ones there: TeX +//! fonts under `/usr/share/texmf/fonts`, say, which `fc-list` and other apps then show (#1529). +//! +//! The format is fontconfig's documented one +//! (, "Configuration file +//! format"): `` and `` are all that is read here. The files are +//! read as they are: fontconfig itself (a library, or `fc-list`) isn't asked. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +/// The most configuration files read, and the largest one (they are a few kilobytes each). +const MAX_FILES: usize = 512; +const MAX_FILE_BYTES: u64 = 1 << 20; +/// The most folders taken, and how deep files may include files. +const MAX_DIRS: usize = 256; +const MAX_DEPTH: usize = 8; + +/// A `` (a font folder) or an `` (another configuration file, or a folder of them) +/// with its `prefix` attribute and its text. +#[derive(Debug, PartialEq)] +struct Entry { + include: bool, + prefix: String, + path: String, +} + +/// The `` and `` elements of a configuration file, in order. Anything that isn't +/// one of them, comments included, is skipped; a file that isn't well formed gives what was read +/// before the damage. +fn entries(xml: &str) -> Vec { + let mut out = vec![]; + let mut rest = xml; + while let Some(at) = rest.find('<') { + rest = rest.get(at + 1..).unwrap_or_default(); + if let Some(comment) = rest.strip_prefix("!--") { + rest = comment.split_once("-->").map_or("", |(_, after)| after); + continue; + } + let Some((tag, after)) = rest.split_once('>') else { break }; + let name = tag.split_whitespace().next().unwrap_or_default(); + if !matches!(name, "dir" | "include") || tag.ends_with('/') { + continue; + } + let Some((text, after)) = after.split_once("").replace(""", "\"").replace("'", "'"); + if !path.is_empty() { + out.push(Entry { include: name == "include", prefix: attribute(tag, "prefix").unwrap_or_default().to_string(), path }); + } + } + out +} + +/// The value of attribute `name` in a start tag's text. +fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let mut rest = tag; + while let Some(at) = rest.find(name) { + let before_ok = rest.get(..at).is_some_and(|b| b.ends_with(char::is_whitespace)); + rest = rest.get(at + name.len()..)?; + let Some(value) = rest.trim_start().strip_prefix('=') else { continue }; + let value = value.trim_start(); + let quote = value.chars().next().filter(|q| matches!(q, '"' | '\''))?; + let (value, _) = value.get(1..)?.split_once(quote)?; + if before_ok { + return Some(value); + } + } + None +} + +/// Where the user's files are: the home folder and the XDG data and configuration folders. +#[derive(Clone, Debug, Default)] +pub(crate) struct Places { + pub home: Option, + pub data_home: Option, + pub config_home: Option, +} + +impl Places { + /// The path an entry of the file `from` names: `~` is the home folder, `prefix="xdg"` the XDG + /// data folder (a ``) or configuration folder (an ``), `prefix="relative"` the + /// file's own folder, as is an `` that isn't a full path. A `` relative to the + /// folder the app was started in is none. + fn resolve(&self, e: &Entry, from: &Path) -> Option { + // `~` alone or before a `/`: another user's home (`~name`) isn't looked up. + if let Some(rest) = e.path.strip_prefix('~').filter(|rest| rest.is_empty() || rest.starts_with('/')) { + return Some(self.home.as_ref()?.join(rest.trim_start_matches('/'))); + } + let path = Path::new(&e.path); + let base = match e.prefix.as_str() { + "xdg" if e.include => self.config_home.as_deref(), + "xdg" => self.data_home.as_deref(), + _ if path.is_absolute() => return Some(path.to_path_buf()), + "relative" => from.parent(), + _ if e.include => from.parent(), + _ => None, + }; + Some(base?.join(path)).filter(|p| p.is_absolute()) + } +} + +/// The font folders the configuration file `root` and the files it includes list, in order, each +/// once. Files that are missing or can't be read list none. +pub(crate) fn font_dirs(root: &Path, places: &Places) -> Vec { + let mut read = Reader { places, files: HashSet::new(), dirs: vec![] }; + read.file(root, 0); + read.dirs +} + +struct Reader<'a> { + places: &'a Places, + /// The files read so far (a file including itself is read once). + files: HashSet, + dirs: Vec, +} + +impl Reader<'_> { + fn file(&mut self, path: &Path, depth: usize) { + if depth > MAX_DEPTH || self.files.len() >= MAX_FILES || !self.files.insert(path.to_path_buf()) { + return; + } + // A folder of configuration files: those named as fontconfig reads them (a digit first, + // `.conf` last), in name order. + if let Ok(list) = std::fs::read_dir(path) { + let mut files: Vec = list + .flatten() + .take(MAX_FILES) + .filter(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + name.starts_with(|c: char| c.is_ascii_digit()) && name.ends_with(".conf") + }) + .map(|e| e.path()) + .collect(); + files.sort(); + for f in files { + self.file(&f, depth + 1); + } + return; + } + // A file that is missing, too large or not text lists nothing. + if !std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() <= MAX_FILE_BYTES) { + return; + } + let Ok(text) = std::fs::read_to_string(path) else { return }; + for e in entries(&text) { + let Some(to) = self.places.resolve(&e, path) else { continue }; + if e.include { + self.file(&to, depth + 1); + } else if self.dirs.len() < MAX_DIRS && !self.dirs.contains(&to) { + self.dirs.push(to); + } + } + } +} + +/// The configuration file fontconfig starts from: `FONTCONFIG_FILE`, else `fonts.conf` in +/// `FONTCONFIG_PATH`, else `/etc/fonts/fonts.conf`. +pub(crate) fn root() -> PathBuf { + let var = |name| std::env::var_os(name).map(PathBuf::from).filter(|p| p.is_absolute()); + var("FONTCONFIG_FILE").or_else(|| var("FONTCONFIG_PATH").map(|d| d.join("fonts.conf"))).unwrap_or_else(|| "/etc/fonts/fonts.conf".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(include: bool, prefix: &str, path: &str) -> Entry { + Entry { include, prefix: prefix.into(), path: path.into() } + } + + #[test] + fn dirs_and_includes_are_read_and_the_rest_is_skipped() { + let xml = r#" + + + Default configuration file + + /usr/share/fonts + fonts + more & more + ~/.fonts + + /var/cache/fontconfig + mono + conf.d + fontconfig/fonts.conf + /cut/off"#; + assert_eq!( + entries(xml), + [ + entry(false, "", "/usr/share/fonts"), + entry(false, "xdg", "fonts"), + entry(false, "relative", "more & more"), + entry(false, "", "~/.fonts"), + entry(true, "", "conf.d"), + entry(true, "xdg", "fontconfig/fonts.conf"), + ] + ); + // Damaged files and files that aren't XML read as nothing, without a panic. + for junk in + ["", "<", "", "", "", "