Skip to content

Commit a9d4f58

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 502b832 commit a9d4f58

53 files changed

Lines changed: 3737 additions & 119 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.

Build/Agent/check-whitespace.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ Get-Content check-results.log | ForEach-Object {
8383
}
8484

8585
if ($problems.Count -gt 0) {
86-
Write-Host "`u26A0`uFE0F Please review the output for further information."
86+
Write-Host '[WARN] Please review the output for further information.'
8787
Write-Host '### A whitespace issue was found in one or more of the commits.'
8888
Write-Host 'This check validates commit history from origin/main..HEAD, not just the current working tree.'
8989
Write-Host 'If the report names an older commit, fix the file and then amend, squash, or rebase so that commit no longer appears in the branch history.'

Build/Agent/fix-whitespace.ps1

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,61 @@ function Write-Utf8Text {
3838
[System.IO.File]::WriteAllText($Path, $Content, $encoding)
3939
}
4040

41+
function Get-OriginalLineEnding {
42+
param([Parameter(Mandatory)][string]$Content)
43+
44+
if ($Content.Contains("`r`n")) { return "`r`n" }
45+
if ($Content.Contains("`n")) { return "`n" }
46+
if ($Content.Contains("`r")) { return "`r" }
47+
return [Environment]::NewLine
48+
}
49+
50+
function Read-Utf8Text {
51+
param([Parameter(Mandatory)][string]$Path)
52+
53+
$stream = [System.IO.File]::OpenRead($Path)
54+
try {
55+
$encoding = New-Object System.Text.UTF8Encoding($false, $true)
56+
$reader = New-Object System.IO.StreamReader($stream, $encoding, $true)
57+
try {
58+
return $reader.ReadToEnd()
59+
}
60+
finally {
61+
$reader.Dispose()
62+
}
63+
}
64+
finally {
65+
$stream.Dispose()
66+
}
67+
}
68+
69+
function Get-TextFilesFromDiff {
70+
param([Parameter(Mandatory)][string]$Range)
71+
72+
$files = New-Object System.Collections.Generic.List[string]
73+
foreach ($line in (git diff --numstat $Range)) {
74+
$parts = $line -split "`t", 3
75+
if ($parts.Length -eq 3 -and $parts[0] -ne '-' -and $parts[1] -ne '-') {
76+
$files.Add($parts[2])
77+
}
78+
}
79+
80+
return $files.ToArray()
81+
}
82+
4183
function Format-FileWhitespace {
4284
param([Parameter(Mandatory)][string]$Path)
4385
if (-not (Test-Path -LiteralPath $Path)) { return }
4486
try {
4587
$hasUtf8Bom = Test-HasUtf8Bom -Path $Path
46-
$raw = Get-Content -LiteralPath $Path -Raw -Encoding utf8
88+
$raw = Read-Utf8Text -Path $Path
4789
}
4890
catch {
4991
Write-Host "Skipping non-UTF8 or binary file: $Path"
5092
return
5193
}
5294
$orig = $raw
95+
$lineEnding = Get-OriginalLineEnding -Content $raw
5396
# Normalize newlines to real LF characters for processing
5497
$normalized = $raw -replace '\r\n', "`n" -replace '\r', "`n"
5598
# Build a mutable list of lines and trim trailing spaces/tabs
@@ -60,7 +103,7 @@ function Format-FileWhitespace {
60103
# Remove trailing blank lines
61104
while ($lines.Count -gt 0 -and [string]::IsNullOrWhiteSpace($lines[$lines.Count - 1])) { $lines.RemoveAt($lines.Count - 1) }
62105
# Join back and ensure exactly one trailing newline
63-
$new = ($lines -join "`n") + "`n"
106+
$new = ($lines -join $lineEnding) + $lineEnding
64107
if ($new -ne $orig) {
65108
Write-Utf8Text -Path $Path -Content $new -EmitBom $hasUtf8Bom
66109
Write-Host "Fixed whitespace: $Path"
@@ -78,7 +121,8 @@ if (Test-Path -LiteralPath 'check-results.log') {
78121
if (-not $fixFiles -or $fixFiles.Count -eq 0) {
79122
$base = Get-BaseRef
80123
Write-Host "Fixing whitespace for files changed since $base..HEAD"
81-
$fixFiles = git diff --name-only "$base"..HEAD
124+
$range = "$base..HEAD"
125+
$fixFiles = Get-TextFilesFromDiff -Range $range
82126
}
83127

84128
$files = $fixFiles | Where-Object { $_ -and (Test-Path $_) }

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+
}

0 commit comments

Comments
 (0)