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
62mod eot;
63mod incremental;
64mod sfnt;
65mod svg;
66mod templates;
67#[cfg(test)]
68mod test_helpers;
69mod ttf;
70mod types;
71mod util;
72mod woff;
73mod write;
74
75#[cfg(feature = "napi")]
76use napi::threadsafe_function::ThreadsafeFunction;
77#[cfg(feature = "napi")]
78use napi::{Error as NapiError, Status};
79#[cfg(feature = "napi")]
80use napi_derive::napi;
81use rayon::join;
82use std::collections::HashSet;
83use std::io::ErrorKind;
84use std::path::Path;
85use std::sync::Arc;
86#[cfg(feature = "napi")]
87use std::sync::Mutex;
88use tokio::task::JoinSet;
89
90use svg::types::{GlyphCache, PreparedSvgFont, SvgOptions};
91use svg::{
92    build_svg_font, prepare_svg_font, prepare_svg_font_incremental, svg_options_from_options,
93};
94#[cfg(feature = "napi")]
95use templates::{
96    SharedTemplateData, apply_context_function, build_css_context, build_html_context,
97    build_html_registry_and_dependencies,
98};
99use ttf::TtfGlyphCache;
100#[cfg(feature = "napi")]
101use util::to_napi_err;
102use write::write_generate_webfonts_result;
103
104pub use types::{
105    CssContext, FontType, FormatOptions, GenerateWebfontsOptions, GenerateWebfontsResult,
106    GlyphChange, GlyphChangeEntry, HtmlContext, RegenerateError, SvgFormatOptions,
107    TtfFormatOptions, Woff2FormatOptions, WoffFormatOptions,
108};
109use types::{
110    DEFAULT_FONT_ORDER, FontOutputs, LoadedSvgFile, RegenerationState,
111    ResolvedGenerateWebfontsOptions, resolved_font_types,
112};
113
114#[cfg(feature = "bench")]
115pub mod bench_support {
116    use std::io;
117
118    use super::{
119        GenerateWebfontsOptions, GenerateWebfontsResult, GlyphCache, LoadedSvgFile,
120        PreparedSvgFont, build_font_outputs, finalize_generate_webfonts_options, prepare_svg_font,
121        prepare_svg_font_incremental, resolve_generate_webfonts_options, svg_options_from_options,
122    };
123    use crate::sfnt::SerializedFontTables;
124    use crate::svg::types::ParsedGlyph;
125    use crate::svg::{finalize_glyphs, parse_glyphs};
126    use crate::ttf::{self, Woff2TransformCache};
127    use write_fonts::FontBuilder;
128    use write_fonts::types::Tag;
129
130    /// Source fixture used by Rust benchmarks without exposing generator internals.
131    #[derive(Clone)]
132    pub struct BenchSvgSource {
133        pub path: String,
134        pub glyph_name: String,
135        pub contents: String,
136    }
137
138    /// Opaque parsed-glyph cache used by incremental SVG prepare benchmarks.
139    #[derive(Clone, Default)]
140    pub struct BenchGlyphCache(GlyphCache);
141
142    /// Opaque parsed glyph set used to isolate parse and finalize stages.
143    #[derive(Clone)]
144    pub struct BenchParsedGlyphs(Vec<ParsedGlyph>);
145
146    /// Opaque prepared SVG font used to isolate font-output generation stages.
147    #[derive(Clone)]
148    pub struct BenchPreparedSvgFont(PreparedSvgFont);
149
150    /// Opaque serialized TTF table set used to isolate SFNT assembly costs.
151    #[derive(Clone)]
152    pub struct BenchSerializedFontTables(SerializedFontTables);
153
154    /// Opaque WOFF2 transform cache used by preparation benchmarks.
155    #[derive(Clone, Default)]
156    pub struct BenchWoff2TransformCache(Woff2TransformCache);
157
158    /// Opaque prepared WOFF2 directory and table stream.
159    pub struct BenchPreparedWoff2(crate::woff::PreparedWoff2);
160
161    fn load_sources(sources: &[BenchSvgSource]) -> Vec<LoadedSvgFile> {
162        sources
163            .iter()
164            .map(|source| LoadedSvgFile {
165                contents: source.contents.clone(),
166                glyph_name: source.glyph_name.clone(),
167                path: source.path.clone(),
168            })
169            .collect()
170    }
171
172    fn resolve(
173        options: GenerateWebfontsOptions,
174        sources: &[LoadedSvgFile],
175    ) -> io::Result<super::ResolvedGenerateWebfontsOptions> {
176        let mut options = resolve_generate_webfonts_options(options)?;
177        finalize_generate_webfonts_options(&mut options, sources)?;
178        Ok(options)
179    }
180
181    /// Run the SVG parse+process preparation path and return the number of prepared glyphs.
182    pub fn prepare_svg_full(
183        options: GenerateWebfontsOptions,
184        sources: &[BenchSvgSource],
185    ) -> io::Result<usize> {
186        let sources = load_sources(sources);
187        let options = resolve(options, &sources)?;
188        let svg_options = svg_options_from_options(&options);
189        let prepared = prepare_svg_font(&svg_options, &sources)?;
190        Ok(prepared.processed_glyphs.len())
191    }
192
193    /// Parse SVG glyph geometry without running set-wide finalization/processing.
194    pub fn parse_svg_only(
195        options: GenerateWebfontsOptions,
196        sources: &[BenchSvgSource],
197    ) -> io::Result<BenchParsedGlyphs> {
198        let sources = load_sources(sources);
199        let options = resolve(options, &sources)?;
200        let svg_options = svg_options_from_options(&options);
201        parse_glyphs(&svg_options, &sources).map(BenchParsedGlyphs)
202    }
203
204    /// Run set-wide SVG finalization/processing from already parsed glyph geometry.
205    pub fn finalize_svg_only(
206        options: GenerateWebfontsOptions,
207        sources: &[BenchSvgSource],
208        parsed: BenchParsedGlyphs,
209    ) -> io::Result<BenchPreparedSvgFont> {
210        let sources = load_sources(sources);
211        let options = resolve(options, &sources)?;
212        let svg_options = svg_options_from_options(&options);
213        finalize_glyphs(&svg_options, parsed.0).map(BenchPreparedSvgFont)
214    }
215
216    /// Build requested font outputs from an already prepared SVG font and return total output bytes.
217    pub fn build_outputs_only(
218        options: GenerateWebfontsOptions,
219        sources: &[BenchSvgSource],
220        prepared: &BenchPreparedSvgFont,
221    ) -> io::Result<usize> {
222        let sources = load_sources(sources);
223        let options = resolve(options, &sources)?;
224        let svg_options = svg_options_from_options(&options);
225        let fonts = build_font_outputs(&options, &svg_options, &prepared.0, None)?;
226        Ok(fonts.svg_font.as_ref().map_or(0, |v| v.len())
227            + fonts.ttf_font.as_ref().map_or(0, |v| v.len())
228            + fonts.woff_font.as_ref().map_or(0, |v| v.len())
229            + fonts.woff2_font.as_ref().map_or(0, |v| v.len())
230            + fonts.eot_font.as_ref().map_or(0, |v| v.len()))
231    }
232
233    /// Build serialized TTF tables from an already prepared SVG font.
234    pub fn build_serialized_ttf_tables(
235        options: GenerateWebfontsOptions,
236        sources: &[BenchSvgSource],
237        prepared: &BenchPreparedSvgFont,
238    ) -> io::Result<BenchSerializedFontTables> {
239        let sources = load_sources(sources);
240        let options = resolve(options, &sources)?;
241        let ttf_options = ttf::ttf_options_from_options(&options);
242        ttf::generate_ttf_font_from_glyphs(ttf_options, &prepared.0.processed_glyphs)
243            .map(BenchSerializedFontTables)
244    }
245
246    /// Rebuild serialized table metadata from already dumped table bytes.
247    pub fn rewrap_serialized_ttf_tables(
248        tables: &BenchSerializedFontTables,
249    ) -> io::Result<BenchSerializedFontTables> {
250        SerializedFontTables::new(tables.0.clone_raw_tables()).map(BenchSerializedFontTables)
251    }
252
253    /// Assemble final TTF bytes with the current serialized-table SFNT writer, without cache reuse.
254    pub fn serialized_ttf_uncached(tables: &BenchSerializedFontTables) -> Vec<u8> {
255        tables.0.uncached_ttf()
256    }
257
258    /// Encode serialized tables with the internal WOFF2 encoder.
259    pub fn internal_woff2(tables: &BenchSerializedFontTables, quality: u8) -> io::Result<Vec<u8>> {
260        crate::woff::tables_to_woff2(&tables.0, quality, None)
261    }
262
263    /// Prepare the internal WOFF2 stream without Brotli compression.
264    pub fn prepare_internal_woff2(
265        tables: &BenchSerializedFontTables,
266        cache: &mut BenchWoff2TransformCache,
267    ) -> io::Result<BenchPreparedWoff2> {
268        crate::woff::prepare_woff2(&tables.0, &mut cache.0).map(BenchPreparedWoff2)
269    }
270
271    /// Brotli-compress an already prepared internal WOFF2 stream and return its byte length.
272    pub fn compress_prepared_internal_woff2(
273        prepared: &BenchPreparedWoff2,
274        quality: u8,
275    ) -> io::Result<usize> {
276        crate::woff::compress_prepared_woff2(&prepared.0, quality)
277    }
278
279    /// Assemble final TTF bytes with write-fonts FontBuilder from the same serialized tables.
280    pub fn fontbuilder_ttf(tables: &BenchSerializedFontTables) -> Vec<u8> {
281        let mut builder = FontBuilder::new();
282        for table in tables.0.tables() {
283            builder.add_raw(Tag::new(&table.tag), table.bytes.as_slice());
284        }
285        builder.build()
286    }
287
288    /// Clear retained WOFF1 payloads so benchmarks can compare warm vs cold compression cache.
289    pub fn clear_woff1_payload_cache(result: &mut GenerateWebfontsResult) {
290        if let Some(state) = result.regeneration_state.lock().unwrap().as_mut()
291            && let Some(cache) = state.ttf_cache.as_mut()
292        {
293            cache.clear_woff1_payloads();
294        }
295    }
296
297    /// Run the incremental SVG preparation path and return the number of prepared glyphs.
298    pub fn prepare_svg_incremental(
299        options: GenerateWebfontsOptions,
300        sources: &[BenchSvgSource],
301        cache: &mut BenchGlyphCache,
302    ) -> io::Result<usize> {
303        let sources = load_sources(sources);
304        let options = resolve(options, &sources)?;
305        let svg_options = svg_options_from_options(&options);
306        let prepared = prepare_svg_font_incremental(&svg_options, &sources, &mut cache.0)?;
307        Ok(prepared.processed_glyphs.len())
308    }
309}
310
311#[cfg(all(test, feature = "napi"))]
312#[unsafe(no_mangle)]
313extern "C" fn napi_call_threadsafe_function(
314    _: napi::sys::napi_threadsafe_function,
315    _: *mut std::ffi::c_void,
316    _: napi::sys::napi_threadsafe_function_call_mode,
317) -> napi::sys::napi_status {
318    0
319}
320
321/// Generate a webfont from a set of SVG files.
322///
323/// Loads the SVGs listed in `options.files`, builds the configured
324/// `options.types` formats, optionally writes them (along with the CSS and
325/// HTML preview) to `options.dest`, and returns a `GenerateWebfontsResult`
326/// holding the font bytes and template-rendering methods.
327///
328/// Optional callbacks:
329/// - `rename(path)` — derive a custom glyph name from each SVG file path.
330/// - `cssContext(ctx)` — mutate the Handlebars context before CSS rendering;
331///   return the (possibly mutated) context.
332/// - `htmlContext(ctx)` — same, but for the HTML preview.
333#[cfg(feature = "napi")]
334#[napi]
335#[allow(clippy::type_complexity)] // NAPI proc macro requires the verbose ThreadsafeFunction type
336pub async fn generate_webfonts(
337    options: GenerateWebfontsOptions,
338    rename: Option<ThreadsafeFunction<String, String, String, Status, false>>,
339    css_context: Option<
340        ThreadsafeFunction<
341            serde_json::Map<String, serde_json::Value>,
342            serde_json::Map<String, serde_json::Value>,
343            serde_json::Map<String, serde_json::Value>,
344            Status,
345            false,
346        >,
347    >,
348    html_context: Option<
349        ThreadsafeFunction<
350            serde_json::Map<String, serde_json::Value>,
351            serde_json::Map<String, serde_json::Value>,
352            serde_json::Map<String, serde_json::Value>,
353            Status,
354            false,
355        >,
356    >,
357) -> napi::Result<GenerateWebfontsResult> {
358    validate_generate_webfonts_options(&options)?;
359    let source_files = load_svg_files_napi(&options.files, rename.as_ref()).await?;
360    let mut resolved_options = resolve_generate_webfonts_options(options)?;
361    finalize_generate_webfonts_options(&mut resolved_options, &source_files)?;
362
363    let mut result =
364        tokio::task::spawn_blocking(move || generate_webfonts_sync(resolved_options, source_files))
365            .await
366            .map_err(|error| {
367                NapiError::new(
368                    Status::GenericFailure,
369                    format!("Native webfont generation task failed: {error}"),
370                )
371            })??;
372
373    // Pre-compute mutated contexts via ThreadsafeFunction (async-safe).
374    // When callbacks are present, we build SharedTemplateData here and seed the
375    // OnceLock cache so it isn't re-created in get_cached() / writeFiles.
376    if css_context.is_some() || html_context.is_some() {
377        let shared =
378            SharedTemplateData::new(&result.options, &result.source_files).map_err(to_napi_err)?;
379
380        let mut css_ctx = build_css_context(&result.options, &shared);
381        if css_context.is_some() {
382            css_ctx = apply_context_function(css_ctx, css_context.as_ref())
383                .await
384                .map_err(to_napi_err)?;
385            result.css_context = Some(css_ctx.clone());
386        }
387
388        let mut html_ctx = if result.options.html || html_context.is_some() {
389            build_html_context(&result.options, &shared, &result.source_files, None)
390                .map_err(to_napi_err)?
391        } else {
392            serde_json::Map::new()
393        };
394        if html_context.is_some() {
395            html_ctx = apply_context_function(html_ctx, html_context.as_ref())
396                .await
397                .map_err(to_napi_err)?;
398            result.html_context = Some(html_ctx.clone());
399        }
400
401        // Seed the OnceLock -- avoids re-creating SharedTemplateData in get_cached()
402        let (html_registry, html_template_dependencies) =
403            build_html_registry_and_dependencies(&result.options).map_err(to_napi_err)?;
404        let css_hbs_context = handlebars::Context::wraps(&css_ctx).map_err(to_napi_err)?;
405        let html_hbs_context = handlebars::Context::wraps(&html_ctx).map_err(to_napi_err)?;
406        let _ = result.cached.set(Ok(types::CachedTemplateData {
407            shared,
408            css_context: css_ctx,
409            css_hbs_context: Mutex::new(css_hbs_context),
410            html_context: html_ctx,
411            html_hbs_context: Mutex::new(html_hbs_context),
412            html_template_dependencies,
413            html_registry,
414            render_cache: Mutex::new(Default::default()),
415        }));
416    }
417
418    if result.options.write_files
419        && let Some(written) = write_generate_webfonts_result(&result).await?
420    {
421        // Only incremental results can call `regenerate`, so only they need write-skip state.
422        result.seed_written_outputs(written);
423    }
424
425    Ok(result)
426}
427
428/// A glyph rename function that maps file stems to custom glyph names.
429pub type RenameFn = Box<dyn Fn(&str) -> String + Send + Sync>;
430
431/// Generate webfonts from SVG files.
432///
433/// This is the pure Rust async entry point. Requires a tokio runtime.
434pub async fn generate(
435    options: GenerateWebfontsOptions,
436    rename: Option<RenameFn>,
437) -> std::io::Result<GenerateWebfontsResult> {
438    validate_generate_webfonts_options(&options)?;
439    let source_files = load_svg_files(&options.files, rename.as_deref()).await?;
440    let mut resolved_options = resolve_generate_webfonts_options(options)?;
441    finalize_generate_webfonts_options(&mut resolved_options, &source_files)?;
442
443    let result =
444        tokio::task::spawn_blocking(move || generate_webfonts_sync(resolved_options, source_files))
445            .await
446            .map_err(std::io::Error::other)??;
447
448    if result.options.write_files
449        && let Some(written) = write_generate_webfonts_result(&result).await?
450    {
451        // Only incremental results can call `regenerate`, so only they need write-skip state.
452        result.seed_written_outputs(written);
453    }
454
455    Ok(result)
456}
457
458/// Synchronous version of [`generate`]. Spawns a tokio runtime internally.
459pub fn generate_sync(
460    options: GenerateWebfontsOptions,
461    rename: Option<RenameFn>,
462) -> std::io::Result<GenerateWebfontsResult> {
463    tokio::runtime::Runtime::new()?.block_on(generate(options, rename))
464}
465
466fn validate_generate_webfonts_options(options: &GenerateWebfontsOptions) -> std::io::Result<()> {
467    if options.dest.is_empty() {
468        return Err(std::io::Error::new(
469            ErrorKind::InvalidInput,
470            "\"options.dest\" is empty.".to_owned(),
471        ));
472    }
473
474    if options.files.is_empty() {
475        return Err(std::io::Error::new(
476            ErrorKind::InvalidInput,
477            "\"options.files\" is empty.".to_owned(),
478        ));
479    }
480
481    if options.css.unwrap_or(true)
482        && let Some(ref path) = options.css_template
483        && !Path::new(path).exists()
484    {
485        return Err(std::io::Error::new(
486            ErrorKind::InvalidInput,
487            format!("\"options.cssTemplate\" file not found: {path}"),
488        ));
489    }
490
491    if options.html.unwrap_or(false)
492        && let Some(ref path) = options.html_template
493        && !Path::new(path).exists()
494    {
495        return Err(std::io::Error::new(
496            ErrorKind::InvalidInput,
497            format!("\"options.htmlTemplate\" file not found: {path}"),
498        ));
499    }
500
501    if let Some(quality) = options
502        .format_options
503        .as_ref()
504        .and_then(|value| value.woff2.as_ref())
505        .and_then(|value| value.compression_quality)
506        && quality > 11
507    {
508        return Err(std::io::Error::new(
509            ErrorKind::InvalidInput,
510            format!(
511                "\"options.formatOptions.woff2.compressionQuality\" must be between 0 and 11, got {quality}."
512            ),
513        ));
514    }
515
516    Ok(())
517}
518
519pub(crate) fn resolve_generate_webfonts_options(
520    options: GenerateWebfontsOptions,
521) -> std::io::Result<ResolvedGenerateWebfontsOptions> {
522    let types = resolved_font_types(&options);
523    validate_font_type_order(&options, &types)?;
524    let order = resolve_font_type_order(&options, &types);
525    let css = options.css.unwrap_or(true);
526    let html = options.html.unwrap_or(false);
527    let font_name = options.font_name.unwrap_or_else(|| "iconfont".to_owned());
528    let css_dest = options
529        .css_dest
530        .unwrap_or_else(|| default_output_dest(&options.dest, &font_name, "css"));
531    let html_dest = options
532        .html_dest
533        .unwrap_or_else(|| default_output_dest(&options.dest, &font_name, "html"));
534    let write_files = options.write_files.unwrap_or(true);
535    let explicit_codepoints: std::collections::BTreeMap<String, u32> =
536        options.codepoints.unwrap_or_default().into_iter().collect();
537
538    let svg_format = options
539        .format_options
540        .as_ref()
541        .and_then(|fo| fo.svg.as_ref());
542    let center_vertically = svg_format
543        .and_then(|s| s.center_vertically)
544        .or(options.center_vertically);
545    let optimize_output = svg_format
546        .and_then(|s| s.optimize_output)
547        .or(options.optimize_output);
548    let preserve_aspect_ratio = svg_format
549        .and_then(|s| s.preserve_aspect_ratio)
550        .or(options.preserve_aspect_ratio);
551
552    Ok(ResolvedGenerateWebfontsOptions {
553        ascent: options.ascent,
554        center_horizontally: options.center_horizontally,
555        center_vertically,
556        css,
557        css_dest,
558        css_template: match options.css_template {
559            Some(ref t) if t.is_empty() => {
560                return Err(std::io::Error::new(
561                    ErrorKind::InvalidInput,
562                    "\"options.cssTemplate\" must not be empty.".to_owned(),
563                ));
564            }
565            other => other,
566        },
567        codepoints: explicit_codepoints.clone(),
568        explicit_codepoints,
569        css_fonts_url: options.css_fonts_url,
570        descent: options.descent,
571        dest: options.dest,
572        files: options.files,
573        fixed_width: options.fixed_width,
574        format_options: options.format_options,
575        html,
576        html_dest,
577        html_template: match options.html_template {
578            Some(ref t) if t.is_empty() => {
579                return Err(std::io::Error::new(
580                    ErrorKind::InvalidInput,
581                    "\"options.htmlTemplate\" must not be empty.".to_owned(),
582                ));
583            }
584            other => other,
585        },
586        incremental: options.incremental.unwrap_or(false),
587        font_height: options.font_height,
588        font_name,
589        font_style: options.font_style,
590        font_weight: options.font_weight,
591        ligature: options.ligature.unwrap_or(true),
592        normalize: options.normalize.unwrap_or(true),
593        order,
594        optimize_output,
595        preserve_aspect_ratio,
596        round: options.round,
597        start_codepoint: options.start_codepoint.unwrap_or(0xF101),
598        template_options: options.template_options,
599        types,
600        write_files,
601    })
602}
603
604pub(crate) fn finalize_generate_webfonts_options(
605    options: &mut ResolvedGenerateWebfontsOptions,
606    source_files: &[LoadedSvgFile],
607) -> std::io::Result<()> {
608    options.codepoints = resolve_codepoints(
609        source_files,
610        &options.explicit_codepoints,
611        options.start_codepoint,
612    )?;
613
614    Ok(())
615}
616
617fn resolve_font_type_order(options: &GenerateWebfontsOptions, types: &[FontType]) -> Vec<FontType> {
618    match &options.order {
619        Some(order) => order.clone(),
620        None => DEFAULT_FONT_ORDER
621            .iter()
622            .copied()
623            .filter(|font_type| types.contains(font_type))
624            .collect(),
625    }
626}
627
628fn default_output_dest(dest: &str, font_name: &str, extension: &str) -> String {
629    Path::new(dest)
630        .join(format!("{font_name}.{extension}"))
631        .to_string_lossy()
632        .into_owned()
633}
634
635fn generate_webfonts_sync(
636    options: ResolvedGenerateWebfontsOptions,
637    source_files: Vec<LoadedSvgFile>,
638) -> std::io::Result<GenerateWebfontsResult> {
639    let svg_options = svg_options_from_options(&options);
640    // When incremental, retain the parsed-glyph cache so a later `regenerate` can reuse the
641    // glyphs whose source didn't change. Otherwise the geometry is dropped as soon as the font
642    // is built, so one-shot builds carry no extra memory.
643    let (prepared, glyph_cache, mut ttf_cache) = if options.incremental {
644        let mut cache = GlyphCache::default();
645        let prepared = prepare_svg_font_incremental(&svg_options, &source_files, &mut cache)?;
646        (prepared, Some(cache), Some(TtfGlyphCache::default()))
647    } else {
648        (prepare_svg_font(&svg_options, &source_files)?, None, None)
649    };
650    let fonts = build_font_outputs(&options, &svg_options, &prepared, ttf_cache.as_mut())?;
651    let regeneration_state = glyph_cache.map(|glyph_cache| RegenerationState {
652        caches_dirty: false,
653        glyph_cache,
654        ttf_cache,
655        written_outputs: std::collections::HashMap::new(),
656    });
657
658    Ok(GenerateWebfontsResult {
659        cached: std::sync::OnceLock::new(),
660        carried_render: None,
661        css_context: None,
662        fonts,
663        html_context: None,
664        options: std::sync::Arc::new(options),
665        regeneration_state: std::sync::Arc::new(std::sync::Mutex::new(regeneration_state)),
666        source_files: std::sync::Arc::new(source_files),
667    })
668}
669
670/// Build every requested output format from an already-prepared glyph set.
671fn build_font_outputs(
672    options: &ResolvedGenerateWebfontsOptions,
673    svg_options: &SvgOptions<'_>,
674    prepared: &PreparedSvgFont,
675    mut ttf_cache: Option<&mut TtfGlyphCache>,
676) -> std::io::Result<FontOutputs> {
677    let wants_svg = options.types.contains(&FontType::Svg);
678    let wants_ttf = options.types.contains(&FontType::Ttf);
679    let wants_woff = options.types.contains(&FontType::Woff);
680    let wants_woff2 = options.types.contains(&FontType::Woff2);
681    let wants_eot = options.types.contains(&FontType::Eot);
682
683    let (svg_font, ttf_tables) = join(
684        || -> std::io::Result<Option<String>> {
685            if wants_svg {
686                Ok(Some(build_svg_font(svg_options, prepared)))
687            } else {
688                Ok(None)
689            }
690        },
691        || -> std::io::Result<Option<sfnt::SerializedFontTables>> {
692            if wants_ttf || wants_woff || wants_woff2 || wants_eot {
693                let ttf_options = ttf::ttf_options_from_options(options);
694                match ttf_cache.as_deref_mut() {
695                    Some(cache) => ttf::generate_ttf_font_from_glyphs_cached(
696                        ttf_options,
697                        &prepared.processed_glyphs,
698                        cache,
699                    )
700                    .map(Some),
701                    None => {
702                        ttf::generate_ttf_font_from_glyphs(ttf_options, &prepared.processed_glyphs)
703                            .map(Some)
704                    }
705                }
706            } else {
707                Ok(None)
708            }
709        },
710    );
711
712    let svg_font = svg_font?.map(Arc::new);
713    let ttf_tables = ttf_tables?;
714
715    let (ttf_font, woff_font, woff2_font, eot_font) = if let Some(ttf_tables) = ttf_tables {
716        let woff_metadata = options
717            .format_options
718            .as_ref()
719            .and_then(|value| value.woff.as_ref())
720            .and_then(|value| value.metadata.as_deref());
721        let woff2_quality = options
722            .format_options
723            .as_ref()
724            .and_then(|value| value.woff2.as_ref())
725            .and_then(|value| value.compression_quality)
726            .unwrap_or(11);
727
728        let ttf_tables = Arc::new(ttf_tables);
729        let ttf_font = wants_ttf.then(|| ttf_tables.ttf_arc());
730        let (woff1_cache, woff2_cache) = match ttf_cache {
731            Some(cache) => {
732                let (woff1, woff2) = cache.output_caches();
733                (Some(woff1), Some(woff2))
734            }
735            None => (None, None),
736        };
737        let (woff_font, (woff2_font, eot_font)) = join(
738            || -> std::io::Result<Option<Vec<u8>>> {
739                if wants_woff {
740                    match woff1_cache {
741                        Some(cache) => {
742                            woff::tables_to_woff1_cached(&ttf_tables, woff_metadata, cache)
743                        }
744                        None => woff::tables_to_woff1(&ttf_tables, woff_metadata),
745                    }
746                    .map(Some)
747                } else {
748                    Ok(None)
749                }
750            },
751            || {
752                join(
753                    || -> std::io::Result<Option<Vec<u8>>> {
754                        if wants_woff2 {
755                            woff::tables_to_woff2(&ttf_tables, woff2_quality, woff2_cache).map(Some)
756                        } else {
757                            Ok(None)
758                        }
759                    },
760                    || -> std::io::Result<Option<Vec<u8>>> {
761                        if wants_eot {
762                            eot::tables_to_eot(&ttf_tables).map(Some)
763                        } else {
764                            Ok(None)
765                        }
766                    },
767                )
768            },
769        );
770
771        (
772            ttf_font,
773            woff_font?.map(Arc::new),
774            woff2_font?.map(Arc::new),
775            eot_font?.map(Arc::new),
776        )
777    } else {
778        (None, None, None, None)
779    };
780
781    Ok(FontOutputs {
782        svg_font,
783        ttf_font,
784        woff_font,
785        woff2_font,
786        eot_font,
787    })
788}
789
790fn validate_font_type_order(
791    options: &GenerateWebfontsOptions,
792    requested_types: &[FontType],
793) -> std::io::Result<()> {
794    if let Some(order) = &options.order
795        && let Some(invalid_type) = order
796            .iter()
797            .copied()
798            .find(|font_type| !requested_types.contains(font_type))
799    {
800        return Err(std::io::Error::new(
801            ErrorKind::InvalidInput,
802            format!(
803                "Invalid font type order: '{}' is not present in 'types'.",
804                invalid_type.as_extension()
805            ),
806        ));
807    }
808
809    Ok(())
810}
811
812/// Load SVG file contents in parallel, preserving the original order.
813async fn load_svg_contents(paths: &[String]) -> std::io::Result<Vec<(String, String)>> {
814    let mut tasks = JoinSet::new();
815
816    for (index, path) in paths.iter().cloned().enumerate() {
817        tasks.spawn(async move {
818            tokio::fs::read_to_string(&path)
819                .await
820                .map(|contents| (index, (path, contents)))
821        });
822    }
823
824    let mut results = Vec::with_capacity(paths.len());
825    while let Some(result) = tasks.join_next().await {
826        let (index, pair) = result
827            .map_err(|error| std::io::Error::other(format!("SVG loading task failed: {error}")))?
828            .map_err(|error| {
829                std::io::Error::other(format!("Failed to read source SVG file: {error}"))
830            })?;
831        results.push((index, pair));
832    }
833
834    results.sort_by_key(|(index, _)| *index);
835    Ok(results.into_iter().map(|(_, pair)| pair).collect())
836}
837
838/// Load SVG files and resolve glyph names using an optional sync rename function.
839async fn load_svg_files(
840    paths: &[String],
841    rename: Option<&(dyn Fn(&str) -> String + Send + Sync)>,
842) -> std::io::Result<Vec<LoadedSvgFile>> {
843    let raw = load_svg_contents(paths).await?;
844    let source_files: Vec<LoadedSvgFile> = raw
845        .into_iter()
846        .map(|(path, contents)| {
847            let glyph_name = util::glyph_name_from_path(&path, rename)?;
848            Ok(LoadedSvgFile {
849                contents,
850                glyph_name,
851                path,
852            })
853        })
854        .collect::<std::io::Result<_>>()?;
855
856    validate_glyph_names(&source_files)?;
857    Ok(source_files)
858}
859
860/// NAPI version: resolve glyph names via async ThreadsafeFunction callback.
861#[cfg(feature = "napi")]
862async fn load_svg_files_napi(
863    paths: &[String],
864    rename: Option<
865        &napi::threadsafe_function::ThreadsafeFunction<String, String, String, Status, false>,
866    >,
867) -> napi::Result<Vec<LoadedSvgFile>> {
868    let raw = load_svg_contents(paths).await.map_err(to_napi_err)?;
869    let mut source_files = Vec::with_capacity(raw.len());
870
871    for (path, contents) in raw {
872        let glyph_name = if let Some(rename) = rename {
873            rename.call_async(path.clone()).await?
874        } else {
875            util::default_glyph_name_from_path(&path).map_err(to_napi_err)?
876        };
877        source_files.push(LoadedSvgFile {
878            contents,
879            glyph_name,
880            path,
881        });
882    }
883
884    validate_glyph_names(&source_files).map_err(to_napi_err)?;
885    Ok(source_files)
886}
887
888pub(crate) fn validate_glyph_names(source_files: &[LoadedSvgFile]) -> std::io::Result<()> {
889    let mut seen_names = HashSet::with_capacity(source_files.len());
890
891    for source_file in source_files {
892        if !seen_names.insert(source_file.glyph_name.clone()) {
893            return Err(std::io::Error::new(
894                ErrorKind::InvalidInput,
895                format!(
896                    "The glyph name \"{}\" must be unique.",
897                    source_file.glyph_name
898                ),
899            ));
900        }
901    }
902
903    Ok(())
904}
905
906// Re-export resolve_codepoints for use in finalize_generate_webfonts_options
907use util::resolve_codepoints;
908
909#[cfg(test)]
910mod tests {
911    use super::{
912        resolve_generate_webfonts_options, resolved_font_types, validate_font_type_order,
913        validate_generate_webfonts_options, woff,
914    };
915    use crate::{
916        FontType, FormatOptions, GenerateWebfontsOptions, Woff2FormatOptions,
917        ttf::generate_ttf_font_bytes,
918    };
919
920    #[test]
921    fn generates_woff2_font_with_expected_header() {
922        let ttf_result = generate_ttf_font_bytes(GenerateWebfontsOptions {
923            css: Some(false),
924            dest: "artifacts".to_owned(),
925            files: vec![format!(
926                "{}/../vite-svg-2-webfont/src/fixtures/webfont-test/svg/add.svg",
927                env!("CARGO_MANIFEST_DIR")
928            )],
929            html: Some(false),
930            font_name: Some("iconfont".to_owned()),
931            ligature: Some(false),
932            ..Default::default()
933        })
934        .expect("expected ttf generation to succeed");
935
936        let result = woff::ttf_to_woff2(&ttf_result, 10).expect("woff2 generation should succeed");
937
938        assert_eq!(&result[..4], b"wOF2");
939    }
940
941    #[test]
942    fn rejects_order_entries_that_are_not_present_in_types() {
943        let options = GenerateWebfontsOptions {
944            dest: "artifacts".to_owned(),
945            files: vec![],
946            font_name: Some("iconfont".to_owned()),
947            ligature: Some(false),
948            order: Some(vec![FontType::Svg, FontType::Woff]),
949            types: Some(vec![FontType::Svg]),
950            ..Default::default()
951        };
952
953        let error = validate_font_type_order(&options, &resolved_font_types(&options)).unwrap_err();
954
955        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
956        assert!(
957            error
958                .to_string()
959                .contains("Invalid font type order: 'woff' is not present in 'types'.")
960        );
961    }
962
963    #[test]
964    fn rejects_an_empty_dest() {
965        let options = GenerateWebfontsOptions {
966            dest: String::new(),
967            files: vec!["icon.svg".to_owned()],
968            font_name: Some("iconfont".to_owned()),
969            ligature: Some(false),
970            types: Some(vec![FontType::Svg]),
971            ..Default::default()
972        };
973
974        let error = validate_generate_webfonts_options(&options).unwrap_err();
975
976        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
977        assert!(error.to_string().contains("\"options.dest\" is empty."));
978    }
979
980    #[test]
981    fn rejects_empty_files() {
982        let options = GenerateWebfontsOptions {
983            dest: "artifacts".to_owned(),
984            files: vec![],
985            font_name: Some("iconfont".to_owned()),
986            ligature: Some(false),
987            types: Some(vec![FontType::Svg]),
988            ..Default::default()
989        };
990
991        let error = validate_generate_webfonts_options(&options).unwrap_err();
992
993        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
994        assert!(error.to_string().contains("\"options.files\" is empty."));
995    }
996
997    fn options_with_woff2_quality(quality: u8) -> GenerateWebfontsOptions {
998        GenerateWebfontsOptions {
999            css: Some(false),
1000            dest: "artifacts".to_owned(),
1001            files: vec!["icon.svg".to_owned()],
1002            font_name: Some("iconfont".to_owned()),
1003            format_options: Some(FormatOptions {
1004                woff2: Some(Woff2FormatOptions {
1005                    compression_quality: Some(quality),
1006                }),
1007                ..Default::default()
1008            }),
1009            html: Some(false),
1010            ligature: Some(false),
1011            types: Some(vec![FontType::Woff2]),
1012            ..Default::default()
1013        }
1014    }
1015
1016    #[test]
1017    fn rejects_woff2_compression_quality_above_11() {
1018        let error =
1019            validate_generate_webfonts_options(&options_with_woff2_quality(12)).unwrap_err();
1020
1021        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
1022        assert!(error.to_string().contains(
1023            "\"options.formatOptions.woff2.compressionQuality\" must be between 0 and 11, got 12."
1024        ));
1025    }
1026
1027    #[test]
1028    fn accepts_woff2_compression_quality_of_11() {
1029        validate_generate_webfonts_options(&options_with_woff2_quality(11))
1030            .expect("compression quality 11 is the upper bound and must be accepted");
1031    }
1032
1033    #[test]
1034    fn rejects_empty_css_template() {
1035        let options = GenerateWebfontsOptions {
1036            css: Some(true),
1037            css_template: Some(String::new()),
1038            dest: "artifacts".to_owned(),
1039            files: vec!["icon.svg".to_owned()],
1040            html: Some(false),
1041            font_name: Some("iconfont".to_owned()),
1042            ligature: Some(false),
1043            types: Some(vec![FontType::Svg]),
1044            ..Default::default()
1045        };
1046
1047        let error = resolve_generate_webfonts_options(options)
1048            .err()
1049            .expect("expected empty css template to fail");
1050
1051        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
1052        assert!(
1053            error
1054                .to_string()
1055                .contains("\"options.cssTemplate\" must not be empty.")
1056        );
1057    }
1058
1059    #[test]
1060    fn rejects_empty_html_template() {
1061        let options = GenerateWebfontsOptions {
1062            css: Some(false),
1063            dest: "artifacts".to_owned(),
1064            files: vec!["icon.svg".to_owned()],
1065            html: Some(true),
1066            html_template: Some(String::new()),
1067            font_name: Some("iconfont".to_owned()),
1068            ligature: Some(false),
1069            types: Some(vec![FontType::Svg]),
1070            ..Default::default()
1071        };
1072
1073        let error = resolve_generate_webfonts_options(options)
1074            .err()
1075            .expect("expected empty html template to fail");
1076
1077        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
1078        assert!(
1079            error
1080                .to_string()
1081                .contains("\"options.htmlTemplate\" must not be empty.")
1082        );
1083    }
1084
1085    #[test]
1086    fn resolves_write_defaults_from_dest_and_font_name() {
1087        let options = GenerateWebfontsOptions {
1088            css: Some(false),
1089            dest: "artifacts".to_owned(),
1090            files: vec!["icon.svg".to_owned()],
1091            html: Some(false),
1092            font_name: Some("iconfont".to_owned()),
1093            ligature: Some(false),
1094            types: Some(vec![FontType::Svg]),
1095            ..Default::default()
1096        };
1097
1098        let resolved = resolve_generate_webfonts_options(options)
1099            .expect("expected defaults to resolve successfully");
1100
1101        assert!(resolved.write_files);
1102        assert_eq!(resolved.css_dest, "artifacts/iconfont.css");
1103        assert_eq!(resolved.html_dest, "artifacts/iconfont.html");
1104    }
1105
1106    #[test]
1107    fn rejects_nonexistent_css_template_when_css_is_true() {
1108        let error = validate_generate_webfonts_options(&GenerateWebfontsOptions {
1109            css: Some(true),
1110            css_template: Some("/tmp/__nonexistent_template__.hbs".to_owned()),
1111            dest: "artifacts".to_owned(),
1112            files: vec!["icon.svg".to_owned()],
1113            html: Some(false),
1114            ..Default::default()
1115        })
1116        .unwrap_err();
1117
1118        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
1119        assert!(error.to_string().contains("cssTemplate"));
1120    }
1121
1122    #[test]
1123    fn allows_nonexistent_css_template_when_css_is_false() {
1124        validate_generate_webfonts_options(&GenerateWebfontsOptions {
1125            css: Some(false),
1126            css_template: Some("/tmp/__nonexistent_template__.hbs".to_owned()),
1127            dest: "artifacts".to_owned(),
1128            files: vec!["icon.svg".to_owned()],
1129            html: Some(false),
1130            ..Default::default()
1131        })
1132        .expect("should allow nonexistent css template when css is false");
1133    }
1134
1135    #[test]
1136    fn rejects_nonexistent_html_template_when_html_is_true() {
1137        let error = validate_generate_webfonts_options(&GenerateWebfontsOptions {
1138            css: Some(false),
1139            dest: "artifacts".to_owned(),
1140            files: vec!["icon.svg".to_owned()],
1141            html: Some(true),
1142            html_template: Some("/tmp/__nonexistent_template__.hbs".to_owned()),
1143            ..Default::default()
1144        })
1145        .unwrap_err();
1146
1147        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
1148        assert!(error.to_string().contains("htmlTemplate"));
1149    }
1150
1151    #[test]
1152    fn allows_nonexistent_html_template_when_html_is_false() {
1153        validate_generate_webfonts_options(&GenerateWebfontsOptions {
1154            css: Some(false),
1155            dest: "artifacts".to_owned(),
1156            files: vec!["icon.svg".to_owned()],
1157            html: Some(false),
1158            html_template: Some("/tmp/__nonexistent_template__.hbs".to_owned()),
1159            ..Default::default()
1160        })
1161        .expect("should allow nonexistent html template when html is false");
1162    }
1163}