Skip to main content

perspective_viewer/utils/
local_fonts.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13//! Page-global Local Font Access state shared by every `Font`
14//! control on the page: the `local-fonts` permission, the enumerated
15//! family list once granted, and a measured web-safe fallback list
16//! otherwise.
17
18use 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
28/// CSS generic families always offered first, in this order.
29pub 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/// The `local-fonts` permission as last observed.
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum LocalFontsAccess {
69    /// No `window.queryLocalFonts` (non-Chromium, or insecure context).
70    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
90/// The permission state, or `None` before [`probe_local_fonts_access`]
91/// has resolved.
92pub fn local_fonts_access() -> Option<LocalFontsAccess> {
93    STATE.with(|s| s.access.get())
94}
95
96/// Subscribe to any change in access state or family list.
97pub fn on_local_fonts_changed(cb: Callback<()>) -> Subscription {
98    STATE.with(|s| s.changed.add_notify_listener(&cb))
99}
100
101/// The options a `Font` control offers for `current`: the generic
102/// keywords, then the enumerated families when granted (and non-empty) or
103/// the measured fallback otherwise, then `current` itself if absent.
104pub 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
134/// Resolve the permission state once, without ever showing a prompt.
135pub 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
167/// Ask the browser for local fonts, synchronously from a user-gesture handler
168/// so `queryLocalFonts()` may prompt.
169pub 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
236/// The enumerated families, or `None` when the call yielded none, which
237/// Chromium resolves in place of rejecting a dismissed or blocked prompt.
238async 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
316/// The subset of [`FALLBACK_FONT_FAMILIES`] the browser can render,
317/// detected by comparing rendered widths against the generic baselines.
318fn 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}