From 2ed2ce18d8e229e95776068e73daffb194f9fd96 Mon Sep 17 00:00:00 2001 From: Madhasoeki Date: Sat, 31 Oct 2026 22:25:50 +0700 Subject: [PATCH] Fonts: unavailable monospace fonts fall back to a monospace substitute (#1598) When a document uses a monospace font that is not installed on the system (such as Cousine, Source Code Pro, JetBrains Mono, Consolas, or Cascadia Code), document_face previously fell back to the generic proportional MISSING_FONT_FAMILY (Calibri), causing code snippets and columns to misalign. Add is_monospace_family to recognize monospace families, ensure substitutes returns open monospace replacements (Liberation Mono, Courier New, Menlo, Courier, DejaVu Sans Mono), recognize Cousine as a metric twin of Courier New, and keep monospace families on their substitute chain in document_face. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fonts/src/fontdb.rs | 80 ++++++++++++++++++++++++++++++++++++-- crates/fonts/src/lib.rs | 3 +- crates/fonts/src/tests.rs | 51 ++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 043b8f2d..cc2bd417 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -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,75 @@ 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 mono" + | "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 == "code" || 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,9 +1146,15 @@ 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 { - if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) { + if family.eq_ignore_ascii_case(FALLBACK_FAMILY) + || has_metric_twin(family) + || is_monospace_family(family) + || is_office_cloud_font(family) + || self.has_family(family) + { return self.face(family, style); } if let Some(twin) = office_twin(family) { diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 92d2cb44..0bb0edb5 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -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; diff --git a/crates/fonts/src/tests.rs b/crates/fonts/src/tests.rs index a6fca4c4..69f54227 100644 --- a/crates/fonts/src/tests.rs +++ b/crates/fonts/src/tests.rs @@ -237,6 +237,57 @@ 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 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", "Monotype Baskerville", "zzz"] { + assert!(!is_monospace_family(prop), "{prop} should not be recognized as monospace"); + } +} + +#[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]