Merge pull request #5551 from dotCooCoo/fix/stylistic-sets-feature-variations

Type: list stylistic sets whose lookups come from FeatureVariations
This commit is contained in:
BFlat
2026-11-04 16:04:16 +07:00
committed by GitHub
+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()) },
]
);
}
}