Skip to main content

sl_map_web/
fonts.rs

1//! Discovery of TrueType fonts under the configured
2//! [`crate::config::Config::fonts_directory`].
3//!
4//! Scanned once at startup. The render workers then resolve a
5//! client-supplied `font_id` (the filename basename) to a path under
6//! that directory and load it via [`sl_map_apis::text::load_font`]. Display
7//! names come from [`sl_map_apis::text::embedded_font_name`] (falling back to
8//! [`sl_map_apis::text::display_name_from_file_name`]). The library never
9//! bundles a font; every font the user can pick comes from this directory.
10
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13
14/// Public metadata for a single font discovered at startup. Sent to
15/// the browser by `GET /api/fonts`.
16#[derive(Debug, Clone, serde::Serialize)]
17pub struct FontInfo {
18    /// Filename basename, e.g. `DejaVuSans.ttf`. Used as the opaque
19    /// identifier in render-form requests. Never trust client input
20    /// against the filesystem directly — always resolve via
21    /// [`FontDirectory::path_for`].
22    pub id: String,
23    /// Display name. When the font's `name` table yields a usable name
24    /// it is the embedded name with the file id in parentheses
25    /// (`DejaVu Sans (DejaVuSans.ttf)`). Otherwise it falls back to the
26    /// file stem with hyphens and underscores replaced by spaces
27    /// (`noto-sans-mono.ttf` → `noto sans mono`).
28    pub name: String,
29}
30
31/// Index of available fonts. Built once at startup, then cloned into
32/// `AppState` as an `Arc<FontDirectory>`.
33#[derive(Debug)]
34pub struct FontDirectory {
35    /// Absolute path the directory was opened at. Kept for resolving
36    /// individual font paths and for diagnostic messages.
37    root: PathBuf,
38    /// `id` → discovered font. Sorted (BTreeMap) so iteration is
39    /// deterministic when populating the UI.
40    by_id: BTreeMap<String, FontEntry>,
41}
42
43/// One discovered font: its on-disk path plus the display label resolved
44/// once at scan time (see [`FontInfo::name`]).
45#[derive(Debug)]
46struct FontEntry {
47    /// absolute path to the `.ttf` file.
48    path: PathBuf,
49    /// pre-computed display label served by `GET /api/fonts`.
50    display_name: String,
51}
52
53/// Errors that can occur while scanning a fonts directory.
54#[derive(Debug, thiserror::Error)]
55pub enum FontDirectoryError {
56    /// the directory could not be opened.
57    #[error("error opening fonts directory: {0}")]
58    Io(#[from] std::io::Error),
59    /// the directory exists but contains no `*.ttf` files.
60    #[error(
61        "fonts directory `{0}` contains no .ttf files; \
62         add at least one font (DejaVuSans.ttf is checked in at the workspace root)"
63    )]
64    Empty(PathBuf),
65}
66
67impl FontDirectory {
68    /// Scan `root` for `*.ttf` files and build the index. Returns an
69    /// error if the directory cannot be opened or contains no fonts.
70    ///
71    /// # Errors
72    ///
73    /// Returns a [`FontDirectoryError`] when the directory is missing,
74    /// unreadable, or has no `.ttf` entries.
75    pub fn scan(root: PathBuf) -> Result<Self, FontDirectoryError> {
76        let mut by_id: BTreeMap<String, FontEntry> = BTreeMap::new();
77        for entry in fs_err::read_dir(&root)? {
78            let entry = entry?;
79            let path = entry.path();
80            if !path.is_file() {
81                continue;
82            }
83            let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
84                continue;
85            };
86            if !ext.eq_ignore_ascii_case("ttf") {
87                continue;
88            }
89            let Some(id) = path.file_name().and_then(|n| n.to_str()) else {
90                continue;
91            };
92            // Resolve the display label once, here, so `GET /api/fonts`
93            // never re-reads the file. A font that cannot be read or
94            // parsed is still listed under its filename-derived label;
95            // a truly unloadable font fails later at render via
96            // `ab_glyph`, matching the pre-existing behaviour.
97            let display_name = match fs_err::read(&path) {
98                Ok(bytes) => sl_map_apis::text::embedded_font_name(&bytes).map_or_else(
99                    || sl_map_apis::text::display_name_from_file_name(id),
100                    |name| format!("{name} ({id})"),
101                ),
102                Err(err) => {
103                    tracing::warn!(font = id, %err, "could not read font for name extraction");
104                    sl_map_apis::text::display_name_from_file_name(id)
105                }
106            };
107            by_id.insert(id.to_owned(), FontEntry { path, display_name });
108        }
109        if by_id.is_empty() {
110            return Err(FontDirectoryError::Empty(root));
111        }
112        Ok(Self { root, by_id })
113    }
114
115    /// Public list for `GET /api/fonts`.
116    #[must_use]
117    pub fn list(&self) -> Vec<FontInfo> {
118        self.by_id
119            .iter()
120            .map(|(id, entry)| FontInfo {
121                id: id.to_owned(),
122                name: entry.display_name.clone(),
123            })
124            .collect()
125    }
126
127    /// Resolve a client-supplied `font_id` to the absolute on-disk
128    /// path of the font file. Returns `None` if the id does not match
129    /// a discovered font or if the id contains path-separator
130    /// characters (defence in depth — the scan only inserts plain
131    /// basenames, but client input is treated as untrusted).
132    #[must_use]
133    pub fn path_for(&self, font_id: &str) -> Option<&Path> {
134        if font_id.is_empty()
135            || font_id.contains('/')
136            || font_id.contains('\\')
137            || font_id.contains("..")
138        {
139            return None;
140        }
141        self.by_id.get(font_id).map(|entry| entry.path.as_path())
142    }
143
144    /// Root directory the scan was performed against; surfaced in
145    /// error messages.
146    #[must_use]
147    pub fn root(&self) -> &Path {
148        &self.root
149    }
150}
151
152#[cfg(test)]
153mod test {
154    use super::*;
155    use pretty_assertions::assert_eq;
156
157    /// The font checked in at the workspace root.
158    const DEJAVU: &[u8] = include_bytes!("../../DejaVuSans.ttf");
159
160    #[test]
161    fn list_uses_embedded_name_with_id() -> Result<(), Box<dyn std::error::Error>> {
162        let tmp = tempfile::tempdir()?;
163        fs_err::write(tmp.path().join("DejaVuSans.ttf"), DEJAVU)?;
164        fs_err::write(tmp.path().join("bad.ttf"), b"not a font")?;
165        let fd = FontDirectory::scan(tmp.path().to_owned())?;
166        let fonts = fd.list();
167        let dejavu = fonts
168            .iter()
169            .find(|f| f.id == "DejaVuSans.ttf")
170            .ok_or("DejaVuSans.ttf missing from list")?;
171        assert_eq!(dejavu.name, "DejaVu Sans (DejaVuSans.ttf)");
172        let bad = fonts
173            .iter()
174            .find(|f| f.id == "bad.ttf")
175            .ok_or("bad.ttf missing from list")?;
176        assert_eq!(bad.name, "bad");
177        Ok(())
178    }
179
180    #[test]
181    fn path_for_rejects_path_traversal() -> Result<(), Box<dyn std::error::Error>> {
182        let tmp = tempfile::tempdir()?;
183        fs_err::write(tmp.path().join("one.ttf"), b"fake")?;
184        let fd = FontDirectory::scan(tmp.path().to_owned())?;
185        assert!(fd.path_for("one.ttf").is_some());
186        assert!(fd.path_for("../one.ttf").is_none());
187        assert!(fd.path_for("subdir/one.ttf").is_none());
188        assert!(fd.path_for("missing.ttf").is_none());
189        Ok(())
190    }
191
192    #[test]
193    fn scan_empty_directory_fails() -> Result<(), Box<dyn std::error::Error>> {
194        let tmp = tempfile::tempdir()?;
195        let result = FontDirectory::scan(tmp.path().to_owned());
196        assert!(
197            matches!(result, Err(FontDirectoryError::Empty(_))),
198            "expected Empty, got {result:?}",
199        );
200        Ok(())
201    }
202}