mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
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 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
b55d277b49
commit
43356a1cfb
+78
-23
@@ -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<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 +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<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 +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<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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user