Skip to main content

webfont_generator/
types.rs

1use std::collections::{BTreeMap, HashMap};
2use std::sync::Arc;
3use std::sync::{Mutex, OnceLock};
4
5#[cfg(feature = "napi")]
6use napi::bindgen_prelude::Uint8Array;
7#[cfg(feature = "napi")]
8use napi_derive::napi;
9use serde_json::{Map, Value};
10
11use crate::templates::{
12    SharedTemplateData, build_css_context, build_html_context, build_html_registry, make_src,
13    render_css_with_hbs_context, render_css_with_src_mutate, render_default_html_with_styles,
14    render_html_with_hbs_context,
15};
16use crate::util::to_io_err;
17
18/// Font output format. Used in the `types` and `order` options to control which
19/// formats are generated and the order they appear in the CSS `@font-face`
20/// `src:` descriptor.
21#[cfg_attr(feature = "napi", napi(string_enum = "lowercase"))]
22#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
23#[derive(Clone, Copy, PartialEq, Eq, Hash)]
24pub enum FontType {
25    /// SVG font (`.svg`). Legacy format; intermediate representation that all
26    /// other formats are derived from.
27    Svg,
28    /// TrueType font (`.ttf`).
29    Ttf,
30    /// Embedded OpenType (`.eot`). Legacy format for older Internet Explorer.
31    Eot,
32    /// Web Open Font Format 1.0 (`.woff`).
33    Woff,
34    /// Web Open Font Format 2.0 (`.woff2`). Best compression; preferred for
35    /// modern browsers.
36    Woff2,
37}
38
39impl FontType {
40    /// Returns the CSS `format()` value (e.g., "truetype", "woff2").
41    #[inline]
42    pub fn css_format(self) -> &'static str {
43        match self {
44            FontType::Svg => "svg",
45            FontType::Ttf => "truetype",
46            FontType::Eot => "embedded-opentype",
47            FontType::Woff => "woff",
48            FontType::Woff2 => "woff2",
49        }
50    }
51
52    /// Returns the file extension (e.g., "svg", "ttf", "woff2").
53    #[inline]
54    pub fn as_extension(self) -> &'static str {
55        match self {
56            FontType::Svg => "svg",
57            FontType::Ttf => "ttf",
58            FontType::Eot => "eot",
59            FontType::Woff => "woff",
60            FontType::Woff2 => "woff2",
61        }
62    }
63}
64
65/// SVG-format–specific options for the intermediate SVG font and the per-glyph
66/// path processing that feeds every other format.
67#[cfg_attr(feature = "napi", napi(object))]
68#[derive(Clone, Default)]
69pub struct SvgFormatOptions {
70    /// SVG-format override of the top-level `centerVertically` option. When set,
71    /// it wins over the top-level value; centers each glyph vertically inside
72    /// the em-square based on its bounding box.
73    pub center_vertically: Option<bool>,
74    /// Value of the SVG font's `id` attribute. Defaults to `fontName` when
75    /// omitted.
76    pub font_id: Option<String>,
77    /// Content embedded inside the generated SVG font's `<metadata>` element.
78    pub metadata: Option<String>,
79    /// SVG-format override of the top-level `optimizeOutput` option. When set,
80    /// it wins over the top-level value; runs an SVG path optimizer over each
81    /// glyph, trading a small amount of build time for smaller output bytes.
82    pub optimize_output: Option<bool>,
83    /// SVG-format override of the top-level `preserveAspectRatio` option. When
84    /// set, it wins over the top-level value; preserves the source viewBox
85    /// aspect ratio when scaling glyphs into the em-square.
86    pub preserve_aspect_ratio: Option<bool>,
87}
88
89/// TTF-format–specific options. Populates fields in the generated TTF `name`
90/// and `head` tables.
91#[cfg_attr(feature = "napi", napi(object))]
92#[derive(Clone)]
93pub struct TtfFormatOptions {
94    /// Copyright string written to the TTF `name` table (record id 0).
95    pub copyright: Option<String>,
96    /// Description string written to the TTF `name` table (record id 10).
97    pub description: Option<String>,
98    /// Unix timestamp in seconds used for the `created` and `modified` fields
99    /// in the TTF `head` table. Pin to a fixed value to produce byte-stable
100    /// reproducible builds.
101    pub ts: Option<i64>,
102    /// Manufacturer URL written to the TTF `name` table (record id 11).
103    pub url: Option<String>,
104    /// Version string written to the TTF `name` table (record id 5).
105    pub version: Option<String>,
106}
107
108/// WOFF-format–specific options. Affects only WOFF1 output; WOFF2 ignores these.
109#[cfg_attr(feature = "napi", napi(object))]
110#[derive(Clone)]
111pub struct WoffFormatOptions {
112    /// XML string embedded in the WOFF1 metadata block.
113    pub metadata: Option<String>,
114}
115
116/// Per-format configuration object. Each field carries options that only apply
117/// to the corresponding output format.
118#[cfg_attr(feature = "napi", napi(object))]
119#[derive(Clone, Default)]
120pub struct FormatOptions {
121    /// SVG-format options.
122    pub svg: Option<SvgFormatOptions>,
123    /// TTF-format options.
124    pub ttf: Option<TtfFormatOptions>,
125    /// WOFF1-format options. (WOFF2 has no format-specific options.)
126    pub woff: Option<WoffFormatOptions>,
127}
128
129/// Guaranteed fields supplied to a `cssContext` callback. Additional keys from
130/// user-supplied `templateOptions` are merged into the same object at runtime,
131/// so the JS-side type widens this with an open-ended index signature.
132#[cfg_attr(feature = "napi", napi(object))]
133#[derive(Clone)]
134pub struct CssContext {
135    /// Name of the generated font, mirroring the `fontName` option.
136    pub font_name: String,
137    /// Pre-rendered value for the CSS `@font-face` `src:` descriptor — a
138    /// comma-separated list of `url(...) format(...)` entries derived from the
139    /// configured `types`, `order`, and `cssFontsUrl`.
140    pub src: String,
141    /// Map from glyph name to its assigned codepoint as a hex-encoded string
142    /// (e.g. `"add" -> "f101"`), suitable for use inside CSS `content`
143    /// declarations like `content: "\f101"`.
144    pub codepoints: HashMap<String, String>,
145}
146
147/// Guaranteed fields supplied to an `htmlContext` callback. Additional keys
148/// from user-supplied `templateOptions` are merged into the same object at
149/// runtime, so the JS-side type widens this with an open-ended index signature.
150#[cfg_attr(feature = "napi", napi(object))]
151#[derive(Clone)]
152pub struct HtmlContext {
153    /// Name of the generated font, mirroring the `fontName` option.
154    pub font_name: String,
155    /// Glyph names in declaration order, after any `rename` callback has been
156    /// applied. Useful for iterating over icons in a preview template.
157    pub names: Vec<String>,
158    /// Pre-rendered CSS (the same string the engine writes to the `.css`
159    /// output) so HTML templates can embed it inline for self-contained
160    /// previews without an external stylesheet reference.
161    pub styles: String,
162    /// Map from glyph name to its assigned codepoint as a numeric value
163    /// (e.g. `"add" -> 0xF101`). Use the CSS context's hex form if you need a
164    /// string for embedding into CSS `content` declarations.
165    pub codepoints: HashMap<String, u32>,
166}
167
168/// Top-level options controlling webfont generation. Only `dest` and `files`
169/// are required; every other field has a sensible default. See the per-field
170/// docs for defaults and units.
171#[cfg_attr(feature = "napi", napi(object))]
172#[derive(Clone, Default)]
173pub struct GenerateWebfontsOptions {
174    /// Font ascent in font units. Overrides the value computed from the source
175    /// glyphs.
176    pub ascent: Option<f64>,
177    /// When `true`, centers each glyph horizontally inside the em-square based
178    /// on its bounding box.
179    pub center_horizontally: Option<bool>,
180    /// When `true`, centers each glyph vertically inside the em-square based
181    /// on its bounding box. Convenience alias for
182    /// `formatOptions.svg.centerVertically`.
183    pub center_vertically: Option<bool>,
184    /// Whether to generate a CSS file. Defaults to `true`.
185    pub css: Option<bool>,
186    /// Output path for the generated CSS file. Defaults to
187    /// `path.join(dest, fontName + '.css')`.
188    pub css_dest: Option<String>,
189    /// Path to a custom Handlebars template for CSS generation. The template
190    /// receives the `cssContext` shape plus any `templateOptions` keys.
191    pub css_template: Option<String>,
192    /// Explicit Unicode codepoints for specific glyphs, keyed by glyph name.
193    /// Glyphs not listed here are auto-assigned starting at `startCodepoint`.
194    pub codepoints: Option<HashMap<String, u32>>,
195    /// URL prefix for font files in the generated CSS. Defaults to the
196    /// relative path from `cssDest` to `dest`.
197    pub css_fonts_url: Option<String>,
198    /// Font descent in font units. Overrides the value computed from the
199    /// source glyphs.
200    pub descent: Option<f64>,
201    /// Output directory for generated font files. Required.
202    pub dest: String,
203    /// Paths to the SVG files to include in the font. Required.
204    pub files: Vec<String>,
205    /// When `true`, produces a monospace font sized to the widest glyph.
206    pub fixed_width: Option<bool>,
207    /// Per-format option overrides. See `FormatOptions`.
208    pub format_options: Option<FormatOptions>,
209    /// Whether to generate an HTML preview file. Defaults to `false`.
210    pub html: Option<bool>,
211    /// Output path for the generated HTML preview file. Defaults to
212    /// `path.join(dest, fontName + '.html')`.
213    pub html_dest: Option<String>,
214    /// Path to a custom Handlebars template for HTML preview generation.
215    pub html_template: Option<String>,
216    /// Explicit output font height in units per em. Overrides the height
217    /// computed from the source glyphs.
218    pub font_height: Option<f64>,
219    /// Name of the generated font family; also used as the base name for
220    /// output files. Defaults to `'iconfont'`.
221    pub font_name: Option<String>,
222    /// CSS `font-style` value for the generated `@font-face` rule.
223    pub font_style: Option<String>,
224    /// CSS `font-weight` value for the generated `@font-face` rule.
225    pub font_weight: Option<String>,
226    /// Enable ligature support so each glyph can be referenced by its name as
227    /// a text ligature. Defaults to `true`.
228    pub ligature: Option<bool>,
229    /// Scale icons to the height of the tallest icon. Defaults to `true`.
230    pub normalize: Option<bool>,
231    /// Order of `@font-face` `src:` entries in the generated CSS. Every entry
232    /// must also appear in `types`. Defaults to
233    /// `['eot', 'woff2', 'woff', 'ttf', 'svg']` filtered to the requested
234    /// `types`.
235    pub order: Option<Vec<FontType>>,
236    /// Run an SVG path optimizer over each glyph, trading a small amount of
237    /// build time for smaller output bytes. Convenience alias for
238    /// `formatOptions.svg.optimizeOutput`.
239    pub optimize_output: Option<bool>,
240    /// Preserve the source viewBox aspect ratio when scaling glyphs into the
241    /// em-square. Convenience alias for `formatOptions.svg.preserveAspectRatio`.
242    pub preserve_aspect_ratio: Option<bool>,
243    /// SVG path coordinate rounding precision.
244    pub round: Option<f64>,
245    /// Starting codepoint for auto-assigned glyphs. Defaults to `0xF101`.
246    pub start_codepoint: Option<u32>,
247    /// Additional key-value pairs merged into the Handlebars template
248    /// context for both CSS and HTML rendering. Typical home for
249    /// `classPrefix` and `baseSelector`.
250    pub template_options: Option<Map<String, Value>>,
251    /// Font formats to generate. Defaults to `['eot', 'woff', 'woff2']`.
252    pub types: Option<Vec<FontType>>,
253    /// Whether to write generated files to disk. Set to `false` for
254    /// in-memory usage. Defaults to `true`.
255    pub write_files: Option<bool>,
256}
257
258pub(crate) const DEFAULT_FONT_TYPES: [FontType; 3] =
259    [FontType::Eot, FontType::Woff, FontType::Woff2];
260
261pub(crate) const DEFAULT_FONT_ORDER: [FontType; 5] = [
262    FontType::Eot,
263    FontType::Woff2,
264    FontType::Woff,
265    FontType::Ttf,
266    FontType::Svg,
267];
268
269pub(crate) fn resolved_font_types(options: &GenerateWebfontsOptions) -> Vec<FontType> {
270    match &options.types {
271        Some(types) => types.clone(),
272        None => DEFAULT_FONT_TYPES.to_vec(),
273    }
274}
275
276pub(crate) struct ResolvedGenerateWebfontsOptions {
277    pub ascent: Option<f64>,
278    pub center_horizontally: Option<bool>,
279    pub center_vertically: Option<bool>,
280    pub css: bool,
281    pub css_dest: String,
282    pub css_template: Option<String>,
283    pub codepoints: BTreeMap<String, u32>,
284    pub css_fonts_url: Option<String>,
285    pub descent: Option<f64>,
286    pub dest: String,
287    pub files: Vec<String>,
288    pub fixed_width: Option<bool>,
289    pub format_options: Option<FormatOptions>,
290    pub html: bool,
291    pub html_dest: String,
292    pub html_template: Option<String>,
293    pub font_height: Option<f64>,
294    pub font_name: String,
295    pub font_style: Option<String>,
296    pub font_weight: Option<String>,
297    pub ligature: bool,
298    pub normalize: bool,
299    pub order: Vec<FontType>,
300    pub optimize_output: Option<bool>,
301    pub preserve_aspect_ratio: Option<bool>,
302    pub round: Option<f64>,
303    pub start_codepoint: u32,
304    pub template_options: Option<Map<String, Value>>,
305    pub types: Vec<FontType>,
306    pub write_files: bool,
307}
308
309pub(crate) struct LoadedSvgFile {
310    pub contents: String,
311    pub glyph_name: String,
312    pub path: String,
313}
314
315/// Caches the last rendered CSS/HTML result for repeated calls with the same urls.
316#[derive(Default)]
317pub(crate) struct RenderCache {
318    /// Result of generateCss() with no urls (computed once).
319    css_no_urls: Option<String>,
320    /// Last generateCss(urls) result.
321    css_last_urls: Option<HashMap<FontType, String>>,
322    css_last_result: Option<String>,
323    /// Result of generateHtml() with no urls (computed once).
324    html_no_urls: Option<String>,
325    /// Last generateHtml(urls) result.
326    html_last_urls: Option<HashMap<FontType, String>>,
327    html_last_result: Option<String>,
328}
329
330pub(crate) struct CachedTemplateData {
331    pub shared: SharedTemplateData,
332    pub css_context: Map<String, Value>,
333    pub css_hbs_context: Mutex<handlebars::Context>,
334    pub html_context: Map<String, Value>,
335    pub html_hbs_context: Mutex<handlebars::Context>,
336    pub html_registry: Option<handlebars::Handlebars<'static>>,
337    pub(crate) render_cache: Mutex<RenderCache>,
338}
339
340/// Result of a successful `generateWebfonts` call. Exposes the generated
341/// font bytes (or `null` for formats that were not requested) and methods to
342/// render the CSS and HTML preview.
343#[cfg_attr(feature = "napi", napi)]
344pub struct GenerateWebfontsResult {
345    pub(crate) css_context: Option<Map<String, Value>>,
346    pub(crate) eot_font: Option<Arc<Vec<u8>>>,
347    pub(crate) html_context: Option<Map<String, Value>>,
348    pub(crate) options: ResolvedGenerateWebfontsOptions,
349    pub(crate) source_files: Vec<LoadedSvgFile>,
350    pub(crate) svg_font: Option<Arc<String>>,
351    pub(crate) ttf_font: Option<Arc<Vec<u8>>>,
352    pub(crate) woff2_font: Option<Arc<Vec<u8>>>,
353    pub(crate) woff_font: Option<Arc<Vec<u8>>>,
354    pub(crate) cached: OnceLock<Result<CachedTemplateData, String>>,
355}
356
357// Pure Rust getters (always available)
358impl GenerateWebfontsResult {
359    /// Returns the EOT font bytes, if generated.
360    pub fn eot_bytes(&self) -> Option<&[u8]> {
361        self.eot_font.as_ref().map(|v| v.as_ref().as_slice())
362    }
363
364    /// Returns the SVG font string, if generated.
365    pub fn svg_string(&self) -> Option<&str> {
366        self.svg_font.as_ref().map(|v| v.as_ref().as_str())
367    }
368
369    /// Returns the TTF font bytes, if generated.
370    pub fn ttf_bytes(&self) -> Option<&[u8]> {
371        self.ttf_font.as_ref().map(|v| v.as_ref().as_slice())
372    }
373
374    /// Returns the WOFF font bytes, if generated.
375    pub fn woff_bytes(&self) -> Option<&[u8]> {
376        self.woff_font.as_ref().map(|v| v.as_ref().as_slice())
377    }
378
379    /// Returns the WOFF2 font bytes, if generated.
380    pub fn woff2_bytes(&self) -> Option<&[u8]> {
381        self.woff2_font.as_ref().map(|v| v.as_ref().as_slice())
382    }
383
384    pub(crate) fn get_cached_io(&self) -> std::io::Result<&CachedTemplateData> {
385        self.cached
386            .get_or_init(|| {
387                let shared = SharedTemplateData::new(&self.options, &self.source_files)
388                    .map_err(|e| e.to_string())?;
389                let css_context = match &self.css_context {
390                    Some(ctx) => ctx.clone(),
391                    None => build_css_context(&self.options, &shared),
392                };
393                let html_context = match &self.html_context {
394                    Some(ctx) => ctx.clone(),
395                    None => build_html_context(&self.options, &shared, &self.source_files, None)
396                        .map_err(|e| e.to_string())?,
397                };
398                let html_registry =
399                    build_html_registry(&self.options).map_err(|e| e.to_string())?;
400                let css_hbs_context =
401                    handlebars::Context::wraps(&css_context).map_err(|e| e.to_string())?;
402                let html_hbs_context =
403                    handlebars::Context::wraps(&html_context).map_err(|e| e.to_string())?;
404                Ok(CachedTemplateData {
405                    shared,
406                    css_context,
407                    css_hbs_context: Mutex::new(css_hbs_context),
408                    html_context,
409                    html_hbs_context: Mutex::new(html_hbs_context),
410                    html_registry,
411                    render_cache: Mutex::new(RenderCache::default()),
412                })
413            })
414            .as_ref()
415            .map_err(to_io_err)
416    }
417
418    /// Generate a CSS string for this webfont result.
419    ///
420    /// Pass `urls` to override the default font URLs in the CSS output.
421    pub fn generate_css_pure(
422        &self,
423        urls: Option<HashMap<FontType, String>>,
424    ) -> std::io::Result<String> {
425        let cached = self.get_cached_io()?;
426        let mut rc = cached.render_cache.lock().unwrap();
427
428        match &urls {
429            None => {
430                if let Some(result) = &rc.css_no_urls {
431                    return Ok(result.clone());
432                }
433                let ctx = cached.css_hbs_context.lock().unwrap();
434                let result =
435                    render_css_with_hbs_context(&cached.shared, &ctx, &cached.css_context)?;
436                rc.css_no_urls = Some(result.clone());
437                Ok(result)
438            }
439            Some(urls) => {
440                // If the template doesn't reference {{src}}, URLs don't affect output
441                if !cached.shared.css_template_uses_src {
442                    drop(rc);
443                    return self.generate_css_pure(None);
444                }
445                if rc.css_last_urls.as_ref() == Some(urls)
446                    && let Some(result) = &rc.css_last_result
447                {
448                    return Ok(result.clone());
449                }
450                let src = make_src(&self.options, urls);
451                let mut ctx = cached.css_hbs_context.lock().unwrap();
452                let result = render_css_with_src_mutate(
453                    &cached.shared,
454                    &mut ctx,
455                    &cached.css_context,
456                    &src,
457                )?;
458                rc.css_last_urls = Some(urls.clone());
459                rc.css_last_result = Some(result.clone());
460                Ok(result)
461            }
462        }
463    }
464
465    /// Generate an HTML string for this webfont result.
466    ///
467    /// Pass `urls` to override the default font URLs in the HTML output.
468    pub fn generate_html_pure(
469        &self,
470        urls: Option<HashMap<FontType, String>>,
471    ) -> std::io::Result<String> {
472        let cached = self.get_cached_io()?;
473        let mut rc = cached.render_cache.lock().unwrap();
474
475        match &urls {
476            None => {
477                if let Some(result) = &rc.html_no_urls {
478                    return Ok(result.clone());
479                }
480                let ctx = cached.html_hbs_context.lock().unwrap();
481                let result = render_html_with_hbs_context(
482                    cached.html_registry.as_ref(),
483                    &ctx,
484                    &cached.html_context,
485                )?;
486                rc.html_no_urls = Some(result.clone());
487                Ok(result)
488            }
489            Some(urls) => {
490                // If the CSS template doesn't reference {{src}}, URLs don't affect output
491                if !cached.shared.css_template_uses_src {
492                    drop(rc);
493                    return self.generate_html_pure(None);
494                }
495                if rc.html_last_urls.as_ref() == Some(urls)
496                    && let Some(result) = &rc.html_last_result
497                {
498                    return Ok(result.clone());
499                }
500                // Render CSS with the custom URLs (in-place src mutate, no clone)
501                let src = make_src(&self.options, urls);
502                let styles = {
503                    let mut css_ctx = cached.css_hbs_context.lock().unwrap();
504                    render_css_with_src_mutate(
505                        &cached.shared,
506                        &mut css_ctx,
507                        &cached.css_context,
508                        &src,
509                    )?
510                };
511                // Hot path: default HTML template -- inject styles directly, skip clone
512                if self.options.html_template.is_none() {
513                    let result = render_default_html_with_styles(&cached.html_context, &styles);
514                    rc.html_last_urls = Some(urls.clone());
515                    rc.html_last_result = Some(result.clone());
516                    return Ok(result);
517                }
518                // Custom HTML template: in-place styles mutate, no clone
519                let mut html_ctx = cached.html_hbs_context.lock().unwrap();
520                let registry = cached
521                    .html_registry
522                    .as_ref()
523                    .expect("HTML registry should exist for custom template");
524                let result = crate::util::render_with_field_swap(
525                    &mut html_ctx,
526                    "styles",
527                    serde_json::Value::String(styles),
528                    |ctx| {
529                        registry
530                            .render_with_context("html", ctx)
531                            .map_err(crate::util::to_io_err)
532                    },
533                )?;
534                rc.html_last_urls = Some(urls.clone());
535                rc.html_last_result = Some(result.clone());
536                Ok(result)
537            }
538        }
539    }
540}
541
542// NAPI getters and methods
543#[cfg(feature = "napi")]
544#[napi]
545impl GenerateWebfontsResult {
546    /// EOT font bytes, or `null` if EOT was not in `types`.
547    #[napi(getter)]
548    pub fn eot(&self) -> Option<Uint8Array> {
549        self.eot_font
550            .as_ref()
551            .map(|v| Uint8Array::from(v.as_ref().clone()))
552    }
553
554    /// SVG font XML string, or `null` if SVG was not in `types`.
555    #[napi(getter)]
556    pub fn svg(&self) -> Option<String> {
557        self.svg_font.as_ref().map(|v| v.as_ref().clone())
558    }
559
560    /// TTF font bytes, or `null` if TTF was not in `types`.
561    #[napi(getter)]
562    pub fn ttf(&self) -> Option<Uint8Array> {
563        self.ttf_font
564            .as_ref()
565            .map(|v| Uint8Array::from(v.as_ref().clone()))
566    }
567
568    /// WOFF2 font bytes, or `null` if WOFF2 was not in `types`.
569    #[napi(getter)]
570    pub fn woff2(&self) -> Option<Uint8Array> {
571        self.woff2_font
572            .as_ref()
573            .map(|v| Uint8Array::from(v.as_ref().clone()))
574    }
575
576    /// WOFF font bytes, or `null` if WOFF was not in `types`.
577    #[napi(getter)]
578    pub fn woff(&self) -> Option<Uint8Array> {
579        self.woff_font
580            .as_ref()
581            .map(|v| Uint8Array::from(v.as_ref().clone()))
582    }
583
584    /// Render the CSS string for this result. Pass `urls` to override the
585    /// default font URLs in the `@font-face src:` descriptor (only the keys
586    /// you supply are overridden). The result is cached per `urls` value, so
587    /// repeated calls with the same input are cheap.
588    #[napi(ts_args_type = "urls?: Partial<Record<FontType, string>>")]
589    pub fn generate_css(&self, urls: Option<HashMap<String, String>>) -> napi::Result<String> {
590        let urls = urls.map(parse_native_urls).transpose()?;
591        self.generate_css_pure(urls)
592            .map_err(crate::util::to_napi_err)
593    }
594
595    /// Render the HTML preview string for this result. Pass `urls` to
596    /// override font URLs in the embedded stylesheet (only the keys you
597    /// supply are overridden). The result is cached per `urls` value.
598    #[napi(ts_args_type = "urls?: Partial<Record<FontType, string>>")]
599    pub fn generate_html(&self, urls: Option<HashMap<String, String>>) -> napi::Result<String> {
600        let urls = urls.map(parse_native_urls).transpose()?;
601        self.generate_html_pure(urls)
602            .map_err(crate::util::to_napi_err)
603    }
604}
605
606#[cfg(feature = "napi")]
607fn parse_native_urls(urls: HashMap<String, String>) -> napi::Result<HashMap<FontType, String>> {
608    urls.into_iter()
609        .filter_map(|(font_type, url)| {
610            let font_type = match font_type.as_str() {
611                "svg" => Some(FontType::Svg),
612                "ttf" => Some(FontType::Ttf),
613                "eot" => Some(FontType::Eot),
614                "woff" => Some(FontType::Woff),
615                "woff2" => Some(FontType::Woff2),
616                _ => None,
617            }?;
618
619            Some(Ok((font_type, url)))
620        })
621        .collect::<napi::Result<HashMap<FontType, String>>>()
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use crate::{finalize_generate_webfonts_options, resolve_generate_webfonts_options};
628
629    fn build_result(template: Option<&str>) -> GenerateWebfontsResult {
630        let fixture = crate::test_helpers::webfont_fixture("add.svg");
631
632        let mut css_template = None;
633        let cleanup_dir;
634        if let Some(content) = template {
635            let tmp = std::env::temp_dir().join(format!(
636                "render-cache-test-{}",
637                std::time::SystemTime::now()
638                    .duration_since(std::time::UNIX_EPOCH)
639                    .unwrap()
640                    .as_nanos()
641            ));
642            std::fs::create_dir_all(&tmp).unwrap();
643            let path = tmp.join("template.hbs");
644            std::fs::write(&path, content).unwrap();
645            css_template = Some(path.to_string_lossy().into_owned());
646            cleanup_dir = Some(tmp);
647        } else {
648            cleanup_dir = None;
649        }
650
651        let options = GenerateWebfontsOptions {
652            css: Some(true),
653            css_template,
654            codepoints: Some(HashMap::from([("add".to_owned(), 0xE001u32)])),
655            dest: "artifacts".to_owned(),
656            files: vec![fixture],
657            html: Some(false),
658            font_name: Some("iconfont".to_owned()),
659            ligature: Some(false),
660            order: Some(vec![FontType::Svg]),
661            start_codepoint: Some(0xE001),
662            types: Some(vec![FontType::Svg]),
663            ..Default::default()
664        };
665
666        let mut resolved = resolve_generate_webfonts_options(options).unwrap();
667        let source_files: Vec<LoadedSvgFile> = resolved
668            .files
669            .iter()
670            .map(|path| LoadedSvgFile {
671                contents: std::fs::read_to_string(path).unwrap(),
672                glyph_name: std::path::Path::new(path)
673                    .file_stem()
674                    .unwrap()
675                    .to_str()
676                    .unwrap()
677                    .to_owned(),
678                path: path.clone(),
679            })
680            .collect();
681        finalize_generate_webfonts_options(&mut resolved, &source_files).unwrap();
682
683        let result = GenerateWebfontsResult {
684            cached: std::sync::OnceLock::new(),
685            css_context: None,
686            eot_font: None,
687            html_context: None,
688            options: resolved,
689            source_files,
690            svg_font: None,
691            ttf_font: None,
692            woff2_font: None,
693            woff_font: None,
694        };
695
696        if let Some(dir) = cleanup_dir {
697            // Don't clean up yet -- template file needed for lazy compilation
698            std::mem::forget(dir);
699        }
700
701        result
702    }
703
704    #[test]
705    fn generate_css_returns_cached_result_on_repeated_calls_without_urls() {
706        let result = build_result(None);
707
708        let first = result.generate_css_pure(None).unwrap();
709        let second = result.generate_css_pure(None).unwrap();
710
711        assert_eq!(first, second);
712        assert!(!first.is_empty());
713    }
714
715    #[test]
716    fn generate_css_returns_cached_result_on_repeated_calls_with_same_urls() {
717        let result = build_result(None);
718        let urls = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
719
720        let first = result.generate_css_pure(Some(urls.clone())).unwrap();
721        let second = result.generate_css_pure(Some(urls)).unwrap();
722
723        assert_eq!(first, second);
724        assert!(first.contains("/a.svg"));
725    }
726
727    #[test]
728    fn generate_css_returns_different_result_for_different_urls() {
729        let result = build_result(None);
730        let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
731        let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
732
733        let result_a = result.generate_css_pure(Some(urls_a)).unwrap();
734        let result_b = result.generate_css_pure(Some(urls_b)).unwrap();
735
736        assert_ne!(result_a, result_b);
737        assert!(result_a.contains("/a.svg"));
738        assert!(result_b.contains("/b.svg"));
739    }
740
741    #[test]
742    fn generate_css_cache_updates_when_urls_change() {
743        let result = build_result(None);
744        let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
745        let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
746
747        let first_a = result.generate_css_pure(Some(urls_a.clone())).unwrap();
748        let first_b = result.generate_css_pure(Some(urls_b)).unwrap();
749        let second_a = result.generate_css_pure(Some(urls_a)).unwrap();
750
751        assert_eq!(
752            first_a, second_a,
753            "returning to original urls should produce same result"
754        );
755        assert_ne!(first_a, first_b);
756    }
757
758    #[test]
759    fn generate_css_cache_works_with_custom_template() {
760        let result = build_result(Some("@font-face { src: {{{src}}}; }"));
761        let urls = HashMap::from([(FontType::Svg, "/cached.svg".to_owned())]);
762
763        let first = result.generate_css_pure(Some(urls.clone())).unwrap();
764        let second = result.generate_css_pure(Some(urls)).unwrap();
765
766        assert_eq!(first, second);
767        assert!(first.contains("/cached.svg"));
768    }
769
770    #[test]
771    fn generate_css_no_urls_and_with_urls_are_independent_caches() {
772        let result = build_result(None);
773        let urls = HashMap::from([(FontType::Svg, "/custom.svg".to_owned())]);
774
775        let no_urls = result.generate_css_pure(None).unwrap();
776        let with_urls = result.generate_css_pure(Some(urls)).unwrap();
777        let no_urls_again = result.generate_css_pure(None).unwrap();
778
779        assert_eq!(
780            no_urls, no_urls_again,
781            "no-urls cache should survive a with-urls call"
782        );
783        assert_ne!(no_urls, with_urls);
784    }
785
786    #[test]
787    fn generate_css_with_urls_returns_no_urls_result_when_template_does_not_use_src() {
788        let result = build_result(Some(".icon { font-family: {{fontName}}; }"));
789        let urls = HashMap::from([(FontType::Svg, "/should-not-appear.svg".to_owned())]);
790
791        let no_urls = result.generate_css_pure(None).unwrap();
792        let with_urls = result.generate_css_pure(Some(urls)).unwrap();
793
794        assert_eq!(
795            no_urls, with_urls,
796            "template without {{src}} should ignore urls"
797        );
798        assert!(!with_urls.contains("/should-not-appear.svg"));
799        assert!(
800            with_urls.contains("iconfont"),
801            "should still render the template"
802        );
803    }
804
805    #[test]
806    fn generate_html_with_urls_returns_no_urls_result_when_css_template_does_not_use_src() {
807        let result = build_result(Some(".icon { font-family: {{fontName}}; }"));
808        let urls = HashMap::from([(FontType::Svg, "/should-not-appear.svg".to_owned())]);
809
810        let no_urls = result.generate_html_pure(None).unwrap();
811        let with_urls = result.generate_html_pure(Some(urls)).unwrap();
812
813        assert_eq!(
814            no_urls, with_urls,
815            "CSS template without {{src}} means HTML is also unaffected by urls"
816        );
817    }
818
819    #[test]
820    fn generate_css_without_urls_produces_valid_css_using_css_fonts_url() {
821        let result = build_result(None);
822
823        let css = result.generate_css_pure(None).unwrap();
824
825        assert!(
826            css.contains("@font-face"),
827            "should contain @font-face declaration"
828        );
829        assert!(css.contains("font-family:"), "should contain font-family");
830        assert!(
831            css.contains("iconfont.svg?"),
832            "should use font name in URL with hash"
833        );
834        assert!(
835            css.contains("format(\"svg\")"),
836            "should contain format declaration"
837        );
838        assert!(
839            css.contains("content:"),
840            "should contain codepoint content rules"
841        );
842    }
843
844    #[test]
845    fn generate_css_with_urls_replaces_default_urls_in_src() {
846        let result = build_result(None);
847        let urls = HashMap::from([(FontType::Svg, "/cdn/icons.svg".to_owned())]);
848
849        let css = result.generate_css_pure(Some(urls)).unwrap();
850
851        assert!(
852            css.contains("/cdn/icons.svg"),
853            "custom URL should appear in output"
854        );
855        assert!(
856            !css.contains("iconfont.svg?"),
857            "default hash-based URL should not appear"
858        );
859        assert!(
860            css.contains("format(\"svg\")"),
861            "format should still be present"
862        );
863    }
864
865    #[test]
866    fn generate_html_without_urls_produces_valid_html() {
867        let result = build_result(None);
868
869        let html = result.generate_html_pure(None).unwrap();
870
871        assert!(
872            html.contains("<!DOCTYPE html>"),
873            "should be a full HTML document"
874        );
875        assert!(html.contains("iconfont"), "should contain font name");
876        assert!(html.contains("icon-add"), "should contain icon class name");
877    }
878
879    #[test]
880    fn generate_html_with_urls_embeds_css_using_custom_urls() {
881        let result = build_result(None);
882        let urls = HashMap::from([(FontType::Svg, "/cdn/icons.svg".to_owned())]);
883
884        let html = result.generate_html_pure(Some(urls)).unwrap();
885
886        assert!(
887            html.contains("/cdn/icons.svg"),
888            "custom URL should appear in embedded CSS"
889        );
890        assert!(
891            html.contains("icon-add"),
892            "should still contain icon class name"
893        );
894    }
895
896    #[test]
897    fn generate_html_cache_returns_same_result_for_same_urls() {
898        let result = build_result(None);
899        let urls = HashMap::from([(FontType::Svg, "/cached.svg".to_owned())]);
900
901        let first = result.generate_html_pure(Some(urls.clone())).unwrap();
902        let second = result.generate_html_pure(Some(urls)).unwrap();
903
904        assert_eq!(first, second);
905        assert!(first.contains("/cached.svg"));
906    }
907
908    #[test]
909    fn generate_html_cache_returns_different_result_for_different_urls() {
910        let result = build_result(None);
911        let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
912        let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
913
914        let result_a = result.generate_html_pure(Some(urls_a)).unwrap();
915        let result_b = result.generate_html_pure(Some(urls_b)).unwrap();
916
917        assert_ne!(result_a, result_b);
918        assert!(result_a.contains("/a.svg"));
919        assert!(result_b.contains("/b.svg"));
920    }
921
922    /// Build a result with multiple font types (svg + woff2) for testing partial URL overrides.
923    fn build_multi_type_result() -> GenerateWebfontsResult {
924        let fixture = crate::test_helpers::webfont_fixture("add.svg");
925        let options = GenerateWebfontsOptions {
926            css: Some(true),
927            codepoints: Some(HashMap::from([("add".to_owned(), 0xE001u32)])),
928            dest: "artifacts".to_owned(),
929            files: vec![fixture],
930            html: Some(true),
931            font_name: Some("iconfont".to_owned()),
932            ligature: Some(false),
933            order: Some(vec![FontType::Woff2, FontType::Svg]),
934            start_codepoint: Some(0xE001),
935            types: Some(vec![FontType::Svg, FontType::Woff2]),
936            ..Default::default()
937        };
938
939        let mut resolved = resolve_generate_webfonts_options(options).unwrap();
940        let source_files: Vec<LoadedSvgFile> = resolved
941            .files
942            .iter()
943            .map(|path| LoadedSvgFile {
944                contents: std::fs::read_to_string(path).unwrap(),
945                glyph_name: std::path::Path::new(path)
946                    .file_stem()
947                    .unwrap()
948                    .to_str()
949                    .unwrap()
950                    .to_owned(),
951                path: path.clone(),
952            })
953            .collect();
954        finalize_generate_webfonts_options(&mut resolved, &source_files).unwrap();
955
956        GenerateWebfontsResult {
957            cached: std::sync::OnceLock::new(),
958            css_context: None,
959            eot_font: None,
960            html_context: None,
961            options: resolved,
962            source_files,
963            svg_font: None,
964            ttf_font: None,
965            woff2_font: None,
966            woff_font: None,
967        }
968    }
969
970    #[test]
971    fn generate_css_partial_urls_uses_empty_string_for_missing_types() {
972        let result = build_multi_type_result();
973        // Override only woff2, leave svg un-provided -- matches upstream behavior
974        let urls = HashMap::from([(FontType::Woff2, "/cdn/font.woff2".to_owned())]);
975
976        let css = result.generate_css_pure(Some(urls)).unwrap();
977
978        assert!(
979            css.contains("/cdn/font.woff2"),
980            "overridden URL should appear"
981        );
982        assert!(
983            !css.contains("iconfont.svg?"),
984            "non-overridden type should not have default hash-based URL"
985        );
986        assert!(
987            css.contains("url(\"#iconfont\")"),
988            "non-overridden SVG type should produce empty base URL (upstream compat)"
989        );
990    }
991
992    #[test]
993    fn generate_html_partial_urls_uses_empty_string_for_missing_types() {
994        let result = build_multi_type_result();
995        let urls = HashMap::from([(FontType::Woff2, "/cdn/font.woff2".to_owned())]);
996
997        let html = result.generate_html_pure(Some(urls)).unwrap();
998
999        assert!(
1000            html.contains("/cdn/font.woff2"),
1001            "overridden URL should appear in HTML"
1002        );
1003        assert!(
1004            !html.contains("iconfont.svg?"),
1005            "non-overridden type should not have default hash-based URL in HTML"
1006        );
1007    }
1008}