From 2ed2ce18d8e229e95776068e73daffb194f9fd96 Mon Sep 17 00:00:00 2001 From: Madhasoeki Date: Sat, 31 Oct 2026 22:25:50 +0700 Subject: [PATCH 1/4] 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] From 8ec4f29059de67706a72e1066b06cc592828aa80 Mon Sep 17 00:00:00 2001 From: Madhasoeki Date: Sun, 1 Nov 2026 07:19:41 +0700 Subject: [PATCH 2/4] Fonts: refine monospace heuristic and preserve office_twin order (#1598) - Avoid false-positive monospace match on 'code' in split_whitespace for proportional families like Code Pro. - Check office_twin before is_monospace_family in document_face so alias families like Courier New CE resolve to Courier New when installed. - Add negative tests for Code Pro and test Courier New CE mapping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fonts/src/fontdb.rs | 12 +++++------- crates/fonts/src/tests.rs | 12 ++++++++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index cc2bd417..1cffbe00 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -105,7 +105,7 @@ pub fn is_monospace_family(family: &str) -> bool { || 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") + || s.split_whitespace().any(|w| w == "mono" || w == "terminal" || w == "monospace") } /// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with @@ -1149,17 +1149,15 @@ impl FontDb { /// 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_monospace_family(family) - || is_office_cloud_font(family) - || self.has_family(family) - { + if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) { return self.face(family, style); } 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) } diff --git a/crates/fonts/src/tests.rs b/crates/fonts/src/tests.rs index 69f54227..f912f998 100644 --- a/crates/fonts/src/tests.rs +++ b/crates/fonts/src/tests.rs @@ -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}"); } @@ -261,7 +265,7 @@ fn office_fonts_have_substitutes() { 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"] { + for prop in ["Calibri", "Arial", "Times New Roman", "Inter", "Monotype Corsiva", "Monotype Baskerville", "Code Pro", "zzz"] { assert!(!is_monospace_family(prop), "{prop} should not be recognized as monospace"); } } From c0468b59cd043286ca7453c0465da898a4f36751 Mon Sep 17 00:00:00 2001 From: Madhasoeki Date: Sun, 1 Nov 2026 10:31:17 +0700 Subject: [PATCH 3/4] Fonts: classify Cascadia Code PL as monospace (#1598) Add Cascadia Code PL and Cascadia Mono PL to the monospace family matching list, and add regression tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fonts/src/fontdb.rs | 2 ++ crates/fonts/src/tests.rs | 1 + 2 files changed, 3 insertions(+) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 1cffbe00..2e2f3529 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -72,7 +72,9 @@ pub fn is_monospace_family(family: &str) -> bool { | "source code pro" | "jetbrains mono" | "cascadia code" + | "cascadia code pl" | "cascadia mono" + | "cascadia mono pl" | "fira code" | "fira mono" | "inconsolata" diff --git a/crates/fonts/src/tests.rs b/crates/fonts/src/tests.rs index f912f998..dc52cfbb 100644 --- a/crates/fonts/src/tests.rs +++ b/crates/fonts/src/tests.rs @@ -251,6 +251,7 @@ fn office_fonts_have_substitutes() { "Source Code Pro", "JetBrains Mono", "Cascadia Code", + "Cascadia Code PL", "Cascadia Mono", "Fira Code", "Inconsolata", From f3d8ba85a62c72bb526dad3b91852485b1ab4f09 Mon Sep 17 00:00:00 2001 From: Madhasoeki Date: Sun, 1 Nov 2026 11:35:53 +0700 Subject: [PATCH 4/4] tests(fonts): add specific tests requested by reviewer for monospace resolution Verify Consolas, Courier New, Fira Code, Monotype Corsiva, Monoton, and ensure proportional family substitutes remain unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fonts/src/tests.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/fonts/src/tests.rs b/crates/fonts/src/tests.rs index dc52cfbb..596415d5 100644 --- a/crates/fonts/src/tests.rs +++ b/crates/fonts/src/tests.rs @@ -266,9 +266,24 @@ fn office_fonts_have_substitutes() { assert!(is_monospace_family(mono), "{mono} should be recognized as monospace"); } - for prop in ["Calibri", "Arial", "Times New Roman", "Inter", "Monotype Corsiva", "Monotype Baskerville", "Code Pro", "zzz"] { + 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]