Type: list stylistic sets whose lookups come from FeatureVariations

A variable font can leave an ssNN feature empty in GSUB or GPOS and
supply its lookups through a FeatureVariations substitution. The
OpenType panel skipped those sets because the default feature has no
lookups. A set is now listed when a substitution in a variation record
replaces its feature with one that has lookups, and its name is read
from the default feature or the substituted one.

Records after one without conditions are ignored, and at most 4096
variation records and 4096 substitutions are read per table.

Follows #5447.
This commit is contained in:
dotCooCoo
2026-11-04 15:41:05 +07:00
committed by bflatastic
parent 9c191793e2
commit 663c3264ca
+125 -27
View File
@@ -1,8 +1,11 @@
//! The font-defined OpenType stylistic sets offered by the OpenType panel.
//! <https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt>
use skrifa::raw::TableProvider;
use skrifa::raw::tables::layout::{FeatureList, FeatureParams};
use std::collections::HashMap;
use skrifa::raw::tables::layout::{Feature, FeatureList, FeatureParams, FeatureVariations};
use skrifa::raw::types::Tag;
use skrifa::raw::{ResolveOffset, TableProvider};
use super::FontFace;
use super::names::name;
@@ -24,20 +27,71 @@ fn number(tag: [u8; 4]) -> Option<usize> {
(1..=20).contains(&n).then_some(n)
}
/// The features that FeatureVariations substitutions replace with a feature that has lookups,
/// keyed by feature index. A variable font can leave a set's default feature empty and supply its
/// lookups only at some variation locations. `tags` holds each feature's tag, which a substituted
/// feature needs to read its parameters. At most 4096 variation records and 4096 substitutions are
/// read.
fn variation_features<'a>(variations: Option<FeatureVariations<'a>>, tags: &[Tag]) -> HashMap<usize, Feature<'a>> {
let mut found = HashMap::new();
let Some(variations) = variations else { return found };
let mut budget = 4096_usize;
for record in variations.feature_variation_records().iter().take(4096) {
if let Some(Ok(substitution)) = record.feature_table_substitution(variations.offset_data()) {
for replacement in substitution.substitutions() {
if budget == 0 {
return found;
}
budget -= 1;
let index = usize::from(replacement.feature_index());
let Some(&tag) = tags.get(index) else { continue };
if let Ok(alternate) = replacement.alternate_feature_offset().resolve_with_args::<Feature>(substitution.offset_data(), tag)
&& !alternate.lookup_list_indices().is_empty()
{
found.entry(index).or_insert(alternate);
}
}
}
// Only the first record whose conditions match applies, so records after one without
// conditions are never used.
let unconditional = match record.condition_set(variations.offset_data()) {
None => true,
Some(Ok(conditions)) => conditions.condition_count() == 0,
Some(Err(_)) => false,
};
if unconditional {
break;
}
}
found
}
/// Collect registered set records from either layout table. Fonts may carry a set in GSUB, GPOS,
/// or both. `labels[n - 1]` is `Some` once set `n` is found, holding its readable custom name if
/// any record supplies one.
fn collect(font: &skrifa::FontRef<'_>, features: FeatureList<'_>, labels: &mut [Option<Option<String>>; 20]) {
for record in features.feature_records().iter().take(4096) {
/// or both. A record with no lookups counts only when a FeatureVariations substitution gives it
/// some. `labels[n - 1]` is `Some` once set `n` is found, holding its readable custom name if any
/// record supplies one.
fn collect(
font: &skrifa::FontRef<'_>,
features: FeatureList<'_>,
variations: Option<FeatureVariations<'_>>,
labels: &mut [Option<Option<String>>; 20],
) {
let tags: Vec<Tag> = features.feature_records().iter().take(4096).map(|record| record.feature_tag()).collect();
let substituted = variation_features(variations, &tags);
for (index, record) in features.feature_records().iter().enumerate().take(4096) {
let Some(n) = number(record.feature_tag().to_be_bytes()) else { continue };
let Ok(feature) = record.feature(features.offset_data()) else { continue };
if feature.lookup_list_indices().is_empty() {
let Ok(default) = record.feature(features.offset_data()) else { continue };
let label = |feature: &Feature<'_>| {
feature.feature_params().and_then(Result::ok).and_then(|params| match params {
FeatureParams::StylisticSet(p) => name(font, &[p.ui_name_id()]),
_ => None,
})
};
let alternate = substituted.get(&index);
if default.lookup_list_indices().is_empty() && alternate.is_none() {
continue;
}
let custom = feature.feature_params().and_then(Result::ok).and_then(|params| match params {
FeatureParams::StylisticSet(p) => name(font, &[p.ui_name_id()]),
_ => None,
});
let custom = label(&default).or_else(|| alternate.and_then(label));
let slot = &mut labels[n - 1];
if custom.is_some() {
*slot = Some(custom);
@@ -56,12 +110,12 @@ impl FontFace {
if let Ok(gsub) = font.gsub()
&& let Ok(features) = gsub.feature_list()
{
collect(&font, features, &mut labels);
collect(&font, features, gsub.feature_variations().and_then(Result::ok), &mut labels);
}
if let Ok(gpos) = font.gpos()
&& let Ok(features) = gpos.feature_list()
{
collect(&font, features, &mut labels);
collect(&font, features, gpos.feature_variations().and_then(Result::ok), &mut labels);
}
labels.into_iter().enumerate().filter_map(|(i, name)| Some(StylisticSet { number: u8::try_from(i + 1).ok()?, name: name? })).collect()
}
@@ -74,10 +128,11 @@ mod tests {
use write_fonts::FontBuilder;
use write_fonts::tables::gsub::{Gsub, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList};
use write_fonts::tables::layout::{
CoverageFormat1, Feature, FeatureList, FeatureParams as WriteParams, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord,
StylisticSetParams,
Condition, ConditionFormat1, ConditionSet, CoverageFormat1, Feature, FeatureList, FeatureParams as WriteParams, FeatureRecord,
FeatureTableSubstitution, FeatureTableSubstitutionRecord, FeatureVariationRecord, FeatureVariations as WriteVariations, LangSys, Lookup,
Script, ScriptList, ScriptRecord, StylisticSetParams,
};
use write_fonts::types::{GlyphId16, NameId, Tag};
use write_fonts::types::{F2Dot14, GlyphId16, NameId, Tag};
#[test]
fn registered_set_numbers_only() {
@@ -88,19 +143,14 @@ mod tests {
assert_eq!(number(*b"salt"), None);
}
#[test]
fn face_lists_only_its_sets_with_the_fonts_name() {
fn face_with(features: FeatureList, variations: Option<WriteVariations>) -> std::sync::Arc<FontFace> {
const BASE: &[u8] = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf");
let base = skrifa::FontRef::new(BASE).unwrap();
let gid = |c| GlyphId16::new(u16::try_from(base.charmap().map(c).unwrap().to_u32()).unwrap());
let subst = SingleSubstFormat2::new(CoverageFormat1::new(vec![gid('a')]).into(), vec![gid('A')]);
let lookup = SubstitutionLookup::Single(Lookup::new(Default::default(), vec![subst.into()]));
let features = FeatureList::new(vec![
FeatureRecord::new(Tag::new(b"ss01"), Feature::new(Some(WriteParams::StylisticSet(StylisticSetParams::new(NameId::new(256)))), vec![0])),
FeatureRecord::new(Tag::new(b"ss03"), Feature::new(None, vec![0])),
FeatureRecord::new(Tag::new(b"ss05"), Feature::new(None, vec![])),
]);
let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(vec![0, 1, 2])), vec![]))]);
let indices = (0..u16::try_from(features.feature_records.len()).unwrap()).collect();
let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(indices)), vec![]))]);
let mut builder = FontBuilder::new();
let names = crate::test_fonts::name_table(&[
(1, "Stylistic Test Sans"),
@@ -111,7 +161,9 @@ mod tests {
])
.unwrap();
builder.add_raw(Tag::new(b"name"), names);
builder.add_table(&Gsub::new(scripts, features, SubstitutionLookupList::new(vec![lookup]))).unwrap();
let mut gsub = Gsub::new(scripts, features, SubstitutionLookupList::new(vec![lookup]));
gsub.feature_variations = variations.into();
builder.add_table(&gsub).unwrap();
for record in base.table_directory.table_records() {
let tag = Tag::new(&record.tag().to_be_bytes());
if !builder.contains(tag) {
@@ -120,7 +172,53 @@ mod tests {
}
let db = super::super::FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(builder.build()), 1);
let face = db.face("Stylistic Test Sans", "Regular").unwrap();
db.face("Stylistic Test Sans", "Regular").unwrap()
}
fn set_params() -> Option<WriteParams> {
Some(WriteParams::StylisticSet(StylisticSetParams::new(NameId::new(256))))
}
#[test]
fn face_lists_only_its_sets_with_the_fonts_name() {
let features = FeatureList::new(vec![
FeatureRecord::new(Tag::new(b"ss01"), Feature::new(set_params(), vec![0])),
FeatureRecord::new(Tag::new(b"ss03"), Feature::new(None, vec![0])),
FeatureRecord::new(Tag::new(b"ss05"), Feature::new(None, vec![])),
]);
let face = face_with(features, None);
assert_eq!(face.stylistic_sets(), [StylisticSet { number: 1, name: Some("Curved Capitals".into()) }, StylisticSet { number: 3, name: None }]);
}
#[test]
fn a_set_whose_lookups_come_from_feature_variations_is_listed() {
let features = FeatureList::new(vec![
FeatureRecord::new(Tag::new(b"ss02"), Feature::new(None, vec![0])),
FeatureRecord::new(Tag::new(b"ss05"), Feature::new(None, vec![])),
FeatureRecord::new(Tag::new(b"ss07"), Feature::new(None, vec![])),
FeatureRecord::new(Tag::new(b"ss09"), Feature::new(set_params(), vec![])),
]);
let condition =
ConditionSet::new(vec![Condition::Format1AxisRange(ConditionFormat1::new(0, F2Dot14::from_f32(0.5), F2Dot14::from_f32(1.0)))]);
let conditional = FeatureTableSubstitution::new(vec![
FeatureTableSubstitutionRecord::new(1, Feature::new(set_params(), vec![0])),
FeatureTableSubstitutionRecord::new(2, Feature::new(None, vec![])),
]);
let always = FeatureTableSubstitution::new(vec![FeatureTableSubstitutionRecord::new(3, Feature::new(None, vec![0]))]);
let unreachable = FeatureTableSubstitution::new(vec![FeatureTableSubstitutionRecord::new(2, Feature::new(None, vec![0]))]);
let variations = WriteVariations::new(vec![
FeatureVariationRecord::new(Some(condition), Some(conditional)),
FeatureVariationRecord::new(None, Some(always)),
FeatureVariationRecord::new(None, Some(unreachable)),
]);
let face = face_with(features, Some(variations));
assert_eq!(
face.stylistic_sets(),
[
StylisticSet { number: 2, name: None },
StylisticSet { number: 5, name: Some("Curved Capitals".into()) },
StylisticSet { number: 9, name: Some("Curved Capitals".into()) },
]
);
}
}