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