diff --git a/sugarloaf/src/font/constants.rs b/sugarloaf/src/font/constants.rs index b704a0fc..7f5c0f8f 100644 --- a/sugarloaf/src/font/constants.rs +++ b/sugarloaf/src/font/constants.rs @@ -7,14 +7,19 @@ macro_rules! font { pub const DEFAULT_FONT_FAMILY: &str = "cascadiacode"; -pub const FONT_CASCADIAMONO_BOLD: &[u8] = - font!("./resources/CascadiaCode/CascadiaCode-Bold.otf"); +/// Cascadia Code Nerd Font, upright, variable `wght` axis (200–700). +/// Used for Regular and Bold slots — same outlines, different `wght` value. +pub const FONT_CASCADIA_CODE_NF: &[u8] = + font!("./resources/CascadiaCode/CascadiaCodeNF.ttf"); -pub const FONT_CASCADIAMONO_BOLD_ITALIC: &[u8] = - font!("./resources/CascadiaCode/CascadiaCode-BoldItalic.otf"); +/// Cascadia Code Nerd Font, italic, variable `wght` axis (200–700). +/// Used for Italic and Bold-Italic slots — same outlines, different `wght` value. +pub const FONT_CASCADIA_CODE_NF_ITALIC: &[u8] = + font!("./resources/CascadiaCode/CascadiaCodeNFItalic.ttf"); -pub const FONT_CASCADIAMONO_ITALIC: &[u8] = - font!("./resources/CascadiaCode/CascadiaCode-Italic.otf"); +/// Default `wght` axis value used when a slot wants regular weight. +pub const WGHT_REGULAR: f32 = 400.0; -pub const FONT_CASCADIAMONO_NF_REGULAR: &[u8] = - font!("./resources/CascadiaCode/CascadiaCodeNF-Regular.otf"); +/// `wght` axis value used when a slot wants bold weight (matches the +/// Cascadia Code variable font's bold instance). +pub const WGHT_BOLD: f32 = 700.0; diff --git a/sugarloaf/src/font/macos.rs b/sugarloaf/src/font/macos.rs index 2e865339..be02d3b2 100644 --- a/sugarloaf/src/font/macos.rs +++ b/sugarloaf/src/font/macos.rs @@ -297,6 +297,51 @@ impl FontHandle { pub fn postscript_name(&self) -> String { self.base_font.postscript_name() } + + /// Return a derived `FontHandle` with the `wght` variation axis pinned + /// to `value`. Used to bake the bold weight into Rio's bold/bold-italic + /// fallback slots from a single variable-font file (matches ghostty's + /// `Face.setVariations` flow in `face/coretext.zig:225-254`). + /// + /// The returned CTFont references the same underlying font data — only + /// the descriptor's variation attributes change. Returns `None` if + /// CoreText refuses to construct the derived descriptor or font. + pub fn with_wght_variation(self, value: f32) -> Option { + use core_foundation::base::TCFType; + use core_foundation::dictionary::CFDictionary; + use core_foundation::number::CFNumber; + use core_foundation::string::CFString; + + // The wght axis identifier in CoreText is the four-byte tag + // 'w','g','h','t' as a CFNumber. Match Cascadia's axis registration. + const WGHT_TAG: i64 = u32::from_be_bytes(*b"wght") as i64; + + let id_num = CFNumber::from(WGHT_TAG); + let val_num = CFNumber::from(value as f64); + let variation: CFDictionary = CFDictionary::from_CFType_pairs( + &[(id_num.as_CFType(), val_num.as_CFType())], + ); + let var_attr_key = + unsafe { CFString::wrap_under_get_rule(kCTFontVariationAttribute) }; + let attrs: CFDictionary = CFDictionary::from_CFType_pairs(&[ + (var_attr_key, variation.as_CFType()), + ]); + let desc = font_descriptor::new_from_attributes(&attrs); + + let derived_ref = unsafe { + CTFontCreateCopyWithAttributes( + self.base_font.as_concrete_TypeRef(), + 0.0, + std::ptr::null(), + desc.as_concrete_TypeRef(), + ) + }; + if derived_ref.is_null() { + return None; + } + let derived = unsafe { CTFont::wrap_under_create_rule(derived_ref) }; + Some(Self { base_font: derived }) + } } /// Output of a single glyph rasterization. Mirrors the fields of @@ -1398,7 +1443,7 @@ pub fn shape_text_utf16( #[cfg(test)] mod tests { use super::*; - use crate::font::constants::FONT_CASCADIAMONO_NF_REGULAR; + use crate::font::constants::FONT_CASCADIA_CODE_NF; use core_foundation::base::CFIndex; fn glyph_id_for_char(handle: &FontHandle, size: f64, ch: char) -> u16 { @@ -1424,7 +1469,7 @@ mod tests { #[test] fn shapes_ascii_monospace() { let handle = - FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + FontHandle::from_bytes(FONT_CASCADIA_CODE_NF).expect("load font"); let glyphs = shape_text(&handle, "Hello", 18.0); assert_eq!(glyphs.len(), 5, "one glyph per ASCII char"); @@ -1459,7 +1504,7 @@ mod tests { // CascadiaMono carries a well-formed OS/2 table, so we should get // real values — not the x-height/2 fallback. let handle = - FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + FontHandle::from_bytes(FONT_CASCADIA_CODE_NF).expect("load font"); let m = font_metrics(&handle, 24.0); assert!(m.strikeout_thickness > 0.0, "thickness should be positive"); @@ -1485,7 +1530,7 @@ mod tests { // path swapped indices for the slow-path output it'd still compile, // but the cluster values would be wrong for multi-byte text. let handle = - FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + FontHandle::from_bytes(FONT_CASCADIA_CODE_NF).expect("load font"); let glyphs = shape_text(&handle, "abcde", 18.0); for (i, g) in glyphs.iter().enumerate() { assert_eq!(g.cluster, i as u32); @@ -1497,7 +1542,7 @@ mod tests { // Mixed BMP non-ASCII: 'é' is 2 bytes in UTF-8, 1 code unit in UTF-16. // Byte offsets should jump accordingly. let handle = - FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + FontHandle::from_bytes(FONT_CASCADIA_CODE_NF).expect("load font"); let glyphs = shape_text(&handle, "aébc", 18.0); // Expected clusters: a=0, é=1, b=3 (after 2-byte é), c=4. assert_eq!(glyphs.len(), 4); @@ -1510,7 +1555,7 @@ mod tests { #[test] fn shapes_empty_input() { let handle = - FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + FontHandle::from_bytes(FONT_CASCADIA_CODE_NF).expect("load font"); assert!(shape_text(&handle, "", 18.0).is_empty()); } @@ -1520,7 +1565,7 @@ mod tests { // returns a font (usually the primary itself). The result must not be // null — Rio's lazy-discovery path relies on that. let primary = - FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load primary"); + FontHandle::from_bytes(FONT_CASCADIA_CODE_NF).expect("load primary"); let result = discover_fallback(&primary, 'A'); assert!( result.is_some(), @@ -1535,7 +1580,7 @@ mod tests { // back. The test asserts non-null and, to avoid being flaky on // different macOS versions, does not hardcode the PS name. let primary = - FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load primary"); + FontHandle::from_bytes(FONT_CASCADIA_CODE_NF).expect("load primary"); let fallback = discover_fallback(&primary, '\u{6C34}') .expect("CoreText should cascade to a CJK font for 水"); // The discovered font must cover the codepoint — the whole @@ -1564,7 +1609,7 @@ mod tests { // single-font fragment), but we still exercise the multi-run // path here because it's the cheapest invariant to regress on. let handle = - FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + FontHandle::from_bytes(FONT_CASCADIA_CODE_NF).expect("load font"); // "A水B" — the CJK "water" ideograph is not in CascadiaMono, so // CoreText will cascade into a system CJK font for the middle // glyph, splitting the line across 3 CTRuns. @@ -1609,7 +1654,7 @@ mod tests { // CTFontDescriptor → CTFont → rasterize. Verifies the FFI is wired // correctly and the ref-don't-copy CFData is accepted by // CTFontManagerCreateFontDescriptorFromData. - let handle = FontHandle::from_static_bytes(FONT_CASCADIAMONO_NF_REGULAR) + let handle = FontHandle::from_static_bytes(FONT_CASCADIA_CODE_NF) .expect("static bytes should parse"); let size = 18.0; let gid = glyph_id_for_char(&handle, size as f64, 'M'); @@ -1623,7 +1668,7 @@ mod tests { #[test] fn rasterizes_an_inked_glyph() { let handle = - FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + FontHandle::from_bytes(FONT_CASCADIA_CODE_NF).expect("load font"); let size = 24.0; let gid = glyph_id_for_char(&handle, size as f64, 'A'); let g = rasterize_glyph(&handle, gid, size, false, false, false) @@ -1657,7 +1702,7 @@ mod tests { // This test is a regression guard — if `cascade_list_for_languages` // ever returns empty for a legit font, dynamic fallback stops working. let handle = - FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + FontHandle::from_bytes(FONT_CASCADIA_CODE_NF).expect("load font"); let paths = default_cascade_list(&handle); assert!( !paths.is_empty(), @@ -1674,8 +1719,8 @@ mod tests { fn from_bytes_index_zero_matches_from_bytes() { // For a plain TTF the single font is at index 0; both loaders // should land on equivalent CTFonts. - let a = FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("a"); - let b = FontHandle::from_bytes_index(FONT_CASCADIAMONO_NF_REGULAR, 0).expect("b"); + let a = FontHandle::from_bytes(FONT_CASCADIA_CODE_NF).expect("a"); + let b = FontHandle::from_bytes_index(FONT_CASCADIA_CODE_NF, 0).expect("b"); // Compare via a shape probe — identical glyph ids means same face. let gid_a = glyph_id_for_char(&a, 18.0, 'A'); let gid_b = glyph_id_for_char(&b, 18.0, 'A'); @@ -1684,7 +1729,7 @@ mod tests { #[test] fn from_bytes_index_out_of_range_returns_none() { - let h = FontHandle::from_bytes_index(FONT_CASCADIAMONO_NF_REGULAR, 99); + let h = FontHandle::from_bytes_index(FONT_CASCADIA_CODE_NF, 99); assert!(h.is_none(), "index 99 on a single-font TTF should fail"); } @@ -1705,7 +1750,7 @@ mod tests { #[test] fn zero_ink_glyph_yields_empty_bitmap() { let handle = - FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + FontHandle::from_bytes(FONT_CASCADIA_CODE_NF).expect("load font"); let size = 24.0; let gid = glyph_id_for_char(&handle, size as f64, ' '); let g = rasterize_glyph(&handle, gid, size, false, false, false) diff --git a/sugarloaf/src/font/mod.rs b/sugarloaf/src/font/mod.rs index dd6cb7f3..82b08b09 100644 --- a/sugarloaf/src/font/mod.rs +++ b/sugarloaf/src/font/mod.rs @@ -807,19 +807,27 @@ impl FontLibraryData { self.insert(data); } FindResult::NotFound(spec) => { - // Family-level misses are already reported by the - // regular slot. A per-slot miss here means the - // family resolved but the requested style/weight - // didn't — log-only, no UI warning. Alias to the - // regular slot so the position stays populated - // without cloning the face. - if !spec.is_default_family() { + if spec.is_default_family() { + // Default family: the user didn't ask for a custom + // family, so load the bundled Cascadia Code variant + // for this slot rather than aliasing back to the + // regular face. Aliasing leaves bold/italic + // attributes invisible in the lookup walk and + // forces a cascade-discovery fallback at shape + // time — which can resolve bold to a system font + // with mismatched metrics. + self.insert(load_fallback_from_memory(slot)); + } else { + // Family resolved but the requested style/weight + // didn't — log-only, no UI warning. Alias to the + // regular slot so the position stays populated + // without cloning the face. warn!( "Font family '{}' has no {:?} variant; falling back to regular", spec.family, slot ); + self.insert_alias(regular_index); } - self.insert_alias(regular_index); } } } @@ -920,7 +928,7 @@ impl FontLibraryData { #[cfg(target_arch = "wasm32")] pub fn load(&mut self, _font_spec: SugarloafFonts) -> Vec { - self.insert(FontData::from_slice(FONT_CASCADIAMONO_NF_REGULAR).unwrap()); + self.insert(FontData::from_slice(FONT_CASCADIA_CODE_NF).unwrap()); vec![] } @@ -1017,6 +1025,12 @@ pub struct FontData { pub synth: Synthesis, pub should_embolden: bool, pub should_italicize: bool, + /// `wght` axis value to apply when this `FontData` is backed by a + /// variable font (currently set only for the bundled Cascadia Code + /// fallback faces). On macOS the value is baked into `handle` via + /// `CTFontCreateCopyWithAttributes`; on Linux/Windows it's applied + /// to swash's shaper/scaler at render time. + pub wght_variation: Option, pub is_emoji: bool, // Cached metrics per font size (per-font caching) metrics_cache: FxHashMap, @@ -1221,6 +1235,7 @@ impl FontData { offset, should_italicize, should_embolden, + wght_variation: None, key, synth, style, @@ -1279,6 +1294,7 @@ impl FontData { synth: Synthesis::default(), should_embolden: false, should_italicize: false, + wght_variation: None, is_emoji: attrs.is_color, metrics_cache: FxHashMap::default(), handle: Some(handle), @@ -1318,6 +1334,7 @@ impl FontData { synth: Synthesis::default(), should_embolden, should_italicize, + wght_variation: None, is_emoji: attrs.is_color, metrics_cache: FxHashMap::default(), handle: Some(handle), @@ -1335,19 +1352,53 @@ impl FontData { #[inline] pub fn from_static_slice( data: &'static [u8], + ) -> Result> { + Self::from_static_slice_with_wght(data, None) + } + + /// Like [`from_static_slice`] but optionally bakes a `wght` axis + /// value into the loaded face. Mirrors ghostty's `Face.setVariations` + /// pattern: load the same variable-font bytes for every weight slot, + /// then set the `wght` axis post-construction so the rasterizer pulls + /// the right outlines (regular vs. bold) from a single source file. + /// + /// `wght = None` leaves the font at its default instance. Pass + /// `Some(700.0)` for the bold slot, etc. + pub fn from_static_slice_with_wght( + data: &'static [u8], + wght: Option, ) -> Result> { let font = FontRef::from_index(data, 0).unwrap(); let (offset, key) = (font.offset, font.key); let attributes = font.attributes(); let style = attributes.style(); - let weight = attributes.weight(); + // The default instance of a variable font reports the regular + // weight (e.g. 400). When the caller asks for a specific `wght` + // value we override the reported weight so `is_bold()` and the + // bold-spec lookup walk match the slot's intent. + let weight = match wght { + Some(v) => swash::Weight(v.round().clamp(0.0, u16::MAX as f32) as u16), + None => attributes.weight(), + }; let stretch = attributes.stretch(); let synth = attributes.synthesize(attributes); let is_emoji = has_color_tables(&font); let postscript_name = parse_postscript_name(data); #[cfg(target_os = "macos")] - let handle = crate::font::macos::FontHandle::from_static_bytes(data); + let handle = { + let base = crate::font::macos::FontHandle::from_static_bytes(data); + // Bake the `wght` variation into the CTFont via + // `CTFontCreateCopyWithAttributes` so shape_text / + // font_metrics / rasterize_glyph all pull the right outlines + // without per-call setup. + match (base, wght) { + (Some(h), Some(v)) => { + Some(h.clone().with_wght_variation(v).unwrap_or(h)) + } + (h, _) => h, + } + }; Ok(Self { data: Some(SharedData::from_static(data)), @@ -1357,6 +1408,7 @@ impl FontData { style, should_embolden: false, should_italicize: false, + wght_variation: wght, weight, stretch, path: None, @@ -1392,6 +1444,7 @@ impl FontData { style, should_embolden: false, should_italicize: false, + wght_variation: None, weight, stretch, path: None, @@ -1445,6 +1498,7 @@ impl FontData { offset, should_italicize: false, should_embolden: false, + wght_variation: None, key, synth, style, @@ -1661,15 +1715,24 @@ fn find_font( FindResult::NotFound(font_spec) } +/// Load a bundled fallback face for `slot` from the embedded Cascadia Code +/// variable font. Mirrors ghostty's `SharedGridSet` setup (see +/// `ghostty/src/font/SharedGridSet.zig:264-317`): regular and bold load +/// the same upright variable file, italic and bold-italic load the same +/// italic variable file, and the bold slots set the `wght` axis to 700. fn load_fallback_from_memory(slot: Slot) -> FontData { - let font_to_load = match slot { - Slot::Regular => constants::FONT_CASCADIAMONO_NF_REGULAR, - Slot::Bold => constants::FONT_CASCADIAMONO_BOLD, - Slot::Italic => constants::FONT_CASCADIAMONO_ITALIC, - Slot::BoldItalic => constants::FONT_CASCADIAMONO_BOLD_ITALIC, + use constants::{ + FONT_CASCADIA_CODE_NF, FONT_CASCADIA_CODE_NF_ITALIC, WGHT_BOLD, + }; + + let (data, wght) = match slot { + Slot::Regular => (FONT_CASCADIA_CODE_NF, None), + Slot::Bold => (FONT_CASCADIA_CODE_NF, Some(WGHT_BOLD)), + Slot::Italic => (FONT_CASCADIA_CODE_NF_ITALIC, None), + Slot::BoldItalic => (FONT_CASCADIA_CODE_NF_ITALIC, Some(WGHT_BOLD)), }; - FontData::from_static_slice(font_to_load).unwrap() + FontData::from_static_slice_with_wght(data, wght).unwrap() } #[cfg(test)] @@ -1683,7 +1746,7 @@ mod alias_tests { fn insert_alias_resolves_to_target() { let mut lib = FontLibraryData::default(); lib.insert( - FontData::from_static_slice(constants::FONT_CASCADIAMONO_NF_REGULAR) + FontData::from_static_slice(constants::FONT_CASCADIA_CODE_NF) .expect("load regular"), ); lib.insert_alias(0); @@ -1705,7 +1768,7 @@ mod alias_tests { fn alias_of_alias_collapses_to_root() { let mut lib = FontLibraryData::default(); lib.insert( - FontData::from_static_slice(constants::FONT_CASCADIAMONO_NF_REGULAR) + FontData::from_static_slice(constants::FONT_CASCADIA_CODE_NF) .expect("load regular"), ); lib.insert_alias(0); @@ -1718,6 +1781,37 @@ mod alias_tests { assert!(matches!(lib.inner.get(&2), Some(FontEntry::Alias(0)))); } + /// Default-family fallback loading produces an actual bold face + /// (not an alias) when the regular slot uses the bundled Cascadia + /// Code variable font. Guards against the alias-everywhere + /// regression introduced in commit `e82299705f` that left the + /// embedded bold slot unloaded with default config. + #[test] + fn fallback_bold_slot_reports_is_bold() { + let regular = load_fallback_from_memory(Slot::Regular); + let bold = load_fallback_from_memory(Slot::Bold); + let italic = load_fallback_from_memory(Slot::Italic); + let bold_italic = load_fallback_from_memory(Slot::BoldItalic); + + assert!(!regular.is_bold(), "regular slot must not be bold"); + assert!(bold.is_bold(), "bold slot must report is_bold"); + assert!(!italic.is_bold(), "italic slot must not be bold"); + assert!(bold_italic.is_bold(), "bold-italic slot must report is_bold"); + + assert!(!regular.is_italic(), "regular slot must not be italic"); + assert!(!bold.is_italic(), "bold slot must not be italic"); + assert!(italic.is_italic(), "italic slot must report is_italic"); + assert!( + bold_italic.is_italic(), + "bold-italic slot must report is_italic" + ); + + assert_eq!(bold.wght_variation, Some(constants::WGHT_BOLD)); + assert_eq!(bold_italic.wght_variation, Some(constants::WGHT_BOLD)); + assert_eq!(regular.wght_variation, None); + assert_eq!(italic.wght_variation, None); + } + /// Aliases share the target's `metrics_cache`, so requesting /// metrics through the alias returns the same numbers as the /// target without populating a duplicate cache. @@ -1725,7 +1819,7 @@ mod alias_tests { fn alias_shares_metrics_with_target() { let mut lib = FontLibraryData::default(); lib.insert( - FontData::from_static_slice(constants::FONT_CASCADIAMONO_NF_REGULAR) + FontData::from_static_slice(constants::FONT_CASCADIA_CODE_NF) .expect("load regular"), ); lib.insert_alias(0); @@ -1801,13 +1895,13 @@ mod postscript_resolver_tests { // Read the PS name straight from the handle so the test doesn't // hardcode a value that changes if the bundled font is updated. let handle = crate::font::macos::FontHandle::from_static_bytes( - FONT_CASCADIAMONO_NF_REGULAR, + FONT_CASCADIA_CODE_NF, ) .expect("parse CascadiaMono"); let ps_name = handle.postscript_name(); let mut lib = FontLibraryData::default(); - let font_data = FontData::from_static_slice(FONT_CASCADIAMONO_NF_REGULAR) + let font_data = FontData::from_static_slice(FONT_CASCADIA_CODE_NF) .expect("load CascadiaMono"); lib.insert(font_data); @@ -1831,17 +1925,17 @@ mod postscript_resolver_tests { #[test] fn duplicate_insert_keeps_first_id() { let handle = crate::font::macos::FontHandle::from_static_bytes( - FONT_CASCADIAMONO_NF_REGULAR, + FONT_CASCADIA_CODE_NF, ) .expect("parse CascadiaMono"); let ps_name = handle.postscript_name(); let mut lib = FontLibraryData::default(); lib.insert( - FontData::from_static_slice(FONT_CASCADIAMONO_NF_REGULAR).expect("load a"), + FontData::from_static_slice(FONT_CASCADIA_CODE_NF).expect("load a"), ); lib.insert( - FontData::from_static_slice(FONT_CASCADIAMONO_NF_REGULAR).expect("load b"), + FontData::from_static_slice(FONT_CASCADIA_CODE_NF).expect("load b"), ); assert_eq!( lib.font_id_for_postscript_name(&ps_name), @@ -1862,7 +1956,7 @@ mod postscript_resolver_tests { let mut data = FontLibraryData::default(); data.insert( - FontData::from_static_slice(FONT_CASCADIAMONO_NF_REGULAR).expect("load"), + FontData::from_static_slice(FONT_CASCADIA_CODE_NF).expect("load"), ); let lib = FontLibrary { inner: Arc::new(parking_lot::RwLock::new(data)), @@ -1901,7 +1995,7 @@ mod postscript_resolver_tests { let mut data = FontLibraryData::default(); data.insert( - FontData::from_static_slice(FONT_CASCADIAMONO_NF_REGULAR).expect("load"), + FontData::from_static_slice(FONT_CASCADIA_CODE_NF).expect("load"), ); let lib = FontLibrary { inner: Arc::new(parking_lot::RwLock::new(data)), diff --git a/sugarloaf/src/text.rs b/sugarloaf/src/text.rs index 204d7557..303dcbab 100644 --- a/sugarloaf/src/text.rs +++ b/sugarloaf/src/text.rs @@ -94,6 +94,11 @@ struct ShapedRun { size_bucket: u16, synthetic_bold: bool, synthetic_italic: bool, + /// `wght` axis value to apply when rasterizing this run's glyphs + /// (variable-font fallback faces only). On macOS the variation is + /// already baked into the CTFont handle, so this stays unused. + #[cfg(not(target_os = "macos"))] + wght_variation: Option, ascent_px: i16, glyphs: Vec, } @@ -211,6 +216,13 @@ pub struct Text { /// rasterizer's use of the same fields). synthesis_cache: FxHashMap, + /// `font_id → wght axis value` for variable-font fallback faces. + /// On macOS the variation is baked into the CTFont handle directly, + /// so this cache is only consulted on the swash path. `None` (cache + /// miss or stored `None`) means "use the default instance". + #[cfg(not(target_os = "macos"))] + wght_variation_cache: FxHashMap>, + /// `(font_id, size_bucket) → ascent_px`. Used to compute /// `bearing_y` at rasterize time. ascent_cache: FxHashMap<(u32, u16), i16>, @@ -249,6 +261,8 @@ impl Text { font_library: font_library.clone(), font_resolve: FxHashMap::default(), synthesis_cache: FxHashMap::default(), + #[cfg(not(target_os = "macos"))] + wght_variation_cache: FxHashMap::default(), ascent_cache: FxHashMap::default(), shape_cache: FxHashMap::default(), #[cfg(target_os = "macos")] @@ -422,8 +436,9 @@ impl Text { }; #[cfg(not(target_os = "macos"))] - let (glyphs, ascent_px) = { + let (glyphs, ascent_px, wght_variation) = { use swash::FontRef; + use swash::Setting; // Pull (or cache) the font bytes + offset + key once per // font_id to avoid the RwLock read-lock per shape. @@ -439,12 +454,30 @@ impl Text { key: font_entry.2, }; + // `wght` axis value to apply to the face (variable-font + // fallback slots only — `None` for normal fonts). + let wght = match self.wght_variation_cache.entry(font_id) { + std::collections::hash_map::Entry::Occupied(e) => *e.get(), + std::collections::hash_map::Entry::Vacant(e) => { + let lib = self.font_library.inner.read(); + let v = lib.get(&(font_id as usize)).wght_variation; + *e.insert(v) + } + }; + // wght axis tag as a swash u32 Tag — 'w','g','h','t' big-endian. + const WGHT_TAG: swash::Tag = u32::from_be_bytes(*b"wght"); + let wght_var = wght.map(|v| Setting { tag: WGHT_TAG, value: v }); + let var_slice: &[Setting] = match wght_var { + Some(ref s) => std::slice::from_ref(s), + None => &[], + }; + // Ascent — via swash metrics scaled to device-px size. let ascent_px = *self .ascent_cache .entry((font_id, size_bucket)) .or_insert_with(|| { - let m = font_ref.metrics(&[]).scale(size_u16 as f32); + let m = font_ref.metrics(var_slice).scale(size_u16 as f32); m.ascent.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16 }); @@ -454,6 +487,7 @@ impl Text { .shape_ctx .builder(font_ref) .size(size_u16 as f32) + .variations(var_slice.iter().copied()) .build(); shaper.add_str(text); let mut glyphs: Vec = Vec::new(); @@ -469,7 +503,7 @@ impl Text { }); } }); - (glyphs, ascent_px) + (glyphs, ascent_px, wght) }; let run = ShapedRun { @@ -478,6 +512,8 @@ impl Text { size_bucket, synthetic_bold, synthetic_italic, + #[cfg(not(target_os = "macos"))] + wght_variation, ascent_px, glyphs, }; @@ -643,6 +679,7 @@ impl Text { run.synthetic_bold, run.synthetic_italic, self.font_library.inner.read().hinting, + run.wght_variation, )?; let is_color = raw.is_color; let raster = crate::grid::RasterizedGlyph { @@ -692,6 +729,7 @@ impl Text { run.synthetic_bold, run.synthetic_italic, self.font_library.inner.read().hinting, + run.wght_variation, )?; let is_color = raw.is_color; @@ -786,6 +824,7 @@ impl Text { run.synthetic_bold, run.synthetic_italic, self.font_library.inner.read().hinting, + run.wght_variation, )?; ( raw.width, @@ -1224,13 +1263,14 @@ fn rasterize_swash_glyph( synthetic_bold: bool, synthetic_italic: bool, hint: bool, + wght_variation: Option, ) -> Option { use swash::scale::{ image::{Content, Image as GlyphImage}, Render, Source, StrikeWith, }; use swash::zeno::{Angle, Format, Transform}; - use swash::FontRef; + use swash::{FontRef, Setting}; let font_ref = FontRef { data: font_entry.0.as_ref(), @@ -1238,7 +1278,21 @@ fn rasterize_swash_glyph( key: font_entry.2, }; - let mut scaler = scale_ctx.builder(font_ref).hint(hint).size(size_px).build(); + // wght axis tag — variable-font fallback faces use this to pick the + // right outlines (regular vs. bold) from a single source file. + const WGHT_TAG: swash::Tag = u32::from_be_bytes(*b"wght"); + let wght_var = wght_variation.map(|v| Setting { tag: WGHT_TAG, value: v }); + let var_slice: &[Setting] = match wght_var { + Some(ref s) => std::slice::from_ref(s), + None => &[], + }; + + let mut scaler = scale_ctx + .builder(font_ref) + .hint(hint) + .size(size_px) + .variations(var_slice.iter().copied()) + .build(); let mut image = GlyphImage::new(); let sources: &[Source] = &[