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