Skip to main content

webfont_generator/
types.rs

1use std::collections::{BTreeMap, HashMap};
2use std::sync::Arc;
3use std::sync::{Mutex, OnceLock};
4
5#[cfg(feature = "napi")]
6use napi::bindgen_prelude::Uint8Array;
7#[cfg(feature = "napi")]
8use napi_derive::napi;
9use serde_json::{Map, Value};
10
11use crate::templates::{
12    SharedTemplateData, build_css_context, build_html_context, build_html_registry, make_src,
13    render_css_with_hbs_context, render_css_with_src_mutate, render_default_html_with_styles,
14    render_html_with_hbs_context,
15};
16use crate::util::to_io_err;
17
18#[cfg_attr(feature = "napi", napi(string_enum = "lowercase"))]
19#[derive(Clone, Copy, PartialEq, Eq, Hash)]
20pub enum FontType {
21    Svg,
22    Ttf,
23    Eot,
24    Woff,
25    Woff2,
26}
27
28impl FontType {
29    /// Returns the CSS `format()` value (e.g., "truetype", "woff2").
30    #[inline]
31    pub fn css_format(self) -> &'static str {
32        match self {
33            FontType::Svg => "svg",
34            FontType::Ttf => "truetype",
35            FontType::Eot => "embedded-opentype",
36            FontType::Woff => "woff",
37            FontType::Woff2 => "woff2",
38        }
39    }
40
41    /// Returns the file extension (e.g., "svg", "ttf", "woff2").
42    #[inline]
43    pub fn as_extension(self) -> &'static str {
44        match self {
45            FontType::Svg => "svg",
46            FontType::Ttf => "ttf",
47            FontType::Eot => "eot",
48            FontType::Woff => "woff",
49            FontType::Woff2 => "woff2",
50        }
51    }
52}
53
54#[cfg_attr(feature = "napi", napi(object))]
55#[derive(Clone, Default)]
56pub struct SvgFormatOptions {
57    pub center_vertically: Option<bool>,
58    pub font_id: Option<String>,
59    pub metadata: Option<String>,
60    pub optimize_output: Option<bool>,
61    pub preserve_aspect_ratio: Option<bool>,
62}
63
64#[cfg_attr(feature = "napi", napi(object))]
65#[derive(Clone)]
66pub struct TtfFormatOptions {
67    pub copyright: Option<String>,
68    pub description: Option<String>,
69    pub ts: Option<i64>,
70    pub url: Option<String>,
71    pub version: Option<String>,
72}
73
74#[cfg_attr(feature = "napi", napi(object))]
75#[derive(Clone)]
76pub struct WoffFormatOptions {
77    pub metadata: Option<String>,
78}
79
80#[cfg_attr(feature = "napi", napi(object))]
81#[derive(Clone, Default)]
82pub struct FormatOptions {
83    pub svg: Option<SvgFormatOptions>,
84    pub ttf: Option<TtfFormatOptions>,
85    pub woff: Option<WoffFormatOptions>,
86}
87
88#[cfg_attr(feature = "napi", napi(object))]
89#[derive(Clone, Default)]
90pub struct GenerateWebfontsOptions {
91    pub ascent: Option<f64>,
92    pub center_horizontally: Option<bool>,
93    pub center_vertically: Option<bool>,
94    pub css: Option<bool>,
95    pub css_dest: Option<String>,
96    pub css_template: Option<String>,
97    pub codepoints: Option<HashMap<String, u32>>,
98    pub css_fonts_url: Option<String>,
99    pub descent: Option<f64>,
100    pub dest: String,
101    pub files: Vec<String>,
102    pub fixed_width: Option<bool>,
103    pub format_options: Option<FormatOptions>,
104    pub html: Option<bool>,
105    pub html_dest: Option<String>,
106    pub html_template: Option<String>,
107    pub font_height: Option<f64>,
108    pub font_name: Option<String>,
109    pub font_style: Option<String>,
110    pub font_weight: Option<String>,
111    pub ligature: Option<bool>,
112    pub normalize: Option<bool>,
113    pub order: Option<Vec<FontType>>,
114    pub optimize_output: Option<bool>,
115    pub preserve_aspect_ratio: Option<bool>,
116    pub round: Option<f64>,
117    pub start_codepoint: Option<u32>,
118    pub template_options: Option<Map<String, Value>>,
119    pub types: Option<Vec<FontType>>,
120    pub write_files: Option<bool>,
121}
122
123pub(crate) const DEFAULT_FONT_TYPES: [FontType; 3] =
124    [FontType::Eot, FontType::Woff, FontType::Woff2];
125
126pub(crate) const DEFAULT_FONT_ORDER: [FontType; 5] = [
127    FontType::Eot,
128    FontType::Woff2,
129    FontType::Woff,
130    FontType::Ttf,
131    FontType::Svg,
132];
133
134pub(crate) fn resolved_font_types(options: &GenerateWebfontsOptions) -> Vec<FontType> {
135    match &options.types {
136        Some(types) => types.clone(),
137        None => DEFAULT_FONT_TYPES.to_vec(),
138    }
139}
140
141pub(crate) struct ResolvedGenerateWebfontsOptions {
142    pub ascent: Option<f64>,
143    pub center_horizontally: Option<bool>,
144    pub center_vertically: Option<bool>,
145    pub css: bool,
146    pub css_dest: String,
147    pub css_template: Option<String>,
148    pub codepoints: BTreeMap<String, u32>,
149    pub css_fonts_url: Option<String>,
150    pub descent: Option<f64>,
151    pub dest: String,
152    pub files: Vec<String>,
153    pub fixed_width: Option<bool>,
154    pub format_options: Option<FormatOptions>,
155    pub html: bool,
156    pub html_dest: String,
157    pub html_template: Option<String>,
158    pub font_height: Option<f64>,
159    pub font_name: String,
160    pub font_style: Option<String>,
161    pub font_weight: Option<String>,
162    pub ligature: bool,
163    pub normalize: bool,
164    pub order: Vec<FontType>,
165    pub optimize_output: Option<bool>,
166    pub preserve_aspect_ratio: Option<bool>,
167    pub round: Option<f64>,
168    pub start_codepoint: u32,
169    pub template_options: Option<Map<String, Value>>,
170    pub types: Vec<FontType>,
171    pub write_files: bool,
172}
173
174pub(crate) struct LoadedSvgFile {
175    pub contents: String,
176    pub glyph_name: String,
177    pub path: String,
178}
179
180/// Caches the last rendered CSS/HTML result for repeated calls with the same urls.
181#[derive(Default)]
182pub(crate) struct RenderCache {
183    /// Result of generateCss() with no urls (computed once).
184    css_no_urls: Option<String>,
185    /// Last generateCss(urls) result.
186    css_last_urls: Option<HashMap<FontType, String>>,
187    css_last_result: Option<String>,
188    /// Result of generateHtml() with no urls (computed once).
189    html_no_urls: Option<String>,
190    /// Last generateHtml(urls) result.
191    html_last_urls: Option<HashMap<FontType, String>>,
192    html_last_result: Option<String>,
193}
194
195pub(crate) struct CachedTemplateData {
196    pub shared: SharedTemplateData,
197    pub css_context: Map<String, Value>,
198    pub css_hbs_context: Mutex<handlebars::Context>,
199    pub html_context: Map<String, Value>,
200    pub html_hbs_context: Mutex<handlebars::Context>,
201    pub html_registry: Option<handlebars::Handlebars<'static>>,
202    pub(crate) render_cache: Mutex<RenderCache>,
203}
204
205#[cfg_attr(feature = "napi", napi)]
206pub struct GenerateWebfontsResult {
207    pub(crate) css_context: Option<Map<String, Value>>,
208    pub(crate) eot_font: Option<Arc<Vec<u8>>>,
209    pub(crate) html_context: Option<Map<String, Value>>,
210    pub(crate) options: ResolvedGenerateWebfontsOptions,
211    pub(crate) source_files: Vec<LoadedSvgFile>,
212    pub(crate) svg_font: Option<Arc<String>>,
213    pub(crate) ttf_font: Option<Arc<Vec<u8>>>,
214    pub(crate) woff2_font: Option<Arc<Vec<u8>>>,
215    pub(crate) woff_font: Option<Arc<Vec<u8>>>,
216    pub(crate) cached: OnceLock<Result<CachedTemplateData, String>>,
217}
218
219// Pure Rust getters (always available)
220impl GenerateWebfontsResult {
221    /// Returns the EOT font bytes, if generated.
222    pub fn eot_bytes(&self) -> Option<&[u8]> {
223        self.eot_font.as_ref().map(|v| v.as_ref().as_slice())
224    }
225
226    /// Returns the SVG font string, if generated.
227    pub fn svg_string(&self) -> Option<&str> {
228        self.svg_font.as_ref().map(|v| v.as_ref().as_str())
229    }
230
231    /// Returns the TTF font bytes, if generated.
232    pub fn ttf_bytes(&self) -> Option<&[u8]> {
233        self.ttf_font.as_ref().map(|v| v.as_ref().as_slice())
234    }
235
236    /// Returns the WOFF font bytes, if generated.
237    pub fn woff_bytes(&self) -> Option<&[u8]> {
238        self.woff_font.as_ref().map(|v| v.as_ref().as_slice())
239    }
240
241    /// Returns the WOFF2 font bytes, if generated.
242    pub fn woff2_bytes(&self) -> Option<&[u8]> {
243        self.woff2_font.as_ref().map(|v| v.as_ref().as_slice())
244    }
245
246    pub(crate) fn get_cached_io(&self) -> std::io::Result<&CachedTemplateData> {
247        self.cached
248            .get_or_init(|| {
249                let shared = SharedTemplateData::new(&self.options, &self.source_files)
250                    .map_err(|e| e.to_string())?;
251                let css_context = match &self.css_context {
252                    Some(ctx) => ctx.clone(),
253                    None => build_css_context(&self.options, &shared),
254                };
255                let html_context = match &self.html_context {
256                    Some(ctx) => ctx.clone(),
257                    None => build_html_context(&self.options, &shared, &self.source_files, None)
258                        .map_err(|e| e.to_string())?,
259                };
260                let html_registry =
261                    build_html_registry(&self.options).map_err(|e| e.to_string())?;
262                let css_hbs_context =
263                    handlebars::Context::wraps(&css_context).map_err(|e| e.to_string())?;
264                let html_hbs_context =
265                    handlebars::Context::wraps(&html_context).map_err(|e| e.to_string())?;
266                Ok(CachedTemplateData {
267                    shared,
268                    css_context,
269                    css_hbs_context: Mutex::new(css_hbs_context),
270                    html_context,
271                    html_hbs_context: Mutex::new(html_hbs_context),
272                    html_registry,
273                    render_cache: Mutex::new(RenderCache::default()),
274                })
275            })
276            .as_ref()
277            .map_err(to_io_err)
278    }
279
280    /// Generate a CSS string for this webfont result.
281    ///
282    /// Pass `urls` to override the default font URLs in the CSS output.
283    pub fn generate_css_pure(
284        &self,
285        urls: Option<HashMap<FontType, String>>,
286    ) -> std::io::Result<String> {
287        let cached = self.get_cached_io()?;
288        let mut rc = cached.render_cache.lock().unwrap();
289
290        match &urls {
291            None => {
292                if let Some(result) = &rc.css_no_urls {
293                    return Ok(result.clone());
294                }
295                let ctx = cached.css_hbs_context.lock().unwrap();
296                let result =
297                    render_css_with_hbs_context(&cached.shared, &ctx, &cached.css_context)?;
298                rc.css_no_urls = Some(result.clone());
299                Ok(result)
300            }
301            Some(urls) => {
302                // If the template doesn't reference {{src}}, URLs don't affect output
303                if !cached.shared.css_template_uses_src {
304                    drop(rc);
305                    return self.generate_css_pure(None);
306                }
307                if rc.css_last_urls.as_ref() == Some(urls)
308                    && let Some(result) = &rc.css_last_result
309                {
310                    return Ok(result.clone());
311                }
312                let src = make_src(&self.options, urls);
313                let mut ctx = cached.css_hbs_context.lock().unwrap();
314                let result = render_css_with_src_mutate(
315                    &cached.shared,
316                    &mut ctx,
317                    &cached.css_context,
318                    &src,
319                )?;
320                rc.css_last_urls = Some(urls.clone());
321                rc.css_last_result = Some(result.clone());
322                Ok(result)
323            }
324        }
325    }
326
327    /// Generate an HTML string for this webfont result.
328    ///
329    /// Pass `urls` to override the default font URLs in the HTML output.
330    pub fn generate_html_pure(
331        &self,
332        urls: Option<HashMap<FontType, String>>,
333    ) -> std::io::Result<String> {
334        let cached = self.get_cached_io()?;
335        let mut rc = cached.render_cache.lock().unwrap();
336
337        match &urls {
338            None => {
339                if let Some(result) = &rc.html_no_urls {
340                    return Ok(result.clone());
341                }
342                let ctx = cached.html_hbs_context.lock().unwrap();
343                let result = render_html_with_hbs_context(
344                    cached.html_registry.as_ref(),
345                    &ctx,
346                    &cached.html_context,
347                )?;
348                rc.html_no_urls = Some(result.clone());
349                Ok(result)
350            }
351            Some(urls) => {
352                // If the CSS template doesn't reference {{src}}, URLs don't affect output
353                if !cached.shared.css_template_uses_src {
354                    drop(rc);
355                    return self.generate_html_pure(None);
356                }
357                if rc.html_last_urls.as_ref() == Some(urls)
358                    && let Some(result) = &rc.html_last_result
359                {
360                    return Ok(result.clone());
361                }
362                // Render CSS with the custom URLs (in-place src mutate, no clone)
363                let src = make_src(&self.options, urls);
364                let styles = {
365                    let mut css_ctx = cached.css_hbs_context.lock().unwrap();
366                    render_css_with_src_mutate(
367                        &cached.shared,
368                        &mut css_ctx,
369                        &cached.css_context,
370                        &src,
371                    )?
372                };
373                // Hot path: default HTML template -- inject styles directly, skip clone
374                if self.options.html_template.is_none() {
375                    let result = render_default_html_with_styles(&cached.html_context, &styles);
376                    rc.html_last_urls = Some(urls.clone());
377                    rc.html_last_result = Some(result.clone());
378                    return Ok(result);
379                }
380                // Custom HTML template: in-place styles mutate, no clone
381                let mut html_ctx = cached.html_hbs_context.lock().unwrap();
382                let registry = cached
383                    .html_registry
384                    .as_ref()
385                    .expect("HTML registry should exist for custom template");
386                let result = crate::util::render_with_field_swap(
387                    &mut html_ctx,
388                    "styles",
389                    serde_json::Value::String(styles),
390                    |ctx| {
391                        registry
392                            .render_with_context("html", ctx)
393                            .map_err(crate::util::to_io_err)
394                    },
395                )?;
396                rc.html_last_urls = Some(urls.clone());
397                rc.html_last_result = Some(result.clone());
398                Ok(result)
399            }
400        }
401    }
402}
403
404// NAPI getters and methods
405#[cfg(feature = "napi")]
406#[napi]
407impl GenerateWebfontsResult {
408    #[napi(getter)]
409    pub fn eot(&self) -> Option<Uint8Array> {
410        self.eot_font
411            .as_ref()
412            .map(|v| Uint8Array::from(v.as_ref().clone()))
413    }
414
415    #[napi(getter)]
416    pub fn svg(&self) -> Option<String> {
417        self.svg_font.as_ref().map(|v| v.as_ref().clone())
418    }
419
420    #[napi(getter)]
421    pub fn ttf(&self) -> Option<Uint8Array> {
422        self.ttf_font
423            .as_ref()
424            .map(|v| Uint8Array::from(v.as_ref().clone()))
425    }
426
427    #[napi(getter)]
428    pub fn woff2(&self) -> Option<Uint8Array> {
429        self.woff2_font
430            .as_ref()
431            .map(|v| Uint8Array::from(v.as_ref().clone()))
432    }
433
434    #[napi(getter)]
435    pub fn woff(&self) -> Option<Uint8Array> {
436        self.woff_font
437            .as_ref()
438            .map(|v| Uint8Array::from(v.as_ref().clone()))
439    }
440
441    #[napi(ts_args_type = "urls?: Partial<Record<FontType, string>>")]
442    pub fn generate_css(&self, urls: Option<HashMap<String, String>>) -> napi::Result<String> {
443        let urls = urls.map(parse_native_urls).transpose()?;
444        self.generate_css_pure(urls)
445            .map_err(crate::util::to_napi_err)
446    }
447
448    #[napi(ts_args_type = "urls?: Partial<Record<FontType, string>>")]
449    pub fn generate_html(&self, urls: Option<HashMap<String, String>>) -> napi::Result<String> {
450        let urls = urls.map(parse_native_urls).transpose()?;
451        self.generate_html_pure(urls)
452            .map_err(crate::util::to_napi_err)
453    }
454}
455
456#[cfg(feature = "napi")]
457fn parse_native_urls(urls: HashMap<String, String>) -> napi::Result<HashMap<FontType, String>> {
458    urls.into_iter()
459        .filter_map(|(font_type, url)| {
460            let font_type = match font_type.as_str() {
461                "svg" => Some(FontType::Svg),
462                "ttf" => Some(FontType::Ttf),
463                "eot" => Some(FontType::Eot),
464                "woff" => Some(FontType::Woff),
465                "woff2" => Some(FontType::Woff2),
466                _ => None,
467            }?;
468
469            Some(Ok((font_type, url)))
470        })
471        .collect::<napi::Result<HashMap<FontType, String>>>()
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use crate::{finalize_generate_webfonts_options, resolve_generate_webfonts_options};
478
479    fn build_result(template: Option<&str>) -> GenerateWebfontsResult {
480        let fixture = crate::test_helpers::webfont_fixture("add.svg");
481
482        let mut css_template = None;
483        let cleanup_dir;
484        if let Some(content) = template {
485            let tmp = std::env::temp_dir().join(format!(
486                "render-cache-test-{}",
487                std::time::SystemTime::now()
488                    .duration_since(std::time::UNIX_EPOCH)
489                    .unwrap()
490                    .as_nanos()
491            ));
492            std::fs::create_dir_all(&tmp).unwrap();
493            let path = tmp.join("template.hbs");
494            std::fs::write(&path, content).unwrap();
495            css_template = Some(path.to_string_lossy().into_owned());
496            cleanup_dir = Some(tmp);
497        } else {
498            cleanup_dir = None;
499        }
500
501        let options = GenerateWebfontsOptions {
502            css: Some(true),
503            css_template,
504            codepoints: Some(HashMap::from([("add".to_owned(), 0xE001u32)])),
505            dest: "artifacts".to_owned(),
506            files: vec![fixture],
507            html: Some(false),
508            font_name: Some("iconfont".to_owned()),
509            ligature: Some(false),
510            order: Some(vec![FontType::Svg]),
511            start_codepoint: Some(0xE001),
512            types: Some(vec![FontType::Svg]),
513            ..Default::default()
514        };
515
516        let mut resolved = resolve_generate_webfonts_options(options).unwrap();
517        let source_files: Vec<LoadedSvgFile> = resolved
518            .files
519            .iter()
520            .map(|path| LoadedSvgFile {
521                contents: std::fs::read_to_string(path).unwrap(),
522                glyph_name: std::path::Path::new(path)
523                    .file_stem()
524                    .unwrap()
525                    .to_str()
526                    .unwrap()
527                    .to_owned(),
528                path: path.clone(),
529            })
530            .collect();
531        finalize_generate_webfonts_options(&mut resolved, &source_files).unwrap();
532
533        let result = GenerateWebfontsResult {
534            cached: std::sync::OnceLock::new(),
535            css_context: None,
536            eot_font: None,
537            html_context: None,
538            options: resolved,
539            source_files,
540            svg_font: None,
541            ttf_font: None,
542            woff2_font: None,
543            woff_font: None,
544        };
545
546        if let Some(dir) = cleanup_dir {
547            // Don't clean up yet -- template file needed for lazy compilation
548            std::mem::forget(dir);
549        }
550
551        result
552    }
553
554    #[test]
555    fn generate_css_returns_cached_result_on_repeated_calls_without_urls() {
556        let result = build_result(None);
557
558        let first = result.generate_css_pure(None).unwrap();
559        let second = result.generate_css_pure(None).unwrap();
560
561        assert_eq!(first, second);
562        assert!(!first.is_empty());
563    }
564
565    #[test]
566    fn generate_css_returns_cached_result_on_repeated_calls_with_same_urls() {
567        let result = build_result(None);
568        let urls = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
569
570        let first = result.generate_css_pure(Some(urls.clone())).unwrap();
571        let second = result.generate_css_pure(Some(urls)).unwrap();
572
573        assert_eq!(first, second);
574        assert!(first.contains("/a.svg"));
575    }
576
577    #[test]
578    fn generate_css_returns_different_result_for_different_urls() {
579        let result = build_result(None);
580        let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
581        let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
582
583        let result_a = result.generate_css_pure(Some(urls_a)).unwrap();
584        let result_b = result.generate_css_pure(Some(urls_b)).unwrap();
585
586        assert_ne!(result_a, result_b);
587        assert!(result_a.contains("/a.svg"));
588        assert!(result_b.contains("/b.svg"));
589    }
590
591    #[test]
592    fn generate_css_cache_updates_when_urls_change() {
593        let result = build_result(None);
594        let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
595        let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
596
597        let first_a = result.generate_css_pure(Some(urls_a.clone())).unwrap();
598        let first_b = result.generate_css_pure(Some(urls_b)).unwrap();
599        let second_a = result.generate_css_pure(Some(urls_a)).unwrap();
600
601        assert_eq!(
602            first_a, second_a,
603            "returning to original urls should produce same result"
604        );
605        assert_ne!(first_a, first_b);
606    }
607
608    #[test]
609    fn generate_css_cache_works_with_custom_template() {
610        let result = build_result(Some("@font-face { src: {{{src}}}; }"));
611        let urls = HashMap::from([(FontType::Svg, "/cached.svg".to_owned())]);
612
613        let first = result.generate_css_pure(Some(urls.clone())).unwrap();
614        let second = result.generate_css_pure(Some(urls)).unwrap();
615
616        assert_eq!(first, second);
617        assert!(first.contains("/cached.svg"));
618    }
619
620    #[test]
621    fn generate_css_no_urls_and_with_urls_are_independent_caches() {
622        let result = build_result(None);
623        let urls = HashMap::from([(FontType::Svg, "/custom.svg".to_owned())]);
624
625        let no_urls = result.generate_css_pure(None).unwrap();
626        let with_urls = result.generate_css_pure(Some(urls)).unwrap();
627        let no_urls_again = result.generate_css_pure(None).unwrap();
628
629        assert_eq!(
630            no_urls, no_urls_again,
631            "no-urls cache should survive a with-urls call"
632        );
633        assert_ne!(no_urls, with_urls);
634    }
635
636    #[test]
637    fn generate_css_with_urls_returns_no_urls_result_when_template_does_not_use_src() {
638        let result = build_result(Some(".icon { font-family: {{fontName}}; }"));
639        let urls = HashMap::from([(FontType::Svg, "/should-not-appear.svg".to_owned())]);
640
641        let no_urls = result.generate_css_pure(None).unwrap();
642        let with_urls = result.generate_css_pure(Some(urls)).unwrap();
643
644        assert_eq!(
645            no_urls, with_urls,
646            "template without {{src}} should ignore urls"
647        );
648        assert!(!with_urls.contains("/should-not-appear.svg"));
649        assert!(
650            with_urls.contains("iconfont"),
651            "should still render the template"
652        );
653    }
654
655    #[test]
656    fn generate_html_with_urls_returns_no_urls_result_when_css_template_does_not_use_src() {
657        let result = build_result(Some(".icon { font-family: {{fontName}}; }"));
658        let urls = HashMap::from([(FontType::Svg, "/should-not-appear.svg".to_owned())]);
659
660        let no_urls = result.generate_html_pure(None).unwrap();
661        let with_urls = result.generate_html_pure(Some(urls)).unwrap();
662
663        assert_eq!(
664            no_urls, with_urls,
665            "CSS template without {{src}} means HTML is also unaffected by urls"
666        );
667    }
668
669    #[test]
670    fn generate_css_without_urls_produces_valid_css_using_css_fonts_url() {
671        let result = build_result(None);
672
673        let css = result.generate_css_pure(None).unwrap();
674
675        assert!(
676            css.contains("@font-face"),
677            "should contain @font-face declaration"
678        );
679        assert!(css.contains("font-family:"), "should contain font-family");
680        assert!(
681            css.contains("iconfont.svg?"),
682            "should use font name in URL with hash"
683        );
684        assert!(
685            css.contains("format(\"svg\")"),
686            "should contain format declaration"
687        );
688        assert!(
689            css.contains("content:"),
690            "should contain codepoint content rules"
691        );
692    }
693
694    #[test]
695    fn generate_css_with_urls_replaces_default_urls_in_src() {
696        let result = build_result(None);
697        let urls = HashMap::from([(FontType::Svg, "/cdn/icons.svg".to_owned())]);
698
699        let css = result.generate_css_pure(Some(urls)).unwrap();
700
701        assert!(
702            css.contains("/cdn/icons.svg"),
703            "custom URL should appear in output"
704        );
705        assert!(
706            !css.contains("iconfont.svg?"),
707            "default hash-based URL should not appear"
708        );
709        assert!(
710            css.contains("format(\"svg\")"),
711            "format should still be present"
712        );
713    }
714
715    #[test]
716    fn generate_html_without_urls_produces_valid_html() {
717        let result = build_result(None);
718
719        let html = result.generate_html_pure(None).unwrap();
720
721        assert!(
722            html.contains("<!DOCTYPE html>"),
723            "should be a full HTML document"
724        );
725        assert!(html.contains("iconfont"), "should contain font name");
726        assert!(html.contains("icon-add"), "should contain icon class name");
727    }
728
729    #[test]
730    fn generate_html_with_urls_embeds_css_using_custom_urls() {
731        let result = build_result(None);
732        let urls = HashMap::from([(FontType::Svg, "/cdn/icons.svg".to_owned())]);
733
734        let html = result.generate_html_pure(Some(urls)).unwrap();
735
736        assert!(
737            html.contains("/cdn/icons.svg"),
738            "custom URL should appear in embedded CSS"
739        );
740        assert!(
741            html.contains("icon-add"),
742            "should still contain icon class name"
743        );
744    }
745
746    #[test]
747    fn generate_html_cache_returns_same_result_for_same_urls() {
748        let result = build_result(None);
749        let urls = HashMap::from([(FontType::Svg, "/cached.svg".to_owned())]);
750
751        let first = result.generate_html_pure(Some(urls.clone())).unwrap();
752        let second = result.generate_html_pure(Some(urls)).unwrap();
753
754        assert_eq!(first, second);
755        assert!(first.contains("/cached.svg"));
756    }
757
758    #[test]
759    fn generate_html_cache_returns_different_result_for_different_urls() {
760        let result = build_result(None);
761        let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
762        let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
763
764        let result_a = result.generate_html_pure(Some(urls_a)).unwrap();
765        let result_b = result.generate_html_pure(Some(urls_b)).unwrap();
766
767        assert_ne!(result_a, result_b);
768        assert!(result_a.contains("/a.svg"));
769        assert!(result_b.contains("/b.svg"));
770    }
771
772    /// Build a result with multiple font types (svg + woff2) for testing partial URL overrides.
773    fn build_multi_type_result() -> GenerateWebfontsResult {
774        let fixture = crate::test_helpers::webfont_fixture("add.svg");
775        let options = GenerateWebfontsOptions {
776            css: Some(true),
777            codepoints: Some(HashMap::from([("add".to_owned(), 0xE001u32)])),
778            dest: "artifacts".to_owned(),
779            files: vec![fixture],
780            html: Some(true),
781            font_name: Some("iconfont".to_owned()),
782            ligature: Some(false),
783            order: Some(vec![FontType::Woff2, FontType::Svg]),
784            start_codepoint: Some(0xE001),
785            types: Some(vec![FontType::Svg, FontType::Woff2]),
786            ..Default::default()
787        };
788
789        let mut resolved = resolve_generate_webfonts_options(options).unwrap();
790        let source_files: Vec<LoadedSvgFile> = resolved
791            .files
792            .iter()
793            .map(|path| LoadedSvgFile {
794                contents: std::fs::read_to_string(path).unwrap(),
795                glyph_name: std::path::Path::new(path)
796                    .file_stem()
797                    .unwrap()
798                    .to_str()
799                    .unwrap()
800                    .to_owned(),
801                path: path.clone(),
802            })
803            .collect();
804        finalize_generate_webfonts_options(&mut resolved, &source_files).unwrap();
805
806        GenerateWebfontsResult {
807            cached: std::sync::OnceLock::new(),
808            css_context: None,
809            eot_font: None,
810            html_context: None,
811            options: resolved,
812            source_files,
813            svg_font: None,
814            ttf_font: None,
815            woff2_font: None,
816            woff_font: None,
817        }
818    }
819
820    #[test]
821    fn generate_css_partial_urls_uses_empty_string_for_missing_types() {
822        let result = build_multi_type_result();
823        // Override only woff2, leave svg un-provided -- matches upstream behavior
824        let urls = HashMap::from([(FontType::Woff2, "/cdn/font.woff2".to_owned())]);
825
826        let css = result.generate_css_pure(Some(urls)).unwrap();
827
828        assert!(
829            css.contains("/cdn/font.woff2"),
830            "overridden URL should appear"
831        );
832        assert!(
833            !css.contains("iconfont.svg?"),
834            "non-overridden type should not have default hash-based URL"
835        );
836        assert!(
837            css.contains("url(\"#iconfont\")"),
838            "non-overridden SVG type should produce empty base URL (upstream compat)"
839        );
840    }
841
842    #[test]
843    fn generate_html_partial_urls_uses_empty_string_for_missing_types() {
844        let result = build_multi_type_result();
845        let urls = HashMap::from([(FontType::Woff2, "/cdn/font.woff2".to_owned())]);
846
847        let html = result.generate_html_pure(Some(urls)).unwrap();
848
849        assert!(
850            html.contains("/cdn/font.woff2"),
851            "overridden URL should appear in HTML"
852        );
853        assert!(
854            !html.contains("iconfont.svg?"),
855            "non-overridden type should not have default hash-based URL in HTML"
856        );
857    }
858}