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