Skip to content

Commit ea21aab

Browse files
authored
LT-22324: add OpenType font feature options (#870)
* LT-22324: add OpenType font feature options Add word generation font options Finish up the docx font updates Partial fixes LT-22324: preserve OpenType font features xWorks: preserve OpenType features in Word export * LT-22324: address OpenType feature review follow-ups * LT-22324: finish OpenType locale review fixes * Update spec from robustness review * Implement revisions from review * LT-22324: address Jason review comments * LT-22324: address Jason's latest review comments
1 parent 846c123 commit ea21aab

58 files changed

Lines changed: 5744 additions & 200 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Directory.Packages.props

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,15 @@
157157
=============================================================
158158
-->
159159
<ItemGroup Label="Test Infrastructure">
160+
<PackageVersion Include="HarfBuzzSharp" Version="7.3.0" />
161+
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Win32" Version="7.3.0" />
160162
<PackageVersion Include="Moq" Version="4.20.70" />
161163
<PackageVersion Include="NUnit" Version="3.14.0" />
162164
<PackageVersion Include="NUnit3TestAdapter" Version="4.6.0" />
163165
<PackageVersion Include="NUnitForms" Version="1.3.1" />
164166
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
167+
<PackageVersion Include="SkiaSharp" Version="2.88.9" />
168+
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="2.88.9" />
169+
<PackageVersion Include="SkiaSharp.NativeAssets.Win32" Version="2.88.9" />
165170
</ItemGroup>
166171
</Project>

Docs/opentype-font-features.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# OpenType Font Features
2+
3+
FieldWorks stores font options as renderer-neutral feature strings such as `smcp=1`, `kern=0`, and `cv01=2`. The same value is used by writing-system default fonts, style font settings, rendering, and export paths.
4+
5+
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.
6+
7+
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.
8+
9+
For export, CSS output maps these values to `font-feature-settings`, and Notebook export preserves writing-system default font features in `DefaultFontFeatures`.
10+
11+
Word DOCX export preserves the subset of OpenType features that Microsoft WordprocessingML can represent with Office 2010 `w14` typography elements:
12+
13+
- `liga`, `clig`, `hlig`, and `dlig` map to Word ligature settings.
14+
- `lnum` and `onum` map to lining and old-style number forms.
15+
- `pnum` and `tnum` map to proportional and tabular number spacing.
16+
- `calt` maps to contextual alternatives.
17+
- `ss01` through `ss20` map to Word stylistic sets.
18+
19+
Other tags, including character variants such as `cv01`, small-cap features such as `smcp`, kerning, swashes, and private or vendor tags, do not have a documented arbitrary DOCX feature-tag representation. Word export ignores those unsupported tags while preserving supported tags from the same feature string.

FieldWorks.sln

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RenderTestInfrastructure",
2222
EndProject
2323
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RenderVerification", "Src\Common\RenderVerification\RenderVerification.csproj", "{6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}"
2424
EndProject
25+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RenderComparisonTests", "Src\Common\RenderVerification\RenderComparisonTests\RenderComparisonTests.csproj", "{5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}"
26+
EndProject
2527
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discourse", "Src\LexText\Discourse\Discourse.csproj", "{A51BAFC3-1649-584D-8D25-101884EE9EAA}"
2628
EndProject
2729
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscourseTests", "Src\LexText\Discourse\DiscourseTests\DiscourseTests.csproj", "{1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}"
@@ -347,6 +349,12 @@ Global
347349
{6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Debug|x64.Build.0 = Debug|x64
348350
{6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Release|x64.ActiveCfg = Release|x64
349351
{6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Release|x64.Build.0 = Release|x64
352+
{5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Bounds|x64.ActiveCfg = Release|x64
353+
{5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Bounds|x64.Build.0 = Release|x64
354+
{5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Debug|x64.ActiveCfg = Debug|x64
355+
{5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Debug|x64.Build.0 = Debug|x64
356+
{5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Release|x64.ActiveCfg = Release|x64
357+
{5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Release|x64.Build.0 = Release|x64
350358
{A51BAFC3-1649-584D-8D25-101884EE9EAA}.Bounds|x64.ActiveCfg = Release|x64
351359
{A51BAFC3-1649-584D-8D25-101884EE9EAA}.Bounds|x64.Build.0 = Release|x64
352360
{A51BAFC3-1649-584D-8D25-101884EE9EAA}.Debug|x64.ActiveCfg = Debug|x64
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Globalization;
4+
using System.Linq;
5+
using System.Diagnostics;
6+
7+
namespace SIL.FieldWorks.Common.FwUtils
8+
{
9+
/// <summary>
10+
/// Parses and normalizes renderer-neutral font feature strings of the form tag=value.
11+
/// </summary>
12+
public static class FontFeatureSettings
13+
{
14+
private static readonly TraceSwitch s_traceSwitch =
15+
new TraceSwitch("FwUtils_FontFeatureSettings", "Font feature parsing diagnostics");
16+
17+
internal static TraceSwitch DiagnosticsSwitch
18+
{
19+
get { return s_traceSwitch; }
20+
}
21+
22+
/// <summary>
23+
/// Parses a comma-separated font feature string into normalized feature settings.
24+
/// Invalid entries are ignored so project data cannot crash render/UI paths.
25+
/// </summary>
26+
public static IReadOnlyList<FontFeatureSetting> Parse(string features)
27+
{
28+
if (string.IsNullOrWhiteSpace(features))
29+
return Array.Empty<FontFeatureSetting>();
30+
31+
var settingsByTag = new Dictionary<string, FontFeatureSetting>(StringComparer.Ordinal);
32+
foreach (var rawPart in features.Split(','))
33+
{
34+
var part = rawPart.Trim();
35+
if (part.Length == 0)
36+
continue;
37+
38+
var equalsIndex = part.IndexOf('=');
39+
if (equalsIndex <= 0 || equalsIndex == part.Length - 1)
40+
{
41+
TraceIgnoredEntry(part, "expected tag=value");
42+
continue;
43+
}
44+
45+
var tag = part.Substring(0, equalsIndex).Trim();
46+
var valueText = part.Substring(equalsIndex + 1).Trim();
47+
if (!IsValidOpenTypeTag(tag))
48+
{
49+
TraceIgnoredEntry(part, "tag must contain exactly four printable ASCII characters");
50+
continue;
51+
}
52+
53+
int value;
54+
if (!int.TryParse(valueText, NumberStyles.Integer, CultureInfo.InvariantCulture, out value) || value < 0)
55+
{
56+
TraceIgnoredEntry(part, "value must be a non-negative integer");
57+
continue;
58+
}
59+
60+
settingsByTag[tag] = new FontFeatureSetting(tag, value);
61+
}
62+
63+
return settingsByTag.Values.OrderBy(setting => setting.Tag, StringComparer.Ordinal).ToArray();
64+
}
65+
66+
/// <summary>
67+
/// Returns a deterministic string representation of valid feature settings.
68+
/// </summary>
69+
public static string Normalize(string features)
70+
{
71+
return string.Join(",", Parse(features).Select(setting => setting.ToString()));
72+
}
73+
74+
/// <summary>
75+
/// Returns a deterministic representation for OpenType feature strings while preserving
76+
/// legacy numeric Graphite feature identifiers.
77+
/// </summary>
78+
public static string NormalizePreservingLegacy(string features)
79+
{
80+
if (string.IsNullOrWhiteSpace(features))
81+
return string.Empty;
82+
83+
var trimmed = features.Trim();
84+
return LooksLikeLegacyGraphiteFeatureString(trimmed) ? trimmed : Normalize(trimmed);
85+
}
86+
87+
private static bool LooksLikeLegacyGraphiteFeatureString(string features)
88+
{
89+
var firstPart = features.Split(',').FirstOrDefault();
90+
if (string.IsNullOrWhiteSpace(firstPart))
91+
return false;
92+
93+
var equalsIndex = firstPart.IndexOf('=');
94+
if (equalsIndex <= 0)
95+
return false;
96+
97+
var featureId = firstPart.Substring(0, equalsIndex).Trim();
98+
return featureId.Length > 0 && featureId.All(char.IsDigit);
99+
}
100+
101+
/// <summary>
102+
/// Returns whether a string is a valid four-character OpenType feature tag.
103+
/// </summary>
104+
public static bool IsValidOpenTypeTag(string tag)
105+
{
106+
return tag != null && tag.Length == 4 && tag.All(character => character >= 0x20 && character <= 0x7e);
107+
}
108+
109+
private static void TraceIgnoredEntry(string part, string reason)
110+
{
111+
Trace.WriteLineIf(s_traceSwitch.TraceWarning,
112+
string.Format(CultureInfo.InvariantCulture,
113+
"Ignored invalid font feature entry '{0}': {1}.",
114+
part,
115+
reason),
116+
s_traceSwitch.DisplayName);
117+
}
118+
}
119+
120+
/// <summary>
121+
/// A single renderer-neutral font feature setting.
122+
/// </summary>
123+
public sealed class FontFeatureSetting
124+
{
125+
/// <summary>
126+
/// Initializes a new instance of the <see cref="FontFeatureSetting"/> class.
127+
/// </summary>
128+
public FontFeatureSetting(string tag, int value)
129+
{
130+
if (!FontFeatureSettings.IsValidOpenTypeTag(tag))
131+
throw new ArgumentException("OpenType feature tags must contain exactly four printable ASCII characters.", nameof(tag));
132+
if (value < 0)
133+
throw new ArgumentOutOfRangeException(nameof(value), "Feature values must be non-negative.");
134+
135+
Tag = tag;
136+
Value = value;
137+
}
138+
139+
/// <summary>
140+
/// Gets the four-character OpenType feature tag.
141+
/// </summary>
142+
public string Tag { get; }
143+
144+
/// <summary>
145+
/// Gets the feature value.
146+
/// </summary>
147+
public int Value { get; }
148+
149+
/// <inheritdoc />
150+
public override string ToString()
151+
{
152+
return string.Format(CultureInfo.InvariantCulture, "{0}={1}", Tag, Value);
153+
}
154+
}
155+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using System.Linq;
2+
using System.Diagnostics;
3+
using System.IO;
4+
using NUnit.Framework;
5+
6+
namespace SIL.FieldWorks.Common.FwUtils
7+
{
8+
[TestFixture]
9+
public class FontFeatureSettingsTests
10+
{
11+
[Test]
12+
public void Parse_ReturnsNormalizedTagValueSettings()
13+
{
14+
var settings = FontFeatureSettings.Parse(" smcp = 1, kern=0,cv01=2 ").ToArray();
15+
16+
Assert.That(settings.Select(setting => setting.ToString()),
17+
Is.EqualTo(new[] { "cv01=2", "kern=0", "smcp=1" }));
18+
}
19+
20+
[Test]
21+
public void Parse_LastValueWinsForDuplicateTags()
22+
{
23+
var settings = FontFeatureSettings.Parse("smcp=1,smcp=0").ToArray();
24+
25+
Assert.That(settings, Has.Length.EqualTo(1));
26+
Assert.That(settings[0].ToString(), Is.EqualTo("smcp=0"));
27+
}
28+
29+
[Test]
30+
public void Parse_IgnoresInvalidEntries()
31+
{
32+
var settings = FontFeatureSettings.Parse("smcp=1,bad=2,cv01=-1,kern=x,liga=0").ToArray();
33+
34+
Assert.That(settings.Select(setting => setting.ToString()),
35+
Is.EqualTo(new[] { "liga=0", "smcp=1" }));
36+
}
37+
38+
[Test]
39+
public void Parse_LogsIgnoredInvalidEntries()
40+
{
41+
var writer = new StringWriter();
42+
var listener = new TextWriterTraceListener(writer);
43+
var previousLevel = FontFeatureSettings.DiagnosticsSwitch.Level;
44+
45+
try
46+
{
47+
FontFeatureSettings.DiagnosticsSwitch.Level = TraceLevel.Warning;
48+
Trace.Listeners.Add(listener);
49+
50+
FontFeatureSettings.Parse("smcp=1,bad=2,kern=x,broken");
51+
52+
listener.Flush();
53+
var output = writer.ToString();
54+
Assert.That(output, Does.Contain("Ignored invalid font feature entry 'bad=2'"));
55+
Assert.That(output, Does.Contain("Ignored invalid font feature entry 'kern=x'"));
56+
Assert.That(output, Does.Contain("Ignored invalid font feature entry 'broken'"));
57+
}
58+
finally
59+
{
60+
Trace.Listeners.Remove(listener);
61+
listener.Dispose();
62+
FontFeatureSettings.DiagnosticsSwitch.Level = previousLevel;
63+
}
64+
}
65+
66+
[Test]
67+
public void Parse_AcceptsCustomPrintableAsciiTags()
68+
{
69+
var settings = FontFeatureSettings.Parse("!abc=1,a\"b\\=2").ToArray();
70+
71+
Assert.That(settings.Select(setting => setting.ToString()),
72+
Is.EqualTo(new[] { "!abc=1", "a\"b\\=2" }));
73+
}
74+
75+
[Test]
76+
public void Normalize_ReturnsDeterministicRendererNeutralString()
77+
{
78+
Assert.That(FontFeatureSettings.Normalize(" smcp = 1, kern=0 "), Is.EqualTo("kern=0,smcp=1"));
79+
}
80+
81+
[Test]
82+
public void NormalizePreservingLegacy_PreservesNumericGraphiteFeatureIds()
83+
{
84+
Assert.That(FontFeatureSettings.NormalizePreservingLegacy(" 123=1,456=2 "), Is.EqualTo("123=1,456=2"));
85+
}
86+
87+
[Test]
88+
public void NormalizePreservingLegacy_NormalizesOpenTypeFeatures()
89+
{
90+
Assert.That(FontFeatureSettings.NormalizePreservingLegacy(" smcp = 1, kern=0 "), Is.EqualTo("kern=0,smcp=1"));
91+
}
92+
93+
[Test]
94+
public void NormalizePreservingLegacy_NormalizesOpenTypeFeaturesThatStartWithPunctuation()
95+
{
96+
Assert.That(FontFeatureSettings.NormalizePreservingLegacy(" !abc = 1, kern=0 "),
97+
Is.EqualTo("!abc=1,kern=0"));
98+
}
99+
}
100+
}

0 commit comments

Comments
 (0)