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("") else { break };
+ rest = after;
+ // No element is inside these: text with a tag in it isn't a path.
+ if text.contains('<') {
+ continue;
+ }
+ let path = text.trim().replace("&", "&").replace("<", "<").replace(">", ">").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
+ ["", "<", "", "", "", "