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"))]
22#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
23#[derive(Clone, Copy, PartialEq, Eq, Hash)]
24pub enum FontType {
25 Svg,
28 Ttf,
30 Eot,
32 Woff,
34 Woff2,
37}
38
39impl FontType {
40 #[inline]
42 pub fn css_format(self) -> &'static str {
43 match self {
44 FontType::Svg => "svg",
45 FontType::Ttf => "truetype",
46 FontType::Eot => "embedded-opentype",
47 FontType::Woff => "woff",
48 FontType::Woff2 => "woff2",
49 }
50 }
51
52 #[inline]
54 pub fn as_extension(self) -> &'static str {
55 match self {
56 FontType::Svg => "svg",
57 FontType::Ttf => "ttf",
58 FontType::Eot => "eot",
59 FontType::Woff => "woff",
60 FontType::Woff2 => "woff2",
61 }
62 }
63}
64
65#[cfg_attr(feature = "napi", napi(object))]
68#[derive(Clone, Default)]
69pub struct SvgFormatOptions {
70 pub center_vertically: Option<bool>,
74 pub font_id: Option<String>,
77 pub metadata: Option<String>,
79 pub optimize_output: Option<bool>,
83 pub preserve_aspect_ratio: Option<bool>,
87}
88
89#[cfg_attr(feature = "napi", napi(object))]
92#[derive(Clone)]
93pub struct TtfFormatOptions {
94 pub copyright: Option<String>,
96 pub description: Option<String>,
98 pub ts: Option<i64>,
102 pub url: Option<String>,
104 pub version: Option<String>,
106}
107
108#[cfg_attr(feature = "napi", napi(object))]
110#[derive(Clone)]
111pub struct WoffFormatOptions {
112 pub metadata: Option<String>,
114}
115
116#[cfg_attr(feature = "napi", napi(object))]
119#[derive(Clone, Default)]
120pub struct FormatOptions {
121 pub svg: Option<SvgFormatOptions>,
123 pub ttf: Option<TtfFormatOptions>,
125 pub woff: Option<WoffFormatOptions>,
127}
128
129#[cfg_attr(feature = "napi", napi(object))]
133#[derive(Clone)]
134pub struct CssContext {
135 pub font_name: String,
137 pub src: String,
141 pub codepoints: HashMap<String, String>,
145}
146
147#[cfg_attr(feature = "napi", napi(object))]
151#[derive(Clone)]
152pub struct HtmlContext {
153 pub font_name: String,
155 pub names: Vec<String>,
158 pub styles: String,
162 pub codepoints: HashMap<String, u32>,
166}
167
168#[cfg_attr(feature = "napi", napi(object))]
172#[derive(Clone, Default)]
173pub struct GenerateWebfontsOptions {
174 pub ascent: Option<f64>,
177 pub center_horizontally: Option<bool>,
180 pub center_vertically: Option<bool>,
184 pub css: Option<bool>,
186 pub css_dest: Option<String>,
189 pub css_template: Option<String>,
192 pub codepoints: Option<HashMap<String, u32>>,
195 pub css_fonts_url: Option<String>,
198 pub descent: Option<f64>,
201 pub dest: String,
203 pub files: Vec<String>,
205 pub fixed_width: Option<bool>,
207 pub format_options: Option<FormatOptions>,
209 pub html: Option<bool>,
211 pub html_dest: Option<String>,
214 pub html_template: Option<String>,
216 pub font_height: Option<f64>,
219 pub font_name: Option<String>,
222 pub font_style: Option<String>,
224 pub font_weight: Option<String>,
226 pub ligature: Option<bool>,
229 pub normalize: Option<bool>,
231 pub order: Option<Vec<FontType>>,
236 pub optimize_output: Option<bool>,
240 pub preserve_aspect_ratio: Option<bool>,
243 pub round: Option<f64>,
245 pub start_codepoint: Option<u32>,
247 pub template_options: Option<Map<String, Value>>,
251 pub types: Option<Vec<FontType>>,
253 pub write_files: Option<bool>,
256}
257
258pub(crate) const DEFAULT_FONT_TYPES: [FontType; 3] =
259 [FontType::Eot, FontType::Woff, FontType::Woff2];
260
261pub(crate) const DEFAULT_FONT_ORDER: [FontType; 5] = [
262 FontType::Eot,
263 FontType::Woff2,
264 FontType::Woff,
265 FontType::Ttf,
266 FontType::Svg,
267];
268
269pub(crate) fn resolved_font_types(options: &GenerateWebfontsOptions) -> Vec<FontType> {
270 match &options.types {
271 Some(types) => types.clone(),
272 None => DEFAULT_FONT_TYPES.to_vec(),
273 }
274}
275
276pub(crate) struct ResolvedGenerateWebfontsOptions {
277 pub ascent: Option<f64>,
278 pub center_horizontally: Option<bool>,
279 pub center_vertically: Option<bool>,
280 pub css: bool,
281 pub css_dest: String,
282 pub css_template: Option<String>,
283 pub codepoints: BTreeMap<String, u32>,
284 pub css_fonts_url: Option<String>,
285 pub descent: Option<f64>,
286 pub dest: String,
287 pub files: Vec<String>,
288 pub fixed_width: Option<bool>,
289 pub format_options: Option<FormatOptions>,
290 pub html: bool,
291 pub html_dest: String,
292 pub html_template: Option<String>,
293 pub font_height: Option<f64>,
294 pub font_name: String,
295 pub font_style: Option<String>,
296 pub font_weight: Option<String>,
297 pub ligature: bool,
298 pub normalize: bool,
299 pub order: Vec<FontType>,
300 pub optimize_output: Option<bool>,
301 pub preserve_aspect_ratio: Option<bool>,
302 pub round: Option<f64>,
303 pub start_codepoint: u32,
304 pub template_options: Option<Map<String, Value>>,
305 pub types: Vec<FontType>,
306 pub write_files: bool,
307}
308
309pub(crate) struct LoadedSvgFile {
310 pub contents: String,
311 pub glyph_name: String,
312 pub path: String,
313}
314
315#[derive(Default)]
317pub(crate) struct RenderCache {
318 css_no_urls: Option<String>,
320 css_last_urls: Option<HashMap<FontType, String>>,
322 css_last_result: Option<String>,
323 html_no_urls: Option<String>,
325 html_last_urls: Option<HashMap<FontType, String>>,
327 html_last_result: Option<String>,
328}
329
330pub(crate) struct CachedTemplateData {
331 pub shared: SharedTemplateData,
332 pub css_context: Map<String, Value>,
333 pub css_hbs_context: Mutex<handlebars::Context>,
334 pub html_context: Map<String, Value>,
335 pub html_hbs_context: Mutex<handlebars::Context>,
336 pub html_registry: Option<handlebars::Handlebars<'static>>,
337 pub(crate) render_cache: Mutex<RenderCache>,
338}
339
340#[cfg_attr(feature = "napi", napi)]
344pub struct GenerateWebfontsResult {
345 pub(crate) css_context: Option<Map<String, Value>>,
346 pub(crate) eot_font: Option<Arc<Vec<u8>>>,
347 pub(crate) html_context: Option<Map<String, Value>>,
348 pub(crate) options: ResolvedGenerateWebfontsOptions,
349 pub(crate) source_files: Vec<LoadedSvgFile>,
350 pub(crate) svg_font: Option<Arc<String>>,
351 pub(crate) ttf_font: Option<Arc<Vec<u8>>>,
352 pub(crate) woff2_font: Option<Arc<Vec<u8>>>,
353 pub(crate) woff_font: Option<Arc<Vec<u8>>>,
354 pub(crate) cached: OnceLock<Result<CachedTemplateData, String>>,
355}
356
357impl GenerateWebfontsResult {
359 pub fn eot_bytes(&self) -> Option<&[u8]> {
361 self.eot_font.as_ref().map(|v| v.as_ref().as_slice())
362 }
363
364 pub fn svg_string(&self) -> Option<&str> {
366 self.svg_font.as_ref().map(|v| v.as_ref().as_str())
367 }
368
369 pub fn ttf_bytes(&self) -> Option<&[u8]> {
371 self.ttf_font.as_ref().map(|v| v.as_ref().as_slice())
372 }
373
374 pub fn woff_bytes(&self) -> Option<&[u8]> {
376 self.woff_font.as_ref().map(|v| v.as_ref().as_slice())
377 }
378
379 pub fn woff2_bytes(&self) -> Option<&[u8]> {
381 self.woff2_font.as_ref().map(|v| v.as_ref().as_slice())
382 }
383
384 pub(crate) fn get_cached_io(&self) -> std::io::Result<&CachedTemplateData> {
385 self.cached
386 .get_or_init(|| {
387 let shared = SharedTemplateData::new(&self.options, &self.source_files)
388 .map_err(|e| e.to_string())?;
389 let css_context = match &self.css_context {
390 Some(ctx) => ctx.clone(),
391 None => build_css_context(&self.options, &shared),
392 };
393 let html_context = match &self.html_context {
394 Some(ctx) => ctx.clone(),
395 None => build_html_context(&self.options, &shared, &self.source_files, None)
396 .map_err(|e| e.to_string())?,
397 };
398 let html_registry =
399 build_html_registry(&self.options).map_err(|e| e.to_string())?;
400 let css_hbs_context =
401 handlebars::Context::wraps(&css_context).map_err(|e| e.to_string())?;
402 let html_hbs_context =
403 handlebars::Context::wraps(&html_context).map_err(|e| e.to_string())?;
404 Ok(CachedTemplateData {
405 shared,
406 css_context,
407 css_hbs_context: Mutex::new(css_hbs_context),
408 html_context,
409 html_hbs_context: Mutex::new(html_hbs_context),
410 html_registry,
411 render_cache: Mutex::new(RenderCache::default()),
412 })
413 })
414 .as_ref()
415 .map_err(to_io_err)
416 }
417
418 pub fn generate_css_pure(
422 &self,
423 urls: Option<HashMap<FontType, String>>,
424 ) -> std::io::Result<String> {
425 let cached = self.get_cached_io()?;
426 let mut rc = cached.render_cache.lock().unwrap();
427
428 match &urls {
429 None => {
430 if let Some(result) = &rc.css_no_urls {
431 return Ok(result.clone());
432 }
433 let ctx = cached.css_hbs_context.lock().unwrap();
434 let result =
435 render_css_with_hbs_context(&cached.shared, &ctx, &cached.css_context)?;
436 rc.css_no_urls = Some(result.clone());
437 Ok(result)
438 }
439 Some(urls) => {
440 if !cached.shared.css_template_uses_src {
442 drop(rc);
443 return self.generate_css_pure(None);
444 }
445 if rc.css_last_urls.as_ref() == Some(urls)
446 && let Some(result) = &rc.css_last_result
447 {
448 return Ok(result.clone());
449 }
450 let src = make_src(&self.options, urls);
451 let mut ctx = cached.css_hbs_context.lock().unwrap();
452 let result = render_css_with_src_mutate(
453 &cached.shared,
454 &mut ctx,
455 &cached.css_context,
456 &src,
457 )?;
458 rc.css_last_urls = Some(urls.clone());
459 rc.css_last_result = Some(result.clone());
460 Ok(result)
461 }
462 }
463 }
464
465 pub fn generate_html_pure(
469 &self,
470 urls: Option<HashMap<FontType, String>>,
471 ) -> std::io::Result<String> {
472 let cached = self.get_cached_io()?;
473 let mut rc = cached.render_cache.lock().unwrap();
474
475 match &urls {
476 None => {
477 if let Some(result) = &rc.html_no_urls {
478 return Ok(result.clone());
479 }
480 let ctx = cached.html_hbs_context.lock().unwrap();
481 let result = render_html_with_hbs_context(
482 cached.html_registry.as_ref(),
483 &ctx,
484 &cached.html_context,
485 )?;
486 rc.html_no_urls = Some(result.clone());
487 Ok(result)
488 }
489 Some(urls) => {
490 if !cached.shared.css_template_uses_src {
492 drop(rc);
493 return self.generate_html_pure(None);
494 }
495 if rc.html_last_urls.as_ref() == Some(urls)
496 && let Some(result) = &rc.html_last_result
497 {
498 return Ok(result.clone());
499 }
500 let src = make_src(&self.options, urls);
502 let styles = {
503 let mut css_ctx = cached.css_hbs_context.lock().unwrap();
504 render_css_with_src_mutate(
505 &cached.shared,
506 &mut css_ctx,
507 &cached.css_context,
508 &src,
509 )?
510 };
511 if self.options.html_template.is_none() {
513 let result = render_default_html_with_styles(&cached.html_context, &styles);
514 rc.html_last_urls = Some(urls.clone());
515 rc.html_last_result = Some(result.clone());
516 return Ok(result);
517 }
518 let mut html_ctx = cached.html_hbs_context.lock().unwrap();
520 let registry = cached
521 .html_registry
522 .as_ref()
523 .expect("HTML registry should exist for custom template");
524 let result = crate::util::render_with_field_swap(
525 &mut html_ctx,
526 "styles",
527 serde_json::Value::String(styles),
528 |ctx| {
529 registry
530 .render_with_context("html", ctx)
531 .map_err(crate::util::to_io_err)
532 },
533 )?;
534 rc.html_last_urls = Some(urls.clone());
535 rc.html_last_result = Some(result.clone());
536 Ok(result)
537 }
538 }
539 }
540}
541
542#[cfg(feature = "napi")]
544#[napi]
545impl GenerateWebfontsResult {
546 #[napi(getter)]
548 pub fn eot(&self) -> Option<Uint8Array> {
549 self.eot_font
550 .as_ref()
551 .map(|v| Uint8Array::from(v.as_ref().clone()))
552 }
553
554 #[napi(getter)]
556 pub fn svg(&self) -> Option<String> {
557 self.svg_font.as_ref().map(|v| v.as_ref().clone())
558 }
559
560 #[napi(getter)]
562 pub fn ttf(&self) -> Option<Uint8Array> {
563 self.ttf_font
564 .as_ref()
565 .map(|v| Uint8Array::from(v.as_ref().clone()))
566 }
567
568 #[napi(getter)]
570 pub fn woff2(&self) -> Option<Uint8Array> {
571 self.woff2_font
572 .as_ref()
573 .map(|v| Uint8Array::from(v.as_ref().clone()))
574 }
575
576 #[napi(getter)]
578 pub fn woff(&self) -> Option<Uint8Array> {
579 self.woff_font
580 .as_ref()
581 .map(|v| Uint8Array::from(v.as_ref().clone()))
582 }
583
584 #[napi(ts_args_type = "urls?: Partial<Record<FontType, string>>")]
589 pub fn generate_css(&self, urls: Option<HashMap<String, String>>) -> napi::Result<String> {
590 let urls = urls.map(parse_native_urls).transpose()?;
591 self.generate_css_pure(urls)
592 .map_err(crate::util::to_napi_err)
593 }
594
595 #[napi(ts_args_type = "urls?: Partial<Record<FontType, string>>")]
599 pub fn generate_html(&self, urls: Option<HashMap<String, String>>) -> napi::Result<String> {
600 let urls = urls.map(parse_native_urls).transpose()?;
601 self.generate_html_pure(urls)
602 .map_err(crate::util::to_napi_err)
603 }
604}
605
606#[cfg(feature = "napi")]
607fn parse_native_urls(urls: HashMap<String, String>) -> napi::Result<HashMap<FontType, String>> {
608 urls.into_iter()
609 .filter_map(|(font_type, url)| {
610 let font_type = match font_type.as_str() {
611 "svg" => Some(FontType::Svg),
612 "ttf" => Some(FontType::Ttf),
613 "eot" => Some(FontType::Eot),
614 "woff" => Some(FontType::Woff),
615 "woff2" => Some(FontType::Woff2),
616 _ => None,
617 }?;
618
619 Some(Ok((font_type, url)))
620 })
621 .collect::<napi::Result<HashMap<FontType, String>>>()
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627 use crate::{finalize_generate_webfonts_options, resolve_generate_webfonts_options};
628
629 fn build_result(template: Option<&str>) -> GenerateWebfontsResult {
630 let fixture = crate::test_helpers::webfont_fixture("add.svg");
631
632 let mut css_template = None;
633 let cleanup_dir;
634 if let Some(content) = template {
635 let tmp = std::env::temp_dir().join(format!(
636 "render-cache-test-{}",
637 std::time::SystemTime::now()
638 .duration_since(std::time::UNIX_EPOCH)
639 .unwrap()
640 .as_nanos()
641 ));
642 std::fs::create_dir_all(&tmp).unwrap();
643 let path = tmp.join("template.hbs");
644 std::fs::write(&path, content).unwrap();
645 css_template = Some(path.to_string_lossy().into_owned());
646 cleanup_dir = Some(tmp);
647 } else {
648 cleanup_dir = None;
649 }
650
651 let options = GenerateWebfontsOptions {
652 css: Some(true),
653 css_template,
654 codepoints: Some(HashMap::from([("add".to_owned(), 0xE001u32)])),
655 dest: "artifacts".to_owned(),
656 files: vec![fixture],
657 html: Some(false),
658 font_name: Some("iconfont".to_owned()),
659 ligature: Some(false),
660 order: Some(vec![FontType::Svg]),
661 start_codepoint: Some(0xE001),
662 types: Some(vec![FontType::Svg]),
663 ..Default::default()
664 };
665
666 let mut resolved = resolve_generate_webfonts_options(options).unwrap();
667 let source_files: Vec<LoadedSvgFile> = resolved
668 .files
669 .iter()
670 .map(|path| LoadedSvgFile {
671 contents: std::fs::read_to_string(path).unwrap(),
672 glyph_name: std::path::Path::new(path)
673 .file_stem()
674 .unwrap()
675 .to_str()
676 .unwrap()
677 .to_owned(),
678 path: path.clone(),
679 })
680 .collect();
681 finalize_generate_webfonts_options(&mut resolved, &source_files).unwrap();
682
683 let result = GenerateWebfontsResult {
684 cached: std::sync::OnceLock::new(),
685 css_context: None,
686 eot_font: None,
687 html_context: None,
688 options: resolved,
689 source_files,
690 svg_font: None,
691 ttf_font: None,
692 woff2_font: None,
693 woff_font: None,
694 };
695
696 if let Some(dir) = cleanup_dir {
697 std::mem::forget(dir);
699 }
700
701 result
702 }
703
704 #[test]
705 fn generate_css_returns_cached_result_on_repeated_calls_without_urls() {
706 let result = build_result(None);
707
708 let first = result.generate_css_pure(None).unwrap();
709 let second = result.generate_css_pure(None).unwrap();
710
711 assert_eq!(first, second);
712 assert!(!first.is_empty());
713 }
714
715 #[test]
716 fn generate_css_returns_cached_result_on_repeated_calls_with_same_urls() {
717 let result = build_result(None);
718 let urls = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
719
720 let first = result.generate_css_pure(Some(urls.clone())).unwrap();
721 let second = result.generate_css_pure(Some(urls)).unwrap();
722
723 assert_eq!(first, second);
724 assert!(first.contains("/a.svg"));
725 }
726
727 #[test]
728 fn generate_css_returns_different_result_for_different_urls() {
729 let result = build_result(None);
730 let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
731 let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
732
733 let result_a = result.generate_css_pure(Some(urls_a)).unwrap();
734 let result_b = result.generate_css_pure(Some(urls_b)).unwrap();
735
736 assert_ne!(result_a, result_b);
737 assert!(result_a.contains("/a.svg"));
738 assert!(result_b.contains("/b.svg"));
739 }
740
741 #[test]
742 fn generate_css_cache_updates_when_urls_change() {
743 let result = build_result(None);
744 let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
745 let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
746
747 let first_a = result.generate_css_pure(Some(urls_a.clone())).unwrap();
748 let first_b = result.generate_css_pure(Some(urls_b)).unwrap();
749 let second_a = result.generate_css_pure(Some(urls_a)).unwrap();
750
751 assert_eq!(
752 first_a, second_a,
753 "returning to original urls should produce same result"
754 );
755 assert_ne!(first_a, first_b);
756 }
757
758 #[test]
759 fn generate_css_cache_works_with_custom_template() {
760 let result = build_result(Some("@font-face { src: {{{src}}}; }"));
761 let urls = HashMap::from([(FontType::Svg, "/cached.svg".to_owned())]);
762
763 let first = result.generate_css_pure(Some(urls.clone())).unwrap();
764 let second = result.generate_css_pure(Some(urls)).unwrap();
765
766 assert_eq!(first, second);
767 assert!(first.contains("/cached.svg"));
768 }
769
770 #[test]
771 fn generate_css_no_urls_and_with_urls_are_independent_caches() {
772 let result = build_result(None);
773 let urls = HashMap::from([(FontType::Svg, "/custom.svg".to_owned())]);
774
775 let no_urls = result.generate_css_pure(None).unwrap();
776 let with_urls = result.generate_css_pure(Some(urls)).unwrap();
777 let no_urls_again = result.generate_css_pure(None).unwrap();
778
779 assert_eq!(
780 no_urls, no_urls_again,
781 "no-urls cache should survive a with-urls call"
782 );
783 assert_ne!(no_urls, with_urls);
784 }
785
786 #[test]
787 fn generate_css_with_urls_returns_no_urls_result_when_template_does_not_use_src() {
788 let result = build_result(Some(".icon { font-family: {{fontName}}; }"));
789 let urls = HashMap::from([(FontType::Svg, "/should-not-appear.svg".to_owned())]);
790
791 let no_urls = result.generate_css_pure(None).unwrap();
792 let with_urls = result.generate_css_pure(Some(urls)).unwrap();
793
794 assert_eq!(
795 no_urls, with_urls,
796 "template without {{src}} should ignore urls"
797 );
798 assert!(!with_urls.contains("/should-not-appear.svg"));
799 assert!(
800 with_urls.contains("iconfont"),
801 "should still render the template"
802 );
803 }
804
805 #[test]
806 fn generate_html_with_urls_returns_no_urls_result_when_css_template_does_not_use_src() {
807 let result = build_result(Some(".icon { font-family: {{fontName}}; }"));
808 let urls = HashMap::from([(FontType::Svg, "/should-not-appear.svg".to_owned())]);
809
810 let no_urls = result.generate_html_pure(None).unwrap();
811 let with_urls = result.generate_html_pure(Some(urls)).unwrap();
812
813 assert_eq!(
814 no_urls, with_urls,
815 "CSS template without {{src}} means HTML is also unaffected by urls"
816 );
817 }
818
819 #[test]
820 fn generate_css_without_urls_produces_valid_css_using_css_fonts_url() {
821 let result = build_result(None);
822
823 let css = result.generate_css_pure(None).unwrap();
824
825 assert!(
826 css.contains("@font-face"),
827 "should contain @font-face declaration"
828 );
829 assert!(css.contains("font-family:"), "should contain font-family");
830 assert!(
831 css.contains("iconfont.svg?"),
832 "should use font name in URL with hash"
833 );
834 assert!(
835 css.contains("format(\"svg\")"),
836 "should contain format declaration"
837 );
838 assert!(
839 css.contains("content:"),
840 "should contain codepoint content rules"
841 );
842 }
843
844 #[test]
845 fn generate_css_with_urls_replaces_default_urls_in_src() {
846 let result = build_result(None);
847 let urls = HashMap::from([(FontType::Svg, "/cdn/icons.svg".to_owned())]);
848
849 let css = result.generate_css_pure(Some(urls)).unwrap();
850
851 assert!(
852 css.contains("/cdn/icons.svg"),
853 "custom URL should appear in output"
854 );
855 assert!(
856 !css.contains("iconfont.svg?"),
857 "default hash-based URL should not appear"
858 );
859 assert!(
860 css.contains("format(\"svg\")"),
861 "format should still be present"
862 );
863 }
864
865 #[test]
866 fn generate_html_without_urls_produces_valid_html() {
867 let result = build_result(None);
868
869 let html = result.generate_html_pure(None).unwrap();
870
871 assert!(
872 html.contains("<!DOCTYPE html>"),
873 "should be a full HTML document"
874 );
875 assert!(html.contains("iconfont"), "should contain font name");
876 assert!(html.contains("icon-add"), "should contain icon class name");
877 }
878
879 #[test]
880 fn generate_html_with_urls_embeds_css_using_custom_urls() {
881 let result = build_result(None);
882 let urls = HashMap::from([(FontType::Svg, "/cdn/icons.svg".to_owned())]);
883
884 let html = result.generate_html_pure(Some(urls)).unwrap();
885
886 assert!(
887 html.contains("/cdn/icons.svg"),
888 "custom URL should appear in embedded CSS"
889 );
890 assert!(
891 html.contains("icon-add"),
892 "should still contain icon class name"
893 );
894 }
895
896 #[test]
897 fn generate_html_cache_returns_same_result_for_same_urls() {
898 let result = build_result(None);
899 let urls = HashMap::from([(FontType::Svg, "/cached.svg".to_owned())]);
900
901 let first = result.generate_html_pure(Some(urls.clone())).unwrap();
902 let second = result.generate_html_pure(Some(urls)).unwrap();
903
904 assert_eq!(first, second);
905 assert!(first.contains("/cached.svg"));
906 }
907
908 #[test]
909 fn generate_html_cache_returns_different_result_for_different_urls() {
910 let result = build_result(None);
911 let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
912 let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
913
914 let result_a = result.generate_html_pure(Some(urls_a)).unwrap();
915 let result_b = result.generate_html_pure(Some(urls_b)).unwrap();
916
917 assert_ne!(result_a, result_b);
918 assert!(result_a.contains("/a.svg"));
919 assert!(result_b.contains("/b.svg"));
920 }
921
922 fn build_multi_type_result() -> GenerateWebfontsResult {
924 let fixture = crate::test_helpers::webfont_fixture("add.svg");
925 let options = GenerateWebfontsOptions {
926 css: Some(true),
927 codepoints: Some(HashMap::from([("add".to_owned(), 0xE001u32)])),
928 dest: "artifacts".to_owned(),
929 files: vec![fixture],
930 html: Some(true),
931 font_name: Some("iconfont".to_owned()),
932 ligature: Some(false),
933 order: Some(vec![FontType::Woff2, FontType::Svg]),
934 start_codepoint: Some(0xE001),
935 types: Some(vec![FontType::Svg, FontType::Woff2]),
936 ..Default::default()
937 };
938
939 let mut resolved = resolve_generate_webfonts_options(options).unwrap();
940 let source_files: Vec<LoadedSvgFile> = resolved
941 .files
942 .iter()
943 .map(|path| LoadedSvgFile {
944 contents: std::fs::read_to_string(path).unwrap(),
945 glyph_name: std::path::Path::new(path)
946 .file_stem()
947 .unwrap()
948 .to_str()
949 .unwrap()
950 .to_owned(),
951 path: path.clone(),
952 })
953 .collect();
954 finalize_generate_webfonts_options(&mut resolved, &source_files).unwrap();
955
956 GenerateWebfontsResult {
957 cached: std::sync::OnceLock::new(),
958 css_context: None,
959 eot_font: None,
960 html_context: None,
961 options: resolved,
962 source_files,
963 svg_font: None,
964 ttf_font: None,
965 woff2_font: None,
966 woff_font: None,
967 }
968 }
969
970 #[test]
971 fn generate_css_partial_urls_uses_empty_string_for_missing_types() {
972 let result = build_multi_type_result();
973 let urls = HashMap::from([(FontType::Woff2, "/cdn/font.woff2".to_owned())]);
975
976 let css = result.generate_css_pure(Some(urls)).unwrap();
977
978 assert!(
979 css.contains("/cdn/font.woff2"),
980 "overridden URL should appear"
981 );
982 assert!(
983 !css.contains("iconfont.svg?"),
984 "non-overridden type should not have default hash-based URL"
985 );
986 assert!(
987 css.contains("url(\"#iconfont\")"),
988 "non-overridden SVG type should produce empty base URL (upstream compat)"
989 );
990 }
991
992 #[test]
993 fn generate_html_partial_urls_uses_empty_string_for_missing_types() {
994 let result = build_multi_type_result();
995 let urls = HashMap::from([(FontType::Woff2, "/cdn/font.woff2".to_owned())]);
996
997 let html = result.generate_html_pure(Some(urls)).unwrap();
998
999 assert!(
1000 html.contains("/cdn/font.woff2"),
1001 "overridden URL should appear in HTML"
1002 );
1003 assert!(
1004 !html.contains("iconfont.svg?"),
1005 "non-overridden type should not have default hash-based URL in HTML"
1006 );
1007 }
1008}