Skip to main content

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}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn family_of_names_a_variant_with_a_suffix() {
308        let plain = Variant {
309            bold: false,
310            italic: false,
311        };
312        let bold = Variant {
313            bold: true,
314            italic: false,
315        };
316        let italic = Variant {
317            bold: false,
318            italic: true,
319        };
320        let both = Variant {
321            bold: true,
322            italic: true,
323        };
324        assert_eq!(family_of("Arial", plain), FontFamily::Name("Arial".into()));
325        assert_eq!(
326            family_of("Arial", bold),
327            FontFamily::Name("Arial:bold".into())
328        );
329        assert_eq!(
330            family_of("Arial", italic),
331            FontFamily::Name("Arial:italic".into())
332        );
333        assert_eq!(
334            family_of("Arial", both),
335            FontFamily::Name("Arial:bolditalic".into())
336        );
337    }
338
339    #[test]
340    fn metric_substitutes_matches_regardless_of_case_or_spaces() {
341        assert_eq!(
342            metric_substitutes("Times New Roman"),
343            ["Liberation Serif", "DejaVu Serif"]
344        );
345        assert_eq!(
346            metric_substitutes("TIMES NEW ROMAN"),
347            ["Liberation Serif", "DejaVu Serif"]
348        );
349        assert_eq!(
350            metric_substitutes("TimesNewRoman"),
351            ["Liberation Serif", "DejaVu Serif"]
352        );
353        assert_eq!(metric_substitutes("Calibri"), ["Carlito"]);
354    }
355
356    #[test]
357    fn metric_substitutes_is_empty_for_a_family_with_no_known_substitute() {
358        assert_eq!(metric_substitutes("Comic Sans MS"), &[] as &[&str]);
359    }
360
361    /// The order this checks in is load-bearing: `Liberation Mono` contains
362    /// neither "times" nor "serif", but a family named for a monospace face
363    /// still has to be caught before falling through to the serif check.
364    #[test]
365    fn generic_recognises_monospace_families() {
366        assert_eq!(generic("Liberation Mono"), fontdb::Family::Monospace);
367        assert_eq!(generic("Courier New"), fontdb::Family::Monospace);
368        assert_eq!(generic("Consolas"), fontdb::Family::Monospace);
369    }
370
371    #[test]
372    fn generic_recognises_serif_families() {
373        assert_eq!(generic("Times New Roman"), fontdb::Family::Serif);
374        assert_eq!(generic("Liberation Serif"), fontdb::Family::Serif);
375        assert_eq!(generic("Georgia"), fontdb::Family::Serif);
376    }
377
378    /// The case the comment on `generic` calls out by name: `Liberation Sans`
379    /// contains "sans", not "serif", so the serif check above must not catch
380    /// it - if it did, every sans-serif document would draw with serifs.
381    #[test]
382    fn generic_defaults_to_sans_serif_rather_than_matching_serif_by_accident() {
383        assert_eq!(generic("Liberation Sans"), fontdb::Family::SansSerif);
384        assert_eq!(generic("Wingdings"), fontdb::Family::SansSerif);
385    }
386}