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