From eb25a85d90b7abcd8967d3728207e34f05cf7e78 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 06:07:41 +0200 Subject: [PATCH] Type: stylistic sets, alternates, titling, figure styles, superscript and subscript A character style's OpenType features took nine tags. OtFeatures now also keeps ss01-ss20, salt, titl, pnum, lnum, sups and subs (off by default, one bit each in `extra`): they are accepted by the type commands, stored with the style and passed to the shaper, so a font that has them uses them. The Affinity import test used ss01 as its example of a feature kept but not rendered; it now uses cv01 for that (character variants still aren't rendered) and keeps ss01 as one that is. Fixes #1607 --- crates/engine/src/cmd/textedit.rs | 7 ++++- crates/engine/tests/affinity_text.rs | 4 ++- crates/text/src/features.rs | 46 ++++++++++++++++++++++++++-- crates/text/src/lib.rs | 2 +- 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index d8816db4..a4f98107 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -230,7 +230,12 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result> let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) }; let tags: Vec<&str> = v.as_array().ok_or_else(|| bad(cmd, "features must be a list of tags"))?.iter().filter_map(Value::as_str).collect(); if let Some(t) = tags.iter().find(|t| !vectorcraft_text::OtFeatures::known_tag(t)) { - return Err(bad(cmd, format!("unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh; prefix - to turn off)"))); + return Err(bad( + cmd, + format!( + "unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs; prefix - to turn off)" + ), + )); } Ok(Some(vectorcraft_text::OtFeatures::default().with_tags(tags).to_tags())) } diff --git a/crates/engine/tests/affinity_text.rs b/crates/engine/tests/affinity_text.rs index e2adc77c..feb70481 100644 --- a/crates/engine/tests/affinity_text.rs +++ b/crates/engine/tests/affinity_text.rs @@ -159,6 +159,8 @@ fn caps_and_feature_overrides_pass_through_to_editable_type() { vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"CAP\x01"))), (tag(b"Valu"), F::I32(1))], vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"liga"))), (tag(b"Valu"), F::I32(0))], vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"ss01"))), (tag(b"Valu"), F::I32(1))], + // Character variants aren't rendered yet (stylistic sets are, #1607). + vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"cv01"))), (tag(b"Valu"), F::I32(1))], ], ), )], @@ -169,7 +171,7 @@ fn caps_and_feature_overrides_pass_through_to_editable_type() { let t = texts(&loaded.doc)[0]; assert_eq!(t.runs[0].text, "Mixed"); assert!(t.runs[0].style.all_caps); - assert_eq!(t.runs[0].style.features, ["-liga", "ss01"]); + assert_eq!(t.runs[0].style.features, ["-liga", "ss01", "cv01"]); assert!(loaded.warnings.iter().any(|w| w.contains("retained but not rendered"))); let restored = vectorcraft_format::load(&vectorcraft_format::save_file(&loaded.doc)).unwrap(); assert_eq!(texts(&restored)[0], t); diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index af7a7c40..07526846 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -41,8 +41,19 @@ pub struct OtFeatures { pub ordinals: bool, /// Swashes (`swsh`). pub swash: bool, + /// The features of [`EXTRA_FEATURES`] turned on, one bit each (off by default): stylistic + /// sets, alternates, titling, proportional and lining figures, superscript and subscript. + pub extra: u32, } +/// Features beyond the named fields, off unless asked for, each bit `i` of [`OtFeatures::extra`]: +/// stylistic sets `ss01`–`ss20`, stylistic alternates `salt`, titling `titl`, proportional `pnum` +/// and lining `lnum` figures, superscript `sups` and subscript `subs`. +pub const EXTRA_FEATURES: [&str; 26] = [ + "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10", "ss11", "ss12", "ss13", "ss14", "ss15", "ss16", "ss17", "ss18", + "ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", +]; + impl Default for OtFeatures { fn default() -> Self { Self { @@ -56,6 +67,7 @@ impl Default for OtFeatures { tabular_figures: false, ordinals: false, swash: false, + extra: 0, } } } @@ -89,13 +101,14 @@ impl OtFeatures { v.push(if on { tag.to_string() } else { format!("-{tag}") }); } } + v.extend(EXTRA_FEATURES.iter().enumerate().filter(|(i, _)| self.extra & (1 << i) != 0).map(|(_, t)| t.to_string())); v } /// Is `tag` (optionally prefixed with `-` or `+`) one this set understands? pub fn known_tag(tag: &str) -> bool { let t = tag.trim_start_matches(['-', '+']); - matches!(t, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") + matches!(t, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&t) } /// This set with `tags` applied on top. @@ -116,7 +129,15 @@ impl OtFeatures { "tnum" => o.tabular_figures = on, "ordn" => o.ordinals = on, "swsh" => o.swash = on, - _ => {} + _ => { + if let Some(i) = EXTRA_FEATURES.iter().position(|e| *e == t) { + if on { + o.extra |= 1 << i; + } else { + o.extra &= !(1 << i); + } + } + } } } self @@ -155,6 +176,11 @@ impl OtFeatures { v.push(f(tag, true)); } } + for (i, tag) in EXTRA_FEATURES.iter().enumerate() { + if let (true, Ok(t)) = (s.extra & (1 << i) != 0, <[u8; 4]>::try_from(tag.as_bytes())) { + v.push(f(&t, true)); + } + } if self.vertical { // Not `vrt2`: its glyphs are already turned on their side, and the layout turns Latin // and digits itself (they would lie upside down, and tate-chu-yoko break). @@ -230,6 +256,22 @@ pub fn explicit_ligatures>(features: &[S]) -> bool { #[cfg(test)] mod tests { use super::*; + + /// #1607: stylistic sets, alternates, titling, figure styles and superscript/subscript are + /// known, round-trip through the stored tags and reach the shaper. + #[test] + fn extra_features_are_kept_and_shaped() { + for t in ["ss01", "ss20", "-ss07", "salt", "titl", "pnum", "lnum", "sups", "subs"] { + assert!(OtFeatures::known_tag(t), "{t}"); + } + assert!(!OtFeatures::known_tag("ss21")); + let o = OtFeatures::from_tags(["ss03", "pnum", "sups", "-liga"]); + assert_eq!(o.to_tags(), vec!["-liga", "ss03", "pnum", "sups"]); + assert_eq!(OtFeatures::from_tags(["ss03", "-ss03"]).to_tags(), Vec::::new(), "turned off again"); + let st = CharStyle { features: vec!["ss05".into(), "titl".into()], ..CharStyle::default() }; + let tags: Vec = OtFeatures::default().resolve(&st).iter().filter(|f| f.value == 1).map(|f| f.tag.to_string()).collect(); + assert!(tags.contains(&"ss05".to_string()) && tags.contains(&"titl".to_string()), "{tags:?}"); + } use crate::{FontDb, layout}; use vectorcraft_doc::TextObject; use vectorcraft_geom::Point; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 300bfb91..aef5c768 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -26,7 +26,7 @@ pub mod test_fonts; pub mod thread; pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont}; -pub use features::{LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; +pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; pub use fontdb::{ FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, WantedFont, WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, style_weight, system_font_dirs, user_font_dirs,