pub(crate) const FONT_SUBSTITUTIONS: &[(&str, &[&str])] = &[
("Calibri", &["Carlito", "Liberation Sans", "Noto Sans"]),
("Cambria", &["Caladea", "Liberation Serif", "Noto Serif"]),
("Arial", &["Liberation Sans", "Noto Sans", "Helvetica"]),
(
"Times New Roman",
&["Liberation Serif", "Noto Serif", "Times"],
),
(
"Courier New",
&["Liberation Mono", "Noto Sans Mono", "Courier"],
),
("Verdana", &["DejaVu Sans", "Noto Sans"]),
("Georgia", &["DejaVu Serif", "Noto Serif"]),
("Trebuchet MS", &["Ubuntu", "Noto Sans"]),
(
"Consolas",
&["Inconsolata", "Liberation Mono", "Noto Sans Mono"],
),
("Segoe UI", &["Noto Sans", "Liberation Sans"]),
];
pub(crate) fn strip_weight_suffix(name: &str) -> Option<&str> {
let trimmed = name.trim_end();
let mut best: Option<&str> = None;
for weight in (100..=900).step_by(100) {
for suffix in canonical_weight_names(weight) {
let Some(cut) = trimmed.len().checked_sub(suffix.len()) else {
continue;
};
if cut == 0 || !trimmed.is_char_boundary(cut) {
continue;
}
if !trimmed[cut..].eq_ignore_ascii_case(suffix) {
continue;
}
let base = trimmed[..cut].trim_end();
if base.len() == cut || base.is_empty() {
continue;
}
if best.is_none_or(|b| base.len() < b.len()) {
best = Some(base);
}
}
}
best
}
pub(crate) fn substitutes_for(family: &str) -> Option<(&'static str, &'static [&'static str])> {
let lookup = |name: &str| {
FONT_SUBSTITUTIONS
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(key, subs)| (*key, *subs))
};
lookup(family).or_else(|| lookup(strip_weight_suffix(family)?))
}
pub(crate) fn canonical_weight_names(weight: i32) -> &'static [&'static str] {
match weight {
100 => &["Thin", "Hairline"],
200 => &["ExtraLight", "Extra Light", "UltraLight", "Ultra Light"],
300 => &["Light"],
400 => &["Regular", "Normal"],
500 => &["Medium"],
600 => &["Semibold", "SemiBold", "Semi Bold", "DemiBold", "Demi Bold"],
700 => &["Bold"],
800 => &["ExtraBold", "Extra Bold", "UltraBold", "Ultra Bold"],
900 => &["Black", "Heavy"],
_ => &[],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_a_trailing_weight_word_to_the_base_family() {
assert_eq!(strip_weight_suffix("Segoe UI Light"), Some("Segoe UI"));
assert_eq!(strip_weight_suffix("Calibri Light"), Some("Calibri"));
assert_eq!(strip_weight_suffix("Segoe UI Semibold"), Some("Segoe UI"));
assert_eq!(strip_weight_suffix("Arial Black"), Some("Arial"));
assert_eq!(strip_weight_suffix("Calibri LIGHT"), Some("Calibri"));
assert_eq!(strip_weight_suffix("Foo Extra Bold"), Some("Foo"));
}
#[test]
fn longest_weight_suffix_wins() {
assert_eq!(strip_weight_suffix("Foo Extra Light"), Some("Foo"));
assert_eq!(strip_weight_suffix("Foo Ultra Bold"), Some("Foo"));
}
#[test]
fn weight_word_must_be_its_own_word() {
assert_eq!(strip_weight_suffix("Highlight"), None);
assert_eq!(strip_weight_suffix("Blackadder"), None);
assert_eq!(strip_weight_suffix("Light"), None, "nothing precedes it");
assert_eq!(strip_weight_suffix("Times New Roman"), None);
}
#[test]
fn substitutes_reach_face_qualified_names_through_the_base_family() {
let (matched, subs) = substitutes_for("Segoe UI").expect("base family is in the table");
assert_eq!(matched, "Segoe UI");
let (via, face_subs) =
substitutes_for("Segoe UI Light").expect("face-qualified name must reach the family");
assert_eq!(via, "Segoe UI", "resolved through the base family");
assert_eq!(face_subs, subs, "and gets the same substitutes");
assert_eq!(
substitutes_for("Calibri Light").map(|(k, _)| k),
Some("Calibri")
);
assert!(substitutes_for("Wingdings").is_none());
assert!(substitutes_for("Wingdings Light").is_none());
}
}