odox_ui/fonts.rs
1//! Resolving the font families a document names to faces on this machine.
2//!
3//! Nothing is embedded. A document names a family — `Liberation Serif`, `Times
4//! New Roman`, `Arial` — and the machine is asked for it, because every platform
5//! this ships on carries a metrically compatible face for the families office
6//! documents use, and three applications carrying a megabyte of fonts each would
7//! be three megabytes spent on a question the operating system has already
8//! answered. The cost is that a document naming a family the machine does not
9//! have is drawn in a fallback, which is what every other application does too.
10//!
11//! Faces are loaded once per document, not once per frame: egui rebuilds its
12//! glyph atlas when the font definitions change, so the families a document uses
13//! are resolved when it opens and handed over in one call.
14//
15// Author: David M. Anderson
16// Built with AI assistance (Claude, Anthropic)
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::sync::Arc;
20
21use eframe::egui::{FontData, FontDefinitions, FontFamily};
22use odox_core::{Element, Ns};
23
24/// The four faces a family is asked for.
25///
26/// ODF says bold and italic per run, and a renderer that synthesized them by
27/// skewing and thickening the regular face would be drawing something no font
28/// designer made. So each combination is its own face, and its own egui family.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub struct Variant {
31 /// Bold.
32 pub bold: bool,
33 /// Italic.
34 pub italic: bool,
35}
36
37/// The egui font family a document's family name and variant resolve to.
38///
39/// The name is the one the document used, so that two documents naming the same
40/// family share an atlas entry and a document naming a family nobody has still
41/// gets a family that exists and draws in the fallback.
42pub fn family_of(family: &str, variant: Variant) -> FontFamily {
43 let suffix = match (variant.bold, variant.italic) {
44 (false, false) => "",
45 (true, false) => ":bold",
46 (false, true) => ":italic",
47 (true, true) => ":bolditalic",
48 };
49 FontFamily::Name(format!("{family}{suffix}").into())
50}
51
52/// Build the font definitions for a document: egui's own, plus a face for every
53/// family the document names.
54///
55/// The fallback chain behind each face is egui's built-in proportional font and
56/// its emoji fonts, so a glyph the document's own face lacks is still drawn
57/// rather than shown as a box.
58pub fn definitions(families: &BTreeSet<String>) -> FontDefinitions {
59 let mut definitions = FontDefinitions::default();
60 let fallback = definitions
61 .families
62 .get(&FontFamily::Proportional)
63 .cloned()
64 .unwrap_or_default();
65
66 let mut database = fontdb::Database::new();
67 database.load_system_fonts();
68 set_generics(&mut database);
69
70 for family in families {
71 for variant in [
72 Variant {
73 bold: false,
74 italic: false,
75 },
76 Variant {
77 bold: true,
78 italic: false,
79 },
80 Variant {
81 bold: false,
82 italic: true,
83 },
84 Variant {
85 bold: true,
86 italic: true,
87 },
88 ] {
89 let key = match family_of(family, variant) {
90 FontFamily::Name(name) => name.to_string(),
91 // `family_of` builds a named family and nothing else; the other
92 // arms exist only because `FontFamily` is an enum.
93 other => format!("{other:?}"),
94 };
95 let mut chain = Vec::new();
96 if let Some(face) = load(&database, family, variant) {
97 definitions.font_data.insert(key.clone(), Arc::new(face));
98 chain.push(key.clone());
99 }
100 chain.extend(fallback.iter().cloned());
101 definitions
102 .families
103 .insert(FontFamily::Name(key.into()), chain);
104 }
105 }
106 definitions
107}
108
109/// Ask the machine for one face.
110fn load(database: &fontdb::Database, family: &str, variant: Variant) -> Option<FontData> {
111 let mut wanted = vec![fontdb::Family::Name(family)];
112 // The metrically compatible substitute, where the family is one of the
113 // handful that has a well-known one. A document laid out in Times New Roman
114 // on a machine that has Liberation Serif keeps its line breaks; the same
115 // document in whatever the generic serif happens to be does not.
116 wanted.extend(
117 metric_substitutes(family)
118 .iter()
119 .map(|name| fontdb::Family::Name(name)),
120 );
121 // A generic family last, so that a document naming something absent lands on
122 // a face of roughly the right shape rather than on the first font in
123 // alphabetical order.
124 wanted.push(generic(family));
125 let query = fontdb::Query {
126 families: &wanted,
127 weight: if variant.bold {
128 fontdb::Weight::BOLD
129 } else {
130 fontdb::Weight::NORMAL
131 },
132 stretch: fontdb::Stretch::Normal,
133 style: if variant.italic {
134 fontdb::Style::Italic
135 } else {
136 fontdb::Style::Normal
137 },
138 };
139 let id = database.query(&query)?;
140 let index = database.face(id)?.index;
141 database.with_face_data(id, |data, face_index| FontData {
142 font: data.to_vec().into(),
143 // A font collection holds several faces in one file and `fontdb` reports
144 // which of them answered; handing over the file without the index draws
145 // the wrong one.
146 index: face_index.max(index),
147 tweak: eframe::egui::FontTweak::default(),
148 })
149}
150
151/// The families that are metrically compatible with the ones office documents
152/// name, in the order to try them.
153///
154/// Each pair here has the same advance widths as the family it stands in for, so
155/// a document laid out in one and drawn in the other breaks its lines in the same
156/// places. The list is short because that property is what earns a place on it:
157/// a face that merely looks similar belongs to the generic fallback below.
158fn metric_substitutes(family: &str) -> &'static [&'static str] {
159 match family.to_ascii_lowercase().as_str() {
160 "times new roman" | "times" | "timesnewroman" => &["Liberation Serif", "DejaVu Serif"],
161 "arial" | "helvetica" | "arialmt" => &["Liberation Sans", "DejaVu Sans"],
162 "courier new" | "courier" => &["Liberation Mono", "DejaVu Sans Mono"],
163 "calibri" => &["Carlito"],
164 "cambria" => &["Caladea"],
165 "liberation serif" => &["DejaVu Serif"],
166 "liberation sans" => &["DejaVu Sans"],
167 "liberation mono" => &["DejaVu Sans Mono"],
168 _ => &[],
169 }
170}
171
172/// Point the generic families at faces this machine has.
173///
174/// `fontdb` names Times New Roman, Arial and Courier New as its own defaults,
175/// which is right on Windows and wrong on every Linux box: the generic fallback
176/// resolves to nothing at all, and a document naming a family nobody has draws in
177/// egui's built-in face rather than in anything the document asked for. Measured
178/// on Debian 13, where none of the three exists.
179fn set_generics(database: &mut fontdb::Database) {
180 let present = |database: &fontdb::Database, name: &str| {
181 database
182 .query(&fontdb::Query {
183 families: &[fontdb::Family::Name(name)],
184 weight: fontdb::Weight::NORMAL,
185 stretch: fontdb::Stretch::Normal,
186 style: fontdb::Style::Normal,
187 })
188 .is_some()
189 };
190 let first = |database: &fontdb::Database, names: &[&str]| {
191 names
192 .iter()
193 .find(|name| present(database, name))
194 .map(|name| (*name).to_owned())
195 };
196
197 if let Some(name) = first(
198 database,
199 &[
200 "Liberation Serif",
201 "DejaVu Serif",
202 "Noto Serif",
203 "Times New Roman",
204 "Georgia",
205 ],
206 ) {
207 database.set_serif_family(name);
208 }
209 if let Some(name) = first(
210 database,
211 &[
212 "Liberation Sans",
213 "DejaVu Sans",
214 "Noto Sans",
215 "Arial",
216 "Helvetica",
217 ],
218 ) {
219 database.set_sans_serif_family(name);
220 }
221 if let Some(name) = first(
222 database,
223 &[
224 "Liberation Mono",
225 "DejaVu Sans Mono",
226 "Noto Sans Mono",
227 "Courier New",
228 ],
229 ) {
230 database.set_monospace_family(name);
231 }
232}
233
234/// The generic family a name suggests, for a machine that does not have it.
235///
236/// The names are the ones office documents actually carry. It is a short list on
237/// purpose: guessing from the name is what produces a serif document drawn in a
238/// sans face, so anything unrecognized asks for the default proportional family
239/// rather than for a shape.
240fn generic(family: &str) -> fontdb::Family<'static> {
241 let lower = family.to_ascii_lowercase();
242 if lower.contains("mono") || lower.contains("courier") || lower.contains("consol") {
243 return fontdb::Family::Monospace;
244 }
245 if lower.contains("times") || lower.contains("serif") || lower.contains("georgia") {
246 // `Liberation Sans` contains neither, and `Liberation Serif` contains
247 // `serif`, which is why the sans test comes second rather than first.
248 return fontdb::Family::Serif;
249 }
250 fontdb::Family::SansSerif
251}
252
253/// Every font family a document's styles name.
254///
255/// Read from the styles rather than from the text, because a family is named in a
256/// style and used by whatever references it, and because the answer is wanted
257/// before the first frame is drawn.
258pub fn families_used(parts: &[&Element]) -> BTreeSet<String> {
259 let mut families = BTreeSet::new();
260 let mut faces: BTreeMap<String, String> = BTreeMap::new();
261 for root in parts {
262 collect(root, &mut families, &mut faces);
263 }
264 // A style naming a font face rather than a family resolves through the
265 // declarations, and the declarations are what a renderer has to ask the
266 // machine for.
267 let resolved: BTreeSet<String> = families
268 .iter()
269 .map(|name| faces.get(name).cloned().unwrap_or_else(|| name.clone()))
270 .collect();
271 resolved
272}
273
274fn collect(
275 element: &Element,
276 families: &mut BTreeSet<String>,
277 faces: &mut BTreeMap<String, String>,
278) {
279 if element.is(&Ns::Style, "font-face")
280 && let Some(name) = element.attr(&Ns::Style, "name")
281 {
282 let family = element
283 .attr(&Ns::Svg, "font-family")
284 .unwrap_or(name)
285 .trim_matches('\'')
286 .to_owned();
287 faces.insert(name.to_owned(), family);
288 }
289 for (ns, local) in [(Ns::Style, "font-name"), (Ns::Fo, "font-family")] {
290 if let Some(name) = element.attr(&ns, local) {
291 let name = name.trim_matches('\'');
292 if !name.is_empty() {
293 families.insert(name.to_owned());
294 }
295 }
296 }
297 for child in element.elements() {
298 collect(child, families, faces);
299 }
300}