Skip to main content

typst_library/text/font/
book.rs

1use std::cmp::Reverse;
2use std::collections::BTreeMap;
3
4use unicode_segmentation::UnicodeSegmentation;
5
6use crate::text::{
7    Font, FontFlags, FontInfo, FontStretch, FontStyle, FontVariant, FontWeight,
8    StandardAxes, is_default_ignorable,
9};
10
11/// Metadata about a collection of fonts.
12#[derive(Debug, Default, Clone, Hash)]
13pub struct FontBook {
14    /// Maps from lowercased family names to font indices.
15    families: BTreeMap<String, Vec<usize>>,
16    /// Metadata about each font in the collection.
17    infos: Vec<FontInfo>,
18}
19
20impl FontBook {
21    /// Create a new, empty font book.
22    pub fn new() -> Self {
23        Self { families: BTreeMap::new(), infos: vec![] }
24    }
25
26    /// Create a font book from a collection of font infos.
27    pub fn from_infos(infos: impl IntoIterator<Item = FontInfo>) -> Self {
28        let mut book = Self::new();
29        for info in infos {
30            book.push(info);
31        }
32        book
33    }
34
35    /// Create a font book for a collection of fonts.
36    pub fn from_fonts<'a>(fonts: impl IntoIterator<Item = &'a Font>) -> Self {
37        Self::from_infos(fonts.into_iter().map(|font| font.info().clone()))
38    }
39
40    /// Insert metadata into the font book.
41    pub fn push(&mut self, info: FontInfo) {
42        let index = self.infos.len();
43        let family = info.family.to_lowercase();
44        self.families.entry(family).or_default().push(index);
45        self.infos.push(info);
46    }
47
48    /// Get the font info for the given index.
49    pub fn info(&self, index: usize) -> Option<&FontInfo> {
50        self.infos.get(index)
51    }
52
53    /// Returns true if the book contains a font family with the given name.
54    pub fn contains_family(&self, family: &str) -> bool {
55        self.families.contains_key(family)
56    }
57
58    /// An ordered iterator over all font families this book knows and the
59    /// font indices that belong to them.
60    pub fn families(
61        &self,
62    ) -> impl Iterator<Item = (&str, impl Iterator<Item = usize>)> + '_ {
63        // Since the keys are lowercased, we instead use the family field of the
64        // first face's info.
65        self.families.values().map(|ids| {
66            let family = self.infos[ids[0]].family.as_str();
67            (family, ids.iter().copied())
68        })
69    }
70
71    /// Try to find a font from the given `family` that matches the given
72    /// `variant` as closely as possible.
73    ///
74    /// The `family` should be all lowercase.
75    pub fn select(&self, family: &str, variant: FontVariant) -> Option<usize> {
76        let ids = self.families.get(family)?;
77        self.find_best_variant(None, variant, ids.iter().copied())
78    }
79
80    /// Iterate over all variants of a family.
81    pub fn select_family(&self, family: &str) -> impl Iterator<Item = usize> + '_ {
82        self.families
83            .get(family)
84            .map(|vec| vec.as_slice())
85            .unwrap_or_default()
86            .iter()
87            .copied()
88    }
89
90    /// Try to find and load a fallback font that
91    /// - is as close as possible to the font `like` (if any)
92    /// - is as close as possible to the given `variant`
93    /// - is suitable for shaping the given `text`
94    pub fn select_fallback(
95        &self,
96        like: Option<&FontInfo>,
97        variant: FontVariant,
98        text: &str,
99    ) -> Option<usize> {
100        // Find the fonts that contain the text's first non-space and
101        // non-ignorable char ...
102        let c = text
103            .chars()
104            .find(|&c| !c.is_whitespace() && !is_default_ignorable(c))?;
105
106        let ids = self
107            .infos
108            .iter()
109            .enumerate()
110            .filter(|(_, info)| info.coverage.contains(c as u32))
111            .map(|(index, _)| index);
112
113        // ... and find the best variant among them.
114        self.find_best_variant(like, variant, ids)
115    }
116
117    /// Find the font in the passed iterator that
118    /// - is closest to the font `like` (if any), and
119    /// - is closest to the given `variant`
120    ///
121    /// To do that we compute a score for all variants and select the one with the
122    /// higher score. This score prioritizes:
123    /// - If `like` is some other font:
124    ///   - Are both fonts monospaced?
125    ///   - Do both fonts have serifs?
126    ///   - How many words do the families share in their prefix? E.g. "Noto
127    ///     Sans" and "Noto Sans Arabic" share two words, whereas "IBM Plex
128    ///     Arabic" shares none with "Noto Sans", so prefer "Noto Sans Arabic"
129    ///     if `like` is "Noto Sans". In case there are two equally good
130    ///     matches, we prefer the shorter one because it is less special (e.g.
131    ///     if `like` is "Noto Sans Arabic", we prefer "Noto Sans" over "Noto
132    ///     Sans CJK HK".)
133    /// - The style (normal / italic / oblique). If we want italic or oblique
134    ///   but it doesn't exist, the other one of the two is still better than
135    ///   normal.
136    /// - The absolute distance to the target stretch.
137    /// - The absolute distance to the target weight.
138    /// - All else being equal, we prefer variable fonts over static ones.
139    fn find_best_variant(
140        &self,
141        like: Option<&FontInfo>,
142        variant: FontVariant,
143        ids: impl IntoIterator<Item = usize>,
144    ) -> Option<usize> {
145        let mut best = None;
146        let mut best_score = None;
147
148        for id in ids {
149            let current = &self.infos[id];
150            let score = (
151                like.map(|like| similarity(current, like)),
152                Reverse(distance(current, variant)),
153                current.flags.contains(FontFlags::VARIABLE),
154            );
155
156            if best_score.is_none_or(|b| score > b) {
157                best = Some(id);
158                best_score = Some(score);
159            }
160        }
161
162        best
163    }
164}
165
166/// Determines a metric that scores higher if `other` is similar to `self`.
167/// This is used to pick a closely matching face during font fallback.
168fn similarity(left: &FontInfo, right: &FontInfo) -> impl Ord + Copy {
169    (
170        // Most importantly, we want a font of a similar kind (monospace,
171        // serif, etc.).
172        left.flags.contains(FontFlags::MONOSPACE)
173            == right.flags.contains(FontFlags::MONOSPACE),
174        left.flags.contains(FontFlags::SERIF) == right.flags.contains(FontFlags::SERIF),
175        // We prefer fonts that have more words shared in their name. E.g.
176        // "Noto Sans" and "Noto Sans Arabic" share two words, whereas "IBM
177        // Plex Arabic" shares none with "Noto Sans", so prefer "Noto Sans
178        // Arabic" if `like` is "Noto Sans".
179        shared_prefix_words(&left.family, &right.family),
180        // In case there are two equally good matches, we prefer the shorter
181        // one because it is less special (e.g. if `like` is "Noto Sans
182        // Arabic", we prefer "Noto Sans" over "Noto Sans CJK HK".)
183        Reverse(left.family.len()),
184    )
185}
186
187/// Determines a distance metric from the given variant to
188/// - this font's variant (if static)
189/// - this font's closest instance (if variable)
190///
191/// Used to pick the most suitable font in a family.
192fn distance(info: &FontInfo, variant: FontVariant) -> impl Ord + Copy {
193    // TODO: Potentially also consider optical size for the distance
194    // computation. However, this would ideally also apply to non-variable
195    // font and there are different mechanisms with which these advertise
196    // their intended optical size range.
197
198    let axes = StandardAxes::parse(&info.axes);
199
200    let style_distance = {
201        let mut dist = info.variant.style.distance(variant.style);
202        if axes.ital.is_some() {
203            dist = dist.min(FontStyle::Italic.distance(variant.style));
204        }
205        if axes.slnt.is_some() {
206            dist = dist.min(FontStyle::Oblique.distance(variant.style));
207        }
208        dist
209    };
210
211    let stretch_distance = match axes.wdth {
212        Some(axis) => {
213            axis.distance(variant.stretch, FontStretch::from_wdth, FontStretch::distance)
214        }
215        None => info.variant.stretch.distance(variant.stretch),
216    };
217
218    let weight_distance = match axes.wght {
219        Some(axis) => {
220            axis.distance(variant.weight, FontWeight::from_wght, FontWeight::distance)
221        }
222        None => info.variant.weight.distance(variant.weight),
223    };
224
225    (style_distance, stretch_distance, weight_distance)
226}
227
228/// How many words the two strings share in their prefix.
229fn shared_prefix_words(left: &str, right: &str) -> usize {
230    left.unicode_words()
231        .zip(right.unicode_words())
232        .take_while(|(l, r)| l == r)
233        .count()
234}
235
236#[cfg(test)]
237mod tests {
238    use crate::layout::Ratio;
239    use crate::text::{
240        AxisValue, Coverage, FontAxis, FontBook, FontFlags, FontInfo, FontStretch,
241        FontStyle, FontVariant, FontWeight, Tag,
242    };
243
244    #[test]
245    fn test_find_best_variant() {
246        use FontStyle::*;
247
248        let s = [
249            info("s0", Normal, 400, 100.0, &[]),
250            info("s1", Normal, 500, 100.0, &[]),
251            info("s2", Normal, 800, 100.0, &[]),
252            info("s3", Italic, 400, 100.0, &[]),
253            info("s4", Italic, 800, 100.0, &[]),
254            info("s5", Oblique, 800, 100.0, &[]),
255            info("s6", Normal, 400, 110.0, &[]),
256        ];
257
258        #[rustfmt::skip]
259        let v = [
260           info("v0", Normal, 400, 100.0, &[("wght", 200.0, 700.0), ("ital", 0.0, 1.0)]),
261           info("v1", Normal, 400, 100.0, &[("slnt", -40.0, 40.0), ("wdth", 70.0, 120.0)]),
262        ];
263
264        let book = FontBook::from_infos(s.iter().chain(&v).cloned());
265        let count = s.len() + v.len();
266        let pick = |style, weight, stretch| {
267            let target = variant(style, weight, stretch);
268            let id = book.find_best_variant(None, target, 0..count).unwrap();
269            book.info(id).unwrap()
270        };
271
272        // Variable fonts are preferred ...
273        assert_eq!(pick(Normal, 100, 100.0), &v[0]);
274        assert_eq!(pick(Normal, 200, 100.0), &v[0]);
275        assert_eq!(pick(Normal, 500, 100.0), &v[0]);
276        assert_eq!(pick(Normal, 730, 100.0), &v[0]);
277        assert_eq!(pick(Italic, 400, 100.0), &v[0]);
278        assert_eq!(pick(Oblique, 400, 100.0), &v[1]);
279        assert_eq!(pick(Normal, 400, 120.0), &v[1]);
280        assert_eq!(pick(Normal, 400, 130.0), &v[1]);
281
282        // ... but static variant are still picked if they are closer.
283        assert_eq!(pick(Normal, 760, 100.0), &s[2]);
284        assert_eq!(pick(Normal, 800, 100.0), &s[2]);
285        assert_eq!(pick(Normal, 1000, 100.0), &s[2]);
286        assert_eq!(pick(Italic, 800, 110.0), &s[4]);
287        assert_eq!(pick(Oblique, 800, 100.0), &s[5]);
288        assert_eq!(pick(Oblique, 800, 100.0), &s[5]);
289    }
290
291    fn info(
292        family: &str,
293        style: FontStyle,
294        weight: u16,
295        stretch: f64,
296        axes: &[(&str, f32, f32)],
297    ) -> FontInfo {
298        FontInfo {
299            family: family.into(),
300            variant: variant(style, weight, stretch),
301            flags: if axes.is_empty() { FontFlags::empty() } else { FontFlags::VARIABLE },
302            axes: axes
303                .iter()
304                .map(|&(t, min, max)| FontAxis {
305                    tag: Tag::from_bytes_lossy(t.as_bytes()),
306                    min: AxisValue(min),
307                    max: AxisValue(max),
308                    default: AxisValue((min + max) / 2.0),
309                })
310                .collect(),
311            coverage: Coverage::from_vec(vec![]),
312        }
313    }
314
315    fn variant(style: FontStyle, weight: u16, stretch: f64) -> FontVariant {
316        FontVariant {
317            style,
318            weight: FontWeight::from_number(weight),
319            stretch: FontStretch::from_ratio(Ratio::new(stretch / 100.0)),
320        }
321    }
322}