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.into();
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: contents.into(),
414 glyph_name,
415 path: path.to_owned(),
416 });
417 }
418}