Skip to main content

mathtex_engine/
texmf.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use crate::resource::{Resource, ResourceError, ResourceKind, ResourceProvider, ResourceRequest};
7
8/// Environment variable naming a `texmf-dist` root, read by [`TexmfResources::discover`].
9pub const TEXMF_ROOT_ENV: &str = "MATHTEX_TEXMF_ROOT";
10
11/// Search tree of a file, each with its `texmf.cnf` path prefixes in priority order.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13enum SearchPath {
14    Tex,
15    Tfm,
16    OpenType,
17    Enc,
18    Map,
19}
20
21impl SearchPath {
22    /// Relative directory prefixes, highest priority first, each matching its whole subtree.
23    fn prefixes(self) -> &'static [&'static str] {
24        match self {
25            // XeLaTeX's TEXINPUTS order, the whole tex tree comes after the narrower roots.
26            Self::Tex => &[
27                "tex/xelatex",
28                "tex/latex",
29                "tex/xetex",
30                "tex/generic",
31                "tex",
32            ],
33            Self::Tfm => &["fonts/tfm"],
34            Self::OpenType => &["fonts/opentype", "fonts/truetype"],
35            Self::Enc => &["fonts/enc"],
36            Self::Map => &["fonts/map"],
37        }
38    }
39
40    /// The tree a request searches, font kinds by extension and other kinds refined by extension.
41    fn for_request(kind: ResourceKind, filename: &str) -> Self {
42        let lower = filename.to_ascii_lowercase();
43        let is_font_program = [".otf", ".ttf", ".otc"]
44            .iter()
45            .any(|ext| lower.ends_with(ext));
46        match kind {
47            ResourceKind::Encoding => Self::Enc,
48            ResourceKind::Map => Self::Map,
49            ResourceKind::Font if is_font_program => Self::OpenType,
50            ResourceKind::Font => Self::Tfm,
51            _ if lower.ends_with(".enc") => Self::Enc,
52            _ if lower.ends_with(".map") => Self::Map,
53            _ if lower.ends_with(".tfm") => Self::Tfm,
54            _ if is_font_program => Self::OpenType,
55            _ => Self::Tex,
56        }
57    }
58}
59
60/// Resource provider over a TeX Live `texmf-dist` tree by exact file name, through its `ls-R` index.
61#[derive(Clone, Debug)]
62pub struct TexmfResources {
63    root: PathBuf,
64    /// Basename to the relative directories holding it, in `ls-R` order.
65    index: Arc<HashMap<String, Vec<String>>>,
66}
67
68impl TexmfResources {
69    /// Reads the `ls-R` index of `root`, a `texmf-dist` directory.
70    pub fn from_root(root: impl Into<PathBuf>) -> Result<Self, TexmfError> {
71        let root = root.into();
72        let index_path = root.join("ls-R");
73        let bytes = std::fs::read(&index_path).map_err(|error| TexmfError::Index {
74            path: index_path.clone(),
75            message: error.to_string(),
76        })?;
77        let text = String::from_utf8_lossy(&bytes);
78        let mut index: HashMap<String, Vec<String>> = HashMap::new();
79        let mut dir = String::new();
80        for line in text.lines() {
81            let line = line.trim_end();
82            if line.is_empty() || line.starts_with('%') {
83                continue;
84            }
85            if let Some(header) = line.strip_suffix(':') {
86                dir = header.strip_prefix("./").unwrap_or(header).to_string();
87                continue;
88            }
89            index.entry(line.to_string()).or_default().push(dir.clone());
90        }
91        if index.is_empty() {
92            return Err(TexmfError::EmptyIndex { path: index_path });
93        }
94        Ok(Self {
95            root,
96            index: Arc::new(index),
97        })
98    }
99
100    /// Finds the tree through `MATHTEX_TEXMF_ROOT`, else `kpsewhich -var-value TEXMFDIST`.
101    pub fn discover() -> Result<Self, TexmfError> {
102        if let Some(root) = std::env::var_os(TEXMF_ROOT_ENV) {
103            return Self::from_root(root);
104        }
105        let output = std::process::Command::new("kpsewhich")
106            .args(["-var-value", "TEXMFDIST"])
107            .output()
108            .map_err(|error| TexmfError::NoRoot {
109                message: format!("{TEXMF_ROOT_ENV} is unset and kpsewhich did not run: {error}"),
110            })?;
111        let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
112        if !output.status.success() || root.is_empty() {
113            return Err(TexmfError::NoRoot {
114                message: format!("{TEXMF_ROOT_ENV} is unset and kpsewhich knows no TEXMFDIST"),
115            });
116        }
117        Self::from_root(root)
118    }
119
120    /// The tree's root directory.
121    #[must_use]
122    pub fn root(&self) -> &Path {
123        &self.root
124    }
125
126    /// Number of distinct file names in the index.
127    #[must_use]
128    pub fn len(&self) -> usize {
129        self.index.len()
130    }
131
132    /// Whether the index holds no file names, never true for a provider that was built.
133    #[must_use]
134    pub fn is_empty(&self) -> bool {
135        self.index.is_empty()
136    }
137
138    /// The file a request names, found in the highest priority directory of its search path.
139    #[must_use]
140    pub fn resolve(&self, request: &ResourceRequest) -> Option<PathBuf> {
141        let name = basename(&request.canonical_name());
142        let dirs = self.index.get(&name)?;
143        for prefix in SearchPath::for_request(request.kind, &name).prefixes() {
144            for dir in dirs {
145                let under = dir
146                    .strip_prefix(prefix)
147                    .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
148                if under {
149                    return Some(self.root.join(dir).join(&name));
150                }
151            }
152        }
153        None
154    }
155}
156
157/// The file name TeX means, without `./`, XeTeX's `[]` and `:` prefixes, brackets, quotes and directories.
158fn basename(name: &str) -> String {
159    let mut name = name.trim();
160    loop {
161        if let Some(rest) = name.strip_prefix("./").or_else(|| name.strip_prefix("[]")) {
162            name = rest;
163        } else if let Some(rest) = name.strip_prefix(':') {
164            name = rest;
165        } else {
166            break;
167        }
168    }
169    let name = name.trim_matches(|c| matches!(c, '[' | ']' | '"' | '\''));
170    name.rsplit(['/', '\\']).next().unwrap_or(name).to_string()
171}
172
173impl ResourceProvider for TexmfResources {
174    fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
175        let path = self
176            .resolve(request)
177            .ok_or_else(|| ResourceError::not_found(request))?;
178        std::fs::read(&path)
179            .map(|bytes| Resource::answering(request, bytes))
180            .map_err(|error| ResourceError::from_io(request, &error))
181    }
182}
183
184/// Why a TeX Live tree could not be opened.
185#[derive(Clone, Debug, PartialEq, Eq)]
186#[non_exhaustive]
187pub enum TexmfError {
188    /// No root was configured or discoverable.
189    NoRoot {
190        /// What was tried.
191        message: String,
192    },
193    /// The `ls-R` index could not be read, `mktexlsr` writes it.
194    Index {
195        /// Path of the index.
196        path: PathBuf,
197        /// The read failure.
198        message: String,
199    },
200    /// The `ls-R` index lists no files.
201    EmptyIndex {
202        /// Path of the index.
203        path: PathBuf,
204    },
205}
206
207impl fmt::Display for TexmfError {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        match self {
210            Self::NoRoot { message } => f.write_str(message),
211            Self::Index { path, message } => {
212                write!(f, "cannot read {}: {message}", path.display())
213            }
214            Self::EmptyIndex { path } => write!(f, "{} lists no files", path.display()),
215        }
216    }
217}
218
219impl std::error::Error for TexmfError {}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn tree() -> PathBuf {
226        let root = std::env::temp_dir().join(format!("mathtex-texmf-{}", std::process::id()));
227        for dir in ["tex/latex/base", "tex/plain/base", "fonts/tfm/public/cm"] {
228            std::fs::create_dir_all(root.join(dir)).expect("create tree");
229        }
230        std::fs::write(root.join("tex/latex/base/x.tex"), b"latex").expect("write");
231        std::fs::write(root.join("tex/plain/base/x.tex"), b"plain").expect("write");
232        std::fs::write(root.join("fonts/tfm/public/cm/cmr10.tfm"), b"tfm").expect("write");
233        let index = "% ls-R\n./tex/plain/base:\nx.tex\n\n./tex/latex/base:\nx.tex\n\n./fonts/tfm/public/cm:\ncmr10.tfm\n";
234        std::fs::write(root.join("ls-R"), index).expect("write index");
235        root
236    }
237
238    #[test]
239    fn requests_resolve_by_search_path_priority() {
240        let root = tree();
241        let texmf = TexmfResources::from_root(&root).expect("index");
242        assert_eq!(texmf.len(), 2);
243        // tex/latex outranks the generic tex tree although ls-R lists the plain copy first.
244        let latex = texmf
245            .read("./x.tex", ResourceKind::TexInput)
246            .expect("x.tex");
247        assert_eq!(latex.bytes, b"latex");
248        let tfm = texmf.read("cmr10.tfm", ResourceKind::Font).expect("tfm");
249        assert_eq!(tfm.bytes, b"tfm");
250        // Suffixes are the engine's job, a bare name does not resolve.
251        assert!(texmf.read("cmr10", ResourceKind::Font).is_err());
252        std::fs::remove_dir_all(root).expect("remove tree");
253    }
254
255    #[test]
256    fn a_root_without_an_index_says_why() {
257        let missing = std::env::temp_dir().join("mathtex-texmf-missing-root");
258        assert!(matches!(
259            TexmfResources::from_root(&missing),
260            Err(TexmfError::Index { .. })
261        ));
262    }
263}