Skip to main content

perspective_viewer/ui/
font_loader.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
13use std::cell::{Cell, Ref, RefCell};
14use std::future::Future;
15use std::rc::Rc;
16
17use futures::future::{join_all, select_all};
18use perspective_js::utils::{global, *};
19use wasm_bindgen::prelude::*;
20use wasm_bindgen_futures::JsFuture;
21use yew::prelude::*;
22
23use crate::utils::*;
24
25const FONT_TEST_SAMPLE: &str = "ABCDΔ";
26
27const FONT_DOWNLOAD_TIMEOUT_MS: i32 = 1000;
28
29#[derive(Clone, Properties)]
30pub struct FontLoaderProps {
31    state: Rc<FontLoaderState>,
32}
33
34impl PartialEq for FontLoaderProps {
35    fn eq(&self, _rhs: &Self) -> bool {
36        false
37    }
38}
39
40#[function_component(FontLoader)]
41pub fn font_loader(props: &FontLoaderProps) -> Html {
42    if matches!(props.get_status(), FontLoaderStatus::Finished) {
43        html! {}
44    } else {
45        let inner = props
46            .get_fonts()
47            .iter()
48            .map(font_test_html)
49            .collect::<Html>();
50
51        html! { <><style>{ ":host{opacity:0!important;}" }</style>{ inner }</> }
52    }
53}
54
55/// The possible font loading state, which proceeds from top to bottom once per
56/// `<perspective-viewer>` element.
57#[derive(Clone, Copy)]
58pub enum FontLoaderStatus {
59    Uninitialized,
60    Loading,
61    Finished,
62}
63
64struct FontLoaderState {
65    status: Cell<FontLoaderStatus>,
66    elem: web_sys::HtmlElement,
67    on_update: Callback<()>,
68    fonts: RefCell<Vec<(String, String)>>,
69}
70
71type PromiseSet = Vec<ApiFuture<JsValue>>;
72
73impl FontLoaderProps {
74    pub fn new(elem: &web_sys::HtmlElement, on_update: Callback<()>) -> Self {
75        let inner = FontLoaderState {
76            status: Cell::new(FontLoaderStatus::Uninitialized),
77            elem: elem.clone(),
78            on_update,
79            fonts: RefCell::new(vec![]),
80        };
81
82        let state = yew::props!(Self {
83            state: Rc::new(inner)
84        });
85
86        ApiFuture::spawn(state.clone().load_fonts_task_safe());
87        state
88    }
89
90    pub fn get_status(&self) -> FontLoaderStatus {
91        self.state.status.get()
92    }
93
94    fn get_fonts(&'_ self) -> Ref<'_, Vec<(String, String)>> {
95        self.state.fonts.borrow()
96    }
97
98    async fn load_fonts_task_safe(self) -> ApiResult<JsValue> {
99        if let Err(msg) = self.load_fonts_task().await {
100            web_sys::console::warn_1(&msg.into());
101        };
102
103        Ok(JsValue::UNDEFINED)
104    }
105
106    async fn load_fonts_task(self) -> ApiResult<JsValue> {
107        await_dom_loaded().await?;
108        let txt = global::window()
109            .get_computed_style(&self.state.elem)?
110            .unwrap()
111            .get_property_value("--preload-fonts")?;
112
113        let mut block_promises: PromiseSet = vec![];
114        let preload_fonts = parse_fonts(&txt);
115        self.state.fonts.borrow_mut().clone_from(&preload_fonts);
116        self.state.status.set(FontLoaderStatus::Loading);
117        self.state.on_update.emit(());
118
119        for (family, weight) in preload_fonts.iter() {
120            let block_fonts: PromiseSet = vec![
121                ApiFuture::new(timeout_font_task(family, weight)),
122                ApiFuture::new(await_font(family.clone(), weight.clone())),
123            ];
124
125            let fut = async { select_all(block_fonts).await.0 };
126            block_promises.push(ApiFuture::new(fut))
127        }
128
129        let res = join_all(block_promises)
130            .await
131            .into_iter()
132            .collect::<ApiResult<Vec<JsValue>>>()
133            .map(|_| JsValue::UNDEFINED);
134
135        self.state.status.set(FontLoaderStatus::Finished);
136        self.state.on_update.emit(());
137        res
138    }
139}
140
141/// Resolve once the `@font-face`s needed to render [`FONT_TEST_SAMPLE`] in
142/// `(family, weight)` are loaded — `FontFaceSet.load(spec, text)` both
143/// triggers and awaits exactly those SUBSET faces, never a sibling
144/// unicode-range face the page may never use. It resolves EMPTY while the
145/// family has no registration at all (its stylesheet may still be fetching,
146/// and Firefox registers lazily-parsed faces later than Chrome), so poll.
147async fn await_font(family: String, weight: String) -> ApiResult<JsValue> {
148    let spec = format!("{weight} 12px \"{family}\"");
149    loop {
150        let fonts = global::document().fonts();
151        let loaded = JsFuture::from(fonts.load_with_text(&spec, FONT_TEST_SAMPLE)).await?;
152        if js_sys::Array::from(&loaded).length() > 0 {
153            return Ok(JsValue::UNDEFINED);
154        }
155
156        set_timeout(100).await?;
157    }
158}
159
160fn timeout_font_task(
161    family: &str,
162    weight: &str,
163) -> impl Future<Output = ApiResult<JsValue>> + use<> {
164    let timeout_msg = format!("Timeout awaiting font \"{family}:{weight}\"");
165    async {
166        set_timeout(FONT_DOWNLOAD_TIMEOUT_MS).await?;
167        Err(timeout_msg.into())
168    }
169}
170
171fn font_test_html((family, weight): &(String, String)) -> Html {
172    let style = format!("opacity:0;font-family:\"{family}\";font-weight:{weight}");
173
174    html! { <span {style}>{ FONT_TEST_SAMPLE }</span> }
175}
176
177fn parse_font(txt: &str) -> Option<Vec<(String, String)>> {
178    match *txt.trim().split(':').collect::<Vec<_>>().as_slice() {
179        [family, weights] => Some(
180            weights
181                .split(',')
182                .map(|weight| (family.to_owned(), weight.to_owned()))
183                .collect::<Vec<_>>(),
184        ),
185        _ => None,
186    }
187}
188
189fn parse_fonts(txt: &str) -> Vec<(String, String)> {
190    let trim = txt.trim();
191    let trim = if trim.len() > 2 {
192        &trim[1..trim.len() - 1]
193    } else {
194        trim
195    };
196
197    trim.split(';')
198        .filter_map(parse_font)
199        .flatten()
200        .collect::<Vec<_>>()
201}