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 #[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 #[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#[derive(Default)]
182pub(crate) struct RenderCache {
183 css_no_urls: Option<String>,
185 css_last_urls: Option<HashMap<FontType, String>>,
187 css_last_result: Option<String>,
188 html_no_urls: Option<String>,
190 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
219impl GenerateWebfontsResult {
221 pub fn eot_bytes(&self) -> Option<&[u8]> {
223 self.eot_font.as_ref().map(|v| v.as_ref().as_slice())
224 }
225
226 pub fn svg_string(&self) -> Option<&str> {
228 self.svg_font.as_ref().map(|v| v.as_ref().as_str())
229 }
230
231 pub fn ttf_bytes(&self) -> Option<&[u8]> {
233 self.ttf_font.as_ref().map(|v| v.as_ref().as_slice())
234 }
235
236 pub fn woff_bytes(&self) -> Option<&[u8]> {
238 self.woff_font.as_ref().map(|v| v.as_ref().as_slice())
239 }
240
241 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 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 !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 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 !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 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 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 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#[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 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 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 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}