From 43356a1cfb71cebcaf49fdfe6145a63a50b387ab Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:19:12 +0200 Subject: [PATCH] Fonts: fallback glyphs keep the weight and slant, Bold Italic of a missing family keeps the bold, Han and kana prefer an installed sans (#2491) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/text/src/fontdb.rs | 101 +++++++++++++++++++++++++++++--------- crates/text/src/shape.rs | 2 +- crates/text/src/tests.rs | 34 +++++++++++++ docs/type-parity.md | 2 + 4 files changed, 115 insertions(+), 24 deletions(-) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index f75f3175f..bcf6716f1 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -500,11 +500,47 @@ pub const THAI_FAMILIES: &[&str] = &[ "Garuda", ]; +/// Installed sans-serif families tried first for Chinese characters the font in use lacks, so that +/// Han next to Hangul (which only these cover) is not set in the bundled Mincho (#2491). +const HAN_SANS_FAMILIES: &[&str] = &[ + "PingFang SC", + "Hiragino Sans", + "Hiragino Kaku Gothic ProN", + "Microsoft YaHei", + "Yu Gothic", + "Noto Sans CJK SC", + "Noto Sans CJK JP", + "Noto Sans SC", + "Noto Sans JP", + "Source Han Sans", +]; + +/// The same for kana: the Japanese families first. +const KANA_SANS_FAMILIES: &[&str] = &[ + "Hiragino Sans", + "Hiragino Kaku Gothic ProN", + "Yu Gothic", + "Noto Sans CJK JP", + "Noto Sans JP", + "Source Han Sans", + "PingFang SC", + "Microsoft YaHei", + "Noto Sans CJK SC", +]; + /// The families tried first for character `c`, in a script whose text only fonts made for it set -/// well (Thai, Lao, Khmer, Myanmar); none for other characters. +/// well (Thai, Lao, Khmer, Myanmar), or that is set in an installed sans-serif rather than the +/// bundled Mincho (Han, kana, CJK punctuation); none for other characters. When none of the +/// families is installed the loaded fonts are used, the Mincho among them. fn script_families(c: char) -> &'static [&'static str] { use unicode_script::{Script, UnicodeScript}; + if matches!(c, '\u{3000}'..='\u{303F}' | '\u{FF00}'..='\u{FFEF}') { + // Punctuation is set the Japanese way (the corner brackets sit by the line's start). + return KANA_SANS_FAMILIES; + } match c.script() { + Script::Han | Script::Bopomofo => HAN_SANS_FAMILIES, + Script::Hiragana | Script::Katakana => KANA_SANS_FAMILIES, Script::Thai => THAI_FAMILIES, Script::Lao => &["Lao UI", "Leelawadee UI", "Lao Sangam MN", "Noto Sans Lao", "Noto Sans Lao UI", "Phetsarath OT"], Script::Khmer => &["Khmer UI", "Khmer Sangam MN", "Noto Sans Khmer", "Noto Sans Khmer UI", "Khmer OS"], @@ -1594,6 +1630,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 +2284,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> { + 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> { // 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 +2325,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> { + /// 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> { 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 +2344,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> { + /// 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> { let faces = self.read_faces(); - let mut order: Vec<&Arc> = 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 diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 2e324c2bf..16eeeb750 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -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 }; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 5cd6114e3..792abd9c4 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -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 diff --git a/docs/type-parity.md b/docs/type-parity.md index ddb7bda6f..e9379ad6d 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -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; Han, kana and CJK punctuation prefer an installed sans over the bundled Mincho | 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; Han: PingFang SC, Hiragino Sans, Microsoft YaHei, Yu Gothic, Noto Sans CJK SC/JP, Source Han Sans; kana and CJK punctuation: the Japanese families first; the bundled Mincho stays when none is installed; no synthesized bold or italic yet | | 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; Han and kana prefer an installed sans over the bundled Mincho (#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 |