Skip to main content

webfont_generator/
lib.rs

1//! # webfont-generator
2//!
3//! Generate webfonts (SVG, TTF, EOT, WOFF, WOFF2) from SVG icon files.
4//!
5//! ## Library usage
6//!
7//! ```rust,no_run
8//! # #[cfg(feature = "fs")]
9//! # {
10//! use webfont_generator::{GenerateWebfontsOptions, FontType};
11//!
12//! // Async API (requires a tokio runtime)
13//! # async fn example() -> std::io::Result<()> {
14//! let options = GenerateWebfontsOptions {
15//!     dest: "output".to_owned(),
16//!     files: vec!["icons/add.svg".to_owned(), "icons/remove.svg".to_owned()],
17//!     font_name: Some("my-icons".to_owned()),
18//!     types: Some(vec![FontType::Woff2, FontType::Woff]),
19//!     ..Default::default()
20//! };
21//!
22//! let result = webfont_generator::generate(options, None).await?;
23//! if let Some(woff2) = result.woff2_bytes() {
24//!     println!("Generated WOFF2: {} bytes", woff2.len());
25//! }
26//! # Ok(())
27//! # }
28//! # }
29//! ```
30//!
31//! ```rust,no_run
32//! # #[cfg(feature = "fs")]
33//! # {
34//! use webfont_generator::{GenerateWebfontsOptions, FontType};
35//!
36//! // Synchronous API
37//! let options = GenerateWebfontsOptions {
38//!     dest: "output".to_owned(),
39//!     files: vec!["icons/add.svg".to_owned()],
40//!     write_files: Some(false),
41//!     ..Default::default()
42//! };
43//!
44//! let result = webfont_generator::generate_sync(options, None).unwrap();
45//! # }
46//! ```
47//!
48//! ## CLI
49//!
50//! Install the CLI binary with:
51//!
52//! ```sh
53//! cargo install webfont-generator --features cli
54//! ```
55//!
56//! Then run:
57//!
58//! ```sh
59//! webfont-generator --dest ./dist/fonts ./icons/
60//! ```
61//!
62//! ## Feature flags
63//!
64//! The default features are `fs`, `in_memory`, and `async`.
65//!
66//! - **`fs`**: File-based generation, hooks, templates, and output writing. Enables `async`.
67//! - **`in_memory`**: Synchronous generation and regeneration from named SVG text, without
68//!   filesystem access or a runtime requirement.
69//! - **`async`**: Tokio-backed asynchronous wrappers for enabled input APIs. Combine with
70//!   `in_memory` to run source generation on Tokio's blocking pool.
71//! - **`cli`**: Builds the `webfont-generator` CLI binary (adds `clap` dependency).
72//!   Enables `fs`; not enabled by default — use `cargo install webfont-generator --features cli`.
73//! - **`bench`**: Internal benchmark helpers; enables `fs` and `in_memory`.
74//!
75//! With no input features enabled, common option and output types remain available.
76//!
77//! Node.js bindings are built by the separate, unpublished `webfont-generator-napi` crate.
78
79#[cfg(feature = "bench")]
80pub mod bench_support;
81#[cfg(any(feature = "fs", feature = "in_memory"))]
82mod byte_helpers;
83#[cfg(any(feature = "fs", feature = "in_memory"))]
84mod formats;
85#[cfg(feature = "fs")]
86mod hooks;
87#[cfg(any(feature = "fs", feature = "in_memory"))]
88mod incremental;
89#[cfg(any(feature = "fs", feature = "in_memory"))]
90mod input;
91#[cfg(feature = "in_memory")]
92mod memory;
93mod output;
94#[cfg(any(feature = "fs", feature = "in_memory"))]
95mod pipeline;
96#[cfg(any(feature = "fs", feature = "in_memory"))]
97mod rendering;
98#[cfg(any(feature = "fs", feature = "in_memory"))]
99mod result;
100#[cfg(any(feature = "fs", feature = "in_memory"))]
101mod sfnt;
102#[cfg(any(feature = "fs", feature = "in_memory"))]
103mod svg;
104#[cfg(all(test, any(feature = "fs", feature = "in_memory")))]
105mod test_helpers;
106mod types;
107
108#[cfg(feature = "fs")]
109pub use hooks::GenerationHooks;
110#[cfg(feature = "fs")]
111use std::sync::Mutex;
112
113#[cfg(feature = "fs")]
114use input::{
115    ResolvedFileGenerationOptions, finalize_generate_webfonts_options,
116    resolve_validated_generate_webfonts_options,
117};
118#[cfg(any(feature = "fs", feature = "in_memory"))]
119use input::{
120    ResolvedGenerationOptions, ResolvedVariants, build_variant_family_sources,
121    resolve_missing_glyphs,
122};
123#[cfg(feature = "fs")]
124use input::{load_svg_files_with_hooks, load_variant_svg_files_with_hooks};
125#[cfg(feature = "in_memory")]
126pub use memory::{
127    GenerateWebfontsFromSourcesOptions, GenerateWebfontsFromSourcesResult, SourceChange,
128    SourceFontVariant, SourceMembership, SvgSource, VariantSourceSet, generate_from_sources_sync,
129};
130#[cfg(all(feature = "in_memory", feature = "async"))]
131pub use memory::{SourceRegenerateError, generate_from_sources};
132pub use output::collect::{CollectedOutput, OutputContents, OutputKind};
133#[cfg(feature = "fs")]
134use output::write_generate_webfonts_result;
135#[cfg(feature = "fs")]
136use pipeline::{generate_variant_webfonts_sync, generate_webfonts_sync};
137#[cfg(feature = "fs")]
138use rendering::{
139    CachedTemplateData, SharedTemplateData, build_css_context, build_html_context,
140    load_html_template,
141};
142#[cfg(feature = "fs")]
143pub use result::{GenerateWebfontsResult, RegenerateError};
144pub use types::{
145    ColorGlyphSelection, FontType, FormatOptions, MissingGlyphBehavior, MissingGlyphOptions,
146    SvgFormatOptions, TemplateVariant, TtfFormatOptions, Woff2FormatOptions, WoffFormatOptions,
147};
148#[cfg(feature = "fs")]
149pub use types::{
150    CssContext, FontVariant, GenerateWebfontsOptions, GlyphChange, GlyphChangeEntry, HtmlContext,
151    RegenerationFiles, VariantFileSet,
152};
153
154#[cfg(feature = "fs")]
155type PreparedVariantInput = (
156    svg::types::PreparedVariantFamily,
157    Vec<input::LoadedSvgSource>,
158    Option<svg::VariantGlyphCache>,
159);
160
161#[cfg(feature = "fs")]
162fn prepare_variant_family(
163    options: &mut ResolvedFileGenerationOptions,
164    source_files: Vec<Vec<input::LoadedSvgSource>>,
165) -> std::io::Result<PreparedVariantInput> {
166    let mut cache = options.incremental.then(svg::VariantGlyphCache::default);
167    let family = prepare_variant_family_cached(
168        options,
169        source_files.iter().map(Vec::as_slice).collect(),
170        cache.as_mut(),
171    )?;
172    let sources = source_files.into_iter().flatten().collect();
173    Ok((family, sources, cache))
174}
175
176#[cfg(feature = "fs")]
177fn prepare_variant_family_cached(
178    options: &mut ResolvedFileGenerationOptions,
179    source_files: Vec<&[input::LoadedSvgSource]>,
180    cache: Option<&mut svg::VariantGlyphCache>,
181) -> std::io::Result<svg::types::PreparedVariantFamily> {
182    let variants = options
183        .variants
184        .as_ref()
185        .expect("variant source preparation requires resolved variants");
186    prepare_content_variant_family(&mut options.common, variants, source_files, cache)
187}
188
189#[cfg(feature = "fs")]
190fn prepare_content_variant_family(
191    options: &mut ResolvedGenerationOptions,
192    variants: &ResolvedVariants,
193    source_files: Vec<&[input::LoadedSvgSource]>,
194    cache: Option<&mut svg::VariantGlyphCache>,
195) -> std::io::Result<svg::types::PreparedVariantFamily> {
196    let family = resolve_content_variant_sources(options, variants, source_files)?;
197    let svg_options = svg::svg_options_from_options(options);
198    match cache {
199        Some(cache) => svg::prepare_variant_svg_family_cached(&svg_options, &family, Some(cache)),
200        None => svg::prepare_variant_svg_family(&svg_options, &family),
201    }
202}
203
204#[cfg(any(feature = "fs", feature = "in_memory"))]
205fn resolve_content_variant_sources<'a>(
206    options: &mut ResolvedGenerationOptions,
207    variants: &ResolvedVariants,
208    source_files: Vec<&'a [input::LoadedSvgSource]>,
209) -> std::io::Result<input::VariantFamilySources<&'a [input::LoadedSvgSource]>> {
210    let (mut family, codepoints) = build_variant_family_sources(
211        source_files,
212        &options.explicit_codepoints,
213        options.start_codepoint,
214    )?;
215    input::validate_color_names(
216        options,
217        family.glyphs.iter().map(|glyph| glyph.name.as_str()),
218    )?;
219    let variant_names = variants
220        .variants
221        .iter()
222        .map(|variant| variant.name.as_str())
223        .collect::<Vec<_>>();
224    let fallback_index = options.missing_glyphs.variant.as_deref().map(|name| {
225        variant_names
226            .iter()
227            .position(|variant_name| *variant_name == name)
228            .expect("validated fallback must name a resolved variant")
229    });
230    resolve_missing_glyphs(
231        &mut family,
232        options.missing_glyphs.behavior,
233        fallback_index,
234        &variant_names,
235    )?;
236    options.codepoints = codepoints;
237    Ok(family)
238}
239
240#[cfg(feature = "fs")]
241fn variant_preparation_join_error(error: tokio::task::JoinError) -> std::io::Error {
242    std::io::Error::other(format!("Native variant preparation task failed: {error}"))
243}
244
245#[cfg(test)]
246#[tokio::test]
247#[cfg(feature = "fs")]
248async fn variant_preparation_reports_panicking_worker() {
249    let join_error = tokio::task::spawn_blocking(|| panic!("variant preparation panic"))
250        .await
251        .unwrap_err();
252    let error = variant_preparation_join_error(join_error);
253
254    assert!(
255        error
256            .to_string()
257            .starts_with("Native variant preparation task failed:")
258    );
259}
260
261#[cfg(test)]
262#[tokio::test]
263#[cfg(feature = "fs")]
264async fn hooked_variant_generation_succeeds_after_preparing_sources() {
265    let path = test_helpers::webfont_fixture("add.svg");
266    let variants = vec![
267        FontVariant {
268            name: "small".to_owned(),
269            files: vec![path.clone()],
270            weight: Some(300),
271            default: Some(true),
272        },
273        FontVariant {
274            name: "large".to_owned(),
275            files: vec![path.clone()],
276            weight: Some(700),
277            default: None,
278        },
279    ];
280    let options = GenerateWebfontsOptions {
281        dest: "artifacts".to_owned(),
282        files: vec![],
283        types: Some(vec![FontType::Woff2]),
284        variants: Some(variants.clone()),
285        write_files: Some(false),
286        ..Default::default()
287    };
288
289    let result = generate_with_hooks(options, &())
290        .await
291        .expect("variant generation should succeed");
292    assert!(result.woff2_bytes().is_some());
293    assert!(result.eot_bytes().is_none());
294
295    let mut duplicate = variants;
296    duplicate[0].files.push(path);
297    let error = generate_with_hooks(
298        GenerateWebfontsOptions {
299            dest: "artifacts".to_owned(),
300            files: vec![],
301            types: Some(vec![FontType::Woff2]),
302            variants: Some(duplicate),
303            ..Default::default()
304        },
305        &(),
306    )
307    .await
308    .err()
309    .expect("duplicate names within one variant should fail");
310    assert!(error.to_string().contains("must be unique"));
311}
312
313#[cfg(test)]
314#[tokio::test]
315#[cfg(feature = "fs")]
316async fn hooked_generation_keeps_the_ordinary_source_path() {
317    let result = generate_with_hooks(
318        GenerateWebfontsOptions {
319            css: Some(false),
320            dest: "artifacts".to_owned(),
321            files: vec![test_helpers::webfont_fixture("add.svg")],
322            html: Some(false),
323            types: Some(vec![FontType::Svg]),
324            write_files: Some(false),
325            ..Default::default()
326        },
327        &(),
328    )
329    .await
330    .expect("ordinary generation with hooks should succeed");
331
332    assert!(result.svg_string().is_some());
333}
334
335#[cfg(test)]
336#[tokio::test]
337#[cfg(feature = "fs")]
338async fn shared_variant_path_keeps_per_membership_hook_names() {
339    struct Names;
340    impl GenerationHooks for Names {
341        type Error = std::io::Error;
342
343        async fn rename(&self, paths: &[String]) -> std::io::Result<Option<Vec<String>>> {
344            assert_eq!(paths.len(), 2);
345            assert_eq!(paths[0], paths[1]);
346            Ok(Some(vec!["small-icon".into(), "large-icon".into()]))
347        }
348    }
349
350    let path = test_helpers::webfont_fixture("add.svg");
351    let mut result = generate_with_hooks(
352        GenerateWebfontsOptions {
353            dest: "artifacts".into(),
354            variants: Some(
355                ["small", "large"]
356                    .into_iter()
357                    .enumerate()
358                    .map(|(index, name)| FontVariant {
359                        name: name.into(),
360                        files: vec![path.clone()],
361                        weight: Some(300 + index as u16 * 400),
362                        default: Some(index == 0),
363                    })
364                    .collect(),
365            ),
366            types: Some(vec![FontType::Ttf]),
367            write_files: Some(false),
368            incremental: Some(true),
369            ..Default::default()
370        },
371        &Names,
372    )
373    .await
374    .unwrap();
375
376    assert!(result.ttf_bytes().is_some());
377    assert_eq!(result.source_files[0].id, result.source_files[1].id);
378    assert_eq!(result.source_files[0].glyph_name, "small-icon");
379    assert_eq!(result.source_files[1].glyph_name, "large-icon");
380    assert_eq!(result.options.codepoints.len(), 2);
381    result
382        .regenerate(
383            &RegenerationFiles::Variants(vec![
384                VariantFileSet {
385                    variant: "small".into(),
386                    files: vec![path.clone(), test_helpers::webfont_fixture("test.svg")],
387                },
388                VariantFileSet {
389                    variant: "large".into(),
390                    files: vec![path],
391                },
392            ]),
393            &[],
394        )
395        .unwrap();
396    assert_eq!(result.source_files[0].id, result.source_files[2].id);
397    assert_eq!(result.source_files[0].glyph_name, "small-icon");
398    assert_eq!(result.source_files[2].glyph_name, "large-icon");
399}
400
401#[cfg(test)]
402#[tokio::test]
403#[cfg(feature = "fs")]
404async fn variant_update_replaces_a_collapsed_duplicate_hook_occurrence() {
405    struct Names;
406    impl GenerationHooks for Names {
407        type Error = std::io::Error;
408        async fn rename(&self, _: &[String]) -> std::io::Result<Option<Vec<String>>> {
409            Ok(Some(vec!["first".into(), "last".into(), "other".into()]))
410        }
411    }
412    let path = test_helpers::webfont_fixture("add.svg");
413    let mut result = generate_with_hooks(
414        GenerateWebfontsOptions {
415            dest: "artifacts".into(),
416            write_files: Some(false),
417            incremental: Some(true),
418            types: Some(vec![FontType::Ttf]),
419            variants: Some(vec![
420                FontVariant {
421                    name: "light".into(),
422                    files: vec![path.clone(), path.clone()],
423                    weight: Some(300),
424                    default: Some(true),
425                },
426                FontVariant {
427                    name: "bold".into(),
428                    files: vec![path.clone()],
429                    weight: Some(700),
430                    default: None,
431                },
432            ]),
433            ..Default::default()
434        },
435        &Names,
436    )
437    .await
438    .unwrap();
439    result
440        .regenerate(
441            &RegenerationFiles::Variants(
442                ["light", "bold"]
443                    .map(|variant| VariantFileSet {
444                        variant: variant.into(),
445                        files: vec![path.clone()],
446                    })
447                    .into(),
448            ),
449            &[(
450                path,
451                GlyphChange::Changed {
452                    name: Some("updated".into()),
453                },
454            )],
455        )
456        .unwrap();
457    assert_eq!(result.source_files.len(), 2);
458    assert!(
459        result
460            .source_files
461            .iter()
462            .all(|source| source.glyph_name == "updated")
463    );
464    assert_eq!(result.options.codepoints.len(), 1);
465}
466
467/// Generate a webfont from a set of SVG files.
468///
469/// Loads the SVGs listed in `options.files`, builds the configured
470/// `options.types` formats, optionally writes them (along with the CSS and
471/// HTML preview) to `options.dest`, and returns a `GenerateWebfontsResult`
472/// holding the font bytes and template-rendering methods.
473///
474/// Multi-variant input generates one shared variable font per requested modern format.
475///
476/// Runtime-independent asynchronous callbacks, supplied through [`GenerationHooks`]:
477/// - `rename(paths)` — derive custom glyph names for the batch of SVG file paths.
478/// - `css_context(ctx)` — mutate the Handlebars context before CSS rendering;
479///   return the (possibly mutated) context.
480/// - `html_context(ctx)` — same, but for the HTML preview.
481#[cfg(feature = "fs")]
482pub async fn generate_with_hooks<H: GenerationHooks>(
483    options: GenerateWebfontsOptions,
484    hooks: &H,
485) -> Result<GenerateWebfontsResult, H::Error> {
486    let mut resolved_options = resolve_validated_generate_webfonts_options(options)?;
487    let mut result = if let Some(variants) = &resolved_options.variants {
488        let variant_paths = variants
489            .variants
490            .iter()
491            .map(|variant| variant.source_ids.clone())
492            .collect::<Vec<_>>();
493        let source_files = load_variant_svg_files_with_hooks(&variant_paths, hooks).await?;
494        let generation = tokio::task::spawn_blocking(move || {
495            let (family, source_files, cache) =
496                prepare_variant_family(&mut resolved_options, source_files)?;
497            generate_variant_webfonts_sync(resolved_options, source_files, family, cache)
498        });
499        generation.await.map_err(variant_preparation_join_error)??
500    } else {
501        let source_files = load_svg_files_with_hooks(&resolved_options.files, hooks, true).await?;
502        finalize_generate_webfonts_options(&mut resolved_options, &source_files)?;
503        tokio::task::spawn_blocking(move || generate_webfonts_sync(resolved_options, source_files))
504            .await
505            .map_err(|error| {
506                std::io::Error::other(format!("Native webfont generation task failed: {error}"))
507            })??
508    };
509
510    // Pre-compute mutated contexts through runtime-independent async hooks.
511    // When callbacks are present, we build SharedTemplateData here and seed the
512    // OnceLock cache so it isn't re-created in get_cached() / writeFiles.
513    if hooks.has_css_context() || hooks.has_html_context() {
514        let shared = SharedTemplateData::from_files(&result.options, &result.source_files)?;
515
516        let mut css_ctx = build_css_context(&result.options, &shared);
517        if hooks.has_css_context() {
518            css_ctx = hooks.css_context(css_ctx).await?;
519            css_ctx.insert(
520                "__webfontVariantMode".to_owned(),
521                serde_json::Value::Bool(result.options.variants.is_some()),
522            );
523            result.css_context = Some(css_ctx.clone());
524        }
525
526        let mut html_ctx =
527            if result.options.html || hooks.has_html_context() || result.options.variants.is_some()
528            {
529                if result.options.variants.is_some() && hooks.has_css_context() {
530                    rendering::build_html_context_with_css(
531                        &result.options,
532                        &shared,
533                        &result.source_files,
534                        &css_ctx,
535                    )?
536                } else {
537                    build_html_context(&result.options, &shared, &result.source_files, None)?
538                }
539            } else {
540                serde_json::Map::new()
541            };
542        if hooks.has_html_context() {
543            html_ctx = hooks.html_context(html_ctx).await?;
544            result.html_context = Some(html_ctx.clone());
545        }
546
547        // Seed the OnceLock -- avoids re-creating SharedTemplateData in get_cached()
548        let html_template = load_html_template(&result.options)?;
549        let css_hbs_context =
550            handlebars::Context::wraps(&css_ctx).map_err(std::io::Error::other)?;
551        let html_hbs_context =
552            handlebars::Context::wraps(&html_ctx).map_err(std::io::Error::other)?;
553        let _ = result.cached.set(Ok(CachedTemplateData {
554            shared,
555            css_context: css_ctx,
556            css_hbs_context: Mutex::new(css_hbs_context),
557            html_context: html_ctx,
558            html_hbs_context: Mutex::new(html_hbs_context),
559            html_template,
560            render_cache: Mutex::new(Default::default()),
561            #[cfg(test)]
562            html_render_count: Default::default(),
563        }));
564    }
565
566    if result.options.write_files
567        && let Some(written) = write_generate_webfonts_result(&result).await?
568    {
569        // Only incremental results can call `regenerate`, so only they need write-skip state.
570        result.seed_written_outputs(written);
571    }
572
573    Ok(result)
574}
575
576/// A glyph rename function that maps file stems to custom glyph names.
577#[cfg(feature = "fs")]
578pub type RenameFn = Box<dyn Fn(&str) -> String + Send + Sync>;
579
580/// Generate webfonts from SVG files.
581///
582/// This is the pure Rust async entry point. Requires a tokio runtime. Multi-variant input generates
583/// one shared variable font per requested modern format.
584#[cfg(feature = "fs")]
585pub async fn generate(
586    options: GenerateWebfontsOptions,
587    rename: Option<RenameFn>,
588) -> std::io::Result<GenerateWebfontsResult> {
589    generate_with_hooks(options, &hooks::RenameHooks(rename.as_deref())).await
590}
591
592/// Synchronous version of [`generate`]. Spawns a tokio runtime internally and has the same source
593/// preparation behavior for multi-variant input.
594#[cfg(feature = "fs")]
595pub fn generate_sync(
596    options: GenerateWebfontsOptions,
597    rename: Option<RenameFn>,
598) -> std::io::Result<GenerateWebfontsResult> {
599    tokio::runtime::Runtime::new()?.block_on(generate(options, rename))
600}