Skip to main content

flash_font_injector/
lib.rs

1use std::collections::HashSet;
2
3use camino::{Utf8Path, Utf8PathBuf};
4use rayon::prelude::*;
5
6use error::{FontError, FontResult};
7use sys::NativeFontRegistry;
8
9pub mod error;
10mod sys;
11
12pub(crate) trait FontRegistry {
13    fn add_font(path: &Utf8Path) -> FontResult<()>;
14    fn remove_font(path: &Utf8Path) -> FontResult<()>;
15}
16
17/// Manages the lifecycle of temporarily loaded system fonts.
18///
19/// Fonts are keyed by their canonical (absolute) path, ensuring each physical
20/// font file is loaded at most once. When the manager is dropped, all loaded
21/// fonts are automatically unloaded.
22///
23/// # Examples
24///
25/// ```ignore
26/// use flash_font_injector::FontManager;
27/// use camino::Utf8Path;
28///
29/// let mut manager = FontManager::default();
30/// manager.load(Utf8Path::new("path/to/font.ttf")).unwrap();
31///
32/// assert!(manager.len() > 0);
33///
34/// manager.unload(Utf8Path::new("path/to/font.ttf")).unwrap();
35/// ```
36#[derive(Debug, Default)]
37pub struct FontManager {
38    loaded_fonts: HashSet<Utf8PathBuf>,
39    config: FontManagerConfig,
40}
41
42/// Configuration for the `FontManager`.
43#[derive(Debug, Clone)]
44pub struct FontManagerConfig {
45    /// Determines whether loaded fonts should remain in the system after the `FontManager` is dropped.
46    /// If `true`, fonts stay loaded. If `false`, they are automatically unloaded.
47    pub keep_loaded_fonts: bool,
48}
49
50impl Default for FontManagerConfig {
51    fn default() -> Self {
52        Self {
53            keep_loaded_fonts: true,
54        }
55    }
56}
57
58impl FontManager {
59    /// Creates an empty `FontManager`.
60    pub fn new(config: FontManagerConfig) -> Self {
61        Self {
62            loaded_fonts: HashSet::new(),
63            config,
64        }
65    }
66
67    /// Loads a font from the given file path into the system.
68    ///
69    /// The provided path is used directly without canonicalization.
70    /// If the same font file is already loaded, this is a no-op that returns
71    /// `Ok(())`.
72    /// Expect full path
73    pub fn load(&mut self, path: &Utf8Path) -> FontResult<()> {
74        if !self.loaded_fonts.contains(path) {
75            NativeFontRegistry::add_font(path)?;
76            self.loaded_fonts.insert(path.to_path_buf());
77        }
78
79        Ok(())
80    }
81
82    /// Loads multiple fonts in parallel.
83    ///
84    /// Returns an error if any of the fonts failed to load, though some fonts might
85    /// still have been loaded successfully.
86    pub fn load_all(&mut self, paths: Vec<Utf8PathBuf>) -> FontResult<()> {
87        let to_load: Vec<_> = paths
88            .into_iter()
89            .filter(|path| !self.loaded_fonts.contains(path))
90            .collect();
91
92        let results: Vec<_> = to_load
93            .into_par_iter()
94            .map(|path| {
95                if NativeFontRegistry::add_font(&path).is_ok() {
96                    Ok(path)
97                } else {
98                    Err(path)
99                }
100            })
101            .collect();
102
103        let mut first_err = None;
104        for res in results {
105            match res {
106                Ok(path) => {
107                    self.loaded_fonts.insert(path);
108                }
109                Err(path) => {
110                    if first_err.is_none() {
111                        first_err = Some(FontError::LoadFailed(path));
112                    }
113                }
114            }
115        }
116
117        if let Some(err) = first_err {
118            return Err(err);
119        }
120
121        Ok(())
122    }
123
124    /// Unloads a previously loaded font and removes it from the manager.
125    ///
126    /// The provided path is used directly without canonicalization. If the font
127    /// is not currently loaded, this is a no-op that returns `Ok(())`.
128    pub fn unload(&mut self, path: &Utf8Path) -> FontResult<()> {
129        if self.loaded_fonts.remove(path) {
130            NativeFontRegistry::remove_font(path)?;
131        }
132
133        Ok(())
134    }
135
136    /// Unloads all currently loaded fonts in parallel.
137    ///
138    /// The manager's internal tracking is cleared even if some unloads fail.
139    /// Returns the first encountered error if unloading any font fails.
140    pub fn unload_all(&mut self) -> FontResult<()> {
141        let to_unload: Vec<_> = self.loaded_fonts.drain().collect();
142
143        let errs: Vec<_> = to_unload
144            .into_par_iter()
145            .filter(|path| NativeFontRegistry::remove_font(path).is_err())
146            .collect();
147
148        if !errs.is_empty() {
149            return Err(FontError::UnloadFailed(errs[0].clone()));
150        }
151
152        Ok(())
153    }
154
155    /// Returns an iterator over the canonical paths of all loaded fonts.
156    pub fn loaded_fonts(&self) -> impl Iterator<Item = &Utf8Path> {
157        self.loaded_fonts.iter().map(|p| p.as_path())
158    }
159
160    /// Returns the number of currently loaded fonts.
161    pub fn len(&self) -> usize {
162        self.loaded_fonts.len()
163    }
164
165    /// Returns `true` if no fonts are currently loaded.
166    pub fn is_empty(&self) -> bool {
167        self.loaded_fonts.is_empty()
168    }
169}
170
171impl Drop for FontManager {
172    fn drop(&mut self) {
173        if !self.config.keep_loaded_fonts {
174            let _ = self.unload_all();
175        }
176    }
177}
178
179#[cfg(all(test, not(ci)))]
180mod tests {
181
182    use super::*;
183
184    #[test]
185    fn test_font_manager() {
186        let font_path = Utf8Path::new("../fonts/方正少儿_GBK.ttf");
187
188        let mut manager = FontManager::new(FontManagerConfig {
189            keep_loaded_fonts: false,
190        });
191
192        manager.load(font_path).unwrap();
193        assert!(manager.loaded_fonts.contains(font_path));
194        assert_eq!(manager.len(), 1);
195
196        // Loading the same font again should be a no-op.
197        manager.load(font_path).unwrap();
198        assert_eq!(manager.len(), 1);
199
200        manager.unload(font_path).unwrap();
201        assert!(!manager.loaded_fonts.contains(font_path));
202        assert!(manager.is_empty());
203    }
204}