Skip to main content

webfont_generator/incremental/
mod.rs

1#[cfg(test)]
2mod tests;
3
4use std::collections::HashSet;
5use std::io::ErrorKind;
6use std::path::Path;
7
8use crate::svg::{prepare_svg_font_incremental, source_content_hash, svg_options_from_options};
9use crate::types::{
10    GenerateWebfontsResult, GlyphChange, LoadedSvgFile, RegenerateError, RegenerationState,
11};
12use crate::write::write_generate_webfonts_result_sync;
13use crate::{build_font_outputs, finalize_generate_webfonts_options, validate_glyph_names};
14
15impl GenerateWebfontsResult {
16    /// Rebuild after a batch of file changes, reusing cached glyph geometry for files whose
17    /// contents are unchanged. Requires the result to have been generated with `incremental`
18    /// enabled. `ordered_paths` is the complete file set after the changes, in the order a fresh
19    /// build would use (e.g. the glob result); the rebuilt glyphs are ordered to match it, so
20    /// auto-assigned codepoints and glyph order — and therefore the output bytes — are identical
21    /// to a fresh `generate` of that set, including for additions that sort before existing
22    /// glyphs. `changes` describes what to do per affected file: added/changed files are read from
23    /// disk and re-parsed; any file absent from `ordered_paths` is dropped (an explicit `Removed`
24    /// is optional but harmless). Every requested format is rebuilt in memory, and — matching
25    /// `generate` — when the result was built with `write_files` enabled the refreshed fonts are
26    /// written to disk too, while CSS/HTML companion files are skipped if their rendered bytes are
27    /// unchanged from the previous write. Rendered CSS/HTML is reused when the glyph names and
28    /// codepoints the templates read are unchanged (a content edit), and re-rendered otherwise.
29    ///
30    /// ```rust,no_run
31    /// use webfont_generator::{
32    ///     generate_sync, FontType, GenerateWebfontsOptions, GlyphChange,
33    /// };
34    ///
35    /// # fn main() -> std::io::Result<()> {
36    /// let files = vec![
37    ///     "icons/add.svg".to_owned(),
38    ///     "icons/remove.svg".to_owned(),
39    /// ];
40    /// let mut result = generate_sync(
41    ///     GenerateWebfontsOptions {
42    ///         dest: "dist".to_owned(),
43    ///         files: files.clone(),
44    ///         incremental: Some(true),
45    ///         types: Some(vec![FontType::Woff2]),
46    ///         write_files: Some(false),
47    ///         ..Default::default()
48    ///     },
49    ///     None,
50    /// )?;
51    ///
52    /// result.regenerate(
53    ///     &files,
54    ///     &[(
55    ///         "icons/add.svg".to_owned(),
56    ///         GlyphChange::Changed { name: None },
57    ///     )],
58    /// )?;
59    /// # Ok(())
60    /// # }
61    /// ```
62    pub fn regenerate(
63        &mut self,
64        ordered_paths: &[String],
65        changes: &[(String, GlyphChange)],
66    ) -> std::io::Result<()> {
67        if self.css_context.is_some() || self.html_context.is_some() {
68            return Err(std::io::Error::new(
69                ErrorKind::InvalidInput,
70                "regenerate is not supported for results generated with cssContext/htmlContext callbacks.",
71            ));
72        }
73
74        let mut state = self.take_regeneration_state_lease()?;
75        let result = self.regenerate_with_state(ordered_paths, changes, state.state_mut());
76        if result.is_ok() {
77            state.commit();
78        }
79        result
80    }
81
82    /// Asynchronously rebuild on Tokio's blocking pool and return the next result generation.
83    /// This consumes `self`, so Rust prevents reuse of a stale result after success. On ordinary
84    /// failure, [`RegenerateError::into_result`] returns the input result for retry. This future is
85    /// not cancellation-safe: dropping it lets already-started blocking work finish and drops the
86    /// consumed result; filesystem writes may continue.
87    ///
88    /// ```rust,no_run
89    /// use webfont_generator::{generate_sync, GenerateWebfontsOptions, GlyphChange};
90    ///
91    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
92    /// let files = vec!["icons/add.svg".to_owned()];
93    /// let result = generate_sync(GenerateWebfontsOptions {
94    ///     files: files.clone(),
95    ///     dest: "dist".to_owned(),
96    ///     incremental: Some(true),
97    ///     ..Default::default()
98    /// }, None)?;
99    /// let result = result.regenerate_async(
100    ///     files.clone(),
101    ///     vec![(files[0].clone(), GlyphChange::Changed { name: None })],
102    /// ).await?;
103    /// # let _ = result;
104    /// # Ok(())
105    /// # }
106    /// ```
107    pub async fn regenerate_async(
108        self,
109        ordered_paths: Vec<String>,
110        changes: Vec<(String, GlyphChange)>,
111    ) -> Result<Self, RegenerateError> {
112        self.regenerate_in_background(ordered_paths, Some(changes))
113            .await
114    }
115
116    fn regenerate_with_state(
117        &mut self,
118        ordered_paths: &[String],
119        changes: &[(String, GlyphChange)],
120        state: &mut RegenerationState,
121    ) -> std::io::Result<()> {
122        let cache = &mut state.glyph_cache;
123        let mut source_files = self.source_files.as_ref().clone();
124        let mut options = self.options.as_ref().clone();
125
126        // Snapshot the inputs the CSS/HTML templates depend on (glyph names + codepoints) so we
127        // can tell, after rebuilding, whether the rendered output could have changed.
128        let prev_names: Vec<String> = self
129            .source_files
130            .iter()
131            .map(|f| f.glyph_name.clone())
132            .collect();
133        let prev_codepoints = self.options.codepoints.clone();
134
135        let order_changed = self
136            .source_files
137            .iter()
138            .map(|file| file.path.as_str())
139            .ne(ordered_paths.iter().map(String::as_str));
140        let mut changed_inputs = order_changed;
141
142        for (path, change) in changes {
143            match change {
144                GlyphChange::Removed => {
145                    state.caches_dirty = true;
146                    let before = source_files.len();
147                    source_files.retain(|file| &file.path != path);
148                    cache.entries.remove(path);
149                    cache.content_hashes.remove(path);
150                    cache.processed_entries.remove(path);
151                    changed_inputs |= source_files.len() != before;
152                }
153                GlyphChange::Added { name } => {
154                    let contents = std::fs::read_to_string(path)?;
155                    state.caches_dirty = true;
156                    let hash = source_content_hash(&contents);
157                    upsert_source_file(&mut source_files, path, contents, name.clone());
158                    // Reuse identical SVG geometry if another path already parsed these bytes;
159                    // otherwise prepare_svg_font_incremental will parse and populate the cache.
160                    if let Some(cached) = cache.by_content_hash.get(&hash) {
161                        cache.entries.insert(path.clone(), cached.clone());
162                        cache.content_hashes.insert(path.clone(), hash);
163                    } else {
164                        cache.entries.remove(path);
165                        cache.content_hashes.remove(path);
166                    }
167                    cache.processed_entries.remove(path);
168                    changed_inputs = true;
169                }
170                GlyphChange::Changed { name } => {
171                    let contents = std::fs::read_to_string(path)?;
172                    let hash = source_content_hash(&contents);
173                    let content_changed = cache.content_hashes.get(path) != Some(&hash);
174                    let name_changed = name.as_ref().is_some_and(|name| {
175                        source_files
176                            .iter()
177                            .find(|file| &file.path == path)
178                            .is_none_or(|file| file.glyph_name != *name)
179                    });
180
181                    if !content_changed && !name_changed {
182                        continue;
183                    }
184
185                    upsert_source_file(&mut source_files, path, contents, name.clone());
186                    if content_changed {
187                        state.caches_dirty = true;
188                        if let Some(cached) = cache.by_content_hash.get(&hash) {
189                            cache.entries.insert(path.clone(), cached.clone());
190                            cache.content_hashes.insert(path.clone(), hash);
191                        } else {
192                            cache.entries.remove(path);
193                            cache.content_hashes.remove(path);
194                        }
195                        cache.processed_entries.remove(path);
196                    }
197                    changed_inputs = true;
198                }
199            }
200        }
201
202        if !changed_inputs {
203            return Ok(());
204        }
205
206        // Reorder to the caller's order so additions land in their fresh-build position rather than
207        // at the tail. `ordered_paths` is authoritative for membership: any file not listed is
208        // dropped here (handling removals), and every listed path must resolve to a known file.
209        let mut by_path: std::collections::HashMap<String, LoadedSvgFile> = source_files
210            .into_iter()
211            .map(|file| (file.path.clone(), file))
212            .collect();
213        let mut reordered = Vec::with_capacity(ordered_paths.len());
214        for path in ordered_paths {
215            let file = by_path.remove(path).ok_or_else(|| {
216                std::io::Error::new(
217                    ErrorKind::InvalidInput,
218                    format!("regenerate: `{path}` is in the ordered set but was not loaded; declare it as an added change."),
219                )
220            })?;
221            reordered.push(file);
222        }
223        source_files = reordered;
224        validate_glyph_names(&source_files)?;
225        options.files = ordered_paths.to_vec();
226
227        // Re-resolve codepoints for the (possibly changed) set from the stable explicit base, so
228        // auto-assigned codepoints match what a fresh build of the new set would produce.
229        finalize_generate_webfonts_options(&mut options, &source_files)?;
230
231        let svg_options = svg_options_from_options(&options);
232        state.caches_dirty = true;
233        let prepared = prepare_svg_font_incremental(&svg_options, &source_files, cache)?;
234        let fonts =
235            build_font_outputs(&options, &svg_options, &prepared, state.ttf_cache.as_mut())?;
236
237        // Rebuild the template data fresh (the font hash changed), but keep the rendered CSS/HTML
238        // that can't have changed: only when the glyph names and codepoints the templates read are
239        // unchanged (e.g. a pure content edit). Carry those safe entries into the new result.
240        let names_unchanged = source_files
241            .iter()
242            .map(|file| file.glyph_name.as_str())
243            .eq(prev_names.iter().map(String::as_str));
244        let codepoints_unchanged = options.codepoints == prev_codepoints;
245        let carried = self.reusable_render_cache(names_unchanged, codepoints_unchanged);
246        let previous_source_files =
247            std::mem::replace(&mut self.source_files, std::sync::Arc::new(source_files));
248        let previous_options = std::mem::replace(&mut self.options, std::sync::Arc::new(options));
249        let previous_fonts = std::mem::replace(&mut self.fonts, fonts);
250        let previous_cached = std::mem::take(&mut self.cached);
251        let previous_carried = std::mem::replace(&mut self.carried_render, carried);
252        let previous_css_context = self.css_context.take();
253        let previous_html_context = self.html_context.take();
254
255        // Match `generate`'s write behavior: refresh font files, and skip unchanged CSS/HTML.
256        if self.options.write_files
257            && let Err(error) =
258                write_generate_webfonts_result_sync(self, &mut state.written_outputs)
259        {
260            self.source_files = previous_source_files;
261            self.options = previous_options;
262            self.fonts = previous_fonts;
263            self.cached = previous_cached;
264            self.carried_render = previous_carried;
265            self.css_context = previous_css_context;
266            self.html_context = previous_html_context;
267            return Err(error);
268        }
269        Ok(())
270    }
271
272    /// Rebuild after re-diffing the complete ordered file set against the retained incremental
273    /// cache. This is useful when the caller has a fresh file list but not a reliable watcher
274    /// change batch: every current path is re-read and hashed, missing prior paths are treated as
275    /// removed, new paths as added, and paths whose content hash changed as changed. Existing glyph
276    /// names are preserved; added paths derive their glyph name from the file stem.
277    ///
278    /// ```rust,no_run
279    /// use webfont_generator::{generate_sync, GenerateWebfontsOptions};
280    ///
281    /// # fn run() -> std::io::Result<()> {
282    /// let files = vec!["icons/add.svg".to_owned()];
283    /// let mut result = generate_sync(GenerateWebfontsOptions {
284    ///     files: files.clone(),
285    ///     dest: "dist".to_owned(),
286    ///     incremental: Some(true),
287    ///     ..Default::default()
288    /// }, None)?;
289    /// result.regenerate_all(&files)?;
290    /// # Ok(())
291    /// # }
292    /// ```
293    pub fn regenerate_all(&mut self, ordered_paths: &[String]) -> std::io::Result<()> {
294        if !self.options.incremental {
295            return Err(std::io::Error::new(
296                ErrorKind::InvalidInput,
297                "regenerate requires the font to be generated with `incremental` enabled.",
298            ));
299        }
300        let state = self.regeneration_state.lock().unwrap();
301        let cache = &state
302            .as_ref()
303            .ok_or_else(|| {
304                std::io::Error::new(
305                    ErrorKind::WouldBlock,
306                    "This result is already regenerating or has been replaced.",
307                )
308            })?
309            .glyph_cache;
310        let ordered_set: HashSet<&str> = ordered_paths.iter().map(String::as_str).collect();
311        let mut previous_paths = HashSet::with_capacity(self.source_files.len());
312        let mut changes = Vec::new();
313
314        for source_file in self.source_files.iter() {
315            previous_paths.insert(source_file.path.as_str());
316            if !ordered_set.contains(source_file.path.as_str()) {
317                changes.push((source_file.path.clone(), GlyphChange::Removed));
318            }
319        }
320
321        for path in ordered_paths {
322            if !previous_paths.contains(path.as_str()) {
323                changes.push((path.clone(), GlyphChange::Added { name: None }));
324                continue;
325            }
326
327            let contents = std::fs::read_to_string(path)?;
328            let hash = source_content_hash(&contents);
329            if cache.content_hashes.get(path) != Some(&hash) {
330                changes.push((path.clone(), GlyphChange::Changed { name: None }));
331            }
332        }
333
334        drop(state);
335        self.regenerate(ordered_paths, &changes)
336    }
337
338    /// Asynchronously re-diff and rebuild on Tokio's blocking pool, consuming the old result and
339    /// returning the next result generation. This future is not cancellation-safe: dropping it
340    /// lets already-started blocking work finish and drops the consumed result.
341    ///
342    /// ```rust,no_run
343    /// use webfont_generator::{generate_sync, GenerateWebfontsOptions};
344    ///
345    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
346    /// let files = vec!["icons/add.svg".to_owned()];
347    /// let result = generate_sync(GenerateWebfontsOptions {
348    ///     files: files.clone(),
349    ///     dest: "dist".to_owned(),
350    ///     incremental: Some(true),
351    ///     ..Default::default()
352    /// }, None)?;
353    /// let result = result.regenerate_all_async(files).await?;
354    /// # let _ = result;
355    /// # Ok(())
356    /// # }
357    /// ```
358    pub async fn regenerate_all_async(
359        self,
360        ordered_paths: Vec<String>,
361    ) -> Result<Self, RegenerateError> {
362        self.regenerate_in_background(ordered_paths, None).await
363    }
364
365    async fn regenerate_in_background(
366        mut self,
367        ordered_paths: Vec<String>,
368        changes: Option<Vec<(String, GlyphChange)>>,
369    ) -> Result<Self, RegenerateError> {
370        let task = tokio::task::spawn_blocking(move || {
371            let outcome = match changes {
372                Some(changes) => self.regenerate(&ordered_paths, &changes),
373                None => self.regenerate_all(&ordered_paths),
374            };
375            match outcome {
376                Ok(()) => Ok(self),
377                Err(source) => Err(RegenerateError::new(Some(self), source)),
378            }
379        });
380        match task.await {
381            Ok(result) => result,
382            Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
383            Err(error) => Err(RegenerateError::new(
384                None,
385                std::io::Error::other(format!("asynchronous regeneration task failed: {error}")),
386            )),
387        }
388    }
389}
390
391/// Insert or update a source file by path: update an existing entry's contents (and name, if
392/// given), or append a new entry (deriving the glyph name from the file stem when none is given).
393fn upsert_source_file(
394    source_files: &mut Vec<LoadedSvgFile>,
395    path: &str,
396    contents: String,
397    name: Option<String>,
398) {
399    if let Some(file) = source_files.iter_mut().find(|file| file.path == path) {
400        file.contents = contents;
401        if let Some(name) = name {
402            file.glyph_name = name;
403        }
404    } else {
405        let glyph_name = name.unwrap_or_else(|| {
406            Path::new(path)
407                .file_stem()
408                .and_then(|stem| stem.to_str())
409                .unwrap_or_default()
410                .to_owned()
411        });
412        source_files.push(LoadedSvgFile {
413            contents,
414            glyph_name,
415            path: path.to_owned(),
416        });
417    }
418}