Skip to main content

tessera_components/
fonts.rs

1//! Fonts loaded on demand when text uses their family or Unicode ranges.
2//!
3//! Text uses system fonts unless an application registers [`Fonts`] or a
4//! [`FontStylesheet`] at its entry. Web builds bundle a startup font and load
5//! additional fonts from a CDN.
6
7use std::{borrow::Cow, cell::RefCell, io, rc::Rc, sync::Arc};
8
9use glyphon::fontdb::{self, Database, FaceInfo};
10use tessera_ui::{EntryRegistry, LoadState, Resource, Source, TesseraPackage};
11
12pub use tessera_ui::FontAsset as Font;
13
14use crate::{text::write_font_system, theme::TextStyle};
15
16/// An entry package supplying fonts on demand.
17pub struct Fonts(
18    /// Font sources available to this application.
19    pub &'static [Font],
20);
21
22/// An entry package supplying the fonts declared by a CSS stylesheet's
23/// `@font-face` rules, such as a web font CDN. Each rule's file is fetched when
24/// text uses a character in its `unicode-range`.
25pub struct FontStylesheet(
26    /// Stylesheet URL.
27    pub &'static str,
28);
29
30impl TesseraPackage for Fonts {
31    fn register(self, _: &mut EntryRegistry) {
32        let registry = registry();
33        let mut registry = registry.borrow_mut();
34        for font in self.0 {
35            let font = FontSource::from(*font);
36            if !registry
37                .sources
38                .iter()
39                .any(|source| source.source == font.source)
40            {
41                registry.sources.push(font);
42            }
43        }
44    }
45}
46
47impl TesseraPackage for FontStylesheet {
48    fn register(self, _: &mut EntryRegistry) {
49        let registry = registry();
50        let mut registry = registry.borrow_mut();
51        if !registry.stylesheets.iter().any(|sheet| sheet.url == self.0) {
52            registry.stylesheets.push(Stylesheet::new(self.0));
53        }
54    }
55}
56
57struct Stylesheet {
58    url: &'static str,
59    faces: Option<Resource<Arc<[FontFace]>>>,
60}
61
62impl Stylesheet {
63    fn new(url: &'static str) -> Self {
64        Self { url, faces: None }
65    }
66}
67
68#[derive(Clone)]
69struct FontFace {
70    family: String,
71    src: String,
72    ranges: Vec<(u32, u32)>,
73}
74
75#[derive(Clone)]
76struct FontSource {
77    source: Source,
78    family: Cow<'static, str>,
79    fallback: Cow<'static, [(u32, u32)]>,
80    coverage: Cow<'static, [(u32, u32)]>,
81}
82
83impl From<Font> for FontSource {
84    fn from(font: Font) -> Self {
85        Self {
86            source: font.asset.into(),
87            family: font.family.into(),
88            fallback: font.fallback.into(),
89            coverage: font.coverage.into(),
90        }
91    }
92}
93
94struct FontRequest {
95    font: FontSource,
96    faces: Resource<Arc<[FaceInfo]>>,
97    /// Revision that installed the font; zero when it failed to load.
98    installed: Option<u64>,
99}
100
101struct FontRegistry {
102    sources: Vec<FontSource>,
103    stylesheets: Vec<Stylesheet>,
104    requests: Vec<FontRequest>,
105    revision: u64,
106}
107
108impl Default for FontRegistry {
109    fn default() -> Self {
110        Self {
111            sources: Vec::new(),
112            #[cfg(target_family = "wasm")]
113            stylesheets: [
114                "https://cdn.jsdelivr.net/npm/@fontsource-variable/roboto/index.css",
115                "https://cdn.jsdelivr.net/npm/@fontsource-variable/noto-sans-sc/index.css",
116            ]
117            .map(Stylesheet::new)
118            .into(),
119            #[cfg(not(target_family = "wasm"))]
120            stylesheets: Vec::new(),
121            requests: Vec::new(),
122            revision: 0,
123        }
124    }
125}
126
127fn registry() -> Rc<RefCell<FontRegistry>> {
128    tessera_ui::__private::local_runtime_resource::<RefCell<FontRegistry>>()
129}
130
131/// Requests the fonts `text` needs and installs those that have loaded.
132///
133/// Returns `None` while any of them is loading; otherwise a value that changes
134/// only when a font relevant to `text` is installed.
135pub(crate) fn prepare(text: &str, style: TextStyle) -> Option<u64> {
136    let registry = registry();
137    let mut registry = registry.borrow_mut();
138    let FontRegistry {
139        sources,
140        stylesheets,
141        requests,
142        revision,
143    } = &mut *registry;
144    let mut pending = false;
145    stylesheets.retain_mut(|sheet| {
146        match sheet
147            .faces
148            .get_or_insert_with(|| Resource::load(sheet.url, parse_stylesheet))
149            .get()
150        {
151            LoadState::Loading => {
152                pending = true;
153                return true;
154            }
155            LoadState::Ready(faces) => {
156                sources.extend(faces.iter().map(|face| face.font(sheet.url)));
157            }
158            LoadState::Failed(_) => {}
159        }
160        false
161    });
162    let family = style.font_family;
163    let mut key = 0;
164    for font in sources.iter().filter(|font| needed(font, family, text)) {
165        let index = match requests
166            .iter()
167            .position(|request| request.font.source == font.source)
168        {
169            Some(index) => index,
170            None => {
171                requests.push(FontRequest {
172                    font: font.clone(),
173                    faces: Resource::load(font.source.clone(), parse_font),
174                    installed: None,
175                });
176                requests.len() - 1
177            }
178        };
179        let installed = match requests[index].installed {
180            Some(installed) => installed,
181            None => match requests[index].faces.get() {
182                LoadState::Loading => {
183                    pending = true;
184                    continue;
185                }
186                LoadState::Ready(faces) => {
187                    let replace = !requests.iter().any(|request| {
188                        request.installed.is_some_and(|installed| installed > 0)
189                            && request.font.family == font.family
190                    });
191                    let mut fonts = write_font_system();
192                    let db = fonts.db_mut();
193                    if replace {
194                        let existing = db
195                            .faces()
196                            .filter(|face| {
197                                face.families.iter().any(|(name, _)| *name == font.family)
198                            })
199                            .map(|face| face.id)
200                            .collect::<Vec<_>>();
201                        for id in existing {
202                            db.remove_face(id);
203                        }
204                    }
205                    for face in faces.iter() {
206                        db.push_face_info(face.clone());
207                    }
208                    *revision += 1;
209                    *revision
210                }
211                LoadState::Failed(_) => 0,
212            },
213        };
214        requests[index].installed = Some(installed);
215        key = key.max(installed);
216    }
217    (!pending).then_some(key)
218}
219
220fn needed(font: &FontSource, family: Option<&str>, text: &str) -> bool {
221    let contains = |ranges: &[(u32, u32)], c: char| {
222        ranges
223            .iter()
224            .any(|&(start, end)| (start..=end).contains(&(c as u32)))
225    };
226    let named = family == Some(&*font.family);
227    (named && font.coverage.is_empty())
228        || text.chars().any(|c| {
229            (font.coverage.is_empty() || contains(&font.coverage, c))
230                && (named || contains(&font.fallback, c))
231        })
232}
233
234fn parse_font(bytes: Arc<[u8]>) -> io::Result<Arc<[FaceInfo]>> {
235    let bytes: Arc<[u8]> = if bytes.starts_with(b"wOF2") {
236        wuff::decompress_woff2(&bytes)
237            .map_err(|error| io::Error::other(format!("{error:?}")))?
238            .into()
239    } else {
240        bytes
241    };
242    let mut db = Database::new();
243    db.load_font_source(fontdb::Source::Binary(Arc::new(bytes)));
244    let faces = db.faces().cloned().collect::<Arc<[_]>>();
245    if faces.is_empty() {
246        return Err(io::Error::other("font contains no supported faces"));
247    }
248    Ok(faces)
249}
250
251fn parse_stylesheet(bytes: Arc<[u8]>) -> io::Result<Arc<[FontFace]>> {
252    let css = std::str::from_utf8(&bytes).map_err(io::Error::other)?;
253    Ok(css
254        .split("@font-face")
255        .skip(1)
256        .filter_map(|rule| {
257            let rule = &rule[rule.find('{')? + 1..rule.find('}')?];
258            let property = |name: &str| {
259                rule.split(';').find_map(|declaration| {
260                    let (key, value) = declaration.split_once(':')?;
261                    (key.trim() == name).then(|| value.trim())
262                })
263            };
264            let src = property("src")?;
265            let src = &src[src.find("url(")? + 4..];
266            let src = src[..src.find(')')?].trim_matches(['"', '\'']);
267            let ranges = property("unicode-range").map_or_else(
268                || vec![(0, 0x10ffff)],
269                |ranges| {
270                    ranges
271                        .split(',')
272                        .filter_map(|range| {
273                            let range = range.trim().strip_prefix("U+")?;
274                            let (start, end) = range.split_once('-').unwrap_or((range, range));
275                            let parse = |value: &str, fill| {
276                                u32::from_str_radix(&value.replace('?', fill), 16).ok()
277                            };
278                            Some((parse(start, "0")?, parse(end, "F")?))
279                        })
280                        .collect()
281                },
282            );
283            Some(FontFace {
284                family: property("font-family")?
285                    .trim_matches(['"', '\''])
286                    .to_owned(),
287                src: src.to_owned(),
288                ranges,
289            })
290        })
291        .collect())
292}
293
294impl FontFace {
295    fn font(&self, stylesheet: &str) -> FontSource {
296        let url = if self.src.contains("://") {
297            self.src.clone()
298        } else {
299            let base = &stylesheet[..stylesheet.rfind('/').map_or(0, |end| end + 1)];
300            format!("{base}{}", self.src.trim_start_matches("./"))
301        };
302        FontSource {
303            source: url.into(),
304            family: self.family.clone().into(),
305            fallback: self.ranges.clone().into(),
306            coverage: self.ranges.clone().into(),
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn stylesheet_rules_resolve_files_and_unicode_ranges() {
317        let css = br#"
318            /* latin */
319            @font-face {
320              font-family: 'Example Sans';
321              src: url(./files/latin.woff2) format('woff2');
322              unicode-range: U+0000-00FF, U+4??, U+2014;
323            }
324            @font-face { font-family: "Other"; src: url("https://cdn.test/other.ttf"); }
325        "#;
326        let fonts = parse_stylesheet(Arc::from(&css[..]))
327            .unwrap()
328            .iter()
329            .map(|face| face.font("https://cdn.test/sans/index.css"))
330            .collect::<Vec<_>>();
331        assert!(fonts[0].source == Source::from("https://cdn.test/sans/files/latin.woff2"));
332        assert_eq!(fonts[0].family, "Example Sans");
333        assert_eq!(
334            *fonts[0].coverage,
335            [(0, 0xff), (0x400, 0x4ff), (0x2014, 0x2014)]
336        );
337        assert!(fonts[1].source == Source::from("https://cdn.test/other.ttf"));
338        assert_eq!(*fonts[1].coverage, [(0, 0x10ffff)]);
339    }
340}