Skip to main content

perspective_viewer/components/
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::iter::{Iterator, repeat_with};
16use std::rc::Rc;
17
18use futures::future::{join_all, select_all};
19use perspective_js::utils::{global, *};
20use wasm_bindgen::prelude::*;
21use wasm_bindgen::{JsCast, intern};
22use wasm_bindgen_futures::JsFuture;
23use yew::prelude::*;
24
25use crate::utils::*;
26
27/// This test string is injected into the DOM with the target `font-family`
28/// applied. It is important for this string to contain the correct unicode
29/// range, as otherwise the browser may download the latin-only variant of the
30/// font which will later be invalidated.
31const FONT_TEST_SAMPLE: &str = "ABCDΔ";
32
33/// How long to wait for the test string to receive the font before proceeding
34/// anyway (with a consoel warning).
35const FONT_DOWNLOAD_TIMEOUT_MS: i32 = 1000;
36
37/// `state` is private to force construction of props with the `::new()` static
38/// method, which initializes the async `load_fonts_task()` method.
39#[derive(Clone, Properties)]
40pub struct FontLoaderProps {
41    state: Rc<FontLoaderState>,
42}
43
44impl PartialEq for FontLoaderProps {
45    fn eq(&self, _rhs: &Self) -> bool {
46        false
47    }
48}
49
50/// The `FontLoader` component ensures that fonts are loaded before they are
51/// visible.
52pub struct FontLoader {}
53
54impl Component for FontLoader {
55    type Message = ();
56    type Properties = FontLoaderProps;
57
58    fn create(_ctx: &Context<Self>) -> Self {
59        Self {}
60    }
61
62    fn update(&mut self, _ctx: &Context<Self>, _msg: ()) -> bool {
63        false
64    }
65
66    fn view(&self, ctx: &Context<Self>) -> yew::virtual_dom::VNode {
67        if matches!(ctx.props().get_status(), FontLoaderStatus::Finished) {
68            html! {}
69        } else {
70            let inner = ctx
71                .props()
72                .get_fonts()
73                .iter()
74                .map(font_test_html)
75                .collect::<Html>();
76
77            html! { <><style>{ ":host{opacity:0!important;}" }</style>{ inner }</> }
78        }
79    }
80}
81
82/// The possible font loading state, which proceeds from top to bottom once per
83/// `<perspective-viewer>` element.
84#[derive(Clone, Copy)]
85pub enum FontLoaderStatus {
86    Uninitialized,
87    Loading,
88    Finished,
89}
90
91struct FontLoaderState {
92    status: Cell<FontLoaderStatus>,
93    elem: web_sys::HtmlElement,
94    on_update: Callback<()>,
95    fonts: RefCell<Vec<(String, String)>>,
96}
97
98type PromiseSet = Vec<ApiFuture<JsValue>>;
99
100impl FontLoaderProps {
101    pub fn new(elem: &web_sys::HtmlElement, on_update: Callback<()>) -> Self {
102        let inner = FontLoaderState {
103            status: Cell::new(FontLoaderStatus::Uninitialized),
104            elem: elem.clone(),
105            on_update,
106            fonts: RefCell::new(vec![]),
107        };
108
109        let state = yew::props!(Self {
110            state: Rc::new(inner)
111        });
112
113        ApiFuture::spawn(state.clone().load_fonts_task_safe());
114        state
115    }
116
117    pub fn get_status(&self) -> FontLoaderStatus {
118        self.state.status.get()
119    }
120
121    fn get_fonts(&'_ self) -> Ref<'_, Vec<(String, String)>> {
122        self.state.fonts.borrow()
123    }
124
125    /// We only want errors in this task to warn, since they are not necessarily
126    /// error conditions and mainly of interest to developers.
127    async fn load_fonts_task_safe(self) -> ApiResult<JsValue> {
128        if let Err(msg) = self.load_fonts_task().await {
129            web_sys::console::warn_1(&msg.into());
130        };
131
132        Ok(JsValue::UNDEFINED)
133    }
134
135    /// Awaits loading of a required set of font/weight pairs, given an element
136    /// with a CSS variable of the format:
137    /// ```css
138    /// perspective-viewer {
139    ///     --preload-fonts: "Roboto Mono:200;Open Sans:300,400";
140    /// }
141    /// ```
142    async fn load_fonts_task(self) -> ApiResult<JsValue> {
143        await_dom_loaded().await?;
144        let txt = global::window()
145            .get_computed_style(&self.state.elem)?
146            .unwrap()
147            .get_property_value("--preload-fonts")?;
148
149        let mut block_promises: PromiseSet = vec![];
150        let preload_fonts = parse_fonts(&txt);
151        self.state.fonts.borrow_mut().clone_from(&preload_fonts);
152        self.state.status.set(FontLoaderStatus::Loading);
153        self.state.on_update.emit(());
154
155        for (family, weight) in preload_fonts.iter() {
156            let task = timeout_font_task(family, weight);
157            let mut block_fonts: PromiseSet = vec![ApiFuture::new(task)];
158
159            for entry in font_iter(global::document().fonts().values()) {
160                let font_face = js_sys::Reflect::get(&entry, &intern("value").into())?
161                    .dyn_into::<web_sys::FontFace>()?;
162
163                // Safari always has to be "different".
164                if family == &font_face.family().replace('"', "")
165                    && (weight == &font_face.weight()
166                        || (font_face.weight() == "normal" && weight == "400"))
167                {
168                    block_fonts.push(ApiFuture::new(async move {
169                        Ok(JsFuture::from(font_face.loaded()?).await?)
170                    }));
171                }
172            }
173
174            let fut = async { select_all(block_fonts.into_iter()).await.0 };
175            block_promises.push(ApiFuture::new(fut))
176        }
177
178        if block_promises.len() != preload_fonts.len() {
179            web_sys::console::warn_1(&format!("Missing preload fonts {:?}", &preload_fonts).into());
180        }
181
182        let res = join_all(block_promises)
183            .await
184            .into_iter()
185            .collect::<ApiResult<Vec<JsValue>>>()
186            .map(|_| JsValue::UNDEFINED);
187
188        self.state.status.set(FontLoaderStatus::Finished);
189        self.state.on_update.emit(());
190        res
191    }
192}
193
194// An async task which times out.  Can be used to timeout an optional async task
195// by combinging with `Promise::any`.
196fn timeout_font_task(
197    family: &str,
198    weight: &str,
199) -> impl Future<Output = ApiResult<JsValue>> + use<> {
200    let timeout_msg = format!("Timeout awaiting font \"{family}:{weight}\"");
201    async {
202        set_timeout(FONT_DOWNLOAD_TIMEOUT_MS).await?;
203        Err(timeout_msg.into())
204    }
205}
206
207/// Generates a `<span>` for a specific font family and weight with `opacity:0`,
208/// since not all of the fonts may be shown and when e.g. the settings panel is
209/// closed, and this will defer font loading.
210fn font_test_html((family, weight): &(String, String)) -> Html {
211    let style = format!("opacity:0;font-family:\"{family}\";font-weight:{weight}");
212
213    html! { <span {style}>{ FONT_TEST_SAMPLE }</span> }
214}
215
216fn parse_font(txt: &str) -> Option<Vec<(String, String)>> {
217    match *txt.trim().split(':').collect::<Vec<_>>().as_slice() {
218        [family, weights] => Some(
219            weights
220                .split(',')
221                .map(|weight| (family.to_owned(), weight.to_owned()))
222                .collect::<Vec<_>>(),
223        ),
224        _ => None,
225    }
226}
227
228/// Parse a `--preload-fonts` value into (Family, Weight) tuples.
229fn parse_fonts(txt: &str) -> Vec<(String, String)> {
230    let trim = txt.trim();
231    let trim = if trim.len() > 2 {
232        &trim[1..trim.len() - 1]
233    } else {
234        trim
235    };
236
237    trim.split(';')
238        .filter_map(parse_font)
239        .flatten()
240        .collect::<Vec<_>>()
241}
242
243/// wasm_bindgen doesn't fully implement `FontFaceIterator`, but this is
244/// basically how it would be implemented if it was.
245fn font_iter(
246    iter: web_sys::FontFaceSetIterator,
247) -> impl Iterator<Item = web_sys::FontFaceSetIteratorResult> {
248    repeat_with(move || iter.next())
249        .filter_map(|x| x.ok())
250        .take_while(|entry| {
251            !js_sys::Reflect::get(entry, &intern("done").into())
252                .unwrap()
253                .as_bool()
254                .unwrap()
255        })
256}