1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
use std::collections::HashMap;
use std::sync::Arc;
use napi::bindgen_prelude::Uint8Array;
use napi_derive::napi;
use super::GenerateWebfontsResult;
use crate::types::{FontType, GlyphChange, GlyphChangeEntry};
#[napi]
impl GenerateWebfontsResult {
/// EOT font bytes, or `null` if EOT was not in `types`.
#[napi(getter)]
pub fn eot(&self) -> Option<Uint8Array> {
self.fonts
.eot_font
.as_ref()
.map(|v| Uint8Array::from(v.as_ref().clone()))
}
/// SVG font XML string, or `null` if SVG was not in `types`.
#[napi(getter)]
pub fn svg(&self) -> Option<String> {
self.fonts.svg_font.as_ref().map(|v| v.as_ref().clone())
}
/// TTF font bytes, or `null` if TTF was not in `types`.
#[napi(getter)]
pub fn ttf(&self) -> Option<Uint8Array> {
self.fonts
.ttf_font
.as_ref()
.map(|v| Uint8Array::from(v.as_ref().clone()))
}
/// WOFF2 font bytes, or `null` if WOFF2 was not in `types`.
#[napi(getter)]
pub fn woff2(&self) -> Option<Uint8Array> {
self.fonts
.woff2_font
.as_ref()
.map(|v| Uint8Array::from(v.as_ref().clone()))
}
/// WOFF font bytes, or `null` if WOFF was not in `types`.
#[napi(getter)]
pub fn woff(&self) -> Option<Uint8Array> {
self.fonts
.woff_font
.as_ref()
.map(|v| Uint8Array::from(v.as_ref().clone()))
}
/// Render the CSS string for this result. Pass `urls` to override the
/// default font URLs in the `@font-face src:` descriptor. A supplied map is a complete
/// override; omitted formats use empty URLs. The result is cached per `urls` value, so
/// repeated calls with the same input are cheap.
#[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(to_napi_err)
}
/// Render the HTML preview string for this result. Pass `urls` to
/// override font URLs in the embedded stylesheet. A supplied map is a complete override;
/// omitted formats use empty URLs. The result is cached per `urls` value.
#[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(to_napi_err)
}
/// Rebuild the font after a batch of file changes, reusing cached glyph geometry for files
/// whose contents are unchanged. Requires the font to have been generated with
/// `incremental: true`. Supply `{ files: [...] }` for an ordinary font or
/// `{ variants: [{ variant, files }, ...] }` with every configured design for a family.
/// Each list is the complete file set after the changes, in the order a
/// fresh build would use (e.g. the glob result) — the rebuilt glyphs are ordered to match it,
/// so the output bytes are identical to a fresh `generateWebfonts` of that set. `changes`
/// describes the affected files: added/changed files are re-read from disk; any file absent
/// from `files` is dropped. Omit `changes` to re-read/hash every current file and infer
/// added/changed/removed paths from `files`. Every requested format is refreshed in memory,
/// and — like `generateWebfonts` — when the result was built with `writeFiles` enabled the
/// refreshed fonts are written to disk too, while CSS/HTML companion files are skipped if their
/// rendered bytes are unchanged since the last write.
#[napi(js_name = "regenerate")]
pub fn regenerate_from_js(
&mut self,
files: crate::types::RegenerationFileOptions,
changes: Option<Vec<GlyphChangeEntry>>,
) -> napi::Result<()> {
let files = parse_regeneration_files(files)?;
let changes = parse_glyph_changes(changes)?;
match changes {
Some(changes) => self.regenerate(&files, &changes),
None => self.regenerate_all(&files),
}
.map_err(to_napi_err)
}
/// Rebuild off the Node.js event loop and resolve with a replacement result. The receiver
/// remains readable and unchanged while regeneration runs and after failure. Assign the
/// resolved result before starting another regeneration. Overlapping calls on the same result
/// lineage are rejected, and disk writes remain non-transactional.
#[napi(js_name = "regenerateAsync")]
pub async fn regenerate_async_from_js(
&self,
files: crate::types::RegenerationFileOptions,
changes: Option<Vec<GlyphChangeEntry>>,
) -> napi::Result<GenerateWebfontsResult> {
let files = parse_regeneration_files(files)?;
let changes = parse_glyph_changes(changes)?;
let state = self.take_regeneration_state().map_err(to_napi_err)?;
let original_sources = Arc::clone(&self.source_files);
let is_variant = self.options.variants.is_some();
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),
}));
if !matches!(result, Ok(Ok(()))) {
let mut state = replacement.take_regeneration_state().ok();
if let Some(state) = state.as_mut()
&& is_variant
&& !Arc::ptr_eq(&original_sources, &replacement.source_files)
{
// Authoritative sources belong to the receiver, not this movable cache
// state. Discard caches for the failed replacement's committed sources.
state.variant_cache = None;
// The write map describes actual successful writes, including partial
// ones. A no-op retry must reconcile disk output with the old receiver.
state.variant_write_pending = replacement.options.write_files;
}
*original_state.lock().unwrap() = state;
}
match result {
Ok(Ok(())) => Ok(replacement),
Ok(Err(error)) => Err(error),
Err(payload) => std::panic::resume_unwind(payload),
}
})
.await
.map_err(|error| {
napi::Error::from_reason(format!("Native webfont regeneration task failed: {error}"))
})?
.map_err(to_napi_err)
}
}
fn parse_regeneration_files(
input: crate::types::RegenerationFileOptions,
) -> napi::Result<crate::RegenerationFiles> {
match (input.files, input.variants) {
(Some(files), None) => Ok(crate::RegenerationFiles::Single(files)),
(None, Some(variants)) => Ok(crate::RegenerationFiles::Variants(variants)),
_ => Err(to_napi_err(
"Regeneration input requires exactly one of files or variants.",
)),
}
}
pub(crate) fn to_napi_err(error: impl std::fmt::Display) -> napi::Error {
napi::Error::new(napi::Status::GenericFailure, error.to_string())
}
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()
}
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)]
#[path = "napi/tests.rs"]
mod tests;