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