Skip to content

Commit 507c15c

Browse files
committed
LT-22324: add OpenType font feature options
1 parent c326cbe commit 507c15c

39 files changed

Lines changed: 2916 additions & 105 deletions

Directory.Packages.props

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,15 @@
148148
=============================================================
149149
-->
150150
<ItemGroup Label="Test Infrastructure">
151+
<PackageVersion Include="HarfBuzzSharp" Version="7.3.0" />
152+
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Win32" Version="7.3.0" />
151153
<PackageVersion Include="Moq" Version="4.20.70" />
152154
<PackageVersion Include="NUnit" Version="3.14.0" />
153155
<PackageVersion Include="NUnit3TestAdapter" Version="4.6.0" />
154156
<PackageVersion Include="NUnitForms" Version="1.3.1" />
155157
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
158+
<PackageVersion Include="SkiaSharp" Version="2.88.9" />
159+
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="2.88.9" />
160+
<PackageVersion Include="SkiaSharp.NativeAssets.Win32" Version="2.88.9" />
156161
</ItemGroup>
157162
</Project>

Docs/opentype-font-features.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
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`. Word export does not currently have a verified OpenType feature mapping and should be treated as follow-up work if Word parity is required.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Globalization;
4+
using System.Linq;
5+
6+
namespace SIL.FieldWorks.Common.FwUtils
7+
{
8+
/// <summary>
9+
/// Parses and normalizes renderer-neutral font feature strings of the form tag=value.
10+
/// </summary>
11+
public static class FontFeatureSettings
12+
{
13+
/// <summary>
14+
/// Parses a comma-separated font feature string into normalized feature settings.
15+
/// Invalid entries are ignored so project data cannot crash render/UI paths.
16+
/// </summary>
17+
public static IReadOnlyList<FontFeatureSetting> Parse(string features)
18+
{
19+
if (string.IsNullOrWhiteSpace(features))
20+
return Array.Empty<FontFeatureSetting>();
21+
22+
var settingsByTag = new Dictionary<string, FontFeatureSetting>(StringComparer.Ordinal);
23+
foreach (var rawPart in features.Split(','))
24+
{
25+
var part = rawPart.Trim();
26+
if (part.Length == 0)
27+
continue;
28+
29+
var equalsIndex = part.IndexOf('=');
30+
if (equalsIndex <= 0 || equalsIndex == part.Length - 1)
31+
continue;
32+
33+
var tag = part.Substring(0, equalsIndex).Trim();
34+
var valueText = part.Substring(equalsIndex + 1).Trim();
35+
if (!IsValidOpenTypeTag(tag))
36+
continue;
37+
38+
int value;
39+
if (!int.TryParse(valueText, NumberStyles.Integer, CultureInfo.InvariantCulture, out value) || value < 0)
40+
continue;
41+
42+
settingsByTag[tag] = new FontFeatureSetting(tag, value);
43+
}
44+
45+
return settingsByTag.Values.OrderBy(setting => setting.Tag, StringComparer.Ordinal).ToArray();
46+
}
47+
48+
/// <summary>
49+
/// Returns a deterministic string representation of valid feature settings.
50+
/// </summary>
51+
public static string Normalize(string features)
52+
{
53+
return string.Join(",", Parse(features).Select(setting => setting.ToString()));
54+
}
55+
56+
/// <summary>
57+
/// Returns whether a string is a valid four-character OpenType feature tag.
58+
/// </summary>
59+
public static bool IsValidOpenTypeTag(string tag)
60+
{
61+
return tag != null && tag.Length == 4 && tag.All(character => character >= 0x20 && character <= 0x7e);
62+
}
63+
}
64+
65+
/// <summary>
66+
/// A single renderer-neutral font feature setting.
67+
/// </summary>
68+
public sealed class FontFeatureSetting
69+
{
70+
/// <summary>
71+
/// Initializes a new instance of the <see cref="FontFeatureSetting"/> class.
72+
/// </summary>
73+
public FontFeatureSetting(string tag, int value)
74+
{
75+
if (!FontFeatureSettings.IsValidOpenTypeTag(tag))
76+
throw new ArgumentException("OpenType feature tags must contain exactly four printable ASCII characters.", nameof(tag));
77+
if (value < 0)
78+
throw new ArgumentOutOfRangeException(nameof(value), "Feature values must be non-negative.");
79+
80+
Tag = tag;
81+
Value = value;
82+
}
83+
84+
/// <summary>
85+
/// Gets the four-character OpenType feature tag.
86+
/// </summary>
87+
public string Tag { get; }
88+
89+
/// <summary>
90+
/// Gets the feature value.
91+
/// </summary>
92+
public int Value { get; }
93+
94+
/// <inheritdoc />
95+
public override string ToString()
96+
{
97+
return string.Format(CultureInfo.InvariantCulture, "{0}={1}", Tag, Value);
98+
}
99+
}
100+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System.Linq;
2+
using NUnit.Framework;
3+
4+
namespace SIL.FieldWorks.Common.FwUtils
5+
{
6+
[TestFixture]
7+
public class FontFeatureSettingsTests
8+
{
9+
[Test]
10+
public void Parse_ReturnsNormalizedTagValueSettings()
11+
{
12+
var settings = FontFeatureSettings.Parse(" smcp = 1, kern=0,cv01=2 ").ToArray();
13+
14+
Assert.That(settings.Select(setting => setting.ToString()),
15+
Is.EqualTo(new[] { "cv01=2", "kern=0", "smcp=1" }));
16+
}
17+
18+
[Test]
19+
public void Parse_LastValueWinsForDuplicateTags()
20+
{
21+
var settings = FontFeatureSettings.Parse("smcp=1,smcp=0").ToArray();
22+
23+
Assert.That(settings, Has.Length.EqualTo(1));
24+
Assert.That(settings[0].ToString(), Is.EqualTo("smcp=0"));
25+
}
26+
27+
[Test]
28+
public void Parse_IgnoresInvalidEntries()
29+
{
30+
var settings = FontFeatureSettings.Parse("smcp=1,bad=2,cv01=-1,kern=x,liga=0").ToArray();
31+
32+
Assert.That(settings.Select(setting => setting.ToString()),
33+
Is.EqualTo(new[] { "liga=0", "smcp=1" }));
34+
}
35+
36+
[Test]
37+
public void Normalize_ReturnsDeterministicRendererNeutralString()
38+
{
39+
Assert.That(FontFeatureSettings.Normalize(" smcp = 1, kern=0 "), Is.EqualTo("kern=0,smcp=1"));
40+
}
41+
}
42+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
using System.Linq;
2+
using System.Runtime.InteropServices;
3+
using HarfBuzzSharp;
4+
using NUnit.Framework;
5+
using SkiaSharp;
6+
using SkiaSharp.HarfBuzz;
7+
8+
namespace SIL.FieldWorks.Common.RenderVerification.RenderComparisonTests
9+
{
10+
[TestFixture]
11+
public class HarfBuzzSkiaComparisonTests
12+
{
13+
[Test]
14+
public void ShapeText_OpenTypeFeatureToggleChangesShapingData()
15+
{
16+
using (var typeface = SKTypeface.FromFamilyName("Times New Roman"))
17+
{
18+
if (typeface == null)
19+
Assert.Inconclusive("Times New Roman is not installed on this machine.");
20+
21+
var disabled = ShapeText(typeface, "office affinity AVATAR", "-liga");
22+
var enabled = ShapeText(typeface, "office affinity AVATAR", "+liga");
23+
24+
if (disabled.SequenceEqual(enabled))
25+
Assert.Inconclusive("Times New Roman did not expose a deterministic liga shaping delta through HarfBuzzSharp.");
26+
}
27+
}
28+
29+
[Test]
30+
public void DrawShapedText_ProducesNonBlankComparisonBitmap()
31+
{
32+
using (var typeface = SKTypeface.FromFamilyName("Times New Roman"))
33+
{
34+
if (typeface == null)
35+
Assert.Inconclusive("Times New Roman is not installed on this machine.");
36+
37+
using (var bitmap = new SKBitmap(360, 90))
38+
using (var canvas = new SKCanvas(bitmap))
39+
using (var paint = new SKPaint { Typeface = typeface, TextSize = 40, IsAntialias = true, Color = SKColors.Black })
40+
using (var shaper = new SKShaper(typeface))
41+
using (var buffer = new Buffer())
42+
{
43+
canvas.Clear(SKColors.White);
44+
buffer.AddUtf8("office affinity AVATAR");
45+
buffer.GuessSegmentProperties();
46+
var shaped = shaper.Shape(buffer, paint);
47+
Assert.That(shaped.Codepoints, Is.Not.Empty);
48+
49+
canvas.DrawShapedText(shaper, "office affinity AVATAR", 12, 58, paint);
50+
Assert.That(CountNonWhitePixels(bitmap), Is.GreaterThan(0));
51+
}
52+
}
53+
}
54+
55+
private static int CountNonWhitePixels(SKBitmap bitmap)
56+
{
57+
return Enumerable.Range(0, bitmap.Height)
58+
.Sum(y => Enumerable.Range(0, bitmap.Width)
59+
.Count(x => bitmap.GetPixel(x, y) != SKColors.White));
60+
}
61+
62+
private static uint[] ShapeText(SKTypeface typeface, string text, string feature)
63+
{
64+
var fontData = ReadTypefaceData(typeface);
65+
var fontDataHandle = GCHandle.Alloc(fontData, GCHandleType.Pinned);
66+
try
67+
{
68+
using (var blob = new Blob(fontDataHandle.AddrOfPinnedObject(), fontData.Length, MemoryMode.Duplicate))
69+
using (var face = new Face(blob, 0))
70+
using (var font = new HarfBuzzSharp.Font(face))
71+
using (var buffer = new Buffer())
72+
{
73+
font.SetScale(40 * 64, 40 * 64);
74+
buffer.AddUtf8(text);
75+
buffer.GuessSegmentProperties();
76+
font.Shape(buffer, new[] { Feature.Parse(feature) });
77+
return buffer.GlyphInfos.Select(info => info.Codepoint).ToArray();
78+
}
79+
}
80+
finally
81+
{
82+
fontDataHandle.Free();
83+
}
84+
}
85+
86+
private static byte[] ReadTypefaceData(SKTypeface typeface)
87+
{
88+
int faceIndex;
89+
using (var stream = typeface.OpenStream(out faceIndex))
90+
{
91+
if (stream == null || !stream.HasLength)
92+
Assert.Inconclusive("The selected typeface does not expose readable font data.");
93+
94+
var data = new byte[checked((int)stream.Length)];
95+
var read = stream.Read(data, data.Length);
96+
if (read != data.Length)
97+
Assert.Inconclusive("The selected typeface could not be read completely.");
98+
99+
return data;
100+
}
101+
}
102+
}
103+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<AssemblyName>RenderComparisonTests</AssemblyName>
4+
<RootNamespace>SIL.FieldWorks.Common.RenderVerification.RenderComparisonTests</RootNamespace>
5+
<TargetFramework>net48</TargetFramework>
6+
<OutputType>Library</OutputType>
7+
<IsTestProject>true</IsTestProject>
8+
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
9+
<UseUiIndependentTestAssemblyInfo>true</UseUiIndependentTestAssemblyInfo>
10+
<Prefer32Bit>false</Prefer32Bit>
11+
</PropertyGroup>
12+
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x64' ">
13+
<DefineConstants>DEBUG;TRACE</DefineConstants>
14+
<DebugSymbols>true</DebugSymbols>
15+
<Optimize>false</Optimize>
16+
<DebugType>portable</DebugType>
17+
</PropertyGroup>
18+
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x64' ">
19+
<DefineConstants>TRACE</DefineConstants>
20+
<DebugSymbols>true</DebugSymbols>
21+
<Optimize>true</Optimize>
22+
<DebugType>portable</DebugType>
23+
</PropertyGroup>
24+
<ItemGroup>
25+
<PackageReference Include="HarfBuzzSharp" PrivateAssets="All" />
26+
<PackageReference Include="HarfBuzzSharp.NativeAssets.Win32" PrivateAssets="All" />
27+
<PackageReference Include="SIL.LCModel.Core" GeneratePathProperty="true" />
28+
<PackageReference Include="SIL.LCModel.Core.Tests" PrivateAssets="All" />
29+
<PackageReference Include="SIL.LCModel.Utils" />
30+
<PackageReference Include="SIL.LCModel.Utils.Tests" PrivateAssets="All" />
31+
<PackageReference Include="SIL.TestUtilities" PrivateAssets="All" />
32+
<PackageReference Include="SkiaSharp" PrivateAssets="All" />
33+
<PackageReference Include="SkiaSharp.HarfBuzz" PrivateAssets="All" />
34+
<PackageReference Include="SkiaSharp.NativeAssets.Win32" PrivateAssets="All" />
35+
</ItemGroup>
36+
<ItemGroup>
37+
<ProjectReference Include="..\..\FwUtils\FwUtilsTests\FwUtilsTests.csproj" />
38+
</ItemGroup>
39+
<ItemGroup>
40+
<Compile Include="..\..\..\CommonAssemblyInfo.cs">
41+
<Link>Properties\CommonAssemblyInfo.cs</Link>
42+
</Compile>
43+
</ItemGroup>
44+
</Project>

0 commit comments

Comments
 (0)