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