diff --git a/crates/text/src/fontdb/stylistic_sets.rs b/crates/text/src/fontdb/stylistic_sets.rs index 8d6578227..d2b1177b9 100644 --- a/crates/text/src/fontdb/stylistic_sets.rs +++ b/crates/text/src/fontdb/stylistic_sets.rs @@ -1,8 +1,11 @@ //! The font-defined OpenType stylistic sets offered by the OpenType panel. //! -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 { (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>, tags: &[Tag]) -> HashMap> { + 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::(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>; 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>, + labels: &mut [Option>; 20], +) { + let tags: Vec = 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) -> std::sync::Arc { 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 { + 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()) }, + ] + ); + } }