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    ColorGlyphSelection, CssContext, FontType, FontVariant, FormatOptions, GenerateWebfontsOptions,
98    GlyphChange, GlyphChangeEntry, HtmlContext, MissingGlyphBehavior, MissingGlyphOptions,
99    RegenerationFiles, SvgFormatOptions, TemplateVariant, TtfFormatOptions, VariantFileSet,
100    Woff2FormatOptions, 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    input::validate_color_names(
134        options,
135        family.glyphs.iter().map(|glyph| glyph.name.as_str()),
136    )?;
137    let variants = options
138        .variants
139        .as_ref()
140        .expect("variant source preparation requires resolved variants");
141    let variant_names = variants
142        .variants
143        .iter()
144        .map(|variant| variant.name.as_str())
145        .collect::<Vec<_>>();
146    let fallback_index = options.missing_glyphs.variant.as_deref().map(|name| {
147        variant_names
148            .iter()
149            .position(|variant_name| *variant_name == name)
150            .expect("validated fallback must name a resolved variant")
151    });
152    resolve_missing_glyphs(
153        &mut family,
154        options.missing_glyphs.behavior,
155        fallback_index,
156        &variant_names,
157    )?;
158    options.codepoints = codepoints;
159    let svg_options = svg::svg_options_from_options(options);
160    let prepared = match cache {
161        Some(cache) => svg::prepare_variant_svg_family_cached(&svg_options, &family, Some(cache))?,
162        None => svg::prepare_variant_svg_family(&svg_options, &family)?,
163    };
164    Ok(prepared)
165}
166
167fn variant_preparation_join_error(error: tokio::task::JoinError) -> std::io::Error {
168    std::io::Error::other(format!("Native variant preparation task failed: {error}"))
169}
170
171#[cfg(test)]
172#[tokio::test]
173async fn variant_preparation_reports_panicking_worker() {
174    let join_error = tokio::task::spawn_blocking(|| panic!("variant preparation panic"))
175        .await
176        .unwrap_err();
177    let error = variant_preparation_join_error(join_error);
178
179    assert!(
180        error
181            .to_string()
182            .starts_with("Native variant preparation task failed:")
183    );
184}
185
186#[cfg(test)]
187#[tokio::test]
188async fn hooked_variant_generation_succeeds_after_preparing_sources() {
189    let path = test_helpers::webfont_fixture("add.svg");
190    let variants = vec![
191        FontVariant {
192            name: "small".to_owned(),
193            files: vec![path.clone()],
194            weight: Some(300),
195            default: Some(true),
196        },
197        FontVariant {
198            name: "large".to_owned(),
199            files: vec![path.clone()],
200            weight: Some(700),
201            default: None,
202        },
203    ];
204    let options = GenerateWebfontsOptions {
205        dest: "artifacts".to_owned(),
206        files: vec![],
207        types: Some(vec![FontType::Woff2]),
208        variants: Some(variants.clone()),
209        write_files: Some(false),
210        ..Default::default()
211    };
212
213    let result = generate_with_hooks(options, &())
214        .await
215        .expect("variant generation should succeed");
216    assert!(result.woff2_bytes().is_some());
217    assert!(result.eot_bytes().is_none());
218
219    let mut duplicate = variants;
220    duplicate[0].files.push(path);
221    let error = generate_with_hooks(
222        GenerateWebfontsOptions {
223            dest: "artifacts".to_owned(),
224            files: vec![],
225            types: Some(vec![FontType::Woff2]),
226            variants: Some(duplicate),
227            ..Default::default()
228        },
229        &(),
230    )
231    .await
232    .err()
233    .expect("duplicate names within one variant should fail");
234    assert!(error.to_string().contains("must be unique"));
235}
236
237#[cfg(test)]
238#[tokio::test]
239async fn hooked_generation_keeps_the_ordinary_source_path() {
240    let result = generate_with_hooks(
241        GenerateWebfontsOptions {
242            css: Some(false),
243            dest: "artifacts".to_owned(),
244            files: vec![test_helpers::webfont_fixture("add.svg")],
245            html: Some(false),
246            types: Some(vec![FontType::Svg]),
247            write_files: Some(false),
248            ..Default::default()
249        },
250        &(),
251    )
252    .await
253    .expect("ordinary generation with hooks should succeed");
254
255    assert!(result.svg_string().is_some());
256}
257
258/// Generate a webfont from a set of SVG files.
259///
260/// Loads the SVGs listed in `options.files`, builds the configured
261/// `options.types` formats, optionally writes them (along with the CSS and
262/// HTML preview) to `options.dest`, and returns a `GenerateWebfontsResult`
263/// holding the font bytes and template-rendering methods.
264///
265/// Multi-variant input generates one shared variable font per requested modern format.
266///
267/// Runtime-independent asynchronous callbacks, supplied through [`GenerationHooks`]:
268/// - `rename(paths)` — derive custom glyph names for the batch of SVG file paths.
269/// - `css_context(ctx)` — mutate the Handlebars context before CSS rendering;
270///   return the (possibly mutated) context.
271/// - `html_context(ctx)` — same, but for the HTML preview.
272pub async fn generate_with_hooks<H: GenerationHooks>(
273    options: GenerateWebfontsOptions,
274    hooks: &H,
275) -> Result<GenerateWebfontsResult, H::Error> {
276    validate_generate_webfonts_options(&options)?;
277    let mut resolved_options = resolve_generate_webfonts_options(options)?;
278    let mut result = if let Some(variants) = &resolved_options.variants {
279        let variant_paths = variants
280            .variants
281            .iter()
282            .map(|variant| variant.files.clone())
283            .collect::<Vec<_>>();
284        let source_files = load_variant_svg_files_with_hooks(&variant_paths, hooks).await?;
285        let generation = tokio::task::spawn_blocking(move || {
286            let (family, source_files, cache) =
287                prepare_variant_family(&mut resolved_options, source_files)?;
288            generate_variant_webfonts_sync(resolved_options, source_files, family, cache)
289        });
290        generation.await.map_err(variant_preparation_join_error)??
291    } else {
292        let source_files = load_svg_files_with_hooks(&resolved_options.files, hooks, true).await?;
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}