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//!
61//! Node.js bindings are built by the separate, unpublished `webfont-generator-napi` crate.
62
63#[cfg(feature = "bench")]
64pub mod bench_support;
65mod byte_helpers;
66mod formats;
67mod hooks;
68mod incremental;
69mod input;
70mod output;
71mod pipeline;
72mod rendering;
73mod result;
74mod sfnt;
75mod svg;
76#[cfg(test)]
77mod test_helpers;
78mod types;
79
80pub use hooks::GenerationHooks;
81use std::sync::Mutex;
82
83use input::{
84    ResolvedGenerateWebfontsOptions, build_variant_family_sources,
85    finalize_generate_webfonts_options, resolve_generate_webfonts_options, resolve_missing_glyphs,
86    validate_generate_webfonts_options,
87};
88use input::{load_svg_files_with_hooks, load_variant_svg_files_with_hooks};
89use output::write_generate_webfonts_result;
90use pipeline::{generate_variant_webfonts_sync, generate_webfonts_sync};
91use rendering::{
92    CachedTemplateData, SharedTemplateData, build_css_context, build_html_context,
93    build_html_registry_and_dependencies,
94};
95pub use result::{GenerateWebfontsResult, RegenerateError};
96pub use types::{
97    CssContext, FontType, FontVariant, FormatOptions, GenerateWebfontsOptions, GlyphChange,
98    GlyphChangeEntry, HtmlContext, MissingGlyphBehavior, MissingGlyphOptions, RegenerationFiles,
99    SvgFormatOptions, TemplateVariant, TtfFormatOptions, VariantFileSet, Woff2FormatOptions,
100    WoffFormatOptions,
101};
102
103type PreparedVariantInput = (
104    svg::types::PreparedVariantFamily,
105    Vec<input::LoadedSvgFile>,
106    Option<svg::VariantGlyphCache>,
107);
108
109fn prepare_variant_family(
110    options: &mut ResolvedGenerateWebfontsOptions,
111    source_files: Vec<Vec<input::LoadedSvgFile>>,
112) -> std::io::Result<PreparedVariantInput> {
113    let mut cache = options.incremental.then(svg::VariantGlyphCache::default);
114    let family = prepare_variant_family_cached(
115        options,
116        source_files.iter().map(Vec::as_slice).collect(),
117        cache.as_mut(),
118    )?;
119    let sources = source_files.into_iter().flatten().collect();
120    Ok((family, sources, cache))
121}
122
123fn prepare_variant_family_cached(
124    options: &mut ResolvedGenerateWebfontsOptions,
125    source_files: Vec<&[input::LoadedSvgFile]>,
126    cache: Option<&mut svg::VariantGlyphCache>,
127) -> std::io::Result<svg::types::PreparedVariantFamily> {
128    let (mut family, codepoints) = build_variant_family_sources(
129        source_files,
130        &options.explicit_codepoints,
131        options.start_codepoint,
132    )?;
133    let variants = options
134        .variants
135        .as_ref()
136        .expect("variant source preparation requires resolved variants");
137    let variant_names = variants
138        .variants
139        .iter()
140        .map(|variant| variant.name.as_str())
141        .collect::<Vec<_>>();
142    let fallback_index = options.missing_glyphs.variant.as_deref().map(|name| {
143        variant_names
144            .iter()
145            .position(|variant_name| *variant_name == name)
146            .expect("validated fallback must name a resolved variant")
147    });
148    resolve_missing_glyphs(
149        &mut family,
150        options.missing_glyphs.behavior,
151        fallback_index,
152        &variant_names,
153    )?;
154    options.codepoints = codepoints;
155    let svg_options = svg::svg_options_from_options(options);
156    let prepared = match cache {
157        Some(cache) => svg::prepare_variant_svg_family_cached(&svg_options, &family, Some(cache))?,
158        None => svg::prepare_variant_svg_family(&svg_options, &family)?,
159    };
160    Ok(prepared)
161}
162
163fn variant_preparation_join_error(error: tokio::task::JoinError) -> std::io::Error {
164    std::io::Error::other(format!("Native variant preparation task failed: {error}"))
165}
166
167#[cfg(test)]
168#[tokio::test]
169async fn variant_preparation_reports_panicking_worker() {
170    let join_error = tokio::task::spawn_blocking(|| panic!("variant preparation panic"))
171        .await
172        .unwrap_err();
173    let error = variant_preparation_join_error(join_error);
174
175    assert!(
176        error
177            .to_string()
178            .starts_with("Native variant preparation task failed:")
179    );
180}
181
182#[cfg(test)]
183#[tokio::test]
184async fn hooked_variant_generation_succeeds_after_preparing_sources() {
185    let path = test_helpers::webfont_fixture("add.svg");
186    let variants = vec![
187        FontVariant {
188            name: "small".to_owned(),
189            files: vec![path.clone()],
190            weight: Some(300),
191            default: Some(true),
192        },
193        FontVariant {
194            name: "large".to_owned(),
195            files: vec![path.clone()],
196            weight: Some(700),
197            default: None,
198        },
199    ];
200    let options = GenerateWebfontsOptions {
201        dest: "artifacts".to_owned(),
202        files: vec![],
203        types: Some(vec![FontType::Woff2]),
204        variants: Some(variants.clone()),
205        write_files: Some(false),
206        ..Default::default()
207    };
208
209    let result = generate_with_hooks(options, &())
210        .await
211        .expect("variant generation should succeed");
212    assert!(result.woff2_bytes().is_some());
213    assert!(result.eot_bytes().is_none());
214
215    let mut duplicate = variants;
216    duplicate[0].files.push(path);
217    let error = generate_with_hooks(
218        GenerateWebfontsOptions {
219            dest: "artifacts".to_owned(),
220            files: vec![],
221            types: Some(vec![FontType::Woff2]),
222            variants: Some(duplicate),
223            ..Default::default()
224        },
225        &(),
226    )
227    .await
228    .err()
229    .expect("duplicate names within one variant should fail");
230    assert!(error.to_string().contains("must be unique"));
231}
232
233#[cfg(test)]
234#[tokio::test]
235async fn hooked_generation_keeps_the_ordinary_source_path() {
236    let result = generate_with_hooks(
237        GenerateWebfontsOptions {
238            css: Some(false),
239            dest: "artifacts".to_owned(),
240            files: vec![test_helpers::webfont_fixture("add.svg")],
241            html: Some(false),
242            types: Some(vec![FontType::Svg]),
243            write_files: Some(false),
244            ..Default::default()
245        },
246        &(),
247    )
248    .await
249    .expect("ordinary generation with hooks should succeed");
250
251    assert!(result.svg_string().is_some());
252}
253
254/// Generate a webfont from a set of SVG files.
255///
256/// Loads the SVGs listed in `options.files`, builds the configured
257/// `options.types` formats, optionally writes them (along with the CSS and
258/// HTML preview) to `options.dest`, and returns a `GenerateWebfontsResult`
259/// holding the font bytes and template-rendering methods.
260///
261/// Multi-variant input generates one shared variable font per requested modern format.
262///
263/// Runtime-independent asynchronous callbacks, supplied through [`GenerationHooks`]:
264/// - `rename(paths)` — derive custom glyph names for the batch of SVG file paths.
265/// - `css_context(ctx)` — mutate the Handlebars context before CSS rendering;
266///   return the (possibly mutated) context.
267/// - `html_context(ctx)` — same, but for the HTML preview.
268pub async fn generate_with_hooks<H: GenerationHooks>(
269    options: GenerateWebfontsOptions,
270    hooks: &H,
271) -> Result<GenerateWebfontsResult, H::Error> {
272    validate_generate_webfonts_options(&options)?;
273    let mut result = if options.variants.is_some() {
274        let mut resolved_options = resolve_generate_webfonts_options(options)?;
275        let variant_paths = resolved_options
276            .variants
277            .as_ref()
278            .expect("validated variant options must resolve variants")
279            .variants
280            .iter()
281            .map(|variant| variant.files.clone())
282            .collect::<Vec<_>>();
283        let source_files = load_variant_svg_files_with_hooks(&variant_paths, hooks).await?;
284        let generation = tokio::task::spawn_blocking(move || {
285            let (family, source_files, cache) =
286                prepare_variant_family(&mut resolved_options, source_files)?;
287            generate_variant_webfonts_sync(resolved_options, source_files, family, cache)
288        });
289        generation.await.map_err(variant_preparation_join_error)??
290    } else {
291        let source_files = load_svg_files_with_hooks(&options.files, hooks, true).await?;
292        let mut resolved_options = resolve_generate_webfonts_options(options)?;
293        finalize_generate_webfonts_options(&mut resolved_options, &source_files)?;
294        tokio::task::spawn_blocking(move || generate_webfonts_sync(resolved_options, source_files))
295            .await
296            .map_err(|error| {
297                std::io::Error::other(format!("Native webfont generation task failed: {error}"))
298            })??
299    };
300
301    // Pre-compute mutated contexts through runtime-independent async hooks.
302    // When callbacks are present, we build SharedTemplateData here and seed the
303    // OnceLock cache so it isn't re-created in get_cached() / writeFiles.
304    if hooks.has_css_context() || hooks.has_html_context() {
305        let shared = SharedTemplateData::new(&result.options, &result.source_files)?;
306
307        let mut css_ctx = build_css_context(&result.options, &shared);
308        if hooks.has_css_context() {
309            css_ctx = hooks.css_context(css_ctx).await?;
310            css_ctx.insert(
311                "__webfontVariantMode".to_owned(),
312                serde_json::Value::Bool(result.options.variants.is_some()),
313            );
314            result.css_context = Some(css_ctx.clone());
315        }
316
317        let mut html_ctx =
318            if result.options.html || hooks.has_html_context() || result.options.variants.is_some()
319            {
320                if result.options.variants.is_some() && hooks.has_css_context() {
321                    rendering::build_html_context_with_css(
322                        &result.options,
323                        &shared,
324                        &result.source_files,
325                        &css_ctx,
326                    )?
327                } else {
328                    build_html_context(&result.options, &shared, &result.source_files, None)?
329                }
330            } else {
331                serde_json::Map::new()
332            };
333        if hooks.has_html_context() {
334            html_ctx = hooks.html_context(html_ctx).await?;
335            result.html_context = Some(html_ctx.clone());
336        }
337
338        // Seed the OnceLock -- avoids re-creating SharedTemplateData in get_cached()
339        let (html_registry, html_template_dependencies) =
340            build_html_registry_and_dependencies(&result.options)?;
341        let css_hbs_context =
342            handlebars::Context::wraps(&css_ctx).map_err(std::io::Error::other)?;
343        let html_hbs_context =
344            handlebars::Context::wraps(&html_ctx).map_err(std::io::Error::other)?;
345        let _ = result.cached.set(Ok(CachedTemplateData {
346            shared,
347            css_context: css_ctx,
348            css_hbs_context: Mutex::new(css_hbs_context),
349            html_context: html_ctx,
350            html_hbs_context: Mutex::new(html_hbs_context),
351            html_template_dependencies,
352            html_registry,
353            render_cache: Mutex::new(Default::default()),
354        }));
355    }
356
357    if result.options.write_files
358        && let Some(written) = write_generate_webfonts_result(&result).await?
359    {
360        // Only incremental results can call `regenerate`, so only they need write-skip state.
361        result.seed_written_outputs(written);
362    }
363
364    Ok(result)
365}
366
367/// A glyph rename function that maps file stems to custom glyph names.
368pub type RenameFn = Box<dyn Fn(&str) -> String + Send + Sync>;
369
370/// Generate webfonts from SVG files.
371///
372/// This is the pure Rust async entry point. Requires a tokio runtime. Multi-variant input generates
373/// one shared variable font per requested modern format.
374pub async fn generate(
375    options: GenerateWebfontsOptions,
376    rename: Option<RenameFn>,
377) -> std::io::Result<GenerateWebfontsResult> {
378    generate_with_hooks(options, &hooks::RenameHooks(rename.as_deref())).await
379}
380
381/// Synchronous version of [`generate`]. Spawns a tokio runtime internally and has the same source
382/// preparation behavior for multi-variant input.
383pub fn generate_sync(
384    options: GenerateWebfontsOptions,
385    rename: Option<RenameFn>,
386) -> std::io::Result<GenerateWebfontsResult> {
387    tokio::runtime::Runtime::new()?.block_on(generate(options, rename))
388}