mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-02 06:57:04 -06:00
Merge pull request #1614
Fonts: unavailable monospace fonts fall back to a monospace substitute (#1598)
This commit is contained in:
@@ -30,12 +30,15 @@ pub fn bundled() -> Vec<&'static [u8]> {
|
||||
/// Open substitutes for families documents often ask for but we can't ship (metric-compatible
|
||||
/// where one exists), tried in order after the family itself.
|
||||
pub fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
match family.to_ascii_lowercase().as_str() {
|
||||
let f = family.to_ascii_lowercase();
|
||||
if is_monospace_family(&f) {
|
||||
return &["Liberation Mono", "Courier New", "Menlo", "Courier", "DejaVu Sans Mono"];
|
||||
}
|
||||
match f.as_str() {
|
||||
"calibri" | "calibri light" => &["Carlito", "Liberation Sans", "Arial", "Inter"],
|
||||
"cambria" | "cambria math" => &["Caladea", "Liberation Serif", "Times New Roman", "Merriweather"],
|
||||
"arial" | "helvetica" | "helvetica neue" | "arial nova" => &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"],
|
||||
"times new roman" | "times" => &["Liberation Serif", "Times New Roman", "Times", "Merriweather"],
|
||||
"courier new" | "courier" | "consolas" | "menlo" => &["Liberation Mono", "Courier New", "Menlo", "Courier"],
|
||||
"aptos" | "aptos display" | "aptos narrow" | "segoe ui" | "segoe ui light" | "segoe ui semibold" | "tahoma" | "verdana" => {
|
||||
&["Inter", "Helvetica Neue", "Arial", "Liberation Sans"]
|
||||
}
|
||||
@@ -52,12 +55,77 @@ pub fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Families that are monospaced: when not installed, documents keep a monospaced substitute
|
||||
/// ([`substitutes`]) so code blocks and columns stay aligned instead of falling back to
|
||||
/// proportional Calibri.
|
||||
pub fn is_monospace_family(family: &str) -> bool {
|
||||
let f = family.to_ascii_lowercase();
|
||||
let s = f.trim();
|
||||
if matches!(
|
||||
s,
|
||||
"courier new"
|
||||
| "courier"
|
||||
| "cousine"
|
||||
| "consolas"
|
||||
| "menlo"
|
||||
| "monaco"
|
||||
| "source code pro"
|
||||
| "jetbrains mono"
|
||||
| "cascadia code"
|
||||
| "cascadia code pl"
|
||||
| "cascadia mono"
|
||||
| "cascadia mono pl"
|
||||
| "fira code"
|
||||
| "fira mono"
|
||||
| "inconsolata"
|
||||
| "dejavu sans mono"
|
||||
| "sf mono"
|
||||
| "roboto mono"
|
||||
| "hack"
|
||||
| "ubuntu mono"
|
||||
| "pt mono"
|
||||
| "liberation mono"
|
||||
| "andale mono"
|
||||
| "lucida console"
|
||||
| "lucida sans typewriter"
|
||||
| "fixedsys"
|
||||
| "terminal"
|
||||
| "monospace"
|
||||
| "iosevka"
|
||||
| "monolisa"
|
||||
| "envy code r"
|
||||
| "hasklig"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
s.starts_with("courier")
|
||||
|| s.starts_with("consolas")
|
||||
|| s.starts_with("inconsolata")
|
||||
|| s.starts_with("iosevka")
|
||||
|| s.ends_with(" mono")
|
||||
|| s.ends_with(" code")
|
||||
|| s.ends_with(" console")
|
||||
|| s.ends_with(" typewriter")
|
||||
|| s.ends_with(" monospace")
|
||||
|| s.split_whitespace().any(|w| w == "mono" || w == "terminal" || w == "monospace")
|
||||
}
|
||||
|
||||
/// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with
|
||||
/// them breaks into the same lines.
|
||||
fn has_metric_twin(family: &str) -> bool {
|
||||
matches!(
|
||||
family.to_ascii_lowercase().as_str(),
|
||||
"calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier"
|
||||
"calibri"
|
||||
| "calibri light"
|
||||
| "cambria"
|
||||
| "cambria math"
|
||||
| "arial"
|
||||
| "helvetica"
|
||||
| "times new roman"
|
||||
| "times"
|
||||
| "courier new"
|
||||
| "courier"
|
||||
| "cousine"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1080,6 +1148,7 @@ impl FontDb {
|
||||
/// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri,
|
||||
/// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial,
|
||||
/// see `office_twin`); any other missing family is drawn in Calibri (or Carlito).
|
||||
/// Monospaced families keep a monospaced substitute instead of falling back to Calibri.
|
||||
/// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks.
|
||||
pub fn document_face(&self, family: &str, style: &str) -> Arc<FontFace> {
|
||||
if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) {
|
||||
@@ -1088,6 +1157,9 @@ impl FontDb {
|
||||
if let Some(twin) = office_twin(family) {
|
||||
return self.face(twin, style);
|
||||
}
|
||||
if is_monospace_family(family) {
|
||||
return self.face(family, style);
|
||||
}
|
||||
self.face(MISSING_FONT_FAMILY, style)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ mod shape;
|
||||
|
||||
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
|
||||
pub use fontdb::{
|
||||
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs,
|
||||
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, is_monospace_family, substitutes,
|
||||
system_font_dirs,
|
||||
};
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::Tag;
|
||||
|
||||
@@ -216,9 +216,13 @@ fn missing_document_fonts_are_drawn_in_calibri() {
|
||||
assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}");
|
||||
}
|
||||
// PowerPoint's own twins and Windows' substitutes, when the font itself is missing.
|
||||
for (missing, twin) in
|
||||
[("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")]
|
||||
{
|
||||
for (missing, twin) in [
|
||||
("Liberation Sans", "Arial"),
|
||||
("Liberation Serif", "Times New Roman"),
|
||||
("Arial CE", "Arial"),
|
||||
("Times New Roman CYR", "Times New Roman"),
|
||||
("Courier New CE", "Courier New"),
|
||||
] {
|
||||
if !db.has_family(missing) {
|
||||
assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}");
|
||||
}
|
||||
@@ -237,6 +241,73 @@ fn office_fonts_have_substitutes() {
|
||||
assert!(substitutes("Times New Roman").contains(&"Liberation Serif"));
|
||||
assert!(substitutes("Aptos").contains(&"Inter"));
|
||||
assert!(substitutes("zzz").is_empty());
|
||||
|
||||
for mono in [
|
||||
"Courier New",
|
||||
"Cousine",
|
||||
"Consolas",
|
||||
"Menlo",
|
||||
"Monaco",
|
||||
"Source Code Pro",
|
||||
"JetBrains Mono",
|
||||
"Cascadia Code",
|
||||
"Cascadia Code PL",
|
||||
"Cascadia Mono",
|
||||
"Fira Code",
|
||||
"Inconsolata",
|
||||
"DejaVu Sans Mono",
|
||||
"Roboto Mono",
|
||||
"Hack",
|
||||
"Ubuntu Mono",
|
||||
"Custom Monospace",
|
||||
"Fantasque Sans Mono",
|
||||
] {
|
||||
assert!(substitutes(mono).contains(&"Liberation Mono"), "{mono} should have Liberation Mono substitute");
|
||||
assert!(is_monospace_family(mono), "{mono} should be recognized as monospace");
|
||||
}
|
||||
|
||||
for prop in ["Calibri", "Arial", "Times New Roman", "Inter", "Monotype Corsiva", "Monoton", "Monotype Baskerville", "Code Pro", "zzz"] {
|
||||
assert!(!is_monospace_family(prop), "{prop} should not be recognized as monospace");
|
||||
}
|
||||
|
||||
// Explicit checks requested in review: Consolas, Courier New, Fira Code, Monotype Corsiva, Monoton,
|
||||
// and ensuring proportional families maintain their unchanged substitutes.
|
||||
assert!(is_monospace_family("Consolas"));
|
||||
assert!(is_monospace_family("Courier New"));
|
||||
assert!(is_monospace_family("Fira Code"));
|
||||
assert!(!is_monospace_family("Monotype Corsiva"));
|
||||
assert!(!is_monospace_family("Monoton"));
|
||||
|
||||
assert_eq!(substitutes("Calibri"), &["Carlito", "Liberation Sans", "Arial", "Inter"]);
|
||||
assert_eq!(substitutes("Arial"), &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"]);
|
||||
assert_eq!(substitutes("Times New Roman"), &["Liberation Serif", "Times New Roman", "Times", "Merriweather"]);
|
||||
assert_eq!(substitutes("Georgia"), &["Merriweather", "Liberation Serif", "Georgia"]);
|
||||
assert!(substitutes("Monotype Corsiva").is_empty());
|
||||
assert!(substitutes("Monoton").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_monospace_fonts_keep_monospace_fallback() {
|
||||
let db = FontDb::global();
|
||||
let mono_sub = db.face("Unavailable JetBrains Mono", "Regular").family.clone();
|
||||
let calibri_target = db.face(MISSING_FONT_FAMILY, "Regular").family.clone();
|
||||
|
||||
assert!(is_monospace_family(&mono_sub), "substitute {mono_sub} must be monospace");
|
||||
|
||||
// Cousine is metric-compatible with Courier New / Liberation Mono and must not fall back to Calibri.
|
||||
if !db.has_family("Cousine") {
|
||||
assert_eq!(db.document_face("Cousine", "Regular").family, mono_sub);
|
||||
}
|
||||
|
||||
// Monospace fonts missing from the system must resolve to a monospace substitute.
|
||||
for mono in ["Unavailable JetBrains Mono", "Custom App Code", "Unknown Space Mono", "Mock Terminal Font"] {
|
||||
let f = db.document_face(mono, "Regular");
|
||||
assert_eq!(f.family, mono_sub, "{mono}");
|
||||
assert!(is_monospace_family(&f.family), "{mono} must resolve to a monospace family");
|
||||
if mono_sub != calibri_target {
|
||||
assert_ne!(f.family, calibri_target, "{mono} must not fall back to Calibri");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user