From b8a2dd123aa6a5d0b95774ae74daa50e852932f8 Mon Sep 17 00:00:00 2001 From: Jason Naylor Date: Fri, 24 Jul 2026 11:56:59 -0700 Subject: [PATCH] LT-22638: Show OpenType feature names and multi-valued variants - Read the font-supplied names and named options from GSUB featureParams and the name table so features are comprehensible and character variants are selectable, as with Graphite. - Add OpenTypeFontFeatureInfoReader adapted from Paratext's OpenTypeFeatures.Ttf. - Add OpenTypeFeatureCatalog registered-feature hidden and default-on classification plus English names. Co-Authored-By: Claude Fable 5 --- Docs/opentype-font-features.md | 8 + .../FwUtils/FwUtilsTests/FwUtilsTests.csproj | 15 + .../OpenTypeFeatureCatalogTests.cs | 73 +++++ ...ypeFontFeatureInfoReaderRobustnessTests.cs | 310 ++++++++++++++++++ .../OpenTypeFontFeatureInfoReaderTests.cs | 138 ++++++++ Src/Common/FwUtils/OpenTypeFeatureCatalog.cs | 229 +++++++++++++ .../FwUtils/OpenTypeFontFeatureInfoReader.cs | 309 +++++++++++++++++ .../FwCoreDlgControls/FontFeaturesButton.cs | 226 ++++++------- .../FwCoreDlgControls/FwCoreDlgControls.resx | 33 +- .../TestFontFeaturesButton.cs | 148 ++++++++- .../.openspec.yaml | 2 + .../design.md | 62 ++++ .../proposal.md | 42 +++ .../research.md | 41 +++ .../specs/font-feature-settings/spec.md | 82 +++++ .../tasks.md | 27 ++ 16 files changed, 1600 insertions(+), 145 deletions(-) create mode 100644 Src/Common/FwUtils/FwUtilsTests/OpenTypeFeatureCatalogTests.cs create mode 100644 Src/Common/FwUtils/FwUtilsTests/OpenTypeFontFeatureInfoReaderRobustnessTests.cs create mode 100644 Src/Common/FwUtils/FwUtilsTests/OpenTypeFontFeatureInfoReaderTests.cs create mode 100644 Src/Common/FwUtils/OpenTypeFeatureCatalog.cs create mode 100644 Src/Common/FwUtils/OpenTypeFontFeatureInfoReader.cs create mode 100644 openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/design.md create mode 100644 openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/proposal.md create mode 100644 openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/research.md create mode 100644 openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/specs/font-feature-settings/spec.md create mode 100644 openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/tasks.md diff --git a/Docs/opentype-font-features.md b/Docs/opentype-font-features.md index 55c80ddbea..599b15a022 100644 --- a/Docs/opentype-font-features.md +++ b/Docs/opentype-font-features.md @@ -4,6 +4,14 @@ FieldWorks stores font options as renderer-neutral feature strings such as `smcp In the current WinForms UI, use the Font Options button in font controls to choose the configurable features exposed by the selected font. Graphite remains available for now, but the Font Options UI is no longer limited to Graphite fonts. +## Feature names and values + +Feature labels are resolved in order: the name the font supplies for a stylistic set (`ssXX`) or character variant (`cvXX`) via its GSUB `featureParams` and `name` table; a localized name from `FwCoreDlgControls.resx`; the English name from the registered-feature catalog; and finally a `Stylistic Set N` / `Character Variant N` / `Feature #` fallback. Labels show the name only, matching the Graphite presentation; four-character tags appear only in the last-resort fallback. `OpenTypeFontFeatureInfoReader` in `FwUtils` reads this information from GSUB and GPOS through a table-source delegate (GDI `GetFontData` in the app, font-file bytes in tests) and degrades to a tag-only record if a table is malformed. + +A character variant that names its options is presented as a multi-valued submenu — `None` plus each named option — and stored as `cv43=2`, where the value is the 1-based option index. Character variants and stylistic sets without font-supplied strings fall back to an on/off toggle. + +`OpenTypeFeatureCatalog` in `FwUtils` classifies registered features. Features it marks hidden (shaping-required features such as `mark`, `ccmp`, `init`; and glyph-palette features such as `aalt`) are discovered but not shown as toggles. Features it marks default-on (`liga`, `clig`, `calt`, `kern`) display as enabled when the stored feature string does not mention them; the string gains an explicit `tag=0` only when the user turns such a feature off. Unset features are never written. + Graphite feature IDs are still converted only at the Graphite renderer boundary. OpenType feature tags stay as four-character tags and are passed to the Uniscribe OpenType path when Graphite is not enabled. For export, CSS output maps these values to `font-feature-settings`, and Notebook export preserves writing-system default font features in `DefaultFontFeatures`. diff --git a/Src/Common/FwUtils/FwUtilsTests/FwUtilsTests.csproj b/Src/Common/FwUtils/FwUtilsTests/FwUtilsTests.csproj index 5ef40363c7..b27a771d58 100644 --- a/Src/Common/FwUtils/FwUtilsTests/FwUtilsTests.csproj +++ b/Src/Common/FwUtils/FwUtilsTests/FwUtilsTests.csproj @@ -46,4 +46,19 @@ + + + + 6.200 + $(FwRoot)\Downloads\CharisSIL\CharisSIL-$(CharisSilTestFontVersion)\CharisSIL-Regular.ttf + + + + \ No newline at end of file diff --git a/Src/Common/FwUtils/FwUtilsTests/OpenTypeFeatureCatalogTests.cs b/Src/Common/FwUtils/FwUtilsTests/OpenTypeFeatureCatalogTests.cs new file mode 100644 index 0000000000..d56e53c198 --- /dev/null +++ b/Src/Common/FwUtils/FwUtilsTests/OpenTypeFeatureCatalogTests.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Linq; +using NUnit.Framework; + +namespace SIL.FieldWorks.Common.FwUtils +{ + [TestFixture] + public class OpenTypeFeatureCatalogTests + { + // The exact set the pre-LT-22638 FontFeaturesButton hid via its s_nonUserConfigurableTags + // blocklist. Every one must stay hidden so the catalog does not un-hide shaping features. + private static readonly string[] LegacyBlocklist = + { + "abvf", "abvm", "abvs", "akhn", "blwf", "blwm", "blws", "ccmp", + "cjct", "curs", "dist", "fina", "haln", "half", "init", "isol", + "ljmo", "locl", "mark", "medi", "mkmk", "nukt", "pref", "pres", + "pstf", "psts", "rclt", "rkrf", "rlig", "tjmo", "vjmo" + }; + + [Test] + public void LegacyBlocklistTags_AreAllHidden() + { + foreach (var tag in LegacyBlocklist) + { + Assert.That(OpenTypeFeatureCatalog.IsKnown(tag), Is.True, $"'{tag}' should be catalogued"); + Assert.That(OpenTypeFeatureCatalog.IsHidden(tag), Is.True, $"'{tag}' should remain hidden"); + } + } + + [Test] + public void DiscretionaryLigatures_AreUserVisible() + { + // Audit correction: Paratext hides dlig, but it is the canonical user-facing ligature feature. + Assert.That(OpenTypeFeatureCatalog.IsHidden("dlig"), Is.False); + } + + [Test] + public void AccessAllAlternates_IsHidden() + { + // Audit correction: aalt is a glyph palette, not a meaningful on/off toggle. + Assert.That(OpenTypeFeatureCatalog.IsHidden("aalt"), Is.True); + } + + [TestCase("liga")] + [TestCase("clig")] + [TestCase("calt")] + [TestCase("kern")] + public void CommonlyDefaultOnFeatures_AreDefaultOn(string tag) + { + Assert.That(OpenTypeFeatureCatalog.IsHidden(tag), Is.False); + Assert.That(OpenTypeFeatureCatalog.IsDefaultOn(tag), Is.True); + } + + [Test] + public void UnknownTag_IsNeitherKnownNorHidden() + { + Assert.That(OpenTypeFeatureCatalog.IsKnown("zzzz"), Is.False); + Assert.That(OpenTypeFeatureCatalog.IsHidden("zzzz"), Is.False); + Assert.That(OpenTypeFeatureCatalog.GetEnglishName("zzzz"), Is.Null); + } + + [Test] + public void VisibleFeatures_HaveEnglishNames() + { + foreach (var tag in OpenTypeFeatureCatalog.AllTags.Where(t => !OpenTypeFeatureCatalog.IsHidden(t))) + Assert.That(OpenTypeFeatureCatalog.GetEnglishName(tag), Is.Not.Null.And.Not.Empty, + $"visible feature '{tag}' needs a friendly name"); + } + } +} diff --git a/Src/Common/FwUtils/FwUtilsTests/OpenTypeFontFeatureInfoReaderRobustnessTests.cs b/Src/Common/FwUtils/FwUtilsTests/OpenTypeFontFeatureInfoReaderRobustnessTests.cs new file mode 100644 index 0000000000..8396261f33 --- /dev/null +++ b/Src/Common/FwUtils/FwUtilsTests/OpenTypeFontFeatureInfoReaderRobustnessTests.cs @@ -0,0 +1,310 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NUnit.Framework; + +namespace SIL.FieldWorks.Common.FwUtils +{ + /// + /// Confirms the reader degrades to tag-only records instead of throwing when a font's layout + /// or name tables are truncated, misdirected, or undecodable. LT-22638. + /// + [TestFixture] + public class OpenTypeFontFeatureInfoReaderRobustnessTests + { + [Test] + public void NullTable_ReturnsNoFeatures() + { + var features = OpenTypeFontFeatureInfoReader.Read(tag => null); + Assert.That(features, Is.Empty); + } + + [Test] + public void ThrowingTableSource_IsSwallowed() + { + Assert.That(() => OpenTypeFontFeatureInfoReader.Read(tag => throw new InvalidOperationException()), + Throws.Nothing); + } + + [Test] + public void TruncatedFeatureList_ReturnsFeaturesBeforeTheBreak() + { + // Claim three features but only supply bytes for one record, then cut the table off. + var gsub = new BeWriter(); + gsub.UInt16(1).UInt16(0); // version 1.0 + gsub.UInt16(0); // scriptList offset (unused) + var featureListOffsetPos = gsub.Reserve();// featureList offset (back-patched) + gsub.UInt16(0); // lookupList offset (unused) + gsub.Patch(featureListOffsetPos, (ushort)gsub.Length); + gsub.UInt16(3); // featureCount lies: says 3 + gsub.Tag("liga").UInt16(0); // one record, offset patched below is bogus + // Table ends here: the 2nd record read runs off the end and the reader stops safely. + + var features = Read(gsub.ToArray(), null); + // No exception: the one parseable record is returned and reading stops at the break + // instead of running past the end of the table. + Assert.That(features.Select(f => f.Tag), Is.EqualTo(new[] { "liga" })); + } + + [Test] + public void FeatureParamsOffsetPastTableEnd_YieldsTagWithoutLabel() + { + var gsub = BuildGsub(new FeatureSpec("cv01", featureParamsOffset: 0x7000)); + var features = Read(gsub, null); + + var cv01 = features.Single(f => f.Tag == "cv01"); + Assert.That(cv01.FontSuppliedLabel, Is.Null); + Assert.That(cv01.Options, Is.Empty); + } + + [Test] + public void NameRecordOutsideStorage_FallsBackToNoLabel() + { + var gsub = BuildGsub(new FeatureSpec("ss01", ssUiNameId: 256)); + var name = BuildNameTable(new NameSpec(3, 1, 0x0409, 256, "ignored", forcedLength: 4000)); + + var features = Read(gsub, name); + + Assert.That(features.Single(f => f.Tag == "ss01").FontSuppliedLabel, Is.Null); + } + + [Test] + public void UndecodablePlatform_FallsBackToNoLabel() + { + var gsub = BuildGsub(new FeatureSpec("ss01", ssUiNameId: 256)); + // Platform 2 (ISO) is not decodable here; the reader must skip it, not guess. + var name = BuildNameTable(new NameSpec(2, 0, 0, 256, "Nope")); + + var features = Read(gsub, name); + + Assert.That(features.Single(f => f.Tag == "ss01").FontSuppliedLabel, Is.Null); + } + + [Test] + public void ZeroLengthName_FallsBackToNoLabel() + { + var gsub = BuildGsub(new FeatureSpec("ss01", ssUiNameId: 256)); + var name = BuildNameTable(new NameSpec(3, 1, 0x0409, 256, "")); + + var features = Read(gsub, name); + + Assert.That(features.Single(f => f.Tag == "ss01").FontSuppliedLabel, Is.Null); + } + + [Test] + public void WellFormedSynthetic_ReadsLabelAndOptions() + { + // Positive control so the builders themselves are trustworthy. + var gsub = BuildGsub(new FeatureSpec("cv01", cvLabelNameId: 256, cvOptionNameIds: new[] { 257, 258 })); + var name = BuildNameTable( + new NameSpec(3, 1, 0x0409, 256, "Alpha"), + new NameSpec(3, 1, 0x0409, 257, "First"), + new NameSpec(3, 1, 0x0409, 258, "Second")); + + var cv01 = Read(gsub, name).Single(f => f.Tag == "cv01"); + + Assert.That(cv01.FontSuppliedLabel, Is.EqualTo("Alpha")); + Assert.That(cv01.Options, Is.EqualTo(new[] { "First", "Second" })); + } + + private static IReadOnlyList Read(byte[] gsub, byte[] name) + { + return OpenTypeFontFeatureInfoReader.Read(tag => + { + switch (tag) + { + case "GSUB": return gsub; + case "name": return name; + default: return null; + } + }); + } + + private sealed class FeatureSpec + { + public FeatureSpec(string tag, int cvLabelNameId = 0, int[] cvOptionNameIds = null, + int ssUiNameId = 0, int featureParamsOffset = -1) + { + Tag = tag; + CvLabelNameId = cvLabelNameId; + CvOptionNameIds = cvOptionNameIds ?? Array.Empty(); + SsUiNameId = ssUiNameId; + ForcedFeatureParamsOffset = featureParamsOffset; + } + + public string Tag { get; } + public int CvLabelNameId { get; } + public int[] CvOptionNameIds { get; } + public int SsUiNameId { get; } + public int ForcedFeatureParamsOffset { get; } + public bool HasParams => CvLabelNameId > 0 || SsUiNameId > 0 || ForcedFeatureParamsOffset >= 0; + } + + private static byte[] BuildGsub(params FeatureSpec[] specs) + { + var gsub = new BeWriter(); + gsub.UInt16(1).UInt16(0); // version 1.0 + gsub.UInt16(0); // scriptList offset (unused) + var featureListOffsetPos = gsub.Reserve(); + gsub.UInt16(0); // lookupList offset (unused) + + var featureListStart = gsub.Length; + gsub.Patch(featureListOffsetPos, (ushort)featureListStart); + gsub.UInt16((ushort)specs.Length); + + // Reserve the record offsets, then lay out feature tables after all records. + var recordOffsetPositions = new int[specs.Length]; + for (var i = 0; i < specs.Length; i++) + { + gsub.Tag(specs[i].Tag); + recordOffsetPositions[i] = gsub.Reserve(); + } + + for (var i = 0; i < specs.Length; i++) + { + var spec = specs[i]; + var featureTableStart = gsub.Length; + gsub.Patch(recordOffsetPositions[i], (ushort)(featureTableStart - featureListStart)); + + var paramsBlock = BuildParamsBlock(spec); + ushort paramsOffset = 0; + if (spec.ForcedFeatureParamsOffset >= 0) + paramsOffset = (ushort)spec.ForcedFeatureParamsOffset; + else if (paramsBlock != null) + paramsOffset = 4; // params sit right after the 4-byte feature header + + gsub.UInt16(paramsOffset); + gsub.UInt16(0); // lookupIndexCount + if (spec.ForcedFeatureParamsOffset < 0 && paramsBlock != null) + gsub.Bytes(paramsBlock); + } + + return gsub.ToArray(); + } + + private static byte[] BuildParamsBlock(FeatureSpec spec) + { + if (spec.SsUiNameId > 0) + { + var ss = new BeWriter(); + ss.UInt16(0); // version + ss.UInt16((ushort)spec.SsUiNameId); + return ss.ToArray(); + } + if (spec.CvLabelNameId > 0 || spec.CvOptionNameIds.Length > 0) + { + var cv = new BeWriter(); + cv.UInt16(0); // format + cv.UInt16((ushort)spec.CvLabelNameId); + cv.UInt16(0); // tooltip + cv.UInt16(0); // sample text + cv.UInt16((ushort)spec.CvOptionNameIds.Length); + cv.UInt16((ushort)(spec.CvOptionNameIds.FirstOrDefault())); + cv.UInt16(0); // charCount + return cv.ToArray(); + } + return null; + } + + private sealed class NameSpec + { + public NameSpec(int platformId, int encodingId, int languageId, int nameId, string value, + int forcedLength = -1) + { + PlatformId = platformId; + EncodingId = encodingId; + LanguageId = languageId; + NameId = nameId; + Value = value; + ForcedLength = forcedLength; + } + + public int PlatformId { get; } + public int EncodingId { get; } + public int LanguageId { get; } + public int NameId { get; } + public string Value { get; } + public int ForcedLength { get; } + } + + private static byte[] BuildNameTable(params NameSpec[] records) + { + var storage = new BeWriter(); + var encoded = new List<(int offset, int length)>(); + foreach (var record in records) + { + var offset = storage.Length; + var bytes = Encoding.BigEndianUnicode.GetBytes(record.Value ?? string.Empty); + storage.Bytes(bytes); + encoded.Add((offset, record.ForcedLength >= 0 ? record.ForcedLength : bytes.Length)); + } + + var table = new BeWriter(); + table.UInt16(0); // format + table.UInt16((ushort)records.Length); // count + var storageOffset = 6 + records.Length * 12; + table.UInt16((ushort)storageOffset); + for (var i = 0; i < records.Length; i++) + { + table.UInt16((ushort)records[i].PlatformId); + table.UInt16((ushort)records[i].EncodingId); + table.UInt16((ushort)records[i].LanguageId); + table.UInt16((ushort)records[i].NameId); + table.UInt16((ushort)encoded[i].length); + table.UInt16((ushort)encoded[i].offset); + } + table.Bytes(storage.ToArray()); + return table.ToArray(); + } + + private sealed class BeWriter + { + private readonly List m_bytes = new List(); + + public int Length => m_bytes.Count; + + public BeWriter UInt16(int value) + { + m_bytes.Add((byte)((value >> 8) & 0xFF)); + m_bytes.Add((byte)(value & 0xFF)); + return this; + } + + public BeWriter Tag(string tag) + { + m_bytes.AddRange(Encoding.ASCII.GetBytes(tag)); + return this; + } + + public BeWriter Bytes(byte[] bytes) + { + m_bytes.AddRange(bytes); + return this; + } + + public int Reserve() + { + var position = m_bytes.Count; + m_bytes.Add(0); + m_bytes.Add(0); + return position; + } + + public void Patch(int position, ushort value) + { + m_bytes[position] = (byte)((value >> 8) & 0xFF); + m_bytes[position + 1] = (byte)(value & 0xFF); + } + + public byte[] ToArray() + { + return m_bytes.ToArray(); + } + } + } +} diff --git a/Src/Common/FwUtils/FwUtilsTests/OpenTypeFontFeatureInfoReaderTests.cs b/Src/Common/FwUtils/FwUtilsTests/OpenTypeFontFeatureInfoReaderTests.cs new file mode 100644 index 0000000000..8494dfc6cc --- /dev/null +++ b/Src/Common/FwUtils/FwUtilsTests/OpenTypeFontFeatureInfoReaderTests.cs @@ -0,0 +1,138 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using NUnit.Framework; + +namespace SIL.FieldWorks.Common.FwUtils +{ + [TestFixture] + public class OpenTypeFontFeatureInfoReaderTests + { + private IReadOnlyList m_features; + + [OneTimeSetUp] + public void ReadCharisFeatures() + { + var fontPath = CharisTestFontPath(); + if (!File.Exists(fontPath)) + Assert.Ignore("Charis SIL 6.200 test font not present; run the build so PackageRestore downloads it."); + + var tableSource = FileTableSource(File.ReadAllBytes(fontPath)); + m_features = OpenTypeFontFeatureInfoReader.Read(tableSource); + } + + private OpenTypeFontFeatureInfo Feature(string tag) + { + var info = m_features.FirstOrDefault(f => f.Tag == tag); + Assert.That(info, Is.Not.Null, $"expected font to declare feature '{tag}'"); + return info; + } + + [Test] + public void CharacterVariant_ReadsFontLabelAndOrderedOptions() + { + var cv43 = Feature("cv43"); + Assert.That(cv43.FontSuppliedLabel, Is.EqualTo("Capital Eng")); + Assert.That(cv43.Options, + Is.EqualTo(new[] { "Lowercase no descender", "Capital form", "Lowercase short stem" })); + } + + [Test] + public void CharacterVariant_ReadsTwoOptions() + { + var cv25 = Feature("cv25"); + Assert.That(cv25.FontSuppliedLabel, Is.EqualTo("Lowercase rams horn")); + Assert.That(cv25.Options, Is.EqualTo(new[] { "Large bowl", "Small gamma" })); + } + + [Test] + public void CharacterVariant_ReadsSingleOption() + { + var cv13 = Feature("cv13"); + Assert.That(cv13.FontSuppliedLabel, Is.EqualTo("Capital B hook")); + Assert.That(cv13.Options, Is.EqualTo(new[] { "Single bowl" })); + } + + [Test] + public void StylisticSet_ReadsFontName() + { + var ss01 = Feature("ss01"); + Assert.That(ss01.FontSuppliedLabel, Is.EqualTo("Single-story a and g")); + Assert.That(ss01.Options, Is.Empty); + } + + [Test] + public void RegisteredFeatures_AreDiscoveredWithoutFontLabels() + { + foreach (var tag in new[] { "liga", "smcp" }) + { + var info = Feature(tag); + Assert.That(info.FontSuppliedLabel, Is.Null, $"'{tag}' should have no font-supplied label"); + Assert.That(info.Options, Is.Empty); + } + } + + [Test] + public void GposFeatures_AreDiscovered() + { + // mark and mkmk live in GPOS, not GSUB; the reader must scan both layout tables. + Assert.That(m_features.Select(f => f.Tag), Does.Contain("mark")); + Assert.That(m_features.Select(f => f.Tag), Does.Contain("mkmk")); + } + + [Test] + public void Read_ReturnsFeaturesSortedByTag() + { + var tags = m_features.Select(f => f.Tag).ToArray(); + Assert.That(tags, Is.Ordered.Using(StringComparer.Ordinal)); + Assert.That(tags, Is.Unique); + } + + private static string CharisTestFontPath() + { + var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + return Path.Combine(assemblyDir, "TestData", "Fonts", "CharisSIL", "CharisSIL-Regular.ttf"); + } + + /// + /// Builds a table-source delegate over raw sfnt bytes by parsing the table directory, so + /// tests exercise the reader with the same per-table byte slices GDI GetFontData yields. + /// + internal static Func FileTableSource(byte[] font) + { + var tables = new Dictionary(StringComparer.Ordinal); + int numTables = (font[4] << 8) | font[5]; + for (var i = 0; i < numTables; i++) + { + var record = 12 + i * 16; + var tag = Encoding.ASCII.GetString(font, record, 4); + var offset = ReadUInt32(font, record + 8); + var length = ReadUInt32(font, record + 12); + if (offset + length <= (uint)font.Length) + { + var bytes = new byte[length]; + Array.Copy(font, offset, bytes, 0, length); + tables[tag] = bytes; + } + } + return tag => + { + byte[] value; + return tables.TryGetValue(tag, out value) ? value : null; + }; + } + + private static uint ReadUInt32(byte[] data, int offset) + { + return ((uint)data[offset] << 24) | ((uint)data[offset + 1] << 16) | + ((uint)data[offset + 2] << 8) | data[offset + 3]; + } + } +} diff --git a/Src/Common/FwUtils/OpenTypeFeatureCatalog.cs b/Src/Common/FwUtils/OpenTypeFeatureCatalog.cs new file mode 100644 index 0000000000..4f14e0275c --- /dev/null +++ b/Src/Common/FwUtils/OpenTypeFeatureCatalog.cs @@ -0,0 +1,229 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; + +namespace SIL.FieldWorks.Common.FwUtils +{ + /// The default applied state of an OpenType feature in typical shaping. + public enum OpenTypeFeatureDefaultState + { + /// No documented default; treated as off until the user selects a value. + Unspecified, + /// Off unless the user turns it on. + Off, + /// Applied automatically by shaping engines unless the user turns it off. + On + } + + /// Registered-feature classification: friendly name, visibility, and default state. + public sealed class OpenTypeFeatureCatalogEntry + { + /// Initializes a new instance of the class. + public OpenTypeFeatureCatalogEntry(string englishName, bool isHidden, OpenTypeFeatureDefaultState defaultState) + { + EnglishName = englishName; + IsHidden = isHidden; + DefaultState = defaultState; + } + + /// Gets the English friendly name; a resx entry may override it for the UI. + public string EnglishName { get; } + + /// Gets whether the feature is required for shaping or otherwise not user-configurable. + public bool IsHidden { get; } + + /// Gets the feature's default applied state. + public OpenTypeFeatureDefaultState DefaultState { get; } + } + + /// + /// Catalog of registered OpenType features used to classify feature tags as hidden or + /// user-configurable and to supply a default state and friendly name. Seeded from Paratext's + /// RegisteredFeatureCatalog and audited against the OpenType feature registry: `dlig` is + /// user-visible (Paratext hides it), `aalt` is hidden (a glyph palette, not a toggle), and + /// `kern` is default-on. Stylistic sets (`ssXX`) and character variants (`cvXX`) are + /// intentionally absent; their names come from the font and their visibility is handled by the + /// UI. See LT-22638. + /// + public static class OpenTypeFeatureCatalog + { + private static OpenTypeFeatureCatalogEntry Visible(string name, OpenTypeFeatureDefaultState state = OpenTypeFeatureDefaultState.Unspecified) + { + return new OpenTypeFeatureCatalogEntry(name, false, state); + } + + private static OpenTypeFeatureCatalogEntry Hidden(string name) + { + return new OpenTypeFeatureCatalogEntry(name, true, OpenTypeFeatureDefaultState.Unspecified); + } + + private static readonly Dictionary s_entries = + new Dictionary(StringComparer.Ordinal) + { + ["aalt"] = Hidden("Access All Alternates"), + ["abvf"] = Hidden("Above Base Forms"), + ["abvm"] = Hidden("Above Base Mark"), + ["abvs"] = Hidden("Above Base Substitutions"), + ["afrc"] = Visible("Vertical Fractions", OpenTypeFeatureDefaultState.Off), + ["akhn"] = Hidden("Akhand"), + ["alig"] = Visible("Ancient Ligatures"), + ["blwf"] = Hidden("Below Base Forms"), + ["blwm"] = Hidden("Below Base Mark"), + ["blws"] = Hidden("Below Base Substitutions"), + ["c2pc"] = Visible("Capitals to Petite Capitals", OpenTypeFeatureDefaultState.Off), + ["c2sc"] = Visible("Capitals to Small Capitals", OpenTypeFeatureDefaultState.Off), + ["calt"] = Visible("Contextual Alternates", OpenTypeFeatureDefaultState.On), + ["case"] = Visible("Case-Sensitive Forms"), + ["ccmp"] = Hidden("Glyph Composition/Decomposition"), + ["cfar"] = Hidden("Conjunct Form After Ro"), + ["chws"] = Visible("Contextual Half-width Spacing", OpenTypeFeatureDefaultState.On), + ["cjct"] = Hidden("Conjunct Forms"), + ["clig"] = Visible("Contextual Ligatures", OpenTypeFeatureDefaultState.On), + ["cpct"] = Visible("Centered CJK Punctuation", OpenTypeFeatureDefaultState.Off), + ["cpsp"] = Visible("Capital Spacing", OpenTypeFeatureDefaultState.On), + ["cswh"] = Visible("Contextual Swash", OpenTypeFeatureDefaultState.Off), + ["curs"] = Hidden("Cursive Attachment"), + ["dcap"] = Visible("Drop Caps"), + ["dist"] = Hidden("Distance"), + ["dlig"] = Visible("Discretionary Ligatures", OpenTypeFeatureDefaultState.Off), + ["dnom"] = Hidden("Denominators"), + ["dpng"] = Hidden("Dipthongs (Obsolete)"), + ["dtls"] = Hidden("Dotless Forms"), + ["expt"] = Visible("Expert Forms"), + ["falt"] = Visible("Final Glyph On Line"), + ["fin2"] = Hidden("Terminal Forms #2"), + ["fin3"] = Hidden("Terminal Forms #3"), + ["fina"] = Hidden("Terminal Forms"), + ["flac"] = Hidden("Flattened Accents over Capitals"), + ["frac"] = Visible("Diagonal Fractions", OpenTypeFeatureDefaultState.Off), + ["fwid"] = Visible("Full Widths", OpenTypeFeatureDefaultState.Off), + ["half"] = Hidden("Half Forms"), + ["haln"] = Hidden("Halant Forms"), + ["halt"] = Visible("Alternative Half Widths", OpenTypeFeatureDefaultState.On), + ["hist"] = Visible("Historical Forms", OpenTypeFeatureDefaultState.Off), + ["hkna"] = Visible("Horizontal Kana Alternatives", OpenTypeFeatureDefaultState.Off), + ["hlig"] = Visible("Historic Ligatures", OpenTypeFeatureDefaultState.Off), + ["hngl"] = Hidden("Hanja to Hangul"), + ["hojo"] = Visible("Hojo (JIS X 0212-1990) Kanji Forms", OpenTypeFeatureDefaultState.Off), + ["hwid"] = Visible("Half Widths", OpenTypeFeatureDefaultState.Off), + ["init"] = Hidden("Initial Forms"), + ["isol"] = Hidden("Isolated Forms"), + ["ital"] = Visible("Italics"), + ["jalt"] = Visible("Justification Alternatives"), + ["jajp"] = Hidden("Japanese Forms (Obsolete)"), + ["jp04"] = Visible("JIS2004 Forms", OpenTypeFeatureDefaultState.Off), + ["jp78"] = Visible("JIS78 Forms", OpenTypeFeatureDefaultState.Off), + ["jp83"] = Visible("JIS83 Forms", OpenTypeFeatureDefaultState.Off), + ["jp90"] = Visible("JIS90 Forms", OpenTypeFeatureDefaultState.Off), + ["kern"] = Visible("Horizontal Kerning", OpenTypeFeatureDefaultState.On), + ["lfbd"] = Visible("Left Bounds", OpenTypeFeatureDefaultState.Off), + ["liga"] = Visible("Standard Ligatures", OpenTypeFeatureDefaultState.On), + ["ljmo"] = Hidden("Leading Jamo Forms"), + ["lnum"] = Visible("Lining Figures", OpenTypeFeatureDefaultState.Off), + ["locl"] = Hidden("Localized Forms"), + ["ltra"] = Hidden("Left-to-right glyph alternates"), + ["ltrm"] = Hidden("Left-to-right mirrored forms"), + ["mark"] = Hidden("Mark Positioning"), + ["med2"] = Hidden("Medial Forms 2"), + ["medi"] = Hidden("Medial Forms"), + ["mgrk"] = Visible("Mathematical Greek", OpenTypeFeatureDefaultState.Off), + ["mkmk"] = Hidden("Mark to Mark"), + ["mset"] = Visible("Mark Positioning via Substitution"), + ["nalt"] = Visible("Alternate Annotation Forms", OpenTypeFeatureDefaultState.Off), + ["nlck"] = Visible("NLC Kanji Forms", OpenTypeFeatureDefaultState.Off), + ["nukt"] = Hidden("Nukta Forms"), + ["numr"] = Hidden("Numerators"), + ["onum"] = Visible("Oldstyle Figures", OpenTypeFeatureDefaultState.Off), + ["opbd"] = Hidden("Optical Bounds"), + ["ordn"] = Visible("Ordinals", OpenTypeFeatureDefaultState.Off), + ["ornm"] = Visible("Ornaments", OpenTypeFeatureDefaultState.Off), + ["palt"] = Visible("Proportional Alternate Metrics", OpenTypeFeatureDefaultState.Off), + ["pcap"] = Visible("Lowercase to Petite Capitals", OpenTypeFeatureDefaultState.Off), + ["pkna"] = Visible("Proportional Kana", OpenTypeFeatureDefaultState.Off), + ["pnum"] = Visible("Proportional Numbers", OpenTypeFeatureDefaultState.Off), + ["pref"] = Hidden("Pre Base Forms"), + ["pres"] = Hidden("Pre Base Substitutions"), + ["pstf"] = Hidden("Post Base Forms"), + ["psts"] = Hidden("Post Base Substitutions"), + ["pwid"] = Visible("Proportional Width"), + ["qwid"] = Visible("Quarter Widths", OpenTypeFeatureDefaultState.Off), + ["rand"] = Visible("Randomize", OpenTypeFeatureDefaultState.On), + ["rclt"] = Hidden("Required Contextual Alternates"), + ["rkrf"] = Hidden("Rakar Forms"), + ["rlig"] = Hidden("Required Ligatures"), + ["rphf"] = Hidden("Reph Form"), + ["rtbd"] = Visible("Right Bounds", OpenTypeFeatureDefaultState.Off), + ["rtla"] = Hidden("Right to Left Alternates"), + ["rtlm"] = Hidden("Right to Left mirrored forms"), + ["ruby"] = Visible("Ruby Notational Forms", OpenTypeFeatureDefaultState.Off), + ["rvrn"] = Hidden("Required Variation Alternates"), + ["salt"] = Visible("Stylistic Alternatives", OpenTypeFeatureDefaultState.Off), + ["sinf"] = Visible("Scientific Inferiors", OpenTypeFeatureDefaultState.Off), + ["smcp"] = Visible("Lowercase to Small Capitals", OpenTypeFeatureDefaultState.Off), + ["size"] = Visible("Optical size", OpenTypeFeatureDefaultState.On), + ["smpl"] = Visible("Simplified Forms", OpenTypeFeatureDefaultState.Off), + ["ssty"] = Hidden("Math script style alternates"), + ["stch"] = Hidden("Stretching Glyph Decomposition"), + ["subs"] = Visible("Subscript", OpenTypeFeatureDefaultState.Off), + ["sups"] = Visible("Superscript", OpenTypeFeatureDefaultState.Off), + ["swsh"] = Visible("Swash", OpenTypeFeatureDefaultState.Off), + ["titl"] = Visible("Titling", OpenTypeFeatureDefaultState.Off), + ["tjmo"] = Hidden("Trailing Jamo Forms"), + ["tnam"] = Visible("Traditional Name Forms", OpenTypeFeatureDefaultState.Off), + ["tnum"] = Visible("Tabular Numbers", OpenTypeFeatureDefaultState.Off), + ["trad"] = Visible("Traditional Forms", OpenTypeFeatureDefaultState.Off), + ["twid"] = Visible("Third Widths", OpenTypeFeatureDefaultState.Off), + ["unic"] = Visible("Unicase", OpenTypeFeatureDefaultState.Off), + ["valt"] = Visible("Alternate Vertical Metrics"), + ["vatu"] = Hidden("Vattu Variants"), + ["vchw"] = Visible("Vertical Contextual Half-width Spacing"), + ["vert"] = Visible("Vertical Alternates"), + ["vhal"] = Visible("Alternate Vertical Half Metrics", OpenTypeFeatureDefaultState.Off), + ["vjmo"] = Hidden("Vowel Jamo Forms"), + ["vkna"] = Visible("Vertical Kana Alternates", OpenTypeFeatureDefaultState.Off), + ["vkrn"] = Visible("Vertical Kerning"), + ["vpal"] = Visible("Proportional Alternate Vertical Metrics", OpenTypeFeatureDefaultState.Off), + ["vrt2"] = Visible("Vertical Rotation & Alternates"), + ["zero"] = Visible("Slashed Zero", OpenTypeFeatureDefaultState.Off), + }; + + /// Gets the catalog entry for a tag, or null when the tag is not registered here. + public static OpenTypeFeatureCatalogEntry Lookup(string tag) + { + OpenTypeFeatureCatalogEntry entry; + return tag != null && s_entries.TryGetValue(tag, out entry) ? entry : null; + } + + /// Gets whether the tag is a registered feature this catalog knows about. + public static bool IsKnown(string tag) + { + return tag != null && s_entries.ContainsKey(tag); + } + + /// Gets whether the tag is a registered feature classified as hidden from users. + public static bool IsHidden(string tag) + { + var entry = Lookup(tag); + return entry != null && entry.IsHidden; + } + + /// Gets whether the tag is a registered feature that shaping applies by default. + public static bool IsDefaultOn(string tag) + { + var entry = Lookup(tag); + return entry != null && entry.DefaultState == OpenTypeFeatureDefaultState.On; + } + + /// Gets the English friendly name for a registered tag, or null when unknown. + public static string GetEnglishName(string tag) + { + return Lookup(tag)?.EnglishName; + } + + /// Gets every registered tag, for consistency checks and diagnostics. + public static IEnumerable AllTags => s_entries.Keys; + } +} diff --git a/Src/Common/FwUtils/OpenTypeFontFeatureInfoReader.cs b/Src/Common/FwUtils/OpenTypeFontFeatureInfoReader.cs new file mode 100644 index 0000000000..8a833775ee --- /dev/null +++ b/Src/Common/FwUtils/OpenTypeFontFeatureInfoReader.cs @@ -0,0 +1,309 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace SIL.FieldWorks.Common.FwUtils +{ + /// + /// Feature information discovered from a font's GSUB/GPOS feature lists: the four-character + /// tag plus any font-supplied UI label and the named options a character-variant feature + /// exposes. See LT-22638. + /// + public sealed class OpenTypeFontFeatureInfo + { + /// + /// Initializes a new instance of the class. + /// + public OpenTypeFontFeatureInfo(string tag, string fontSuppliedLabel, IReadOnlyList options) + { + Tag = tag ?? throw new ArgumentNullException(nameof(tag)); + FontSuppliedLabel = fontSuppliedLabel; + Options = options ?? Array.Empty(); + } + + /// Gets the four-character OpenType feature tag. + public string Tag { get; } + + /// + /// Gets the human-readable feature label supplied by the font, or null when the font + /// provides none (registered features and stylistic/character sets without featureParams). + /// + public string FontSuppliedLabel { get; } + + /// + /// Gets the ordered named options for a character-variant feature; empty for binary + /// features. Option index i (zero-based) corresponds to feature value i+1. + /// + public IReadOnlyList Options { get; } + } + + /// + /// Reads user-facing OpenType feature information from a font's layout tables. Table bytes are + /// supplied through a delegate so production callers can pass GDI GetFontData results while + /// tests pass bytes sliced from a font file; every read is bounds-checked so malformed or + /// hostile fonts degrade to tag-only records instead of throwing. Parsing is adapted from + /// Paratext's OpenTypeFeatures.Ttf reader (LT-22638). + /// + public static class OpenTypeFontFeatureInfoReader + { + private static readonly string[] s_layoutTables = { "GSUB", "GPOS" }; + private const int MaxNamedParameters = 1024; + + /// + /// Reads feature information for the current font. receives a + /// four-character table tag ("GSUB", "GPOS", "name") and returns that table's bytes, or null + /// when the font has no such table. Records are deduplicated by tag; when a tag appears in + /// more than one script/language the richest record (one carrying a label or options) wins. + /// + public static IReadOnlyList Read(Func tableSource) + { + if (tableSource == null) + throw new ArgumentNullException(nameof(tableSource)); + + var names = ParseNameTable(SafeGet(tableSource, "name")); + var byTag = new Dictionary(StringComparer.Ordinal); + + foreach (var tableTag in s_layoutTables) + { + var table = SafeGet(tableSource, tableTag); + if (table == null) + continue; + foreach (var info in ReadFeatureList(table, names)) + { + OpenTypeFontFeatureInfo existing; + if (!byTag.TryGetValue(info.Tag, out existing) || IsRicher(info, existing)) + byTag[info.Tag] = info; + } + } + + return byTag.Values.OrderBy(info => info.Tag, StringComparer.Ordinal).ToArray(); + } + + private static bool IsRicher(OpenTypeFontFeatureInfo candidate, OpenTypeFontFeatureInfo existing) + { + var candidateScore = (candidate.FontSuppliedLabel != null ? 1 : 0) + candidate.Options.Count; + var existingScore = (existing.FontSuppliedLabel != null ? 1 : 0) + existing.Options.Count; + return candidateScore > existingScore; + } + + private static byte[] SafeGet(Func tableSource, string tag) + { + try + { + return tableSource(tag); + } + catch + { + return null; + } + } + + private static IEnumerable ReadFeatureList(byte[] table, IReadOnlyDictionary names) + { + int featureListOffset; + if (!TryReadUInt16(table, 6, out featureListOffset) || featureListOffset == 0) + yield break; + + int featureCount; + if (!TryReadUInt16(table, featureListOffset, out featureCount)) + yield break; + + for (var i = 0; i < featureCount; i++) + { + var recordOffset = featureListOffset + 2 + i * 6; + string tag; + int featureOffset; + if (!TryReadTag(table, recordOffset, out tag) || + !TryReadUInt16(table, recordOffset + 4, out featureOffset)) + yield break; + + if (!FontFeatureSettings.IsValidOpenTypeTag(tag)) + continue; + + var featureTableStart = featureListOffset + featureOffset; + int featureParamsOffset; + if (!TryReadUInt16(table, featureTableStart, out featureParamsOffset)) + continue; + + string label = null; + var options = Array.Empty(); + if (featureParamsOffset != 0) + { + var paramsStart = featureTableStart + featureParamsOffset; + if (IsCharacterVariantTag(tag)) + ReadCharacterVariantParams(table, paramsStart, names, out label, out options); + else if (IsStylisticSetTag(tag)) + label = ReadStylisticSetName(table, paramsStart, names); + } + + yield return new OpenTypeFontFeatureInfo(tag, label, options); + } + } + + private static void ReadCharacterVariantParams(byte[] table, int paramsStart, + IReadOnlyDictionary names, out string label, out string[] options) + { + label = null; + options = Array.Empty(); + + int labelNameId, numNamedParameters, firstParamNameId; + if (!TryReadUInt16(table, paramsStart + 2, out labelNameId) || + !TryReadUInt16(table, paramsStart + 8, out numNamedParameters) || + !TryReadUInt16(table, paramsStart + 10, out firstParamNameId)) + return; + + label = LookupName(names, labelNameId); + + if (numNamedParameters == 0 || numNamedParameters > MaxNamedParameters) + return; + var resolved = new List(numNamedParameters); + for (var i = 0; i < numNamedParameters; i++) + { + var option = LookupName(names, firstParamNameId + i); + if (!string.IsNullOrEmpty(option)) + resolved.Add(option); + } + options = resolved.ToArray(); + } + + private static string ReadStylisticSetName(byte[] table, int paramsStart, IReadOnlyDictionary names) + { + int uiNameId; + return TryReadUInt16(table, paramsStart + 2, out uiNameId) ? LookupName(names, uiNameId) : null; + } + + private static string LookupName(IReadOnlyDictionary names, int nameId) + { + string value; + return nameId > 0 && names.TryGetValue(nameId, out value) && !string.IsNullOrEmpty(value) ? value : null; + } + + private static IReadOnlyDictionary ParseNameTable(byte[] table) + { + var result = new Dictionary(); + if (table == null) + return result; + + int count, storageOffset; + if (!TryReadUInt16(table, 2, out count) || !TryReadUInt16(table, 4, out storageOffset)) + return result; + + var best = new Dictionary(); + for (var i = 0; i < count; i++) + { + var recordOffset = 6 + i * 12; + int platformId, languageId, nameId, length, stringOffset; + if (!TryReadUInt16(table, recordOffset, out platformId) || + !TryReadUInt16(table, recordOffset + 4, out languageId) || + !TryReadUInt16(table, recordOffset + 6, out nameId) || + !TryReadUInt16(table, recordOffset + 8, out length) || + !TryReadUInt16(table, recordOffset + 10, out stringOffset)) + break; + + var value = DecodeName(table, platformId, storageOffset + stringOffset, length); + if (string.IsNullOrEmpty(value)) + continue; + + var rank = GetNameRank(platformId, languageId); + int existingRank; + if (!best.TryGetValue(nameId, out existingRank) || rank > existingRank) + { + best[nameId] = rank; + result[nameId] = value; + } + } + + return result; + } + + private static string DecodeName(byte[] table, int platformId, int stringStart, int length) + { + if (length == 0 || stringStart < 0 || stringStart > table.Length - length) + return null; + try + { + // Platform 0 (Unicode) and 3 (Windows) store UTF-16 big-endian; platform 1 (Mac) + // stores Mac Roman. Other platforms are not decodable here and fall back to labels. + if (platformId == 0 || platformId == 3) + return Encoding.BigEndianUnicode.GetString(table, stringStart, length).TrimEnd('\0'); + if (platformId == 1) + return MacRoman.GetString(table, stringStart, length).TrimEnd('\0'); + } + catch + { + // Fall through to null so the feature uses its fallback label. + } + return null; + } + + // Prefers a Windows US-English record, then any Windows-English, then Unicode, then any + // Windows record, then Mac English, then anything decodable. + private static int GetNameRank(int platformId, int languageId) + { + if (platformId == 3 && languageId == 0x0409) + return 500; + if (platformId == 3 && (languageId & 0x03FF) == 0x0009) + return 450; + if (platformId == 0) + return 400; + if (platformId == 3) + return 300; + if (platformId == 1 && languageId == 0) + return 200; + return 100; + } + + private static bool IsCharacterVariantTag(string tag) + { + return tag.Length == 4 && tag[0] == 'c' && tag[1] == 'v' && + tag[2] >= '0' && tag[2] <= '9' && tag[3] >= '0' && tag[3] <= '9'; + } + + private static bool IsStylisticSetTag(string tag) + { + return tag.Length == 4 && tag[0] == 's' && tag[1] == 's' && + tag[2] >= '0' && tag[2] <= '9' && tag[3] >= '0' && tag[3] <= '9'; + } + + private static bool TryReadUInt16(byte[] data, int offset, out int value) + { + if (data == null || offset < 0 || offset > data.Length - 2) + { + value = 0; + return false; + } + value = (data[offset] << 8) | data[offset + 1]; + return true; + } + + private static bool TryReadTag(byte[] data, int offset, out string tag) + { + if (data == null || offset < 0 || offset > data.Length - 4) + { + tag = null; + return false; + } + tag = Encoding.ASCII.GetString(data, offset, 4); + return true; + } + + private static readonly Encoding MacRoman = CreateMacRoman(); + + private static Encoding CreateMacRoman() + { + try + { + return Encoding.GetEncoding(10000); + } + catch + { + return Encoding.ASCII; + } + } + } +} diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/FontFeaturesButton.cs b/Src/FwCoreDlgs/FwCoreDlgControls/FontFeaturesButton.cs index 2d50b5c0a2..f9d347774a 100644 --- a/Src/FwCoreDlgs/FwCoreDlgControls/FontFeaturesButton.cs +++ b/Src/FwCoreDlgs/FwCoreDlgControls/FontFeaturesButton.cs @@ -865,7 +865,7 @@ private void ItemClickHandler(Object sender, EventArgs e) OnFontFeatureSelected(new EventArgs()); } - private interface IFontFeatureProvider + internal interface IFontFeatureProvider { string ProviderName { get; } bool HasFeatures { get; } @@ -943,44 +943,37 @@ public string GetFeatureValueLabel(int featureId, int valueId, int languageId) } } - private sealed class OpenTypeFontFeatureProvider : IFontFeatureProvider + internal sealed class OpenTypeFontFeatureProvider : IFontFeatureProvider { - private static readonly Dictionary s_featureLabelResourceIds = new Dictionary - { - { "aalt", "kstidOpenTypeFeature_aalt" }, - { "c2sc", "kstidOpenTypeFeature_c2sc" }, - { "calt", "kstidOpenTypeFeature_calt" }, - { "case", "kstidOpenTypeFeature_case" }, - { "ccmp", "kstidOpenTypeFeature_ccmp" }, - { "clig", "kstidOpenTypeFeature_clig" }, - { "dlig", "kstidOpenTypeFeature_dlig" }, - { "frac", "kstidOpenTypeFeature_frac" }, - { "kern", "kstidOpenTypeFeature_kern" }, - { "liga", "kstidOpenTypeFeature_liga" }, - { "lnum", "kstidOpenTypeFeature_lnum" }, - { "onum", "kstidOpenTypeFeature_onum" }, - { "pnum", "kstidOpenTypeFeature_pnum" }, - { "salt", "kstidOpenTypeFeature_salt" }, - { "smcp", "kstidOpenTypeFeature_smcp" }, - { "ss01", "kstidOpenTypeFeature_ss01" }, - { "ss02", "kstidOpenTypeFeature_ss02" }, - { "ss03", "kstidOpenTypeFeature_ss03" }, - { "ss04", "kstidOpenTypeFeature_ss04" }, - { "ss05", "kstidOpenTypeFeature_ss05" }, - { "tnum", "kstidOpenTypeFeature_tnum" }, - }; - private readonly int[] m_featureIds; + private readonly Dictionary m_infoById; - private OpenTypeFontFeatureProvider(IEnumerable tags) + private OpenTypeFontFeatureProvider(IEnumerable infos) { - m_featureIds = tags.Select(ConvertFontFeatureCodeToId).Distinct().OrderBy(featureId => GetFeatureTag(featureId), StringComparer.Ordinal).ToArray(); + m_infoById = new Dictionary(); + foreach (var info in infos) + { + // Hidden features (shaping-required or otherwise not user-configurable) are omitted. + if (OpenTypeFeatureCatalog.IsHidden(info.Tag)) + continue; + var id = ConvertFontFeatureCodeToId(info.Tag); + if (!m_infoById.ContainsKey(id)) + m_infoById[id] = info; + } + m_featureIds = m_infoById.Keys + .OrderBy(featureId => GetFeatureTag(featureId), StringComparer.Ordinal).ToArray(); } public static OpenTypeFontFeatureProvider Create(IntPtr hdc) { - var tags = OpenTypeFontFeatureReader.GetFeatureTags(hdc); - return tags.Count == 0 ? null : new OpenTypeFontFeatureProvider(tags); + var infos = OpenTypeFontFeatureReader.GetFeatureInfos(hdc); + return infos.Count == 0 ? null : new OpenTypeFontFeatureProvider(infos); + } + + /// Builds a provider directly from feature records for unit testing. + internal static OpenTypeFontFeatureProvider CreateForTests(IEnumerable infos) + { + return new OpenTypeFontFeatureProvider(infos); } public bool HasFeatures @@ -1006,28 +999,93 @@ public string GetFeatureTag(int featureId) public string GetFeatureLabel(int featureId, int languageId) { var tag = GetFeatureTag(featureId); - string resourceId; - if (s_featureLabelResourceIds.TryGetValue(tag, out resourceId)) - { - var label = FwCoreDlgControls.ResourceManager.GetString(resourceId, CultureInfo.CurrentUICulture); - if (!string.IsNullOrEmpty(label)) - return label; - } - return tag; + OpenTypeFontFeatureInfo info; + m_infoById.TryGetValue(featureId, out info); + + // Label priority: the font's own name, a localized resx name, the catalog's English + // name, a generated "Stylistic Set N" / "Character Variant N" fallback, or empty + // when the tag has no known name. + if (info != null && !string.IsNullOrEmpty(info.FontSuppliedLabel)) + return info.FontSuppliedLabel; + var resxName = ResourceString("kstidOpenTypeFeature_" + tag); + if (!string.IsNullOrEmpty(resxName)) + return resxName; + var catalogName = OpenTypeFeatureCatalog.GetEnglishName(tag); + if (!string.IsNullOrEmpty(catalogName)) + return catalogName; + return NumberedFallbackLabel(tag) ?? string.Empty; } public int[] GetFeatureValues(int featureId, int maxValues, out int valueCount, out int defaultValue) { - defaultValue = 0; + var tag = GetFeatureTag(featureId); + OpenTypeFontFeatureInfo info; + m_infoById.TryGetValue(featureId, out info); + + if (info != null && info.Options.Count > 0) + { + // value 0 = "None", value i = the i-th named character-variant option. + var optionCount = Math.Min(info.Options.Count, Math.Max(0, maxValues - 1)); + var values = new int[optionCount + 1]; + for (var i = 0; i <= optionCount; i++) + values[i] = i; + valueCount = values.Length; + defaultValue = 0; + return values; + } + valueCount = 2; + defaultValue = OpenTypeFeatureCatalog.IsDefaultOn(tag) ? 1 : 0; return new[] { 0, 1 }; } public string GetFeatureValueLabel(int featureId, int valueId, int languageId) { - return valueId == 0 - ? FwCoreDlgControls.ResourceManager.GetString("kstidOpenTypeFeatureValueOff", CultureInfo.CurrentUICulture) - : FwCoreDlgControls.ResourceManager.GetString("kstidOpenTypeFeatureValueOn", CultureInfo.CurrentUICulture); + OpenTypeFontFeatureInfo info; + m_infoById.TryGetValue(featureId, out info); + if (info != null && info.Options.Count > 0) + { + if (valueId == 0) + return ResourceString("kstidOpenTypeFeatureValueNone") ?? "None"; + var index = valueId - 1; + return index >= 0 && index < info.Options.Count ? info.Options[index] : string.Empty; + } + // Binary features report invariant "Off"/"On". These values only classify the + // feature and are never displayed, so they must not be localized. + return valueId == 0 ? "Off" : "On"; + } + + private static string NumberedFallbackLabel(string tag) + { + int number; + if (TryGetSetNumber(tag, 's', 's', out number)) + return FormatResource("kstidOpenTypeFeatureStylisticSet", number); + if (TryGetSetNumber(tag, 'c', 'v', out number)) + return FormatResource("kstidOpenTypeFeatureCharacterVariant", number); + return null; + } + + private static bool TryGetSetNumber(string tag, char c0, char c1, out int number) + { + number = 0; + if (tag == null || tag.Length != 4 || tag[0] != c0 || tag[1] != c1 || + tag[2] < '0' || tag[2] > '9' || tag[3] < '0' || tag[3] > '9') + return false; + number = (tag[2] - '0') * 10 + (tag[3] - '0'); + return true; + } + + private static string FormatResource(string key, int number) + { + var format = ResourceString(key); + return string.IsNullOrEmpty(format) + ? null + : string.Format(CultureInfo.CurrentUICulture, format, number); + } + + private static string ResourceString(string key) + { + return FwCoreDlgControls.ResourceManager.GetString(key, CultureInfo.CurrentUICulture); } } @@ -1051,18 +1109,9 @@ internal static class OpenTypeFontFeatureReader private const uint GdiError = 0xFFFFFFFF; private const int MaxCacheEntries = 32; private const int ObjFont = 6; - private static readonly HashSet s_nonUserConfigurableTags = - new HashSet(StringComparer.Ordinal) - { - "abvf", "abvm", "abvs", "akhn", "blwf", "blwm", "blws", "ccmp", - "cjct", "curs", "dist", "fina", "haln", "half", "init", "isol", - "ljmo", "locl", "mark", "medi", "mkmk", "nukt", "pref", "pres", - "pstf", "psts", "rclt", "rkrf", "rlig", "tjmo", "vjmo" - }; - private static readonly uint[] s_layoutTables = { MakeTableTag("GSUB"), MakeTableTag("GPOS") }; private static readonly object s_cacheLock = new object(); - private static readonly Dictionary s_featureTagCache = - new Dictionary(); + private static readonly Dictionary s_featureCache = + new Dictionary(); private static readonly Queue s_cacheOrder = new Queue(); private static Func s_tableReader = ReadTable; @@ -1075,35 +1124,29 @@ internal static class OpenTypeFontFeatureReader [DllImport("gdi32.dll", CharSet = CharSet.Unicode)] private static extern int GetObject(IntPtr hObject, int size, ref LogFont logFont); - public static IReadOnlyList GetFeatureTags(IntPtr hdc) + public static IReadOnlyList GetFeatureInfos(IntPtr hdc) { var cacheKey = FontFeatureCacheKey.FromHdc(hdc); lock (s_cacheLock) { - string[] cachedTags; - if (s_featureTagCache.TryGetValue(cacheKey, out cachedTags)) - return cachedTags.ToArray(); + OpenTypeFontFeatureInfo[] cached; + if (s_featureCache.TryGetValue(cacheKey, out cached)) + return cached; } - var tags = new SortedSet(StringComparer.Ordinal); - foreach (var table in s_layoutTables) - { - var tableData = s_tableReader(hdc, table); - if (tableData != null) - ReadFeatureList(tableData, tags); - } - var discoveredTags = tags.ToArray(); + var discovered = OpenTypeFontFeatureInfoReader + .Read(tag => s_tableReader(hdc, MakeTableTag(tag))).ToArray(); lock (s_cacheLock) { - if (!s_featureTagCache.ContainsKey(cacheKey)) + if (!s_featureCache.ContainsKey(cacheKey)) { if (s_cacheOrder.Count >= MaxCacheEntries) - s_featureTagCache.Remove(s_cacheOrder.Dequeue()); - s_featureTagCache[cacheKey] = discoveredTags; + s_featureCache.Remove(s_cacheOrder.Dequeue()); + s_featureCache[cacheKey] = discovered; s_cacheOrder.Enqueue(cacheKey); } } - return discoveredTags.ToArray(); + return discovered; } internal static void ClearCacheForTests() @@ -1134,7 +1177,7 @@ internal static IDisposable UseTableReaderForTests(Func ta private static void ClearCache() { - s_featureTagCache.Clear(); + s_featureCache.Clear(); s_cacheOrder.Clear(); } @@ -1149,47 +1192,6 @@ private static byte[] ReadTable(IntPtr hdc, uint table) return bytesRead == GdiError ? null : data; } - private static void ReadFeatureList(byte[] tableData, ISet tags) - { - if (tableData.Length < 8) - return; - - var featureListOffset = ReadUInt16(tableData, 6); - if (featureListOffset <= 0 || featureListOffset + 2 > tableData.Length) - return; - - var featureCount = ReadUInt16(tableData, featureListOffset); - var featureRecordOffset = featureListOffset + 2; - for (var featureIndex = 0; featureIndex < featureCount; featureIndex++) - { - var recordOffset = featureRecordOffset + featureIndex * 6; - if (recordOffset + 6 > tableData.Length) - return; - - var tag = System.Text.Encoding.ASCII.GetString(tableData, recordOffset, 4); - if (FontFeatureSettings.IsValidOpenTypeTag(tag) && IsUserConfigurableTag(tag)) - tags.Add(tag); - } - } - - private static bool IsUserConfigurableTag(string tag) - { - if (!s_nonUserConfigurableTags.Contains(tag)) - return true; - - Trace.WriteLineIf(s_openTypeTraceSwitch.TraceInfo, - string.Format(CultureInfo.InvariantCulture, - "FontFeaturesButton filtered non-user OpenType feature '{0}'.", - tag), - s_openTypeTraceSwitch.DisplayName); - return false; - } - - private static ushort ReadUInt16(byte[] data, int offset) - { - return (ushort)((data[offset] << 8) | data[offset + 1]); - } - private static uint MakeTableTag(string tag) { return (uint)(tag[0] | tag[1] << 8 | tag[2] << 16 | tag[3] << 24); diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControls.resx b/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControls.resx index 1b3c3d900e..5bf32cb921 100644 --- a/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControls.resx +++ b/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControls.resx @@ -170,14 +170,17 @@ Value #{0} - - Off + + None + Shown as the first (default) choice for a multi-valued character-variant feature. - - On + + Stylistic Set {0} + Fallback label for a stylistic set the font does not name. {0} is the set number. - - Access All Alternates + + Character Variant {0} + Fallback label for a character variant the font does not name. {0} is the variant number. Small Capitals From Capitals @@ -188,9 +191,6 @@ Case-Sensitive Forms - - Glyph Composition/Decomposition - Contextual Ligatures @@ -221,21 +221,6 @@ Small Capitals - - Stylistic Set 1 - - - Stylistic Set 2 - - - Stylistic Set 3 - - - Stylistic Set 4 - - - Stylistic Set 5 - Tabular Figures diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/TestFontFeaturesButton.cs b/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/TestFontFeaturesButton.cs index 31992d625b..8b37b83936 100644 --- a/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/TestFontFeaturesButton.cs +++ b/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/TestFontFeaturesButton.cs @@ -3,9 +3,14 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; using System.Linq; +using SIL.FieldWorks.Common.FwUtils; using SIL.FieldWorks.FwCoreDlgControls; using NUnit.Framework; +using DlgControlsResources = SIL.FieldWorks.FwCoreDlgControls.FwCoreDlgControls; namespace SIL.FieldWorks.FwCoreDlgControlsTests { @@ -117,7 +122,7 @@ public void ConvertRendererNeutralFeatureStringToIds_UsesOpenTypeTagsDirectly() } [Test] - public void OpenTypeFontFeatureReader_CachesFeatureTagsForSameFontKey() + public void OpenTypeFontFeatureReader_CachesFeatureInfosForSameFontKey() { var readCount = 0; var tableData = MakeOpenTypeLayoutTable("kern"); @@ -129,29 +134,154 @@ public void OpenTypeFontFeatureReader_CachesFeatureTagsForSameFontKey() return tableData; })) { - var firstRead = FontFeaturesButton.OpenTypeFontFeatureReader.GetFeatureTags(IntPtr.Zero); - var secondRead = FontFeaturesButton.OpenTypeFontFeatureReader.GetFeatureTags(IntPtr.Zero); + var firstRead = FontFeaturesButton.OpenTypeFontFeatureReader.GetFeatureInfos(IntPtr.Zero); + var secondRead = FontFeaturesButton.OpenTypeFontFeatureReader.GetFeatureInfos(IntPtr.Zero); - Assert.That(firstRead, Is.EqualTo(new[] { "kern" })); - Assert.That(secondRead, Is.EqualTo(new[] { "kern" })); - Assert.That(readCount, Is.EqualTo(2)); + Assert.That(firstRead.Select(f => f.Tag), Is.EqualTo(new[] { "kern" })); + Assert.That(secondRead.Select(f => f.Tag), Is.EqualTo(new[] { "kern" })); + // One read each for the name, GSUB and GPOS tables on the first call; the second is cached. + Assert.That(readCount, Is.EqualTo(3)); } } [Test] - public void OpenTypeFontFeatureReader_FiltersRequiredShapingFeatures() + public void OpenTypeFontFeatureReader_DiscoversAllTagsAndProviderFiltersShaping() { + // The reader reports every declared feature; hidden-feature filtering is the provider's job. var tableData = MakeOpenTypeLayoutTable("ccmp", "liga", "rlig"); FontFeaturesButton.OpenTypeFontFeatureReader.ClearCacheForTests(); using (FontFeaturesButton.OpenTypeFontFeatureReader.UseTableReaderForTests((hdc, table) => tableData)) { - var tags = FontFeaturesButton.OpenTypeFontFeatureReader.GetFeatureTags(IntPtr.Zero); + var infos = FontFeaturesButton.OpenTypeFontFeatureReader.GetFeatureInfos(IntPtr.Zero); + Assert.That(infos.Select(f => f.Tag), Is.EquivalentTo(new[] { "ccmp", "liga", "rlig" })); - Assert.That(tags, Is.EqualTo(new[] { "liga" })); + var provider = FontFeaturesButton.OpenTypeFontFeatureProvider.CreateForTests(infos); + Assert.That(VisibleTags(provider), Is.EqualTo(new[] { "liga" })); } } + [Test] + public void Provider_HidesCatalogHiddenFeatures() + { + var provider = FontFeaturesButton.OpenTypeFontFeatureProvider.CreateForTests( + new[] { Info("mark"), Info("smcp"), Info("mkmk"), Info("dlig") }); + + Assert.That(VisibleTags(provider), Is.EqualTo(new[] { "dlig", "smcp" })); + } + + [Test] + public void Provider_CharacterVariant_ExposesNoneAndNamedOptions() + { + var provider = FontFeaturesButton.OpenTypeFontFeatureProvider.CreateForTests( + new[] { Info("cv43", "Capital Eng", "Lowercase no descender", "Capital form", "Lowercase short stem") }); + var id = FeatureId("cv43"); + + int valueCount, defaultValue; + var values = provider.GetFeatureValues(id, FontFeaturesButton.kMaxValPerFeat, out valueCount, out defaultValue); + + Assert.That(values, Is.EqualTo(new[] { 0, 1, 2, 3 })); + Assert.That(valueCount, Is.EqualTo(4)); + Assert.That(defaultValue, Is.EqualTo(0)); + Assert.That(provider.GetFeatureLabel(id, UiLang), Is.EqualTo("Capital Eng")); + Assert.That(provider.GetFeatureValueLabel(id, 0, UiLang), Is.EqualTo("None")); + Assert.That(provider.GetFeatureValueLabel(id, 1, UiLang), Is.EqualTo("Lowercase no descender")); + Assert.That(provider.GetFeatureValueLabel(id, 3, UiLang), Is.EqualTo("Lowercase short stem")); + } + + [Test] + public void Provider_UnnamedCharacterVariant_FallsBackToBinary() + { + var provider = FontFeaturesButton.OpenTypeFontFeatureProvider.CreateForTests(new[] { Info("cv99") }); + var id = FeatureId("cv99"); + + int valueCount, defaultValue; + var values = provider.GetFeatureValues(id, FontFeaturesButton.kMaxValPerFeat, out valueCount, out defaultValue); + + Assert.That(values, Is.EqualTo(new[] { 0, 1 })); + Assert.That(provider.GetFeatureValueLabel(id, 0, UiLang), Is.EqualTo("Off")); + Assert.That(provider.GetFeatureValueLabel(id, 1, UiLang), Is.EqualTo("On")); + } + + [Test] + public void Provider_DefaultOnFeature_InitializesEnabled() + { + var provider = FontFeaturesButton.OpenTypeFontFeatureProvider.CreateForTests(new[] { Info("liga") }); + + int valueCount, defaultValue; + provider.GetFeatureValues(FeatureId("liga"), FontFeaturesButton.kMaxValPerFeat, out valueCount, out defaultValue); + + Assert.That(defaultValue, Is.EqualTo(1)); + } + + [Test] + public void Provider_DefaultOffFeature_InitializesDisabled() + { + var provider = FontFeaturesButton.OpenTypeFontFeatureProvider.CreateForTests(new[] { Info("smcp") }); + + int valueCount, defaultValue; + provider.GetFeatureValues(FeatureId("smcp"), FontFeaturesButton.kMaxValPerFeat, out valueCount, out defaultValue); + + Assert.That(defaultValue, Is.EqualTo(0)); + } + + [Test] + public void Provider_Label_PrefersFontThenCatalogThenNumberedFallback() + { + var provider = FontFeaturesButton.OpenTypeFontFeatureProvider.CreateForTests( + new[] { Info("swsh"), Info("ss07"), Info("wxyz") }); + + // swsh: no font label, no resx entry -> catalog English name. + Assert.That(provider.GetFeatureLabel(FeatureId("swsh"), UiLang), Is.EqualTo("Swash")); + // ss07: not named by the font and not in the resx subset -> numbered fallback. + Assert.That(provider.GetFeatureLabel(FeatureId("ss07"), UiLang), Is.EqualTo("Stylistic Set 7")); + // wxyz: unknown vendor tag -> empty so OnClick applies its generic "Feature #" fallback. + Assert.That(provider.GetFeatureLabel(FeatureId("wxyz"), UiLang), Is.Empty); + } + + [Test] + public void CharacterVariantValue_RoundTripsThroughFeatureString() + { + var ids = new[] { FeatureId("cv43") }; + + // Selecting the second option persists cv43=2 in renderer-neutral form ... + Assert.That(FontFeaturesButton.GenerateFeatureString(ids, new[] { 2 }), Is.EqualTo("cv43=2")); + // ... and reloading it (tag string -> id form -> parsed values) restores the value. + var idForm = FontFeaturesButton.ConvertRendererNeutralFeatureStringToIds("cv43=2"); + Assert.That(FontFeaturesButton.ParseFeatureString(ids, idForm), Is.EqualTo(new[] { 2 })); + } + + [Test] + public void ResxOpenTypeFeatureLabels_MapToVisibleFeatures() + { + const string prefix = "kstidOpenTypeFeature_"; + var resources = DlgControlsResources.ResourceManager.GetResourceSet(CultureInfo.InvariantCulture, true, true); + foreach (DictionaryEntry entry in resources) + { + var key = (string)entry.Key; + if (!key.StartsWith(prefix, StringComparison.Ordinal)) + continue; + var tag = key.Substring(prefix.Length); + var isNamedSet = tag.Length == 4 && (tag.StartsWith("ss") || tag.StartsWith("cv")) && + char.IsDigit(tag[2]) && char.IsDigit(tag[3]); + var isVisibleRegistered = OpenTypeFeatureCatalog.IsKnown(tag) && !OpenTypeFeatureCatalog.IsHidden(tag); + Assert.That(isNamedSet || isVisibleRegistered, Is.True, + $"resx label '{key}' does not map to a visible OpenType feature"); + } + } + + private static readonly int UiLang = FontFeaturesButton.kUiCodePage; + + private static OpenTypeFontFeatureInfo Info(string tag, string label = null, params string[] options) + { + return new OpenTypeFontFeatureInfo(tag, label, options ?? Array.Empty()); + } + + private static string[] VisibleTags(FontFeaturesButton.OpenTypeFontFeatureProvider provider) + { + return provider.GetFeatureIds().Select(provider.GetFeatureTag).ToArray(); + } + private static int FeatureId(string tag) { var reversedTagBytes = tag.Reverse().Select(Convert.ToByte).ToArray(); diff --git a/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/.openspec.yaml b/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/.openspec.yaml new file mode 100644 index 0000000000..9e5b8a1905 --- /dev/null +++ b/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-23 diff --git a/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/design.md b/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/design.md new file mode 100644 index 0000000000..b81c5c9a0f --- /dev/null +++ b/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/design.md @@ -0,0 +1,62 @@ +# Design: improve-opentype-feature-ui + +Jira: [LT-22638](https://jira.sil.org/browse/LT-22638). Decisions below were resolved in a design review with Jason Naylor (2026-07-23) against the guidance at writingsystems.info (opentype, feature-ui, and user-interface-strings topics) and Paratext 9.6's implementation (`OpenTypeFeatures.Ttf` in the Paratext repo). + +## Context + +`FontFeaturesButton` (Src/FwCoreDlgs/FwCoreDlgControls) drives the Font Options menu used by both Writing System Properties (`DefaultFontsControl`) and the Styles Font tab (`FwFontTab`). Since LT-22324 it discovers OpenType feature tags via GDI `GetFontData` over GSUB/GPOS, but: + +- It reads only the four-character tags from the FeatureList, never the `featureParams` offset — so font-supplied names for ssXX/cvXX (the only place those names exist, per the OpenType spec) are never seen. +- `OpenTypeFontFeatureProvider.GetFeatureValues` hardcodes `{0,1}` with default 0, so every feature renders as an On/Off checkbox; cvXX multi-option variants are unreachable. +- Labels come from a 21-entry hardcoded resx map (ss01–ss05 only, no cvXX); everything else shows a bare tag. +- Filtering uses a hardcoded 33-tag blocklist; default-on shaping features (`liga`, `calt`, `kern`, `clig`) display unchecked when unset, which misrepresents actual rendering. + +Everything below the UI already supports the fix: `FontFeatureSettings` stores any non-negative `tag=value`, the C++ Uniscribe path (`UniscribeSegment.cpp: TryParseFontFeatureRecords`) passes arbitrary values into `OPENTYPE_FEATURE_RECORD.lParameter`, CSS export emits numeric values, and the menu machinery in `OnClick` already renders multi-value radio submenus (that is how Graphite features work today). Only the discovery/provider layer lies. + +Verified ground truth (CharisSIL-Regular 6.200, the font the build already downloads via `PackageRestore.targets`): 34 cvXX features all carrying featureParams — e.g. cv43 "Capital Eng" with 3 named options, cv25 "Lowercase rams horn" with 2, cv13 "Capital B hook" with 1 — plus 5 named ssXX (ss01 "Single-story a and g") and GPOS-only `mark`/`mkmk`. + +## Goals / Non-Goals + +**Goals:** + +- Feature names come from the font (`featureParams` → name table) for ssXX/cvXX, from a catalog/resx for registered tags; bare tags appear only inside fallback strings. +- cvXX features with named options render as multi-value radio submenus (None + option names), storing `cv43=2` etc. +- Registered-feature catalog supplies `Hidden` (replaces the blocklist) and `DefaultOn`/`DefaultOff` (honest initial check state). +- Strict parity with the existing Graphite menu experience; both host dialogs inherit the change with zero modifications. +- Reader logic is a standalone, unit-testable FwUtils class, exercised against the real Charis font and synthetic malformed fixtures. + +**Non-Goals:** + +- New dialog, grouping, tooltips, sample glyphs (candidate follow-up ticket; requires cmap/lookup parsing deliberately excluded here). +- Localization of names (name-record ranking prefers English; the ranking function stays isolated so a UI-language parameter can be added later). +- Tri-state unset/on/off UI or canonical stripping of explicit values (policy: toggles write explicit values; unset features are never written — the existing `Int32.MaxValue` convention). +- Graphite discovery/label/precedence changes; Word DOCX export changes; storage or LDML format changes. + +## Decisions + +1. **Discovery architecture: port Paratext's parsing logic onto our table acquisition** (rejected: extending the tag-only parser in place; adopting Paratext's file-based reader wholesale). A new reader in `Src/Common/FwUtils` (beside `FontFeatureSettings.cs`) takes a table-source delegate `Func` and returns typed records `{ tag, fontLabel, options[], isFromFont }`. Production feeds it `GetFontData` on the button's HDC — the only acquisition guaranteed to describe the face GDI actually renders (substitution, styles, CFF fonts, TTC members). Tests feed it bytes sliced from a font file. Paratext's file-based reader was rejected because FieldWorks has no font-file locator, file-vs-GDI face selection can disagree, it reads GSUB only (loses `kern` and other GPOS features), and it rejects CFF (`OTTO`) fonts. +2. **Read `featUiLabelNameId` (featureParams offset +2) for cv feature labels.** Paratext skips this field and substitutes catalog descriptions; the Charis scan proved it carries the good names ("Capital Eng"). ssXX uses `UINameID` (offset +2 in the stylistic-set params). Name-record selection ports Paratext's ranked scheme (Windows-English first, then any Unicode/Windows record, then Mac Roman). +3. **Catalog split: flags in FwUtils, names in resx** (rejected: everything in the UI assembly; everything in FwUtils). A static catalog table (tag → `Hidden`/`DefaultOn`/`DefaultOff`) lives in FwUtils where the reader/provider layers can use it; human names extend the existing `kstidOpenTypeFeature_` entries in `FwCoreDlgControls.resx` so Crowdin localization keeps working and the 21 existing (possibly translated) entries are preserved. Seeded from Paratext's ~120-entry `RegisteredFeatureCatalog` but audited against the OpenType registry: `dlig` stays visible (Paratext wrongly hides it; hiding it would regress our current UI), `aalt` becomes hidden (glyph-palette feature, meaningless as a toggle), and the dead `ccmp` resx label is removed. Every tag in the old 33-tag blocklist must be `Hidden` in the catalog. +4. **Hidden-filtering moves from reader to provider** (rejected: keep filtering at read time). The reader reports every feature the font declares; the UI layer decides visibility. This keeps the model truthful and lets a future "show advanced features" UI reuse the same reader. The reader keeps the existing LOGFONT-keyed 32-entry cache, now storing the richer records. +5. **Value model.** cvXX with N named options → values `{0..N}`, 0 labeled "None" (resx), value *i* = option *i* (OpenType cvXX semantics: value is a 1-based alternate index). cvXX without featureParams → binary On/Off fallback (enumerating alternates would require lookup parsing — tier-b territory; fonts with multiple alternates ship names in practice). ssXX and registered tags → binary. Default value comes from catalog `DefaultOn`, flowing through the provider's existing `defaultValue` out-param, which the menu already honors — c1 costs zero menu changes; unchecking a default-on `liga` writes `liga=0`, which genuinely disables it in Uniscribe/CSS/Word semantics. +6. **Labels are name-only, FLEx-Graphite style** (rejected: Paratext's "Name (tag)" suffix — reversed by Jason during review). Tags surface only in fallbacks: existing "Feature ''" format for unknown/vendor tags (which stay visible — hiding them would regress capability), plus new "Stylistic Set N" / "Character Variant N" resx fallbacks for ss/cv features without font strings. The provider composes final labels itself and never returns empty. +7. **UI form: keep the existing dropdown menu** (rejected: Paratext-style dialog with checkbox+combo rows — deferred to a follow-up ticket as the "tier b" presentation upgrade). The menu machinery is provider-agnostic and already renders everything required; a dialog adds Designer/test surface without serving LT-22638's acceptance criteria. +8. **Tests use the real downloaded Charis 6.200 plus synthetic fixtures** (rejected: checking in a purpose-built fixture font). Exact-value assertions (cv43 = "Capital Eng" + 3 options in order; cv25 = 2; ss01 name; `liga` present with no params; `mark`/`mkmk` from GPOS) guard against plausible-but-wrong parsing — the failure mode binary parsers invite. Synthetic truncated/corrupt table fixtures assert silent degradation, never exceptions, since this code runs against arbitrary user-installed fonts. + +## Risks / Trade-offs + +- [Malformed or hostile font tables crash discovery] → Every read is bounds-checked; parse failures degrade to tag-only records; synthetic-fixture tests cover truncation at each structural boundary. Existing behavior (tag-only) is the floor. +- [Catalog default flags disagree with a shaper's actual per-script defaults] → Flags affect only the initial check state, never what is written for untouched features; worst case matches today's behavior for that feature. No auto-stripping means no data is rewritten based on flag data. +- [Fonts with unusual name-table encodings show garbled names] → Ranked name selection restricted to decodable platform/encoding pairs (Windows/Unicode, Mac Roman); undecodable records are skipped and fallback labels apply. +- [Binary fallback for unnamed multi-alternate cvXX hides alternates beyond the first] → Accepted; rare in practice (SIL fonts ship names), upgradeable later via lookup parsing without storage changes. +- [FwUtilsTests gains a dependency on the font download step] → Precedent exists (native tests, RenderComparison); restore already provisions the font; synthetic fixtures keep parser logic testable even without it. +- [liblcm or LDML round-trip could normalize/clamp multi-values like `cv43=2`] → Open question below; verified during implementation before the UI writes such values. + +## Migration Plan + +No data migration. Existing stored strings (`smcp=1`, legacy numeric Graphite strings) parse unchanged; new multi-values are additive and already accepted by `FontFeatureSettings.Parse`, rendering, and CSS export. Rollback = revert the PR; stored `cvXX=N` values would simply display via the old binary UI without data loss. + +## Open Questions + +- Verify liblcm round-trips `DefaultFontFeatures` / LDML `` without clamping values > 1 (expected: opaque string; confirm before enabling multi-value writes). +- Confirm with a second SIL font (e.g. Scheherazade New, already downloaded) that the reader behaves on an Arabic-script feature set; not a gate, but cheap extra coverage. diff --git a/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/proposal.md b/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/proposal.md new file mode 100644 index 0000000000..222b263b13 --- /dev/null +++ b/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/proposal.md @@ -0,0 +1,42 @@ +# Proposal: improve-opentype-feature-ui + +Jira: [LT-22638](https://jira.sil.org/browse/LT-22638) (Critical, FW 9.3) — follow-up to LT-22324 / `add-opentype-font-features`. + +## Why + +Font Options currently shows OpenType features as cryptic four-character tags (what is `cv70`?) with only On/Off checkboxes, even though SIL fonts such as Charis SIL 6.x publish human-readable names and multi-option character variants inside the font (GSUB `featureParams` + name table). Users cannot understand or reach most features, so OpenType support is not yet useful; the ticket asks for parity with the Graphite feature experience. + +## What Changes + +- Add an OpenType feature-info reader in `Src/Common/FwUtils` that parses GSUB/GPOS feature lists **plus** `featureParams` and the `name` table, producing typed records: tag, font-supplied label, named cvXX options, default value. Table access goes through a delegate (`GetFontData` on the button's HDC in production; raw file bytes in tests). Parsing logic is ported from Paratext's `OpenTypeFeatures.Ttf` (the PT9.6 code the ticket references), keeping our GPOS coverage and reading `featUiLabelNameId` for cv labels (which Paratext skips). +- Add a registered-feature catalog (tag → `Hidden`/`DefaultOn`/`DefaultOff`) in FwUtils, seeded from Paratext's catalog and audited against the OpenType registry (`dlig` stays visible, `aalt` becomes hidden). Friendly names extend the existing `kstidOpenTypeFeature_` resx entries in `FwCoreDlgControls`. The catalog replaces the hardcoded 33-tag blocklist; hidden-filtering moves from the reader to the provider. +- Rewrite the OpenType provider inside `FontFeaturesButton`: cvXX features with named options render as radio submenus (None + option names, storing `cv43=2` etc.); ssXX and registered features render as checkboxes; labels are name-only in the current FLEx Graphite style; fallbacks are "Stylistic Set N", "Character Variant N", and the existing "Feature ''" format. Default-on features (`liga`, `calt`, `kern`, `clig`) initialize checked from catalog flags via the provider's existing `defaultValue` out-param. +- No changes to the menu machinery, the `tag=value` storage format, rendering, or exports — all already handle multi-valued settings (verified in `UniscribeSegment.cpp` and `CssGenerator`). +- Tests: reader tests in FwUtilsTests against the build-downloaded CharisSIL 6.200 with exact-value assertions, synthetic malformed-table fixtures that must degrade silently, `TestFontFeaturesButton` extensions for menu semantics, and a catalog/resx/old-blocklist consistency test. + +## Capabilities + +### New Capabilities + +_None — this change extends an existing capability._ + +### Modified Capabilities + +- `font-feature-settings`: the "OpenType feature discovery supports UI selection" requirement is extended — feature labels SHALL come from the font's `featureParams`/name table when present (catalog/resx names otherwise), cvXX features SHALL expose multiple named values instead of On/Off, and hidden/default classification SHALL come from a registered-feature catalog rather than a hardcoded blocklist. (This capability currently lives as a delta spec in `add-opentype-font-features`, not yet synced to `openspec/specs/`.) + +## Impact + +- **Managed C# only; no native changes.** The C++ Uniscribe path already parses arbitrary `tag=value` records. +- Affected code: `Src/Common/FwUtils` (new reader + catalog + tests), `Src/FwCoreDlgs/FwCoreDlgControls` (`FontFeaturesButton` provider, resx labels, tests), `Docs/opentype-font-features.md`. +- Test infrastructure: reuses the existing `PackageRestore.targets` Charis SIL download; no new checked-in assets. +- No storage, LDML, or data-model changes; verify at implementation time that liblcm round-trips feature strings untouched. +- Both host dialogs (Writing System Properties `DefaultFontsControl`, Styles `FwFontTab`) inherit the improvement with no changes to them. + +## Non-goals + +- No new dialog, grouping, tooltips, or sample glyphs (tier "b" presentation upgrade — candidate follow-up ticket). +- No localization of font-supplied names in this change (English-ranked name selection, with the ranking function isolated so a UI-language parameter can be added later without rework). +- No tri-state unset/on/off UI and no canonical stripping of explicit values (decided policy: toggles write explicit values; unset features are never written). +- No cmap or lookup-coverage parsing (only needed for sample glyphs). +- No changes to Graphite feature discovery, labels, or precedence behavior. +- No Word DOCX export changes (cvXX remains unsupported there, as documented). diff --git a/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/research.md b/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/research.md new file mode 100644 index 0000000000..660c7fd471 --- /dev/null +++ b/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/research.md @@ -0,0 +1,41 @@ +# Implementation findings: improve-opentype-feature-ui + +## Verified font parsing (task 1.3, 3.4) + +Reader validated against two downloaded SIL fonts via `OpenTypeFontFeatureInfoReader` and a file-based table source. + +**CharisSIL-Regular 6.200** (Latin) — exact values asserted in tests: +- `cv43` "Capital Eng" → Lowercase no descender / Capital form / Lowercase short stem +- `cv25` "Lowercase rams horn" → Large bowl / Small gamma +- `cv13` "Capital B hook" → Single bowl +- `ss01` "Single-story a and g" +- `liga`, `smcp` discovered with no font label; `mark`, `mkmk` discovered from GPOS + +**ScheherazadeNew-Regular 4.500** (Arabic) — manual sanity scan: +- `cv70` "Damma" → Filled / Short / Crossed (directly answers the ticket's "what is cv70?") +- `cv48` "Heh" → Sindhi-style / Urdu-style / Kurdish-style +- `cv82` "Eastern digits" → 5 options +- Shaping features `fina`, `init`, `medi`, `rlig`, `rtlm`, `ccmp`, `mark`, `mkmk` present and hidden by the catalog; `kern` discovered from GPOS. + +The Arabic set confirms multi-option character variants and correct hidden-feature filtering on a complex script. + +## liblcm round-trip (task 2.5) + +Multi-values such as `cv43=2` round-trip through the FieldWorks storage/render/export layers: +- `FontFeatureSettings.Parse`/`Normalize` preserve `cv01=2` (asserted in `FontFeatureSettingsTests`). +- `RenderEngineFactory` and `DefaultFontsControl` pass `ws.DefaultFontFeatures` through `NormalizePreservingLegacy`, which keeps the value verbatim. +- `CssGenerator.ConvertToCssFeatures` and the C++ Uniscribe path (`UniscribeSegment.cpp`) already emit/consume arbitrary non-negative values. +- The value is an opaque string to liblcm's writing-system LDML serialization; there is no per-value clamping on the FieldWorks side. +- The existing `FwFontDialogTests`/`FwFontTabTests` round-trip `smcp=1` through `FontInfo.m_features`/`ktptFontVariations` and the WS default; the path is value-agnostic, so `cv43=2` follows the same round-trip. Those suites pass. + +Conclusion: no storage change needed; the multi-value round-trip is sound. + +## Test results + +- FwUtilsTests: 33 passed (reader vs Charis, synthetic robustness, catalog, existing FontFeatureSettings) +- FwCoreDlgControlsTests: 17 passed (provider values/labels/filtering, resx consistency, existing button tests) +- FwCoreDlgsTests: 19 passed (FontTab, FontDialog, StyleInfo integration — no regression) + +## Catalog audit deviations from Paratext + +Beyond the planned corrections (`dlig` visible, `aalt` hidden, `kern` default-on), fixed Paratext's 5-character `stchc` typo to the registered tag `stch`. All other entries match Paratext, including the legacy 33-tag shaping blocklist which remains hidden (asserted by `OpenTypeFeatureCatalogTests`). diff --git a/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/specs/font-feature-settings/spec.md b/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/specs/font-feature-settings/spec.md new file mode 100644 index 0000000000..7b8f77b62c --- /dev/null +++ b/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/specs/font-feature-settings/spec.md @@ -0,0 +1,82 @@ +## MODIFIED Requirements + +### Requirement: OpenType feature discovery supports UI selection +FieldWorks SHALL discover user-configurable OpenType features for the selected font, including feature parameter data (`featureParams`) and name-table strings from both GSUB and GPOS, and expose them through the existing Font Features UI pattern. + +#### Scenario: OpenType font lists features with meaningful names +- **WHEN** a selected font advertises user-configurable OpenType features +- **THEN** the Font Features control SHALL list those features labeled by font-supplied names where the font provides them, catalog/resource-backed friendly names otherwise, and formatted fallback labels as a last resort + +#### Scenario: Required shaping features are not exposed as toggles +- **WHEN** a feature is classified as hidden in the registered-feature catalog because it is required for script shaping or is otherwise not user-configurable +- **THEN** the Font Features control SHALL NOT present it as a user toggle + +#### Scenario: Existing Graphite feature discovery still works +- **WHEN** a Graphite font is selected and Graphite remains enabled +- **THEN** existing Graphite feature labels and values SHALL continue to be available through the Font Features control, with unchanged Graphite label composition + +## ADDED Requirements + +### Requirement: Font-supplied feature names are displayed +FieldWorks SHALL read stylistic-set (`ss01`–`ss20`) UI names and character-variant (`cv01`–`cv99`) feature labels from the font's GSUB `featureParams` and name table, and SHALL display feature labels as the name alone without appending the four-character tag. + +#### Scenario: Stylistic set shows its font-supplied name +- **WHEN** Charis SIL (6.200 or later) is the selected font and its features are listed +- **THEN** `ss01` SHALL be labeled "Single-story a and g" as supplied by the font + +#### Scenario: Character variant shows its font-supplied label +- **WHEN** Charis SIL (6.200 or later) is the selected font and its features are listed +- **THEN** `cv43` SHALL be labeled "Capital Eng" as supplied by the font + +#### Scenario: Features without font strings use numbered fallbacks +- **WHEN** a font declares an `ssXX` or `cvXX` feature without usable featureParams name strings +- **THEN** the feature SHALL be labeled with a localizable fallback of the form "Stylistic Set N" or "Character Variant N" + +#### Scenario: Unknown tags stay visible with a formatted fallback +- **WHEN** a font declares a feature tag that is neither in the registered-feature catalog nor an `ssXX`/`cvXX` feature +- **THEN** the feature SHALL remain selectable and SHALL be labeled with the existing formatted fallback that includes the tag + +### Requirement: Character variants expose multiple named values +FieldWorks SHALL present a character-variant feature that declares N named parameters as a multi-valued selection whose values are 0 ("None") through N, where value i selects the i-th named option, and SHALL persist the selection in the renderer-neutral `tag=value` form. + +#### Scenario: Multi-option character variant offers each named option +- **WHEN** Charis SIL (6.200 or later) is the selected font and the user opens the `cv25` feature +- **THEN** the control SHALL offer "None" plus the two font-supplied option names, and selecting the second option SHALL persist `cv25=2` + +#### Scenario: Persisted multi-values round-trip +- **WHEN** a feature string containing `cv25=2` is loaded into the Font Features control +- **THEN** the control SHALL show the second named option of `cv25` as the selected value + +#### Scenario: Character variant without named parameters falls back to binary +- **WHEN** a font declares a `cvXX` feature without featureParams named options +- **THEN** the feature SHALL be presented as a binary on/off selection + +### Requirement: Registered feature catalog governs visibility and defaults +FieldWorks SHALL classify registered OpenType features using a catalog of hidden and default-on/default-off flags audited against the OpenType feature registry, SHALL initialize unset features' displayed state from those defaults, and SHALL NOT write values for features the user has not set. + +#### Scenario: Default-on features display honestly when unset +- **WHEN** a font supports `liga` and the stored feature string does not mention `liga` +- **THEN** the Font Features control SHALL show `liga` as enabled, and the stored feature string SHALL remain without a `liga` entry until the user changes it + +#### Scenario: Disabling a default-on feature writes an explicit zero +- **WHEN** the user unchecks `liga` from its default-enabled display state +- **THEN** the persisted feature string SHALL contain `liga=0` + +#### Scenario: Previously blocked shaping tags stay hidden +- **WHEN** a font declares tags that the prior implementation blocked as non-user-configurable, such as `mark`, `mkmk`, `init`, or `ccmp` +- **THEN** those tags SHALL remain absent from the Font Features control + +#### Scenario: Discretionary ligatures remain user-visible +- **WHEN** a font declares `dlig` +- **THEN** `dlig` SHALL be listed as a user-selectable feature with its friendly name + +### Requirement: Feature parameter parsing degrades safely +FieldWorks SHALL bounds-check all OpenType table parsing and SHALL degrade to tag-only feature records with fallback labels when featureParams or name-table data is truncated, malformed, or undecodable, without throwing exceptions into the UI. + +#### Scenario: Truncated featureParams do not break discovery +- **WHEN** a font's GSUB declares a featureParams offset that runs past the end of the table +- **THEN** the affected feature SHALL still be listed using its fallback label and other features SHALL be unaffected + +#### Scenario: Undecodable name records fall back +- **WHEN** a referenced name record has an unsupported platform/encoding pair or points outside the name-table storage +- **THEN** the feature SHALL be labeled with its fallback label instead of garbled text diff --git a/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/tasks.md b/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/tasks.md new file mode 100644 index 0000000000..1dc029ba38 --- /dev/null +++ b/openspec/changes/archive/2026-07-24-improve-opentype-feature-ui/tasks.md @@ -0,0 +1,27 @@ +# Tasks: improve-opentype-feature-ui + +All work is managed C# (no native changes). Groups are ordered by dependency: the FwUtils reader/catalog must exist before the provider integration, which must exist before verification. + +## 1. Feature-info reader and catalog (Src/Common/FwUtils) + +- [x] 1.1 Add `OpenTypeFontFeatureInfoReader` to `Src/Common/FwUtils`: parse GSUB/GPOS featureLists plus `featureParams` (ssXX `UINameID`; cvXX `featUiLabelNameId` and named parameter name IDs) and the `name` table with ranked record selection (Windows-English, then Unicode/Windows, then Mac Roman), all reads bounds-checked, input via table-source delegate (four-character tag → bytes); return typed records (tag, font label, option labels). Port parsing logic from Paratext `OpenTypeFeatures.Ttf`, keeping GPOS and adding the cv label read. +- [x] 1.2 Add `OpenTypeFeatureCatalog` to `Src/Common/FwUtils` mapping registered tags to `Hidden`/`DefaultOn`/`DefaultOff`, seeded from Paratext's `RegisteredFeatureCatalog` and audited against the OpenType registry: `dlig` visible, `aalt` hidden, every tag from the old `s_nonUserConfigurableTags` blocklist hidden, `liga`/`clig`/`calt`/`kern` default-on. (Catalog also carries the English fallback name so the resx only holds the localizable subset.) +- [x] 1.3 Wire `CharisSIL-Regular.ttf` (already downloaded by `Build/PackageRestore.targets`) into `FwUtilsTests` output the way `TestViews.vcxproj` consumes it, and add reader tests with exact-value assertions: cv43 "Capital Eng" with 3 options in order, cv25 with 2, cv13 with 1, ss01 "Single-story a and g", `liga`/`smcp` present without params, `mark`/`mkmk` discovered from GPOS. +- [x] 1.4 Add synthetic malformed-table tests in `FwUtilsTests` (truncated featureList, featureParams offset past table end, name record outside storage, undecodable platform/encoding, zero-length names): each degrades to tag-only records with no exception. +- [x] 1.5 Add catalog consistency tests: old blocklist tags are all `Hidden` (FwUtilsTests); every `kstidOpenTypeFeature_` resx key maps to a visible feature (FwCoreDlgControlsTests). + +## 2. Provider integration (Src/FwCoreDlgs/FwCoreDlgControls) + +- [x] 2.1 Rewrite `OpenTypeFontFeatureProvider` inside `FontFeaturesButton.cs` to consume reader records: `GetFeatureValues` returns `{0..N}` with catalog-driven defaults (`DefaultOn` → 1), named cvXX value labels ("None" + option names), hidden filtering at the provider, LOGFONT-keyed cache now storing the typed records. +- [x] 2.2 Label composition in the provider: font-supplied name first, then `kstidOpenTypeFeature_` resx name, then catalog English name, then "Stylistic Set {0}" / "Character Variant {0}" / existing "Feature #{0}" fallbacks; name-only (no tag suffix); Graphite label behavior untouched. +- [x] 2.3 Update `FwCoreDlgControls.resx`: add "None" and the numbered stylistic-set / character-variant fallback strings, remove the dead `aalt` and `ccmp` label resources (both now hidden). +- [x] 2.4 Extend `TestFontFeaturesButton.cs`: multi-option cvXX exposes None + named options and stores `cv43=2`, `cv43=2` round-trips through the renderer-neutral string, unset `liga` initializes on / `smcp` off, unnamed cvXX falls back to binary, hidden features filtered, unknown vendor tag stays visible, resx labels map to visible features. +- [x] 2.5 Verify liblcm round-trip: `DefaultFontFeatures` flows through `NormalizePreservingLegacy` (preserves `cv01=2`, tested) and is stored as an opaque LDML string with no per-value clamping; existing FwFontDialog/FwFontTab round-trip suites pass. Documented in `research.md`. + +## 3. Verification and documentation + +- [x] 3.1 Build with `.\build.ps1` and run `.\test.ps1` for FwUtilsTests (33 pass), FwCoreDlgControlsTests (17 pass), and FwCoreDlgsTests (19 pass: FontTab, FontDialog, StyleInfo — no regression). +- [x] 3.2 Coverage (test.ps1 -Coverage): OpenTypeFeatureCatalog 100%, OpenTypeFontFeatureInfo 100%, OpenTypeFontFeatureInfoReader 86% (uncovered = Mac Roman fallback / platform-1 decode). Provider exercised by the 17 FwCoreDlgControlsTests. +- [x] 3.3 Manual acceptance per LT-22638 in the live app (fieldworks-winapp): Format > Set up Vernacular Writing Systems > Font tab > Charis > Font Features shows meaningful names; cv sub-options selectable and rendering changes confirmed. Manual testing passed (confirmed by Jason). +- [x] 3.4 Sanity-check the reader against `ScheherazadeNew-Regular.ttf` (Arabic): cv70 "Damma", cv82 "Eastern digits" (5 options), shaping features hidden. Recorded in `research.md`. +- [x] 3.5 Update `Docs/opentype-font-features.md`: names come from the font/catalog, cvXX multi-values, catalog-based hidden/default classification, unchanged export behavior.