Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,15 @@
=============================================================
-->
<ItemGroup Label="Test Infrastructure">
<PackageVersion Include="HarfBuzzSharp" Version="7.3.0" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Win32" Version="7.3.0" />
<PackageVersion Include="Moq" Version="4.20.70" />
<PackageVersion Include="NUnit" Version="3.14.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageVersion Include="NUnitForms" Version="1.3.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="SkiaSharp" Version="2.88.9" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="2.88.9" />
<PackageVersion Include="SkiaSharp.NativeAssets.Win32" Version="2.88.9" />
</ItemGroup>
</Project>
19 changes: 19 additions & 0 deletions Docs/opentype-font-features.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# OpenType Font Features

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.

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.

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`.

Word DOCX export preserves the subset of OpenType features that Microsoft WordprocessingML can represent with Office 2010 `w14` typography elements:

- `liga`, `clig`, `hlig`, and `dlig` map to Word ligature settings.
- `lnum` and `onum` map to lining and old-style number forms.
- `pnum` and `tnum` map to proportional and tabular number spacing.
- `calt` maps to contextual alternatives.
- `ss01` through `ss20` map to Word stylistic sets.

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.
8 changes: 8 additions & 0 deletions FieldWorks.sln
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RenderTestInfrastructure",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RenderVerification", "Src\Common\RenderVerification\RenderVerification.csproj", "{6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RenderComparisonTests", "Src\Common\RenderVerification\RenderComparisonTests\RenderComparisonTests.csproj", "{5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discourse", "Src\LexText\Discourse\Discourse.csproj", "{A51BAFC3-1649-584D-8D25-101884EE9EAA}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscourseTests", "Src\LexText\Discourse\DiscourseTests\DiscourseTests.csproj", "{1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}"
Expand Down Expand Up @@ -347,6 +349,12 @@ Global
{6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Debug|x64.Build.0 = Debug|x64
{6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Release|x64.ActiveCfg = Release|x64
{6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Release|x64.Build.0 = Release|x64
{5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Bounds|x64.ActiveCfg = Release|x64
{5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Bounds|x64.Build.0 = Release|x64
{5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Debug|x64.ActiveCfg = Debug|x64
{5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Debug|x64.Build.0 = Debug|x64
{5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Release|x64.ActiveCfg = Release|x64
{5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Release|x64.Build.0 = Release|x64
{A51BAFC3-1649-584D-8D25-101884EE9EAA}.Bounds|x64.ActiveCfg = Release|x64
{A51BAFC3-1649-584D-8D25-101884EE9EAA}.Bounds|x64.Build.0 = Release|x64
{A51BAFC3-1649-584D-8D25-101884EE9EAA}.Debug|x64.ActiveCfg = Debug|x64
Expand Down
155 changes: 155 additions & 0 deletions Src/Common/FwUtils/FontFeatureSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Diagnostics;

namespace SIL.FieldWorks.Common.FwUtils
{
/// <summary>
/// Parses and normalizes renderer-neutral font feature strings of the form tag=value.
/// </summary>
public static class FontFeatureSettings
{
private static readonly TraceSwitch s_traceSwitch =
new TraceSwitch("FwUtils_FontFeatureSettings", "Font feature parsing diagnostics");

internal static TraceSwitch DiagnosticsSwitch
{
get { return s_traceSwitch; }
}

/// <summary>
/// Parses a comma-separated font feature string into normalized feature settings.
/// Invalid entries are ignored so project data cannot crash render/UI paths.
/// </summary>
public static IReadOnlyList<FontFeatureSetting> Parse(string features)
{
if (string.IsNullOrWhiteSpace(features))
return Array.Empty<FontFeatureSetting>();

var settingsByTag = new Dictionary<string, FontFeatureSetting>(StringComparer.Ordinal);
foreach (var rawPart in features.Split(','))
{
var part = rawPart.Trim();
if (part.Length == 0)
continue;

var equalsIndex = part.IndexOf('=');
if (equalsIndex <= 0 || equalsIndex == part.Length - 1)
{
TraceIgnoredEntry(part, "expected tag=value");
continue;
}

var tag = part.Substring(0, equalsIndex).Trim();
var valueText = part.Substring(equalsIndex + 1).Trim();
if (!IsValidOpenTypeTag(tag))
{
TraceIgnoredEntry(part, "tag must contain exactly four printable ASCII characters");
continue;
}

int value;
if (!int.TryParse(valueText, NumberStyles.Integer, CultureInfo.InvariantCulture, out value) || value < 0)
{
TraceIgnoredEntry(part, "value must be a non-negative integer");
continue;
}

settingsByTag[tag] = new FontFeatureSetting(tag, value);
}

return settingsByTag.Values.OrderBy(setting => setting.Tag, StringComparer.Ordinal).ToArray();
}

/// <summary>
/// Returns a deterministic string representation of valid feature settings.
/// </summary>
public static string Normalize(string features)
{
return string.Join(",", Parse(features).Select(setting => setting.ToString()));
}

/// <summary>
/// Returns a deterministic representation for OpenType feature strings while preserving
/// legacy numeric Graphite feature identifiers.
/// </summary>
public static string NormalizePreservingLegacy(string features)
{
if (string.IsNullOrWhiteSpace(features))
return string.Empty;

var trimmed = features.Trim();
return LooksLikeLegacyGraphiteFeatureString(trimmed) ? trimmed : Normalize(trimmed);
}

private static bool LooksLikeLegacyGraphiteFeatureString(string features)
{
var firstPart = features.Split(',').FirstOrDefault();
if (string.IsNullOrWhiteSpace(firstPart))
return false;

var equalsIndex = firstPart.IndexOf('=');
if (equalsIndex <= 0)
return false;

var featureId = firstPart.Substring(0, equalsIndex).Trim();
return featureId.Length > 0 && featureId.All(char.IsDigit);
}

/// <summary>
/// Returns whether a string is a valid four-character OpenType feature tag.
/// </summary>
public static bool IsValidOpenTypeTag(string tag)
{
return tag != null && tag.Length == 4 && tag.All(character => character >= 0x20 && character <= 0x7e);
}

private static void TraceIgnoredEntry(string part, string reason)
{
Trace.WriteLineIf(s_traceSwitch.TraceWarning,
string.Format(CultureInfo.InvariantCulture,
"Ignored invalid font feature entry '{0}': {1}.",
part,
reason),
s_traceSwitch.DisplayName);
}
}

/// <summary>
/// A single renderer-neutral font feature setting.
/// </summary>
public sealed class FontFeatureSetting
{
/// <summary>
/// Initializes a new instance of the <see cref="FontFeatureSetting"/> class.
/// </summary>
public FontFeatureSetting(string tag, int value)
{
if (!FontFeatureSettings.IsValidOpenTypeTag(tag))
throw new ArgumentException("OpenType feature tags must contain exactly four printable ASCII characters.", nameof(tag));
if (value < 0)
throw new ArgumentOutOfRangeException(nameof(value), "Feature values must be non-negative.");

Tag = tag;
Value = value;
}

/// <summary>
/// Gets the four-character OpenType feature tag.
/// </summary>
public string Tag { get; }

/// <summary>
/// Gets the feature value.
/// </summary>
public int Value { get; }

/// <inheritdoc />
public override string ToString()
{
return string.Format(CultureInfo.InvariantCulture, "{0}={1}", Tag, Value);
}
}
}
100 changes: 100 additions & 0 deletions Src/Common/FwUtils/FwUtilsTests/FontFeatureSettingsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using System.Linq;
using System.Diagnostics;
using System.IO;
using NUnit.Framework;

namespace SIL.FieldWorks.Common.FwUtils
{
[TestFixture]
public class FontFeatureSettingsTests
{
[Test]
public void Parse_ReturnsNormalizedTagValueSettings()
{
var settings = FontFeatureSettings.Parse(" smcp = 1, kern=0,cv01=2 ").ToArray();

Assert.That(settings.Select(setting => setting.ToString()),
Is.EqualTo(new[] { "cv01=2", "kern=0", "smcp=1" }));
}

[Test]
public void Parse_LastValueWinsForDuplicateTags()
{
var settings = FontFeatureSettings.Parse("smcp=1,smcp=0").ToArray();

Assert.That(settings, Has.Length.EqualTo(1));
Assert.That(settings[0].ToString(), Is.EqualTo("smcp=0"));
}

[Test]
public void Parse_IgnoresInvalidEntries()
{
var settings = FontFeatureSettings.Parse("smcp=1,bad=2,cv01=-1,kern=x,liga=0").ToArray();

Assert.That(settings.Select(setting => setting.ToString()),
Is.EqualTo(new[] { "liga=0", "smcp=1" }));
}

[Test]
public void Parse_LogsIgnoredInvalidEntries()
{
var writer = new StringWriter();
var listener = new TextWriterTraceListener(writer);
var previousLevel = FontFeatureSettings.DiagnosticsSwitch.Level;

try
{
FontFeatureSettings.DiagnosticsSwitch.Level = TraceLevel.Warning;
Trace.Listeners.Add(listener);

FontFeatureSettings.Parse("smcp=1,bad=2,kern=x,broken");

listener.Flush();
var output = writer.ToString();
Assert.That(output, Does.Contain("Ignored invalid font feature entry 'bad=2'"));
Assert.That(output, Does.Contain("Ignored invalid font feature entry 'kern=x'"));
Assert.That(output, Does.Contain("Ignored invalid font feature entry 'broken'"));
}
finally
{
Trace.Listeners.Remove(listener);
listener.Dispose();
FontFeatureSettings.DiagnosticsSwitch.Level = previousLevel;
}
}

[Test]
public void Parse_AcceptsCustomPrintableAsciiTags()
{
var settings = FontFeatureSettings.Parse("!abc=1,a\"b\\=2").ToArray();

Assert.That(settings.Select(setting => setting.ToString()),
Is.EqualTo(new[] { "!abc=1", "a\"b\\=2" }));
}

[Test]
public void Normalize_ReturnsDeterministicRendererNeutralString()
{
Assert.That(FontFeatureSettings.Normalize(" smcp = 1, kern=0 "), Is.EqualTo("kern=0,smcp=1"));
}

[Test]
public void NormalizePreservingLegacy_PreservesNumericGraphiteFeatureIds()
{
Assert.That(FontFeatureSettings.NormalizePreservingLegacy(" 123=1,456=2 "), Is.EqualTo("123=1,456=2"));
}

[Test]
public void NormalizePreservingLegacy_NormalizesOpenTypeFeatures()
{
Assert.That(FontFeatureSettings.NormalizePreservingLegacy(" smcp = 1, kern=0 "), Is.EqualTo("kern=0,smcp=1"));
}

[Test]
public void NormalizePreservingLegacy_NormalizesOpenTypeFeaturesThatStartWithPunctuation()
{
Assert.That(FontFeatureSettings.NormalizePreservingLegacy(" !abc = 1, kern=0 "),
Is.EqualTo("!abc=1,kern=0"));
}
}
}
Loading
Loading