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: Arc<str>,
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
387#[derive(Clone)]
388pub(crate) struct CarriedRenderCache {
389    cache: RenderCache,
390    css_dependencies: TemplateDependencies,
391    html_dependencies: TemplateDependencies,
392}
393
394pub(crate) struct CachedTemplateData {
395    pub shared: SharedTemplateData,
396    pub css_context: Map<String, Value>,
397    pub css_hbs_context: Mutex<handlebars::Context>,
398    pub html_context: Map<String, Value>,
399    pub html_hbs_context: Mutex<handlebars::Context>,
400    pub html_template_dependencies: TemplateDependencies,
401    pub html_registry: Option<handlebars::Handlebars<'static>>,
402    pub(crate) render_cache: Mutex<RenderCache>,
403}
404
405/// Rendered bytes for each requested output format. Held by [`GenerateWebfontsResult`] and
406/// produced by the generator's format pipeline; grouping them lets an incremental regenerate
407/// refresh every format in a single assignment.
408#[derive(Clone, Default)]
409pub(crate) struct FontOutputs {
410    pub(crate) svg_font: Option<Arc<String>>,
411    pub(crate) ttf_font: Option<Arc<Vec<u8>>>,
412    pub(crate) woff_font: Option<Arc<Vec<u8>>>,
413    pub(crate) woff2_font: Option<Arc<Vec<u8>>>,
414    pub(crate) eot_font: Option<Arc<Vec<u8>>>,
415}
416
417pub(crate) struct RegenerationState {
418    pub(crate) caches_dirty: bool,
419    pub(crate) glyph_cache: GlyphCache,
420    pub(crate) ttf_cache: Option<TtfGlyphCache>,
421    pub(crate) written_outputs: HashMap<String, [u8; 16]>,
422}
423
424pub(crate) struct RegenerationStateLease {
425    slot: Arc<Mutex<Option<RegenerationState>>>,
426    state: Option<RegenerationState>,
427    keep_caches: bool,
428}
429
430/// Failure from asynchronous regeneration. Ordinary regeneration errors retain the input result
431/// so callers can recover it with [`RegenerateError::into_result`] and retry.
432pub struct RegenerateError {
433    result: Option<Box<GenerateWebfontsResult>>,
434    source: std::io::Error,
435}
436
437impl RegenerateError {
438    pub(crate) fn new(result: Option<GenerateWebfontsResult>, source: std::io::Error) -> Self {
439        Self {
440            result: result.map(Box::new),
441            source,
442        }
443    }
444
445    /// Return the result that was consumed by the failed operation. This is `None` only if the
446    /// blocking task was cancelled by the runtime before it could return the result.
447    pub fn into_result(self) -> Option<GenerateWebfontsResult> {
448        self.result.map(|result| *result)
449    }
450
451    /// Return both the recoverable result and the underlying I/O error.
452    pub fn into_parts(self) -> (Option<GenerateWebfontsResult>, std::io::Error) {
453        (self.result.map(|result| *result), self.source)
454    }
455}
456
457impl std::fmt::Debug for RegenerateError {
458    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
459        formatter
460            .debug_struct("RegenerateError")
461            .field("recoverable", &self.result.is_some())
462            .field("source", &self.source)
463            .finish()
464    }
465}
466
467impl std::fmt::Display for RegenerateError {
468    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
469        self.source.fmt(formatter)
470    }
471}
472
473impl std::error::Error for RegenerateError {
474    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
475        Some(&self.source)
476    }
477}
478
479impl RegenerationStateLease {
480    pub(crate) fn state_mut(&mut self) -> &mut RegenerationState {
481        self.state.as_mut().unwrap()
482    }
483
484    pub(crate) fn commit(mut self) {
485        self.keep_caches = true;
486    }
487}
488
489impl Drop for RegenerationStateLease {
490    fn drop(&mut self) {
491        let mut state = self.state.take().unwrap();
492        if !self.keep_caches && state.caches_dirty {
493            state.glyph_cache = GlyphCache::default();
494            if state.ttf_cache.is_some() {
495                state.ttf_cache = Some(TtfGlyphCache::default());
496            }
497        }
498        state.caches_dirty = false;
499        *self.slot.lock().unwrap() = Some(state);
500    }
501}
502
503/// Result of a successful `generateWebfonts` call. Exposes the generated
504/// font bytes (or `null` for formats that were not requested) and methods to
505/// render the CSS and HTML preview.
506#[cfg_attr(feature = "napi", napi)]
507pub struct GenerateWebfontsResult {
508    pub(crate) cached: OnceLock<Result<CachedTemplateData, String>>,
509    /// Render-cache entries carried across an incremental `regenerate` to seed the rebuilt
510    /// [`CachedTemplateData`], so CSS/HTML that
511    /// doesn't depend on what changed isn't re-rendered. `None` for a normal build.
512    pub(crate) carried_render: Option<CarriedRenderCache>,
513    pub(crate) css_context: Option<Map<String, Value>>,
514    pub(crate) fonts: FontOutputs,
515    pub(crate) html_context: Option<Map<String, Value>>,
516    pub(crate) options: Arc<ResolvedGenerateWebfontsOptions>,
517    pub(crate) regeneration_state: Arc<Mutex<Option<RegenerationState>>>,
518    pub(crate) source_files: Arc<Vec<LoadedSvgFile>>,
519}
520
521// Pure Rust getters (always available)
522impl GenerateWebfontsResult {
523    #[cfg(any(feature = "napi", feature = "bench"))]
524    pub(crate) fn snapshot_for_regeneration(&self, state: RegenerationState) -> Self {
525        Self {
526            cached: OnceLock::new(),
527            carried_render: self.render_cache_source(),
528            css_context: self.css_context.clone(),
529            fonts: self.fonts.clone(),
530            html_context: self.html_context.clone(),
531            options: self.options.clone(),
532            regeneration_state: Arc::new(Mutex::new(Some(state))),
533            source_files: self.source_files.clone(),
534        }
535    }
536
537    pub(crate) fn take_regeneration_state(&self) -> std::io::Result<RegenerationState> {
538        if !self.options.incremental {
539            return Err(std::io::Error::new(
540                std::io::ErrorKind::InvalidInput,
541                "regenerate requires the font to be generated with `incremental` enabled.",
542            ));
543        }
544        self.regeneration_state
545            .lock()
546            .unwrap()
547            .take()
548            .ok_or_else(|| {
549                std::io::Error::new(
550                    std::io::ErrorKind::WouldBlock,
551                    "This result is already regenerating or has been replaced.",
552                )
553            })
554    }
555
556    pub(crate) fn take_regeneration_state_lease(&self) -> std::io::Result<RegenerationStateLease> {
557        Ok(RegenerationStateLease {
558            slot: Arc::clone(&self.regeneration_state),
559            state: Some(self.take_regeneration_state()?),
560            keep_caches: false,
561        })
562    }
563
564    #[cfg(feature = "bench")]
565    pub(crate) fn restore_regeneration_state(&self, state: RegenerationState) {
566        *self.regeneration_state.lock().unwrap() = Some(state);
567    }
568
569    pub(crate) fn seed_written_outputs(&self, written_outputs: HashMap<String, [u8; 16]>) {
570        if let Some(state) = self.regeneration_state.lock().unwrap().as_mut() {
571            state.written_outputs = written_outputs;
572        }
573    }
574
575    #[doc(hidden)]
576    #[cfg(feature = "bench")]
577    pub fn regenerate_owned_for_bench(
578        &self,
579        files: &[String],
580        changes: &[(String, GlyphChange)],
581    ) -> std::io::Result<Self> {
582        let state = self.take_regeneration_state()?;
583        let mut replacement = self.snapshot_for_regeneration(state);
584        match replacement.regenerate(files, changes) {
585            Ok(()) => Ok(replacement),
586            Err(error) => {
587                if let Ok(state) = replacement.take_regeneration_state() {
588                    self.restore_regeneration_state(state);
589                }
590                Err(error)
591            }
592        }
593    }
594
595    #[cfg(test)]
596    pub(crate) fn has_carried_css_no_urls_for_test(&self) -> bool {
597        self.carried_render
598            .as_ref()
599            .is_some_and(|carried| carried.cache.css_no_urls.is_some())
600    }
601
602    #[cfg(test)]
603    pub(crate) fn has_carried_html_no_urls_for_test(&self) -> bool {
604        self.carried_render
605            .as_ref()
606            .is_some_and(|carried| carried.cache.html_no_urls.is_some())
607    }
608
609    /// Returns the EOT font bytes, if generated.
610    pub fn eot_bytes(&self) -> Option<&[u8]> {
611        self.fonts.eot_font.as_ref().map(|v| v.as_ref().as_slice())
612    }
613
614    /// Returns the SVG font string, if generated.
615    pub fn svg_string(&self) -> Option<&str> {
616        self.fonts.svg_font.as_ref().map(|v| v.as_ref().as_str())
617    }
618
619    /// Returns the TTF font bytes, if generated.
620    pub fn ttf_bytes(&self) -> Option<&[u8]> {
621        self.fonts.ttf_font.as_ref().map(|v| v.as_ref().as_slice())
622    }
623
624    /// Returns the WOFF font bytes, if generated.
625    pub fn woff_bytes(&self) -> Option<&[u8]> {
626        self.fonts.woff_font.as_ref().map(|v| v.as_ref().as_slice())
627    }
628
629    /// Returns the WOFF2 font bytes, if generated.
630    pub fn woff2_bytes(&self) -> Option<&[u8]> {
631        self.fonts
632            .woff2_font
633            .as_ref()
634            .map(|v| v.as_ref().as_slice())
635    }
636
637    pub(crate) fn get_cached_io(&self) -> std::io::Result<&CachedTemplateData> {
638        self.cached
639            .get_or_init(|| {
640                let shared = SharedTemplateData::new(&self.options, &self.source_files)
641                    .map_err(|e| e.to_string())?;
642                let css_context = match &self.css_context {
643                    Some(ctx) => ctx.clone(),
644                    None => build_css_context(&self.options, &shared),
645                };
646                let html_context = match &self.html_context {
647                    Some(ctx) => ctx.clone(),
648                    None => build_html_context(&self.options, &shared, &self.source_files, None)
649                        .map_err(|e| e.to_string())?,
650                };
651                let (html_registry, html_template_dependencies) =
652                    build_html_registry_and_dependencies(&self.options)
653                        .map_err(|e| e.to_string())?;
654                let css_hbs_context =
655                    handlebars::Context::wraps(&css_context).map_err(|e| e.to_string())?;
656                let html_hbs_context =
657                    handlebars::Context::wraps(&html_context).map_err(|e| e.to_string())?;
658                Ok(CachedTemplateData {
659                    shared,
660                    css_context,
661                    css_hbs_context: Mutex::new(css_hbs_context),
662                    html_context,
663                    html_hbs_context: Mutex::new(html_hbs_context),
664                    html_template_dependencies,
665                    html_registry,
666                    // Seed with entries carried across a regenerate;
667                    // these are renders that don't depend on what changed, so reusing them is safe.
668                    render_cache: Mutex::new(
669                        self.carried_render
670                            .as_ref()
671                            .map(|carried| carried.cache.clone())
672                            .unwrap_or_default(),
673                    ),
674                })
675            })
676            .as_ref()
677            .map_err(to_io_err)
678    }
679
680    pub(crate) fn reusable_render_cache(
681        &self,
682        names_unchanged: bool,
683        codepoints_unchanged: bool,
684    ) -> Option<CarriedRenderCache> {
685        self.render_cache_source().map(|carried| {
686            let css_deps = carried.css_dependencies;
687            let css_no_urls_unchanged =
688                css_deps.can_reuse_css_no_urls(names_unchanged, codepoints_unchanged);
689            let css_with_urls_unchanged =
690                css_deps.can_reuse_css_with_urls(names_unchanged, codepoints_unchanged);
691            let html_no_urls_unchanged = carried.html_dependencies.can_reuse_html(
692                names_unchanged,
693                codepoints_unchanged,
694                css_no_urls_unchanged,
695            );
696            let html_with_urls_unchanged = carried.html_dependencies.can_reuse_html(
697                names_unchanged,
698                codepoints_unchanged,
699                css_with_urls_unchanged,
700            );
701
702            let rc = carried.cache;
703            CarriedRenderCache {
704                css_dependencies: carried.css_dependencies,
705                html_dependencies: carried.html_dependencies,
706                cache: RenderCache {
707                    css_no_urls: css_no_urls_unchanged
708                        .then(|| rc.css_no_urls.clone())
709                        .flatten(),
710                    html_no_urls: html_no_urls_unchanged
711                        .then(|| rc.html_no_urls.clone())
712                        .flatten(),
713                    css_last_urls: css_with_urls_unchanged
714                        .then(|| rc.css_last_urls.clone())
715                        .flatten(),
716                    css_last_result: css_with_urls_unchanged
717                        .then(|| rc.css_last_result.clone())
718                        .flatten(),
719                    html_last_urls: html_with_urls_unchanged
720                        .then(|| rc.html_last_urls.clone())
721                        .flatten(),
722                    html_last_result: html_with_urls_unchanged
723                        .then(|| rc.html_last_result.clone())
724                        .flatten(),
725                },
726            }
727        })
728    }
729
730    fn render_cache_source(&self) -> Option<CarriedRenderCache> {
731        self.cached
732            .get()
733            .and_then(|cached| cached.as_ref().ok())
734            .map(|cached| CarriedRenderCache {
735                cache: cached.render_cache.lock().unwrap().clone(),
736                css_dependencies: cached.shared.css_template_dependencies,
737                html_dependencies: cached.html_template_dependencies,
738            })
739            .or_else(|| self.carried_render.clone())
740    }
741
742    /// Generate a CSS string for this webfont result.
743    ///
744    /// Pass `urls` to override the default font URLs in the CSS output.
745    pub fn generate_css_pure(
746        &self,
747        urls: Option<HashMap<FontType, String>>,
748    ) -> std::io::Result<String> {
749        let cached = self.get_cached_io()?;
750        let mut rc = cached.render_cache.lock().unwrap();
751
752        match &urls {
753            None => {
754                if let Some(result) = &rc.css_no_urls {
755                    return Ok(result.clone());
756                }
757                let ctx = cached.css_hbs_context.lock().unwrap();
758                let result =
759                    render_css_with_hbs_context(&cached.shared, &ctx, &cached.css_context)?;
760                rc.css_no_urls = Some(result.clone());
761                Ok(result)
762            }
763            Some(urls) => {
764                // If the template doesn't reference {{src}}, URLs don't affect output
765                if !cached.shared.css_template_uses_src {
766                    drop(rc);
767                    return self.generate_css_pure(None);
768                }
769                if rc.css_last_urls.as_ref() == Some(urls)
770                    && let Some(result) = &rc.css_last_result
771                {
772                    return Ok(result.clone());
773                }
774                let src = make_src(&self.options, urls);
775                let mut ctx = cached.css_hbs_context.lock().unwrap();
776                let result = render_css_with_src_mutate(
777                    &cached.shared,
778                    &mut ctx,
779                    &cached.css_context,
780                    &src,
781                )?;
782                rc.css_last_urls = Some(urls.clone());
783                rc.css_last_result = Some(result.clone());
784                Ok(result)
785            }
786        }
787    }
788
789    /// Generate an HTML string for this webfont result.
790    ///
791    /// Pass `urls` to override the default font URLs in the HTML output.
792    pub fn generate_html_pure(
793        &self,
794        urls: Option<HashMap<FontType, String>>,
795    ) -> std::io::Result<String> {
796        let cached = self.get_cached_io()?;
797        let mut rc = cached.render_cache.lock().unwrap();
798
799        match &urls {
800            None => {
801                if let Some(result) = &rc.html_no_urls {
802                    return Ok(result.clone());
803                }
804                let ctx = cached.html_hbs_context.lock().unwrap();
805                let result = render_html_with_hbs_context(
806                    cached.html_registry.as_ref(),
807                    &ctx,
808                    &cached.html_context,
809                )?;
810                rc.html_no_urls = Some(result.clone());
811                Ok(result)
812            }
813            Some(urls) => {
814                // If the CSS template doesn't reference {{src}}, URLs don't affect output
815                if !cached.shared.css_template_uses_src {
816                    drop(rc);
817                    return self.generate_html_pure(None);
818                }
819                if rc.html_last_urls.as_ref() == Some(urls)
820                    && let Some(result) = &rc.html_last_result
821                {
822                    return Ok(result.clone());
823                }
824                // Render CSS with the custom URLs (in-place src mutate, no clone)
825                let src = make_src(&self.options, urls);
826                let styles = {
827                    let mut css_ctx = cached.css_hbs_context.lock().unwrap();
828                    render_css_with_src_mutate(
829                        &cached.shared,
830                        &mut css_ctx,
831                        &cached.css_context,
832                        &src,
833                    )?
834                };
835                // Hot path: default HTML template -- inject styles directly, skip clone
836                if self.options.html_template.is_none() {
837                    let result = render_default_html_with_styles(&cached.html_context, &styles);
838                    rc.html_last_urls = Some(urls.clone());
839                    rc.html_last_result = Some(result.clone());
840                    return Ok(result);
841                }
842                // Custom HTML template: in-place styles mutate, no clone
843                let mut html_ctx = cached.html_hbs_context.lock().unwrap();
844                let registry = cached
845                    .html_registry
846                    .as_ref()
847                    .expect("HTML registry should exist for custom template");
848                let result = crate::util::render_with_field_swap(
849                    &mut html_ctx,
850                    "styles",
851                    serde_json::Value::String(styles),
852                    |ctx| {
853                        registry
854                            .render_with_context("html", ctx)
855                            .map_err(crate::util::to_io_err)
856                    },
857                )?;
858                rc.html_last_urls = Some(urls.clone());
859                rc.html_last_result = Some(result.clone());
860                Ok(result)
861            }
862        }
863    }
864}
865
866// NAPI getters and methods
867#[cfg(feature = "napi")]
868#[napi]
869impl GenerateWebfontsResult {
870    /// EOT font bytes, or `null` if EOT was not in `types`.
871    #[napi(getter)]
872    pub fn eot(&self) -> Option<Uint8Array> {
873        self.fonts
874            .eot_font
875            .as_ref()
876            .map(|v| Uint8Array::from(v.as_ref().clone()))
877    }
878
879    /// SVG font XML string, or `null` if SVG was not in `types`.
880    #[napi(getter)]
881    pub fn svg(&self) -> Option<String> {
882        self.fonts.svg_font.as_ref().map(|v| v.as_ref().clone())
883    }
884
885    /// TTF font bytes, or `null` if TTF was not in `types`.
886    #[napi(getter)]
887    pub fn ttf(&self) -> Option<Uint8Array> {
888        self.fonts
889            .ttf_font
890            .as_ref()
891            .map(|v| Uint8Array::from(v.as_ref().clone()))
892    }
893
894    /// WOFF2 font bytes, or `null` if WOFF2 was not in `types`.
895    #[napi(getter)]
896    pub fn woff2(&self) -> Option<Uint8Array> {
897        self.fonts
898            .woff2_font
899            .as_ref()
900            .map(|v| Uint8Array::from(v.as_ref().clone()))
901    }
902
903    /// WOFF font bytes, or `null` if WOFF was not in `types`.
904    #[napi(getter)]
905    pub fn woff(&self) -> Option<Uint8Array> {
906        self.fonts
907            .woff_font
908            .as_ref()
909            .map(|v| Uint8Array::from(v.as_ref().clone()))
910    }
911
912    /// Render the CSS string for this result. Pass `urls` to override the
913    /// default font URLs in the `@font-face src:` descriptor (only the keys
914    /// you supply are overridden). The result is cached per `urls` value, so
915    /// repeated calls with the same input are cheap.
916    #[napi(ts_args_type = "urls?: Partial<Record<FontType, string>>")]
917    pub fn generate_css(&self, urls: Option<HashMap<String, String>>) -> napi::Result<String> {
918        let urls = urls.map(parse_native_urls).transpose()?;
919        self.generate_css_pure(urls)
920            .map_err(crate::util::to_napi_err)
921    }
922
923    /// Render the HTML preview string for this result. Pass `urls` to
924    /// override font URLs in the embedded stylesheet (only the keys you
925    /// supply are overridden). The result is cached per `urls` value.
926    #[napi(ts_args_type = "urls?: Partial<Record<FontType, string>>")]
927    pub fn generate_html(&self, urls: Option<HashMap<String, String>>) -> napi::Result<String> {
928        let urls = urls.map(parse_native_urls).transpose()?;
929        self.generate_html_pure(urls)
930            .map_err(crate::util::to_napi_err)
931    }
932
933    /// Rebuild the font after a batch of file changes, reusing cached glyph geometry for files
934    /// whose contents are unchanged. Requires the font to have been generated with
935    /// `incremental: true`. `files` is the complete file set after the changes, in the order a
936    /// fresh build would use (e.g. the glob result) — the rebuilt glyphs are ordered to match it,
937    /// so the output bytes are identical to a fresh `generateWebfonts` of that set. `changes`
938    /// describes the affected files: added/changed files are re-read from disk; any file absent
939    /// from `files` is dropped. Omit `changes` to re-read/hash every current file and infer
940    /// added/changed/removed paths from `files`. Every requested format is refreshed in memory,
941    /// and — like `generateWebfonts` — when the result was built with `writeFiles` enabled the
942    /// refreshed fonts are written to disk too, while CSS/HTML companion files are skipped if their
943    /// rendered bytes are unchanged since the last write.
944    #[napi(js_name = "regenerate")]
945    pub fn regenerate_from_js(
946        &mut self,
947        files: Vec<String>,
948        changes: Option<Vec<GlyphChangeEntry>>,
949    ) -> napi::Result<()> {
950        let changes = parse_glyph_changes(changes)?;
951        match changes {
952            Some(changes) => self.regenerate(&files, &changes),
953            None => self.regenerate_all(&files),
954        }
955        .map_err(crate::util::to_napi_err)
956    }
957
958    /// Rebuild off the Node.js event loop and resolve with a replacement result. The receiver
959    /// remains readable and unchanged while regeneration runs and after failure. Assign the
960    /// resolved result before starting another regeneration. Overlapping calls on the same result
961    /// lineage are rejected, and disk writes remain non-transactional.
962    #[napi(js_name = "regenerateAsync")]
963    pub async fn regenerate_async_from_js(
964        &self,
965        files: Vec<String>,
966        changes: Option<Vec<GlyphChangeEntry>>,
967    ) -> napi::Result<GenerateWebfontsResult> {
968        let changes = parse_glyph_changes(changes)?;
969        let state = self
970            .take_regeneration_state()
971            .map_err(crate::util::to_napi_err)?;
972        let original_state = Arc::clone(&self.regeneration_state);
973        let mut replacement = self.snapshot_for_regeneration(state);
974        tokio::task::spawn_blocking(move || -> std::io::Result<GenerateWebfontsResult> {
975            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match changes {
976                Some(changes) => replacement.regenerate(&files, &changes),
977                None => replacement.regenerate_all(&files),
978            }));
979            match result {
980                Ok(Ok(())) => Ok(replacement),
981                Ok(Err(error)) => {
982                    if let Ok(state) = replacement.take_regeneration_state() {
983                        *original_state.lock().unwrap() = Some(state);
984                    }
985                    Err(error)
986                }
987                Err(payload) => {
988                    if let Ok(state) = replacement.take_regeneration_state() {
989                        *original_state.lock().unwrap() = Some(state);
990                    }
991                    std::panic::resume_unwind(payload)
992                }
993            }
994        })
995        .await
996        .map_err(|error| {
997            napi::Error::from_reason(format!("Native webfont regeneration task failed: {error}"))
998        })?
999        .map_err(crate::util::to_napi_err)
1000    }
1001}
1002
1003#[cfg(feature = "napi")]
1004fn parse_glyph_changes(
1005    changes: Option<Vec<GlyphChangeEntry>>,
1006) -> napi::Result<Option<Vec<(String, GlyphChange)>>> {
1007    changes
1008        .map(|changes| {
1009            changes
1010                .into_iter()
1011                .map(|entry| {
1012                    let change = match entry.change_type.as_str() {
1013                        "added" => GlyphChange::Added { name: entry.name },
1014                        "changed" => GlyphChange::Changed { name: entry.name },
1015                        "removed" => GlyphChange::Removed,
1016                        other => {
1017                            return Err(napi::Error::from_reason(format!(
1018                                "Unknown changeType '{other}'; expected 'added', 'changed', or 'removed'."
1019                            )));
1020                        }
1021                    };
1022                    Ok((entry.path, change))
1023                })
1024                .collect()
1025        })
1026        .transpose()
1027}
1028
1029#[cfg(feature = "napi")]
1030fn parse_native_urls(urls: HashMap<String, String>) -> napi::Result<HashMap<FontType, String>> {
1031    urls.into_iter()
1032        .filter_map(|(font_type, url)| {
1033            let font_type = match font_type.as_str() {
1034                "svg" => Some(FontType::Svg),
1035                "ttf" => Some(FontType::Ttf),
1036                "eot" => Some(FontType::Eot),
1037                "woff" => Some(FontType::Woff),
1038                "woff2" => Some(FontType::Woff2),
1039                _ => None,
1040            }?;
1041
1042            Some(Ok((font_type, url)))
1043        })
1044        .collect::<napi::Result<HashMap<FontType, String>>>()
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::*;
1050    use crate::{finalize_generate_webfonts_options, resolve_generate_webfonts_options};
1051
1052    fn build_result(template: Option<&str>) -> GenerateWebfontsResult {
1053        let fixture = crate::test_helpers::webfont_fixture("add.svg");
1054
1055        let mut css_template = None;
1056        let cleanup_dir;
1057        if let Some(content) = template {
1058            let tmp = std::env::temp_dir().join(format!(
1059                "render-cache-test-{}",
1060                std::time::SystemTime::now()
1061                    .duration_since(std::time::UNIX_EPOCH)
1062                    .unwrap()
1063                    .as_nanos()
1064            ));
1065            std::fs::create_dir_all(&tmp).unwrap();
1066            let path = tmp.join("template.hbs");
1067            std::fs::write(&path, content).unwrap();
1068            css_template = Some(path.to_string_lossy().into_owned());
1069            cleanup_dir = Some(tmp);
1070        } else {
1071            cleanup_dir = None;
1072        }
1073
1074        let options = GenerateWebfontsOptions {
1075            css: Some(true),
1076            css_template,
1077            codepoints: Some(HashMap::from([("add".to_owned(), 0xE001u32)])),
1078            dest: "artifacts".to_owned(),
1079            files: vec![fixture],
1080            html: Some(false),
1081            font_name: Some("iconfont".to_owned()),
1082            ligature: Some(false),
1083            order: Some(vec![FontType::Svg]),
1084            start_codepoint: Some(0xE001),
1085            types: Some(vec![FontType::Svg]),
1086            ..Default::default()
1087        };
1088
1089        let mut resolved = resolve_generate_webfonts_options(options).unwrap();
1090        let source_files: Vec<LoadedSvgFile> = resolved
1091            .files
1092            .iter()
1093            .map(|path| LoadedSvgFile {
1094                contents: std::fs::read_to_string(path).unwrap().into(),
1095                glyph_name: std::path::Path::new(path)
1096                    .file_stem()
1097                    .unwrap()
1098                    .to_str()
1099                    .unwrap()
1100                    .to_owned(),
1101                path: path.clone(),
1102            })
1103            .collect();
1104        finalize_generate_webfonts_options(&mut resolved, &source_files).unwrap();
1105
1106        let result = GenerateWebfontsResult {
1107            cached: std::sync::OnceLock::new(),
1108            carried_render: None,
1109            css_context: None,
1110            fonts: FontOutputs::default(),
1111            html_context: None,
1112            options: Arc::new(resolved),
1113            regeneration_state: Arc::new(Mutex::new(None)),
1114            source_files: Arc::new(source_files),
1115        };
1116
1117        if let Some(dir) = cleanup_dir {
1118            // Don't clean up yet -- template file needed for lazy compilation
1119            std::mem::forget(dir);
1120        }
1121
1122        result
1123    }
1124
1125    #[test]
1126    fn generate_css_returns_cached_result_on_repeated_calls_without_urls() {
1127        let result = build_result(None);
1128
1129        let first = result.generate_css_pure(None).unwrap();
1130        let second = result.generate_css_pure(None).unwrap();
1131
1132        assert_eq!(first, second);
1133        assert!(!first.is_empty());
1134    }
1135
1136    #[test]
1137    fn generate_css_returns_cached_result_on_repeated_calls_with_same_urls() {
1138        let result = build_result(None);
1139        let urls = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
1140
1141        let first = result.generate_css_pure(Some(urls.clone())).unwrap();
1142        let second = result.generate_css_pure(Some(urls)).unwrap();
1143
1144        assert_eq!(first, second);
1145        assert!(first.contains("/a.svg"));
1146    }
1147
1148    #[test]
1149    fn generate_css_returns_different_result_for_different_urls() {
1150        let result = build_result(None);
1151        let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
1152        let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
1153
1154        let result_a = result.generate_css_pure(Some(urls_a)).unwrap();
1155        let result_b = result.generate_css_pure(Some(urls_b)).unwrap();
1156
1157        assert_ne!(result_a, result_b);
1158        assert!(result_a.contains("/a.svg"));
1159        assert!(result_b.contains("/b.svg"));
1160    }
1161
1162    #[test]
1163    fn generate_css_cache_updates_when_urls_change() {
1164        let result = build_result(None);
1165        let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
1166        let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
1167
1168        let first_a = result.generate_css_pure(Some(urls_a.clone())).unwrap();
1169        let first_b = result.generate_css_pure(Some(urls_b)).unwrap();
1170        let second_a = result.generate_css_pure(Some(urls_a)).unwrap();
1171
1172        assert_eq!(
1173            first_a, second_a,
1174            "returning to original urls should produce same result"
1175        );
1176        assert_ne!(first_a, first_b);
1177    }
1178
1179    #[test]
1180    fn generate_css_cache_works_with_custom_template() {
1181        let result = build_result(Some("@font-face { src: {{{src}}}; }"));
1182        let urls = HashMap::from([(FontType::Svg, "/cached.svg".to_owned())]);
1183
1184        let first = result.generate_css_pure(Some(urls.clone())).unwrap();
1185        let second = result.generate_css_pure(Some(urls)).unwrap();
1186
1187        assert_eq!(first, second);
1188        assert!(first.contains("/cached.svg"));
1189    }
1190
1191    #[test]
1192    fn generate_css_no_urls_and_with_urls_are_independent_caches() {
1193        let result = build_result(None);
1194        let urls = HashMap::from([(FontType::Svg, "/custom.svg".to_owned())]);
1195
1196        let no_urls = result.generate_css_pure(None).unwrap();
1197        let with_urls = result.generate_css_pure(Some(urls)).unwrap();
1198        let no_urls_again = result.generate_css_pure(None).unwrap();
1199
1200        assert_eq!(
1201            no_urls, no_urls_again,
1202            "no-urls cache should survive a with-urls call"
1203        );
1204        assert_ne!(no_urls, with_urls);
1205    }
1206
1207    #[test]
1208    fn generate_css_with_urls_returns_no_urls_result_when_template_does_not_use_src() {
1209        let result = build_result(Some(".icon { font-family: {{fontName}}; }"));
1210        let urls = HashMap::from([(FontType::Svg, "/should-not-appear.svg".to_owned())]);
1211
1212        let no_urls = result.generate_css_pure(None).unwrap();
1213        let with_urls = result.generate_css_pure(Some(urls)).unwrap();
1214
1215        assert_eq!(
1216            no_urls, with_urls,
1217            "template without {{src}} should ignore urls"
1218        );
1219        assert!(!with_urls.contains("/should-not-appear.svg"));
1220        assert!(
1221            with_urls.contains("iconfont"),
1222            "should still render the template"
1223        );
1224    }
1225
1226    #[test]
1227    fn generate_html_with_urls_returns_no_urls_result_when_css_template_does_not_use_src() {
1228        let result = build_result(Some(".icon { font-family: {{fontName}}; }"));
1229        let urls = HashMap::from([(FontType::Svg, "/should-not-appear.svg".to_owned())]);
1230
1231        let no_urls = result.generate_html_pure(None).unwrap();
1232        let with_urls = result.generate_html_pure(Some(urls)).unwrap();
1233
1234        assert_eq!(
1235            no_urls, with_urls,
1236            "CSS template without {{src}} means HTML is also unaffected by urls"
1237        );
1238    }
1239
1240    #[test]
1241    fn generate_css_without_urls_produces_valid_css_using_css_fonts_url() {
1242        let result = build_result(None);
1243
1244        let css = result.generate_css_pure(None).unwrap();
1245
1246        assert!(
1247            css.contains("@font-face"),
1248            "should contain @font-face declaration"
1249        );
1250        assert!(css.contains("font-family:"), "should contain font-family");
1251        assert!(
1252            css.contains("iconfont.svg?"),
1253            "should use font name in URL with hash"
1254        );
1255        assert!(
1256            css.contains("format(\"svg\")"),
1257            "should contain format declaration"
1258        );
1259        assert!(
1260            css.contains("content:"),
1261            "should contain codepoint content rules"
1262        );
1263    }
1264
1265    #[test]
1266    fn generate_css_with_urls_replaces_default_urls_in_src() {
1267        let result = build_result(None);
1268        let urls = HashMap::from([(FontType::Svg, "/cdn/icons.svg".to_owned())]);
1269
1270        let css = result.generate_css_pure(Some(urls)).unwrap();
1271
1272        assert!(
1273            css.contains("/cdn/icons.svg"),
1274            "custom URL should appear in output"
1275        );
1276        assert!(
1277            !css.contains("iconfont.svg?"),
1278            "default hash-based URL should not appear"
1279        );
1280        assert!(
1281            css.contains("format(\"svg\")"),
1282            "format should still be present"
1283        );
1284    }
1285
1286    #[test]
1287    fn generate_html_without_urls_produces_valid_html() {
1288        let result = build_result(None);
1289
1290        let html = result.generate_html_pure(None).unwrap();
1291
1292        assert!(
1293            html.contains("<!DOCTYPE html>"),
1294            "should be a full HTML document"
1295        );
1296        assert!(html.contains("iconfont"), "should contain font name");
1297        assert!(html.contains("icon-add"), "should contain icon class name");
1298    }
1299
1300    #[test]
1301    fn generate_html_with_urls_embeds_css_using_custom_urls() {
1302        let result = build_result(None);
1303        let urls = HashMap::from([(FontType::Svg, "/cdn/icons.svg".to_owned())]);
1304
1305        let html = result.generate_html_pure(Some(urls)).unwrap();
1306
1307        assert!(
1308            html.contains("/cdn/icons.svg"),
1309            "custom URL should appear in embedded CSS"
1310        );
1311        assert!(
1312            html.contains("icon-add"),
1313            "should still contain icon class name"
1314        );
1315    }
1316
1317    #[test]
1318    fn generate_html_cache_returns_same_result_for_same_urls() {
1319        let result = build_result(None);
1320        let urls = HashMap::from([(FontType::Svg, "/cached.svg".to_owned())]);
1321
1322        let first = result.generate_html_pure(Some(urls.clone())).unwrap();
1323        let second = result.generate_html_pure(Some(urls)).unwrap();
1324
1325        assert_eq!(first, second);
1326        assert!(first.contains("/cached.svg"));
1327    }
1328
1329    #[test]
1330    fn generate_html_cache_returns_different_result_for_different_urls() {
1331        let result = build_result(None);
1332        let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
1333        let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
1334
1335        let result_a = result.generate_html_pure(Some(urls_a)).unwrap();
1336        let result_b = result.generate_html_pure(Some(urls_b)).unwrap();
1337
1338        assert_ne!(result_a, result_b);
1339        assert!(result_a.contains("/a.svg"));
1340        assert!(result_b.contains("/b.svg"));
1341    }
1342
1343    /// Build a result with multiple font types (svg + woff2) for testing partial URL overrides.
1344    fn build_multi_type_result() -> GenerateWebfontsResult {
1345        let fixture = crate::test_helpers::webfont_fixture("add.svg");
1346        let options = GenerateWebfontsOptions {
1347            css: Some(true),
1348            codepoints: Some(HashMap::from([("add".to_owned(), 0xE001u32)])),
1349            dest: "artifacts".to_owned(),
1350            files: vec![fixture],
1351            html: Some(true),
1352            font_name: Some("iconfont".to_owned()),
1353            ligature: Some(false),
1354            order: Some(vec![FontType::Woff2, FontType::Svg]),
1355            start_codepoint: Some(0xE001),
1356            types: Some(vec![FontType::Svg, FontType::Woff2]),
1357            ..Default::default()
1358        };
1359
1360        let mut resolved = resolve_generate_webfonts_options(options).unwrap();
1361        let source_files: Vec<LoadedSvgFile> = resolved
1362            .files
1363            .iter()
1364            .map(|path| LoadedSvgFile {
1365                contents: std::fs::read_to_string(path).unwrap().into(),
1366                glyph_name: std::path::Path::new(path)
1367                    .file_stem()
1368                    .unwrap()
1369                    .to_str()
1370                    .unwrap()
1371                    .to_owned(),
1372                path: path.clone(),
1373            })
1374            .collect();
1375        finalize_generate_webfonts_options(&mut resolved, &source_files).unwrap();
1376
1377        GenerateWebfontsResult {
1378            cached: std::sync::OnceLock::new(),
1379            carried_render: None,
1380            css_context: None,
1381            fonts: FontOutputs::default(),
1382            html_context: None,
1383            options: Arc::new(resolved),
1384            regeneration_state: Arc::new(Mutex::new(None)),
1385            source_files: Arc::new(source_files),
1386        }
1387    }
1388
1389    #[test]
1390    fn generate_css_partial_urls_uses_empty_string_for_missing_types() {
1391        let result = build_multi_type_result();
1392        // Override only woff2, leave svg un-provided -- matches upstream behavior
1393        let urls = HashMap::from([(FontType::Woff2, "/cdn/font.woff2".to_owned())]);
1394
1395        let css = result.generate_css_pure(Some(urls)).unwrap();
1396
1397        assert!(
1398            css.contains("/cdn/font.woff2"),
1399            "overridden URL should appear"
1400        );
1401        assert!(
1402            !css.contains("iconfont.svg?"),
1403            "non-overridden type should not have default hash-based URL"
1404        );
1405        assert!(
1406            css.contains("url(\"#iconfont\")"),
1407            "non-overridden SVG type should produce empty base URL (upstream compat)"
1408        );
1409    }
1410
1411    #[test]
1412    fn generate_html_partial_urls_uses_empty_string_for_missing_types() {
1413        let result = build_multi_type_result();
1414        let urls = HashMap::from([(FontType::Woff2, "/cdn/font.woff2".to_owned())]);
1415
1416        let html = result.generate_html_pure(Some(urls)).unwrap();
1417
1418        assert!(
1419            html.contains("/cdn/font.woff2"),
1420            "overridden URL should appear in HTML"
1421        );
1422        assert!(
1423            !html.contains("iconfont.svg?"),
1424            "non-overridden type should not have default hash-based URL in HTML"
1425        );
1426    }
1427}