Fonts: a variable font's default face is named and weighed by its default instance (#2521)

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:
vangel76
2026-10-18 01:09:53 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 2a5dfa6a94
commit b55d277b49
3 changed files with 81 additions and 6 deletions
+40 -6
View File
@@ -892,11 +892,22 @@ pub(crate) fn face_styles(f: &skrifa::FontRef<'_>) -> Vec<FaceStyle> {
let native_family = native_family_name(f, &family);
let mut keys = FaceKeys::of(f, &family, &style);
let traits = FontTraits::of(f, &keys);
let mut instances = named_instances(f, &family, &style, &mut keys, native_family.clone());
let (mut instances, renamed) = named_instances(f, &family, &style, &mut keys, native_family.clone());
for i in &mut instances {
i.traits = traits;
}
let face = FaceStyle { family, native_family, style, keys, variations: vec![], weight: None, italic: false, traits };
// A variable font's default face renders at its `wght` default, whatever its name table and
// OS/2 table say (Noto Sans Hebrew: named `Light`, weight class 300, default instance Regular
// at 400): weigh it so, and name it by its default instance when that is another weight.
let weight = f
.axes()
.iter()
.find(|a| a.tag().to_be_bytes() == *b"wght")
.map(|a| a.default_value())
.filter(|w| w.is_finite())
.map(|w| w.clamp(1.0, 1000.0));
let style = renamed.unwrap_or(style);
let face = FaceStyle { family, native_family, style, keys, variations: vec![], weight, italic: false, traits };
std::iter::once(face).chain(instances).collect()
}
@@ -905,17 +916,40 @@ pub(crate) fn face_styles(f: &skrifa::FontRef<'_>) -> Vec<FaceStyle> {
/// names already taken. The default instance's name, when it isn't the face's style name (a face
/// `Regular` whose default instance is `Book`), names the face too (`keys`). Damaged tables give
/// fewer instances or none; the counts read are capped.
fn named_instances(f: &skrifa::FontRef<'_>, family: &str, style: &str, keys: &mut FaceKeys, native_family: Option<String>) -> Vec<FaceStyle> {
fn named_instances(
f: &skrifa::FontRef<'_>,
family: &str,
style: &str,
keys: &mut FaceKeys,
native_family: Option<String>,
) -> (Vec<FaceStyle>, Option<String>) {
let axes = f.axes();
if axes.is_empty() || axes.len() > MAX_AXES {
return vec![];
return (vec![], None);
}
let tags: Vec<Tag> = axes.iter().map(|a| a.tag()).collect();
let defaults: Vec<f32> = axes.iter().map(|a| a.default_value()).collect();
let is_default = |coords: &[f32]| coords.len() == defaults.len() && coords.iter().zip(&defaults).all(|(c, d)| (c - d).abs() < 1e-3);
// The face renders as its default instance. When that instance is another weight than the
// name table says (`Light` face, Regular default instance), the instance names the face.
let renamed = tags.iter().any(|t| t.to_be_bytes() == *b"wght").then(|| {
f.named_instances()
.iter()
.take(MAX_INSTANCES)
.filter(|inst| is_default(&inst.user_coords().take(MAX_AXES).collect::<Vec<f32>>()))
.find_map(|inst| name(f, &[inst.subfamily_name_id()]))
.filter(|d| style_weight(d) != style_weight(style))
});
let renamed = renamed.flatten();
// Instances without a PostScript name of their own are named `<prefix>-<style>` (Adobe
// Technical Note #5902), as PDF and `.ai` files name them.
let prefix = name(f, &[StringId::VARIATIONS_POSTSCRIPT_NAME_PREFIX]).unwrap_or_else(|| family.to_string());
let mut taken = vec![norm(style)];
if let Some(new) = &renamed {
let old = norm(style);
keys.styles.retain(|k| *k != old);
keys.styles.push(norm(new));
}
let mut taken = vec![norm(renamed.as_deref().unwrap_or(style))];
let mut out = Vec::new();
for inst in f.named_instances().iter().take(MAX_INSTANCES) {
let coords: Vec<f32> = inst.user_coords().take(MAX_AXES).collect();
@@ -960,7 +994,7 @@ fn named_instances(f: &skrifa::FontRef<'_>, family: &str, style: &str, keys: &mu
traits: FontTraits::default(),
});
}
out
(out, renamed)
}
/// Parse every face in `data` (a font file or collection) and the named instances of its variable
+40
View File
@@ -280,3 +280,43 @@ fn a_variable_full_name_names_its_default_face() {
let bold = db.resolve("VaritestSans-Bold", "Regular").unwrap().0;
assert_eq!(bold.variations(), [(*b"wght", 700.0)]);
}
/// #2491: a variable font whose name table calls the default face `Light` (and whose OS/2 weight
/// says 300) while its default instance is Regular at `wght` 400 lists, resolves and weighs the
/// default face as Regular, as it renders.
#[test]
fn a_variable_default_face_is_named_and_weighed_by_its_default_instance() {
let bytes = font();
let source = skrifa::FontRef::new(&bytes).unwrap();
let family = "Hebtest Sans";
let names = name_table(&[
(1, "Hebtest Sans Light"),
(2, "Regular"),
(4, "Hebtest Sans Light"),
(6, "HebtestSans-Light"),
(16, family),
(17, "Light"),
(256, "Weight"),
(257, "Regular"),
(258, "SemiBold"),
(259, "Bold"),
])
.unwrap();
let mut os2 = source.table_data(skrifa::raw::types::Tag::new(b"OS/2")).unwrap().as_bytes().to_vec();
os2[4..6].copy_from_slice(&300_u16.to_be_bytes());
let mut builder = write_fonts::FontBuilder::new();
builder.add_raw(write_fonts::types::Tag::new(b"name"), names);
builder.add_raw(write_fonts::types::Tag::new(b"OS/2"), os2);
for record in source.table_directory.table_records() {
let tag = write_fonts::types::Tag::new(&record.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, source.table_data(record.tag()).unwrap().as_bytes());
}
}
let db = FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(builder.build()), 3);
assert_eq!(db.styles(family), ["Regular", "SemiBold", "Bold"]);
let (face, matched) = db.resolve(family, "Regular").unwrap();
assert_eq!((face.style.as_str(), face.weight, matched), ("Regular", 400.0, FontMatch::Exact));
assert!(face.variations().is_empty());
}
+1
View File
@@ -81,6 +81,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 | 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 |
| 2026-10-11 | minor | Thai, Lao, Khmer and Myanmar words for the caret (Ctrl/Alt+arrows) and double-click; combining marks count as part of a word; score unchanged (estimated) |