Skip to main content

webfont_generator/incremental/
mod.rs

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