diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 043b8f2d..2e2f3529 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,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 { 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) } 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..596415d5 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}"); } @@ -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]