perspective_viewer/utils/
local_fonts.rs1use std::cell::{Cell, RefCell};
19use std::rc::Rc;
20
21use perspective_js::utils::global;
22use wasm_bindgen::prelude::*;
23use wasm_bindgen_futures::JsFuture;
24use yew::Callback;
25
26use super::{PubSub, Subscription};
27
28pub const GENERIC_FONT_FAMILIES: [&str; 5] =
30 ["inherit", "monospace", "sans-serif", "serif", "system-ui"];
31
32const FALLBACK_FONT_FAMILIES: [&str; 28] = [
33 "Arial",
34 "Arial Black",
35 "Calibri",
36 "Cambria",
37 "Comic Sans MS",
38 "Consolas",
39 "Courier New",
40 "DejaVu Sans",
41 "DejaVu Serif",
42 "Georgia",
43 "Helvetica",
44 "Helvetica Neue",
45 "Impact",
46 "Liberation Sans",
47 "Liberation Serif",
48 "Lucida Console",
49 "Menlo",
50 "Monaco",
51 "Noto Sans",
52 "Noto Serif",
53 "Roboto",
54 "SF Mono",
55 "Segoe UI",
56 "Tahoma",
57 "Times New Roman",
58 "Trebuchet MS",
59 "Ubuntu",
60 "Verdana",
61];
62
63const FONT_TEST_SAMPLE: &str = "mmmmmmmmmmlli ABCDΔ 0123";
64const FONT_TEST_BASELINES: [&str; 3] = ["monospace", "sans-serif", "serif"];
65
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum LocalFontsAccess {
69 Unsupported,
71 Prompt,
72 Granted,
73 Denied,
74}
75
76#[derive(Default)]
77struct LocalFontsState {
78 access: Cell<Option<LocalFontsAccess>>,
79 probing: Cell<bool>,
80 families: RefCell<Option<Rc<Vec<String>>>>,
81 fallback: RefCell<Option<Rc<Vec<String>>>>,
82 on_status_change: RefCell<Option<Closure<dyn Fn()>>>,
83 changed: PubSub<()>,
84}
85
86thread_local! {
87 static STATE: LocalFontsState = LocalFontsState::default();
88}
89
90pub fn local_fonts_access() -> Option<LocalFontsAccess> {
93 STATE.with(|s| s.access.get())
94}
95
96pub fn on_local_fonts_changed(cb: Callback<()>) -> Subscription {
98 STATE.with(|s| s.changed.add_notify_listener(&cb))
99}
100
101pub fn font_family_options(current: &str) -> Vec<String> {
105 let installed = STATE.with(|s| {
106 let enumerated = match s.access.get() {
107 Some(LocalFontsAccess::Granted) => s.families.borrow().clone(),
108 _ => None,
109 };
110
111 match enumerated {
112 Some(families) if !families.is_empty() => families,
113 _ => fallback_families(s),
114 }
115 });
116
117 compose_font_family_options(current, &installed)
118}
119
120fn compose_font_family_options(current: &str, installed: &[String]) -> Vec<String> {
121 let mut out: Vec<String> = GENERIC_FONT_FAMILIES
122 .iter()
123 .map(|x| (*x).to_owned())
124 .collect();
125
126 out.extend(installed.iter().cloned());
127 if !out.iter().any(|x| x == current) {
128 out.push(current.to_owned());
129 }
130
131 out
132}
133
134pub fn probe_local_fonts_access() {
136 let already = STATE.with(|s| s.access.get().is_some() || s.probing.replace(true));
137 if already {
138 return;
139 }
140
141 if query_local_fonts_fn().is_none() {
142 set_access(LocalFontsAccess::Unsupported);
143 return;
144 }
145
146 wasm_bindgen_futures::spawn_local(async move {
147 match query_permission_status().await {
148 Some(status) => {
149 install_status_listener(&status);
150 let access = access_of_status(&status);
151 set_access(access);
152 if access == LocalFontsAccess::Granted {
153 load_families().await;
154 }
155 },
156 None => match call_query_local_fonts() {
157 Some(promise) => match families_from(promise).await {
158 Some(families) => set_granted(families),
159 None => set_access(LocalFontsAccess::Prompt),
160 },
161 None => set_access(LocalFontsAccess::Unsupported),
162 },
163 }
164 });
165}
166
167pub fn request_local_fonts_access() {
170 let Some(promise) = call_query_local_fonts() else {
171 set_access(LocalFontsAccess::Unsupported);
172 return;
173 };
174
175 wasm_bindgen_futures::spawn_local(async move {
176 match families_from(promise).await {
177 Some(families) => set_granted(families),
178 None => {
179 let access = match query_permission_status().await {
180 Some(status) => access_of_status(&status),
181 None => LocalFontsAccess::Prompt,
182 };
183
184 set_access(access);
185 },
186 }
187 });
188}
189
190fn set_access(access: LocalFontsAccess) {
191 STATE.with(|s| {
192 s.probing.set(false);
193 let prev = s.access.replace(Some(access));
194 if access != LocalFontsAccess::Granted {
195 s.families.borrow_mut().take();
196 }
197
198 if prev != Some(access) {
199 s.changed.emit(());
200 }
201 });
202}
203
204fn set_granted(families: Vec<String>) {
205 STATE.with(|s| {
206 s.probing.set(false);
207 s.access.set(Some(LocalFontsAccess::Granted));
208 *s.families.borrow_mut() = Some(Rc::new(families));
209 s.changed.emit(());
210 });
211}
212
213async fn load_families() {
214 if let Some(promise) = call_query_local_fonts()
215 && let Some(families) = families_from(promise).await
216 {
217 set_granted(families);
218 }
219}
220
221fn query_local_fonts_fn() -> Option<js_sys::Function> {
222 js_sys::Reflect::get(&global::window(), &JsValue::from_str("queryLocalFonts"))
223 .ok()?
224 .dyn_into::<js_sys::Function>()
225 .ok()
226}
227
228fn call_query_local_fonts() -> Option<js_sys::Promise> {
229 query_local_fonts_fn()?
230 .call0(&global::window())
231 .ok()?
232 .dyn_into::<js_sys::Promise>()
233 .ok()
234}
235
236async fn families_from(promise: js_sys::Promise) -> Option<Vec<String>> {
239 let fonts = JsFuture::from(promise).await.ok()?;
240 let family_key = JsValue::from_str("family");
241 let mut families: Vec<String> = js_sys::Array::from(&fonts)
242 .iter()
243 .filter_map(|font| js_sys::Reflect::get(&font, &family_key).ok()?.as_string())
244 .collect();
245
246 families.sort_unstable();
247 families.dedup();
248 (!families.is_empty()).then_some(families)
249}
250
251async fn query_permission_status() -> Option<JsValue> {
252 let navigator: JsValue = global::window().navigator().into();
253 let permissions = js_sys::Reflect::get(&navigator, &JsValue::from_str("permissions")).ok()?;
254 let query = js_sys::Reflect::get(&permissions, &JsValue::from_str("query"))
255 .ok()?
256 .dyn_into::<js_sys::Function>()
257 .ok()?;
258
259 let descriptor = js_sys::Object::new();
260 js_sys::Reflect::set(
261 &descriptor,
262 &JsValue::from_str("name"),
263 &JsValue::from_str("local-fonts"),
264 )
265 .ok()?;
266
267 let promise = query
268 .call1(&permissions, &descriptor)
269 .ok()?
270 .dyn_into::<js_sys::Promise>()
271 .ok()?;
272
273 let status = JsFuture::from(promise).await.ok()?;
274 (!status.is_undefined() && !status.is_null()).then_some(status)
275}
276
277fn access_of_status(status: &JsValue) -> LocalFontsAccess {
278 let state = js_sys::Reflect::get(status, &JsValue::from_str("state"))
279 .ok()
280 .and_then(|x| x.as_string());
281
282 match state.as_deref() {
283 Some("granted") => LocalFontsAccess::Granted,
284 Some("denied") => LocalFontsAccess::Denied,
285 _ => LocalFontsAccess::Prompt,
286 }
287}
288
289fn install_status_listener(status: &JsValue) {
290 let target = status.clone();
291 let closure = Closure::<dyn Fn()>::new(move || {
292 let access = access_of_status(&target);
293 set_access(access);
294 if access == LocalFontsAccess::Granted {
295 wasm_bindgen_futures::spawn_local(load_families());
296 }
297 });
298
299 let _ = js_sys::Reflect::set(
300 status,
301 &JsValue::from_str("onchange"),
302 closure.as_ref().unchecked_ref(),
303 );
304
305 STATE.with(|s| *s.on_status_change.borrow_mut() = Some(closure));
306}
307
308fn fallback_families(state: &LocalFontsState) -> Rc<Vec<String>> {
309 state
310 .fallback
311 .borrow_mut()
312 .get_or_insert_with(|| Rc::new(detect_fallback_families()))
313 .clone()
314}
315
316fn detect_fallback_families() -> Vec<String> {
319 let Some(ctx) = canvas_context() else {
320 return vec![];
321 };
322
323 let baselines: Vec<f64> = FONT_TEST_BASELINES
324 .iter()
325 .map(|x| measure_width(&ctx, x))
326 .collect();
327
328 FALLBACK_FONT_FAMILIES
329 .iter()
330 .filter(|family| {
331 FONT_TEST_BASELINES
332 .iter()
333 .zip(&baselines)
334 .any(|(baseline, width)| {
335 measure_width(&ctx, &format!("\"{family}\", {baseline}")) != *width
336 })
337 })
338 .map(|x| (*x).to_owned())
339 .collect()
340}
341
342fn canvas_context() -> Option<web_sys::CanvasRenderingContext2d> {
343 global::document()
344 .create_element("canvas")
345 .ok()?
346 .dyn_into::<web_sys::HtmlCanvasElement>()
347 .ok()?
348 .get_context("2d")
349 .ok()??
350 .dyn_into::<web_sys::CanvasRenderingContext2d>()
351 .ok()
352}
353
354fn measure_width(ctx: &web_sys::CanvasRenderingContext2d, family: &str) -> f64 {
355 ctx.set_font(&format!("72px {family}"));
356 ctx.measure_text(FONT_TEST_SAMPLE)
357 .map(|x| x.width())
358 .unwrap_or_default()
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 fn owned(xs: &[&str]) -> Vec<String> {
366 xs.iter().map(|x| (*x).to_owned()).collect()
367 }
368
369 #[test]
370 fn generic_families_lead_and_current_appends_when_missing() {
371 let options = compose_font_family_options("Zapf Chancery", &owned(&["Arial", "Menlo"]));
372 assert_eq!(
373 options,
374 owned(&[
375 "inherit",
376 "monospace",
377 "sans-serif",
378 "serif",
379 "system-ui",
380 "Arial",
381 "Menlo",
382 "Zapf Chancery"
383 ])
384 );
385 }
386
387 #[test]
388 fn current_is_not_duplicated() {
389 let options = compose_font_family_options("Arial", &owned(&["Arial"]));
390 assert_eq!(options.iter().filter(|x| *x == "Arial").count(), 1);
391
392 let options = compose_font_family_options("inherit", &[]);
393 assert_eq!(options, owned(&GENERIC_FONT_FAMILIES));
394 }
395}