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::{GenerateWebfontsResult, GlyphChange, LoadedSvgFile};
10use crate::write::write_generate_webfonts_result_sync;
11use crate::{build_font_outputs, finalize_generate_webfonts_options, validate_glyph_names};
12
13impl GenerateWebfontsResult {
14    /// Rebuild after a batch of file changes, reusing cached glyph geometry for files whose
15    /// contents are unchanged. Requires the result to have been generated with `incremental`
16    /// enabled. `ordered_paths` is the complete file set after the changes, in the order a fresh
17    /// build would use (e.g. the glob result); the rebuilt glyphs are ordered to match it, so
18    /// auto-assigned codepoints and glyph order — and therefore the output bytes — are identical
19    /// to a fresh `generate` of that set, including for additions that sort before existing
20    /// glyphs. `changes` describes what to do per affected file: added/changed files are read from
21    /// disk and re-parsed; any file absent from `ordered_paths` is dropped (an explicit `Removed`
22    /// is optional but harmless). Every requested format is rebuilt in memory, and — matching
23    /// `generate` — when the result was built with `write_files` enabled the refreshed fonts are
24    /// written to disk too, while CSS/HTML companion files are skipped if their rendered bytes are
25    /// unchanged from the previous write. Rendered CSS/HTML is reused when the glyph names and
26    /// codepoints the templates read are unchanged (a content edit), and re-rendered otherwise.
27    ///
28    /// ```rust,no_run
29    /// use webfont_generator::{
30    ///     generate_sync, FontType, GenerateWebfontsOptions, GlyphChange,
31    /// };
32    ///
33    /// # fn main() -> std::io::Result<()> {
34    /// let files = vec![
35    ///     "icons/add.svg".to_owned(),
36    ///     "icons/remove.svg".to_owned(),
37    /// ];
38    /// let mut result = generate_sync(
39    ///     GenerateWebfontsOptions {
40    ///         dest: "dist".to_owned(),
41    ///         files: files.clone(),
42    ///         incremental: Some(true),
43    ///         types: Some(vec![FontType::Woff2]),
44    ///         write_files: Some(false),
45    ///         ..Default::default()
46    ///     },
47    ///     None,
48    /// )?;
49    ///
50    /// result.regenerate(
51    ///     &files,
52    ///     &[(
53    ///         "icons/add.svg".to_owned(),
54    ///         GlyphChange::Changed { name: None },
55    ///     )],
56    /// )?;
57    /// # Ok(())
58    /// # }
59    /// ```
60    pub fn regenerate(
61        &mut self,
62        ordered_paths: &[String],
63        changes: &[(String, GlyphChange)],
64    ) -> std::io::Result<()> {
65        if self.css_context.is_some() || self.html_context.is_some() {
66            return Err(std::io::Error::new(
67                ErrorKind::InvalidInput,
68                "regenerate is not supported for results generated with cssContext/htmlContext callbacks.",
69            ));
70        }
71
72        let mut cache = self.glyph_cache.clone().ok_or_else(|| {
73            std::io::Error::new(
74                ErrorKind::InvalidInput,
75                "regenerate requires the font to be generated with `incremental` enabled.",
76            )
77        })?;
78        let mut source_files = self.source_files.clone();
79        let mut options = self.options.clone();
80
81        // Snapshot the inputs the CSS/HTML templates depend on (glyph names + codepoints) so we
82        // can tell, after rebuilding, whether the rendered output could have changed.
83        let prev_names: Vec<String> = self
84            .source_files
85            .iter()
86            .map(|f| f.glyph_name.clone())
87            .collect();
88        let prev_codepoints = self.options.codepoints.clone();
89
90        let order_changed = self
91            .source_files
92            .iter()
93            .map(|file| file.path.as_str())
94            .ne(ordered_paths.iter().map(String::as_str));
95        let mut changed_inputs = order_changed;
96
97        for (path, change) in changes {
98            match change {
99                GlyphChange::Removed => {
100                    let before = source_files.len();
101                    source_files.retain(|file| &file.path != path);
102                    cache.entries.remove(path);
103                    cache.content_hashes.remove(path);
104                    cache.processed_entries.remove(path);
105                    changed_inputs |= source_files.len() != before;
106                }
107                GlyphChange::Added { name } => {
108                    let contents = std::fs::read_to_string(path)?;
109                    let hash = source_content_hash(&contents);
110                    upsert_source_file(&mut source_files, path, contents, name.clone());
111                    // Reuse identical SVG geometry if another path already parsed these bytes;
112                    // otherwise prepare_svg_font_incremental will parse and populate the cache.
113                    if let Some(cached) = cache.by_content_hash.get(&hash) {
114                        cache.entries.insert(path.clone(), cached.clone());
115                        cache.content_hashes.insert(path.clone(), hash);
116                    } else {
117                        cache.entries.remove(path);
118                        cache.content_hashes.remove(path);
119                    }
120                    cache.processed_entries.remove(path);
121                    changed_inputs = true;
122                }
123                GlyphChange::Changed { name } => {
124                    let contents = std::fs::read_to_string(path)?;
125                    let hash = source_content_hash(&contents);
126                    let content_changed = cache.content_hashes.get(path) != Some(&hash);
127                    let name_changed = name.as_ref().is_some_and(|name| {
128                        source_files
129                            .iter()
130                            .find(|file| &file.path == path)
131                            .is_none_or(|file| file.glyph_name != *name)
132                    });
133
134                    if !content_changed && !name_changed {
135                        continue;
136                    }
137
138                    upsert_source_file(&mut source_files, path, contents, name.clone());
139                    if content_changed {
140                        if let Some(cached) = cache.by_content_hash.get(&hash) {
141                            cache.entries.insert(path.clone(), cached.clone());
142                            cache.content_hashes.insert(path.clone(), hash);
143                        } else {
144                            cache.entries.remove(path);
145                            cache.content_hashes.remove(path);
146                        }
147                        cache.processed_entries.remove(path);
148                    }
149                    changed_inputs = true;
150                }
151            }
152        }
153
154        if !changed_inputs {
155            return Ok(());
156        }
157
158        // Reorder to the caller's order so additions land in their fresh-build position rather than
159        // at the tail. `ordered_paths` is authoritative for membership: any file not listed is
160        // dropped here (handling removals), and every listed path must resolve to a known file.
161        let mut by_path: std::collections::HashMap<String, LoadedSvgFile> = source_files
162            .into_iter()
163            .map(|file| (file.path.clone(), file))
164            .collect();
165        let mut reordered = Vec::with_capacity(ordered_paths.len());
166        for path in ordered_paths {
167            let file = by_path.remove(path).ok_or_else(|| {
168                std::io::Error::new(
169                    ErrorKind::InvalidInput,
170                    format!("regenerate: `{path}` is in the ordered set but was not loaded; declare it as an added change."),
171                )
172            })?;
173            reordered.push(file);
174        }
175        source_files = reordered;
176        validate_glyph_names(&source_files)?;
177        options.files = ordered_paths.to_vec();
178
179        // Re-resolve codepoints for the (possibly changed) set from the stable explicit base, so
180        // auto-assigned codepoints match what a fresh build of the new set would produce.
181        finalize_generate_webfonts_options(&mut options, &source_files)?;
182
183        let svg_options = svg_options_from_options(&options);
184        let prepared = prepare_svg_font_incremental(&svg_options, &source_files, &mut cache)?;
185        let fonts = build_font_outputs(&options, &svg_options, &prepared, self.ttf_cache.as_mut())?;
186
187        // Rebuild the template data fresh (the font hash changed), but keep the rendered CSS/HTML
188        // that can't have changed: only when the glyph names and codepoints the templates read are
189        // unchanged (e.g. a pure content edit). reset_render_cache carries the safe entries.
190        let names_unchanged = source_files
191            .iter()
192            .map(|file| file.glyph_name.as_str())
193            .eq(prev_names.iter().map(String::as_str));
194        let codepoints_unchanged = options.codepoints == prev_codepoints;
195        self.source_files = source_files;
196        self.options = options;
197        self.fonts = fonts;
198        self.glyph_cache = Some(cache);
199        self.reset_render_cache(names_unchanged, codepoints_unchanged);
200
201        // Match `generate`'s write behavior: refresh font files, and skip unchanged CSS/HTML.
202        if self.options.write_files {
203            write_generate_webfonts_result_sync(self)?;
204        }
205        Ok(())
206    }
207
208    /// Rebuild after re-diffing the complete ordered file set against the retained incremental
209    /// cache. This is useful when the caller has a fresh file list but not a reliable watcher
210    /// change batch: every current path is re-read and hashed, missing prior paths are treated as
211    /// removed, new paths as added, and paths whose content hash changed as changed. Existing glyph
212    /// names are preserved; added paths derive their glyph name from the file stem.
213    pub fn regenerate_all(&mut self, ordered_paths: &[String]) -> std::io::Result<()> {
214        let cache = self.glyph_cache.as_ref().ok_or_else(|| {
215            std::io::Error::new(
216                ErrorKind::InvalidInput,
217                "regenerate requires the font to be generated with `incremental` enabled.",
218            )
219        })?;
220        let ordered_set: HashSet<&str> = ordered_paths.iter().map(String::as_str).collect();
221        let mut previous_paths = HashSet::with_capacity(self.source_files.len());
222        let mut changes = Vec::new();
223
224        for source_file in &self.source_files {
225            previous_paths.insert(source_file.path.as_str());
226            if !ordered_set.contains(source_file.path.as_str()) {
227                changes.push((source_file.path.clone(), GlyphChange::Removed));
228            }
229        }
230
231        for path in ordered_paths {
232            if !previous_paths.contains(path.as_str()) {
233                changes.push((path.clone(), GlyphChange::Added { name: None }));
234                continue;
235            }
236
237            let contents = std::fs::read_to_string(path)?;
238            let hash = source_content_hash(&contents);
239            if cache.content_hashes.get(path) != Some(&hash) {
240                changes.push((path.clone(), GlyphChange::Changed { name: None }));
241            }
242        }
243
244        self.regenerate(ordered_paths, &changes)
245    }
246}
247
248/// Insert or update a source file by path: update an existing entry's contents (and name, if
249/// given), or append a new entry (deriving the glyph name from the file stem when none is given).
250fn upsert_source_file(
251    source_files: &mut Vec<LoadedSvgFile>,
252    path: &str,
253    contents: String,
254    name: Option<String>,
255) {
256    if let Some(file) = source_files.iter_mut().find(|file| file.path == path) {
257        file.contents = contents;
258        if let Some(name) = name {
259            file.glyph_name = name;
260        }
261    } else {
262        let glyph_name = name.unwrap_or_else(|| {
263            Path::new(path)
264                .file_stem()
265                .and_then(|stem| stem.to_str())
266                .unwrap_or_default()
267                .to_owned()
268        });
269        source_files.push(LoadedSvgFile {
270            contents,
271            glyph_name,
272            path: path.to_owned(),
273        });
274    }
275}