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
20pub enum GlyphChange {
23 Added { name: Option<String> },
25 Changed { name: Option<String> },
27 Removed,
29}
30
31#[cfg_attr(feature = "napi", napi(object))]
35pub struct GlyphChangeEntry {
36 pub path: String,
38 #[cfg_attr(feature = "napi", napi(ts_type = "'added' | 'changed' | 'removed'"))]
40 pub change_type: String,
41 pub name: Option<String>,
44}
45
46#[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,
56 Ttf,
58 Eot,
60 Woff,
62 Woff2,
65}
66
67impl FontType {
68 #[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 #[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#[cfg_attr(feature = "napi", napi(object))]
96#[derive(Clone, Default)]
97pub struct SvgFormatOptions {
98 pub center_vertically: Option<bool>,
102 pub font_id: Option<String>,
105 pub metadata: Option<String>,
107 pub optimize_output: Option<bool>,
111 pub preserve_aspect_ratio: Option<bool>,
115}
116
117#[cfg_attr(feature = "napi", napi(object))]
120#[derive(Clone)]
121pub struct TtfFormatOptions {
122 pub copyright: Option<String>,
124 pub description: Option<String>,
126 pub ts: Option<i64>,
130 pub url: Option<String>,
132 pub version: Option<String>,
134}
135
136#[cfg_attr(feature = "napi", napi(object))]
138#[derive(Clone)]
139pub struct WoffFormatOptions {
140 pub metadata: Option<String>,
142}
143
144#[cfg_attr(feature = "napi", napi(object))]
146#[derive(Clone)]
147pub struct Woff2FormatOptions {
148 pub compression_quality: Option<u8>,
155}
156
157#[cfg_attr(feature = "napi", napi(object))]
160#[derive(Clone, Default)]
161pub struct FormatOptions {
162 pub svg: Option<SvgFormatOptions>,
164 pub ttf: Option<TtfFormatOptions>,
166 pub woff: Option<WoffFormatOptions>,
168 pub woff2: Option<Woff2FormatOptions>,
170}
171
172#[cfg_attr(feature = "napi", napi(object))]
176#[derive(Clone)]
177pub struct CssContext {
178 pub font_name: String,
180 pub src: String,
184 pub codepoints: HashMap<String, String>,
188}
189
190#[cfg_attr(feature = "napi", napi(object))]
194#[derive(Clone)]
195pub struct HtmlContext {
196 pub font_name: String,
198 pub names: Vec<String>,
201 pub styles: String,
205 pub codepoints: HashMap<String, u32>,
209}
210
211#[cfg_attr(feature = "napi", napi(object))]
215#[derive(Clone, Default)]
216pub struct GenerateWebfontsOptions {
217 pub ascent: Option<f64>,
220 pub center_horizontally: Option<bool>,
223 pub center_vertically: Option<bool>,
227 pub css: Option<bool>,
229 pub css_dest: Option<String>,
232 pub css_template: Option<String>,
235 pub codepoints: Option<HashMap<String, u32>>,
238 pub css_fonts_url: Option<String>,
241 pub descent: Option<f64>,
244 pub dest: String,
246 pub files: Vec<String>,
248 pub fixed_width: Option<bool>,
250 pub format_options: Option<FormatOptions>,
252 pub html: Option<bool>,
254 pub html_dest: Option<String>,
257 pub html_template: Option<String>,
259 pub incremental: Option<bool>,
263 pub font_height: Option<f64>,
266 pub font_name: Option<String>,
269 pub font_style: Option<String>,
271 pub font_weight: Option<String>,
273 pub ligature: Option<bool>,
276 pub normalize: Option<bool>,
278 pub order: Option<Vec<FontType>>,
283 pub optimize_output: Option<bool>,
287 pub preserve_aspect_ratio: Option<bool>,
290 pub round: Option<f64>,
292 pub start_codepoint: Option<u32>,
294 pub template_options: Option<Map<String, Value>>,
298 pub types: Option<Vec<FontType>>,
300 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 pub codepoints: BTreeMap<String, u32>,
334 pub explicit_codepoints: BTreeMap<String, u32>,
337 pub css_fonts_url: Option<String>,
338 pub descent: Option<f64>,
339 pub dest: String,
340 pub files: Vec<String>,
341 pub fixed_width: Option<bool>,
342 pub format_options: Option<FormatOptions>,
343 pub html: bool,
344 pub incremental: bool,
345 pub html_dest: String,
346 pub html_template: Option<String>,
347 pub font_height: Option<f64>,
348 pub font_name: String,
349 pub font_style: Option<String>,
350 pub font_weight: Option<String>,
351 pub ligature: bool,
352 pub normalize: bool,
353 pub order: Vec<FontType>,
354 pub optimize_output: Option<bool>,
355 pub preserve_aspect_ratio: Option<bool>,
356 pub round: Option<f64>,
357 pub start_codepoint: u32,
358 pub template_options: Option<Map<String, Value>>,
359 pub types: Vec<FontType>,
360 pub write_files: bool,
361}
362
363#[derive(Clone)]
364pub(crate) struct LoadedSvgFile {
365 pub contents: String,
366 pub glyph_name: String,
367 pub path: String,
368}
369
370#[derive(Clone, Default)]
374pub(crate) struct RenderCache {
375 css_no_urls: Option<String>,
377 css_last_urls: Option<HashMap<FontType, String>>,
379 css_last_result: Option<String>,
380 html_no_urls: Option<String>,
382 html_last_urls: Option<HashMap<FontType, String>>,
384 html_last_result: Option<String>,
385}
386
387pub(crate) struct CachedTemplateData {
388 pub shared: SharedTemplateData,
389 pub css_context: Map<String, Value>,
390 pub css_hbs_context: Mutex<handlebars::Context>,
391 pub html_context: Map<String, Value>,
392 pub html_hbs_context: Mutex<handlebars::Context>,
393 pub html_template_dependencies: TemplateDependencies,
394 pub html_registry: Option<handlebars::Handlebars<'static>>,
395 pub(crate) render_cache: Mutex<RenderCache>,
396}
397
398#[derive(Default)]
402pub(crate) struct FontOutputs {
403 pub(crate) svg_font: Option<Arc<String>>,
404 pub(crate) ttf_font: Option<Arc<Vec<u8>>>,
405 pub(crate) woff_font: Option<Arc<Vec<u8>>>,
406 pub(crate) woff2_font: Option<Arc<Vec<u8>>>,
407 pub(crate) eot_font: Option<Arc<Vec<u8>>>,
408}
409
410#[cfg_attr(feature = "napi", napi)]
414pub struct GenerateWebfontsResult {
415 pub(crate) cached: OnceLock<Result<CachedTemplateData, String>>,
416 pub(crate) carried_render: Option<RenderCache>,
420 pub(crate) css_context: Option<Map<String, Value>>,
421 pub(crate) fonts: FontOutputs,
422 pub(crate) glyph_cache: Option<GlyphCache>,
424 pub(crate) html_context: Option<Map<String, Value>>,
425 pub(crate) options: ResolvedGenerateWebfontsOptions,
426 pub(crate) source_files: Vec<LoadedSvgFile>,
427 pub(crate) ttf_cache: Option<TtfGlyphCache>,
429 pub(crate) written_outputs: HashMap<String, [u8; 16]>,
434}
435
436impl GenerateWebfontsResult {
438 #[cfg(test)]
439 pub(crate) fn has_carried_css_no_urls_for_test(&self) -> bool {
440 self.carried_render
441 .as_ref()
442 .is_some_and(|cache| cache.css_no_urls.is_some())
443 }
444
445 #[cfg(test)]
446 pub(crate) fn has_carried_html_no_urls_for_test(&self) -> bool {
447 self.carried_render
448 .as_ref()
449 .is_some_and(|cache| cache.html_no_urls.is_some())
450 }
451
452 pub fn eot_bytes(&self) -> Option<&[u8]> {
454 self.fonts.eot_font.as_ref().map(|v| v.as_ref().as_slice())
455 }
456
457 pub fn svg_string(&self) -> Option<&str> {
459 self.fonts.svg_font.as_ref().map(|v| v.as_ref().as_str())
460 }
461
462 pub fn ttf_bytes(&self) -> Option<&[u8]> {
464 self.fonts.ttf_font.as_ref().map(|v| v.as_ref().as_slice())
465 }
466
467 pub fn woff_bytes(&self) -> Option<&[u8]> {
469 self.fonts.woff_font.as_ref().map(|v| v.as_ref().as_slice())
470 }
471
472 pub fn woff2_bytes(&self) -> Option<&[u8]> {
474 self.fonts
475 .woff2_font
476 .as_ref()
477 .map(|v| v.as_ref().as_slice())
478 }
479
480 pub(crate) fn get_cached_io(&self) -> std::io::Result<&CachedTemplateData> {
481 self.cached
482 .get_or_init(|| {
483 let shared = SharedTemplateData::new(&self.options, &self.source_files)
484 .map_err(|e| e.to_string())?;
485 let css_context = match &self.css_context {
486 Some(ctx) => ctx.clone(),
487 None => build_css_context(&self.options, &shared),
488 };
489 let html_context = match &self.html_context {
490 Some(ctx) => ctx.clone(),
491 None => build_html_context(&self.options, &shared, &self.source_files, None)
492 .map_err(|e| e.to_string())?,
493 };
494 let (html_registry, html_template_dependencies) =
495 build_html_registry_and_dependencies(&self.options)
496 .map_err(|e| e.to_string())?;
497 let css_hbs_context =
498 handlebars::Context::wraps(&css_context).map_err(|e| e.to_string())?;
499 let html_hbs_context =
500 handlebars::Context::wraps(&html_context).map_err(|e| e.to_string())?;
501 Ok(CachedTemplateData {
502 shared,
503 css_context,
504 css_hbs_context: Mutex::new(css_hbs_context),
505 html_context,
506 html_hbs_context: Mutex::new(html_hbs_context),
507 html_template_dependencies,
508 html_registry,
509 render_cache: Mutex::new(self.carried_render.clone().unwrap_or_default()),
512 })
513 })
514 .as_ref()
515 .map_err(to_io_err)
516 }
517
518 pub(crate) fn reset_render_cache(&mut self, names_unchanged: bool, codepoints_unchanged: bool) {
522 let carried = self
523 .cached
524 .get()
525 .and_then(|cached| cached.as_ref().ok())
526 .map(|cached| {
527 let css_deps = cached.shared.css_template_dependencies;
528 let css_no_urls_unchanged =
529 css_deps.can_reuse_css_no_urls(names_unchanged, codepoints_unchanged);
530 let css_with_urls_unchanged =
531 css_deps.can_reuse_css_with_urls(names_unchanged, codepoints_unchanged);
532 let html_no_urls_unchanged = cached.html_template_dependencies.can_reuse_html(
533 names_unchanged,
534 codepoints_unchanged,
535 css_no_urls_unchanged,
536 );
537 let html_with_urls_unchanged = cached.html_template_dependencies.can_reuse_html(
538 names_unchanged,
539 codepoints_unchanged,
540 css_with_urls_unchanged,
541 );
542
543 let rc = cached.render_cache.lock().unwrap();
544 RenderCache {
545 css_no_urls: css_no_urls_unchanged
546 .then(|| rc.css_no_urls.clone())
547 .flatten(),
548 html_no_urls: html_no_urls_unchanged
549 .then(|| rc.html_no_urls.clone())
550 .flatten(),
551 css_last_urls: css_with_urls_unchanged
552 .then(|| rc.css_last_urls.clone())
553 .flatten(),
554 css_last_result: css_with_urls_unchanged
555 .then(|| rc.css_last_result.clone())
556 .flatten(),
557 html_last_urls: html_with_urls_unchanged
558 .then(|| rc.html_last_urls.clone())
559 .flatten(),
560 html_last_result: html_with_urls_unchanged
561 .then(|| rc.html_last_result.clone())
562 .flatten(),
563 }
564 });
565 self.cached = OnceLock::new();
566 self.css_context = None;
567 self.html_context = None;
568 self.carried_render = carried;
569 }
570
571 pub fn generate_css_pure(
575 &self,
576 urls: Option<HashMap<FontType, String>>,
577 ) -> std::io::Result<String> {
578 let cached = self.get_cached_io()?;
579 let mut rc = cached.render_cache.lock().unwrap();
580
581 match &urls {
582 None => {
583 if let Some(result) = &rc.css_no_urls {
584 return Ok(result.clone());
585 }
586 let ctx = cached.css_hbs_context.lock().unwrap();
587 let result =
588 render_css_with_hbs_context(&cached.shared, &ctx, &cached.css_context)?;
589 rc.css_no_urls = Some(result.clone());
590 Ok(result)
591 }
592 Some(urls) => {
593 if !cached.shared.css_template_uses_src {
595 drop(rc);
596 return self.generate_css_pure(None);
597 }
598 if rc.css_last_urls.as_ref() == Some(urls)
599 && let Some(result) = &rc.css_last_result
600 {
601 return Ok(result.clone());
602 }
603 let src = make_src(&self.options, urls);
604 let mut ctx = cached.css_hbs_context.lock().unwrap();
605 let result = render_css_with_src_mutate(
606 &cached.shared,
607 &mut ctx,
608 &cached.css_context,
609 &src,
610 )?;
611 rc.css_last_urls = Some(urls.clone());
612 rc.css_last_result = Some(result.clone());
613 Ok(result)
614 }
615 }
616 }
617
618 pub fn generate_html_pure(
622 &self,
623 urls: Option<HashMap<FontType, String>>,
624 ) -> std::io::Result<String> {
625 let cached = self.get_cached_io()?;
626 let mut rc = cached.render_cache.lock().unwrap();
627
628 match &urls {
629 None => {
630 if let Some(result) = &rc.html_no_urls {
631 return Ok(result.clone());
632 }
633 let ctx = cached.html_hbs_context.lock().unwrap();
634 let result = render_html_with_hbs_context(
635 cached.html_registry.as_ref(),
636 &ctx,
637 &cached.html_context,
638 )?;
639 rc.html_no_urls = Some(result.clone());
640 Ok(result)
641 }
642 Some(urls) => {
643 if !cached.shared.css_template_uses_src {
645 drop(rc);
646 return self.generate_html_pure(None);
647 }
648 if rc.html_last_urls.as_ref() == Some(urls)
649 && let Some(result) = &rc.html_last_result
650 {
651 return Ok(result.clone());
652 }
653 let src = make_src(&self.options, urls);
655 let styles = {
656 let mut css_ctx = cached.css_hbs_context.lock().unwrap();
657 render_css_with_src_mutate(
658 &cached.shared,
659 &mut css_ctx,
660 &cached.css_context,
661 &src,
662 )?
663 };
664 if self.options.html_template.is_none() {
666 let result = render_default_html_with_styles(&cached.html_context, &styles);
667 rc.html_last_urls = Some(urls.clone());
668 rc.html_last_result = Some(result.clone());
669 return Ok(result);
670 }
671 let mut html_ctx = cached.html_hbs_context.lock().unwrap();
673 let registry = cached
674 .html_registry
675 .as_ref()
676 .expect("HTML registry should exist for custom template");
677 let result = crate::util::render_with_field_swap(
678 &mut html_ctx,
679 "styles",
680 serde_json::Value::String(styles),
681 |ctx| {
682 registry
683 .render_with_context("html", ctx)
684 .map_err(crate::util::to_io_err)
685 },
686 )?;
687 rc.html_last_urls = Some(urls.clone());
688 rc.html_last_result = Some(result.clone());
689 Ok(result)
690 }
691 }
692 }
693}
694
695#[cfg(feature = "napi")]
697#[napi]
698impl GenerateWebfontsResult {
699 #[napi(getter)]
701 pub fn eot(&self) -> Option<Uint8Array> {
702 self.fonts
703 .eot_font
704 .as_ref()
705 .map(|v| Uint8Array::from(v.as_ref().clone()))
706 }
707
708 #[napi(getter)]
710 pub fn svg(&self) -> Option<String> {
711 self.fonts.svg_font.as_ref().map(|v| v.as_ref().clone())
712 }
713
714 #[napi(getter)]
716 pub fn ttf(&self) -> Option<Uint8Array> {
717 self.fonts
718 .ttf_font
719 .as_ref()
720 .map(|v| Uint8Array::from(v.as_ref().clone()))
721 }
722
723 #[napi(getter)]
725 pub fn woff2(&self) -> Option<Uint8Array> {
726 self.fonts
727 .woff2_font
728 .as_ref()
729 .map(|v| Uint8Array::from(v.as_ref().clone()))
730 }
731
732 #[napi(getter)]
734 pub fn woff(&self) -> Option<Uint8Array> {
735 self.fonts
736 .woff_font
737 .as_ref()
738 .map(|v| Uint8Array::from(v.as_ref().clone()))
739 }
740
741 #[napi(ts_args_type = "urls?: Partial<Record<FontType, string>>")]
746 pub fn generate_css(&self, urls: Option<HashMap<String, String>>) -> napi::Result<String> {
747 let urls = urls.map(parse_native_urls).transpose()?;
748 self.generate_css_pure(urls)
749 .map_err(crate::util::to_napi_err)
750 }
751
752 #[napi(ts_args_type = "urls?: Partial<Record<FontType, string>>")]
756 pub fn generate_html(&self, urls: Option<HashMap<String, String>>) -> napi::Result<String> {
757 let urls = urls.map(parse_native_urls).transpose()?;
758 self.generate_html_pure(urls)
759 .map_err(crate::util::to_napi_err)
760 }
761
762 #[napi(js_name = "regenerate")]
774 pub fn regenerate_from_js(
775 &mut self,
776 files: Vec<String>,
777 changes: Option<Vec<GlyphChangeEntry>>,
778 ) -> napi::Result<()> {
779 let Some(changes) = changes else {
780 return self
781 .regenerate_all(&files)
782 .map_err(crate::util::to_napi_err);
783 };
784 let changes = changes
785 .into_iter()
786 .map(|entry| {
787 let change = match entry.change_type.as_str() {
788 "added" => GlyphChange::Added { name: entry.name },
789 "changed" => GlyphChange::Changed { name: entry.name },
790 "removed" => GlyphChange::Removed,
791 other => {
792 return Err(napi::Error::from_reason(format!(
793 "Unknown changeType '{other}'; expected 'added', 'changed', or 'removed'."
794 )));
795 }
796 };
797 Ok((entry.path, change))
798 })
799 .collect::<napi::Result<Vec<_>>>()?;
800 self.regenerate(&files, &changes)
801 .map_err(crate::util::to_napi_err)
802 }
803}
804
805#[cfg(feature = "napi")]
806fn parse_native_urls(urls: HashMap<String, String>) -> napi::Result<HashMap<FontType, String>> {
807 urls.into_iter()
808 .filter_map(|(font_type, url)| {
809 let font_type = match font_type.as_str() {
810 "svg" => Some(FontType::Svg),
811 "ttf" => Some(FontType::Ttf),
812 "eot" => Some(FontType::Eot),
813 "woff" => Some(FontType::Woff),
814 "woff2" => Some(FontType::Woff2),
815 _ => None,
816 }?;
817
818 Some(Ok((font_type, url)))
819 })
820 .collect::<napi::Result<HashMap<FontType, String>>>()
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826 use crate::{finalize_generate_webfonts_options, resolve_generate_webfonts_options};
827
828 fn build_result(template: Option<&str>) -> GenerateWebfontsResult {
829 let fixture = crate::test_helpers::webfont_fixture("add.svg");
830
831 let mut css_template = None;
832 let cleanup_dir;
833 if let Some(content) = template {
834 let tmp = std::env::temp_dir().join(format!(
835 "render-cache-test-{}",
836 std::time::SystemTime::now()
837 .duration_since(std::time::UNIX_EPOCH)
838 .unwrap()
839 .as_nanos()
840 ));
841 std::fs::create_dir_all(&tmp).unwrap();
842 let path = tmp.join("template.hbs");
843 std::fs::write(&path, content).unwrap();
844 css_template = Some(path.to_string_lossy().into_owned());
845 cleanup_dir = Some(tmp);
846 } else {
847 cleanup_dir = None;
848 }
849
850 let options = GenerateWebfontsOptions {
851 css: Some(true),
852 css_template,
853 codepoints: Some(HashMap::from([("add".to_owned(), 0xE001u32)])),
854 dest: "artifacts".to_owned(),
855 files: vec![fixture],
856 html: Some(false),
857 font_name: Some("iconfont".to_owned()),
858 ligature: Some(false),
859 order: Some(vec![FontType::Svg]),
860 start_codepoint: Some(0xE001),
861 types: Some(vec![FontType::Svg]),
862 ..Default::default()
863 };
864
865 let mut resolved = resolve_generate_webfonts_options(options).unwrap();
866 let source_files: Vec<LoadedSvgFile> = resolved
867 .files
868 .iter()
869 .map(|path| LoadedSvgFile {
870 contents: std::fs::read_to_string(path).unwrap(),
871 glyph_name: std::path::Path::new(path)
872 .file_stem()
873 .unwrap()
874 .to_str()
875 .unwrap()
876 .to_owned(),
877 path: path.clone(),
878 })
879 .collect();
880 finalize_generate_webfonts_options(&mut resolved, &source_files).unwrap();
881
882 let result = GenerateWebfontsResult {
883 cached: std::sync::OnceLock::new(),
884 carried_render: None,
885 css_context: None,
886 fonts: FontOutputs::default(),
887 glyph_cache: None,
888 html_context: None,
889 options: resolved,
890 source_files,
891 ttf_cache: None,
892 written_outputs: Default::default(),
893 };
894
895 if let Some(dir) = cleanup_dir {
896 std::mem::forget(dir);
898 }
899
900 result
901 }
902
903 #[test]
904 fn generate_css_returns_cached_result_on_repeated_calls_without_urls() {
905 let result = build_result(None);
906
907 let first = result.generate_css_pure(None).unwrap();
908 let second = result.generate_css_pure(None).unwrap();
909
910 assert_eq!(first, second);
911 assert!(!first.is_empty());
912 }
913
914 #[test]
915 fn generate_css_returns_cached_result_on_repeated_calls_with_same_urls() {
916 let result = build_result(None);
917 let urls = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
918
919 let first = result.generate_css_pure(Some(urls.clone())).unwrap();
920 let second = result.generate_css_pure(Some(urls)).unwrap();
921
922 assert_eq!(first, second);
923 assert!(first.contains("/a.svg"));
924 }
925
926 #[test]
927 fn generate_css_returns_different_result_for_different_urls() {
928 let result = build_result(None);
929 let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
930 let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
931
932 let result_a = result.generate_css_pure(Some(urls_a)).unwrap();
933 let result_b = result.generate_css_pure(Some(urls_b)).unwrap();
934
935 assert_ne!(result_a, result_b);
936 assert!(result_a.contains("/a.svg"));
937 assert!(result_b.contains("/b.svg"));
938 }
939
940 #[test]
941 fn generate_css_cache_updates_when_urls_change() {
942 let result = build_result(None);
943 let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
944 let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
945
946 let first_a = result.generate_css_pure(Some(urls_a.clone())).unwrap();
947 let first_b = result.generate_css_pure(Some(urls_b)).unwrap();
948 let second_a = result.generate_css_pure(Some(urls_a)).unwrap();
949
950 assert_eq!(
951 first_a, second_a,
952 "returning to original urls should produce same result"
953 );
954 assert_ne!(first_a, first_b);
955 }
956
957 #[test]
958 fn generate_css_cache_works_with_custom_template() {
959 let result = build_result(Some("@font-face { src: {{{src}}}; }"));
960 let urls = HashMap::from([(FontType::Svg, "/cached.svg".to_owned())]);
961
962 let first = result.generate_css_pure(Some(urls.clone())).unwrap();
963 let second = result.generate_css_pure(Some(urls)).unwrap();
964
965 assert_eq!(first, second);
966 assert!(first.contains("/cached.svg"));
967 }
968
969 #[test]
970 fn generate_css_no_urls_and_with_urls_are_independent_caches() {
971 let result = build_result(None);
972 let urls = HashMap::from([(FontType::Svg, "/custom.svg".to_owned())]);
973
974 let no_urls = result.generate_css_pure(None).unwrap();
975 let with_urls = result.generate_css_pure(Some(urls)).unwrap();
976 let no_urls_again = result.generate_css_pure(None).unwrap();
977
978 assert_eq!(
979 no_urls, no_urls_again,
980 "no-urls cache should survive a with-urls call"
981 );
982 assert_ne!(no_urls, with_urls);
983 }
984
985 #[test]
986 fn generate_css_with_urls_returns_no_urls_result_when_template_does_not_use_src() {
987 let result = build_result(Some(".icon { font-family: {{fontName}}; }"));
988 let urls = HashMap::from([(FontType::Svg, "/should-not-appear.svg".to_owned())]);
989
990 let no_urls = result.generate_css_pure(None).unwrap();
991 let with_urls = result.generate_css_pure(Some(urls)).unwrap();
992
993 assert_eq!(
994 no_urls, with_urls,
995 "template without {{src}} should ignore urls"
996 );
997 assert!(!with_urls.contains("/should-not-appear.svg"));
998 assert!(
999 with_urls.contains("iconfont"),
1000 "should still render the template"
1001 );
1002 }
1003
1004 #[test]
1005 fn generate_html_with_urls_returns_no_urls_result_when_css_template_does_not_use_src() {
1006 let result = build_result(Some(".icon { font-family: {{fontName}}; }"));
1007 let urls = HashMap::from([(FontType::Svg, "/should-not-appear.svg".to_owned())]);
1008
1009 let no_urls = result.generate_html_pure(None).unwrap();
1010 let with_urls = result.generate_html_pure(Some(urls)).unwrap();
1011
1012 assert_eq!(
1013 no_urls, with_urls,
1014 "CSS template without {{src}} means HTML is also unaffected by urls"
1015 );
1016 }
1017
1018 #[test]
1019 fn generate_css_without_urls_produces_valid_css_using_css_fonts_url() {
1020 let result = build_result(None);
1021
1022 let css = result.generate_css_pure(None).unwrap();
1023
1024 assert!(
1025 css.contains("@font-face"),
1026 "should contain @font-face declaration"
1027 );
1028 assert!(css.contains("font-family:"), "should contain font-family");
1029 assert!(
1030 css.contains("iconfont.svg?"),
1031 "should use font name in URL with hash"
1032 );
1033 assert!(
1034 css.contains("format(\"svg\")"),
1035 "should contain format declaration"
1036 );
1037 assert!(
1038 css.contains("content:"),
1039 "should contain codepoint content rules"
1040 );
1041 }
1042
1043 #[test]
1044 fn generate_css_with_urls_replaces_default_urls_in_src() {
1045 let result = build_result(None);
1046 let urls = HashMap::from([(FontType::Svg, "/cdn/icons.svg".to_owned())]);
1047
1048 let css = result.generate_css_pure(Some(urls)).unwrap();
1049
1050 assert!(
1051 css.contains("/cdn/icons.svg"),
1052 "custom URL should appear in output"
1053 );
1054 assert!(
1055 !css.contains("iconfont.svg?"),
1056 "default hash-based URL should not appear"
1057 );
1058 assert!(
1059 css.contains("format(\"svg\")"),
1060 "format should still be present"
1061 );
1062 }
1063
1064 #[test]
1065 fn generate_html_without_urls_produces_valid_html() {
1066 let result = build_result(None);
1067
1068 let html = result.generate_html_pure(None).unwrap();
1069
1070 assert!(
1071 html.contains("<!DOCTYPE html>"),
1072 "should be a full HTML document"
1073 );
1074 assert!(html.contains("iconfont"), "should contain font name");
1075 assert!(html.contains("icon-add"), "should contain icon class name");
1076 }
1077
1078 #[test]
1079 fn generate_html_with_urls_embeds_css_using_custom_urls() {
1080 let result = build_result(None);
1081 let urls = HashMap::from([(FontType::Svg, "/cdn/icons.svg".to_owned())]);
1082
1083 let html = result.generate_html_pure(Some(urls)).unwrap();
1084
1085 assert!(
1086 html.contains("/cdn/icons.svg"),
1087 "custom URL should appear in embedded CSS"
1088 );
1089 assert!(
1090 html.contains("icon-add"),
1091 "should still contain icon class name"
1092 );
1093 }
1094
1095 #[test]
1096 fn generate_html_cache_returns_same_result_for_same_urls() {
1097 let result = build_result(None);
1098 let urls = HashMap::from([(FontType::Svg, "/cached.svg".to_owned())]);
1099
1100 let first = result.generate_html_pure(Some(urls.clone())).unwrap();
1101 let second = result.generate_html_pure(Some(urls)).unwrap();
1102
1103 assert_eq!(first, second);
1104 assert!(first.contains("/cached.svg"));
1105 }
1106
1107 #[test]
1108 fn generate_html_cache_returns_different_result_for_different_urls() {
1109 let result = build_result(None);
1110 let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
1111 let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
1112
1113 let result_a = result.generate_html_pure(Some(urls_a)).unwrap();
1114 let result_b = result.generate_html_pure(Some(urls_b)).unwrap();
1115
1116 assert_ne!(result_a, result_b);
1117 assert!(result_a.contains("/a.svg"));
1118 assert!(result_b.contains("/b.svg"));
1119 }
1120
1121 fn build_multi_type_result() -> GenerateWebfontsResult {
1123 let fixture = crate::test_helpers::webfont_fixture("add.svg");
1124 let options = GenerateWebfontsOptions {
1125 css: Some(true),
1126 codepoints: Some(HashMap::from([("add".to_owned(), 0xE001u32)])),
1127 dest: "artifacts".to_owned(),
1128 files: vec![fixture],
1129 html: Some(true),
1130 font_name: Some("iconfont".to_owned()),
1131 ligature: Some(false),
1132 order: Some(vec![FontType::Woff2, FontType::Svg]),
1133 start_codepoint: Some(0xE001),
1134 types: Some(vec![FontType::Svg, FontType::Woff2]),
1135 ..Default::default()
1136 };
1137
1138 let mut resolved = resolve_generate_webfonts_options(options).unwrap();
1139 let source_files: Vec<LoadedSvgFile> = resolved
1140 .files
1141 .iter()
1142 .map(|path| LoadedSvgFile {
1143 contents: std::fs::read_to_string(path).unwrap(),
1144 glyph_name: std::path::Path::new(path)
1145 .file_stem()
1146 .unwrap()
1147 .to_str()
1148 .unwrap()
1149 .to_owned(),
1150 path: path.clone(),
1151 })
1152 .collect();
1153 finalize_generate_webfonts_options(&mut resolved, &source_files).unwrap();
1154
1155 GenerateWebfontsResult {
1156 cached: std::sync::OnceLock::new(),
1157 carried_render: None,
1158 css_context: None,
1159 fonts: FontOutputs::default(),
1160 glyph_cache: None,
1161 html_context: None,
1162 options: resolved,
1163 source_files,
1164 ttf_cache: None,
1165 written_outputs: Default::default(),
1166 }
1167 }
1168
1169 #[test]
1170 fn generate_css_partial_urls_uses_empty_string_for_missing_types() {
1171 let result = build_multi_type_result();
1172 let urls = HashMap::from([(FontType::Woff2, "/cdn/font.woff2".to_owned())]);
1174
1175 let css = result.generate_css_pure(Some(urls)).unwrap();
1176
1177 assert!(
1178 css.contains("/cdn/font.woff2"),
1179 "overridden URL should appear"
1180 );
1181 assert!(
1182 !css.contains("iconfont.svg?"),
1183 "non-overridden type should not have default hash-based URL"
1184 );
1185 assert!(
1186 css.contains("url(\"#iconfont\")"),
1187 "non-overridden SVG type should produce empty base URL (upstream compat)"
1188 );
1189 }
1190
1191 #[test]
1192 fn generate_html_partial_urls_uses_empty_string_for_missing_types() {
1193 let result = build_multi_type_result();
1194 let urls = HashMap::from([(FontType::Woff2, "/cdn/font.woff2".to_owned())]);
1195
1196 let html = result.generate_html_pure(Some(urls)).unwrap();
1197
1198 assert!(
1199 html.contains("/cdn/font.woff2"),
1200 "overridden URL should appear in HTML"
1201 );
1202 assert!(
1203 !html.contains("iconfont.svg?"),
1204 "non-overridden type should not have default hash-based URL in HTML"
1205 );
1206 }
1207}