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>
This commit is contained in:
Madhasoeki
2026-10-31 22:25:50 +07:00
co-authored by Copilot
parent a3fbf02952
commit 2ed2ce18d8
3 changed files with 129 additions and 5 deletions
+76 -4
View File
@@ -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<FontFace> {
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) {
+2 -1
View File
@@ -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;
+51
View File
@@ -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]