Skip to content

Commit 11e413a

Browse files
committed
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
1 parent c326cbe commit 11e413a

51 files changed

Lines changed: 3684 additions & 110 deletions

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
@@ -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: 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.
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)