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