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