Merge pull request #2732 from vangel76/fix/2491-fallback-glyph-style

Fonts: fallback glyphs keep bold/italic, Bold Italic of a missing family keeps the bold, CJK prefers an installed sans (#2491)
This commit is contained in:
BFlat
2026-10-18 01:48:39 +07:00
committed by GitHub
4 changed files with 78 additions and 23 deletions
+41 -22
View File
@@ -1594,6 +1594,13 @@ fn style_italic(style: &str) -> bool {
s.contains("italic") || s.contains("oblique") || s == "it"
}
/// How far `face` is from the style asked for: its weight difference, and a penalty for the wrong
/// slant. The penalty is less than the weight gap between Regular and Bold (and Bold and Black),
/// so Bold Italic asked of a family with Bold and Italic faces only gets the Bold (#2491).
fn style_distance(face: &FontFace, weight: f32, italic: bool) -> f32 {
(face.weight - weight).abs() + if face.italic != italic { 250.0 } else { 0.0 }
}
/// [`FontDb::menu_family_list`], with the [`FontDb::family_list`] it was built from.
type MenuFamilies = (Arc<[String]>, Arc<[String]>);
@@ -2241,37 +2248,36 @@ impl FontDb {
return Some((*f).clone());
}
let (tw, ti) = (style_weight(style), style_italic(style));
cands
.iter()
.min_by(|a, b| {
let sa = (a.weight - tw).abs() + if a.italic != ti { 1000.0 } else { 0.0 };
let sb = (b.weight - tw).abs() + if b.italic != ti { 1000.0 } else { 0.0 };
sa.total_cmp(&sb)
})
.map(|f| (*f).clone())
cands.iter().min_by(|a, b| style_distance(a, tw, ti).total_cmp(&style_distance(b, tw, ti))).map(|f| (*f).clone())
}
/// First face (fallback family first, then load order) that covers `c`; on native, system
/// fonts are loaded lazily the first time no loaded face covers a character.
/// [`fallback_for_style`](Self::fallback_for_style) for upright Regular text.
pub(crate) fn fallback_for(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
self.fallback_for_style(c, exclude, 400.0, false)
}
/// First family (fallback family first, then load order) with a face that covers `c`, in that
/// family's face nearest `weight` and `italic`; on native, system fonts are loaded lazily the
/// first time no loaded face covers a character (#2491).
pub(crate) fn fallback_for_style(&self, c: char, exclude: u32, weight: f32, italic: bool) -> Option<Arc<FontFace>> {
// Thai (Lao, Khmer, Myanmar) takes a font made for it, when one is installed, rather than
// whichever face covering it happened to load first (a display face, say).
let prefer = script_families(c);
if let Some(f) = self.family_fallback(c, exclude, prefer) {
if let Some(f) = self.family_fallback(c, exclude, prefer, weight, italic) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if self.load_families(c, prefer)
&& let Some(f) = self.family_fallback(c, exclude, prefer)
&& let Some(f) = self.family_fallback(c, exclude, prefer, weight, italic)
{
return Some(f);
}
if let Some(f) = self.loaded_fallback(c, exclude) {
if let Some(f) = self.loaded_fallback(c, exclude, weight, italic) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if self.system_fallback(c) {
return self.loaded_fallback(c, exclude);
return self.loaded_fallback(c, exclude, weight, italic);
}
None
}
@@ -2283,14 +2289,14 @@ impl FontDb {
self.fallback_for(c, 0)
}
/// The loaded face of the first of `families` that covers `c` (upright, nearest Regular).
fn family_fallback(&self, c: char, exclude: u32, families: &[&str]) -> Option<Arc<FontFace>> {
/// The loaded face of the first of `families` that covers `c`, nearest `weight` and `italic`.
fn family_fallback(&self, c: char, exclude: u32, families: &[&str], weight: f32, italic: bool) -> Option<Arc<FontFace>> {
let faces = self.read_faces();
families.iter().find_map(|fam| {
faces
.iter()
.filter(|f| f.id != exclude && f.family.eq_ignore_ascii_case(fam) && f.covers(c))
.min_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32))
.min_by(|a, b| style_distance(a, weight, italic).total_cmp(&style_distance(b, weight, italic)))
.cloned()
})
}
@@ -2302,14 +2308,27 @@ impl FontDb {
if families.is_empty() || !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled {
return false;
}
families.iter().any(|fam| !self.is_loaded(fam) && self.load_cataloged(fam) && self.family_fallback(c, 0, &[fam]).is_some())
families.iter().any(|fam| !self.is_loaded(fam) && self.load_cataloged(fam) && self.family_fallback(c, 0, &[fam], 400.0, false).is_some())
}
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
/// The first loaded family (the fallback family, then load order) with a face covering `c`,
/// and in it the face nearest `weight` and `italic`.
fn loaded_fallback(&self, c: char, exclude: u32, weight: f32, italic: bool) -> Option<Arc<FontFace>> {
let faces = self.read_faces();
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
order.into_iter().find(|f| f.covers(c)).cloned()
let mut families: Vec<&str> = Vec::new();
for f in faces.iter().filter(|f| f.id != exclude && f.covers(c)) {
if !families.iter().any(|fam| fam.eq_ignore_ascii_case(&f.family)) {
families.push(&f.family);
}
}
// Stable: the fallback family first, the others in load order.
families.sort_by_key(|fam| !fam.eq_ignore_ascii_case(FALLBACK_FAMILY));
let fam = families.first()?;
faces
.iter()
.filter(|f| f.id != exclude && f.family.eq_ignore_ascii_case(fam) && f.covers(c))
.min_by(|a, b| style_distance(a, weight, italic).total_cmp(&style_distance(b, weight, italic)))
.cloned()
}
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
+1 -1
View File
@@ -384,7 +384,7 @@ pub(crate) fn shape_range(
} else if let Some((_, f)) = cache.iter().find(|(k, _)| *k == c) {
f.clone()
} else {
let f = db.fallback_for(c, primary.id()).unwrap_or_else(|| primary.clone());
let f = db.fallback_for_style(c, primary.id(), primary.weight, primary.italic).unwrap_or_else(|| primary.clone());
cache.push((c, f.clone()));
f
};
+34
View File
@@ -405,6 +405,40 @@ fn fallback_font_per_character() {
assert_eq!(l.glyphs[2].font_id, primary.id());
}
/// Characters drawn from a fallback face keep the weight and slant asked for (#2491): bold text
/// takes a bold face of the fallback family when it has one.
#[test]
fn fallback_glyphs_keep_the_weight_of_the_text() {
let primary = db().face("Source Sans 3", "Bold").unwrap();
let regular = db().face("Source Sans 3", "Regular").unwrap();
let c = ['\u{2500}', '\u{2192}', '\u{25B6}', '\u{2588}', '\u{21E5}', '\u{2318}']
.into_iter()
.find(|&c| {
!primary.covers(c)
&& db()
.fallback_for(c, regular.id())
.is_some_and(|f| db().fallback_for_style(c, regular.id(), 700.0, false).is_some_and(|b| b.weight > f.weight))
})
.expect("some symbol covered by a family of the bundled fonts with more than one weight");
let mut st = style(12.0);
st.font_style = "Bold".into();
let l = layout(db(), &point(&format!("a{c}b"), st));
assert_eq!(l.glyphs[0].font_id, primary.id());
let drawn = db().face_by_id(l.glyphs[1].font_id).unwrap();
assert!(drawn.weight >= 600.0, "{} {} for a bold run", drawn.family, drawn.style);
}
/// A missing family's Bold Italic is drawn bold when the fallback family has Bold and Italic but no
/// Bold Italic, not as plain Italic (#2491).
#[test]
fn bold_italic_of_a_missing_family_keeps_the_bold() {
let face = db().face("No Such Family", "Bold Italic").unwrap();
assert_eq!(face.family, FALLBACK_FAMILY);
assert!(face.weight >= 600.0, "{} {}", face.family, face.style);
let italic = db().face("No Such Family", "Italic").unwrap();
assert!(italic.italic && italic.weight < 600.0);
}
/// A space the primary font doesn't map goes to the fallback face like any other character
/// (#1844): an ideographic space in a face without U+3000 keeps its em, so an indent made with it
/// isn't lost. A tab, which the layout handles itself, stays with the primary face. Needs a bundled
+2
View File
@@ -32,6 +32,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG |
| Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending |
| Variable fonts | partial | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Variable filter is missing |
| Fallback glyphs keep the text's weight and slant | done | #2491: a character from a fallback face is set in that family's face nearest the run's weight and slant; Bold Italic of a missing family is drawn Bold (Source Sans 3 has no Bold Italic) rather than Italic |
| Colour fonts (OpenType SVG / COLR) | partial | |
| Every-line and Single-line composers | done | tuned by eye, never compared side by side |
| Optical Margin Alignment | done | Type › Optical Margin Alignment, `type.opticalMarginAlignment`: per type object, in point and area type of left-to-right paragraphs; type on a path and right-to-left paragraphs do not hang |
@@ -81,6 +82,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
| 2026-10-13 | minor | Fit Headline no longer pushes the tracking to its ceiling and loses glyphs when the frame holds one line (#1445, fixes #1444); text frames, wrap shapes and type paths with coordinates beyond a billion points hold no text, and layout limits its search for room, instead of hanging (#1451); scores and hours unchanged |
| 2026-10-12 | minor | Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged |
| 2026-10-12 | minor | A character no installed font had is looked for again after the fonts are rescanned, so installing a font fixes it without a restart (#1376, from #992); scores and hours unchanged |
| 2026-10-16 | minor | Fallback glyphs keep the text's weight and slant; Bold Italic of a missing family keeps the bold (#2491); scores and hours unchanged |
| 2026-10-16 | minor | A variable font whose name table says Light but whose default instance is Regular (Noto Sans Hebrew) lists and weighs its default face as Regular (#2521); scores and hours unchanged |
| 2026-10-12 | minor | Full-name font matching and Missing Fonts searches (#1258): synthetic static/variable font regressions; Windows font-service QA pending; scores and hours unchanged |
| 2026-10-11 | minor | Middle Eastern: digit types done (Character panel menu › Digits: Default, Arabic, Hindi, Farsi; `text.setFormat` / `text.setRangeStyle {digits}`); Character Direction, kashidas, composers and the split caret still missing; scores unchanged at this rounding |