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 svg;
64mod templates;
65#[cfg(test)]
66mod test_helpers;
67mod ttf;
68mod types;
69mod util;
70mod woff;
71
72#[cfg(feature = "napi")]
73use napi::threadsafe_function::ThreadsafeFunction;
74#[cfg(feature = "napi")]
75use napi::{Error as NapiError, Status};
76#[cfg(feature = "napi")]
77use napi_derive::napi;
78use rayon::join;
79use std::collections::HashSet;
80use std::io::ErrorKind;
81use std::path::Path;
82use std::sync::Arc;
83#[cfg(feature = "napi")]
84use std::sync::Mutex;
85use tokio::task::JoinSet;
86
87use svg::{build_svg_font, prepare_svg_font, svg_options_from_options};
88#[cfg(feature = "napi")]
89use templates::{
90    SharedTemplateData, apply_context_function, build_css_context, build_html_context,
91    build_html_registry,
92};
93use templates::{render_css_with_hbs_context, render_html_with_hbs_context};
94#[cfg(feature = "napi")]
95use util::to_napi_err;
96
97pub use types::{
98    CssContext, FontType, FormatOptions, GenerateWebfontsOptions, GenerateWebfontsResult,
99    HtmlContext, SvgFormatOptions, TtfFormatOptions, WoffFormatOptions,
100};
101use types::{
102    DEFAULT_FONT_ORDER, LoadedSvgFile, ResolvedGenerateWebfontsOptions, resolved_font_types,
103};
104
105#[cfg(all(test, feature = "napi"))]
106#[unsafe(no_mangle)]
107extern "C" fn napi_call_threadsafe_function(
108    _: napi::sys::napi_threadsafe_function,
109    _: *mut std::ffi::c_void,
110    _: napi::sys::napi_threadsafe_function_call_mode,
111) -> napi::sys::napi_status {
112    0
113}
114
115/// Generate a webfont from a set of SVG files.
116///
117/// Loads the SVGs listed in `options.files`, builds the configured
118/// `options.types` formats, optionally writes them (along with the CSS and
119/// HTML preview) to `options.dest`, and returns a `GenerateWebfontsResult`
120/// holding the font bytes and template-rendering methods.
121///
122/// Optional callbacks:
123/// - `rename(path)` — derive a custom glyph name from each SVG file path.
124/// - `cssContext(ctx)` — mutate the Handlebars context before CSS rendering;
125///   return the (possibly mutated) context.
126/// - `htmlContext(ctx)` — same, but for the HTML preview.
127#[cfg(feature = "napi")]
128#[napi]
129#[allow(clippy::type_complexity)] // NAPI proc macro requires the verbose ThreadsafeFunction type
130pub async fn generate_webfonts(
131    options: GenerateWebfontsOptions,
132    rename: Option<ThreadsafeFunction<String, String, String, Status, false>>,
133    css_context: Option<
134        ThreadsafeFunction<
135            serde_json::Map<String, serde_json::Value>,
136            serde_json::Map<String, serde_json::Value>,
137            serde_json::Map<String, serde_json::Value>,
138            Status,
139            false,
140        >,
141    >,
142    html_context: Option<
143        ThreadsafeFunction<
144            serde_json::Map<String, serde_json::Value>,
145            serde_json::Map<String, serde_json::Value>,
146            serde_json::Map<String, serde_json::Value>,
147            Status,
148            false,
149        >,
150    >,
151) -> napi::Result<GenerateWebfontsResult> {
152    validate_generate_webfonts_options(&options)?;
153    let source_files = load_svg_files_napi(&options.files, rename.as_ref()).await?;
154    let mut resolved_options = resolve_generate_webfonts_options(options)?;
155    finalize_generate_webfonts_options(&mut resolved_options, &source_files)?;
156
157    let mut result =
158        tokio::task::spawn_blocking(move || generate_webfonts_sync(resolved_options, source_files))
159            .await
160            .map_err(|error| {
161                NapiError::new(
162                    Status::GenericFailure,
163                    format!("Native webfont generation task failed: {error}"),
164                )
165            })??;
166
167    // Pre-compute mutated contexts via ThreadsafeFunction (async-safe).
168    // When callbacks are present, we build SharedTemplateData here and seed the
169    // OnceLock cache so it isn't re-created in get_cached() / writeFiles.
170    if css_context.is_some() || html_context.is_some() {
171        let shared =
172            SharedTemplateData::new(&result.options, &result.source_files).map_err(to_napi_err)?;
173
174        let mut css_ctx = build_css_context(&result.options, &shared);
175        if css_context.is_some() {
176            css_ctx = apply_context_function(css_ctx, css_context.as_ref())
177                .await
178                .map_err(to_napi_err)?;
179            result.css_context = Some(css_ctx.clone());
180        }
181
182        let mut html_ctx = if result.options.html || html_context.is_some() {
183            build_html_context(&result.options, &shared, &result.source_files, None)
184                .map_err(to_napi_err)?
185        } else {
186            serde_json::Map::new()
187        };
188        if html_context.is_some() {
189            html_ctx = apply_context_function(html_ctx, html_context.as_ref())
190                .await
191                .map_err(to_napi_err)?;
192            result.html_context = Some(html_ctx.clone());
193        }
194
195        // Seed the OnceLock -- avoids re-creating SharedTemplateData in get_cached()
196        let html_registry = build_html_registry(&result.options).map_err(to_napi_err)?;
197        let css_hbs_context = handlebars::Context::wraps(&css_ctx).map_err(to_napi_err)?;
198        let html_hbs_context = handlebars::Context::wraps(&html_ctx).map_err(to_napi_err)?;
199        let _ = result.cached.set(Ok(types::CachedTemplateData {
200            shared,
201            css_context: css_ctx,
202            css_hbs_context: Mutex::new(css_hbs_context),
203            html_context: html_ctx,
204            html_hbs_context: Mutex::new(html_hbs_context),
205            html_registry,
206            render_cache: Mutex::new(Default::default()),
207        }));
208    }
209
210    if result.options.write_files {
211        write_generate_webfonts_result(&result).await?;
212    }
213
214    Ok(result)
215}
216
217/// A glyph rename function that maps file stems to custom glyph names.
218pub type RenameFn = Box<dyn Fn(&str) -> String + Send + Sync>;
219
220/// Generate webfonts from SVG files.
221///
222/// This is the pure Rust async entry point. Requires a tokio runtime.
223pub async fn generate(
224    options: GenerateWebfontsOptions,
225    rename: Option<RenameFn>,
226) -> std::io::Result<GenerateWebfontsResult> {
227    validate_generate_webfonts_options(&options)?;
228    let source_files = load_svg_files(&options.files, rename.as_deref()).await?;
229    let mut resolved_options = resolve_generate_webfonts_options(options)?;
230    finalize_generate_webfonts_options(&mut resolved_options, &source_files)?;
231
232    let result =
233        tokio::task::spawn_blocking(move || generate_webfonts_sync(resolved_options, source_files))
234            .await
235            .map_err(std::io::Error::other)??;
236
237    if result.options.write_files {
238        write_generate_webfonts_result(&result).await?;
239    }
240
241    Ok(result)
242}
243
244/// Synchronous version of [`generate`]. Spawns a tokio runtime internally.
245pub fn generate_sync(
246    options: GenerateWebfontsOptions,
247    rename: Option<RenameFn>,
248) -> std::io::Result<GenerateWebfontsResult> {
249    tokio::runtime::Runtime::new()?.block_on(generate(options, rename))
250}
251
252fn validate_generate_webfonts_options(options: &GenerateWebfontsOptions) -> std::io::Result<()> {
253    if options.dest.is_empty() {
254        return Err(std::io::Error::new(
255            ErrorKind::InvalidInput,
256            "\"options.dest\" is empty.".to_owned(),
257        ));
258    }
259
260    if options.files.is_empty() {
261        return Err(std::io::Error::new(
262            ErrorKind::InvalidInput,
263            "\"options.files\" is empty.".to_owned(),
264        ));
265    }
266
267    if options.css.unwrap_or(true)
268        && let Some(ref path) = options.css_template
269        && !Path::new(path).exists()
270    {
271        return Err(std::io::Error::new(
272            ErrorKind::InvalidInput,
273            format!("\"options.cssTemplate\" file not found: {path}"),
274        ));
275    }
276
277    if options.html.unwrap_or(false)
278        && let Some(ref path) = options.html_template
279        && !Path::new(path).exists()
280    {
281        return Err(std::io::Error::new(
282            ErrorKind::InvalidInput,
283            format!("\"options.htmlTemplate\" file not found: {path}"),
284        ));
285    }
286
287    Ok(())
288}
289
290pub(crate) fn resolve_generate_webfonts_options(
291    options: GenerateWebfontsOptions,
292) -> std::io::Result<ResolvedGenerateWebfontsOptions> {
293    let types = resolved_font_types(&options);
294    validate_font_type_order(&options, &types)?;
295    let order = resolve_font_type_order(&options, &types);
296    let css = options.css.unwrap_or(true);
297    let html = options.html.unwrap_or(false);
298    let font_name = options.font_name.unwrap_or_else(|| "iconfont".to_owned());
299    let css_dest = options
300        .css_dest
301        .unwrap_or_else(|| default_output_dest(&options.dest, &font_name, "css"));
302    let html_dest = options
303        .html_dest
304        .unwrap_or_else(|| default_output_dest(&options.dest, &font_name, "html"));
305    let write_files = options.write_files.unwrap_or(true);
306
307    let svg_format = options
308        .format_options
309        .as_ref()
310        .and_then(|fo| fo.svg.as_ref());
311    let center_vertically = svg_format
312        .and_then(|s| s.center_vertically)
313        .or(options.center_vertically);
314    let optimize_output = svg_format
315        .and_then(|s| s.optimize_output)
316        .or(options.optimize_output);
317    let preserve_aspect_ratio = svg_format
318        .and_then(|s| s.preserve_aspect_ratio)
319        .or(options.preserve_aspect_ratio);
320
321    Ok(ResolvedGenerateWebfontsOptions {
322        ascent: options.ascent,
323        center_horizontally: options.center_horizontally,
324        center_vertically,
325        css,
326        css_dest,
327        css_template: match options.css_template {
328            Some(ref t) if t.is_empty() => {
329                return Err(std::io::Error::new(
330                    ErrorKind::InvalidInput,
331                    "\"options.cssTemplate\" must not be empty.".to_owned(),
332                ));
333            }
334            other => other,
335        },
336        codepoints: options.codepoints.unwrap_or_default().into_iter().collect(),
337        css_fonts_url: options.css_fonts_url,
338        descent: options.descent,
339        dest: options.dest,
340        files: options.files,
341        fixed_width: options.fixed_width,
342        format_options: options.format_options,
343        html,
344        html_dest,
345        html_template: match options.html_template {
346            Some(ref t) if t.is_empty() => {
347                return Err(std::io::Error::new(
348                    ErrorKind::InvalidInput,
349                    "\"options.htmlTemplate\" must not be empty.".to_owned(),
350                ));
351            }
352            other => other,
353        },
354        font_height: options.font_height,
355        font_name,
356        font_style: options.font_style,
357        font_weight: options.font_weight,
358        ligature: options.ligature.unwrap_or(true),
359        normalize: options.normalize.unwrap_or(true),
360        order,
361        optimize_output,
362        preserve_aspect_ratio,
363        round: options.round,
364        start_codepoint: options.start_codepoint.unwrap_or(0xF101),
365        template_options: options.template_options,
366        types,
367        write_files,
368    })
369}
370
371pub(crate) fn finalize_generate_webfonts_options(
372    options: &mut ResolvedGenerateWebfontsOptions,
373    source_files: &[LoadedSvgFile],
374) -> std::io::Result<()> {
375    options.codepoints =
376        resolve_codepoints(source_files, &options.codepoints, options.start_codepoint)?;
377
378    Ok(())
379}
380
381fn resolve_font_type_order(options: &GenerateWebfontsOptions, types: &[FontType]) -> Vec<FontType> {
382    match &options.order {
383        Some(order) => order.clone(),
384        None => DEFAULT_FONT_ORDER
385            .iter()
386            .copied()
387            .filter(|font_type| types.contains(font_type))
388            .collect(),
389    }
390}
391
392fn default_output_dest(dest: &str, font_name: &str, extension: &str) -> String {
393    Path::new(dest)
394        .join(format!("{font_name}.{extension}"))
395        .to_string_lossy()
396        .into_owned()
397}
398
399fn generate_webfonts_sync(
400    options: ResolvedGenerateWebfontsOptions,
401    source_files: Vec<LoadedSvgFile>,
402) -> std::io::Result<GenerateWebfontsResult> {
403    let wants_svg = options.types.contains(&FontType::Svg);
404    let wants_ttf = options.types.contains(&FontType::Ttf);
405    let wants_woff = options.types.contains(&FontType::Woff);
406    let wants_woff2 = options.types.contains(&FontType::Woff2);
407    let wants_eot = options.types.contains(&FontType::Eot);
408
409    let svg_options = svg_options_from_options(&options);
410    let prepared = prepare_svg_font(&svg_options, &source_files)?;
411
412    let (svg_font, raw_ttf) = join(
413        || -> std::io::Result<Option<String>> {
414            if wants_svg {
415                Ok(Some(build_svg_font(&svg_options, &prepared)))
416            } else {
417                Ok(None)
418            }
419        },
420        || -> std::io::Result<Option<Vec<u8>>> {
421            if wants_ttf || wants_woff || wants_woff2 || wants_eot {
422                let ttf_options = ttf::ttf_options_from_options(&options);
423                ttf::generate_ttf_font_bytes_from_glyphs(ttf_options, &prepared.processed_glyphs)
424                    .map(Some)
425            } else {
426                Ok(None)
427            }
428        },
429    );
430
431    let svg_font = svg_font?.map(Arc::new);
432    let raw_ttf = raw_ttf?;
433
434    let (ttf_font, woff_font, woff2_font, eot_font) = if let Some(raw_ttf) = raw_ttf {
435        let raw_ttf = Arc::new(raw_ttf);
436        let ttf_font = wants_ttf.then(|| Arc::clone(&raw_ttf));
437        let woff_metadata = options
438            .format_options
439            .as_ref()
440            .and_then(|value| value.woff.as_ref())
441            .and_then(|value| value.metadata.as_deref());
442
443        let (woff_font, (woff2_font, eot_font)) = join(
444            || -> std::io::Result<Option<Vec<u8>>> {
445                if wants_woff {
446                    woff::ttf_to_woff1(&raw_ttf, woff_metadata).map(Some)
447                } else {
448                    Ok(None)
449                }
450            },
451            || {
452                join(
453                    || -> std::io::Result<Option<Vec<u8>>> {
454                        if wants_woff2 {
455                            woff::ttf_to_woff2(&raw_ttf).map(Some)
456                        } else {
457                            Ok(None)
458                        }
459                    },
460                    || -> std::io::Result<Option<Vec<u8>>> {
461                        if wants_eot {
462                            eot::ttf_to_eot(&raw_ttf).map(Some)
463                        } else {
464                            Ok(None)
465                        }
466                    },
467                )
468            },
469        );
470
471        (
472            ttf_font,
473            woff_font?.map(Arc::new),
474            woff2_font?.map(Arc::new),
475            eot_font?.map(Arc::new),
476        )
477    } else {
478        (None, None, None, None)
479    };
480
481    Ok(GenerateWebfontsResult {
482        cached: std::sync::OnceLock::new(),
483        css_context: None,
484        eot_font,
485        html_context: None,
486        options,
487        source_files,
488        svg_font,
489        ttf_font,
490        woff2_font,
491        woff_font,
492    })
493}
494
495async fn write_generate_webfonts_result(result: &GenerateWebfontsResult) -> std::io::Result<()> {
496    let mut tasks = JoinSet::new();
497    let font_name = result.options.font_name.clone();
498    let dest = result.options.dest.clone();
499
500    if let Some(svg_font) = &result.svg_font {
501        let path = default_output_dest(&dest, &font_name, "svg");
502        let contents = Arc::clone(svg_font);
503        tasks.spawn(async move { write_output_file(path, contents.as_bytes()).await });
504    }
505
506    if let Some(ttf_font) = &result.ttf_font {
507        let path = default_output_dest(&dest, &font_name, "ttf");
508        let contents = Arc::clone(ttf_font);
509        tasks.spawn(async move { write_output_file(path, &*contents).await });
510    }
511
512    if let Some(woff_font) = &result.woff_font {
513        let path = default_output_dest(&dest, &font_name, "woff");
514        let contents = Arc::clone(woff_font);
515        tasks.spawn(async move { write_output_file(path, &*contents).await });
516    }
517
518    if let Some(woff2_font) = &result.woff2_font {
519        let path = default_output_dest(&dest, &font_name, "woff2");
520        let contents = Arc::clone(woff2_font);
521        tasks.spawn(async move { write_output_file(path, &*contents).await });
522    }
523
524    if let Some(eot_font) = &result.eot_font {
525        let path = default_output_dest(&dest, &font_name, "eot");
526        let contents = Arc::clone(eot_font);
527        tasks.spawn(async move { write_output_file(path, &*contents).await });
528    }
529
530    // Only render CSS/HTML templates when those files need to be written.
531    if result.options.css || result.options.html {
532        let cached = result.get_cached_io()?;
533
534        if result.options.css {
535            let ctx = cached.css_hbs_context.lock().unwrap();
536            let css = render_css_with_hbs_context(&cached.shared, &ctx, &cached.css_context)?;
537            drop(ctx);
538            let css_dest = result.options.css_dest.clone();
539            let css = Arc::new(css);
540            tasks.spawn(async move { write_output_file(css_dest, css.as_bytes()).await });
541        }
542
543        if result.options.html {
544            let ctx = cached.html_hbs_context.lock().unwrap();
545            let html = render_html_with_hbs_context(
546                cached.html_registry.as_ref(),
547                &ctx,
548                &cached.html_context,
549            )?;
550            let html_dest = result.options.html_dest.clone();
551            tasks.spawn(async move { write_output_file(html_dest, html.into_bytes()).await });
552        }
553    }
554
555    while let Some(result) = tasks.join_next().await {
556        result.map_err(|error| {
557            std::io::Error::other(format!("Native write task failed: {error}"))
558        })??;
559    }
560
561    Ok(())
562}
563
564async fn write_output_file(path: String, contents: impl AsRef<[u8]>) -> std::io::Result<()> {
565    if let Some(parent) = Path::new(&path).parent() {
566        tokio::fs::create_dir_all(parent).await?;
567    }
568
569    tokio::fs::write(path, contents).await
570}
571
572fn validate_font_type_order(
573    options: &GenerateWebfontsOptions,
574    requested_types: &[FontType],
575) -> std::io::Result<()> {
576    if let Some(order) = &options.order
577        && let Some(invalid_type) = order
578            .iter()
579            .copied()
580            .find(|font_type| !requested_types.contains(font_type))
581    {
582        return Err(std::io::Error::new(
583            ErrorKind::InvalidInput,
584            format!(
585                "Invalid font type order: '{}' is not present in 'types'.",
586                invalid_type.as_extension()
587            ),
588        ));
589    }
590
591    Ok(())
592}
593
594/// Load SVG file contents in parallel, preserving the original order.
595async fn load_svg_contents(paths: &[String]) -> std::io::Result<Vec<(String, String)>> {
596    let mut tasks = JoinSet::new();
597
598    for (index, path) in paths.iter().cloned().enumerate() {
599        tasks.spawn(async move {
600            tokio::fs::read_to_string(&path)
601                .await
602                .map(|contents| (index, (path, contents)))
603        });
604    }
605
606    let mut results = Vec::with_capacity(paths.len());
607    while let Some(result) = tasks.join_next().await {
608        let (index, pair) = result
609            .map_err(|error| std::io::Error::other(format!("SVG loading task failed: {error}")))?
610            .map_err(|error| {
611                std::io::Error::other(format!("Failed to read source SVG file: {error}"))
612            })?;
613        results.push((index, pair));
614    }
615
616    results.sort_by_key(|(index, _)| *index);
617    Ok(results.into_iter().map(|(_, pair)| pair).collect())
618}
619
620/// Load SVG files and resolve glyph names using an optional sync rename function.
621async fn load_svg_files(
622    paths: &[String],
623    rename: Option<&(dyn Fn(&str) -> String + Send + Sync)>,
624) -> std::io::Result<Vec<LoadedSvgFile>> {
625    let raw = load_svg_contents(paths).await?;
626    let source_files: Vec<LoadedSvgFile> = raw
627        .into_iter()
628        .map(|(path, contents)| {
629            let glyph_name = util::glyph_name_from_path(&path, rename)?;
630            Ok(LoadedSvgFile {
631                contents,
632                glyph_name,
633                path,
634            })
635        })
636        .collect::<std::io::Result<_>>()?;
637
638    validate_glyph_names(&source_files)?;
639    Ok(source_files)
640}
641
642/// NAPI version: resolve glyph names via async ThreadsafeFunction callback.
643#[cfg(feature = "napi")]
644async fn load_svg_files_napi(
645    paths: &[String],
646    rename: Option<
647        &napi::threadsafe_function::ThreadsafeFunction<String, String, String, Status, false>,
648    >,
649) -> napi::Result<Vec<LoadedSvgFile>> {
650    let raw = load_svg_contents(paths).await.map_err(to_napi_err)?;
651    let mut source_files = Vec::with_capacity(raw.len());
652
653    for (path, contents) in raw {
654        let glyph_name = if let Some(rename) = rename {
655            rename.call_async(path.clone()).await?
656        } else {
657            util::default_glyph_name_from_path(&path).map_err(to_napi_err)?
658        };
659        source_files.push(LoadedSvgFile {
660            contents,
661            glyph_name,
662            path,
663        });
664    }
665
666    validate_glyph_names(&source_files).map_err(to_napi_err)?;
667    Ok(source_files)
668}
669
670fn validate_glyph_names(source_files: &[LoadedSvgFile]) -> std::io::Result<()> {
671    let mut seen_names = HashSet::with_capacity(source_files.len());
672
673    for source_file in source_files {
674        if !seen_names.insert(source_file.glyph_name.clone()) {
675            return Err(std::io::Error::new(
676                ErrorKind::InvalidInput,
677                format!(
678                    "The glyph name \"{}\" must be unique.",
679                    source_file.glyph_name
680                ),
681            ));
682        }
683    }
684
685    Ok(())
686}
687
688// Re-export resolve_codepoints for use in finalize_generate_webfonts_options
689use util::resolve_codepoints;
690
691#[cfg(test)]
692mod tests {
693    use super::{
694        resolve_generate_webfonts_options, resolved_font_types, validate_font_type_order,
695        validate_generate_webfonts_options, woff,
696    };
697    use crate::{FontType, GenerateWebfontsOptions, ttf::generate_ttf_font_bytes};
698
699    #[test]
700    fn generates_woff2_font_with_expected_header() {
701        let ttf_result = generate_ttf_font_bytes(GenerateWebfontsOptions {
702            css: Some(false),
703            dest: "artifacts".to_owned(),
704            files: vec![format!(
705                "{}/../vite-svg-2-webfont/src/fixtures/webfont-test/svg/add.svg",
706                env!("CARGO_MANIFEST_DIR")
707            )],
708            html: Some(false),
709            font_name: Some("iconfont".to_owned()),
710            ligature: Some(false),
711            ..Default::default()
712        })
713        .expect("expected ttf generation to succeed");
714
715        let result = woff::ttf_to_woff2(&ttf_result).expect("woff2 generation should succeed");
716
717        assert_eq!(&result[..4], b"wOF2");
718    }
719
720    #[test]
721    fn rejects_order_entries_that_are_not_present_in_types() {
722        let options = GenerateWebfontsOptions {
723            dest: "artifacts".to_owned(),
724            files: vec![],
725            font_name: Some("iconfont".to_owned()),
726            ligature: Some(false),
727            order: Some(vec![FontType::Svg, FontType::Woff]),
728            types: Some(vec![FontType::Svg]),
729            ..Default::default()
730        };
731
732        let error = validate_font_type_order(&options, &resolved_font_types(&options)).unwrap_err();
733
734        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
735        assert!(
736            error
737                .to_string()
738                .contains("Invalid font type order: 'woff' is not present in 'types'.")
739        );
740    }
741
742    #[test]
743    fn rejects_an_empty_dest() {
744        let options = GenerateWebfontsOptions {
745            dest: String::new(),
746            files: vec!["icon.svg".to_owned()],
747            font_name: Some("iconfont".to_owned()),
748            ligature: Some(false),
749            types: Some(vec![FontType::Svg]),
750            ..Default::default()
751        };
752
753        let error = validate_generate_webfonts_options(&options).unwrap_err();
754
755        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
756        assert!(error.to_string().contains("\"options.dest\" is empty."));
757    }
758
759    #[test]
760    fn rejects_empty_files() {
761        let options = GenerateWebfontsOptions {
762            dest: "artifacts".to_owned(),
763            files: vec![],
764            font_name: Some("iconfont".to_owned()),
765            ligature: Some(false),
766            types: Some(vec![FontType::Svg]),
767            ..Default::default()
768        };
769
770        let error = validate_generate_webfonts_options(&options).unwrap_err();
771
772        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
773        assert!(error.to_string().contains("\"options.files\" is empty."));
774    }
775
776    #[test]
777    fn rejects_empty_css_template() {
778        let options = GenerateWebfontsOptions {
779            css: Some(true),
780            css_template: Some(String::new()),
781            dest: "artifacts".to_owned(),
782            files: vec!["icon.svg".to_owned()],
783            html: Some(false),
784            font_name: Some("iconfont".to_owned()),
785            ligature: Some(false),
786            types: Some(vec![FontType::Svg]),
787            ..Default::default()
788        };
789
790        let error = resolve_generate_webfonts_options(options)
791            .err()
792            .expect("expected empty css template to fail");
793
794        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
795        assert!(
796            error
797                .to_string()
798                .contains("\"options.cssTemplate\" must not be empty.")
799        );
800    }
801
802    #[test]
803    fn rejects_empty_html_template() {
804        let options = GenerateWebfontsOptions {
805            css: Some(false),
806            dest: "artifacts".to_owned(),
807            files: vec!["icon.svg".to_owned()],
808            html: Some(true),
809            html_template: Some(String::new()),
810            font_name: Some("iconfont".to_owned()),
811            ligature: Some(false),
812            types: Some(vec![FontType::Svg]),
813            ..Default::default()
814        };
815
816        let error = resolve_generate_webfonts_options(options)
817            .err()
818            .expect("expected empty html template to fail");
819
820        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
821        assert!(
822            error
823                .to_string()
824                .contains("\"options.htmlTemplate\" must not be empty.")
825        );
826    }
827
828    #[test]
829    fn resolves_write_defaults_from_dest_and_font_name() {
830        let options = GenerateWebfontsOptions {
831            css: Some(false),
832            dest: "artifacts".to_owned(),
833            files: vec!["icon.svg".to_owned()],
834            html: Some(false),
835            font_name: Some("iconfont".to_owned()),
836            ligature: Some(false),
837            types: Some(vec![FontType::Svg]),
838            ..Default::default()
839        };
840
841        let resolved = resolve_generate_webfonts_options(options)
842            .expect("expected defaults to resolve successfully");
843
844        assert!(resolved.write_files);
845        assert_eq!(resolved.css_dest, "artifacts/iconfont.css");
846        assert_eq!(resolved.html_dest, "artifacts/iconfont.html");
847    }
848
849    #[test]
850    fn rejects_nonexistent_css_template_when_css_is_true() {
851        let error = validate_generate_webfonts_options(&GenerateWebfontsOptions {
852            css: Some(true),
853            css_template: Some("/tmp/__nonexistent_template__.hbs".to_owned()),
854            dest: "artifacts".to_owned(),
855            files: vec!["icon.svg".to_owned()],
856            html: Some(false),
857            ..Default::default()
858        })
859        .unwrap_err();
860
861        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
862        assert!(error.to_string().contains("cssTemplate"));
863    }
864
865    #[test]
866    fn allows_nonexistent_css_template_when_css_is_false() {
867        validate_generate_webfonts_options(&GenerateWebfontsOptions {
868            css: Some(false),
869            css_template: Some("/tmp/__nonexistent_template__.hbs".to_owned()),
870            dest: "artifacts".to_owned(),
871            files: vec!["icon.svg".to_owned()],
872            html: Some(false),
873            ..Default::default()
874        })
875        .expect("should allow nonexistent css template when css is false");
876    }
877
878    #[test]
879    fn rejects_nonexistent_html_template_when_html_is_true() {
880        let error = validate_generate_webfonts_options(&GenerateWebfontsOptions {
881            css: Some(false),
882            dest: "artifacts".to_owned(),
883            files: vec!["icon.svg".to_owned()],
884            html: Some(true),
885            html_template: Some("/tmp/__nonexistent_template__.hbs".to_owned()),
886            ..Default::default()
887        })
888        .unwrap_err();
889
890        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
891        assert!(error.to_string().contains("htmlTemplate"));
892    }
893
894    #[test]
895    fn allows_nonexistent_html_template_when_html_is_false() {
896        validate_generate_webfonts_options(&GenerateWebfontsOptions {
897            css: Some(false),
898            dest: "artifacts".to_owned(),
899            files: vec!["icon.svg".to_owned()],
900            html: Some(false),
901            html_template: Some("/tmp/__nonexistent_template__.hbs".to_owned()),
902            ..Default::default()
903        })
904        .expect("should allow nonexistent html template when html is false");
905    }
906}