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#[function_component(FontLoader)]
51pub fn font_loader(props: &FontLoaderProps) -> Html {
52    if matches!(props.get_status(), FontLoaderStatus::Finished) {
53        html! {}
54    } else {
55        let inner = props
56            .get_fonts()
57            .iter()
58            .map(font_test_html)
59            .collect::<Html>();
60
61        html! { <><style>{ ":host{opacity:0!important;}" }</style>{ inner }</> }
62    }
63}
64
65/// The possible font loading state, which proceeds from top to bottom once per
66/// `<perspective-viewer>` element.
67#[derive(Clone, Copy)]
68pub enum FontLoaderStatus {
69    Uninitialized,
70    Loading,
71    Finished,
72}
73
74struct FontLoaderState {
75    status: Cell<FontLoaderStatus>,
76    elem: web_sys::HtmlElement,
77    on_update: Callback<()>,
78    fonts: RefCell<Vec<(String, String)>>,
79}
80
81type PromiseSet = Vec<ApiFuture<JsValue>>;
82
83impl FontLoaderProps {
84    pub fn new(elem: &web_sys::HtmlElement, on_update: Callback<()>) -> Self {
85        let inner = FontLoaderState {
86            status: Cell::new(FontLoaderStatus::Uninitialized),
87            elem: elem.clone(),
88            on_update,
89            fonts: RefCell::new(vec![]),
90        };
91
92        let state = yew::props!(Self {
93            state: Rc::new(inner)
94        });
95
96        ApiFuture::spawn(state.clone().load_fonts_task_safe());
97        state
98    }
99
100    pub fn get_status(&self) -> FontLoaderStatus {
101        self.state.status.get()
102    }
103
104    fn get_fonts(&'_ self) -> Ref<'_, Vec<(String, String)>> {
105        self.state.fonts.borrow()
106    }
107
108    /// We only want errors in this task to warn, since they are not necessarily
109    /// error conditions and mainly of interest to developers.
110    async fn load_fonts_task_safe(self) -> ApiResult<JsValue> {
111        if let Err(msg) = self.load_fonts_task().await {
112            web_sys::console::warn_1(&msg.into());
113        };
114
115        Ok(JsValue::UNDEFINED)
116    }
117
118    /// Awaits loading of a required set of font/weight pairs, given an element
119    /// with a CSS variable of the format:
120    /// ```css
121    /// perspective-viewer {
122    ///     --preload-fonts: "Roboto Mono:200;Open Sans:300,400";
123    /// }
124    /// ```
125    async fn load_fonts_task(self) -> ApiResult<JsValue> {
126        await_dom_loaded().await?;
127        let txt = global::window()
128            .get_computed_style(&self.state.elem)?
129            .unwrap()
130            .get_property_value("--preload-fonts")?;
131
132        let mut block_promises: PromiseSet = vec![];
133        let preload_fonts = parse_fonts(&txt);
134        self.state.fonts.borrow_mut().clone_from(&preload_fonts);
135        self.state.status.set(FontLoaderStatus::Loading);
136        self.state.on_update.emit(());
137
138        for (family, weight) in preload_fonts.iter() {
139            let task = timeout_font_task(family, weight);
140            let mut block_fonts: PromiseSet = vec![ApiFuture::new(task)];
141
142            for entry in font_iter(global::document().fonts().values()) {
143                let font_face = js_sys::Reflect::get(&entry, &intern("value").into())?
144                    .dyn_into::<web_sys::FontFace>()?;
145
146                // Safari always has to be "different".
147                if family == &font_face.family().replace('"', "")
148                    && (weight == &font_face.weight()
149                        || (font_face.weight() == "normal" && weight == "400"))
150                {
151                    block_fonts.push(ApiFuture::new(async move {
152                        Ok(JsFuture::from(font_face.loaded()?).await?)
153                    }));
154                }
155            }
156
157            let fut = async { select_all(block_fonts.into_iter()).await.0 };
158            block_promises.push(ApiFuture::new(fut))
159        }
160
161        if block_promises.len() != preload_fonts.len() {
162            web_sys::console::warn_1(&format!("Missing preload fonts {:?}", &preload_fonts).into());
163        }
164
165        let res = join_all(block_promises)
166            .await
167            .into_iter()
168            .collect::<ApiResult<Vec<JsValue>>>()
169            .map(|_| JsValue::UNDEFINED);
170
171        self.state.status.set(FontLoaderStatus::Finished);
172        self.state.on_update.emit(());
173        res
174    }
175}
176
177// An async task which times out.  Can be used to timeout an optional async task
178// by combinging with `Promise::any`.
179fn timeout_font_task(
180    family: &str,
181    weight: &str,
182) -> impl Future<Output = ApiResult<JsValue>> + use<> {
183    let timeout_msg = format!("Timeout awaiting font \"{family}:{weight}\"");
184    async {
185        set_timeout(FONT_DOWNLOAD_TIMEOUT_MS).await?;
186        Err(timeout_msg.into())
187    }
188}
189
190/// Generates a `<span>` for a specific font family and weight with `opacity:0`,
191/// since not all of the fonts may be shown and when e.g. the settings panel is
192/// closed, and this will defer font loading.
193fn font_test_html((family, weight): &(String, String)) -> Html {
194    let style = format!("opacity:0;font-family:\"{family}\";font-weight:{weight}");
195
196    html! { <span {style}>{ FONT_TEST_SAMPLE }</span> }
197}
198
199fn parse_font(txt: &str) -> Option<Vec<(String, String)>> {
200    match *txt.trim().split(':').collect::<Vec<_>>().as_slice() {
201        [family, weights] => Some(
202            weights
203                .split(',')
204                .map(|weight| (family.to_owned(), weight.to_owned()))
205                .collect::<Vec<_>>(),
206        ),
207        _ => None,
208    }
209}
210
211/// Parse a `--preload-fonts` value into (Family, Weight) tuples.
212fn parse_fonts(txt: &str) -> Vec<(String, String)> {
213    let trim = txt.trim();
214    let trim = if trim.len() > 2 {
215        &trim[1..trim.len() - 1]
216    } else {
217        trim
218    };
219
220    trim.split(';')
221        .filter_map(parse_font)
222        .flatten()
223        .collect::<Vec<_>>()
224}
225
226/// wasm_bindgen doesn't fully implement `FontFaceIterator`, but this is
227/// basically how it would be implemented if it was.
228fn font_iter(
229    iter: web_sys::FontFaceSetIterator,
230) -> impl Iterator<Item = web_sys::FontFaceSetIteratorResult> {
231    repeat_with(move || iter.next())
232        .filter_map(|x| x.ok())
233        .take_while(|entry| {
234            !js_sys::Reflect::get(entry, &intern("done").into())
235                .unwrap()
236                .as_bool()
237                .unwrap()
238        })
239}