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::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
27const FONT_TEST_SAMPLE: &str = "ABCDΔ";
28
29const FONT_DOWNLOAD_TIMEOUT_MS: i32 = 1000;
30
31#[derive(Clone, Properties)]
32pub struct FontLoaderProps {
33    state: Rc<FontLoaderState>,
34}
35
36impl PartialEq for FontLoaderProps {
37    fn eq(&self, _rhs: &Self) -> bool {
38        false
39    }
40}
41
42#[function_component(FontLoader)]
43pub fn font_loader(props: &FontLoaderProps) -> Html {
44    if matches!(props.get_status(), FontLoaderStatus::Finished) {
45        html! {}
46    } else {
47        let inner = props
48            .get_fonts()
49            .iter()
50            .map(font_test_html)
51            .collect::<Html>();
52
53        html! { <><style>{ ":host{opacity:0!important;}" }</style>{ inner }</> }
54    }
55}
56
57/// The possible font loading state, which proceeds from top to bottom once per
58/// `<perspective-viewer>` element.
59#[derive(Clone, Copy)]
60pub enum FontLoaderStatus {
61    Uninitialized,
62    Loading,
63    Finished,
64}
65
66struct FontLoaderState {
67    status: Cell<FontLoaderStatus>,
68    elem: web_sys::HtmlElement,
69    on_update: Callback<()>,
70    fonts: RefCell<Vec<(String, String)>>,
71}
72
73type PromiseSet = Vec<ApiFuture<JsValue>>;
74
75impl FontLoaderProps {
76    pub fn new(elem: &web_sys::HtmlElement, on_update: Callback<()>) -> Self {
77        let inner = FontLoaderState {
78            status: Cell::new(FontLoaderStatus::Uninitialized),
79            elem: elem.clone(),
80            on_update,
81            fonts: RefCell::new(vec![]),
82        };
83
84        let state = yew::props!(Self {
85            state: Rc::new(inner)
86        });
87
88        ApiFuture::spawn(state.clone().load_fonts_task_safe());
89        state
90    }
91
92    pub fn get_status(&self) -> FontLoaderStatus {
93        self.state.status.get()
94    }
95
96    fn get_fonts(&'_ self) -> Ref<'_, Vec<(String, String)>> {
97        self.state.fonts.borrow()
98    }
99
100    async fn load_fonts_task_safe(self) -> ApiResult<JsValue> {
101        if let Err(msg) = self.load_fonts_task().await {
102            web_sys::console::warn_1(&msg.into());
103        };
104
105        Ok(JsValue::UNDEFINED)
106    }
107
108    async fn load_fonts_task(self) -> ApiResult<JsValue> {
109        await_dom_loaded().await?;
110        let txt = global::window()
111            .get_computed_style(&self.state.elem)?
112            .unwrap()
113            .get_property_value("--preload-fonts")?;
114
115        let mut block_promises: PromiseSet = vec![];
116        let preload_fonts = parse_fonts(&txt);
117        self.state.fonts.borrow_mut().clone_from(&preload_fonts);
118        self.state.status.set(FontLoaderStatus::Loading);
119        self.state.on_update.emit(());
120
121        for (family, weight) in preload_fonts.iter() {
122            let task = timeout_font_task(family, weight);
123            let mut block_fonts: PromiseSet = vec![ApiFuture::new(task)];
124
125            for entry in font_iter(global::document().fonts().values()) {
126                let font_face = js_sys::Reflect::get(&entry, &intern("value").into())?
127                    .dyn_into::<web_sys::FontFace>()?;
128
129                if family == &font_face.family().replace('"', "")
130                    && (weight == &font_face.weight()
131                        || (font_face.weight() == "normal" && weight == "400"))
132                {
133                    block_fonts.push(ApiFuture::new(async move {
134                        Ok(JsFuture::from(font_face.loaded()?).await?)
135                    }));
136                }
137            }
138
139            let fut = async { select_all(block_fonts).await.0 };
140            block_promises.push(ApiFuture::new(fut))
141        }
142
143        if block_promises.len() != preload_fonts.len() {
144            web_sys::console::warn_1(&format!("Missing preload fonts {:?}", preload_fonts).into());
145        }
146
147        let res = join_all(block_promises)
148            .await
149            .into_iter()
150            .collect::<ApiResult<Vec<JsValue>>>()
151            .map(|_| JsValue::UNDEFINED);
152
153        self.state.status.set(FontLoaderStatus::Finished);
154        self.state.on_update.emit(());
155        res
156    }
157}
158
159fn timeout_font_task(
160    family: &str,
161    weight: &str,
162) -> impl Future<Output = ApiResult<JsValue>> + use<> {
163    let timeout_msg = format!("Timeout awaiting font \"{family}:{weight}\"");
164    async {
165        set_timeout(FONT_DOWNLOAD_TIMEOUT_MS).await?;
166        Err(timeout_msg.into())
167    }
168}
169
170fn font_test_html((family, weight): &(String, String)) -> Html {
171    let style = format!("opacity:0;font-family:\"{family}\";font-weight:{weight}");
172
173    html! { <span {style}>{ FONT_TEST_SAMPLE }</span> }
174}
175
176fn parse_font(txt: &str) -> Option<Vec<(String, String)>> {
177    match *txt.trim().split(':').collect::<Vec<_>>().as_slice() {
178        [family, weights] => Some(
179            weights
180                .split(',')
181                .map(|weight| (family.to_owned(), weight.to_owned()))
182                .collect::<Vec<_>>(),
183        ),
184        _ => None,
185    }
186}
187
188fn parse_fonts(txt: &str) -> Vec<(String, String)> {
189    let trim = txt.trim();
190    let trim = if trim.len() > 2 {
191        &trim[1..trim.len() - 1]
192    } else {
193        trim
194    };
195
196    trim.split(';')
197        .filter_map(parse_font)
198        .flatten()
199        .collect::<Vec<_>>()
200}
201
202fn font_iter(
203    iter: web_sys::FontFaceSetIterator,
204) -> impl Iterator<Item = web_sys::FontFaceSetIteratorResult> {
205    repeat_with(move || iter.next())
206        .filter_map(|x| x.ok())
207        .take_while(|entry| {
208            !js_sys::Reflect::get(entry, &intern("done").into())
209                .unwrap()
210                .as_bool()
211                .unwrap()
212        })
213}