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//! use webfont_generator::{GenerateWebfontsOptions, FontType};
9//!
10//! // Async API (requires a tokio runtime)
11//! # async fn example() -> std::io::Result<()> {
12//! let options = GenerateWebfontsOptions {
13//!     dest: "output".to_owned(),
14//!     files: vec!["icons/add.svg".to_owned(), "icons/remove.svg".to_owned()],
15//!     font_name: Some("my-icons".to_owned()),
16//!     types: Some(vec![FontType::Woff2, FontType::Woff]),
17//!     ..Default::default()
18//! };
19//!
20//! let result = webfont_generator::generate(options, None).await?;
21//! if let Some(woff2) = result.woff2_bytes() {
22//!     println!("Generated WOFF2: {} bytes", woff2.len());
23//! }
24//! # Ok(())
25//! # }
26//! ```
27//!
28//! ```rust,no_run
29//! use webfont_generator::{GenerateWebfontsOptions, FontType};
30//!
31//! // Synchronous API
32//! let options = GenerateWebfontsOptions {
33//!     dest: "output".to_owned(),
34//!     files: vec!["icons/add.svg".to_owned()],
35//!     write_files: Some(false),
36//!     ..Default::default()
37//! };
38//!
39//! let result = webfont_generator::generate_sync(options, None).unwrap();
40//! ```
41//!
42//! ## CLI
43//!
44//! Install the CLI binary with:
45//!
46//! ```sh
47//! cargo install webfont-generator --features cli
48//! ```
49//!
50//! Then run:
51//!
52//! ```sh
53//! webfont-generator --dest ./dist/fonts ./icons/
54//! ```
55//!
56//! ## Feature flags
57//!
58//! - **`cli`**: Builds the `webfont-generator` CLI binary (adds `clap` dependency).
59//!   Not enabled by default — use `cargo install webfont-generator --features cli`.
60//! - **`napi`**: Enables Node.js NAPI bindings for use as a native addon.
61
62#[cfg(feature = "bench")]
63pub mod bench_support;
64mod byte_helpers;
65mod formats;
66mod incremental;
67mod input;
68mod output;
69mod pipeline;
70mod rendering;
71mod result;
72mod sfnt;
73mod svg;
74#[cfg(test)]
75mod test_helpers;
76mod types;
77
78#[cfg(feature = "napi")]
79use napi::Status;
80#[cfg(feature = "napi")]
81use napi::threadsafe_function::ThreadsafeFunction;
82#[cfg(feature = "napi")]
83use napi_derive::napi;
84#[cfg(feature = "napi")]
85use std::sync::Mutex;
86
87use input::{
88    ResolvedGenerateWebfontsOptions, build_variant_family_sources,
89    finalize_generate_webfonts_options, load_svg_files, load_variant_svg_files,
90    resolve_generate_webfonts_options, resolve_missing_glyphs, validate_generate_webfonts_options,
91};
92#[cfg(feature = "napi")]
93use input::{load_svg_files_napi, load_variant_svg_files_napi};
94use output::write_generate_webfonts_result;
95use pipeline::{generate_variant_webfonts_sync, generate_webfonts_sync};
96#[cfg(feature = "napi")]
97use rendering::{
98    CachedTemplateData, SharedTemplateData, apply_context_function, build_css_context,
99    build_html_context, build_html_registry_and_dependencies,
100};
101#[cfg(feature = "napi")]
102use result::to_napi_err;
103pub use result::{GenerateWebfontsResult, RegenerateError};
104pub use types::{
105    CssContext, FontType, FontVariant, FormatOptions, GenerateWebfontsOptions, GlyphChange,
106    GlyphChangeEntry, HtmlContext, MissingGlyphBehavior, MissingGlyphOptions, RegenerationFiles,
107    SvgFormatOptions, TemplateVariant, TtfFormatOptions, VariantFileSet, Woff2FormatOptions,
108    WoffFormatOptions,
109};
110
111type PreparedVariantInput = (
112    svg::types::PreparedVariantFamily,
113    Vec<input::LoadedSvgFile>,
114    Option<svg::VariantGlyphCache>,
115);
116
117fn prepare_variant_family(
118    options: &mut ResolvedGenerateWebfontsOptions,
119    source_files: Vec<Vec<input::LoadedSvgFile>>,
120) -> std::io::Result<PreparedVariantInput> {
121    let mut cache = options.incremental.then(svg::VariantGlyphCache::default);
122    let family = prepare_variant_family_cached(
123        options,
124        source_files.iter().map(Vec::as_slice).collect(),
125        cache.as_mut(),
126    )?;
127    let sources = source_files.into_iter().flatten().collect();
128    Ok((family, sources, cache))
129}
130
131fn prepare_variant_family_cached(
132    options: &mut ResolvedGenerateWebfontsOptions,
133    source_files: Vec<&[input::LoadedSvgFile]>,
134    cache: Option<&mut svg::VariantGlyphCache>,
135) -> std::io::Result<svg::types::PreparedVariantFamily> {
136    let (mut family, codepoints) = build_variant_family_sources(
137        source_files,
138        &options.explicit_codepoints,
139        options.start_codepoint,
140    )?;
141    let variants = options
142        .variants
143        .as_ref()
144        .expect("variant source preparation requires resolved variants");
145    let variant_names = variants
146        .variants
147        .iter()
148        .map(|variant| variant.name.as_str())
149        .collect::<Vec<_>>();
150    let fallback_index = options.missing_glyphs.variant.as_deref().map(|name| {
151        variant_names
152            .iter()
153            .position(|variant_name| *variant_name == name)
154            .expect("validated fallback must name a resolved variant")
155    });
156    resolve_missing_glyphs(
157        &mut family,
158        options.missing_glyphs.behavior,
159        fallback_index,
160        &variant_names,
161    )?;
162    options.codepoints = codepoints;
163    let svg_options = svg::svg_options_from_options(options);
164    let prepared = match cache {
165        Some(cache) => svg::prepare_variant_svg_family_cached(&svg_options, &family, Some(cache))?,
166        None => svg::prepare_variant_svg_family(&svg_options, &family)?,
167    };
168    Ok(prepared)
169}
170
171#[cfg(feature = "napi")]
172fn variant_preparation_join_error(error: tokio::task::JoinError) -> napi::Error {
173    napi::Error::new(
174        Status::GenericFailure,
175        format!("Native variant preparation task failed: {error}"),
176    )
177}
178
179#[cfg(all(test, feature = "napi"))]
180#[unsafe(no_mangle)]
181extern "C" fn napi_call_threadsafe_function(
182    _: napi::sys::napi_threadsafe_function,
183    _: *mut std::ffi::c_void,
184    _: napi::sys::napi_threadsafe_function_call_mode,
185) -> napi::sys::napi_status {
186    0
187}
188
189#[cfg(all(test, feature = "napi"))]
190#[unsafe(no_mangle)]
191extern "C" fn napi_release_threadsafe_function(
192    _: napi::sys::napi_threadsafe_function,
193    _: napi::sys::napi_threadsafe_function_release_mode,
194) -> napi::sys::napi_status {
195    0
196}
197
198#[cfg(all(test, feature = "napi"))]
199#[tokio::test]
200async fn napi_variant_preparation_reports_panicking_worker() {
201    let join_error = tokio::task::spawn_blocking(|| panic!("variant preparation panic"))
202        .await
203        .unwrap_err();
204    let error = variant_preparation_join_error(join_error);
205
206    assert!(
207        error
208            .reason
209            .starts_with("Native variant preparation task failed:")
210    );
211}
212
213#[cfg(all(test, feature = "napi"))]
214#[tokio::test]
215async fn napi_variant_generation_succeeds_after_preparing_sources() {
216    let path = test_helpers::webfont_fixture("add.svg");
217    let variants = vec![
218        FontVariant {
219            name: "small".to_owned(),
220            files: vec![path.clone()],
221            weight: Some(300),
222            default: Some(true),
223        },
224        FontVariant {
225            name: "large".to_owned(),
226            files: vec![path.clone()],
227            weight: Some(700),
228            default: None,
229        },
230    ];
231    let options = GenerateWebfontsOptions {
232        dest: "artifacts".to_owned(),
233        files: vec![],
234        types: Some(vec![FontType::Woff2]),
235        variants: Some(variants.clone()),
236        write_files: Some(false),
237        ..Default::default()
238    };
239
240    let result = generate_webfonts(options, None, None, None)
241        .await
242        .expect("variant generation should succeed");
243    assert!(result.woff2_bytes().is_some());
244    assert!(result.eot_bytes().is_none());
245
246    let mut duplicate = variants;
247    duplicate[0].files.push(path);
248    let error = generate_webfonts(
249        GenerateWebfontsOptions {
250            dest: "artifacts".to_owned(),
251            files: vec![],
252            types: Some(vec![FontType::Woff2]),
253            variants: Some(duplicate),
254            ..Default::default()
255        },
256        None,
257        None,
258        None,
259    )
260    .await
261    .err()
262    .expect("duplicate names within one variant should fail");
263    assert!(error.reason.contains("must be unique"));
264}
265
266#[cfg(all(test, feature = "napi"))]
267#[tokio::test]
268async fn napi_generation_keeps_the_ordinary_source_path() {
269    let result = generate_webfonts(
270        GenerateWebfontsOptions {
271            css: Some(false),
272            dest: "artifacts".to_owned(),
273            files: vec![test_helpers::webfont_fixture("add.svg")],
274            html: Some(false),
275            types: Some(vec![FontType::Svg]),
276            write_files: Some(false),
277            ..Default::default()
278        },
279        None,
280        None,
281        None,
282    )
283    .await
284    .expect("ordinary NAPI generation should succeed");
285
286    assert!(result.svg_string().is_some());
287}
288
289/// Generate a webfont from a set of SVG files.
290///
291/// Loads the SVGs listed in `options.files`, builds the configured
292/// `options.types` formats, optionally writes them (along with the CSS and
293/// HTML preview) to `options.dest`, and returns a `GenerateWebfontsResult`
294/// holding the font bytes and template-rendering methods.
295///
296/// Multi-variant input generates one shared variable font per requested modern format.
297///
298/// Optional callbacks:
299/// - `rename(paths)` — derive custom glyph names for the batch of SVG file paths.
300/// - `cssContext(ctx)` — mutate the Handlebars context before CSS rendering;
301///   return the (possibly mutated) context.
302/// - `htmlContext(ctx)` — same, but for the HTML preview.
303#[cfg(feature = "napi")]
304#[napi]
305#[allow(clippy::type_complexity)] // NAPI proc macro requires the verbose ThreadsafeFunction type
306pub async fn generate_webfonts(
307    options: GenerateWebfontsOptions,
308    rename: Option<ThreadsafeFunction<Vec<String>, Vec<String>, Vec<String>, Status, false>>,
309    css_context: Option<
310        ThreadsafeFunction<
311            serde_json::Map<String, serde_json::Value>,
312            serde_json::Map<String, serde_json::Value>,
313            serde_json::Map<String, serde_json::Value>,
314            Status,
315            false,
316        >,
317    >,
318    html_context: Option<
319        ThreadsafeFunction<
320            serde_json::Map<String, serde_json::Value>,
321            serde_json::Map<String, serde_json::Value>,
322            serde_json::Map<String, serde_json::Value>,
323            Status,
324            false,
325        >,
326    >,
327) -> napi::Result<GenerateWebfontsResult> {
328    validate_generate_webfonts_options(&options)?;
329    let mut result = if options.variants.is_some() {
330        let mut resolved_options = resolve_generate_webfonts_options(options)?;
331        let variant_paths = resolved_options
332            .variants
333            .as_ref()
334            .expect("validated variant options must resolve variants")
335            .variants
336            .iter()
337            .map(|variant| variant.files.clone())
338            .collect::<Vec<_>>();
339        let source_files = load_variant_svg_files_napi(&variant_paths, rename.as_ref()).await?;
340        let generation = tokio::task::spawn_blocking(move || {
341            let (family, source_files, cache) =
342                prepare_variant_family(&mut resolved_options, source_files)?;
343            generate_variant_webfonts_sync(resolved_options, source_files, family, cache)
344        });
345        generation.await.map_err(variant_preparation_join_error)??
346    } else {
347        let source_files = load_svg_files_napi(&options.files, rename.as_ref(), true).await?;
348        let mut resolved_options = resolve_generate_webfonts_options(options)?;
349        finalize_generate_webfonts_options(&mut resolved_options, &source_files)?;
350        tokio::task::spawn_blocking(move || generate_webfonts_sync(resolved_options, source_files))
351            .await
352            .map_err(|error| {
353                napi::Error::new(
354                    Status::GenericFailure,
355                    format!("Native webfont generation task failed: {error}"),
356                )
357            })??
358    };
359
360    // Pre-compute mutated contexts via ThreadsafeFunction (async-safe).
361    // When callbacks are present, we build SharedTemplateData here and seed the
362    // OnceLock cache so it isn't re-created in get_cached() / writeFiles.
363    if css_context.is_some() || html_context.is_some() {
364        let shared =
365            SharedTemplateData::new(&result.options, &result.source_files).map_err(to_napi_err)?;
366
367        let mut css_ctx = build_css_context(&result.options, &shared);
368        if css_context.is_some() {
369            css_ctx = apply_context_function(css_ctx, css_context.as_ref())
370                .await
371                .map_err(to_napi_err)?;
372            css_ctx.insert(
373                "__webfontVariantMode".to_owned(),
374                serde_json::Value::Bool(result.options.variants.is_some()),
375            );
376            result.css_context = Some(css_ctx.clone());
377        }
378
379        let mut html_ctx =
380            if result.options.html || html_context.is_some() || result.options.variants.is_some() {
381                if result.options.variants.is_some() && css_context.is_some() {
382                    rendering::build_html_context_with_css(
383                        &result.options,
384                        &shared,
385                        &result.source_files,
386                        &css_ctx,
387                    )
388                    .map_err(to_napi_err)?
389                } else {
390                    build_html_context(&result.options, &shared, &result.source_files, None)
391                        .map_err(to_napi_err)?
392                }
393            } else {
394                serde_json::Map::new()
395            };
396        if html_context.is_some() {
397            html_ctx = apply_context_function(html_ctx, html_context.as_ref())
398                .await
399                .map_err(to_napi_err)?;
400            result.html_context = Some(html_ctx.clone());
401        }
402
403        // Seed the OnceLock -- avoids re-creating SharedTemplateData in get_cached()
404        let (html_registry, html_template_dependencies) =
405            build_html_registry_and_dependencies(&result.options).map_err(to_napi_err)?;
406        let css_hbs_context = handlebars::Context::wraps(&css_ctx).map_err(to_napi_err)?;
407        let html_hbs_context = handlebars::Context::wraps(&html_ctx).map_err(to_napi_err)?;
408        let _ = result.cached.set(Ok(CachedTemplateData {
409            shared,
410            css_context: css_ctx,
411            css_hbs_context: Mutex::new(css_hbs_context),
412            html_context: html_ctx,
413            html_hbs_context: Mutex::new(html_hbs_context),
414            html_template_dependencies,
415            html_registry,
416            render_cache: Mutex::new(Default::default()),
417        }));
418    }
419
420    if result.options.write_files
421        && let Some(written) = write_generate_webfonts_result(&result).await?
422    {
423        // Only incremental results can call `regenerate`, so only they need write-skip state.
424        result.seed_written_outputs(written);
425    }
426
427    Ok(result)
428}
429
430/// A glyph rename function that maps file stems to custom glyph names.
431pub type RenameFn = Box<dyn Fn(&str) -> String + Send + Sync>;
432
433/// Generate webfonts from SVG files.
434///
435/// This is the pure Rust async entry point. Requires a tokio runtime. Multi-variant input generates
436/// one shared variable font per requested modern format.
437pub async fn generate(
438    options: GenerateWebfontsOptions,
439    rename: Option<RenameFn>,
440) -> std::io::Result<GenerateWebfontsResult> {
441    validate_generate_webfonts_options(&options)?;
442    let result = if options.variants.is_some() {
443        let mut resolved_options = resolve_generate_webfonts_options(options)?;
444        let variant_paths = resolved_options
445            .variants
446            .as_ref()
447            .expect("validated variant options must resolve variants")
448            .variants
449            .iter()
450            .map(|variant| variant.files.clone())
451            .collect::<Vec<_>>();
452        let source_files = load_variant_svg_files(&variant_paths, rename.as_deref()).await?;
453        let generation = tokio::task::spawn_blocking(move || {
454            let (family, source_files, cache) =
455                prepare_variant_family(&mut resolved_options, source_files)?;
456            generate_variant_webfonts_sync(resolved_options, source_files, family, cache)
457        });
458        generation.await.map_err(std::io::Error::other)??
459    } else {
460        let source_files = load_svg_files(&options.files, rename.as_deref()).await?;
461        let mut resolved_options = resolve_generate_webfonts_options(options)?;
462        finalize_generate_webfonts_options(&mut resolved_options, &source_files)?;
463        tokio::task::spawn_blocking(move || generate_webfonts_sync(resolved_options, source_files))
464            .await
465            .map_err(std::io::Error::other)??
466    };
467
468    if result.options.write_files
469        && let Some(written) = write_generate_webfonts_result(&result).await?
470    {
471        // Only incremental results can call `regenerate`, so only they need write-skip state.
472        result.seed_written_outputs(written);
473    }
474
475    Ok(result)
476}
477
478/// Synchronous version of [`generate`]. Spawns a tokio runtime internally and has the same source
479/// preparation behavior for multi-variant input.
480pub fn generate_sync(
481    options: GenerateWebfontsOptions,
482    rename: Option<RenameFn>,
483) -> std::io::Result<GenerateWebfontsResult> {
484    tokio::runtime::Runtime::new()?.block_on(generate(options, rename))
485}