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
387#[derive(Clone)]
388pub(crate) struct CarriedRenderCache {
389 cache: RenderCache,
390 css_dependencies: TemplateDependencies,
391 html_dependencies: TemplateDependencies,
392}
393
394pub(crate) struct CachedTemplateData {
395 pub shared: SharedTemplateData,
396 pub css_context: Map<String, Value>,
397 pub css_hbs_context: Mutex<handlebars::Context>,
398 pub html_context: Map<String, Value>,
399 pub html_hbs_context: Mutex<handlebars::Context>,
400 pub html_template_dependencies: TemplateDependencies,
401 pub html_registry: Option<handlebars::Handlebars<'static>>,
402 pub(crate) render_cache: Mutex<RenderCache>,
403}
404
405#[derive(Clone, Default)]
409pub(crate) struct FontOutputs {
410 pub(crate) svg_font: Option<Arc<String>>,
411 pub(crate) ttf_font: Option<Arc<Vec<u8>>>,
412 pub(crate) woff_font: Option<Arc<Vec<u8>>>,
413 pub(crate) woff2_font: Option<Arc<Vec<u8>>>,
414 pub(crate) eot_font: Option<Arc<Vec<u8>>>,
415}
416
417pub(crate) struct RegenerationState {
418 pub(crate) caches_dirty: bool,
419 pub(crate) glyph_cache: GlyphCache,
420 pub(crate) ttf_cache: Option<TtfGlyphCache>,
421 pub(crate) written_outputs: HashMap<String, [u8; 16]>,
422}
423
424pub(crate) struct RegenerationStateLease {
425 slot: Arc<Mutex<Option<RegenerationState>>>,
426 state: Option<RegenerationState>,
427 keep_caches: bool,
428}
429
430pub struct RegenerateError {
433 result: Option<Box<GenerateWebfontsResult>>,
434 source: std::io::Error,
435}
436
437impl RegenerateError {
438 pub(crate) fn new(result: Option<GenerateWebfontsResult>, source: std::io::Error) -> Self {
439 Self {
440 result: result.map(Box::new),
441 source,
442 }
443 }
444
445 pub fn into_result(self) -> Option<GenerateWebfontsResult> {
448 self.result.map(|result| *result)
449 }
450
451 pub fn into_parts(self) -> (Option<GenerateWebfontsResult>, std::io::Error) {
453 (self.result.map(|result| *result), self.source)
454 }
455}
456
457impl std::fmt::Debug for RegenerateError {
458 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
459 formatter
460 .debug_struct("RegenerateError")
461 .field("recoverable", &self.result.is_some())
462 .field("source", &self.source)
463 .finish()
464 }
465}
466
467impl std::fmt::Display for RegenerateError {
468 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
469 self.source.fmt(formatter)
470 }
471}
472
473impl std::error::Error for RegenerateError {
474 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
475 Some(&self.source)
476 }
477}
478
479impl RegenerationStateLease {
480 pub(crate) fn state_mut(&mut self) -> &mut RegenerationState {
481 self.state.as_mut().unwrap()
482 }
483
484 pub(crate) fn commit(mut self) {
485 self.keep_caches = true;
486 }
487}
488
489impl Drop for RegenerationStateLease {
490 fn drop(&mut self) {
491 let mut state = self.state.take().unwrap();
492 if !self.keep_caches && state.caches_dirty {
493 state.glyph_cache = GlyphCache::default();
494 if state.ttf_cache.is_some() {
495 state.ttf_cache = Some(TtfGlyphCache::default());
496 }
497 }
498 state.caches_dirty = false;
499 *self.slot.lock().unwrap() = Some(state);
500 }
501}
502
503#[cfg_attr(feature = "napi", napi)]
507pub struct GenerateWebfontsResult {
508 pub(crate) cached: OnceLock<Result<CachedTemplateData, String>>,
509 pub(crate) carried_render: Option<CarriedRenderCache>,
513 pub(crate) css_context: Option<Map<String, Value>>,
514 pub(crate) fonts: FontOutputs,
515 pub(crate) html_context: Option<Map<String, Value>>,
516 pub(crate) options: Arc<ResolvedGenerateWebfontsOptions>,
517 pub(crate) regeneration_state: Arc<Mutex<Option<RegenerationState>>>,
518 pub(crate) source_files: Arc<Vec<LoadedSvgFile>>,
519}
520
521impl GenerateWebfontsResult {
523 #[cfg(any(feature = "napi", feature = "bench"))]
524 pub(crate) fn snapshot_for_regeneration(&self, state: RegenerationState) -> Self {
525 Self {
526 cached: OnceLock::new(),
527 carried_render: self.render_cache_source(),
528 css_context: self.css_context.clone(),
529 fonts: self.fonts.clone(),
530 html_context: self.html_context.clone(),
531 options: self.options.clone(),
532 regeneration_state: Arc::new(Mutex::new(Some(state))),
533 source_files: self.source_files.clone(),
534 }
535 }
536
537 pub(crate) fn take_regeneration_state(&self) -> std::io::Result<RegenerationState> {
538 if !self.options.incremental {
539 return Err(std::io::Error::new(
540 std::io::ErrorKind::InvalidInput,
541 "regenerate requires the font to be generated with `incremental` enabled.",
542 ));
543 }
544 self.regeneration_state
545 .lock()
546 .unwrap()
547 .take()
548 .ok_or_else(|| {
549 std::io::Error::new(
550 std::io::ErrorKind::WouldBlock,
551 "This result is already regenerating or has been replaced.",
552 )
553 })
554 }
555
556 pub(crate) fn take_regeneration_state_lease(&self) -> std::io::Result<RegenerationStateLease> {
557 Ok(RegenerationStateLease {
558 slot: Arc::clone(&self.regeneration_state),
559 state: Some(self.take_regeneration_state()?),
560 keep_caches: false,
561 })
562 }
563
564 #[cfg(feature = "bench")]
565 pub(crate) fn restore_regeneration_state(&self, state: RegenerationState) {
566 *self.regeneration_state.lock().unwrap() = Some(state);
567 }
568
569 pub(crate) fn seed_written_outputs(&self, written_outputs: HashMap<String, [u8; 16]>) {
570 if let Some(state) = self.regeneration_state.lock().unwrap().as_mut() {
571 state.written_outputs = written_outputs;
572 }
573 }
574
575 #[doc(hidden)]
576 #[cfg(feature = "bench")]
577 pub fn regenerate_owned_for_bench(
578 &self,
579 files: &[String],
580 changes: &[(String, GlyphChange)],
581 ) -> std::io::Result<Self> {
582 let state = self.take_regeneration_state()?;
583 let mut replacement = self.snapshot_for_regeneration(state);
584 match replacement.regenerate(files, changes) {
585 Ok(()) => Ok(replacement),
586 Err(error) => {
587 if let Ok(state) = replacement.take_regeneration_state() {
588 self.restore_regeneration_state(state);
589 }
590 Err(error)
591 }
592 }
593 }
594
595 #[cfg(test)]
596 pub(crate) fn has_carried_css_no_urls_for_test(&self) -> bool {
597 self.carried_render
598 .as_ref()
599 .is_some_and(|carried| carried.cache.css_no_urls.is_some())
600 }
601
602 #[cfg(test)]
603 pub(crate) fn has_carried_html_no_urls_for_test(&self) -> bool {
604 self.carried_render
605 .as_ref()
606 .is_some_and(|carried| carried.cache.html_no_urls.is_some())
607 }
608
609 pub fn eot_bytes(&self) -> Option<&[u8]> {
611 self.fonts.eot_font.as_ref().map(|v| v.as_ref().as_slice())
612 }
613
614 pub fn svg_string(&self) -> Option<&str> {
616 self.fonts.svg_font.as_ref().map(|v| v.as_ref().as_str())
617 }
618
619 pub fn ttf_bytes(&self) -> Option<&[u8]> {
621 self.fonts.ttf_font.as_ref().map(|v| v.as_ref().as_slice())
622 }
623
624 pub fn woff_bytes(&self) -> Option<&[u8]> {
626 self.fonts.woff_font.as_ref().map(|v| v.as_ref().as_slice())
627 }
628
629 pub fn woff2_bytes(&self) -> Option<&[u8]> {
631 self.fonts
632 .woff2_font
633 .as_ref()
634 .map(|v| v.as_ref().as_slice())
635 }
636
637 pub(crate) fn get_cached_io(&self) -> std::io::Result<&CachedTemplateData> {
638 self.cached
639 .get_or_init(|| {
640 let shared = SharedTemplateData::new(&self.options, &self.source_files)
641 .map_err(|e| e.to_string())?;
642 let css_context = match &self.css_context {
643 Some(ctx) => ctx.clone(),
644 None => build_css_context(&self.options, &shared),
645 };
646 let html_context = match &self.html_context {
647 Some(ctx) => ctx.clone(),
648 None => build_html_context(&self.options, &shared, &self.source_files, None)
649 .map_err(|e| e.to_string())?,
650 };
651 let (html_registry, html_template_dependencies) =
652 build_html_registry_and_dependencies(&self.options)
653 .map_err(|e| e.to_string())?;
654 let css_hbs_context =
655 handlebars::Context::wraps(&css_context).map_err(|e| e.to_string())?;
656 let html_hbs_context =
657 handlebars::Context::wraps(&html_context).map_err(|e| e.to_string())?;
658 Ok(CachedTemplateData {
659 shared,
660 css_context,
661 css_hbs_context: Mutex::new(css_hbs_context),
662 html_context,
663 html_hbs_context: Mutex::new(html_hbs_context),
664 html_template_dependencies,
665 html_registry,
666 render_cache: Mutex::new(
669 self.carried_render
670 .as_ref()
671 .map(|carried| carried.cache.clone())
672 .unwrap_or_default(),
673 ),
674 })
675 })
676 .as_ref()
677 .map_err(to_io_err)
678 }
679
680 pub(crate) fn reusable_render_cache(
681 &self,
682 names_unchanged: bool,
683 codepoints_unchanged: bool,
684 ) -> Option<CarriedRenderCache> {
685 self.render_cache_source().map(|carried| {
686 let css_deps = carried.css_dependencies;
687 let css_no_urls_unchanged =
688 css_deps.can_reuse_css_no_urls(names_unchanged, codepoints_unchanged);
689 let css_with_urls_unchanged =
690 css_deps.can_reuse_css_with_urls(names_unchanged, codepoints_unchanged);
691 let html_no_urls_unchanged = carried.html_dependencies.can_reuse_html(
692 names_unchanged,
693 codepoints_unchanged,
694 css_no_urls_unchanged,
695 );
696 let html_with_urls_unchanged = carried.html_dependencies.can_reuse_html(
697 names_unchanged,
698 codepoints_unchanged,
699 css_with_urls_unchanged,
700 );
701
702 let rc = carried.cache;
703 CarriedRenderCache {
704 css_dependencies: carried.css_dependencies,
705 html_dependencies: carried.html_dependencies,
706 cache: RenderCache {
707 css_no_urls: css_no_urls_unchanged
708 .then(|| rc.css_no_urls.clone())
709 .flatten(),
710 html_no_urls: html_no_urls_unchanged
711 .then(|| rc.html_no_urls.clone())
712 .flatten(),
713 css_last_urls: css_with_urls_unchanged
714 .then(|| rc.css_last_urls.clone())
715 .flatten(),
716 css_last_result: css_with_urls_unchanged
717 .then(|| rc.css_last_result.clone())
718 .flatten(),
719 html_last_urls: html_with_urls_unchanged
720 .then(|| rc.html_last_urls.clone())
721 .flatten(),
722 html_last_result: html_with_urls_unchanged
723 .then(|| rc.html_last_result.clone())
724 .flatten(),
725 },
726 }
727 })
728 }
729
730 fn render_cache_source(&self) -> Option<CarriedRenderCache> {
731 self.cached
732 .get()
733 .and_then(|cached| cached.as_ref().ok())
734 .map(|cached| CarriedRenderCache {
735 cache: cached.render_cache.lock().unwrap().clone(),
736 css_dependencies: cached.shared.css_template_dependencies,
737 html_dependencies: cached.html_template_dependencies,
738 })
739 .or_else(|| self.carried_render.clone())
740 }
741
742 pub fn generate_css_pure(
746 &self,
747 urls: Option<HashMap<FontType, String>>,
748 ) -> std::io::Result<String> {
749 let cached = self.get_cached_io()?;
750 let mut rc = cached.render_cache.lock().unwrap();
751
752 match &urls {
753 None => {
754 if let Some(result) = &rc.css_no_urls {
755 return Ok(result.clone());
756 }
757 let ctx = cached.css_hbs_context.lock().unwrap();
758 let result =
759 render_css_with_hbs_context(&cached.shared, &ctx, &cached.css_context)?;
760 rc.css_no_urls = Some(result.clone());
761 Ok(result)
762 }
763 Some(urls) => {
764 if !cached.shared.css_template_uses_src {
766 drop(rc);
767 return self.generate_css_pure(None);
768 }
769 if rc.css_last_urls.as_ref() == Some(urls)
770 && let Some(result) = &rc.css_last_result
771 {
772 return Ok(result.clone());
773 }
774 let src = make_src(&self.options, urls);
775 let mut ctx = cached.css_hbs_context.lock().unwrap();
776 let result = render_css_with_src_mutate(
777 &cached.shared,
778 &mut ctx,
779 &cached.css_context,
780 &src,
781 )?;
782 rc.css_last_urls = Some(urls.clone());
783 rc.css_last_result = Some(result.clone());
784 Ok(result)
785 }
786 }
787 }
788
789 pub fn generate_html_pure(
793 &self,
794 urls: Option<HashMap<FontType, String>>,
795 ) -> std::io::Result<String> {
796 let cached = self.get_cached_io()?;
797 let mut rc = cached.render_cache.lock().unwrap();
798
799 match &urls {
800 None => {
801 if let Some(result) = &rc.html_no_urls {
802 return Ok(result.clone());
803 }
804 let ctx = cached.html_hbs_context.lock().unwrap();
805 let result = render_html_with_hbs_context(
806 cached.html_registry.as_ref(),
807 &ctx,
808 &cached.html_context,
809 )?;
810 rc.html_no_urls = Some(result.clone());
811 Ok(result)
812 }
813 Some(urls) => {
814 if !cached.shared.css_template_uses_src {
816 drop(rc);
817 return self.generate_html_pure(None);
818 }
819 if rc.html_last_urls.as_ref() == Some(urls)
820 && let Some(result) = &rc.html_last_result
821 {
822 return Ok(result.clone());
823 }
824 let src = make_src(&self.options, urls);
826 let styles = {
827 let mut css_ctx = cached.css_hbs_context.lock().unwrap();
828 render_css_with_src_mutate(
829 &cached.shared,
830 &mut css_ctx,
831 &cached.css_context,
832 &src,
833 )?
834 };
835 if self.options.html_template.is_none() {
837 let result = render_default_html_with_styles(&cached.html_context, &styles);
838 rc.html_last_urls = Some(urls.clone());
839 rc.html_last_result = Some(result.clone());
840 return Ok(result);
841 }
842 let mut html_ctx = cached.html_hbs_context.lock().unwrap();
844 let registry = cached
845 .html_registry
846 .as_ref()
847 .expect("HTML registry should exist for custom template");
848 let result = crate::util::render_with_field_swap(
849 &mut html_ctx,
850 "styles",
851 serde_json::Value::String(styles),
852 |ctx| {
853 registry
854 .render_with_context("html", ctx)
855 .map_err(crate::util::to_io_err)
856 },
857 )?;
858 rc.html_last_urls = Some(urls.clone());
859 rc.html_last_result = Some(result.clone());
860 Ok(result)
861 }
862 }
863 }
864}
865
866#[cfg(feature = "napi")]
868#[napi]
869impl GenerateWebfontsResult {
870 #[napi(getter)]
872 pub fn eot(&self) -> Option<Uint8Array> {
873 self.fonts
874 .eot_font
875 .as_ref()
876 .map(|v| Uint8Array::from(v.as_ref().clone()))
877 }
878
879 #[napi(getter)]
881 pub fn svg(&self) -> Option<String> {
882 self.fonts.svg_font.as_ref().map(|v| v.as_ref().clone())
883 }
884
885 #[napi(getter)]
887 pub fn ttf(&self) -> Option<Uint8Array> {
888 self.fonts
889 .ttf_font
890 .as_ref()
891 .map(|v| Uint8Array::from(v.as_ref().clone()))
892 }
893
894 #[napi(getter)]
896 pub fn woff2(&self) -> Option<Uint8Array> {
897 self.fonts
898 .woff2_font
899 .as_ref()
900 .map(|v| Uint8Array::from(v.as_ref().clone()))
901 }
902
903 #[napi(getter)]
905 pub fn woff(&self) -> Option<Uint8Array> {
906 self.fonts
907 .woff_font
908 .as_ref()
909 .map(|v| Uint8Array::from(v.as_ref().clone()))
910 }
911
912 #[napi(ts_args_type = "urls?: Partial<Record<FontType, string>>")]
917 pub fn generate_css(&self, urls: Option<HashMap<String, String>>) -> napi::Result<String> {
918 let urls = urls.map(parse_native_urls).transpose()?;
919 self.generate_css_pure(urls)
920 .map_err(crate::util::to_napi_err)
921 }
922
923 #[napi(ts_args_type = "urls?: Partial<Record<FontType, string>>")]
927 pub fn generate_html(&self, urls: Option<HashMap<String, String>>) -> napi::Result<String> {
928 let urls = urls.map(parse_native_urls).transpose()?;
929 self.generate_html_pure(urls)
930 .map_err(crate::util::to_napi_err)
931 }
932
933 #[napi(js_name = "regenerate")]
945 pub fn regenerate_from_js(
946 &mut self,
947 files: Vec<String>,
948 changes: Option<Vec<GlyphChangeEntry>>,
949 ) -> napi::Result<()> {
950 let changes = parse_glyph_changes(changes)?;
951 match changes {
952 Some(changes) => self.regenerate(&files, &changes),
953 None => self.regenerate_all(&files),
954 }
955 .map_err(crate::util::to_napi_err)
956 }
957
958 #[napi(js_name = "regenerateAsync")]
963 pub async fn regenerate_async_from_js(
964 &self,
965 files: Vec<String>,
966 changes: Option<Vec<GlyphChangeEntry>>,
967 ) -> napi::Result<GenerateWebfontsResult> {
968 let changes = parse_glyph_changes(changes)?;
969 let state = self
970 .take_regeneration_state()
971 .map_err(crate::util::to_napi_err)?;
972 let original_state = Arc::clone(&self.regeneration_state);
973 let mut replacement = self.snapshot_for_regeneration(state);
974 tokio::task::spawn_blocking(move || -> std::io::Result<GenerateWebfontsResult> {
975 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match changes {
976 Some(changes) => replacement.regenerate(&files, &changes),
977 None => replacement.regenerate_all(&files),
978 }));
979 match result {
980 Ok(Ok(())) => Ok(replacement),
981 Ok(Err(error)) => {
982 if let Ok(state) = replacement.take_regeneration_state() {
983 *original_state.lock().unwrap() = Some(state);
984 }
985 Err(error)
986 }
987 Err(payload) => {
988 if let Ok(state) = replacement.take_regeneration_state() {
989 *original_state.lock().unwrap() = Some(state);
990 }
991 std::panic::resume_unwind(payload)
992 }
993 }
994 })
995 .await
996 .map_err(|error| {
997 napi::Error::from_reason(format!("Native webfont regeneration task failed: {error}"))
998 })?
999 .map_err(crate::util::to_napi_err)
1000 }
1001}
1002
1003#[cfg(feature = "napi")]
1004fn parse_glyph_changes(
1005 changes: Option<Vec<GlyphChangeEntry>>,
1006) -> napi::Result<Option<Vec<(String, GlyphChange)>>> {
1007 changes
1008 .map(|changes| {
1009 changes
1010 .into_iter()
1011 .map(|entry| {
1012 let change = match entry.change_type.as_str() {
1013 "added" => GlyphChange::Added { name: entry.name },
1014 "changed" => GlyphChange::Changed { name: entry.name },
1015 "removed" => GlyphChange::Removed,
1016 other => {
1017 return Err(napi::Error::from_reason(format!(
1018 "Unknown changeType '{other}'; expected 'added', 'changed', or 'removed'."
1019 )));
1020 }
1021 };
1022 Ok((entry.path, change))
1023 })
1024 .collect()
1025 })
1026 .transpose()
1027}
1028
1029#[cfg(feature = "napi")]
1030fn parse_native_urls(urls: HashMap<String, String>) -> napi::Result<HashMap<FontType, String>> {
1031 urls.into_iter()
1032 .filter_map(|(font_type, url)| {
1033 let font_type = match font_type.as_str() {
1034 "svg" => Some(FontType::Svg),
1035 "ttf" => Some(FontType::Ttf),
1036 "eot" => Some(FontType::Eot),
1037 "woff" => Some(FontType::Woff),
1038 "woff2" => Some(FontType::Woff2),
1039 _ => None,
1040 }?;
1041
1042 Some(Ok((font_type, url)))
1043 })
1044 .collect::<napi::Result<HashMap<FontType, String>>>()
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049 use super::*;
1050 use crate::{finalize_generate_webfonts_options, resolve_generate_webfonts_options};
1051
1052 fn build_result(template: Option<&str>) -> GenerateWebfontsResult {
1053 let fixture = crate::test_helpers::webfont_fixture("add.svg");
1054
1055 let mut css_template = None;
1056 let cleanup_dir;
1057 if let Some(content) = template {
1058 let tmp = std::env::temp_dir().join(format!(
1059 "render-cache-test-{}",
1060 std::time::SystemTime::now()
1061 .duration_since(std::time::UNIX_EPOCH)
1062 .unwrap()
1063 .as_nanos()
1064 ));
1065 std::fs::create_dir_all(&tmp).unwrap();
1066 let path = tmp.join("template.hbs");
1067 std::fs::write(&path, content).unwrap();
1068 css_template = Some(path.to_string_lossy().into_owned());
1069 cleanup_dir = Some(tmp);
1070 } else {
1071 cleanup_dir = None;
1072 }
1073
1074 let options = GenerateWebfontsOptions {
1075 css: Some(true),
1076 css_template,
1077 codepoints: Some(HashMap::from([("add".to_owned(), 0xE001u32)])),
1078 dest: "artifacts".to_owned(),
1079 files: vec![fixture],
1080 html: Some(false),
1081 font_name: Some("iconfont".to_owned()),
1082 ligature: Some(false),
1083 order: Some(vec![FontType::Svg]),
1084 start_codepoint: Some(0xE001),
1085 types: Some(vec![FontType::Svg]),
1086 ..Default::default()
1087 };
1088
1089 let mut resolved = resolve_generate_webfonts_options(options).unwrap();
1090 let source_files: Vec<LoadedSvgFile> = resolved
1091 .files
1092 .iter()
1093 .map(|path| LoadedSvgFile {
1094 contents: std::fs::read_to_string(path).unwrap(),
1095 glyph_name: std::path::Path::new(path)
1096 .file_stem()
1097 .unwrap()
1098 .to_str()
1099 .unwrap()
1100 .to_owned(),
1101 path: path.clone(),
1102 })
1103 .collect();
1104 finalize_generate_webfonts_options(&mut resolved, &source_files).unwrap();
1105
1106 let result = GenerateWebfontsResult {
1107 cached: std::sync::OnceLock::new(),
1108 carried_render: None,
1109 css_context: None,
1110 fonts: FontOutputs::default(),
1111 html_context: None,
1112 options: Arc::new(resolved),
1113 regeneration_state: Arc::new(Mutex::new(None)),
1114 source_files: Arc::new(source_files),
1115 };
1116
1117 if let Some(dir) = cleanup_dir {
1118 std::mem::forget(dir);
1120 }
1121
1122 result
1123 }
1124
1125 #[test]
1126 fn generate_css_returns_cached_result_on_repeated_calls_without_urls() {
1127 let result = build_result(None);
1128
1129 let first = result.generate_css_pure(None).unwrap();
1130 let second = result.generate_css_pure(None).unwrap();
1131
1132 assert_eq!(first, second);
1133 assert!(!first.is_empty());
1134 }
1135
1136 #[test]
1137 fn generate_css_returns_cached_result_on_repeated_calls_with_same_urls() {
1138 let result = build_result(None);
1139 let urls = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
1140
1141 let first = result.generate_css_pure(Some(urls.clone())).unwrap();
1142 let second = result.generate_css_pure(Some(urls)).unwrap();
1143
1144 assert_eq!(first, second);
1145 assert!(first.contains("/a.svg"));
1146 }
1147
1148 #[test]
1149 fn generate_css_returns_different_result_for_different_urls() {
1150 let result = build_result(None);
1151 let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
1152 let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
1153
1154 let result_a = result.generate_css_pure(Some(urls_a)).unwrap();
1155 let result_b = result.generate_css_pure(Some(urls_b)).unwrap();
1156
1157 assert_ne!(result_a, result_b);
1158 assert!(result_a.contains("/a.svg"));
1159 assert!(result_b.contains("/b.svg"));
1160 }
1161
1162 #[test]
1163 fn generate_css_cache_updates_when_urls_change() {
1164 let result = build_result(None);
1165 let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
1166 let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
1167
1168 let first_a = result.generate_css_pure(Some(urls_a.clone())).unwrap();
1169 let first_b = result.generate_css_pure(Some(urls_b)).unwrap();
1170 let second_a = result.generate_css_pure(Some(urls_a)).unwrap();
1171
1172 assert_eq!(
1173 first_a, second_a,
1174 "returning to original urls should produce same result"
1175 );
1176 assert_ne!(first_a, first_b);
1177 }
1178
1179 #[test]
1180 fn generate_css_cache_works_with_custom_template() {
1181 let result = build_result(Some("@font-face { src: {{{src}}}; }"));
1182 let urls = HashMap::from([(FontType::Svg, "/cached.svg".to_owned())]);
1183
1184 let first = result.generate_css_pure(Some(urls.clone())).unwrap();
1185 let second = result.generate_css_pure(Some(urls)).unwrap();
1186
1187 assert_eq!(first, second);
1188 assert!(first.contains("/cached.svg"));
1189 }
1190
1191 #[test]
1192 fn generate_css_no_urls_and_with_urls_are_independent_caches() {
1193 let result = build_result(None);
1194 let urls = HashMap::from([(FontType::Svg, "/custom.svg".to_owned())]);
1195
1196 let no_urls = result.generate_css_pure(None).unwrap();
1197 let with_urls = result.generate_css_pure(Some(urls)).unwrap();
1198 let no_urls_again = result.generate_css_pure(None).unwrap();
1199
1200 assert_eq!(
1201 no_urls, no_urls_again,
1202 "no-urls cache should survive a with-urls call"
1203 );
1204 assert_ne!(no_urls, with_urls);
1205 }
1206
1207 #[test]
1208 fn generate_css_with_urls_returns_no_urls_result_when_template_does_not_use_src() {
1209 let result = build_result(Some(".icon { font-family: {{fontName}}; }"));
1210 let urls = HashMap::from([(FontType::Svg, "/should-not-appear.svg".to_owned())]);
1211
1212 let no_urls = result.generate_css_pure(None).unwrap();
1213 let with_urls = result.generate_css_pure(Some(urls)).unwrap();
1214
1215 assert_eq!(
1216 no_urls, with_urls,
1217 "template without {{src}} should ignore urls"
1218 );
1219 assert!(!with_urls.contains("/should-not-appear.svg"));
1220 assert!(
1221 with_urls.contains("iconfont"),
1222 "should still render the template"
1223 );
1224 }
1225
1226 #[test]
1227 fn generate_html_with_urls_returns_no_urls_result_when_css_template_does_not_use_src() {
1228 let result = build_result(Some(".icon { font-family: {{fontName}}; }"));
1229 let urls = HashMap::from([(FontType::Svg, "/should-not-appear.svg".to_owned())]);
1230
1231 let no_urls = result.generate_html_pure(None).unwrap();
1232 let with_urls = result.generate_html_pure(Some(urls)).unwrap();
1233
1234 assert_eq!(
1235 no_urls, with_urls,
1236 "CSS template without {{src}} means HTML is also unaffected by urls"
1237 );
1238 }
1239
1240 #[test]
1241 fn generate_css_without_urls_produces_valid_css_using_css_fonts_url() {
1242 let result = build_result(None);
1243
1244 let css = result.generate_css_pure(None).unwrap();
1245
1246 assert!(
1247 css.contains("@font-face"),
1248 "should contain @font-face declaration"
1249 );
1250 assert!(css.contains("font-family:"), "should contain font-family");
1251 assert!(
1252 css.contains("iconfont.svg?"),
1253 "should use font name in URL with hash"
1254 );
1255 assert!(
1256 css.contains("format(\"svg\")"),
1257 "should contain format declaration"
1258 );
1259 assert!(
1260 css.contains("content:"),
1261 "should contain codepoint content rules"
1262 );
1263 }
1264
1265 #[test]
1266 fn generate_css_with_urls_replaces_default_urls_in_src() {
1267 let result = build_result(None);
1268 let urls = HashMap::from([(FontType::Svg, "/cdn/icons.svg".to_owned())]);
1269
1270 let css = result.generate_css_pure(Some(urls)).unwrap();
1271
1272 assert!(
1273 css.contains("/cdn/icons.svg"),
1274 "custom URL should appear in output"
1275 );
1276 assert!(
1277 !css.contains("iconfont.svg?"),
1278 "default hash-based URL should not appear"
1279 );
1280 assert!(
1281 css.contains("format(\"svg\")"),
1282 "format should still be present"
1283 );
1284 }
1285
1286 #[test]
1287 fn generate_html_without_urls_produces_valid_html() {
1288 let result = build_result(None);
1289
1290 let html = result.generate_html_pure(None).unwrap();
1291
1292 assert!(
1293 html.contains("<!DOCTYPE html>"),
1294 "should be a full HTML document"
1295 );
1296 assert!(html.contains("iconfont"), "should contain font name");
1297 assert!(html.contains("icon-add"), "should contain icon class name");
1298 }
1299
1300 #[test]
1301 fn generate_html_with_urls_embeds_css_using_custom_urls() {
1302 let result = build_result(None);
1303 let urls = HashMap::from([(FontType::Svg, "/cdn/icons.svg".to_owned())]);
1304
1305 let html = result.generate_html_pure(Some(urls)).unwrap();
1306
1307 assert!(
1308 html.contains("/cdn/icons.svg"),
1309 "custom URL should appear in embedded CSS"
1310 );
1311 assert!(
1312 html.contains("icon-add"),
1313 "should still contain icon class name"
1314 );
1315 }
1316
1317 #[test]
1318 fn generate_html_cache_returns_same_result_for_same_urls() {
1319 let result = build_result(None);
1320 let urls = HashMap::from([(FontType::Svg, "/cached.svg".to_owned())]);
1321
1322 let first = result.generate_html_pure(Some(urls.clone())).unwrap();
1323 let second = result.generate_html_pure(Some(urls)).unwrap();
1324
1325 assert_eq!(first, second);
1326 assert!(first.contains("/cached.svg"));
1327 }
1328
1329 #[test]
1330 fn generate_html_cache_returns_different_result_for_different_urls() {
1331 let result = build_result(None);
1332 let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
1333 let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
1334
1335 let result_a = result.generate_html_pure(Some(urls_a)).unwrap();
1336 let result_b = result.generate_html_pure(Some(urls_b)).unwrap();
1337
1338 assert_ne!(result_a, result_b);
1339 assert!(result_a.contains("/a.svg"));
1340 assert!(result_b.contains("/b.svg"));
1341 }
1342
1343 fn build_multi_type_result() -> GenerateWebfontsResult {
1345 let fixture = crate::test_helpers::webfont_fixture("add.svg");
1346 let options = GenerateWebfontsOptions {
1347 css: Some(true),
1348 codepoints: Some(HashMap::from([("add".to_owned(), 0xE001u32)])),
1349 dest: "artifacts".to_owned(),
1350 files: vec![fixture],
1351 html: Some(true),
1352 font_name: Some("iconfont".to_owned()),
1353 ligature: Some(false),
1354 order: Some(vec![FontType::Woff2, FontType::Svg]),
1355 start_codepoint: Some(0xE001),
1356 types: Some(vec![FontType::Svg, FontType::Woff2]),
1357 ..Default::default()
1358 };
1359
1360 let mut resolved = resolve_generate_webfonts_options(options).unwrap();
1361 let source_files: Vec<LoadedSvgFile> = resolved
1362 .files
1363 .iter()
1364 .map(|path| LoadedSvgFile {
1365 contents: std::fs::read_to_string(path).unwrap(),
1366 glyph_name: std::path::Path::new(path)
1367 .file_stem()
1368 .unwrap()
1369 .to_str()
1370 .unwrap()
1371 .to_owned(),
1372 path: path.clone(),
1373 })
1374 .collect();
1375 finalize_generate_webfonts_options(&mut resolved, &source_files).unwrap();
1376
1377 GenerateWebfontsResult {
1378 cached: std::sync::OnceLock::new(),
1379 carried_render: None,
1380 css_context: None,
1381 fonts: FontOutputs::default(),
1382 html_context: None,
1383 options: Arc::new(resolved),
1384 regeneration_state: Arc::new(Mutex::new(None)),
1385 source_files: Arc::new(source_files),
1386 }
1387 }
1388
1389 #[test]
1390 fn generate_css_partial_urls_uses_empty_string_for_missing_types() {
1391 let result = build_multi_type_result();
1392 let urls = HashMap::from([(FontType::Woff2, "/cdn/font.woff2".to_owned())]);
1394
1395 let css = result.generate_css_pure(Some(urls)).unwrap();
1396
1397 assert!(
1398 css.contains("/cdn/font.woff2"),
1399 "overridden URL should appear"
1400 );
1401 assert!(
1402 !css.contains("iconfont.svg?"),
1403 "non-overridden type should not have default hash-based URL"
1404 );
1405 assert!(
1406 css.contains("url(\"#iconfont\")"),
1407 "non-overridden SVG type should produce empty base URL (upstream compat)"
1408 );
1409 }
1410
1411 #[test]
1412 fn generate_html_partial_urls_uses_empty_string_for_missing_types() {
1413 let result = build_multi_type_result();
1414 let urls = HashMap::from([(FontType::Woff2, "/cdn/font.woff2".to_owned())]);
1415
1416 let html = result.generate_html_pure(Some(urls)).unwrap();
1417
1418 assert!(
1419 html.contains("/cdn/font.woff2"),
1420 "overridden URL should appear in HTML"
1421 );
1422 assert!(
1423 !html.contains("iconfont.svg?"),
1424 "non-overridden type should not have default hash-based URL in HTML"
1425 );
1426 }
1427}