From 55a53f8ecb9d38a067cb79bc5f09b049fbe66a6f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 19:05:48 -0400 Subject: [PATCH] Fonts: missing fonts draw as PowerPoint draws them, and Windows family names resolve (#322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Text: a missing font is drawn in Calibri, as PowerPoint does Co-Authored-By: Claude Opus 5.5 (1M context) * Fonts: Windows family names find their faces; PowerPoint's twins for missing fonts "Segoe UI Semibold", "Arial Black" and the like are the faces' Windows (GDI) family names; a family's installed files now load together; Liberation Sans and Serif draw as Arial and Times New Roman when missing, as PowerPoint draws them. Co-Authored-By: Claude Opus 5.5 (1M context) * Fonts: Office cloud fonts (Aptos…) keep their open substitute Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ATTRIBUTION.md | 2 + crates/fonts/src/fontdb.rs | 135 ++++++++++++++++-- crates/fonts/src/lib.rs | 63 +++++++- .../fonts/tests/fixtures/legacy-regular.ttf | Bin 0 -> 864 bytes .../fonts/tests/fixtures/legacy-semibold.ttf | Bin 0 -> 944 bytes .../fonts/tests/fixtures/make_legacy_font.py | 55 +++++++ crates/text/src/lib.rs | 2 +- 7 files changed, 244 insertions(+), 13 deletions(-) create mode 100644 crates/fonts/tests/fixtures/legacy-regular.ttf create mode 100644 crates/fonts/tests/fixtures/legacy-semibold.ttf create mode 100644 crates/fonts/tests/fixtures/make_legacy_font.py diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index b0d7747..9ca8e5e 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -28,6 +28,8 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene | `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests | | `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures | | `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests | +| `crates/fonts/tests/fixtures/legacy-semibold.ttf` | DeckCraft contributors | a one-glyph Semibold face named like Segoe UI Semibold (its Windows family differs from the typographic one), built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests | +| `crates/fonts/tests/fixtures/legacy-regular.ttf` | DeckCraft contributors | the same family's one-glyph Regular face, built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests | | `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font | ## Bundled through dependencies diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index d9c9ece..3a28ce1 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -52,6 +52,53 @@ 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 installed family PowerPoint draws a missing `family` with instead of Calibri: Liberation +/// Sans and Serif as Arial and Times New Roman (observed in PowerPoint; Liberation Mono, Carlito, +/// Caladea, Arimo and Tinos are not mapped and draw in Calibri), and Windows' standard font +/// substitutes (Arial CE, Times New Roman CYR…). +fn office_twin(family: &str) -> Option<&'static str> { + let f = family.to_ascii_lowercase(); + Some(match f.as_str() { + "liberation sans" | "helvetica" | "helv" => "Arial", + "liberation serif" | "times" | "tms rmn" => "Times New Roman", + _ => { + let base = f + .strip_suffix(" baltic") + .or(f.strip_suffix(" ce")) + .or(f.strip_suffix(" cyr")) + .or(f.strip_suffix(" greek")) + .or(f.strip_suffix(" tur"))?; + match base { + "arial" => "Arial", + "courier new" => "Courier New", + "times new roman" => "Times New Roman", + _ => return None, + } + } + }) +} + +/// Office's downloadable (cloud) families: PowerPoint fetches them when a document uses them, so +/// they draw as themselves there, not as Calibri. Without them DeckCraft keeps their closest open +/// substitute ([`substitutes`]; Aptos as Inter). +fn is_office_cloud_font(family: &str) -> bool { + let f = family.to_ascii_lowercase(); + f.starts_with("aptos") || ["bierstadt", "grandview", "seaford", "skeena", "tenorite"].iter().any(|c| f.starts_with(c)) +} + +/// 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]), @@ -65,6 +112,10 @@ pub struct FontFace { pub family: String, /// Typographic style name (e.g. "Semibold", "Italic"). pub style: String, + /// The Windows (GDI) family and style names (`name` IDs 1 and 2) when the family differs from + /// the typographic one: "Segoe UI Semibold" / "Regular" for Segoe UI Semibold. Documents name + /// fonts this way. + pub legacy: Option<(String, String)>, /// usWeightClass-style weight (400 = regular). pub weight: f32, pub italic: bool, @@ -253,6 +304,11 @@ pub struct FontDb { /// scan, so what they find doesn't depend on what ran before them. #[cfg(not(target_arch = "wasm32"))] cataloged: std::sync::OnceLock<()>, + /// Families (lower case) whose cataloged files have all been loaded: a family's files load + /// together, so a lookup never settles for the one face another name loaded ("Segoe UI + /// Semibold" loads a face of the family "Segoe UI"). + #[cfg(not(target_arch = "wasm32"))] + loaded_families: Mutex>, /// System fallback state: enabled, and characters no system font covers. #[cfg(not(target_arch = "wasm32"))] sys: Mutex, @@ -316,6 +372,16 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { Some((family, style)) } +/// A face's Windows (GDI) family and style (`name` IDs 1 and 2) when that family differs from the +/// typographic `family`. +fn legacy_names(f: &skrifa::FontRef<'_>, family: &str) -> Option<(String, String)> { + let legacy = name(f, &[StringId::FAMILY_NAME])?; + if legacy.eq_ignore_ascii_case(family) { + return None; + } + Some((legacy, name(f, &[StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()))) +} + /// (family, style) of every face in the font file at `path`, reading only its table directories /// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. A /// variable font is cataloged under its default style; its named instances appear once it loads. @@ -362,8 +428,13 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> { font.extend_from_slice(&u32::to_be_bytes(v)); } font.extend_from_slice(&table); - face_names(&skrifa::FontRef::new(&font).ok()?) + let f = skrifa::FontRef::new(&font).ok()?; + let (family, style) = face_names(&f)?; + // Cataloged under its Windows family too, so "Segoe UI Semibold" finds the file. + let legacy = legacy_names(&f, &family); + Some(std::iter::once((family, style)).chain(legacy)) }) + .flatten() .collect() } @@ -491,10 +562,12 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords .map(|v| v >= 0.5) .or(axis(b"slnt").map(|v| v.abs() > 0.1)) .unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style)); + let legacy = if coords.is_empty() { legacy_names(&f, &family) } else { None }; Some(FontFace { id: NEXT_ID.fetch_add(1, Ordering::Relaxed), family, style, + legacy, weight, italic, upem: m.units_per_em.max(1) as f64, @@ -571,6 +644,8 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] cataloged: std::sync::OnceLock::new(), #[cfg(not(target_arch = "wasm32"))] + loaded_families: Mutex::new(std::collections::HashSet::new()), + #[cfg(not(target_arch = "wasm32"))] sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), } } @@ -676,6 +751,8 @@ impl FontDb { /// Returns the number of faces cataloged. #[cfg(not(target_arch = "wasm32"))] pub fn load_system_fonts(&self) -> usize { + // Families load again from the new catalog when next looked up. + self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()).clear(); // The first scan, or a rescan once it is done (never both at once). let mut first = None; self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs())); @@ -756,17 +833,35 @@ 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…), or the family PowerPoint maps it to (Liberation Sans as Arial, + /// see `office_twin`); 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) || 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); + } + 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); - } + // Every installed file of the family first, so the best style is among the candidates. The + // lock is held while the files load, so another thread looking up the family waits for + // them rather than finding none and falling back to another family. #[cfg(not(target_arch = "wasm32"))] - if self.load_cataloged(family) - && let Some(f) = self.find(family, style) { - return Some(f); + let mut loaded = self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()); + let key = family.to_lowercase(); + if !loaded.contains(&key) { + self.load_cataloged(family); + loaded.insert(key); + } } - None + self.find(family, style) } /// Is `family` available (loaded, or installed on the system)? @@ -782,7 +877,9 @@ impl FontDb { } pub(crate) fn is_loaded(&self, family: &str) -> bool { - self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family)) + self.read_faces() + .iter() + .any(|f| f.family.eq_ignore_ascii_case(family) || f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family))) } /// A variable font's axes: (tag, name, min, default, max), empty for static fonts. @@ -837,11 +934,27 @@ impl FontDb { return self.instance(family, style); } let faces = self.read_faces(); - let cands: Vec<&Arc> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); + let mut cands: Vec<&Arc> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); + let ns = norm(style); + if cands.is_empty() { + // A Windows family name ("Segoe UI Semibold", "Arial Black"): its faces, matched by + // their Windows style ("Regular", "Bold"…). + cands = faces.iter().filter(|f| f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family))).collect(); + if let Some(f) = cands.iter().find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == ns)) { + return Some((*f).clone()); + } + // Bold of a family that is itself a weight (Semibold, Light): the face, made bolder + // by the caller, as Windows does, rather than a face of another weight. + if let Some(f) = cands + .iter() + .find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == norm(if style_italic(style) { "Italic" } else { "Regular" }))) + { + return Some((*f).clone()); + } + } if cands.is_empty() { return None; } - let ns = norm(style); if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) { return Some((*f).clone()); } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 8caff94..16be2ca 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,65 @@ mod tests { assert!(!shape(&f, "Hello", &[], |c| c).is_empty()); } + #[test] + fn windows_family_names_find_their_faces() { + // Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's + // typographic family is "Segoe UI" with a "Semibold" style. The fixture is named the same way. + let check = |db: &FontDb| { + assert!(db.has_family("DeckCraft Legacy Semibold")); + for style in ["Regular", "Bold"] { + let f = db.face("DeckCraft Legacy Semibold", style); + assert_eq!((f.family.as_str(), f.style.as_str()), ("DeckCraft Legacy", "Semibold"), "{style}"); + } + assert_eq!(db.document_face("DeckCraft Legacy Semibold", "Regular").family, "DeckCraft Legacy", "installed, so not Calibri"); + // The typographic name still works. + assert_eq!(db.face("DeckCraft Legacy", "Semibold").style, "Semibold"); + }; + // Loaded directly… + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec()), 1); + check(&db); + // …and found by the system font scan under its Windows name, then loaded. + #[cfg(not(target_arch = "wasm32"))] + { + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.set_system_fallback(false); + check(&db); + // Looking up the Windows name loads the Semibold file only; a later lookup of the + // family's Regular must still load and pick the Regular file, not settle for Semibold. + let db = FontDb::with_font_dirs(vec![dir]); + db.set_system_fallback(false); + assert_eq!(db.face("DeckCraft Legacy Semibold", "Regular").style, "Semibold"); + let regular = db.face("DeckCraft Legacy", "Regular"); + assert_eq!((regular.style.as_str(), regular.weight), ("Regular", 400.0)); + } + } + + #[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", "", "Arimo Qqq"] { + let f = db.document_face(missing, "Bold"); + 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")] + { + if !db.has_family(missing) { + assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}"); + } + } + // Metric-compatible stand-ins and DeckCraft's default keep their own fallbacks. + // So do Office's cloud fonts, which PowerPoint downloads and draws as themselves. + for kept in ["Arial", "Times New Roman", "Cambria", "Courier New", FALLBACK_FAMILY, "Aptos", "Aptos Display", "Grandview"] { + 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/fonts/tests/fixtures/legacy-regular.ttf b/crates/fonts/tests/fixtures/legacy-regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ab660c655706868389399fb1b8bfea1857c091b8 GIT binary patch literal 864 zcmah{ze^)Q7=1evb5ZpCK|rw@LBvKQ{x~cI2}UbHQILRz4!fJ=FwqcKFpXG=rG6UaE?KmYrA)m|_tGV@uwR`fvzmclO9@IT}A{%zHXSRw^5lmxd&}iklDn*vB&_YM^ zWqU-vSuVmf(l&!s_|worG2&~K&d!!}BzXhVDao01u-bI#T8)ggc3XE3^RAA0>+tsc zexiGz+jCyU^?Qa#Tx~dr{0_RvKX3Yj%wmZ?^hP*V=ZRBInKZFho+>OvSl}me5tbN3 z5Mh(|(-X%i&N9BcHCNHOKrQ-t?N(R({9aL48fDd}TlYvLBLg2UwlIeZG$#j~vq&vZ i=Aw#~5bvQ#UTNx9P5t$~M;hGU{~_Tce-(2+2z&rY{e5Bp literal 0 HcmV?d00001 diff --git a/crates/fonts/tests/fixtures/legacy-semibold.ttf b/crates/fonts/tests/fixtures/legacy-semibold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..44b0ec7c74927e1bf70163cde2a468c9687777b7 GIT binary patch literal 944 zcmah{&1(}u9DO^pP2*=1=|Mehp@^4K>4)@CkkAMs1*L%!=s~Bun-99%gfxZbCLW~6 z9_rP23;VaF-JpPYyXOSe!=| z&-9e;H7YZBX|Z6hw-(Ek?xt`CkFaMwIaWWgIM4b=#n9haYN<-wTMnzr?|7v!uCF(u zU?r?>N2)c5oA6XWJkV`kUq=xkd~9F|EhtpbX2)e}RWctPvJzR0I@ZxZ#CKdtqT9rZ eIm7ws?5UybVgKL#u6Fy^7nl5LgF}CjlfWPG&xlw6 literal 0 HcmV?d00001 diff --git a/crates/fonts/tests/fixtures/make_legacy_font.py b/crates/fonts/tests/fixtures/make_legacy_font.py new file mode 100644 index 0000000..2753f92 --- /dev/null +++ b/crates/fonts/tests/fixtures/make_legacy_font.py @@ -0,0 +1,55 @@ +"""Build the legacy-name test font: DeckCraft Legacy Semibold (contributor-original, CC0-1.0). + +Usage: python make_legacy_font.py legacy-semibold.ttf legacy-regular.ttf (needs fontTools) + +legacy-semibold.ttf: typographic family "DeckCraft Legacy", style "Semibold" (name IDs 16/17, +weight 600); Windows family "DeckCraft Legacy Semibold", style "Regular" (name IDs 1/2), as fonts +like Segoe UI Semibold are named. legacy-regular.ttf: the family's Regular ("DeckCraft Legacy", +"Regular", weight 400), named only by IDs 1/2 as Segoe UI's regular file is. Glyphs: .notdef and +a square for "A". +""" +import sys + +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen + + +def square(): + pen = TTGlyphPen(None) + pen.moveTo((100, 0)) + pen.lineTo((100, 700)) + pen.lineTo((600, 700)) + pen.lineTo((600, 0)) + pen.closePath() + return pen.glyph() + + +def build(path, names, weight): + fb = FontBuilder(1000, isTTF=True) + fb.setupGlyphOrder([".notdef", "A"]) + fb.setupCharacterMap({ord("A"): "A"}) + fb.setupGlyf({".notdef": TTGlyphPen(None).glyph(), "A": square()}) + fb.setupHorizontalMetrics({".notdef": (500, 0), "A": (700, 100)}) + fb.setupHorizontalHeader(ascent=800, descent=-200) + fb.setupNameTable(names) + fb.setupOS2(usWeightClass=weight, sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, usWinDescent=200) + fb.setupPost() + fb.save(path) + + +build(sys.argv[1], { + "familyName": "DeckCraft Legacy Semibold", + "styleName": "Regular", + "typographicFamily": "DeckCraft Legacy", + "typographicSubfamily": "Semibold", + "uniqueFontIdentifier": "DeckCraft Legacy Semibold", + "fullName": "DeckCraft Legacy Semibold", + "psName": "DeckCraftLegacy-Semibold", +}, 600) +build(sys.argv[2], { + "familyName": "DeckCraft Legacy", + "styleName": "Regular", + "uniqueFontIdentifier": "DeckCraft Legacy Regular", + "fullName": "DeckCraft Legacy", + "psName": "DeckCraftLegacy-Regular", +}, 400) 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);