use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use std::sync::{Mutex, OnceLock};
#[cfg(feature = "napi")]
use napi::bindgen_prelude::Uint8Array;
#[cfg(feature = "napi")]
use napi_derive::napi;
use serde_json::{Map, Value};
use crate::svg::types::GlyphCache;
use crate::templates::{
SharedTemplateData, TemplateDependencies, build_css_context, build_html_context,
build_html_registry_and_dependencies, make_src, render_css_with_hbs_context,
render_css_with_src_mutate, render_default_html_with_styles, render_html_with_hbs_context,
};
use crate::ttf::TtfGlyphCache;
use crate::util::to_io_err;
pub enum GlyphChange {
Added { name: Option<String> },
Changed { name: Option<String> },
Removed,
}
#[cfg_attr(feature = "napi", napi(object))]
pub struct GlyphChangeEntry {
pub path: String,
#[cfg_attr(feature = "napi", napi(ts_type = "'added' | 'changed' | 'removed'"))]
pub change_type: String,
pub name: Option<String>,
}
#[cfg_attr(feature = "napi", napi(string_enum = "lowercase"))]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum FontType {
Svg,
Ttf,
Eot,
Woff,
Woff2,
}
impl FontType {
#[inline]
pub fn css_format(self) -> &'static str {
match self {
FontType::Svg => "svg",
FontType::Ttf => "truetype",
FontType::Eot => "embedded-opentype",
FontType::Woff => "woff",
FontType::Woff2 => "woff2",
}
}
#[inline]
pub fn as_extension(self) -> &'static str {
match self {
FontType::Svg => "svg",
FontType::Ttf => "ttf",
FontType::Eot => "eot",
FontType::Woff => "woff",
FontType::Woff2 => "woff2",
}
}
}
#[cfg_attr(feature = "napi", napi(object))]
#[derive(Clone, Default)]
pub struct SvgFormatOptions {
pub center_vertically: Option<bool>,
pub font_id: Option<String>,
pub metadata: Option<String>,
pub optimize_output: Option<bool>,
pub preserve_aspect_ratio: Option<bool>,
}
#[cfg_attr(feature = "napi", napi(object))]
#[derive(Clone)]
pub struct TtfFormatOptions {
pub copyright: Option<String>,
pub description: Option<String>,
pub ts: Option<i64>,
pub url: Option<String>,
pub version: Option<String>,
}
#[cfg_attr(feature = "napi", napi(object))]
#[derive(Clone)]
pub struct WoffFormatOptions {
pub metadata: Option<String>,
}
#[cfg_attr(feature = "napi", napi(object))]
#[derive(Clone)]
pub struct Woff2FormatOptions {
pub compression_quality: Option<u8>,
}
#[cfg_attr(feature = "napi", napi(object))]
#[derive(Clone, Default)]
pub struct FormatOptions {
pub svg: Option<SvgFormatOptions>,
pub ttf: Option<TtfFormatOptions>,
pub woff: Option<WoffFormatOptions>,
pub woff2: Option<Woff2FormatOptions>,
}
#[cfg_attr(feature = "napi", napi(object))]
#[derive(Clone)]
pub struct CssContext {
pub font_name: String,
pub src: String,
pub codepoints: HashMap<String, String>,
}
#[cfg_attr(feature = "napi", napi(object))]
#[derive(Clone)]
pub struct HtmlContext {
pub font_name: String,
pub names: Vec<String>,
pub styles: String,
pub codepoints: HashMap<String, u32>,
}
#[cfg_attr(feature = "napi", napi(object))]
#[derive(Clone, Default)]
pub struct GenerateWebfontsOptions {
pub ascent: Option<f64>,
pub center_horizontally: Option<bool>,
pub center_vertically: Option<bool>,
pub css: Option<bool>,
pub css_dest: Option<String>,
pub css_template: Option<String>,
pub codepoints: Option<HashMap<String, u32>>,
pub css_fonts_url: Option<String>,
pub descent: Option<f64>,
pub dest: String,
pub files: Vec<String>,
pub fixed_width: Option<bool>,
pub format_options: Option<FormatOptions>,
pub html: Option<bool>,
pub html_dest: Option<String>,
pub html_template: Option<String>,
pub incremental: Option<bool>,
pub font_height: Option<f64>,
pub font_name: Option<String>,
pub font_style: Option<String>,
pub font_weight: Option<String>,
pub ligature: Option<bool>,
pub normalize: Option<bool>,
pub order: Option<Vec<FontType>>,
pub optimize_output: Option<bool>,
pub preserve_aspect_ratio: Option<bool>,
pub round: Option<f64>,
pub start_codepoint: Option<u32>,
pub template_options: Option<Map<String, Value>>,
pub types: Option<Vec<FontType>>,
pub write_files: Option<bool>,
}
pub(crate) const DEFAULT_FONT_TYPES: [FontType; 3] =
[FontType::Eot, FontType::Woff, FontType::Woff2];
pub(crate) const DEFAULT_FONT_ORDER: [FontType; 5] = [
FontType::Eot,
FontType::Woff2,
FontType::Woff,
FontType::Ttf,
FontType::Svg,
];
pub(crate) fn resolved_font_types(options: &GenerateWebfontsOptions) -> Vec<FontType> {
match &options.types {
Some(types) => types.clone(),
None => DEFAULT_FONT_TYPES.to_vec(),
}
}
#[derive(Clone)]
pub(crate) struct ResolvedGenerateWebfontsOptions {
pub ascent: Option<f64>,
pub center_horizontally: Option<bool>,
pub center_vertically: Option<bool>,
pub css: bool,
pub css_dest: String,
pub css_template: Option<String>,
pub codepoints: BTreeMap<String, u32>,
pub explicit_codepoints: BTreeMap<String, u32>,
pub css_fonts_url: Option<String>,
pub descent: Option<f64>,
pub dest: String,
pub files: Vec<String>,
pub fixed_width: Option<bool>,
pub format_options: Option<FormatOptions>,
pub html: bool,
pub incremental: bool,
pub html_dest: String,
pub html_template: Option<String>,
pub font_height: Option<f64>,
pub font_name: String,
pub font_style: Option<String>,
pub font_weight: Option<String>,
pub ligature: bool,
pub normalize: bool,
pub order: Vec<FontType>,
pub optimize_output: Option<bool>,
pub preserve_aspect_ratio: Option<bool>,
pub round: Option<f64>,
pub start_codepoint: u32,
pub template_options: Option<Map<String, Value>>,
pub types: Vec<FontType>,
pub write_files: bool,
}
#[derive(Clone)]
pub(crate) struct LoadedSvgFile {
pub contents: String,
pub glyph_name: String,
pub path: String,
}
#[derive(Clone, Default)]
pub(crate) struct RenderCache {
css_no_urls: Option<String>,
css_last_urls: Option<HashMap<FontType, String>>,
css_last_result: Option<String>,
html_no_urls: Option<String>,
html_last_urls: Option<HashMap<FontType, String>>,
html_last_result: Option<String>,
}
#[derive(Clone)]
pub(crate) struct CarriedRenderCache {
cache: RenderCache,
css_dependencies: TemplateDependencies,
html_dependencies: TemplateDependencies,
}
pub(crate) struct CachedTemplateData {
pub shared: SharedTemplateData,
pub css_context: Map<String, Value>,
pub css_hbs_context: Mutex<handlebars::Context>,
pub html_context: Map<String, Value>,
pub html_hbs_context: Mutex<handlebars::Context>,
pub html_template_dependencies: TemplateDependencies,
pub html_registry: Option<handlebars::Handlebars<'static>>,
pub(crate) render_cache: Mutex<RenderCache>,
}
#[derive(Clone, Default)]
pub(crate) struct FontOutputs {
pub(crate) svg_font: Option<Arc<String>>,
pub(crate) ttf_font: Option<Arc<Vec<u8>>>,
pub(crate) woff_font: Option<Arc<Vec<u8>>>,
pub(crate) woff2_font: Option<Arc<Vec<u8>>>,
pub(crate) eot_font: Option<Arc<Vec<u8>>>,
}
pub(crate) struct RegenerationState {
pub(crate) caches_dirty: bool,
pub(crate) glyph_cache: GlyphCache,
pub(crate) ttf_cache: Option<TtfGlyphCache>,
pub(crate) written_outputs: HashMap<String, [u8; 16]>,
}
pub(crate) struct RegenerationStateLease {
slot: Arc<Mutex<Option<RegenerationState>>>,
state: Option<RegenerationState>,
keep_caches: bool,
}
pub struct RegenerateError {
result: Option<Box<GenerateWebfontsResult>>,
source: std::io::Error,
}
impl RegenerateError {
pub(crate) fn new(result: Option<GenerateWebfontsResult>, source: std::io::Error) -> Self {
Self {
result: result.map(Box::new),
source,
}
}
pub fn into_result(self) -> Option<GenerateWebfontsResult> {
self.result.map(|result| *result)
}
pub fn into_parts(self) -> (Option<GenerateWebfontsResult>, std::io::Error) {
(self.result.map(|result| *result), self.source)
}
}
impl std::fmt::Debug for RegenerateError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RegenerateError")
.field("recoverable", &self.result.is_some())
.field("source", &self.source)
.finish()
}
}
impl std::fmt::Display for RegenerateError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.source.fmt(formatter)
}
}
impl std::error::Error for RegenerateError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
impl RegenerationStateLease {
pub(crate) fn state_mut(&mut self) -> &mut RegenerationState {
self.state.as_mut().unwrap()
}
pub(crate) fn commit(mut self) {
self.keep_caches = true;
}
}
impl Drop for RegenerationStateLease {
fn drop(&mut self) {
let mut state = self.state.take().unwrap();
if !self.keep_caches && state.caches_dirty {
state.glyph_cache = GlyphCache::default();
if state.ttf_cache.is_some() {
state.ttf_cache = Some(TtfGlyphCache::default());
}
}
state.caches_dirty = false;
*self.slot.lock().unwrap() = Some(state);
}
}
#[cfg_attr(feature = "napi", napi)]
pub struct GenerateWebfontsResult {
pub(crate) cached: OnceLock<Result<CachedTemplateData, String>>,
pub(crate) carried_render: Option<CarriedRenderCache>,
pub(crate) css_context: Option<Map<String, Value>>,
pub(crate) fonts: FontOutputs,
pub(crate) html_context: Option<Map<String, Value>>,
pub(crate) options: Arc<ResolvedGenerateWebfontsOptions>,
pub(crate) regeneration_state: Arc<Mutex<Option<RegenerationState>>>,
pub(crate) source_files: Arc<Vec<LoadedSvgFile>>,
}
impl GenerateWebfontsResult {
#[cfg(any(feature = "napi", feature = "bench"))]
pub(crate) fn snapshot_for_regeneration(&self, state: RegenerationState) -> Self {
Self {
cached: OnceLock::new(),
carried_render: self.render_cache_source(),
css_context: self.css_context.clone(),
fonts: self.fonts.clone(),
html_context: self.html_context.clone(),
options: self.options.clone(),
regeneration_state: Arc::new(Mutex::new(Some(state))),
source_files: self.source_files.clone(),
}
}
pub(crate) fn take_regeneration_state(&self) -> std::io::Result<RegenerationState> {
if !self.options.incremental {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"regenerate requires the font to be generated with `incremental` enabled.",
));
}
self.regeneration_state
.lock()
.unwrap()
.take()
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"This result is already regenerating or has been replaced.",
)
})
}
pub(crate) fn take_regeneration_state_lease(&self) -> std::io::Result<RegenerationStateLease> {
Ok(RegenerationStateLease {
slot: Arc::clone(&self.regeneration_state),
state: Some(self.take_regeneration_state()?),
keep_caches: false,
})
}
#[cfg(feature = "bench")]
pub(crate) fn restore_regeneration_state(&self, state: RegenerationState) {
*self.regeneration_state.lock().unwrap() = Some(state);
}
pub(crate) fn seed_written_outputs(&self, written_outputs: HashMap<String, [u8; 16]>) {
if let Some(state) = self.regeneration_state.lock().unwrap().as_mut() {
state.written_outputs = written_outputs;
}
}
#[doc(hidden)]
#[cfg(feature = "bench")]
pub fn regenerate_owned_for_bench(
&self,
files: &[String],
changes: &[(String, GlyphChange)],
) -> std::io::Result<Self> {
let state = self.take_regeneration_state()?;
let mut replacement = self.snapshot_for_regeneration(state);
match replacement.regenerate(files, changes) {
Ok(()) => Ok(replacement),
Err(error) => {
if let Ok(state) = replacement.take_regeneration_state() {
self.restore_regeneration_state(state);
}
Err(error)
}
}
}
#[cfg(test)]
pub(crate) fn has_carried_css_no_urls_for_test(&self) -> bool {
self.carried_render
.as_ref()
.is_some_and(|carried| carried.cache.css_no_urls.is_some())
}
#[cfg(test)]
pub(crate) fn has_carried_html_no_urls_for_test(&self) -> bool {
self.carried_render
.as_ref()
.is_some_and(|carried| carried.cache.html_no_urls.is_some())
}
pub fn eot_bytes(&self) -> Option<&[u8]> {
self.fonts.eot_font.as_ref().map(|v| v.as_ref().as_slice())
}
pub fn svg_string(&self) -> Option<&str> {
self.fonts.svg_font.as_ref().map(|v| v.as_ref().as_str())
}
pub fn ttf_bytes(&self) -> Option<&[u8]> {
self.fonts.ttf_font.as_ref().map(|v| v.as_ref().as_slice())
}
pub fn woff_bytes(&self) -> Option<&[u8]> {
self.fonts.woff_font.as_ref().map(|v| v.as_ref().as_slice())
}
pub fn woff2_bytes(&self) -> Option<&[u8]> {
self.fonts
.woff2_font
.as_ref()
.map(|v| v.as_ref().as_slice())
}
pub(crate) fn get_cached_io(&self) -> std::io::Result<&CachedTemplateData> {
self.cached
.get_or_init(|| {
let shared = SharedTemplateData::new(&self.options, &self.source_files)
.map_err(|e| e.to_string())?;
let css_context = match &self.css_context {
Some(ctx) => ctx.clone(),
None => build_css_context(&self.options, &shared),
};
let html_context = match &self.html_context {
Some(ctx) => ctx.clone(),
None => build_html_context(&self.options, &shared, &self.source_files, None)
.map_err(|e| e.to_string())?,
};
let (html_registry, html_template_dependencies) =
build_html_registry_and_dependencies(&self.options)
.map_err(|e| e.to_string())?;
let css_hbs_context =
handlebars::Context::wraps(&css_context).map_err(|e| e.to_string())?;
let html_hbs_context =
handlebars::Context::wraps(&html_context).map_err(|e| e.to_string())?;
Ok(CachedTemplateData {
shared,
css_context,
css_hbs_context: Mutex::new(css_hbs_context),
html_context,
html_hbs_context: Mutex::new(html_hbs_context),
html_template_dependencies,
html_registry,
render_cache: Mutex::new(
self.carried_render
.as_ref()
.map(|carried| carried.cache.clone())
.unwrap_or_default(),
),
})
})
.as_ref()
.map_err(to_io_err)
}
pub(crate) fn reusable_render_cache(
&self,
names_unchanged: bool,
codepoints_unchanged: bool,
) -> Option<CarriedRenderCache> {
self.render_cache_source().map(|carried| {
let css_deps = carried.css_dependencies;
let css_no_urls_unchanged =
css_deps.can_reuse_css_no_urls(names_unchanged, codepoints_unchanged);
let css_with_urls_unchanged =
css_deps.can_reuse_css_with_urls(names_unchanged, codepoints_unchanged);
let html_no_urls_unchanged = carried.html_dependencies.can_reuse_html(
names_unchanged,
codepoints_unchanged,
css_no_urls_unchanged,
);
let html_with_urls_unchanged = carried.html_dependencies.can_reuse_html(
names_unchanged,
codepoints_unchanged,
css_with_urls_unchanged,
);
let rc = carried.cache;
CarriedRenderCache {
css_dependencies: carried.css_dependencies,
html_dependencies: carried.html_dependencies,
cache: RenderCache {
css_no_urls: css_no_urls_unchanged
.then(|| rc.css_no_urls.clone())
.flatten(),
html_no_urls: html_no_urls_unchanged
.then(|| rc.html_no_urls.clone())
.flatten(),
css_last_urls: css_with_urls_unchanged
.then(|| rc.css_last_urls.clone())
.flatten(),
css_last_result: css_with_urls_unchanged
.then(|| rc.css_last_result.clone())
.flatten(),
html_last_urls: html_with_urls_unchanged
.then(|| rc.html_last_urls.clone())
.flatten(),
html_last_result: html_with_urls_unchanged
.then(|| rc.html_last_result.clone())
.flatten(),
},
}
})
}
fn render_cache_source(&self) -> Option<CarriedRenderCache> {
self.cached
.get()
.and_then(|cached| cached.as_ref().ok())
.map(|cached| CarriedRenderCache {
cache: cached.render_cache.lock().unwrap().clone(),
css_dependencies: cached.shared.css_template_dependencies,
html_dependencies: cached.html_template_dependencies,
})
.or_else(|| self.carried_render.clone())
}
pub fn generate_css_pure(
&self,
urls: Option<HashMap<FontType, String>>,
) -> std::io::Result<String> {
let cached = self.get_cached_io()?;
let mut rc = cached.render_cache.lock().unwrap();
match &urls {
None => {
if let Some(result) = &rc.css_no_urls {
return Ok(result.clone());
}
let ctx = cached.css_hbs_context.lock().unwrap();
let result =
render_css_with_hbs_context(&cached.shared, &ctx, &cached.css_context)?;
rc.css_no_urls = Some(result.clone());
Ok(result)
}
Some(urls) => {
if !cached.shared.css_template_uses_src {
drop(rc);
return self.generate_css_pure(None);
}
if rc.css_last_urls.as_ref() == Some(urls)
&& let Some(result) = &rc.css_last_result
{
return Ok(result.clone());
}
let src = make_src(&self.options, urls);
let mut ctx = cached.css_hbs_context.lock().unwrap();
let result = render_css_with_src_mutate(
&cached.shared,
&mut ctx,
&cached.css_context,
&src,
)?;
rc.css_last_urls = Some(urls.clone());
rc.css_last_result = Some(result.clone());
Ok(result)
}
}
}
pub fn generate_html_pure(
&self,
urls: Option<HashMap<FontType, String>>,
) -> std::io::Result<String> {
let cached = self.get_cached_io()?;
let mut rc = cached.render_cache.lock().unwrap();
match &urls {
None => {
if let Some(result) = &rc.html_no_urls {
return Ok(result.clone());
}
let ctx = cached.html_hbs_context.lock().unwrap();
let result = render_html_with_hbs_context(
cached.html_registry.as_ref(),
&ctx,
&cached.html_context,
)?;
rc.html_no_urls = Some(result.clone());
Ok(result)
}
Some(urls) => {
if !cached.shared.css_template_uses_src {
drop(rc);
return self.generate_html_pure(None);
}
if rc.html_last_urls.as_ref() == Some(urls)
&& let Some(result) = &rc.html_last_result
{
return Ok(result.clone());
}
let src = make_src(&self.options, urls);
let styles = {
let mut css_ctx = cached.css_hbs_context.lock().unwrap();
render_css_with_src_mutate(
&cached.shared,
&mut css_ctx,
&cached.css_context,
&src,
)?
};
if self.options.html_template.is_none() {
let result = render_default_html_with_styles(&cached.html_context, &styles);
rc.html_last_urls = Some(urls.clone());
rc.html_last_result = Some(result.clone());
return Ok(result);
}
let mut html_ctx = cached.html_hbs_context.lock().unwrap();
let registry = cached
.html_registry
.as_ref()
.expect("HTML registry should exist for custom template");
let result = crate::util::render_with_field_swap(
&mut html_ctx,
"styles",
serde_json::Value::String(styles),
|ctx| {
registry
.render_with_context("html", ctx)
.map_err(crate::util::to_io_err)
},
)?;
rc.html_last_urls = Some(urls.clone());
rc.html_last_result = Some(result.clone());
Ok(result)
}
}
}
}
#[cfg(feature = "napi")]
#[napi]
impl GenerateWebfontsResult {
#[napi(getter)]
pub fn eot(&self) -> Option<Uint8Array> {
self.fonts
.eot_font
.as_ref()
.map(|v| Uint8Array::from(v.as_ref().clone()))
}
#[napi(getter)]
pub fn svg(&self) -> Option<String> {
self.fonts.svg_font.as_ref().map(|v| v.as_ref().clone())
}
#[napi(getter)]
pub fn ttf(&self) -> Option<Uint8Array> {
self.fonts
.ttf_font
.as_ref()
.map(|v| Uint8Array::from(v.as_ref().clone()))
}
#[napi(getter)]
pub fn woff2(&self) -> Option<Uint8Array> {
self.fonts
.woff2_font
.as_ref()
.map(|v| Uint8Array::from(v.as_ref().clone()))
}
#[napi(getter)]
pub fn woff(&self) -> Option<Uint8Array> {
self.fonts
.woff_font
.as_ref()
.map(|v| Uint8Array::from(v.as_ref().clone()))
}
#[napi(ts_args_type = "urls?: Partial<Record<FontType, string>>")]
pub fn generate_css(&self, urls: Option<HashMap<String, String>>) -> napi::Result<String> {
let urls = urls.map(parse_native_urls).transpose()?;
self.generate_css_pure(urls)
.map_err(crate::util::to_napi_err)
}
#[napi(ts_args_type = "urls?: Partial<Record<FontType, string>>")]
pub fn generate_html(&self, urls: Option<HashMap<String, String>>) -> napi::Result<String> {
let urls = urls.map(parse_native_urls).transpose()?;
self.generate_html_pure(urls)
.map_err(crate::util::to_napi_err)
}
#[napi(js_name = "regenerate")]
pub fn regenerate_from_js(
&mut self,
files: Vec<String>,
changes: Option<Vec<GlyphChangeEntry>>,
) -> napi::Result<()> {
let changes = parse_glyph_changes(changes)?;
match changes {
Some(changes) => self.regenerate(&files, &changes),
None => self.regenerate_all(&files),
}
.map_err(crate::util::to_napi_err)
}
#[napi(js_name = "regenerateAsync")]
pub async fn regenerate_async_from_js(
&self,
files: Vec<String>,
changes: Option<Vec<GlyphChangeEntry>>,
) -> napi::Result<GenerateWebfontsResult> {
let changes = parse_glyph_changes(changes)?;
let state = self
.take_regeneration_state()
.map_err(crate::util::to_napi_err)?;
let original_state = Arc::clone(&self.regeneration_state);
let mut replacement = self.snapshot_for_regeneration(state);
tokio::task::spawn_blocking(move || -> std::io::Result<GenerateWebfontsResult> {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match changes {
Some(changes) => replacement.regenerate(&files, &changes),
None => replacement.regenerate_all(&files),
}));
match result {
Ok(Ok(())) => Ok(replacement),
Ok(Err(error)) => {
if let Ok(state) = replacement.take_regeneration_state() {
*original_state.lock().unwrap() = Some(state);
}
Err(error)
}
Err(payload) => {
if let Ok(state) = replacement.take_regeneration_state() {
*original_state.lock().unwrap() = Some(state);
}
std::panic::resume_unwind(payload)
}
}
})
.await
.map_err(|error| {
napi::Error::from_reason(format!("Native webfont regeneration task failed: {error}"))
})?
.map_err(crate::util::to_napi_err)
}
}
#[cfg(feature = "napi")]
fn parse_glyph_changes(
changes: Option<Vec<GlyphChangeEntry>>,
) -> napi::Result<Option<Vec<(String, GlyphChange)>>> {
changes
.map(|changes| {
changes
.into_iter()
.map(|entry| {
let change = match entry.change_type.as_str() {
"added" => GlyphChange::Added { name: entry.name },
"changed" => GlyphChange::Changed { name: entry.name },
"removed" => GlyphChange::Removed,
other => {
return Err(napi::Error::from_reason(format!(
"Unknown changeType '{other}'; expected 'added', 'changed', or 'removed'."
)));
}
};
Ok((entry.path, change))
})
.collect()
})
.transpose()
}
#[cfg(feature = "napi")]
fn parse_native_urls(urls: HashMap<String, String>) -> napi::Result<HashMap<FontType, String>> {
urls.into_iter()
.filter_map(|(font_type, url)| {
let font_type = match font_type.as_str() {
"svg" => Some(FontType::Svg),
"ttf" => Some(FontType::Ttf),
"eot" => Some(FontType::Eot),
"woff" => Some(FontType::Woff),
"woff2" => Some(FontType::Woff2),
_ => None,
}?;
Some(Ok((font_type, url)))
})
.collect::<napi::Result<HashMap<FontType, String>>>()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{finalize_generate_webfonts_options, resolve_generate_webfonts_options};
fn build_result(template: Option<&str>) -> GenerateWebfontsResult {
let fixture = crate::test_helpers::webfont_fixture("add.svg");
let mut css_template = None;
let cleanup_dir;
if let Some(content) = template {
let tmp = std::env::temp_dir().join(format!(
"render-cache-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&tmp).unwrap();
let path = tmp.join("template.hbs");
std::fs::write(&path, content).unwrap();
css_template = Some(path.to_string_lossy().into_owned());
cleanup_dir = Some(tmp);
} else {
cleanup_dir = None;
}
let options = GenerateWebfontsOptions {
css: Some(true),
css_template,
codepoints: Some(HashMap::from([("add".to_owned(), 0xE001u32)])),
dest: "artifacts".to_owned(),
files: vec![fixture],
html: Some(false),
font_name: Some("iconfont".to_owned()),
ligature: Some(false),
order: Some(vec![FontType::Svg]),
start_codepoint: Some(0xE001),
types: Some(vec![FontType::Svg]),
..Default::default()
};
let mut resolved = resolve_generate_webfonts_options(options).unwrap();
let source_files: Vec<LoadedSvgFile> = resolved
.files
.iter()
.map(|path| LoadedSvgFile {
contents: std::fs::read_to_string(path).unwrap(),
glyph_name: std::path::Path::new(path)
.file_stem()
.unwrap()
.to_str()
.unwrap()
.to_owned(),
path: path.clone(),
})
.collect();
finalize_generate_webfonts_options(&mut resolved, &source_files).unwrap();
let result = GenerateWebfontsResult {
cached: std::sync::OnceLock::new(),
carried_render: None,
css_context: None,
fonts: FontOutputs::default(),
html_context: None,
options: Arc::new(resolved),
regeneration_state: Arc::new(Mutex::new(None)),
source_files: Arc::new(source_files),
};
if let Some(dir) = cleanup_dir {
std::mem::forget(dir);
}
result
}
#[test]
fn generate_css_returns_cached_result_on_repeated_calls_without_urls() {
let result = build_result(None);
let first = result.generate_css_pure(None).unwrap();
let second = result.generate_css_pure(None).unwrap();
assert_eq!(first, second);
assert!(!first.is_empty());
}
#[test]
fn generate_css_returns_cached_result_on_repeated_calls_with_same_urls() {
let result = build_result(None);
let urls = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
let first = result.generate_css_pure(Some(urls.clone())).unwrap();
let second = result.generate_css_pure(Some(urls)).unwrap();
assert_eq!(first, second);
assert!(first.contains("/a.svg"));
}
#[test]
fn generate_css_returns_different_result_for_different_urls() {
let result = build_result(None);
let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
let result_a = result.generate_css_pure(Some(urls_a)).unwrap();
let result_b = result.generate_css_pure(Some(urls_b)).unwrap();
assert_ne!(result_a, result_b);
assert!(result_a.contains("/a.svg"));
assert!(result_b.contains("/b.svg"));
}
#[test]
fn generate_css_cache_updates_when_urls_change() {
let result = build_result(None);
let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
let first_a = result.generate_css_pure(Some(urls_a.clone())).unwrap();
let first_b = result.generate_css_pure(Some(urls_b)).unwrap();
let second_a = result.generate_css_pure(Some(urls_a)).unwrap();
assert_eq!(
first_a, second_a,
"returning to original urls should produce same result"
);
assert_ne!(first_a, first_b);
}
#[test]
fn generate_css_cache_works_with_custom_template() {
let result = build_result(Some("@font-face { src: {{{src}}}; }"));
let urls = HashMap::from([(FontType::Svg, "/cached.svg".to_owned())]);
let first = result.generate_css_pure(Some(urls.clone())).unwrap();
let second = result.generate_css_pure(Some(urls)).unwrap();
assert_eq!(first, second);
assert!(first.contains("/cached.svg"));
}
#[test]
fn generate_css_no_urls_and_with_urls_are_independent_caches() {
let result = build_result(None);
let urls = HashMap::from([(FontType::Svg, "/custom.svg".to_owned())]);
let no_urls = result.generate_css_pure(None).unwrap();
let with_urls = result.generate_css_pure(Some(urls)).unwrap();
let no_urls_again = result.generate_css_pure(None).unwrap();
assert_eq!(
no_urls, no_urls_again,
"no-urls cache should survive a with-urls call"
);
assert_ne!(no_urls, with_urls);
}
#[test]
fn generate_css_with_urls_returns_no_urls_result_when_template_does_not_use_src() {
let result = build_result(Some(".icon { font-family: {{fontName}}; }"));
let urls = HashMap::from([(FontType::Svg, "/should-not-appear.svg".to_owned())]);
let no_urls = result.generate_css_pure(None).unwrap();
let with_urls = result.generate_css_pure(Some(urls)).unwrap();
assert_eq!(
no_urls, with_urls,
"template without {{src}} should ignore urls"
);
assert!(!with_urls.contains("/should-not-appear.svg"));
assert!(
with_urls.contains("iconfont"),
"should still render the template"
);
}
#[test]
fn generate_html_with_urls_returns_no_urls_result_when_css_template_does_not_use_src() {
let result = build_result(Some(".icon { font-family: {{fontName}}; }"));
let urls = HashMap::from([(FontType::Svg, "/should-not-appear.svg".to_owned())]);
let no_urls = result.generate_html_pure(None).unwrap();
let with_urls = result.generate_html_pure(Some(urls)).unwrap();
assert_eq!(
no_urls, with_urls,
"CSS template without {{src}} means HTML is also unaffected by urls"
);
}
#[test]
fn generate_css_without_urls_produces_valid_css_using_css_fonts_url() {
let result = build_result(None);
let css = result.generate_css_pure(None).unwrap();
assert!(
css.contains("@font-face"),
"should contain @font-face declaration"
);
assert!(css.contains("font-family:"), "should contain font-family");
assert!(
css.contains("iconfont.svg?"),
"should use font name in URL with hash"
);
assert!(
css.contains("format(\"svg\")"),
"should contain format declaration"
);
assert!(
css.contains("content:"),
"should contain codepoint content rules"
);
}
#[test]
fn generate_css_with_urls_replaces_default_urls_in_src() {
let result = build_result(None);
let urls = HashMap::from([(FontType::Svg, "/cdn/icons.svg".to_owned())]);
let css = result.generate_css_pure(Some(urls)).unwrap();
assert!(
css.contains("/cdn/icons.svg"),
"custom URL should appear in output"
);
assert!(
!css.contains("iconfont.svg?"),
"default hash-based URL should not appear"
);
assert!(
css.contains("format(\"svg\")"),
"format should still be present"
);
}
#[test]
fn generate_html_without_urls_produces_valid_html() {
let result = build_result(None);
let html = result.generate_html_pure(None).unwrap();
assert!(
html.contains("<!DOCTYPE html>"),
"should be a full HTML document"
);
assert!(html.contains("iconfont"), "should contain font name");
assert!(html.contains("icon-add"), "should contain icon class name");
}
#[test]
fn generate_html_with_urls_embeds_css_using_custom_urls() {
let result = build_result(None);
let urls = HashMap::from([(FontType::Svg, "/cdn/icons.svg".to_owned())]);
let html = result.generate_html_pure(Some(urls)).unwrap();
assert!(
html.contains("/cdn/icons.svg"),
"custom URL should appear in embedded CSS"
);
assert!(
html.contains("icon-add"),
"should still contain icon class name"
);
}
#[test]
fn generate_html_cache_returns_same_result_for_same_urls() {
let result = build_result(None);
let urls = HashMap::from([(FontType::Svg, "/cached.svg".to_owned())]);
let first = result.generate_html_pure(Some(urls.clone())).unwrap();
let second = result.generate_html_pure(Some(urls)).unwrap();
assert_eq!(first, second);
assert!(first.contains("/cached.svg"));
}
#[test]
fn generate_html_cache_returns_different_result_for_different_urls() {
let result = build_result(None);
let urls_a = HashMap::from([(FontType::Svg, "/a.svg".to_owned())]);
let urls_b = HashMap::from([(FontType::Svg, "/b.svg".to_owned())]);
let result_a = result.generate_html_pure(Some(urls_a)).unwrap();
let result_b = result.generate_html_pure(Some(urls_b)).unwrap();
assert_ne!(result_a, result_b);
assert!(result_a.contains("/a.svg"));
assert!(result_b.contains("/b.svg"));
}
fn build_multi_type_result() -> GenerateWebfontsResult {
let fixture = crate::test_helpers::webfont_fixture("add.svg");
let options = GenerateWebfontsOptions {
css: Some(true),
codepoints: Some(HashMap::from([("add".to_owned(), 0xE001u32)])),
dest: "artifacts".to_owned(),
files: vec![fixture],
html: Some(true),
font_name: Some("iconfont".to_owned()),
ligature: Some(false),
order: Some(vec![FontType::Woff2, FontType::Svg]),
start_codepoint: Some(0xE001),
types: Some(vec![FontType::Svg, FontType::Woff2]),
..Default::default()
};
let mut resolved = resolve_generate_webfonts_options(options).unwrap();
let source_files: Vec<LoadedSvgFile> = resolved
.files
.iter()
.map(|path| LoadedSvgFile {
contents: std::fs::read_to_string(path).unwrap(),
glyph_name: std::path::Path::new(path)
.file_stem()
.unwrap()
.to_str()
.unwrap()
.to_owned(),
path: path.clone(),
})
.collect();
finalize_generate_webfonts_options(&mut resolved, &source_files).unwrap();
GenerateWebfontsResult {
cached: std::sync::OnceLock::new(),
carried_render: None,
css_context: None,
fonts: FontOutputs::default(),
html_context: None,
options: Arc::new(resolved),
regeneration_state: Arc::new(Mutex::new(None)),
source_files: Arc::new(source_files),
}
}
#[test]
fn generate_css_partial_urls_uses_empty_string_for_missing_types() {
let result = build_multi_type_result();
let urls = HashMap::from([(FontType::Woff2, "/cdn/font.woff2".to_owned())]);
let css = result.generate_css_pure(Some(urls)).unwrap();
assert!(
css.contains("/cdn/font.woff2"),
"overridden URL should appear"
);
assert!(
!css.contains("iconfont.svg?"),
"non-overridden type should not have default hash-based URL"
);
assert!(
css.contains("url(\"#iconfont\")"),
"non-overridden SVG type should produce empty base URL (upstream compat)"
);
}
#[test]
fn generate_html_partial_urls_uses_empty_string_for_missing_types() {
let result = build_multi_type_result();
let urls = HashMap::from([(FontType::Woff2, "/cdn/font.woff2".to_owned())]);
let html = result.generate_html_pure(Some(urls)).unwrap();
assert!(
html.contains("/cdn/font.woff2"),
"overridden URL should appear in HTML"
);
assert!(
!html.contains("iconfont.svg?"),
"non-overridden type should not have default hash-based URL in HTML"
);
}
}