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