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 #[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 #[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#[derive(Default)]
183pub(crate) struct RenderCache {
184 css_no_urls: Option<String>,
186 css_last_urls: Option<HashMap<FontType, String>>,
188 css_last_result: Option<String>,
189 html_no_urls: Option<String>,
191 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
220impl GenerateWebfontsResult {
222 pub fn eot_bytes(&self) -> Option<&[u8]> {
224 self.eot_font.as_ref().map(|v| v.as_ref().as_slice())
225 }
226
227 pub fn svg_string(&self) -> Option<&str> {
229 self.svg_font.as_ref().map(|v| v.as_ref().as_str())
230 }
231
232 pub fn ttf_bytes(&self) -> Option<&[u8]> {
234 self.ttf_font.as_ref().map(|v| v.as_ref().as_slice())
235 }
236
237 pub fn woff_bytes(&self) -> Option<&[u8]> {
239 self.woff_font.as_ref().map(|v| v.as_ref().as_slice())
240 }
241
242 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 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 !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 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 !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 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 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 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#[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 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 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 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}