From 329941c4a6920bb6e4a411261d56041edf4b198c Mon Sep 17 00:00:00 2001 From: Mohammad AlSaleh Date: Wed, 17 Jan 2024 12:32:33 +0300 Subject: [PATCH] Try harder to succeed at fall-backing to a Monospace font A combination of some ideas: * Try all Monospace fonts before giving up. * Relax exact weight restriction on font matching when trying Monospace fall-back. Try smaller weights if needed. * Make the fall-back try order weight-offset aware, AND script-aware. * And finally, add the option to adjust the font size of glyphs using fall-back Monospace fonts, so the width of them matches the default font width. For my use-case, the current fall-back attempt always fails with Arabic script. And none of the Arabic-supporting Monospace fonts in my system also support medium weight. So, if my default font is set to medium weight, script-aware fall-back alone will still not work. Signed-off-by: Mohammad AlSaleh --- Cargo.toml | 1 + src/attrs.rs | 3 +- src/buffer.rs | 19 ++++++++ src/buffer_line.rs | 5 +- src/font/fallback/mod.rs | 100 +++++++++++++++++++++++++++++---------- src/font/mod.rs | 39 ++++++++++++++- src/font/system.rs | 24 ++++++---- src/shape.rs | 25 ++++++++-- 8 files changed, 177 insertions(+), 39 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7ca595c..f1ee098 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ self_cell = "1.0.1" swash = { version = "0.1.8", optional = true } syntect = { version = "5.1.0", optional = true } sys-locale = { version = "0.3.1", optional = true } +ttf-parser = "0.20.0" unicode-linebreak = "0.1.5" unicode-script = "0.5.5" unicode-segmentation = "1.10.1" diff --git a/src/attrs.rs b/src/attrs.rs index 8ba4928..5ef4003 100644 --- a/src/attrs.rs +++ b/src/attrs.rs @@ -177,7 +177,8 @@ impl<'a> Attrs<'a> { //TODO: smarter way of including emoji face.post_script_name.contains("Emoji") || (face.style == self.style - && face.weight == self.weight + // Relax exact weight matching for the Monospace fallback use-case + && face.weight <= self.weight && face.stretch == self.stretch) } diff --git a/src/buffer.rs b/src/buffer.rs index 15d1900..b188d89 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -232,6 +232,7 @@ pub struct Buffer { /// True if a redraw is requires. Set to false after processing redraw: bool, wrap: Wrap, + monospace_width: Option, /// Scratch buffer for shaping and laying out. scratch: ShapeBuffer, @@ -247,6 +248,7 @@ impl Clone for Buffer { scroll: self.scroll, redraw: self.redraw, wrap: self.wrap, + monospace_width: self.monospace_width, scratch: ShapeBuffer::default(), } } @@ -275,6 +277,7 @@ impl Buffer { redraw: false, wrap: Wrap::Word, scratch: ShapeBuffer::default(), + monospace_width: None, } } @@ -313,6 +316,7 @@ impl Buffer { self.metrics.font_size, self.width, self.wrap, + self.monospace_width, ); } } @@ -492,6 +496,7 @@ impl Buffer { self.metrics.font_size, self.width, self.wrap, + self.monospace_width, )) } @@ -523,6 +528,20 @@ impl Buffer { } } + /// Get the current `monospace_width` + pub fn monospace_width(&self) -> Option { + self.monospace_width + } + + /// Set monospace width monospace glyphs should be resized to match. `None` means don't resize + pub fn set_monospace_width(&mut self, font_system: &mut FontSystem, monospace_width: Option) { + if monospace_width != self.monospace_width { + self.monospace_width = monospace_width; + self.relayout(font_system); + self.shape_until_scroll(font_system, false); + } + } + /// Get the current buffer dimensions (width, height) pub fn size(&self) -> (f32, f32) { (self.width, self.height) diff --git a/src/buffer_line.rs b/src/buffer_line.rs index ec6c661..8f1f81d 100644 --- a/src/buffer_line.rs +++ b/src/buffer_line.rs @@ -184,6 +184,7 @@ impl BufferLine { font_size: f32, width: f32, wrap: Wrap, + match_mono_width: Option, ) -> &[LayoutLine] { self.layout_in_buffer( &mut ShapeBuffer::default(), @@ -191,6 +192,7 @@ impl BufferLine { font_size, width, wrap, + match_mono_width, ) } @@ -202,12 +204,13 @@ impl BufferLine { font_size: f32, width: f32, wrap: Wrap, + match_mono_width: Option, ) -> &[LayoutLine] { if self.layout_opt.is_none() { let align = self.align; let shape = self.shape_in_buffer(scratch, font_system); let mut layout = Vec::with_capacity(1); - shape.layout_to_buffer(scratch, font_size, width, wrap, align, &mut layout); + shape.layout_to_buffer(scratch, font_size, width, wrap, align, &mut layout, match_mono_width); self.layout_opt = Some(layout); } self.layout_opt.as_ref().expect("layout not found") diff --git a/src/font/fallback/mod.rs b/src/font/fallback/mod.rs index b5e1838..001243c 100644 --- a/src/font/fallback/mod.rs +++ b/src/font/fallback/mod.rs @@ -3,10 +3,11 @@ use alloc::sync::Arc; #[cfg(not(feature = "std"))] use alloc::vec::Vec; +use alloc::collections::BTreeSet; use fontdb::Family; use unicode_script::Script; -use crate::{Font, FontSystem, ShapePlanCache}; +use crate::{Font, FontSystem, ShapePlanCache, FontMatchKey}; use self::platform::*; @@ -31,10 +32,21 @@ use log::debug as missing_warn; #[cfg(feature = "warn_on_missing_glyphs")] use log::warn as missing_warn; +// Match on lowest weight_offset, then script_non_matches +// Default font gets None for both `weight_offset` and `script_non_matches`, and thus, it is +// always the first to be popped from the set. +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +struct MonospaceFallbackInfo { + weight_offset: Option, + script_non_matches: Option, + id: fontdb::ID, +} + pub struct FontFallbackIter<'a> { font_system: &'a mut FontSystem, - font_ids: &'a [fontdb::ID], + font_match_keys: &'a [FontMatchKey], default_families: &'a [&'a Family<'a>], + monospace_fallbacks: BTreeSet, default_i: usize, scripts: &'a [Script], script_i: (usize, usize), @@ -46,14 +58,15 @@ pub struct FontFallbackIter<'a> { impl<'a> FontFallbackIter<'a> { pub fn new( font_system: &'a mut FontSystem, - font_ids: &'a [fontdb::ID], + font_match_keys: &'a [FontMatchKey], default_families: &'a [&'a Family<'a>], scripts: &'a [Script], ) -> Self { Self { font_system, - font_ids, + font_match_keys, default_families, + monospace_fallbacks: BTreeSet::new(), default_i: 0, scripts, script_i: (0, 0), @@ -76,7 +89,7 @@ impl<'a> FontFallbackIter<'a> { "Failed to find preset fallback for {:?} locale '{}', used '{}': '{}'", self.scripts, self.font_system.locale(), - self.face_name(self.font_ids[self.other_i - 1]), + self.face_name(self.font_match_keys[self.other_i - 1].id), word ); } else if !self.scripts.is_empty() && self.common_i > 0 { @@ -119,34 +132,71 @@ impl<'a> FontFallbackIter<'a> { impl<'a> Iterator for FontFallbackIter<'a> { type Item = Arc; fn next(&mut self) -> Option { + if let Some(fallback_info) = self.monospace_fallbacks.pop_first() { + if let Some(font) = self.font_system.get_font(fallback_info.id) { + return Some(font); + } + } + + let font_match_keys_iter = |is_mono| self.font_match_keys + .iter() + .filter(move |m_key| m_key.weight_offset == 0 || is_mono); + while self.default_i < self.default_families.len() { self.default_i += 1; - let mut monospace_fallback = None; - for id in self.font_ids.iter() { + let is_mono = self.default_families[self.default_i - 1] == &Family::Monospace; + + for m_key in font_match_keys_iter(is_mono) { let default_family = self .font_system .db() .family_name(self.default_families[self.default_i - 1]); - if self.face_contains_family(*id, default_family) { - if let Some(font) = self.font_system.get_font(*id) { - return Some(font); + if self.face_contains_family(m_key.id, default_family) { + if let Some(font) = self.font_system.get_font(m_key.id) { + if !is_mono { + return Some(font); + } else if m_key.weight_offset == 0 { + // Default font + let fallback_info = MonospaceFallbackInfo { + weight_offset: None, + script_non_matches: None, + id: m_key.id + }; + assert_eq!(self.monospace_fallbacks.insert(fallback_info), true); + } } } // Set a monospace fallback if Monospace family is not found - if self.default_families[self.default_i - 1] == &Family::Monospace - && monospace_fallback.is_none() - { - if let Some(face_info) = self.font_system.db().face(*id) { + if is_mono { + let script_tags = self.scripts.iter() + .filter_map(|script| { + let script_as_lower = script.short_name().to_lowercase(); + <[u8; 4]>::try_from(script_as_lower.as_bytes()).ok() + }).collect::>(); + + if let Some(face_info) = self.font_system.db().face(m_key.id) { // Don't use emoji fonts as Monospace if face_info.monospaced && !face_info.post_script_name.contains("Emoji") { - monospace_fallback = Some(id); + if let Some(font) = self.font_system.get_font(m_key.id) { + let script_non_matches = self.scripts.len() - script_tags.iter() + .filter(|&&script_tag| { + font.scripts().iter().any(|&tag_bytes| tag_bytes == script_tag) + }).count(); + + let fallback_info = MonospaceFallbackInfo { + weight_offset: Some(m_key.weight_offset), + script_non_matches: Some(script_non_matches), + id: m_key.id + }; + assert_eq!(self.monospace_fallbacks.insert(fallback_info), true); + } } } } } // If default family is Monospace fallback to first monospaced font - if let Some(id) = monospace_fallback { - if let Some(font) = self.font_system.get_font(*id) { + if let Some(fallback_info) = self.monospace_fallbacks.pop_first() { + if let Some(font) = self.font_system.get_font(fallback_info.id) { return Some(font); } } @@ -159,9 +209,9 @@ impl<'a> Iterator for FontFallbackIter<'a> { while self.script_i.1 < script_families.len() { let script_family = script_families[self.script_i.1]; self.script_i.1 += 1; - for id in self.font_ids.iter() { - if self.face_contains_family(*id, script_family) { - if let Some(font) = self.font_system.get_font(*id) { + for m_key in font_match_keys_iter(false) { + if self.face_contains_family(m_key.id, script_family) { + if let Some(font) = self.font_system.get_font(m_key.id) { return Some(font); } } @@ -182,9 +232,9 @@ impl<'a> Iterator for FontFallbackIter<'a> { while self.common_i < common_families.len() { let common_family = common_families[self.common_i]; self.common_i += 1; - for id in self.font_ids.iter() { - if self.face_contains_family(*id, common_family) { - if let Some(font) = self.font_system.get_font(*id) { + for m_key in font_match_keys_iter(false) { + if self.face_contains_family(m_key.id, common_family) { + if let Some(font) = self.font_system.get_font(m_key.id) { return Some(font); } } @@ -195,8 +245,8 @@ impl<'a> Iterator for FontFallbackIter<'a> { //TODO: do we need to do this? //TODO: do not evaluate fonts more than once! let forbidden_families = forbidden_fallback(); - while self.other_i < self.font_ids.len() { - let id = self.font_ids[self.other_i]; + while self.other_i < self.font_match_keys.len() { + let id = self.font_match_keys[self.other_i].id; self.other_i += 1; if forbidden_families .iter() diff --git a/src/font/mod.rs b/src/font/mod.rs index 5be76c3..eed2c51 100644 --- a/src/font/mod.rs +++ b/src/font/mod.rs @@ -27,6 +27,8 @@ pub struct Font { rustybuzz: OwnedFace, data: Arc + Send + Sync>, id: fontdb::ID, + monospace_em_width: Option, + scripts: Vec<[u8; 4]>, } impl fmt::Debug for Font { @@ -42,6 +44,14 @@ impl Font { self.id } + pub fn monospace_em_width(&self) -> Option { + self.monospace_em_width + } + + pub fn scripts(&self) -> &[[u8; 4]] { + &self.scripts + } + pub fn data(&self) -> &[u8] { (*self.data).as_ref() } @@ -62,7 +72,32 @@ impl Font { } impl Font { - pub fn new(info: &fontdb::FaceInfo) -> Option { + pub fn new(db: &fontdb::Database, id: fontdb::ID) -> Option { + let info = db.face(id)?; + + let (monospace_em_width, scripts) = { + db.with_face_data(id, |font_data, face_index| { + let face = ttf_parser::Face::parse(font_data, face_index).ok()?; + let monospace_em_width = info.monospaced.then(|| { + let hor_advance = face.glyph_hor_advance(face.glyph_index(' ')?)? as f32; + let upem = face.units_per_em() as f32; + Some(hor_advance/upem) + }).flatten(); + + if info.monospaced && monospace_em_width.is_none() { + None?; + } + + let scripts = face.tables().gpos.into_iter() + .chain(face.tables().gsub) + .map(|table| table.scripts) + .flatten() + .map(|script| script.tag.to_bytes()) + .collect(); + Some((monospace_em_width, scripts)) + })? + }?; + let data = match &info.source { fontdb::Source::Binary(data) => Arc::clone(data), #[cfg(feature = "std")] @@ -76,6 +111,8 @@ impl Font { Some(Self { id: info.id, + monospace_em_width, + scripts, #[cfg(feature = "swash")] swash: { let swash = swash::FontRef::from_index((*data).as_ref(), info.index as usize)?; diff --git a/src/font/system.rs b/src/font/system.rs index 8f3646e..ded0ef2 100644 --- a/src/font/system.rs +++ b/src/font/system.rs @@ -9,6 +9,12 @@ use core::ops::{Deref, DerefMut}; pub use fontdb; pub use rustybuzz; +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct FontMatchKey { + pub(crate) weight_offset: u16, + pub(crate) id: fontdb::ID, +} + /// Access to the system fonts. pub struct FontSystem { /// The locale of the system. @@ -21,7 +27,7 @@ pub struct FontSystem { font_cache: HashMap>>, /// Cache for font matches. - font_matches_cache: HashMap>>, + font_matches_cache: HashMap>>, /// Cache for rustybuzz shape plans. shape_plan_cache: ShapePlanCache, @@ -111,11 +117,10 @@ impl FontSystem { unsafe { self.db.make_shared_face_data(id); } - let face = self.db.face(id)?; - match Font::new(face) { + match Font::new(&self.db, id) { Some(font) => Some(Arc::new(font)), None => { - log::warn!("failed to load font '{}'", face.post_script_name); + log::warn!("failed to load font '{}'", self.db.face(id)?.post_script_name); None } } @@ -123,7 +128,7 @@ impl FontSystem { .clone() } - pub fn get_font_matches(&mut self, attrs: Attrs<'_>) -> Arc> { + pub fn get_font_matches(&mut self, attrs: Attrs<'_>) -> Arc> { self.font_matches_cache //TODO: do not create AttrsOwned unless entry does not already exist .entry(AttrsOwned::new(attrs)) @@ -131,20 +136,23 @@ impl FontSystem { #[cfg(all(feature = "std", not(target_arch = "wasm32")))] let now = std::time::Instant::now(); - let ids = self + let mut font_match_keys = self .db .faces() .filter(|face| attrs.matches(face)) - .map(|face| face.id) + .map(|face| FontMatchKey{ weight_offset: attrs.weight.0 - face.weight.0, id: face.id }) .collect::>(); + // Sort so we get the keys with weight_offset=0 first + font_match_keys.sort(); + #[cfg(all(feature = "std", not(target_arch = "wasm32")))] { let elapsed = now.elapsed(); log::debug!("font matches for {:?} in {:?}", attrs, elapsed); } - Arc::new(ids) + Arc::new(font_match_keys) }) .clone() } diff --git a/src/shape.rs b/src/shape.rs index 6cda663..b205fc3 100644 --- a/src/shape.rs +++ b/src/shape.rs @@ -144,6 +144,7 @@ fn shape_fallback( y_offset, ascent, descent, + font_monospace_em_width: font.monospace_em_width(), font_id: font.id(), glyph_id: info.glyph_id.try_into().expect("failed to cast glyph ID"), //TODO: color should not be related to shaping @@ -344,6 +345,7 @@ fn shape_skip( let font = font_iter.next().expect("no default font found"); let font_id = font.id(); + let font_monospace_em_width = font.monospace_em_width(); let font = font.as_swash(); let charmap = font.charmap(); @@ -371,6 +373,7 @@ fn shape_skip( y_offset: 0.0, ascent, descent, + font_monospace_em_width, font_id, glyph_id, color_opt: attrs.color_opt, @@ -392,6 +395,7 @@ pub struct ShapeGlyph { pub y_offset: f32, pub ascent: f32, pub descent: f32, + pub font_monospace_em_width: Option, pub font_id: fontdb::ID, pub glyph_id: u16, pub color_opt: Option, @@ -875,6 +879,7 @@ impl ShapeLine { line_width: f32, wrap: Wrap, align: Option, + match_mono_width: Option, ) -> Vec { let mut lines = Vec::with_capacity(1); self.layout_to_buffer( @@ -884,6 +889,7 @@ impl ShapeLine { wrap, align, &mut lines, + match_mono_width, ); lines } @@ -896,6 +902,7 @@ impl ShapeLine { wrap: Wrap, align: Option, layout_lines: &mut Vec, + match_mono_width: Option, ) { // For each visual line a list of (span index, and range of words in that span) // Note that a BiDi visual line could have multiple spans or parts of them @@ -1236,8 +1243,20 @@ impl ShapeLine { (false, true) => &word.glyphs[..ending_glyph], (true, true) => &word.glyphs[starting_glyph..ending_glyph], }; + + let match_mono_em_width = match_mono_width.map(|w| w / font_size); + for glyph in included_glyphs { - let x_advance = font_size * glyph.x_advance + let glyph_font_size = match(match_mono_em_width, glyph.font_monospace_em_width) { + (Some(match_em_width), Some(glyph_em_width)) if glyph_em_width != match_em_width => { + let glyph_font_size = font_size * (match_em_width / glyph_em_width); + log::trace!("Adjusted glyph font size ({font_size} => {glyph_font_size})"); + glyph_font_size + }, + _ => font_size, + }; + + let x_advance = glyph_font_size * glyph.x_advance + if word.blank { justification_expansion } else { @@ -1246,8 +1265,8 @@ impl ShapeLine { if self.rtl { x -= x_advance; } - let y_advance = font_size * glyph.y_advance; - glyphs.push(glyph.layout(font_size, x, y, x_advance, span.level)); + let y_advance = glyph_font_size * glyph.y_advance; + glyphs.push(glyph.layout(glyph_font_size, x, y, x_advance, span.level)); if !self.rtl { x += x_advance; }