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}