From 40ba76113ec5a19da9478418fa2eaea24590f3f7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:35:00 -0400 Subject: [PATCH] Text: a missing font is drawn in Calibri, as PowerPoint does Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/fonts/src/fontdb.rs | 24 ++++++++++++++++++++++++ crates/fonts/src/lib.rs | 18 +++++++++++++++++- crates/text/src/lib.rs | 2 +- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index d9c9ece..39b98f3 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -52,6 +52,19 @@ pub fn substitutes(family: &str) -> &'static [&'static str] { } } +/// 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" + ) +} + +/// The family PowerPoint draws a document's Latin text in when the font it names isn't installed, +/// whatever the theme, `panose` or `pitchFamily` say. +pub const MISSING_FONT_FAMILY: &str = "Calibri"; + #[derive(Clone)] enum FontBytes { Static(&'static [u8]), @@ -756,6 +769,17 @@ impl FontDb { .unwrap_or_else(last_resort_face) } + /// The face a document's text in `family` is drawn with, as PowerPoint draws it: the family + /// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri, + /// Liberation Sans for Arial…); any other missing family is drawn in Calibri (or Carlito). + /// 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) || self.has_family(family) { + return self.face(family, style); + } + self.face(MISSING_FONT_FAMILY, style) + } + fn try_family(&self, family: &str, style: &str) -> Option> { if let Some(f) = self.find(family, style) { return Some(f); diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 8caff94..a83b363 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -14,7 +14,9 @@ pub mod script; 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, base_style, bundled, substitutes, system_font_dirs}; +pub use fontdb::{ + FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs, +}; pub use harfrust::Feature; use harfrust::Tag; pub use kurbo::BezPath; @@ -182,6 +184,20 @@ mod tests { assert!(!shape(&f, "Hello", &[], |c| c).is_empty()); } + #[test] + fn missing_document_fonts_are_drawn_in_calibri() { + // PowerPoint draws a missing family in Calibri, whatever its panose or the theme says. + let db = FontDb::global(); + for missing in ["Qqq Nonexistent", "Avenir Next Qqq", ""] { + let f = db.document_face(missing, "Bold"); + assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}"); + } + // Metric-compatible stand-ins and DeckCraft's default keep their own fallbacks. + for kept in ["Arial", "Times New Roman", "Cambria", "Courier New", FALLBACK_FAMILY] { + assert_eq!(db.document_face(kept, "Regular").family, db.face(kept, "Regular").family, "{kept}"); + } + } + #[test] fn office_fonts_have_substitutes() { assert!(substitutes("Calibri").contains(&"Carlito")); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 7144c55..69e6599 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -261,7 +261,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option) -> Styl let family = resolve::font_family(ctx, r); let bold = r.bold.unwrap_or(false); let italic = r.italic.unwrap_or(false); - let face = FontDb::global().face(&family, style_name(bold, italic)); + let face = FontDb::global().document_face(&family, style_name(bold, italic)); let fake_bold = bold && face.weight < 550.0; let fake_italic = italic && !face.italic; let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0);