Skip to main content

odox_fonts/
lib.rs

1//! Resolving the font families a document names to faces on this machine.
2//!
3//! A document names a family — `Liberation Serif`, `Times New Roman`, `Arial` —
4//! and the machine is asked for it, falling back to a metrically compatible
5//! substitute and then to a generic family of roughly the right shape. The
6//! window draws with the face this answers and a PDF export embeds the same
7//! one, so the two agree on what a document looks like. DESIGN.md §6.
8//
9// Author: David M. Anderson
10// Built with AI assistance (Claude, Anthropic)
11
12#![forbid(unsafe_code)]
13
14pub use fontdb;
15
16/// The four faces a family is asked for.
17///
18/// ODF says bold and italic per run, and a renderer that synthesized them by
19/// skewing and thickening the regular face would be drawing something no font
20/// designer made. So each combination is its own face.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
22pub struct Variant {
23    /// Bold.
24    pub bold: bool,
25    /// Italic.
26    pub italic: bool,
27}
28
29impl Variant {
30    /// All four, regular first.
31    pub const ALL: [Self; 4] = [
32        Self {
33            bold: false,
34            italic: false,
35        },
36        Self {
37            bold: true,
38            italic: false,
39        },
40        Self {
41            bold: false,
42            italic: true,
43        },
44        Self {
45            bold: true,
46            italic: true,
47        },
48    ];
49}
50
51/// The machine's fonts, with the generic families pointed at faces it has.
52pub fn database() -> fontdb::Database {
53    let mut database = fontdb::Database::new();
54    database.load_system_fonts();
55    set_generics(&mut database);
56    database
57}
58
59/// The face a family and variant resolve to: the family itself, then its
60/// metric substitutes, then the generic family its name suggests.
61pub fn find(database: &fontdb::Database, family: &str, variant: Variant) -> Option<fontdb::ID> {
62    let mut wanted = vec![fontdb::Family::Name(family)];
63    wanted.extend(substitute_families(family));
64    query(database, &wanted, variant)
65}
66
67/// The face a family resolves to when the family itself may not be used: its
68/// metric substitutes, then the generic family its name suggests.
69pub fn find_substitute(
70    database: &fontdb::Database,
71    family: &str,
72    variant: Variant,
73) -> Option<fontdb::ID> {
74    query(database, &substitute_families(family), variant)
75}
76
77fn substitute_families(family: &str) -> Vec<fontdb::Family<'static>> {
78    // A document laid out in Times New Roman on a machine that has Liberation
79    // Serif keeps its line breaks; the same document in whatever the generic
80    // serif happens to be does not. The generic comes last, so that a document
81    // naming something absent lands on a face of roughly the right shape rather
82    // than on the first font in alphabetical order.
83    let mut wanted: Vec<fontdb::Family<'static>> = metric_substitutes(family)
84        .iter()
85        .map(|name| fontdb::Family::Name(name))
86        .collect();
87    wanted.push(generic(family));
88    wanted
89}
90
91fn query(
92    database: &fontdb::Database,
93    families: &[fontdb::Family<'_>],
94    variant: Variant,
95) -> Option<fontdb::ID> {
96    database.query(&fontdb::Query {
97        families,
98        weight: if variant.bold {
99            fontdb::Weight::BOLD
100        } else {
101            fontdb::Weight::NORMAL
102        },
103        stretch: fontdb::Stretch::Normal,
104        style: if variant.italic {
105            fontdb::Style::Italic
106        } else {
107            fontdb::Style::Normal
108        },
109    })
110}
111
112/// Whether a face has a glyph for a character.
113pub fn covers(data: &[u8], index: u32, character: char) -> bool {
114    ttf_parser::Face::parse(data, index)
115        .ok()
116        .and_then(|face| face.glyph_index(character))
117        .is_some_and(|glyph| glyph.0 != 0)
118}
119
120/// The faces on the machine that have a glyph for a character, best first:
121/// the generic families' faces in the variant asked for, then every face in
122/// the order the machine lists them. What draws a character the face a
123/// document names does not have.
124pub fn faces_with(
125    database: &fontdb::Database,
126    character: char,
127    variant: Variant,
128) -> impl Iterator<Item = fontdb::ID> + '_ {
129    [
130        fontdb::Family::SansSerif,
131        fontdb::Family::Serif,
132        fontdb::Family::Monospace,
133    ]
134    .into_iter()
135    .filter_map(move |generic| query(database, &[generic], variant))
136    .chain(database.faces().map(|info| info.id))
137    .filter(move |&id| {
138        database
139            .with_face_data(id, |data, index| covers(data, index, character))
140            .unwrap_or(false)
141    })
142}
143
144/// The families that are metrically compatible with the ones office documents
145/// name, in the order to try them.
146///
147/// Each pair here has the same advance widths as the family it stands in for, so
148/// a document laid out in one and drawn in the other breaks its lines in the same
149/// places. The list is short because that property is what earns a place on it:
150/// a face that merely looks similar belongs to the generic fallback.
151pub fn metric_substitutes(family: &str) -> &'static [&'static str] {
152    match family.to_ascii_lowercase().as_str() {
153        "times new roman" | "times" | "timesnewroman" => &["Liberation Serif", "DejaVu Serif"],
154        "arial" | "helvetica" | "arialmt" => &["Liberation Sans", "DejaVu Sans"],
155        "courier new" | "courier" => &["Liberation Mono", "DejaVu Sans Mono"],
156        "calibri" => &["Carlito"],
157        "cambria" => &["Caladea"],
158        "liberation serif" => &["DejaVu Serif"],
159        "liberation sans" => &["DejaVu Sans"],
160        "liberation mono" => &["DejaVu Sans Mono"],
161        _ => &[],
162    }
163}
164
165/// Point the generic families at faces this machine has.
166///
167/// `fontdb` names Times New Roman, Arial and Courier New as its own defaults,
168/// which is right on Windows and wrong on every Linux box: the generic fallback
169/// resolves to nothing at all. Measured on Debian 13, where none of the three
170/// exists.
171fn set_generics(database: &mut fontdb::Database) {
172    let present = |database: &fontdb::Database, name: &str| {
173        query(database, &[fontdb::Family::Name(name)], Variant::default()).is_some()
174    };
175    let first = |database: &fontdb::Database, names: &[&str]| {
176        names
177            .iter()
178            .find(|name| present(database, name))
179            .map(|name| (*name).to_owned())
180    };
181
182    if let Some(name) = first(
183        database,
184        &[
185            "Liberation Serif",
186            "DejaVu Serif",
187            "Noto Serif",
188            "Times New Roman",
189            "Georgia",
190        ],
191    ) {
192        database.set_serif_family(name);
193    }
194    if let Some(name) = first(
195        database,
196        &[
197            "Liberation Sans",
198            "DejaVu Sans",
199            "Noto Sans",
200            "Arial",
201            "Helvetica",
202        ],
203    ) {
204        database.set_sans_serif_family(name);
205    }
206    if let Some(name) = first(
207        database,
208        &[
209            "Liberation Mono",
210            "DejaVu Sans Mono",
211            "Noto Sans Mono",
212            "Courier New",
213        ],
214    ) {
215        database.set_monospace_family(name);
216    }
217}
218
219/// The generic family a name suggests, for a machine that does not have it.
220///
221/// The names are the ones office documents actually carry. It is a short list on
222/// purpose: guessing from the name is what produces a serif document drawn in a
223/// sans face, so anything unrecognized asks for the default proportional family
224/// rather than for a shape.
225pub fn generic(family: &str) -> fontdb::Family<'static> {
226    let lower = family.to_ascii_lowercase();
227    if lower.contains("mono") || lower.contains("courier") || lower.contains("consol") {
228        return fontdb::Family::Monospace;
229    }
230    if lower.contains("times") || lower.contains("serif") || lower.contains("georgia") {
231        // `Liberation Sans` contains neither, and `Liberation Serif` contains
232        // `serif`, which is why the sans test comes second rather than first.
233        return fontdb::Family::Serif;
234    }
235    fontdb::Family::SansSerif
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn metric_substitutes_matches_regardless_of_case_or_spaces() {
244        assert_eq!(
245            metric_substitutes("Times New Roman"),
246            ["Liberation Serif", "DejaVu Serif"]
247        );
248        assert_eq!(
249            metric_substitutes("TIMES NEW ROMAN"),
250            ["Liberation Serif", "DejaVu Serif"]
251        );
252        assert_eq!(
253            metric_substitutes("TimesNewRoman"),
254            ["Liberation Serif", "DejaVu Serif"]
255        );
256        assert_eq!(metric_substitutes("Calibri"), ["Carlito"]);
257    }
258
259    #[test]
260    fn metric_substitutes_is_empty_for_a_family_with_no_known_substitute() {
261        assert_eq!(metric_substitutes("Comic Sans MS"), &[] as &[&str]);
262    }
263
264    /// `Liberation Mono` contains neither "times" nor "serif", but a family
265    /// named for a monospace face still has to be caught before falling
266    /// through to the serif check.
267    #[test]
268    fn generic_recognises_monospace_families() {
269        assert_eq!(generic("Liberation Mono"), fontdb::Family::Monospace);
270        assert_eq!(generic("Courier New"), fontdb::Family::Monospace);
271        assert_eq!(generic("Consolas"), fontdb::Family::Monospace);
272    }
273
274    #[test]
275    fn generic_recognises_serif_families() {
276        assert_eq!(generic("Times New Roman"), fontdb::Family::Serif);
277        assert_eq!(generic("Liberation Serif"), fontdb::Family::Serif);
278        assert_eq!(generic("Georgia"), fontdb::Family::Serif);
279    }
280
281    /// `Liberation Sans` contains "sans", not "serif", so the serif check must
282    /// not catch it: if it did, every sans-serif document would draw with
283    /// serifs.
284    #[test]
285    fn generic_defaults_to_sans_serif_rather_than_matching_serif_by_accident() {
286        assert_eq!(generic("Liberation Sans"), fontdb::Family::SansSerif);
287        assert_eq!(generic("Wingdings"), fontdb::Family::SansSerif);
288    }
289}