diff --git a/Directory.Packages.props b/Directory.Packages.props index fd6974bad6..658e15e23e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -153,10 +153,15 @@ ============================================================= --> + + + + + diff --git a/Docs/opentype-font-features.md b/Docs/opentype-font-features.md new file mode 100644 index 0000000000..55c80ddbea --- /dev/null +++ b/Docs/opentype-font-features.md @@ -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. diff --git a/FieldWorks.sln b/FieldWorks.sln index a4dc0615a2..aae0cc092f 100644 --- a/FieldWorks.sln +++ b/FieldWorks.sln @@ -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}" @@ -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 diff --git a/Src/Common/FwUtils/FontFeatureSettings.cs b/Src/Common/FwUtils/FontFeatureSettings.cs new file mode 100644 index 0000000000..bc22a956f5 --- /dev/null +++ b/Src/Common/FwUtils/FontFeatureSettings.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Diagnostics; + +namespace SIL.FieldWorks.Common.FwUtils +{ + /// + /// Parses and normalizes renderer-neutral font feature strings of the form tag=value. + /// + 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; } + } + + /// + /// Parses a comma-separated font feature string into normalized feature settings. + /// Invalid entries are ignored so project data cannot crash render/UI paths. + /// + public static IReadOnlyList Parse(string features) + { + if (string.IsNullOrWhiteSpace(features)) + return Array.Empty(); + + var settingsByTag = new Dictionary(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(); + } + + /// + /// Returns a deterministic string representation of valid feature settings. + /// + public static string Normalize(string features) + { + return string.Join(",", Parse(features).Select(setting => setting.ToString())); + } + + /// + /// Returns a deterministic representation for OpenType feature strings while preserving + /// legacy numeric Graphite feature identifiers. + /// + 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); + } + + /// + /// Returns whether a string is a valid four-character OpenType feature tag. + /// + 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); + } + } + + /// + /// A single renderer-neutral font feature setting. + /// + public sealed class FontFeatureSetting + { + /// + /// Initializes a new instance of the class. + /// + 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; + } + + /// + /// Gets the four-character OpenType feature tag. + /// + public string Tag { get; } + + /// + /// Gets the feature value. + /// + public int Value { get; } + + /// + public override string ToString() + { + return string.Format(CultureInfo.InvariantCulture, "{0}={1}", Tag, Value); + } + } +} diff --git a/Src/Common/FwUtils/FwUtilsTests/FontFeatureSettingsTests.cs b/Src/Common/FwUtils/FwUtilsTests/FontFeatureSettingsTests.cs new file mode 100644 index 0000000000..436a1e02f4 --- /dev/null +++ b/Src/Common/FwUtils/FwUtilsTests/FontFeatureSettingsTests.cs @@ -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")); + } + } +} diff --git a/Src/Common/RenderVerification/RenderComparisonTests/HarfBuzzSkiaComparisonTests.cs b/Src/Common/RenderVerification/RenderComparisonTests/HarfBuzzSkiaComparisonTests.cs new file mode 100644 index 0000000000..0cd0889b58 --- /dev/null +++ b/Src/Common/RenderVerification/RenderComparisonTests/HarfBuzzSkiaComparisonTests.cs @@ -0,0 +1,103 @@ +using System.Linq; +using System.Runtime.InteropServices; +using HarfBuzzSharp; +using NUnit.Framework; +using SkiaSharp; +using SkiaSharp.HarfBuzz; + +namespace SIL.FieldWorks.Common.RenderVerification.RenderComparisonTests +{ + [TestFixture] + public class HarfBuzzSkiaComparisonTests + { + [Test] + public void ShapeText_OpenTypeFeatureToggleChangesShapingData() + { + using (var typeface = SKTypeface.FromFamilyName("Times New Roman")) + { + if (typeface == null) + Assert.Inconclusive("Times New Roman is not installed on this machine."); + + var disabled = ShapeText(typeface, "office affinity AVATAR", "-liga"); + var enabled = ShapeText(typeface, "office affinity AVATAR", "+liga"); + + if (disabled.SequenceEqual(enabled)) + Assert.Inconclusive("Times New Roman did not expose a deterministic liga shaping delta through HarfBuzzSharp."); + } + } + + [Test] + public void DrawShapedText_ProducesNonBlankComparisonBitmap() + { + using (var typeface = SKTypeface.FromFamilyName("Times New Roman")) + { + if (typeface == null) + Assert.Inconclusive("Times New Roman is not installed on this machine."); + + using (var bitmap = new SKBitmap(360, 90)) + using (var canvas = new SKCanvas(bitmap)) + using (var paint = new SKPaint { Typeface = typeface, TextSize = 40, IsAntialias = true, Color = SKColors.Black }) + using (var shaper = new SKShaper(typeface)) + using (var buffer = new Buffer()) + { + canvas.Clear(SKColors.White); + buffer.AddUtf8("office affinity AVATAR"); + buffer.GuessSegmentProperties(); + var shaped = shaper.Shape(buffer, paint); + Assert.That(shaped.Codepoints, Is.Not.Empty); + + canvas.DrawShapedText(shaper, "office affinity AVATAR", 12, 58, paint); + Assert.That(CountNonWhitePixels(bitmap), Is.GreaterThan(0)); + } + } + } + + private static int CountNonWhitePixels(SKBitmap bitmap) + { + return Enumerable.Range(0, bitmap.Height) + .Sum(y => Enumerable.Range(0, bitmap.Width) + .Count(x => bitmap.GetPixel(x, y) != SKColors.White)); + } + + private static uint[] ShapeText(SKTypeface typeface, string text, string feature) + { + var fontData = ReadTypefaceData(typeface); + var fontDataHandle = GCHandle.Alloc(fontData, GCHandleType.Pinned); + try + { + using (var blob = new Blob(fontDataHandle.AddrOfPinnedObject(), fontData.Length, MemoryMode.Duplicate)) + using (var face = new Face(blob, 0)) + using (var font = new HarfBuzzSharp.Font(face)) + using (var buffer = new Buffer()) + { + font.SetScale(40 * 64, 40 * 64); + buffer.AddUtf8(text); + buffer.GuessSegmentProperties(); + font.Shape(buffer, new[] { Feature.Parse(feature) }); + return buffer.GlyphInfos.Select(info => info.Codepoint).ToArray(); + } + } + finally + { + fontDataHandle.Free(); + } + } + + private static byte[] ReadTypefaceData(SKTypeface typeface) + { + int faceIndex; + using (var stream = typeface.OpenStream(out faceIndex)) + { + if (stream == null || !stream.HasLength) + Assert.Inconclusive("The selected typeface does not expose readable font data."); + + var data = new byte[checked((int)stream.Length)]; + var read = stream.Read(data, data.Length); + if (read != data.Length) + Assert.Inconclusive("The selected typeface could not be read completely."); + + return data; + } + } + } +} diff --git a/Src/Common/RenderVerification/RenderComparisonTests/RenderComparisonTests.csproj b/Src/Common/RenderVerification/RenderComparisonTests/RenderComparisonTests.csproj new file mode 100644 index 0000000000..efef3456cf --- /dev/null +++ b/Src/Common/RenderVerification/RenderComparisonTests/RenderComparisonTests.csproj @@ -0,0 +1,44 @@ + + + RenderComparisonTests + SIL.FieldWorks.Common.RenderVerification.RenderComparisonTests + net48 + Library + true + false + true + false + + + DEBUG;TRACE + true + false + portable + + + TRACE + true + true + portable + + + + + + + + + + + + + + + + + + + Properties\CommonAssemblyInfo.cs + + + diff --git a/Src/Common/SimpleRootSite/RenderEngineFactory.cs b/Src/Common/SimpleRootSite/RenderEngineFactory.cs index e473f0e09d..8180b22315 100644 --- a/Src/Common/SimpleRootSite/RenderEngineFactory.cs +++ b/Src/Common/SimpleRootSite/RenderEngineFactory.cs @@ -20,14 +20,14 @@ namespace SIL.FieldWorks.Common.RootSites /// public class RenderEngineFactory : DisposableBase, IRenderEngineFactory { - private readonly Dictionary, Tuple>> m_fontEngines; + private readonly Dictionary, Tuple>> m_fontEngines; /// /// Initializes a new instance of the class. /// public RenderEngineFactory() { - m_fontEngines = new Dictionary, Tuple>>(); + m_fontEngines = new Dictionary, Tuple>>(); } /// @@ -36,30 +36,37 @@ public RenderEngineFactory() /// Font name may be '<default font>' which produces a renderer suitable for the default /// font. /// - public IRenderEngine get_Renderer(ILgWritingSystem ws, IVwGraphics vg) + public IRenderEngine GetRenderer(ILgWritingSystem ws, IVwGraphics vg) { LgCharRenderProps chrp = vg.FontCharProperties; string fontName = MarshalEx.UShortToString(chrp.szFaceName); + bool usesDefaultFont = fontName == ""; if (fontName == "") { fontName = ws.DefaultFontName; MarshalEx.StringToUShort(fontName, chrp.szFaceName); vg.SetupGraphics(ref chrp); } - Dictionary, Tuple> wsFontEngines; + Dictionary, Tuple> wsFontEngines; if (!m_fontEngines.TryGetValue(ws, out wsFontEngines)) { - wsFontEngines = new Dictionary, Tuple>(); + wsFontEngines = new Dictionary, Tuple>(); m_fontEngines[ws] = wsFontEngines; } + string fontFeatures = GetFontFeatures(chrp, ws, usesDefaultFont); + if (chrp.szFontVar != null) + { + MarshalEx.StringToUShort(fontFeatures ?? string.Empty, chrp.szFontVar); + vg.SetupGraphics(ref chrp); + } var key = Tuple.Create(fontName, chrp.ttvBold == (int)FwTextToggleVal.kttvForceOn, - chrp.ttvItalic == (int)FwTextToggleVal.kttvForceOn); + chrp.ttvItalic == (int)FwTextToggleVal.kttvForceOn, fontFeatures); Tuple fontEngine; if (!wsFontEngines.TryGetValue(key, out fontEngine)) { // We don't have a font engine stored for this combination of font face with bold and italic // so we will create the engine for it here - wsFontEngines[key] = GetRenderingEngine(fontName, vg, ws); + wsFontEngines[key] = GetRenderingEngine(fontName, fontFeatures, vg, ws); } else if (fontEngine.Item1 == ws.IsGraphiteEnabled) { @@ -72,24 +79,39 @@ public IRenderEngine get_Renderer(ILgWritingSystem ws, IVwGraphics vg) // Destroy all the engines associated with this ws and create one for this key. ReleaseRenderEngines(wsFontEngines.Values); wsFontEngines.Clear(); - var renderingEngine = GetRenderingEngine(fontName, vg, ws); + var renderingEngine = GetRenderingEngine(fontName, fontFeatures, vg, ws); wsFontEngines[key] = renderingEngine; } return wsFontEngines[key].Item2; } - private Tuple GetRenderingEngine(string fontName, IVwGraphics vg, ILgWritingSystem ws) + IRenderEngine IRenderEngineFactory.get_Renderer(ILgWritingSystem ws, IVwGraphics vg) + { + return GetRenderer(ws, vg); + } + + private static string GetFontFeatures(LgCharRenderProps chrp, ILgWritingSystem ws, bool usesDefaultFont) + { + string charRenderFeatures = chrp.szFontVar == null + ? string.Empty + : FontFeatureSettings.NormalizePreservingLegacy(MarshalEx.UShortToString(chrp.szFontVar)); + if (!string.IsNullOrEmpty(charRenderFeatures)) + return charRenderFeatures; + + if (usesDefaultFont) + return FontFeatureSettings.NormalizePreservingLegacy(ws.DefaultFontFeatures); + return string.Empty; + } + + private Tuple GetRenderingEngine(string fontName, string fontFeatures, IVwGraphics vg, ILgWritingSystem ws) { // NB: Even if the ws claims graphite is enabled, this might not be a graphite font if (ws.IsGraphiteEnabled) { var graphiteEngine = GraphiteEngineClass.Create(); - string fontFeatures = null; - if (fontName == ws.DefaultFontName) - fontFeatures = GraphiteFontFeatures.ConvertFontFeatureCodesToIds(ws.DefaultFontFeatures); - graphiteEngine.InitRenderer(vg, fontFeatures); + graphiteEngine.InitRenderer(vg, GraphiteFontFeatures.ConvertFontFeatureCodesToIds(fontFeatures)); // check if the font is a valid Graphite font if (graphiteEngine.FontIsValid) { @@ -100,14 +122,14 @@ private Tuple GetRenderingEngine(string fontName, IVwGraphi // It wasn't really a graphite font - release the graphite one and create a Uniscribe below Marshal.ReleaseComObject(graphiteEngine); } - return new Tuple(ws.IsGraphiteEnabled, GetUniscribeEngine(vg, ws)); + return new Tuple(ws.IsGraphiteEnabled, GetUniscribeEngine(vg, ws, fontFeatures)); } - private IRenderEngine GetUniscribeEngine(IVwGraphics vg, ILgWritingSystem ws) + private IRenderEngine GetUniscribeEngine(IVwGraphics vg, ILgWritingSystem ws, string fontFeatures) { IRenderEngine uniscribeEngine; uniscribeEngine = UniscribeEngineClass.Create(); - uniscribeEngine.InitRenderer(vg, null); + uniscribeEngine.InitRenderer(vg, fontFeatures); uniscribeEngine.RenderEngineFactory = this; uniscribeEngine.WritingSystemFactory = ws.WritingSystemFactory; @@ -119,7 +141,7 @@ private IRenderEngine GetUniscribeEngine(IVwGraphics vg, ILgWritingSystem ws) /// public void ClearRenderEngines() { - foreach (Dictionary, Tuple> wsGraphiteEngines in m_fontEngines.Values) + foreach (Dictionary, Tuple> wsGraphiteEngines in m_fontEngines.Values) ReleaseRenderEngines(wsGraphiteEngines.Values); m_fontEngines.Clear(); } @@ -129,7 +151,7 @@ public void ClearRenderEngines() /// public void ClearRenderEngines(ILgWritingSystemFactory wsf) { - foreach (KeyValuePair, Tuple>> kvp in m_fontEngines + foreach (KeyValuePair, Tuple>> kvp in m_fontEngines .Where(kvp => kvp.Key.WritingSystemFactory == wsf).ToArray()) { ReleaseRenderEngines(kvp.Value.Values); diff --git a/Src/Common/SimpleRootSite/SimpleRootSiteTests/RenderEngineFactoryTests.cs b/Src/Common/SimpleRootSite/SimpleRootSiteTests/RenderEngineFactoryTests.cs index 817f106190..2b08c3f079 100644 --- a/Src/Common/SimpleRootSite/SimpleRootSiteTests/RenderEngineFactoryTests.cs +++ b/Src/Common/SimpleRootSite/SimpleRootSiteTests/RenderEngineFactoryTests.cs @@ -4,11 +4,12 @@ using System.Windows.Forms; using NUnit.Framework; -using SIL.LCModel.Core.WritingSystems; -using SIL.LCModel.Core.KernelInterfaces; using SIL.FieldWorks.Common.FwUtils; using SIL.FieldWorks.Common.ViewsInterfaces; +using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.Core.WritingSystems; using SIL.LCModel.Utils; +using SIL.WritingSystems; namespace SIL.FieldWorks.Common.RootSites.SimpleRootSiteTests { @@ -19,10 +20,10 @@ namespace SIL.FieldWorks.Common.RootSites.SimpleRootSiteTests public class RenderEngineFactoryTests { /// - /// Tests the get_RendererFromChrp method with a normal font. + /// Tests the GetRenderer method with a normal font. /// [Test] - public void get_Renderer_Uniscribe() + public void GetRenderer_Uniscribe() { using (var control = new Form()) using (var gm = new GraphicsManager(control)) @@ -33,10 +34,14 @@ public void get_Renderer_Uniscribe() { var wsManager = new WritingSystemManager(); CoreWritingSystemDefinition ws = wsManager.Set("en-US"); - var chrp = new LgCharRenderProps { ws = ws.Handle, szFaceName = new ushort[32] }; + var chrp = new LgCharRenderProps + { + ws = ws.Handle, + szFaceName = new ushort[32], + }; MarshalEx.StringToUShort("Arial", chrp.szFaceName); gm.VwGraphics.SetupGraphics(ref chrp); - IRenderEngine engine = reFactory.get_Renderer(ws, gm.VwGraphics); + IRenderEngine engine = reFactory.GetRenderer(ws, gm.VwGraphics); Assert.That(engine, Is.Not.Null); Assert.That(engine.WritingSystemFactory, Is.SameAs(wsManager)); Assert.That(engine, Is.InstanceOf(typeof(UniscribeEngine))); @@ -50,10 +55,10 @@ public void get_Renderer_Uniscribe() } /// - /// Tests the get_RendererFromChrp method with a Graphite font. + /// Tests the GetRenderer method with a Graphite font. /// [Test] - public void get_Renderer_Graphite() + public void GetRenderer_Graphite() { using (var control = new Form()) using (var gm = new GraphicsManager(control)) @@ -65,17 +70,21 @@ public void get_Renderer_Graphite() var wsManager = new WritingSystemManager(); // by default Graphite is disabled CoreWritingSystemDefinition ws = wsManager.Set("en-US"); - var chrp = new LgCharRenderProps { ws = ws.Handle, szFaceName = new ushort[32] }; + var chrp = new LgCharRenderProps + { + ws = ws.Handle, + szFaceName = new ushort[32], + }; MarshalEx.StringToUShort("Charis SIL", chrp.szFaceName); gm.VwGraphics.SetupGraphics(ref chrp); - IRenderEngine engine = reFactory.get_Renderer(ws, gm.VwGraphics); + IRenderEngine engine = reFactory.GetRenderer(ws, gm.VwGraphics); Assert.That(engine, Is.Not.Null); Assert.That(engine.WritingSystemFactory, Is.SameAs(wsManager)); Assert.That(engine, Is.InstanceOf(typeof(UniscribeEngine))); ws.IsGraphiteEnabled = true; gm.VwGraphics.SetupGraphics(ref chrp); - engine = reFactory.get_Renderer(ws, gm.VwGraphics); + engine = reFactory.GetRenderer(ws, gm.VwGraphics); Assert.That(engine, Is.Not.Null); Assert.That(engine.WritingSystemFactory, Is.SameAs(wsManager)); Assert.That(engine, Is.InstanceOf(typeof(GraphiteEngine))); @@ -87,5 +96,197 @@ public void get_Renderer_Graphite() } } } + + [Test] + public void GetRenderer_DefaultFontFeatures_CopiesNormalizedFeaturesToGraphics() + { + using (var control = new Form()) + using (var gm = new GraphicsManager(control)) + using (var reFactory = new RenderEngineFactory()) + { + gm.Init(1.0f); + try + { + var wsManager = new WritingSystemManager(); + CoreWritingSystemDefinition ws = wsManager.Set("en-US"); + ws.DefaultFont = new FontDefinition("Arial") { Features = " smcp = 1, kern=0 " }; + + var chrp = CreateCharRenderProps(ws.Handle, "", string.Empty); + gm.VwGraphics.SetupGraphics(ref chrp); + + IRenderEngine engine = reFactory.GetRenderer(ws, gm.VwGraphics); + var graphicsChrp = gm.VwGraphics.FontCharProperties; + + Assert.That(engine, Is.InstanceOf(typeof(UniscribeEngine))); + Assert.That( + MarshalEx.UShortToString(graphicsChrp.szFaceName), + Is.EqualTo("Arial")); + Assert.That( + MarshalEx.UShortToString(graphicsChrp.szFontVar), + Is.EqualTo("kern=0,smcp=1")); + wsManager.Save(); + } + finally + { + gm.Uninit(); + } + } + } + + [Test] + public void GetRenderer_DefaultFontWithStyleFeatures_PreservesStyleFeatures() + { + using (var control = new Form()) + using (var gm = new GraphicsManager(control)) + using (var reFactory = new RenderEngineFactory()) + { + gm.Init(1.0f); + try + { + var wsManager = new WritingSystemManager(); + CoreWritingSystemDefinition ws = wsManager.Set("en-US"); + ws.DefaultFont = new FontDefinition("Arial") { Features = string.Empty }; + + var chrp = CreateCharRenderProps(ws.Handle, "", " smcp = 1, kern=0 "); + gm.VwGraphics.SetupGraphics(ref chrp); + + IRenderEngine engine = reFactory.GetRenderer(ws, gm.VwGraphics); + var graphicsChrp = gm.VwGraphics.FontCharProperties; + + Assert.That(engine, Is.InstanceOf(typeof(UniscribeEngine))); + Assert.That( + MarshalEx.UShortToString(graphicsChrp.szFaceName), + Is.EqualTo("Arial")); + Assert.That( + MarshalEx.UShortToString(graphicsChrp.szFontVar), + Is.EqualTo("kern=0,smcp=1")); + wsManager.Save(); + } + finally + { + gm.Uninit(); + } + } + } + + [Test] + public void GetRenderer_OpenTypeFeatures_ArePartOfCacheIdentity() + { + using (var control = new Form()) + using (var gm = new GraphicsManager(control)) + using (var reFactory = new RenderEngineFactory()) + { + gm.Init(1.0f); + try + { + var wsManager = new WritingSystemManager(); + CoreWritingSystemDefinition ws = wsManager.Set("en-US"); + + var firstChrp = CreateCharRenderProps( + ws.Handle, + "Arial", + " smcp = 1, kern=0 "); + gm.VwGraphics.SetupGraphics(ref firstChrp); + IRenderEngine first = reFactory.GetRenderer(ws, gm.VwGraphics); + + var equivalentChrp = CreateCharRenderProps( + ws.Handle, + "Arial", + "kern=0,smcp=1"); + gm.VwGraphics.SetupGraphics(ref equivalentChrp); + IRenderEngine equivalent = reFactory.GetRenderer(ws, gm.VwGraphics); + + var differentChrp = CreateCharRenderProps( + ws.Handle, + "Arial", + "smcp=0,kern=0"); + gm.VwGraphics.SetupGraphics(ref differentChrp); + IRenderEngine different = reFactory.GetRenderer(ws, gm.VwGraphics); + + Assert.That(equivalent, Is.SameAs(first)); + Assert.That(different, Is.Not.SameAs(first)); + Assert.That( + MarshalEx.UShortToString(gm.VwGraphics.FontCharProperties.szFontVar), + Is.EqualTo("kern=0,smcp=0")); + wsManager.Save(); + } + finally + { + gm.Uninit(); + } + } + } + + [Test] + public void GetRenderer_ExplicitFontNameMatchingDefault_DoesNotApplyDefaultFontFeatures() + { + using (var control = new Form()) + using (var gm = new GraphicsManager(control)) + using (var reFactory = new RenderEngineFactory()) + { + gm.Init(1.0f); + try + { + var wsManager = new WritingSystemManager(); + CoreWritingSystemDefinition ws = wsManager.Set("en-US"); + ws.DefaultFont = new FontDefinition("Arial") { Features = "smcp=1" }; + + var chrp = CreateCharRenderProps(ws.Handle, "Arial", string.Empty); + gm.VwGraphics.SetupGraphics(ref chrp); + + reFactory.GetRenderer(ws, gm.VwGraphics); + Assert.That(MarshalEx.UShortToString(gm.VwGraphics.FontCharProperties.szFontVar), Is.EqualTo(string.Empty)); + wsManager.Save(); + } + finally + { + gm.Uninit(); + } + } + } + + [Test] + public void GetRenderer_DefaultNumericGraphiteFeatures_ArePreserved() + { + using (var control = new Form()) + using (var gm = new GraphicsManager(control)) + using (var reFactory = new RenderEngineFactory()) + { + gm.Init(1.0f); + try + { + var wsManager = new WritingSystemManager(); + CoreWritingSystemDefinition ws = wsManager.Set("en-US"); + ws.DefaultFont = new FontDefinition("Arial") { Features = "123=1,456=2" }; + + var chrp = CreateCharRenderProps(ws.Handle, "", string.Empty); + gm.VwGraphics.SetupGraphics(ref chrp); + + reFactory.GetRenderer(ws, gm.VwGraphics); + Assert.That(MarshalEx.UShortToString(gm.VwGraphics.FontCharProperties.szFontVar), Is.EqualTo("123=1,456=2")); + wsManager.Save(); + } + finally + { + gm.Uninit(); + } + } + } + + private static LgCharRenderProps CreateCharRenderProps( + int ws, + string fontName, + string fontFeatures) + { + var chrp = new LgCharRenderProps + { + ws = ws, + szFaceName = new ushort[32], + szFontVar = new ushort[128], + }; + MarshalEx.StringToUShort(fontName, chrp.szFaceName); + MarshalEx.StringToUShort(fontFeatures, chrp.szFontVar); + return chrp; + } } } diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/DefaultFontsControl.cs b/Src/FwCoreDlgs/FwCoreDlgControls/DefaultFontsControl.cs index 239bb66017..08944e51c1 100644 --- a/Src/FwCoreDlgs/FwCoreDlgControls/DefaultFontsControl.cs +++ b/Src/FwCoreDlgs/FwCoreDlgControls/DefaultFontsControl.cs @@ -242,16 +242,17 @@ protected void SetSelectedFonts() // setup controls for default font SetFontInCombo(m_defaultFontComboBox, m_ws.DefaultFontName); m_defaultFontFeaturesButton.WritingSystemFactory = m_ws.WritingSystemFactory; - m_defaultFontFeaturesButton.FontName = m_defaultFontComboBox.Text; - m_defaultFontFeaturesButton.FontFeatures = m_ws.DefaultFontFeatures; + m_defaultFontFeaturesButton.RefreshFeatureContext( + m_defaultFontComboBox.Text, + m_ws.DefaultFontFeatures, + m_ws.IsGraphiteEnabled); bool isGraphiteFont = m_defaultFontFeaturesButton.IsGraphiteFont; - m_graphiteGroupBox.Enabled = isGraphiteFont; + m_graphiteGroupBox.Enabled = isGraphiteFont || m_defaultFontFeaturesButton.HasFontFeatures; + m_enableGraphiteCheckBox.Enabled = isGraphiteFont; if (!isGraphiteFont) m_ws.IsGraphiteEnabled = false; m_enableGraphiteCheckBox.Checked = m_ws.IsGraphiteEnabled; - if (!m_ws.IsGraphiteEnabled) - m_defaultFontFeaturesButton.Enabled = false; } /// @@ -303,15 +304,17 @@ private void m_defaultFontComboBox_SelectedIndexChanged(object sender, EventArgs if (m_ws.DefaultFont != null) { - m_defaultFontFeaturesButton.FontName = m_defaultFontComboBox.Text; - m_defaultFontFeaturesButton.FontFeatures = m_ws.DefaultFont.Features; + m_defaultFontFeaturesButton.RefreshFeatureContext( + m_defaultFontComboBox.Text, + m_ws.DefaultFont.Features, + false); } bool isGraphiteFont = m_defaultFontFeaturesButton.IsGraphiteFont; - m_graphiteGroupBox.Enabled = isGraphiteFont; + m_graphiteGroupBox.Enabled = isGraphiteFont || m_defaultFontFeaturesButton.HasFontFeatures; + m_enableGraphiteCheckBox.Enabled = isGraphiteFont; m_ws.IsGraphiteEnabled = false; m_enableGraphiteCheckBox.Checked = false; - m_defaultFontFeaturesButton.Enabled = false; } } @@ -334,10 +337,10 @@ private void m_enableGraphiteCheckBox_Click(object sender, EventArgs e) if (m_ws == null) return; m_ws.IsGraphiteEnabled = m_enableGraphiteCheckBox.Checked; - if (m_ws.IsGraphiteEnabled) - m_defaultFontFeaturesButton.SetupFontFeatures(); - else - m_defaultFontFeaturesButton.Enabled = false; + m_defaultFontFeaturesButton.RefreshFeatureContext( + m_defaultFontComboBox.Text, + m_defaultFontFeaturesButton.FontFeatures, + m_ws.IsGraphiteEnabled); } #endregion diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/DefaultFontsControl.resx b/Src/FwCoreDlgs/FwCoreDlgControls/DefaultFontsControl.resx index 663352e6ce..358fda9297 100644 --- a/Src/FwCoreDlgs/FwCoreDlgControls/DefaultFontsControl.resx +++ b/Src/FwCoreDlgs/FwCoreDlgControls/DefaultFontsControl.resx @@ -193,7 +193,7 @@ False - Allows user to specify font features, when available in Graphite fonts. + Allows user to specify font features when available in the selected font. @@ -275,7 +275,7 @@ 4 - Graphite Font Options + Font Options m_graphiteGroupBox @@ -310,4 +310,4 @@ System.Windows.Forms.UserControl, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - \ No newline at end of file + diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/FontFeaturesButton.cs b/Src/FwCoreDlgs/FwCoreDlgControls/FontFeaturesButton.cs index 15a3b31625..4064bd33c2 100644 --- a/Src/FwCoreDlgs/FwCoreDlgControls/FontFeaturesButton.cs +++ b/Src/FwCoreDlgs/FwCoreDlgControls/FontFeaturesButton.cs @@ -3,9 +3,12 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) using System; +using System.Collections.Generic; using System.Diagnostics; using System.Drawing; +using System.Globalization; using System.Linq; +using System.Runtime.InteropServices; using System.Windows.Forms; using SIL.LCModel.Core.KernelInterfaces; using SIL.FieldWorks.Common.FwUtils; @@ -36,11 +39,15 @@ public class FontFeaturesButton : Button private System.ComponentModel.IContainer components = null; private string m_fontName; // The font for which we are editing the features. private string m_fontFeatures; // The font feature string stored in the writing system. - private IRenderingFeatures m_featureEngine; + private IFontFeatureProvider m_featureProvider; + private static readonly TraceSwitch s_openTypeTraceSwitch = + new TraceSwitch("FontFeatures.OpenType", "OpenType font feature discovery and provider selection", "Off"); private ILgWritingSystemFactory m_wsf; private int[] m_values; // The actual list of values we're editing. private int[] m_ids; // The corresponding ids. private bool m_isGraphiteFont; + private bool m_hasFontFeatures; + private bool m_useGraphiteFeatures; #endregion #region Constructor and dispose stuff @@ -110,6 +117,11 @@ private class HoldDummyGraphics: IDisposable /// private IntPtr m_hdc; + public IntPtr Hdc + { + get { return m_hdc; } + } + /// -------------------------------------------------------------------------------- /// /// Initializes a new instance of the class. @@ -279,7 +291,52 @@ public string FontFeatures set { CheckDisposed(); - m_fontFeatures = value; + m_fontFeatures = FontFeatureSettings.NormalizePreservingLegacy(value); + } + } + + /// + /// Updates the current font feature context and refreshes discovery exactly once. + /// + internal void RefreshFeatureContext(string fontName, string fontFeatures, bool useGraphiteFeatures) + { + CheckDisposed(); + m_fontName = fontName; + m_fontFeatures = FontFeatureSettings.NormalizePreservingLegacy(fontFeatures); + m_useGraphiteFeatures = useGraphiteFeatures; + SetupFontFeatures(); + } + + /// + /// Gets or sets a value indicating whether Graphite feature discovery should be preferred + /// when the current font supports both Graphite and OpenType features. + /// + public bool UseGraphiteFeatures + { + get + { + CheckDisposed(); + return m_useGraphiteFeatures; + } + set + { + CheckDisposed(); + if (m_useGraphiteFeatures == value) + return; + m_useGraphiteFeatures = value; + SetupFontFeatures(); + } + } + + /// + /// Gets a value indicating whether the current font has configurable features. + /// + public bool HasFontFeatures + { + get + { + CheckDisposed(); + return m_hasFontFeatures; } } @@ -307,6 +364,8 @@ public bool IsGraphiteFont public void SetupFontFeatures() { CheckDisposed(); + m_featureProvider = null; + m_hasFontFeatures = false; if (string.IsNullOrEmpty(m_fontName)) { @@ -317,49 +376,56 @@ public void SetupFontFeatures() using (var hdg = new HoldDummyGraphics(m_fontName, false, false, this)) { - IRenderEngine renderer = GraphiteEngineClass.Create(); - renderer.InitRenderer(hdg.m_vwGraphics, m_fontFeatures); - // check if the font is a valid Graphite font - if (!renderer.FontIsValid) - { - m_isGraphiteFont = false; - Enabled = false; - return; - } - renderer.WritingSystemFactory = m_wsf; - m_isGraphiteFont = true; - m_featureEngine = renderer as IRenderingFeatures; - if (m_featureEngine == null) - { - Enabled = false; + var graphiteProvider = CreateGraphiteProvider(hdg); + m_isGraphiteFont = graphiteProvider != null; + var openTypeProvider = OpenTypeFontFeatureProvider.Create(hdg.Hdc); + + var primaryProvider = m_useGraphiteFeatures + ? (IFontFeatureProvider)graphiteProvider + : openTypeProvider; + var secondaryProvider = m_useGraphiteFeatures + ? openTypeProvider + : (IFontFeatureProvider)graphiteProvider; + + if (TrySelectFeatureProvider(primaryProvider)) return; - } - int cfid; - m_featureEngine.GetFeatureIDs(0, null, out cfid); - if (cfid == 0) - { - Enabled = false; + + if (TrySelectFeatureProvider(secondaryProvider)) return; - } - if (cfid == 1) - { - // What if it's the dummy built-in graphite feature that we ignore? - // Get the list of features (only 1). - using (ArrayPtr idsM = MarshalEx.ArrayToNative(cfid)) - { - m_featureEngine.GetFeatureIDs(cfid, idsM, out cfid); - int [] ids = MarshalEx.NativeToArray(idsM, cfid); - if (ids[0] == kGrLangFeature) - { - Enabled = false; - return; - } - } - } - Enabled = true; + + Enabled = false; } } + private bool TrySelectFeatureProvider(IFontFeatureProvider provider) + { + if (provider == null || !provider.HasFeatures) + return false; + + m_featureProvider = provider; + m_hasFontFeatures = true; + Enabled = true; + Trace.WriteLineIf(s_openTypeTraceSwitch.TraceInfo, + string.Format(CultureInfo.InvariantCulture, + "FontFeaturesButton selected {0} provider for '{1}'.", + provider.ProviderName, + m_fontName ?? string.Empty), + s_openTypeTraceSwitch.DisplayName); + return true; + } + + private IFontFeatureProvider CreateGraphiteProvider(HoldDummyGraphics hdg) + { + IRenderEngine renderer = GraphiteEngineClass.Create(); + renderer.InitRenderer(hdg.m_vwGraphics, GraphiteFontFeatures.ConvertFontFeatureCodesToIds(m_fontFeatures)); + if (!renderer.FontIsValid) + return null; + + renderer.WritingSystemFactory = m_wsf; + var featureEngine = renderer as IRenderingFeatures; + return featureEngine == null ? null : new GraphiteFontFeatureProvider(featureEngine); + } + /// ------------------------------------------------------------------------------------ /// /// Parse a feature string to find the next feature id value, skipping any leading @@ -645,18 +711,15 @@ internal int FeatureIndex /// ------------------------------------------------------------------------------------ protected override void OnClick(EventArgs e) { - var menu = components.ContextMenu("ContextMenu"); - int cfid; - m_featureEngine.GetFeatureIDs(0, null, out cfid); + if (m_featureProvider == null) + return; - // Get the list of features. - using (ArrayPtr idsM = MarshalEx.ArrayToNative(cfid)) - { - m_featureEngine.GetFeatureIDs(cfid, idsM, out cfid); - m_ids = MarshalEx.NativeToArray(idsM, cfid); - } - m_fontFeatures = GraphiteFontFeatures.ConvertFontFeatureCodesToIds(m_fontFeatures); - m_values = ParseFeatureString(m_ids, m_fontFeatures); + var menu = components.ContextMenu("ContextMenu"); + m_ids = m_featureProvider.GetFeatureIds(); + var parserFeatureString = m_featureProvider is OpenTypeFontFeatureProvider + ? ConvertRendererNeutralFeatureStringToIds(m_fontFeatures) + : GraphiteFontFeatures.ConvertFontFeatureCodesToIds(m_fontFeatures); + m_values = ParseFeatureString(m_ids, parserFeatureString); Debug.Assert(m_ids.Length == m_values.Length); for (int ifeat = 0; ifeat < m_ids.Length; ++ifeat) @@ -665,21 +728,16 @@ protected override void OnClick(EventArgs e) if (id == kGrLangFeature) continue; // Don't show Graphite built-in 'lang' feature. string label; - m_featureEngine.GetFeatureLabel(id, kUiCodePage, out label); + label = m_featureProvider.GetFeatureLabel(id, kUiCodePage); if (label.Length == 0) { //Create backup default string, ie, "Feature #1". - label = string.Format(FwCoreDlgControls.kstidFeature, id); + label = string.Format(FwCoreDlgControls.kstidFeature, m_featureProvider.GetFeatureTag(id)); } int cValueIds; int nDefault; int [] valueIds; - using (ArrayPtr valueIdsM = MarshalEx.ArrayToNative(kMaxValPerFeat)) - { - m_featureEngine.GetFeatureValues(id, kMaxValPerFeat, valueIdsM, - out cValueIds, out nDefault); - valueIds = MarshalEx.NativeToArray(valueIdsM, cValueIds); - } + valueIds = m_featureProvider.GetFeatureValues(id, kMaxValPerFeat, out cValueIds, out nDefault); // If we know a value for this feature, use it. Otherwise init to default. int featureValue = nDefault; if (m_values[ifeat] != Int32.MaxValue) @@ -695,9 +753,9 @@ protected override void OnClick(EventArgs e) // ids of 0 and 1. We further require that the actual values belong to a // natural boolean set. string valueLabelT; // Label corresponding to 'true' etc, the checked value - m_featureEngine.GetFeatureValueLabel(id, 1, kUiCodePage, out valueLabelT); + valueLabelT = m_featureProvider.GetFeatureValueLabel(id, 1, kUiCodePage); string valueLabelF; // Label corresponding to 'false' etc, the unchecked val. - m_featureEngine.GetFeatureValueLabel(id, 0, kUiCodePage, out valueLabelF); + valueLabelF = m_featureProvider.GetFeatureValueLabel(id, 0, kUiCodePage); // Enhance: these should be based on a resource, or something that depends // on the code page, if the code page is ever not constant. @@ -733,8 +791,7 @@ protected override void OnClick(EventArgs e) for (int ival = 0; ival < valueIds.Length; ++ival) { string valueLabel; - m_featureEngine.GetFeatureValueLabel(id, valueIds[ival], - kUiCodePage, out valueLabel); + valueLabel = m_featureProvider.GetFeatureValueLabel(id, valueIds[ival], kUiCodePage); if (valueLabel.Length == 0) { // Create backup default string. @@ -805,5 +862,448 @@ private void ItemClickHandler(Object sender, EventArgs e) m_fontFeatures = GenerateFeatureString(m_ids, m_values); OnFontFeatureSelected(new EventArgs()); } + + private interface IFontFeatureProvider + { + string ProviderName { get; } + bool HasFeatures { get; } + int[] GetFeatureIds(); + string GetFeatureTag(int featureId); + string GetFeatureLabel(int featureId, int languageId); + int[] GetFeatureValues(int featureId, int maxValues, out int valueCount, out int defaultValue); + string GetFeatureValueLabel(int featureId, int valueId, int languageId); + } + + private sealed class GraphiteFontFeatureProvider : IFontFeatureProvider + { + private readonly IRenderingFeatures m_featureEngine; + private readonly int[] m_featureIds; + + public GraphiteFontFeatureProvider(IRenderingFeatures featureEngine) + { + m_featureEngine = featureEngine; + int featureCount; + m_featureEngine.GetFeatureIDs(0, null, out featureCount); + if (featureCount == 0) + { + m_featureIds = Array.Empty(); + return; + } + using (ArrayPtr idsM = MarshalEx.ArrayToNative(featureCount)) + { + m_featureEngine.GetFeatureIDs(featureCount, idsM, out featureCount); + m_featureIds = MarshalEx.NativeToArray(idsM, featureCount) + .Where(featureId => featureId != kGrLangFeature).ToArray(); + } + } + + public bool HasFeatures + { + get { return m_featureIds.Length > 0; } + } + + public string ProviderName + { + get { return "Graphite"; } + } + + public int[] GetFeatureIds() + { + return m_featureIds.ToArray(); + } + + public string GetFeatureTag(int featureId) + { + return ConvertFontFeatureIdToCode(featureId); + } + + public string GetFeatureLabel(int featureId, int languageId) + { + string label; + m_featureEngine.GetFeatureLabel(featureId, languageId, out label); + return label; + } + + public int[] GetFeatureValues(int featureId, int maxValues, out int valueCount, out int defaultValue) + { + using (ArrayPtr valueIdsM = MarshalEx.ArrayToNative(maxValues)) + { + m_featureEngine.GetFeatureValues(featureId, maxValues, valueIdsM, out valueCount, out defaultValue); + return MarshalEx.NativeToArray(valueIdsM, valueCount); + } + } + + public string GetFeatureValueLabel(int featureId, int valueId, int languageId) + { + string label; + m_featureEngine.GetFeatureValueLabel(featureId, valueId, languageId, out label); + return label; + } + } + + private sealed class OpenTypeFontFeatureProvider : IFontFeatureProvider + { + private static readonly Dictionary s_featureLabelResourceIds = new Dictionary + { + { "aalt", "kstidOpenTypeFeature_aalt" }, + { "c2sc", "kstidOpenTypeFeature_c2sc" }, + { "calt", "kstidOpenTypeFeature_calt" }, + { "case", "kstidOpenTypeFeature_case" }, + { "ccmp", "kstidOpenTypeFeature_ccmp" }, + { "clig", "kstidOpenTypeFeature_clig" }, + { "dlig", "kstidOpenTypeFeature_dlig" }, + { "frac", "kstidOpenTypeFeature_frac" }, + { "kern", "kstidOpenTypeFeature_kern" }, + { "liga", "kstidOpenTypeFeature_liga" }, + { "lnum", "kstidOpenTypeFeature_lnum" }, + { "onum", "kstidOpenTypeFeature_onum" }, + { "pnum", "kstidOpenTypeFeature_pnum" }, + { "salt", "kstidOpenTypeFeature_salt" }, + { "smcp", "kstidOpenTypeFeature_smcp" }, + { "ss01", "kstidOpenTypeFeature_ss01" }, + { "ss02", "kstidOpenTypeFeature_ss02" }, + { "ss03", "kstidOpenTypeFeature_ss03" }, + { "ss04", "kstidOpenTypeFeature_ss04" }, + { "ss05", "kstidOpenTypeFeature_ss05" }, + { "tnum", "kstidOpenTypeFeature_tnum" }, + }; + + private readonly int[] m_featureIds; + + private OpenTypeFontFeatureProvider(IEnumerable tags) + { + m_featureIds = tags.Select(ConvertFontFeatureCodeToId).Distinct().OrderBy(featureId => GetFeatureTag(featureId), StringComparer.Ordinal).ToArray(); + } + + public static OpenTypeFontFeatureProvider Create(IntPtr hdc) + { + var tags = OpenTypeFontFeatureReader.GetFeatureTags(hdc); + return tags.Count == 0 ? null : new OpenTypeFontFeatureProvider(tags); + } + + public bool HasFeatures + { + get { return m_featureIds.Length > 0; } + } + + public string ProviderName + { + get { return "OpenType"; } + } + + public int[] GetFeatureIds() + { + return m_featureIds.ToArray(); + } + + public string GetFeatureTag(int featureId) + { + return ConvertFontFeatureIdToCode(featureId); + } + + public string GetFeatureLabel(int featureId, int languageId) + { + var tag = GetFeatureTag(featureId); + string resourceId; + if (s_featureLabelResourceIds.TryGetValue(tag, out resourceId)) + { + var label = FwCoreDlgControls.ResourceManager.GetString(resourceId, CultureInfo.CurrentUICulture); + if (!string.IsNullOrEmpty(label)) + return label; + } + return tag; + } + + public int[] GetFeatureValues(int featureId, int maxValues, out int valueCount, out int defaultValue) + { + defaultValue = 0; + valueCount = 2; + return new[] { 0, 1 }; + } + + public string GetFeatureValueLabel(int featureId, int valueId, int languageId) + { + return valueId == 0 + ? FwCoreDlgControls.ResourceManager.GetString("kstidOpenTypeFeatureValueOff", CultureInfo.CurrentUICulture) + : FwCoreDlgControls.ResourceManager.GetString("kstidOpenTypeFeatureValueOn", CultureInfo.CurrentUICulture); + } + } + + private static int ConvertFontFeatureCodeToId(string fontFeature) + { + fontFeature = new string(fontFeature.ToCharArray().Reverse().ToArray()); + byte[] numbers = fontFeature.Select(Convert.ToByte).ToArray(); + return BitConverter.ToInt32(numbers, 0); + } + + internal static string ConvertRendererNeutralFeatureStringToIds(string fontFeatures) + { + return string.Join(",", FontFeatureSettings.Parse(fontFeatures) + .Select(setting => string.Format(CultureInfo.InvariantCulture, "{0}={1}", + ConvertFontFeatureCodeToId(setting.Tag), + setting.Value))); + } + + internal static class OpenTypeFontFeatureReader + { + private const uint GdiError = 0xFFFFFFFF; + private const int MaxCacheEntries = 32; + private const int ObjFont = 6; + private static readonly HashSet s_nonUserConfigurableTags = + new HashSet(StringComparer.Ordinal) + { + "abvf", "abvm", "abvs", "akhn", "blwf", "blwm", "blws", "ccmp", + "cjct", "curs", "dist", "fina", "haln", "half", "init", "isol", + "ljmo", "locl", "mark", "medi", "mkmk", "nukt", "pref", "pres", + "pstf", "psts", "rclt", "rkrf", "rlig", "tjmo", "vjmo" + }; + private static readonly uint[] s_layoutTables = { MakeTableTag("GSUB"), MakeTableTag("GPOS") }; + private static readonly object s_cacheLock = new object(); + private static readonly Dictionary s_featureTagCache = + new Dictionary(); + private static readonly Queue s_cacheOrder = new Queue(); + private static Func s_tableReader = ReadTable; + + [DllImport("gdi32.dll", SetLastError = true)] + private static extern uint GetFontData(IntPtr hdc, uint table, uint offset, byte[] buffer, uint length); + + [DllImport("gdi32.dll")] + private static extern IntPtr GetCurrentObject(IntPtr hdc, int objectType); + + [DllImport("gdi32.dll", CharSet = CharSet.Unicode)] + private static extern int GetObject(IntPtr hObject, int size, ref LogFont logFont); + + public static IReadOnlyList GetFeatureTags(IntPtr hdc) + { + var cacheKey = FontFeatureCacheKey.FromHdc(hdc); + lock (s_cacheLock) + { + string[] cachedTags; + if (s_featureTagCache.TryGetValue(cacheKey, out cachedTags)) + return cachedTags.ToArray(); + } + + var tags = new SortedSet(StringComparer.Ordinal); + foreach (var table in s_layoutTables) + { + var tableData = s_tableReader(hdc, table); + if (tableData != null) + ReadFeatureList(tableData, tags); + } + var discoveredTags = tags.ToArray(); + lock (s_cacheLock) + { + if (!s_featureTagCache.ContainsKey(cacheKey)) + { + if (s_cacheOrder.Count >= MaxCacheEntries) + s_featureTagCache.Remove(s_cacheOrder.Dequeue()); + s_featureTagCache[cacheKey] = discoveredTags; + s_cacheOrder.Enqueue(cacheKey); + } + } + return discoveredTags.ToArray(); + } + + internal static void ClearCacheForTests() + { + lock (s_cacheLock) + { + ClearCache(); + } + } + + internal static IDisposable UseTableReaderForTests(Func tableReader) + { + lock (s_cacheLock) + { + var previousReader = s_tableReader; + s_tableReader = tableReader ?? ReadTable; + ClearCache(); + return new DisposableAction(() => + { + lock (s_cacheLock) + { + s_tableReader = previousReader; + ClearCache(); + } + }); + } + } + + private static void ClearCache() + { + s_featureTagCache.Clear(); + s_cacheOrder.Clear(); + } + + private static byte[] ReadTable(IntPtr hdc, uint table) + { + var size = GetFontData(hdc, table, 0, null, 0); + if (size == GdiError || size == 0) + return null; + + var data = new byte[size]; + var bytesRead = GetFontData(hdc, table, 0, data, size); + return bytesRead == GdiError ? null : data; + } + + private static void ReadFeatureList(byte[] tableData, ISet tags) + { + if (tableData.Length < 8) + return; + + var featureListOffset = ReadUInt16(tableData, 6); + if (featureListOffset <= 0 || featureListOffset + 2 > tableData.Length) + return; + + var featureCount = ReadUInt16(tableData, featureListOffset); + var featureRecordOffset = featureListOffset + 2; + for (var featureIndex = 0; featureIndex < featureCount; featureIndex++) + { + var recordOffset = featureRecordOffset + featureIndex * 6; + if (recordOffset + 6 > tableData.Length) + return; + + var tag = System.Text.Encoding.ASCII.GetString(tableData, recordOffset, 4); + if (FontFeatureSettings.IsValidOpenTypeTag(tag) && IsUserConfigurableTag(tag)) + tags.Add(tag); + } + } + + private static bool IsUserConfigurableTag(string tag) + { + if (!s_nonUserConfigurableTags.Contains(tag)) + return true; + + Trace.WriteLineIf(s_openTypeTraceSwitch.TraceInfo, + string.Format(CultureInfo.InvariantCulture, + "FontFeaturesButton filtered non-user OpenType feature '{0}'.", + tag), + s_openTypeTraceSwitch.DisplayName); + return false; + } + + private static ushort ReadUInt16(byte[] data, int offset) + { + return (ushort)((data[offset] << 8) | data[offset + 1]); + } + + private static uint MakeTableTag(string tag) + { + return (uint)(tag[0] | tag[1] << 8 | tag[2] << 16 | tag[3] << 24); + } + + [StructLayout(LayoutKind.Sequential, CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + private struct LogFont + { + public int Height; + public int Width; + public int Escapement; + public int Orientation; + public int Weight; + public byte Italic; + public byte Underline; + public byte StrikeOut; + public byte CharSet; + public byte OutPrecision; + public byte ClipPrecision; + public byte Quality; + public byte PitchAndFamily; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string FaceName; + } + + private struct FontFeatureCacheKey : IEquatable + { + private readonly string m_faceName; + private readonly int m_height; + private readonly int m_weight; + private readonly byte m_italic; + private readonly byte m_charSet; + private readonly byte m_pitchAndFamily; + private readonly IntPtr m_fallbackHdc; + + private FontFeatureCacheKey(string faceName, int height, int weight, byte italic, + byte charSet, byte pitchAndFamily, IntPtr fallbackHdc) + { + m_faceName = faceName ?? string.Empty; + m_height = height; + m_weight = weight; + m_italic = italic; + m_charSet = charSet; + m_pitchAndFamily = pitchAndFamily; + m_fallbackHdc = fallbackHdc; + } + + public static FontFeatureCacheKey FromHdc(IntPtr hdc) + { + if (hdc != IntPtr.Zero) + { + var hfont = GetCurrentObject(hdc, ObjFont); + if (hfont != IntPtr.Zero) + { + var logFont = new LogFont(); + if (GetObject(hfont, Marshal.SizeOf(typeof(LogFont)), ref logFont) > 0) + { + return new FontFeatureCacheKey(logFont.FaceName, logFont.Height, + logFont.Weight, logFont.Italic, logFont.CharSet, + logFont.PitchAndFamily, IntPtr.Zero); + } + } + } + return new FontFeatureCacheKey(string.Empty, 0, 0, 0, 0, 0, hdc); + } + + public bool Equals(FontFeatureCacheKey other) + { + return string.Equals(m_faceName, other.m_faceName, StringComparison.OrdinalIgnoreCase) && + m_height == other.m_height && + m_weight == other.m_weight && + m_italic == other.m_italic && + m_charSet == other.m_charSet && + m_pitchAndFamily == other.m_pitchAndFamily && + m_fallbackHdc == other.m_fallbackHdc; + } + + public override bool Equals(object obj) + { + return obj is FontFeatureCacheKey && Equals((FontFeatureCacheKey)obj); + } + + public override int GetHashCode() + { + unchecked + { + var hash = StringComparer.OrdinalIgnoreCase.GetHashCode(m_faceName); + hash = (hash * 397) ^ m_height; + hash = (hash * 397) ^ m_weight; + hash = (hash * 397) ^ m_italic; + hash = (hash * 397) ^ m_charSet; + hash = (hash * 397) ^ m_pitchAndFamily; + hash = (hash * 397) ^ m_fallbackHdc.GetHashCode(); + return hash; + } + } + } + + private sealed class DisposableAction : IDisposable + { + private Action m_disposeAction; + + public DisposableAction(Action disposeAction) + { + m_disposeAction = disposeAction; + } + + public void Dispose() + { + var disposeAction = m_disposeAction; + if (disposeAction == null) + return; + m_disposeAction = null; + disposeAction(); + } + } + } } } diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControls.resx b/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControls.resx index edfa9c6ce6..1b3c3d900e 100644 --- a/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControls.resx +++ b/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControls.resx @@ -1,17 +1,17 @@ - @@ -170,6 +170,75 @@ Value #{0} + + Off + + + On + + + Access All Alternates + + + Small Capitals From Capitals + + + Contextual Alternates + + + Case-Sensitive Forms + + + Glyph Composition/Decomposition + + + Contextual Ligatures + + + Discretionary Ligatures + + + Fractions + + + Kerning + + + Standard Ligatures + + + Lining Figures + + + Oldstyle Figures + + + Proportional Figures + + + Stylistic Alternates + + + Small Capitals + + + Stylistic Set 1 + + + Stylistic Set 2 + + + Stylistic Set 3 + + + Stylistic Set 4 + + + Stylistic Set 5 + + + Tabular Figures + "{0}" is not a valid measurement. @@ -497,4 +566,4 @@ Characters with hexadecimal values 0x0 through 0x1f are illegal in XML documents and have been removed. - \ No newline at end of file + diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/FwAttributesTests.cs b/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/FwAttributesTests.cs index 3c0d970386..e50186189a 100644 --- a/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/FwAttributesTests.cs +++ b/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/FwAttributesTests.cs @@ -6,6 +6,9 @@ using System.Windows.Forms; using NUnit.Framework; using SIL.FieldWorks.Common.Controls; +using SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.DomainServices; using SIL.LCModel.Utils; namespace SIL.FieldWorks.FwCoreDlgControls @@ -13,6 +16,36 @@ namespace SIL.FieldWorks.FwCoreDlgControls [TestFixture] public class FwAttributesTest { + [Test] + public void UpdateForStyle_OpenTypeFeatures_RoundTripsNormalizedTags() + { + var fontInfo = CreateExplicitFontInfo(" smcp = 1, kern=0 "); + using (var t = new FwFontAttributes()) + { + t.UpdateForStyle(fontInfo); + + bool isInherited; + Assert.That(t.GetFontFeatures(out isInherited), Is.EqualTo("kern=0,smcp=1")); + Assert.That(isInherited, Is.False); + } + } + + private static FontInfo CreateExplicitFontInfo(string features) + { + return new FontInfo + { + m_bold = { ExplicitValue = false }, + m_italic = { ExplicitValue = false }, + m_superSub = { ExplicitValue = FwSuperscriptVal.kssvOff }, + m_offset = { ExplicitValue = 0 }, + m_fontColor = { ExplicitValue = Color.Black }, + m_backColor = { ExplicitValue = Color.Empty }, + m_underline = { ExplicitValue = FwUnderlineType.kuntNone }, + m_underlineColor = { ExplicitValue = Color.Empty }, + m_features = { ExplicitValue = features } + }; + } + [Test] public void IsInherited_CheckBoxUnchecked_ReturnsFalse() { diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/FwFontTabTests.cs b/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/FwFontTabTests.cs index 6cddebc5de..53027568e0 100644 --- a/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/FwFontTabTests.cs +++ b/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/FwFontTabTests.cs @@ -5,8 +5,10 @@ using NUnit.Framework; using SIL.LCModel; using SIL.LCModel.Utils; +using System.Drawing; using System.Windows.Forms; using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.DomainServices; namespace SIL.FieldWorks.FwCoreDlgControls { @@ -53,6 +55,71 @@ public override void TestTearDown() /// unspecified font and the user-defined character style specifies it. /// /// ---------------------------------------------------------------------------------------- + [Test] + public void SaveToInfo_OpenTypeFeatures_RoundTripsThroughFontAttributes() + { + var charStyle = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.StylesOC.Add(charStyle); + charStyle.Context = ContextValues.Text; + charStyle.Function = FunctionValues.Prose; + charStyle.Structure = StructureValues.Body; + charStyle.Type = StyleType.kstCharacter; + var basedOn = new StyleInfo(charStyle); + var charStyleInfo = new StyleInfo("OpenType Char Style", basedOn, + StyleType.kstCharacter, Cache); + var fontInfo = charStyleInfo.FontInfoForWs(-1); + fontInfo.m_fontName.ExplicitValue = "Times New Roman"; + fontInfo.m_fontSize.ExplicitValue = 12000; + fontInfo.m_bold.ExplicitValue = false; + fontInfo.m_italic.ExplicitValue = false; + fontInfo.m_superSub.ExplicitValue = FwSuperscriptVal.kssvOff; + fontInfo.m_offset.ExplicitValue = 0; + fontInfo.m_fontColor.ExplicitValue = Color.Black; + fontInfo.m_backColor.ExplicitValue = Color.Empty; + fontInfo.m_underline.ExplicitValue = FwUnderlineType.kuntNone; + fontInfo.m_underlineColor.ExplicitValue = Color.Empty; + fontInfo.m_features.ExplicitValue = " smcp = 1, kern=0 "; + + m_fontTab.UpdateForStyle(charStyleInfo, -1); + m_fontTab.SaveToInfo(charStyleInfo); + + Assert.That(charStyleInfo.FontInfoForWs(-1).m_features.Value, Is.EqualTo("kern=0,smcp=1")); + } + + [Test] + public void SaveToInfo_OpenTypeFeatures_DefaultFontSelectionDuringUpdate_PreservesExplicitFeatures() + { + var baseStyle = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.StylesOC.Add(baseStyle); + baseStyle.Context = ContextValues.Text; + baseStyle.Function = FunctionValues.Prose; + baseStyle.Structure = StructureValues.Body; + baseStyle.Type = StyleType.kstCharacter; + var basedOn = new StyleInfo(baseStyle); + basedOn.FontInfoForWs(-1).m_fontName.ExplicitValue = StyleServices.DefaultFont; + + var charStyleInfo = new StyleInfo("OpenType Default Font Style", basedOn, + StyleType.kstCharacter, Cache); + var fontInfo = charStyleInfo.FontInfoForWs(-1); + fontInfo.m_fontName.ExplicitValue = StyleServices.DefaultFont; + fontInfo.m_fontSize.ExplicitValue = 12000; + fontInfo.m_bold.ExplicitValue = false; + fontInfo.m_italic.ExplicitValue = false; + fontInfo.m_superSub.ExplicitValue = FwSuperscriptVal.kssvOff; + fontInfo.m_offset.ExplicitValue = 0; + fontInfo.m_fontColor.ExplicitValue = Color.Black; + fontInfo.m_backColor.ExplicitValue = Color.Empty; + fontInfo.m_underline.ExplicitValue = FwUnderlineType.kuntNone; + fontInfo.m_underlineColor.ExplicitValue = Color.Empty; + fontInfo.m_features.ExplicitValue = " smcp = 1, kern=0 "; + + m_fontTab.UpdateForStyle(charStyleInfo, -1); + m_fontTab.SaveToInfo(charStyleInfo); + + Assert.That(charStyleInfo.FontInfoForWs(-1).m_features.Value, Is.EqualTo("kern=0,smcp=1")); + Assert.That(charStyleInfo.FontInfoForWs(-1).m_features.IsInherited, Is.False); + } + [Test] public void UserDefinedCharacterStyle_ExplicitFontName() { @@ -101,4 +168,4 @@ public void FillFontNames_IsAlphabeticallySorted() } } } -} \ No newline at end of file +} diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/StyleInfoTests.cs b/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/StyleInfoTests.cs index cdc23b5386..c69b2e4c66 100644 --- a/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/StyleInfoTests.cs +++ b/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/StyleInfoTests.cs @@ -196,5 +196,27 @@ public void SaveToDB_NewInfoAndBasedOnNewInfo() Assert.That(style2.Structure, Is.EqualTo(StructureValues.Heading)); Assert.That(style2.Function, Is.EqualTo(FunctionValues.Table)); } + + [Test] + public void SaveToDB_DefaultFontFeatures_RoundTripsThroughRules() + { + var styleFactory = Cache.ServiceLocator.GetInstance(); + var realStyle = styleFactory.Create(); + Cache.LanguageProject.StylesOC.Add(realStyle); + realStyle.Name = "Normal"; + realStyle.Context = ContextValues.Internal; + realStyle.Function = FunctionValues.Prose; + realStyle.Structure = StructureValues.Undefined; + + StyleInfo styleInfo = new StyleInfo(realStyle); + styleInfo.FontInfoForWs(-1).m_features.ExplicitValue = "kern=0,smcp=1"; + + styleInfo.SaveToDB(realStyle, true, true); + StyleInfo reloadedStyleInfo = new StyleInfo(realStyle); + FontInfo reloadedFontInfo = reloadedStyleInfo.FontInfoForWs(-1); + + Assert.That(reloadedFontInfo.m_features.IsExplicit, Is.True); + Assert.That(reloadedFontInfo.m_features.Value, Is.EqualTo("kern=0,smcp=1")); + } } } diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/TestFontFeaturesButton.cs b/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/TestFontFeaturesButton.cs index 35fd5183e6..31992d625b 100644 --- a/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/TestFontFeaturesButton.cs +++ b/Src/FwCoreDlgs/FwCoreDlgControls/FwCoreDlgControlsTests/TestFontFeaturesButton.cs @@ -3,6 +3,7 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) using System; +using System.Linq; using SIL.FieldWorks.FwCoreDlgControls; using NUnit.Framework; @@ -64,5 +65,126 @@ public void TestParseFeatureString() new int[] {Int32.MaxValue}, FontFeaturesButton.ParseFeatureString(new int[] {1}, "1=319")), Is.True, "magic id 1 ignored"); } + + [Test] + public void GenerateFeatureString_EmitsRendererNeutralOpenTypeTags() + { + var ids = new[] { FeatureId("smcp"), FeatureId("kern") }; + var values = new[] { 1, 0 }; + + Assert.That(FontFeaturesButton.GenerateFeatureString(ids, values), Is.EqualTo("smcp=1,kern=0")); + } + + [Test] + public void FontFeatures_NormalizesRendererNeutralTags() + { + using (var button = new FontFeaturesButton()) + { + button.FontFeatures = " smcp = 1, kern=0, bad=2 "; + + Assert.That(button.FontFeatures, Is.EqualTo("kern=0,smcp=1")); + } + } + + [Test] + public void FontFeatures_PreservesLegacyGraphiteFeatureIds() + { + using (var button = new FontFeaturesButton()) + { + button.FontFeatures = " 123=1,456=2 "; + + Assert.That(button.FontFeatures, Is.EqualTo("123=1,456=2")); + } + } + + [Test] + public void UseGraphiteFeatures_DefaultsToFalse() + { + using (var button = new FontFeaturesButton()) + { + Assert.That(button.UseGraphiteFeatures, Is.False); + } + } + + [Test] + public void ConvertRendererNeutralFeatureStringToIds_UsesOpenTypeTagsDirectly() + { + var expected = FeatureId("kern") + "=0," + FeatureId("smcp") + "=1"; + + Assert.That( + FontFeaturesButton.ConvertRendererNeutralFeatureStringToIds(" smcp = 1, kern=0 "), + Is.EqualTo(expected)); + } + + [Test] + public void OpenTypeFontFeatureReader_CachesFeatureTagsForSameFontKey() + { + var readCount = 0; + var tableData = MakeOpenTypeLayoutTable("kern"); + FontFeaturesButton.OpenTypeFontFeatureReader.ClearCacheForTests(); + + using (FontFeaturesButton.OpenTypeFontFeatureReader.UseTableReaderForTests((hdc, table) => + { + readCount++; + return tableData; + })) + { + var firstRead = FontFeaturesButton.OpenTypeFontFeatureReader.GetFeatureTags(IntPtr.Zero); + var secondRead = FontFeaturesButton.OpenTypeFontFeatureReader.GetFeatureTags(IntPtr.Zero); + + Assert.That(firstRead, Is.EqualTo(new[] { "kern" })); + Assert.That(secondRead, Is.EqualTo(new[] { "kern" })); + Assert.That(readCount, Is.EqualTo(2)); + } + } + + [Test] + public void OpenTypeFontFeatureReader_FiltersRequiredShapingFeatures() + { + var tableData = MakeOpenTypeLayoutTable("ccmp", "liga", "rlig"); + FontFeaturesButton.OpenTypeFontFeatureReader.ClearCacheForTests(); + + using (FontFeaturesButton.OpenTypeFontFeatureReader.UseTableReaderForTests((hdc, table) => tableData)) + { + var tags = FontFeaturesButton.OpenTypeFontFeatureReader.GetFeatureTags(IntPtr.Zero); + + Assert.That(tags, Is.EqualTo(new[] { "liga" })); + } + } + + private static int FeatureId(string tag) + { + var reversedTagBytes = tag.Reverse().Select(Convert.ToByte).ToArray(); + return BitConverter.ToInt32(reversedTagBytes, 0); + } + + private static byte[] MakeOpenTypeLayoutTable(params string[] featureTags) + { + var tableData = new byte[10 + featureTags.Length * 6]; + tableData[0] = 0; + tableData[1] = 1; + tableData[2] = 0; + tableData[3] = 0; + tableData[4] = 0; + tableData[5] = 0; + tableData[6] = 0; + tableData[7] = 8; + tableData[8] = 0; + tableData[9] = Convert.ToByte(featureTags.Length); + + for (var index = 0; index < featureTags.Length; index++) + { + var featureTag = featureTags[index]; + var recordOffset = 10 + index * 6; + tableData[recordOffset] = Convert.ToByte(featureTag[0]); + tableData[recordOffset + 1] = Convert.ToByte(featureTag[1]); + tableData[recordOffset + 2] = Convert.ToByte(featureTag[2]); + tableData[recordOffset + 3] = Convert.ToByte(featureTag[3]); + tableData[recordOffset + 4] = 0; + tableData[recordOffset + 5] = 0; + } + + return tableData; + } } } diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/FwFontAttributes.cs b/Src/FwCoreDlgs/FwCoreDlgControls/FwFontAttributes.cs index 5c19a0451a..7327c881b0 100644 --- a/Src/FwCoreDlgs/FwCoreDlgControls/FwFontAttributes.cs +++ b/Src/FwCoreDlgs/FwCoreDlgControls/FwFontAttributes.cs @@ -229,6 +229,7 @@ protected virtual void OnValueChanged(object sender, EventArgs e) private void m_btnFontFeatures_FontFeatureSelected(object sender, EventArgs e) { m_btnFontFeatures.Tag = false; // No longer inherited + OnValueChanged(sender, e); } /// ------------------------------------------------------------------------------------ diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/FwFontTab.cs b/Src/FwCoreDlgs/FwCoreDlgControls/FwFontTab.cs index 38101e664e..c50498e8b3 100644 --- a/Src/FwCoreDlgs/FwCoreDlgControls/FwFontTab.cs +++ b/Src/FwCoreDlgs/FwCoreDlgControls/FwFontTab.cs @@ -139,6 +139,12 @@ private void m_cboFontSize_TextUpdate(object sender, EventArgs e) /// ------------------------------------------------------------------------------------ private void m_cboFontNames_SelectedIndexChanged(object sender, EventArgs e) { + if (m_dontUpdateInheritance) + { + m_FontAttributes.FontName = m_cboFontNames.Text; + return; + } + FontInfo fontInfoForWs = m_currentStyleInfo.FontInfoForWs(m_currentWs); FontInfo inheritedFontInfo = (m_currentStyleInfo.BasedOnStyle == null) ? null : m_currentStyleInfo.BasedOnStyle.FontInfoForWs(m_currentWs); diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/StyleInfo.cs b/Src/FwCoreDlgs/FwCoreDlgControls/StyleInfo.cs index 0b7fb7dbeb..d6a4f85833 100644 --- a/Src/FwCoreDlgs/FwCoreDlgControls/StyleInfo.cs +++ b/Src/FwCoreDlgs/FwCoreDlgControls/StyleInfo.cs @@ -40,6 +40,24 @@ public class StyleInfo : BaseStyleInfo public StyleInfo(IStStyle style) : base(style) { + LoadDefaultFontFeatures(style); + } + + private void LoadDefaultFontFeatures(IStStyle style) + { + if (style == null || style.Rules == null) + return; + + for (int i = 0; i < style.Rules.StrPropCount; i++) + { + int tpt; + string value = style.Rules.GetStrProp(i, out tpt); + if (tpt == (int)FwTextPropType.ktptFontVariations) + { + m_defaultFontInfo.m_features.ExplicitValue = value; + return; + } + } } /// ------------------------------------------------------------------------------------ diff --git a/Src/FwCoreDlgs/FwCoreDlgsTests/FwFontDialogTests.cs b/Src/FwCoreDlgs/FwCoreDlgsTests/FwFontDialogTests.cs index 1dc5086a42..9ea0624a51 100644 --- a/Src/FwCoreDlgs/FwCoreDlgsTests/FwFontDialogTests.cs +++ b/Src/FwCoreDlgs/FwCoreDlgsTests/FwFontDialogTests.cs @@ -5,7 +5,10 @@ using System.Windows.Forms; using NUnit.Framework; using SIL.FieldWorks.Common.FwUtils; +using SIL.FieldWorks.FwCoreDlgControls; using SIL.LCModel; +using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.DomainServices; namespace SIL.FieldWorks.FwCoreDlgs { @@ -56,6 +59,62 @@ public override void TestTearDown() /// Related to FWNX-273: Fonts not in alphabetical order /// /// ---------------------------------------------------------------------------------------- + [Test] + public void SaveFontInfo_OpenTypeFeatures_RoundTripsThroughAttributes() + { + var fontInfo = new FontInfo + { + m_fontName = { ExplicitValue = "Times New Roman" }, + m_fontSize = { ExplicitValue = 12000 }, + m_bold = { ExplicitValue = false }, + m_italic = { ExplicitValue = false }, + m_superSub = { ExplicitValue = FwSuperscriptVal.kssvOff }, + m_offset = { ExplicitValue = 0 }, + m_fontColor = { ExplicitValue = System.Drawing.Color.Black }, + m_backColor = { ExplicitValue = System.Drawing.Color.Empty }, + m_underline = { ExplicitValue = FwUnderlineType.kuntNone }, + m_underlineColor = { ExplicitValue = System.Drawing.Color.Empty }, + m_features = { ExplicitValue = " smcp = 1, kern=0 " } + }; + + ((IFontDialog)m_dialog).Initialize(fontInfo, true, Cache.DefaultVernWs, + Cache.WritingSystemFactory, null, false); + var savedFontInfo = new FontInfo(fontInfo); + + ((IFontDialog)m_dialog).SaveFontInfo(savedFontInfo); + + Assert.That(savedFontInfo.m_features.Value, Is.EqualTo("kern=0,smcp=1")); + Assert.That(savedFontInfo.m_features.IsInherited, Is.False); + } + + [Test] + public void SaveFontInfo_OpenTypeFeatures_RemainExplicitWhenLaterFieldsAreInherited() + { + var fontInfo = new FontInfo + { + m_fontName = { ExplicitValue = "Times New Roman" }, + m_fontSize = { ExplicitValue = 12000 }, + m_bold = { ExplicitValue = false }, + m_italic = { ExplicitValue = false }, + m_superSub = { ExplicitValue = FwSuperscriptVal.kssvOff }, + m_fontColor = { ExplicitValue = System.Drawing.Color.Black }, + m_backColor = { ExplicitValue = System.Drawing.Color.Empty }, + m_underline = { ExplicitValue = FwUnderlineType.kuntNone }, + m_underlineColor = { ExplicitValue = System.Drawing.Color.Empty }, + m_features = { ExplicitValue = " smcp = 1, kern=0 " } + }; + fontInfo.m_offset.ResetToInherited(0); + + ((IFontDialog)m_dialog).Initialize(fontInfo, true, Cache.DefaultVernWs, + Cache.WritingSystemFactory, null, false); + var savedFontInfo = new FontInfo(fontInfo); + + ((IFontDialog)m_dialog).SaveFontInfo(savedFontInfo); + + Assert.That(savedFontInfo.m_features.Value, Is.EqualTo("kern=0,smcp=1")); + Assert.That(savedFontInfo.m_features.IsInherited, Is.False); + } + [Test] public void FillFontList_IsAlphabeticallySorted() { diff --git a/Src/views/Test/RenderEngineTestBase.h b/Src/views/Test/RenderEngineTestBase.h index 1b7a2d88be..52ecedbd84 100644 --- a/Src/views/Test/RenderEngineTestBase.h +++ b/Src/views/Test/RenderEngineTestBase.h @@ -34,6 +34,9 @@ namespace TestViews public: TxtSrc(int n, ILgWritingSystemFactory * pwsf); TxtSrc(const wchar_t *, ILgWritingSystemFactory * pwsf); + TxtSrc(const wchar_t *, ILgWritingSystemFactory * pwsf, const wchar_t * pszFontVar); + TxtSrc(const wchar_t *, ILgWritingSystemFactory * pwsf, const wchar_t * pszFontVar, + int wsOverride); // IUnknown methods. STDMETHOD(QueryInterface)(REFIID iid, void ** ppv); @@ -107,10 +110,13 @@ namespace TestViews protected: long m_cref; StrUni m_stu; + StrUni m_stuFontVar; Vector m_vws; + int m_wsOverride; private: - void Init(const wchar_t* s, ILgWritingSystemFactory * pwsf); + void Init(const wchar_t* s, ILgWritingSystemFactory * pwsf, const wchar_t * pszFontVar = L"", + int wsOverride = 0); }; TxtSrc::TxtSrc(int n, ILgWritingSystemFactory * pwsf) @@ -195,11 +201,25 @@ namespace TestViews Init(s, pwsf); } - void TxtSrc::Init(const wchar_t* s, ILgWritingSystemFactory * pwsf) + TxtSrc::TxtSrc(const wchar_t* s, ILgWritingSystemFactory * pwsf, const wchar_t * pszFontVar) + { + Init(s, pwsf, pszFontVar); + } + + TxtSrc::TxtSrc(const wchar_t* s, ILgWritingSystemFactory * pwsf, const wchar_t * pszFontVar, + int wsOverride) + { + Init(s, pwsf, pszFontVar, wsOverride); + } + + void TxtSrc::Init(const wchar_t* s, ILgWritingSystemFactory * pwsf, const wchar_t * pszFontVar, + int wsOverride) { AssertPtr(pwsf); m_cref = 1; + m_wsOverride = wsOverride; m_stu.Assign(s); + m_stuFontVar.Assign(pszFontVar ? pszFontVar : L""); int cws = 0; pwsf->get_NumberOfWs(&cws); m_vws.Resize(cws); @@ -278,10 +298,16 @@ namespace TestViews pchrp->ttvBold = kttvOff; pchrp->ttvItalic = kttvOff; pchrp->dympHeight = 14000; // 14pt. - wcscpy_s(pchrp->szFontVar, 32, StrUni(L"").Chars()); + wcscpy_s(pchrp->szFontVar, 32, m_stuFontVar.Chars()); wcscpy_s(pchrp->szFaceName, 32, StrUni(L"").Chars()); - if (ich < 1000) + if (m_wsOverride) + { + pchrp->ws = m_wsOverride; + *pichMin = 0; + *pichLim = m_stu.Length(); + } + else if (ich < 1000) { if (m_vws.Size()) pchrp->ws = m_vws[0]; diff --git a/Src/views/Test/TestData/Fonts/CharisSIL-5.000/CharisSIL-R.ttf b/Src/views/Test/TestData/Fonts/CharisSIL-5.000/CharisSIL-R.ttf new file mode 100644 index 0000000000..b8e686a6f6 Binary files /dev/null and b/Src/views/Test/TestData/Fonts/CharisSIL-5.000/CharisSIL-R.ttf differ diff --git a/Src/views/Test/TestData/Fonts/CharisSIL-5.000/OFL.txt b/Src/views/Test/TestData/Fonts/CharisSIL-5.000/OFL.txt new file mode 100644 index 0000000000..7fb722dcaa --- /dev/null +++ b/Src/views/Test/TestData/Fonts/CharisSIL-5.000/OFL.txt @@ -0,0 +1,94 @@ +This Font Software is Copyright (c) 1997-2014, SIL International (http://scripts.sil.org/) +with Reserved Font Names "Charis" and "SIL". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/Src/views/Test/TestData/Fonts/CharisSIL-5.000/README.txt b/Src/views/Test/TestData/Fonts/CharisSIL-5.000/README.txt new file mode 100644 index 0000000000..6ef6281aa0 --- /dev/null +++ b/Src/views/Test/TestData/Fonts/CharisSIL-5.000/README.txt @@ -0,0 +1,81 @@ +README +Charis SIL +======================== + +Thank you for your interest in the Charis SIL fonts. +We hope you find them useful! + +Charis SIL provides glyphs for a wide range of Latin and Cyrillic characters. +Please read the documentation on the website +(http://scripts.sil.org/CharisSILfont) to see what ranges are supported. + +Charis SIL is released under the SIL Open Font License. +Charis SIL is a trademark of SIL International. + +See the OFL and OFL-FAQ for details of the SIL Open Font License. +See the FONTLOG for information on this and previous releases. +See the website (http://scripts.sil.org/CharisSILfont) documentation or the +Charis SIL FAQ (http://scripts.sil.org/ComplexRomanFontFAQ) for frequently +asked questions and their answers. + +TIPS +==== + +As this font is distributed at no cost, we are unable to provide a +commercial level of personal technical support. The font has, however, +been through some testing on various platforms to be sure it works in most +situations. In particular, it has been tested and shown to work on Windows XP, +Windows Vista and Windows 7. Graphite capabilities have been tested on +Graphite-supported platforms. + +If you do find a problem, please do report it to fonts@sil.org. +We can't guarantee any direct response, but will try to fix reported bugs in +future versions. Make sure you read through the +Charis SIL FAQ (http://scripts.sil.org/ComplexRomanFontFAQ). + +Many problems can be solved, or at least explained, through an understanding +of the encoding and use of the fonts. Here are some basic hints: + +Encoding: +The fonts are encoded according to Unicode, so your application must support +Unicode text in order to access letters other than the standard alphabet. +Most Windows applications provide basic Unicode support. You will, however, +need some way of entering Unicode text into your document. + +Keyboarding: +This font does not include any keyboarding helps or utilities. It uses the +built-in keyboards of the operating system. You will need to install the +appropriate keyboard and input method for the characters of the language you +wish to use. If you want to enter characters that are not supported by any +system keyboard, the Keyman program (www.tavultesoft.com) can be helpful +on Windows systems. Also available for Windows is MSKLC +(http://www.microsoft.com/globaldev/tools/msklc.mspx). +For other platforms, KMFL (http://kmfl.sourceforge.net/), +XKB (http://www.x.org/wiki/XKB) or Ukelele (http://scripts.sil.org/ukelele) +can be helpful. + +If you want to enter characters that are not supported by any system +keyboard, and to access the full Unicode range, we suggest you use +gucharmap, kcharselect on Ubuntu or similar software. + +Another method of entering some symbols is provided by a few applications such +as Adobe InDesign or LibreOffice.org. They can display a glyph palette or input +dialog that shows all the glyphs (symbols) in a font and allow you to enter +them by clicking on the glyph you want. + +Rendering: +This font is designed to work with any of two advanced font technologies, +Graphite or OpenType. To take advantage of the advanced typographic +capabilities of this font, you must be using applications that provide an +adequate level of support for Graphite or OpenType. See "Applications +that provide an adequate level of support for SIL Unicode Roman fonts" +(http://scripts.sil.org/Complex_AdLvSup). + + +CONTACT +======== +For more information please visit the Charis SIL page on SIL International's +Computers and Writing systems website: +http://scripts.sil.org/CharisSILfont + +Support through the website: http://scripts.sil.org/Support diff --git a/Src/views/Test/TestUniscribeEngine.h b/Src/views/Test/TestUniscribeEngine.h index bd5514f0e2..859ee2dc37 100644 --- a/Src/views/Test/TestUniscribeEngine.h +++ b/Src/views/Test/TestUniscribeEngine.h @@ -36,6 +36,363 @@ namespace TestViews } TestUniscribeEngine(); + int MeasureTextWithFeatures(const wchar_t * pszText, const wchar_t * pszFontVar) + { + int dxWidth = 0; +#if defined(WIN32) || defined(_M_X64) + int dxMax = 4000; + HWND hwndDesktop = ::GetDesktopWindow(); + HDC hdcDesktop = ::GetDC(hwndDesktop); + unitpp::assert_true("GetDC should return the desktop DC", hdcDesktop != NULL); + HDC hdc = ::CreateCompatibleDC(hdcDesktop); + unitpp::assert_true("CreateCompatibleDC should return a memory DC", hdc != NULL); + HBITMAP hbm = ::CreateCompatibleBitmap(hdcDesktop, dxMax, dxMax); + unitpp::assert_true("CreateCompatibleBitmap should return a bitmap", hbm != NULL); + HGDIOBJ hbmOld = ::SelectObject(hdc, hbm); + unitpp::assert_true("SelectObject should select the bitmap into the memory DC", hbmOld != NULL); + ::SetMapMode(hdc, MM_TEXT); + + IVwGraphicsWin32Ptr qvg; + qvg.CreateInstance(CLSID_VwGraphicsWin32); + qvg->Initialize(hdc); + + LgCharRenderProps chrp; + ZeroMemory(&chrp, isizeof(chrp)); + wcscpy_s(chrp.szFaceName, _countof(chrp.szFaceName), L"Charis SIL"); + wcscpy_s(chrp.szFontVar, _countof(chrp.szFontVar), pszFontVar); + chrp.ws = g_wsEng; + chrp.ttvBold = kttvOff; + chrp.ttvItalic = kttvOff; + chrp.dympHeight = 14000; + qvg->SetupGraphics(&chrp); + + ILgWritingSystemFactoryPtr qwsf; + m_qre->get_WritingSystemFactory(&qwsf); + + IVwTextSourcePtr qts; + TxtSrc ts(pszText, qwsf, pszFontVar); + ts.QueryInterface(IID_IVwTextSource, (void **)&qts); + int cch; + CheckHr(qts->get_Length(&cch)); + + ILgSegmentPtr qseg; + int dichLimSeg; + LgEndSegmentType est; + CheckHr(m_qre->FindBreakPoint(qvg, qts, NULL, 0, cch, cch, TRUE, TRUE, dxMax, + klbWordBreak, klbLetterBreak, ktwshAll, FALSE, &qseg, &dichLimSeg, &dxWidth, + &est, NULL)); + unitpp::assert_true("OpenType feature test should produce a segment", qseg); + CheckHr(qseg->get_Width(0, qvg, &dxWidth)); + + qvg.Clear(); + ::SelectObject(hdc, hbmOld); + ::DeleteObject(hbm); + ::DeleteDC(hdc); + ::ReleaseDC(hwndDesktop, hdcDesktop); +#endif + return dxWidth; + } + +#if defined(WIN32) || defined(_M_X64) + struct RenderedFeatureText + { + int dxWidth; + int cNonWhitePixels; + Vector vPixels; + }; + + class ScopedPrivateFont + { + public: + ScopedPrivateFont(const wchar_t * pszPath) + : m_stuPath(pszPath), m_cFonts(0) + { + m_cFonts = ::AddFontResourceExW(m_stuPath.Chars(), FR_PRIVATE, 0); + } + + ~ScopedPrivateFont() + { + if (m_cFonts > 0) + ::RemoveFontResourceExW(m_stuPath.Chars(), FR_PRIVATE, 0); + } + + bool Loaded() const + { + return m_cFonts > 0; + } + + private: + StrUni m_stuPath; + int m_cFonts; + }; + + class BitmapRenderTarget + { + public: + BitmapRenderTarget(int dxWidth, int dyHeight) + : m_dxWidth(dxWidth), m_dyHeight(dyHeight), m_hdc(NULL), m_hbm(NULL), + m_hbmOld(NULL), m_prgbBits(NULL) + { + m_hdc = ::CreateCompatibleDC(NULL); + unitpp::assert_true("CreateCompatibleDC should return a memory DC", m_hdc != NULL); + + BITMAPINFO bmi; + ZeroMemory(&bmi, isizeof(bmi)); + bmi.bmiHeader.biSize = isizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = m_dxWidth; + bmi.bmiHeader.biHeight = -m_dyHeight; + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + m_hbm = ::CreateDIBSection(m_hdc, &bmi, DIB_RGB_COLORS, + reinterpret_cast(&m_prgbBits), NULL, 0); + unitpp::assert_true("CreateDIBSection should return a bitmap", m_hbm != NULL); + + m_hbmOld = ::SelectObject(m_hdc, m_hbm); + unitpp::assert_true("SelectObject should select the render bitmap", m_hbmOld != NULL); + ::SetMapMode(m_hdc, MM_TEXT); + + RECT rcFill = {0, 0, m_dxWidth, m_dyHeight}; + HBRUSH hbrWhite = ::CreateSolidBrush(RGB(255, 255, 255)); + unitpp::assert_true("CreateSolidBrush should return a white brush", hbrWhite != NULL); + ::FillRect(m_hdc, &rcFill, hbrWhite); + ::DeleteObject(hbrWhite); + } + + ~BitmapRenderTarget() + { + if (m_hdc && m_hbmOld) + ::SelectObject(m_hdc, m_hbmOld); + if (m_hbm) + ::DeleteObject(m_hbm); + if (m_hdc) + ::DeleteDC(m_hdc); + } + + HDC DeviceContext() const + { + return m_hdc; + } + + void CopyPixels(Vector & vpixels) const + { + vpixels.Resize(m_dxWidth * m_dyHeight); + memcpy(vpixels.Begin(), m_prgbBits, m_dxWidth * m_dyHeight * isizeof(DWORD)); + } + + private: + int m_dxWidth; + int m_dyHeight; + HDC m_hdc; + HBITMAP m_hbm; + HGDIOBJ m_hbmOld; + DWORD * m_prgbBits; + }; + + StrUni GetCharisFontPath() + { + wchar_t rgchPath[MAX_PATH]; + DWORD cchPath = ::GetModuleFileNameW(NULL, rgchPath, _countof(rgchPath)); + unitpp::assert_true("GetModuleFileNameW should return the test executable path", + cchPath > 0 && cchPath < _countof(rgchPath)); + + wchar_t * pchLastSlash = wcsrchr(rgchPath, L'\\'); + unitpp::assert_true("Test executable path should contain a directory separator", + pchLastSlash != NULL); + *(pchLastSlash + 1) = 0; + wcscat_s(rgchPath, _countof(rgchPath), + L"TestData\\Fonts\\CharisSIL-5.000\\CharisSIL-R.ttf"); + + DWORD dwAttributes = ::GetFileAttributesW(rgchPath); + unitpp::assert_true("Charis SIL test font should be copied beside TestViews.exe", + dwAttributes != INVALID_FILE_ATTRIBUTES && + (dwAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0); + return StrUni(rgchPath); + } + + void SetDefaultFontForTest(const wchar_t * pszFontName) + { + SetDefaultFontForTest(g_wsEng, pszFontName); + } + + void SetDefaultFontForTest(int ws, const wchar_t * pszFontName) + { + ILgWritingSystemPtr qws; + CheckHr(g_qwsf->get_EngineOrNull(ws, &qws)); + MockLgWritingSystem * pws = dynamic_cast(qws.Ptr()); + unitpp::assert_true("Test writing system should be a mock writing system", + pws != NULL); + StrUni stuFont(pszFontName); + CheckHr(pws->put_DefaultFontName(stuFont.Bstr())); + } + + int CreateWritingSystemForTest(const wchar_t * pszLocale, const wchar_t * pszFontName) + { + ILgWritingSystemPtr qws; + StrUni stuLocale(pszLocale); + CheckHr(g_qwsf->get_Engine(stuLocale.Bstr(), &qws)); + int ws; + CheckHr(qws->get_Handle(&ws)); + SetDefaultFontForTest(ws, pszFontName); + return ws; + } + + RenderedFeatureText RenderTextWithFeatures(const wchar_t * pszText, const wchar_t * pszFontVar, + int wsOverride = 0) + { + const int kdxBitmap = 640; + const int kdyBitmap = 180; + const int kdxMax = 4000; + BitmapRenderTarget target(kdxBitmap, kdyBitmap); + + IVwGraphicsWin32Ptr qvg; + qvg.CreateInstance(CLSID_VwGraphicsWin32); + qvg->Initialize(target.DeviceContext()); + + LgCharRenderProps chrp; + ZeroMemory(&chrp, isizeof(chrp)); + wcscpy_s(chrp.szFaceName, _countof(chrp.szFaceName), L"Charis SIL"); + wcscpy_s(chrp.szFontVar, _countof(chrp.szFontVar), pszFontVar); + chrp.clrFore = kclrBlack; + chrp.clrBack = kclrWhite; + chrp.clrUnder = kclrRed; + chrp.ws = wsOverride ? wsOverride : g_wsEng; + chrp.ttvBold = kttvOff; + chrp.ttvItalic = kttvOff; + chrp.dympHeight = 26000; + qvg->SetupGraphics(&chrp); + + ILgWritingSystemFactoryPtr qwsf; + m_qre->get_WritingSystemFactory(&qwsf); + + IVwTextSourcePtr qts; + TxtSrc ts(pszText, qwsf, pszFontVar, wsOverride); + ts.QueryInterface(IID_IVwTextSource, (void **)&qts); + int cch; + CheckHr(qts->get_Length(&cch)); + + ILgSegmentPtr qseg; + int dichLimSeg; + int dxWidth; + LgEndSegmentType est; + CheckHr(m_qre->FindBreakPoint(qvg, qts, NULL, 0, cch, cch, TRUE, TRUE, kdxMax, + klbWordBreak, klbLetterBreak, ktwshAll, FALSE, &qseg, &dichLimSeg, &dxWidth, + &est, NULL)); + unitpp::assert_true("OpenType render test should produce a segment", qseg); + + RECT rcSrc = {0, 0, kdzmpInch, kdzmpInch}; + RECT rcDst = {10, 10, kdzmpInch + 10, kdzmpInch + 10}; + RenderedFeatureText rendered; + CheckHr(qseg->DrawText(0, qvg, rcSrc, rcDst, &rendered.dxWidth)); + ::GdiFlush(); + + target.CopyPixels(rendered.vPixels); + rendered.cNonWhitePixels = CountNonWhitePixels(rendered); + qvg.Clear(); + return rendered; + } + + int CountNonWhitePixels(const RenderedFeatureText & rendered) + { + int cNonWhitePixels = 0; + for (int i = 0; i < rendered.vPixels.Size(); ++i) + { + if ((rendered.vPixels[i] & 0x00FFFFFF) != 0x00FFFFFF) + ++cNonWhitePixels; + } + return cNonWhitePixels; + } + + int CountDifferentPixels(const RenderedFeatureText & first, const RenderedFeatureText & second) + { + unitpp::assert_eq("Rendered bitmaps should have the same pixel count", + first.vPixels.Size(), second.vPixels.Size()); + int cDifferentPixels = 0; + for (int i = 0; i < first.vPixels.Size(); ++i) + { + if ((first.vPixels[i] & 0x00FFFFFF) != (second.vPixels[i] & 0x00FFFFFF)) + ++cDifferentPixels; + } + return cDifferentPixels; + } +#endif + + void testOpenTypeFeatureMetrics() + { +#if defined(WIN32) || defined(_M_X64) + ScopedPrivateFont font(GetCharisFontPath().Chars()); + unitpp::assert_true("Charis SIL test font should load", font.Loaded()); + SetDefaultFontForTest(L"Charis SIL"); + + int dxWithoutLigatures = MeasureTextWithFeatures(L"office official affinity", L"liga=0"); + int dxWithLigatures = MeasureTextWithFeatures(L"office official affinity", L"liga=1"); + + unitpp::assert_true("OpenType feature-off segment width should be positive", + dxWithoutLigatures > 0); + unitpp::assert_true("OpenType feature-on segment width should be positive", + dxWithLigatures > 0); + unitpp::assert_true("Charis SIL liga feature should change segment metrics", + dxWithoutLigatures != dxWithLigatures); +#endif + } + + void testOpenTypeFeatureRenderedPixels() + { +#if defined(WIN32) || defined(_M_X64) + ScopedPrivateFont font(GetCharisFontPath().Chars()); + unitpp::assert_true("Charis SIL test font should load", font.Loaded()); + SetDefaultFontForTest(L"Charis SIL"); + + RenderedFeatureText regular = RenderTextWithFeatures(L"small caps verify", L"smcp=0"); + RenderedFeatureText smallCaps = RenderTextWithFeatures(L"small caps verify", L"smcp=1"); + + unitpp::assert_true("OpenType feature-off render should draw text", + regular.cNonWhitePixels > 0); + unitpp::assert_true("OpenType feature-on render should draw text", + smallCaps.cNonWhitePixels > 0); + unitpp::assert_true("Charis SIL smcp feature should change rendered pixels", + CountDifferentPixels(regular, smallCaps) > 0); +#endif + } + + void testOpenTypeFeatureRenderedPixelsSwitchState() + { +#if defined(WIN32) || defined(_M_X64) + ScopedPrivateFont font(GetCharisFontPath().Chars()); + unitpp::assert_true("Charis SIL test font should load", font.Loaded()); + SetDefaultFontForTest(L"Charis SIL"); + + RenderedFeatureText featureOnFirst = RenderTextWithFeatures(L"small caps verify", L"smcp=1"); + RenderedFeatureText featureOff = RenderTextWithFeatures(L"small caps verify", L"smcp=0"); + RenderedFeatureText featureOnAgain = RenderTextWithFeatures(L"small caps verify", L"smcp=1"); + + unitpp::assert_true("Feature-on render should differ from feature-off render", + CountDifferentPixels(featureOnFirst, featureOff) > 0); + unitpp::assert_eq("Feature-on render should be stable after switching off and back on", + 0, CountDifferentPixels(featureOnFirst, featureOnAgain)); +#endif + } + + void testOpenTypeFeatureUsesLocaleLanguageSystem() + { +#if defined(WIN32) || defined(_M_X64) + ScopedPrivateFont font(GetCharisFontPath().Chars()); + unitpp::assert_true("Charis SIL test font should load", font.Loaded()); + SetDefaultFontForTest(L"Charis SIL"); + int wsSerbian = CreateWritingSystemForTest(L"sr-Cyrl", L"Charis SIL"); + + RenderedFeatureText defaultLocale = RenderTextWithFeatures( + L"\x0431\x0433\x0434\x043F\x0442", L"locl=1"); + RenderedFeatureText serbianLocale = RenderTextWithFeatures( + L"\x0431\x0433\x0434\x043F\x0442", L"locl=1", wsSerbian); + + unitpp::assert_true("Serbian Cyrillic OpenType render should draw text", + defaultLocale.cNonWhitePixels > 0 && serbianLocale.cNonWhitePixels > 0); + unitpp::assert_true("Charis SIL Serbian locl feature should change rendered pixels", + CountDifferentPixels(defaultLocale, serbianLocale) > 0); +#endif + } + virtual void Setup() { RenderEngineTestBase::Setup(); diff --git a/Src/views/Test/TestViewCaches.h b/Src/views/Test/TestViewCaches.h index aaa0193e41..2b108c1d8d 100644 --- a/Src/views/Test/TestViewCaches.h +++ b/Src/views/Test/TestViewCaches.h @@ -11,6 +11,7 @@ This software is licensed under the LGPL, version 2.1 or later #include "testViews.h" #include "ColorStateCache.h" #include "FontHandleCache.h" +#include "LayoutCache.h" namespace TestViews { @@ -177,6 +178,46 @@ namespace TestViews m_cache = FontHandleCache(); } }; + + class TestShapeRunCache : public unitpp::suite + { + void testFontFeaturesArePartOfCacheKey() + { + ShapeRunCache cache; + SCRIPT_ANALYSIS sa; + ZeroMemory(&sa, sizeof(sa)); + sa.eScript = 1; + + const OLECHAR rgch[] = L"office"; + const int cch = 6; + const OLECHAR rgchFeatureOn[] = L"liga=1"; + const OLECHAR rgchFeatureOnCopy[] = L"liga=1"; + const OLECHAR rgchFeatureOff[] = L"liga=0"; + HFONT hfont = reinterpret_cast(static_cast(0x1234)); + + WORD prgGlyph[] = {1, 2, 3}; + SCRIPT_VISATTR prgsva[3]; + ZeroMemory(prgsva, sizeof(prgsva)); + int prgAdvance[] = {5, 5, 5}; + int prgcst[] = {0, 0, 0}; + GOFFSET prgoff[3]; + ZeroMemory(prgoff, sizeof(prgoff)); + WORD prgCluster[] = {0, 1, 2, 2, 2, 2}; + + cache.Store(rgch, cch, hfont, sa, rgchFeatureOn, prgGlyph, prgsva, + prgAdvance, prgcst, prgoff, prgCluster, 3, 15, false); + + unitpp::assert_true("same feature contents should hit shape cache", + cache.Find(rgch, cch, hfont, sa, rgchFeatureOnCopy) != NULL); + unitpp::assert_true("different feature contents should miss shape cache", + cache.Find(rgch, cch, hfont, sa, rgchFeatureOff) == NULL); + unitpp::assert_true("missing feature contents should miss feature-specific shape cache entry", + cache.Find(rgch, cch, hfont, sa, NULL) == NULL); + } + + public: + TestShapeRunCache(); + }; } #endif // TESTVIEWCACHES_H_INCLUDED diff --git a/Src/views/Test/TestViews.vcxproj b/Src/views/Test/TestViews.vcxproj index 16b1166024..278af98953 100644 --- a/Src/views/Test/TestViews.vcxproj +++ b/Src/views/Test/TestViews.vcxproj @@ -423,6 +423,9 @@ + + + @@ -439,6 +442,18 @@ WorkingDirectory="$(ProjectDir)" /> + + + + + + + + + + diff --git a/Src/views/Test/TestViews.vcxproj.filters b/Src/views/Test/TestViews.vcxproj.filters index 91b1a14379..b45dfb2065 100644 --- a/Src/views/Test/TestViews.vcxproj.filters +++ b/Src/views/Test/TestViews.vcxproj.filters @@ -139,6 +139,15 @@ Resource Files + + Resource Files + + + Resource Files + + + Resource Files + @@ -147,4 +156,4 @@ Resource Files - \ No newline at end of file + diff --git a/Src/views/Test/TestVwTxtSrc.h b/Src/views/Test/TestVwTxtSrc.h index 1675632dd1..7d037f5879 100644 --- a/Src/views/Test/TestVwTxtSrc.h +++ b/Src/views/Test/TestVwTxtSrc.h @@ -511,6 +511,81 @@ namespace TestViews unitpp::assert_eq("GetCharProps not bold", kttvForceOn, chrp.ttvBold); } + void testFontVariations_OverlongEntryWithoutCommaClearsRenderBuffer() + { + SmartBstr sbstrOverlong = MakeOverlongFontVariationBstr(); + HRESULT hr = m_qzvps.Ptr()->put_StringProperty(ktptFontVariations, sbstrOverlong); + unitpp::assert_eq("put_StringProperty should succeed", S_OK, hr); + + SmartBstr sbstrFontVariations; + hr = m_qzvps.Ptr()->get_FontVariations(&sbstrFontVariations); + unitpp::assert_eq("get_FontVariations should succeed", S_OK, hr); + unitpp::assert_eq("original font variations length should be preserved", + BstrLen(sbstrOverlong), BstrLen(sbstrFontVariations)); + + m_qzvps.Ptr()->Lock(); + LgCharRenderProps * pchrp = m_qzvps.Ptr()->Chrp(); + unitpp::assert_eq("render font variations should be dropped without a comma boundary", + 0, static_cast(wcslen(pchrp->szFontVar))); + } + + void testFontVariations_OverlongEntryWithCommaKeepsTrailingRenderableEntry() + { + SmartBstr sbstrOverlong = MakeOverlongFontVariationWithCommaBstr(); + HRESULT hr = m_qzvps.Ptr()->put_StringProperty(ktptFontVariations, sbstrOverlong); + unitpp::assert_eq("put_StringProperty should succeed", S_OK, hr); + + SmartBstr sbstrFontVariations; + hr = m_qzvps.Ptr()->get_FontVariations(&sbstrFontVariations); + unitpp::assert_eq("get_FontVariations should succeed", S_OK, hr); + unitpp::assert_eq("original font variations length should be preserved", + BstrLen(sbstrOverlong), BstrLen(sbstrFontVariations)); + + m_qzvps.Ptr()->Lock(); + LgCharRenderProps * pchrp = m_qzvps.Ptr()->Chrp(); + unitpp::assert_true("render font variations should keep trailing comma-delimited entry", + wcscmp(pchrp->szFontVar, L"ss01=1") == 0); + } + + void testFontVariations_ExactRenderBufferBoundaryIsPreserved() + { + SmartBstr sbstrBoundary = MakeBoundaryFontVariationBstr(); + HRESULT hr = m_qzvps.Ptr()->put_StringProperty(ktptFontVariations, sbstrBoundary); + unitpp::assert_eq("put_StringProperty should succeed", S_OK, hr); + + m_qzvps.Ptr()->Lock(); + LgCharRenderProps * pchrp = m_qzvps.Ptr()->Chrp(); + unitpp::assert_eq("boundary font variations length should be preserved", + BstrLen(sbstrBoundary), static_cast(wcslen(pchrp->szFontVar))); + unitpp::assert_true("boundary font variations should be copied exactly", + wcscmp(pchrp->szFontVar, sbstrBoundary.Chars()) == 0); + } + + void testComputedPropertiesForString_OverlongInheritedFontVariationsWithoutCommaClearsRenderBuffer() + { + SmartBstr sbstrOverlong = MakeOverlongFontVariationBstr(); + HRESULT hr = m_qzvps.Ptr()->put_StringProperty(ktptFontVariations, sbstrOverlong); + unitpp::assert_eq("put_StringProperty should succeed", S_OK, hr); + + VwPropertyStorePtr qzvpsDerived; + SmartBstr sbstrFontFamily(L"Arial"); + hr = m_qzvps.Ptr()->ComputedPropertiesForString(ktptFontFamily, sbstrFontFamily, + &qzvpsDerived); + unitpp::assert_eq("ComputedPropertiesForString should succeed", S_OK, hr); + + SmartBstr sbstrFontVariations; + hr = qzvpsDerived.Ptr()->get_FontVariations(&sbstrFontVariations); + unitpp::assert_eq("derived get_FontVariations should succeed", S_OK, hr); + unitpp::assert_eq("inherited font variations length should be preserved", + BstrLen(sbstrOverlong), BstrLen(sbstrFontVariations)); + + LgCharRenderProps chrp; + memset(&chrp, 0, isizeof(chrp)); + qzvpsDerived.Ptr()->GetChrp(&chrp); + unitpp::assert_eq("inherited render font variations should be dropped without a comma boundary", + 0, static_cast(wcslen(chrp.szFontVar))); + } + virtual void Setup() { CreateTestWritingSystemFactory(); @@ -536,6 +611,29 @@ namespace TestViews m_qzvps.Clear(); CloseTestWritingSystemFactory(); } + + private: + static SmartBstr MakeOverlongFontVariationBstr() + { + return SmartBstr( + L"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + L"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + } + + static SmartBstr MakeOverlongFontVariationWithCommaBstr() + { + return SmartBstr( + L"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + L"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + L",ss01=1"); + } + + static SmartBstr MakeBoundaryFontVariationBstr() + { + return SmartBstr( + L"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + L"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + } }; } diff --git a/Src/views/VwPropertyStore.cpp b/Src/views/VwPropertyStore.cpp index 40e74a9107..1fd1219e6a 100644 --- a/Src/views/VwPropertyStore.cpp +++ b/Src/views/VwPropertyStore.cpp @@ -74,6 +74,29 @@ static const int s_ctptWsStyleProps = (isizeof(s_rgtptWsStyleProps) / isizeof(in // Magic font strings that are used in markup: static OleStringLiteral g_pszDefaultFont(L""); +static void CopyRenderableFontVariations(const StrUni & stuFontVariations, + OLECHAR * prgchFontVar, int cchFontVar) +{ + StrUni stuTmp = stuFontVariations; + const int cchLimit = cchFontVar - 1; + while (stuTmp.Length() > cchLimit) + { + int ichComma = stuTmp.FindCh(L',', 0); + if (ichComma < 0 || ichComma + 1 >= stuTmp.Length()) + { +#if defined(WIN32) || defined(_M_X64) + ::OutputDebugStringW(L"VwPropertyStore dropped an overlong font variations string without a comma boundary.\n"); +#endif + stuTmp.Clear(); + break; + } + + stuTmp = stuTmp.Right(stuTmp.Length() - ichComma - 1); + } + + wcscpy_s(prgchFontVar, cchFontVar, stuTmp.Chars()); +} + //:>******************************************************************************************** //:> DllExports //:>******************************************************************************************** @@ -787,7 +810,7 @@ STDMETHODIMP VwPropertyStore::get_FontVariations(BSTR * pbstr) { BEGIN_COM_METHOD; ChkComOutPtr(pbstr); - CopyBstr(pbstr, m_stuFontFamily.Bstr()); + CopyBstr(pbstr, m_stuFontVariations.Bstr()); END_COM_METHOD(g_factVps, IID_IVwPropertyStore); } @@ -1546,7 +1569,6 @@ STDMETHODIMP VwPropertyStore::put_StringProperty(int sp, BSTR bstrValue) ChkComBstrArg(bstrValue); StrUni stuTmp; - int max; if (m_fLocked) ThrowHr(WarnHr(E_UNEXPECTED)); @@ -1578,15 +1600,8 @@ STDMETHODIMP VwPropertyStore::put_StringProperty(int sp, BSTR bstrValue) m_stuFontVariations += L","; stuTmp = bstrValue; m_stuFontVariations += stuTmp; - stuTmp = m_stuFontVariations; - max = isizeof (m_chrp.szFontVar); - while (stuTmp.Length() >= (isizeof(m_chrp.szFontVar) / isizeof(OLECHAR))) - { - // Pretruncate to avoid overflow. - int ichComma = stuTmp.FindCh(L',', 0); - stuTmp = stuTmp.Right(stuTmp.Length() - ichComma - 1); - } - wcscpy_s(m_chrp.szFontVar, 64, stuTmp.Chars()); + CopyRenderableFontVariations(m_stuFontVariations, m_chrp.szFontVar, + _countof(m_chrp.szFontVar)); break; case ktptTags: m_stuTags = bstrValue; @@ -1847,15 +1862,8 @@ void VwPropertyStore::CopyInheritedFrom(VwPropertyStore* pzvpsParent) m_fRightToLeft = pzvpsParent->m_fRightToLeft; m_chrp.nDirDepth = pzvpsParent->m_chrp.nDirDepth; m_stuFontVariations = pzvpsParent->m_stuFontVariations; - StrUni stuTmp = m_stuFontVariations; - while (stuTmp.Length() >= (isizeof(m_chrp.szFontVar) / isizeof(OLECHAR))) - { - // Pretruncate to avoid overflow. - // TODO (SharonC): Rework. - int ichComma = stuTmp.FindCh(L',', 0); - stuTmp = stuTmp.Right(stuTmp.Length() - ichComma - 1); - } - wcscpy_s(m_chrp.szFontVar, 64, stuTmp.Chars()); + CopyRenderableFontVariations(m_stuFontVariations, m_chrp.szFontVar, + _countof(m_chrp.szFontVar)); m_chrp.clrFore = pzvpsParent->m_chrp.clrFore; m_clrBorderColor = pzvpsParent->m_clrBorderColor; m_nMaxLines = pzvpsParent->m_nMaxLines; diff --git a/Src/views/lib/LayoutCache.h b/Src/views/lib/LayoutCache.h index 6dc1ed16b4..c1bc0b6f44 100644 --- a/Src/views/lib/LayoutCache.h +++ b/Src/views/lib/LayoutCache.h @@ -102,12 +102,31 @@ class ShapeRunEntry ::ZeroMemory(&m_sa, sizeof(m_sa)); } - bool Matches(const OLECHAR * prgch, int cch, HFONT hfont, const SCRIPT_ANALYSIS & sa) + static int CchFontVar(const OLECHAR * prgchFontVar) + { + if (!prgchFontVar) + return 0; + int cch = 0; + while (prgchFontVar[cch]) + ++cch; + return cch; + } + + bool Matches(const OLECHAR * prgch, int cch, HFONT hfont, const SCRIPT_ANALYSIS & sa, + const OLECHAR * prgchFontVar) { if (m_hfont != hfont || m_cch != cch) return false; if (::memcmp(&m_sa, &sa, sizeof(SCRIPT_ANALYSIS)) != 0) return false; + int cchFontVar = CchFontVar(prgchFontVar); + if (m_vchFontVar.Size() != cchFontVar) + return false; + if (cchFontVar > 0 && + ::memcmp(m_vchFontVar.Begin(), prgchFontVar, cchFontVar * isizeof(OLECHAR)) != 0) + { + return false; + } if (cch == 0) return true; return ::memcmp(m_vch.Begin(), prgch, cch * isizeof(OLECHAR)) == 0; @@ -120,6 +139,7 @@ class ShapeRunEntry int m_dxdWidth; bool m_fScriptPlaceFailed; Vector m_vch; + Vector m_vchFontVar; Vector m_vglyph; Vector m_vsva; Vector m_vadvance; @@ -286,12 +306,13 @@ class ShapeRunCache m_msCompute = 0; } - ShapeRunEntry * Find(const OLECHAR * prgch, int cch, HFONT hfont, const SCRIPT_ANALYSIS & sa) + ShapeRunEntry * Find(const OLECHAR * prgch, int cch, HFONT hfont, const SCRIPT_ANALYSIS & sa, + const OLECHAR * prgchFontVar) { for (int ientry = 0; ientry < m_ventry.Size(); ++ientry) { ShapeRunEntry & entry = m_ventry[ientry]; - if (entry.Matches(prgch, cch, hfont, sa)) + if (entry.Matches(prgch, cch, hfont, sa, prgchFontVar)) { ++m_cHit; return &entry; @@ -302,6 +323,7 @@ class ShapeRunCache } ShapeRunEntry * Store(const OLECHAR * prgch, int cch, HFONT hfont, const SCRIPT_ANALYSIS & sa, + const OLECHAR * prgchFontVar, const WORD * prgGlyph, const SCRIPT_VISATTR * prgsva, const int * prgAdvance, const int * prgcst, const GOFFSET * prgoff, const WORD * prgCluster, int cglyph, int dxdWidth, bool fScriptPlaceFailed) @@ -310,7 +332,7 @@ class ShapeRunCache for (int ientry = 0; ientry < m_ventry.Size(); ++ientry) { ShapeRunEntry & entry = m_ventry[ientry]; - if (entry.Matches(prgch, cch, hfont, sa)) + if (entry.Matches(prgch, cch, hfont, sa, prgchFontVar)) { pentry = &entry; break; @@ -344,6 +366,11 @@ class ShapeRunCache if (cch > 0) ::memcpy(pentry->m_vch.Begin(), prgch, cch * isizeof(OLECHAR)); + int cchFontVar = ShapeRunEntry::CchFontVar(prgchFontVar); + pentry->m_vchFontVar.Resize(cchFontVar); + if (cchFontVar > 0) + ::memcpy(pentry->m_vchFontVar.Begin(), prgchFontVar, cchFontVar * isizeof(OLECHAR)); + pentry->m_vglyph.Resize(cglyph); pentry->m_vsva.Resize(cglyph); pentry->m_vadvance.Resize(cglyph); @@ -450,4 +477,4 @@ inline LayoutPassCache * SetCurrentLayoutPassCache(LayoutPassCache * pLayoutPass return pPrev; } -#endif // LAYOUTCACHE_INCLUDED \ No newline at end of file +#endif // LAYOUTCACHE_INCLUDED diff --git a/Src/views/lib/UniscribeEngine.cpp b/Src/views/lib/UniscribeEngine.cpp index 4ff897ec08..570217645b 100644 --- a/Src/views/lib/UniscribeEngine.cpp +++ b/Src/views/lib/UniscribeEngine.cpp @@ -475,6 +475,7 @@ STDMETHODIMP UniscribeEngine::FindBreakPoint( int ichMinNfc = ichLimNfc; // Min of this run is lim of previous (or 0 for first run). IRenderEnginePtr qreneng; + StrUni stuIcuLocale; // This updates ichLim to the end of the next run that has the same character // properties as the one at ichMin. Then we proceed to reduce this if necessary @@ -492,6 +493,11 @@ STDMETHODIMP UniscribeEngine::FindBreakPoint( { ILgWritingSystemPtr qLgWritingSystem; AssertPtr(m_qwsf); + SmartBstr sbstrLocale; + HRESULT hrLocale; + IgnoreHr(hrLocale = m_qwsf->GetIcuLocaleFromWs(chrp.ws, &sbstrLocale)); + if (SUCCEEDED(hrLocale) && BstrLen(sbstrLocale) > 0) + stuIcuLocale.Assign(sbstrLocale.Chars(), BstrLen(sbstrLocale)); CheckHr(m_qwsf->get_EngineOrNull(chrp.ws, &qLgWritingSystem)); AssertPtr(qLgWritingSystem); CheckHr(qLgWritingSystem->InterpretChrp(&chrp)); @@ -543,6 +549,11 @@ STDMETHODIMP UniscribeEngine::FindBreakPoint( uri.cch = ichLimText; } uri.psa = &pscri->a; + int iscriptItem = static_cast(pscri - UniscribeSegment::g_vscri.Begin()); + uri.otTagScript = (iscriptItem >= 0 && + iscriptItem < UniscribeSegment::g_votScriptTags.Size()) + ? UniscribeSegment::g_votScriptTags[iscriptItem] : 0; + uri.stuIcuLocale.Assign(stuIcuLocale.Chars(), stuIcuLocale.Length()); uri.pvg = pvg; ichMinUri = ichMinNfc; diff --git a/Src/views/lib/UniscribeSegment.cpp b/Src/views/lib/UniscribeSegment.cpp index 77176a7216..5f85fcf72a 100644 --- a/Src/views/lib/UniscribeSegment.cpp +++ b/Src/views/lib/UniscribeSegment.cpp @@ -17,6 +17,7 @@ Last reviewed: Not yet. #pragma hdrstop // any other headers (not precompiled) #include "LayoutCache.h" +#include #undef THIS_FILE DEFINE_THIS_FILE @@ -39,10 +40,506 @@ static DummyFactory g_fact(_T("SIL.Language1.UniscribeSeg")); // Vector to hold UniscribeRunInfos in DoAllRuns() static Vector g_vuri; +typedef ULONG FwOpenTypeTag; + +struct FwOpenTypeFeatureRecord +{ + FwOpenTypeTag tagFeature; + LONG lParameter; +}; + +struct FwTextRangeProperties +{ + FwOpenTypeFeatureRecord * potfRecords; + int cotfRecords; +}; + +struct FwScriptCharProp +{ + WORD fCanGlyphAlone : 1; + WORD reserved : 15; +}; + +struct FwScriptGlyphProp +{ + SCRIPT_VISATTR sva; + WORD reserved; +}; + +typedef HRESULT (WINAPI * FwScriptShapeOpenTypeProc)(HDC, SCRIPT_CACHE *, SCRIPT_ANALYSIS *, + FwOpenTypeTag, FwOpenTypeTag, int *, FwTextRangeProperties **, int, const WCHAR *, int, + int, WORD *, FwScriptCharProp *, WORD *, FwScriptGlyphProp *, int *); +typedef HRESULT (WINAPI * FwScriptPlaceOpenTypeProc)(HDC, SCRIPT_CACHE *, SCRIPT_ANALYSIS *, + FwOpenTypeTag, FwOpenTypeTag, int *, FwTextRangeProperties **, int, const WCHAR *, WORD *, + FwScriptCharProp *, int, WORD *, FwScriptGlyphProp *, int, int *, GOFFSET *, ABC *); +typedef HRESULT (WINAPI * FwScriptItemizeOpenTypeProc)(const WCHAR *, int, int, + const SCRIPT_CONTROL *, const SCRIPT_STATE *, SCRIPT_ITEM *, FwOpenTypeTag *, int *); +typedef HRESULT (WINAPI * FwScriptGetFontScriptTagsProc)(HDC, SCRIPT_CACHE *, SCRIPT_ANALYSIS *, + int, FwOpenTypeTag *, int *); +typedef HRESULT (WINAPI * FwScriptGetFontLanguageTagsProc)(HDC, SCRIPT_CACHE *, SCRIPT_ANALYSIS *, + FwOpenTypeTag, int, FwOpenTypeTag *, int *); +typedef int (WINAPI * FwGetLocaleInfoExProc)(LPCWSTR, LCTYPE, LPWSTR, int); + +#ifndef LOCALE_SOPENTYPELANGUAGETAG +#define LOCALE_SOPENTYPELANGUAGETAG 0x0000007a +#endif + +static int CallGetLocaleInfoEx(LPCWSTR pszLocale, LCTYPE type, LPWSTR pszData, int cchData) +{ + static bool s_fTried = false; + static FwGetLocaleInfoExProc s_pfnGetLocaleInfoEx = NULL; + if (!s_fTried) + { + HMODULE hKernel32 = ::GetModuleHandle(L"kernel32.dll"); + if (hKernel32) + { + s_pfnGetLocaleInfoEx = reinterpret_cast( + ::GetProcAddress(hKernel32, "GetLocaleInfoEx")); + } + s_fTried = true; + } + return s_pfnGetLocaleInfoEx ? s_pfnGetLocaleInfoEx(pszLocale, type, pszData, cchData) : 0; +} + +static FwOpenTypeTag MakeOpenTypeTag(const OLECHAR * prgchTag) +{ + return static_cast( + (static_cast(prgchTag[0])) | + (static_cast(prgchTag[1]) << 8) | + (static_cast(prgchTag[2]) << 16) | + (static_cast(prgchTag[3]) << 24)); +} + +static bool IsOpenTypeTagChar(OLECHAR ch) +{ + return ch >= 0x20 && ch <= 0x7e; +} + +static void OpenTypeTagToDebugText(FwOpenTypeTag tag, OLECHAR * prgchTag, int cchTag) +{ + if (cchTag < 5) + return; + + if (!tag) + { + wcscpy_s(prgchTag, cchTag, L"----"); + return; + } + + for (int ich = 0; ich < 4; ++ich) + { + OLECHAR ch = static_cast((tag >> (ich * 8)) & 0xff); + prgchTag[ich] = IsOpenTypeTagChar(ch) ? ch : L'?'; + } + prgchTag[4] = 0; +} + +static void TraceOpenTypeRenderEvent(const OLECHAR * pszMessage, HRESULT hr, + FwOpenTypeTag tagScript, FwOpenTypeTag tagLanguage, int cglyphMax) +{ +#if defined(WIN32) || defined(WIN64) || defined(_M_X64) + OLECHAR rgchScript[5]; + OLECHAR rgchLanguage[5]; + OpenTypeTagToDebugText(tagScript, rgchScript, _countof(rgchScript)); + OpenTypeTagToDebugText(tagLanguage, rgchLanguage, _countof(rgchLanguage)); + StrUni stu; + stu.Format(L"UniscribeSegment OpenType: %s (hr=0x%08x, script='%s', language='%s', glyphMax=%d).\n", + pszMessage, static_cast(hr), rgchScript, rgchLanguage, cglyphMax); + ::OutputDebugStringW(stu.Chars()); +#else + (void)pszMessage; + (void)hr; + (void)tagScript; + (void)tagLanguage; + (void)cglyphMax; +#endif +} + +static bool TryParseFontFeatureRecords(const OLECHAR * prgchFontVar, + Vector & vfeatureRecords) +{ + vfeatureRecords.Delete(0, vfeatureRecords.Size()); + if (!prgchFontVar || !prgchFontVar[0]) + return false; + + const OLECHAR * pch = prgchFontVar; + while (*pch) + { + while (*pch == L' ' || *pch == L',') + ++pch; + if (!*pch) + break; + + const OLECHAR * pchTag = pch; + int cchTag = 0; + while (pch[cchTag] && pch[cchTag] != L'=' && pch[cchTag] != L',' && pch[cchTag] != L' ') + ++cchTag; + + if (cchTag != 4 || !IsOpenTypeTagChar(pchTag[0]) || !IsOpenTypeTagChar(pchTag[1]) || + !IsOpenTypeTagChar(pchTag[2]) || !IsOpenTypeTagChar(pchTag[3])) + { + while (*pch && *pch != L',') + ++pch; + continue; + } + + pch += cchTag; + while (*pch == L' ') + ++pch; + if (*pch != L'=') + { + while (*pch && *pch != L',') + ++pch; + continue; + } + ++pch; + while (*pch == L' ') + ++pch; + + long value = 0; + bool fHaveDigit = false; + while (*pch >= L'0' && *pch <= L'9') + { + int digit = *pch - L'0'; + if (value > (LONG_MAX - digit) / 10) + { + fHaveDigit = false; + while (*pch >= L'0' && *pch <= L'9') + ++pch; + break; + } + fHaveDigit = true; + value = value * 10 + digit; + ++pch; + } + + if (fHaveDigit) + { + FwOpenTypeFeatureRecord record; + record.tagFeature = MakeOpenTypeTag(pchTag); + record.lParameter = value; + vfeatureRecords.Push(record); + } + + while (*pch && *pch != L',') + ++pch; + } + + return vfeatureRecords.Size() > 0; +} + +static bool TextRangeHasOpenTypeFeatures(IVwTextSource * pts, int ichMin, int cch) +{ + int ich = ichMin; + int ichLim = ichMin + cch; + Vector vfeatureRecords; + while (ich < ichLim) + { + LgCharRenderProps chrp; + ZeroMemory(&chrp, isizeof(chrp)); + int ichMinRun; + int ichLimRun; + CheckHr(pts->GetCharProps(ich, &chrp, &ichMinRun, &ichLimRun)); + if (TryParseFontFeatureRecords(chrp.szFontVar, vfeatureRecords)) + return true; + if (ichLimRun <= ich) + break; + ich = min(ichLimRun, ichLim); + } + return false; +} + +static void GetOpenTypeProcs(FwScriptShapeOpenTypeProc * ppfnShape, + FwScriptPlaceOpenTypeProc * ppfnPlace, FwScriptItemizeOpenTypeProc * ppfnItemize = NULL, + FwScriptGetFontScriptTagsProc * ppfnScriptTags = NULL, + FwScriptGetFontLanguageTagsProc * ppfnLanguageTags = NULL) +{ + static bool s_fTried = false; + static FwScriptShapeOpenTypeProc s_pfnShape = NULL; + static FwScriptPlaceOpenTypeProc s_pfnPlace = NULL; + static FwScriptItemizeOpenTypeProc s_pfnItemize = NULL; + static FwScriptGetFontScriptTagsProc s_pfnScriptTags = NULL; + static FwScriptGetFontLanguageTagsProc s_pfnLanguageTags = NULL; + if (!s_fTried) + { + HMODULE hUsp10 = ::GetModuleHandle(L"usp10.dll"); + if (!hUsp10) + hUsp10 = ::LoadLibrary(L"usp10.dll"); + if (hUsp10) + { + s_pfnShape = reinterpret_cast( + ::GetProcAddress(hUsp10, "ScriptShapeOpenType")); + s_pfnPlace = reinterpret_cast( + ::GetProcAddress(hUsp10, "ScriptPlaceOpenType")); + s_pfnItemize = reinterpret_cast( + ::GetProcAddress(hUsp10, "ScriptItemizeOpenType")); + s_pfnScriptTags = reinterpret_cast( + ::GetProcAddress(hUsp10, "ScriptGetFontScriptTags")); + s_pfnLanguageTags = reinterpret_cast( + ::GetProcAddress(hUsp10, "ScriptGetFontLanguageTags")); + } + s_fTried = true; + } + if (ppfnShape) + *ppfnShape = s_pfnShape; + if (ppfnPlace) + *ppfnPlace = s_pfnPlace; + if (ppfnItemize) + *ppfnItemize = s_pfnItemize; + if (ppfnScriptTags) + *ppfnScriptTags = s_pfnScriptTags; + if (ppfnLanguageTags) + *ppfnLanguageTags = s_pfnLanguageTags; +} + +static bool ContainsOpenTypeTag(const Vector & vtags, FwOpenTypeTag tag) +{ + for (int itag = 0; itag < vtags.Size(); ++itag) + { + if (vtags[itag] == tag) + return true; + } + return false; +} + +static void AddOpenTypeTag(Vector & vtags, FwOpenTypeTag tag) +{ + if (tag && !ContainsOpenTypeTag(vtags, tag)) + vtags.Push(tag); +} + +static void GetFontScriptTags(UniscribeRunInfo & uri, FwScriptGetFontScriptTagsProc pfnScriptTags, + Vector & vtags) +{ + if (!pfnScriptTags) + return; + int ctagMax = 8; + Vector vtagBuffer; + for (;;) + { + vtagBuffer.Resize(ctagMax); + int ctag = 0; + HRESULT hr; + IgnoreHr(hr = pfnScriptTags(uri.hdc, &uri.sc, uri.psa, ctagMax, vtagBuffer.Begin(), &ctag)); + if (hr == E_OUTOFMEMORY) + { + ctagMax *= 2; + continue; + } + if (SUCCEEDED(hr)) + { + for (int itag = 0; itag < ctag; ++itag) + AddOpenTypeTag(vtags, vtagBuffer[itag]); + } + break; + } +} + +static void GetFontLanguageTags(UniscribeRunInfo & uri, + FwScriptGetFontLanguageTagsProc pfnLanguageTags, FwOpenTypeTag tagScript, + Vector & vtags) +{ + vtags.Delete(0, vtags.Size()); + if (!pfnLanguageTags || !tagScript) + return; + int ctagMax = 8; + for (;;) + { + vtags.Resize(ctagMax); + int ctag = 0; + HRESULT hr; + IgnoreHr(hr = pfnLanguageTags(uri.hdc, &uri.sc, uri.psa, tagScript, ctagMax, + vtags.Begin(), &ctag)); + if (hr == E_OUTOFMEMORY) + { + ctagMax *= 2; + continue; + } + if (SUCCEEDED(hr)) + vtags.Resize(ctag); + else + vtags.Delete(0, vtags.Size()); + break; + } +} + +static FwOpenTypeTag LanguageTagFromLocale(const StrUni & stuIcuLocale) +{ + if (!stuIcuLocale.Length()) + return 0; + + OLECHAR rgchLocale[LOCALE_NAME_MAX_LENGTH]; + int cchLocale = min(stuIcuLocale.Length(), LOCALE_NAME_MAX_LENGTH - 1); + for (int ich = 0; ich < cchLocale; ++ich) + { + OLECHAR ch = stuIcuLocale.GetAt(ich); + rgchLocale[ich] = ch == L'_' ? L'-' : ch; + } + rgchLocale[cchLocale] = 0; + + OLECHAR rgchAbbrev[16]; + int cchAbbrev = CallGetLocaleInfoEx(rgchLocale, LOCALE_SOPENTYPELANGUAGETAG, rgchAbbrev, + _countof(rgchAbbrev)); + if (cchAbbrev >= 5 && IsOpenTypeTagChar(rgchAbbrev[0]) && IsOpenTypeTagChar(rgchAbbrev[1]) && + IsOpenTypeTagChar(rgchAbbrev[2]) && IsOpenTypeTagChar(rgchAbbrev[3])) + { + return MakeOpenTypeTag(rgchAbbrev); + } + + cchAbbrev = CallGetLocaleInfoEx(rgchLocale, LOCALE_SABBREVLANGNAME, rgchAbbrev, + _countof(rgchAbbrev)); + if (cchAbbrev <= 1) + return 0; + + OLECHAR rgchTag[4] = { L' ', L' ', L' ', L' ' }; + int cchTag = min(cchAbbrev - 1, 3); + if (cchTag < 2) + return 0; + for (int ich = 0; ich < cchTag; ++ich) + { + OLECHAR ch = rgchAbbrev[ich]; + if (ch >= L'a' && ch <= L'z') + ch = static_cast(ch - L'a' + L'A'); + if (ch < L'A' || ch > L'Z') + return 0; + rgchTag[ich] = ch; + } + return MakeOpenTypeTag(rgchTag); +} + +static void BuildLanguageCandidates(UniscribeRunInfo & uri, + FwScriptGetFontLanguageTagsProc pfnLanguageTags, FwOpenTypeTag tagScript, + Vector & vtags) +{ + vtags.Delete(0, vtags.Size()); + Vector vfontLanguageTags; + GetFontLanguageTags(uri, pfnLanguageTags, tagScript, vfontLanguageTags); + + FwOpenTypeTag tagLocale = LanguageTagFromLocale(uri.stuIcuLocale); + if (tagLocale && (!pfnLanguageTags || vfontLanguageTags.Size() == 0 || + ContainsOpenTypeTag(vfontLanguageTags, tagLocale))) + { + AddOpenTypeTag(vtags, tagLocale); + } + vtags.Push(0); + if (!tagLocale && vfontLanguageTags.Size() == 1) + AddOpenTypeTag(vtags, vfontLanguageTags[0]); +} + +static bool ShapePlaceRunWithOpenType(UniscribeRunInfo & uri, int & cglyphMax, + Vector & vfeatureRecords, ABC & abc) +{ + FwScriptShapeOpenTypeProc pfnShape; + FwScriptPlaceOpenTypeProc pfnPlace; + FwScriptGetFontScriptTagsProc pfnScriptTags; + FwScriptGetFontLanguageTagsProc pfnLanguageTags; + GetOpenTypeProcs(&pfnShape, &pfnPlace, NULL, &pfnScriptTags, &pfnLanguageTags); + if (!pfnShape || !pfnPlace) + { + TraceOpenTypeRenderEvent(L"ScriptShapeOpenType or ScriptPlaceOpenType is unavailable; falling back", + E_NOTIMPL, 0, 0, cglyphMax); + return false; + } + + Vector vcharProps; + Vector vglyphProps; + Vector vrgich; + vcharProps.Resize(uri.cch); + vrgich.Resize(1); + vrgich[0] = uri.cch; + + FwTextRangeProperties rangeProperties; + rangeProperties.potfRecords = vfeatureRecords.Begin(); + rangeProperties.cotfRecords = vfeatureRecords.Size(); + FwTextRangeProperties * prangeProperties = &rangeProperties; + + static const OLECHAR rgchDflt[] = { L'D', L'F', L'L', L'T', 0 }; + static const OLECHAR rgchLatn[] = { L'l', L'a', L't', L'n', 0 }; + Vector vscriptTags; + AddOpenTypeTag(vscriptTags, uri.otTagScript); + GetFontScriptTags(uri, pfnScriptTags, vscriptTags); + AddOpenTypeTag(vscriptTags, MakeOpenTypeTag(rgchDflt)); + AddOpenTypeTag(vscriptTags, MakeOpenTypeTag(rgchLatn)); + for (;;) + { + vglyphProps.Resize(cglyphMax); + HRESULT hr = E_FAIL; + FwOpenTypeTag tagLastScript = 0; + FwOpenTypeTag tagLastLanguage = 0; + bool fRetryWithLargerGlyphBuffer = false; + for (int iscriptTag = 0; iscriptTag < vscriptTags.Size(); ++iscriptTag) + { + Vector vlanguageTags; + BuildLanguageCandidates(uri, pfnLanguageTags, vscriptTags[iscriptTag], vlanguageTags); + for (int ilanguageTag = 0; ilanguageTag < vlanguageTags.Size(); ++ilanguageTag) + { + tagLastScript = vscriptTags[iscriptTag]; + tagLastLanguage = vlanguageTags[ilanguageTag]; + DISABLE_MULTISCRIBE + { + IgnoreHr(hr = pfnShape(uri.hdc, &uri.sc, uri.psa, tagLastScript, + tagLastLanguage, vrgich.Begin(), &prangeProperties, 1, uri.prgch, + uri.cch, cglyphMax, uri.prgCluster, vcharProps.Begin(), uri.prgGlyph, + vglyphProps.Begin(), &uri.cglyph)); + } + if (hr == E_OUTOFMEMORY) + { + cglyphMax *= 2; + uri.UpdateGlyphSize(cglyphMax); + TraceOpenTypeRenderEvent( + L"ScriptShapeOpenType requested a larger glyph buffer; retrying", + hr, tagLastScript, tagLastLanguage, cglyphMax); + fRetryWithLargerGlyphBuffer = true; + break; + } + if (FAILED(hr)) + continue; + + DISABLE_MULTISCRIBE + { + IgnoreHr(hr = pfnPlace(uri.hdc, &uri.sc, uri.psa, tagLastScript, + tagLastLanguage, vrgich.Begin(), &prangeProperties, 1, uri.prgch, + uri.prgCluster, vcharProps.Begin(), uri.cch, uri.prgGlyph, + vglyphProps.Begin(), uri.cglyph, uri.prgAdvance, uri.prgoff, &abc)); + } + if (hr == E_OUTOFMEMORY) + { + cglyphMax *= 2; + uri.UpdateGlyphSize(cglyphMax); + TraceOpenTypeRenderEvent( + L"ScriptPlaceOpenType requested a larger glyph buffer; retrying", + hr, tagLastScript, tagLastLanguage, cglyphMax); + fRetryWithLargerGlyphBuffer = true; + break; + } + if (SUCCEEDED(hr)) + { + for (int iglyph = 0; iglyph < uri.cglyph; ++iglyph) + uri.prgsva[iglyph] = vglyphProps[iglyph].sva; + uri.fScriptPlaceFailed = false; + TraceOpenTypeRenderEvent(L"selected OpenType shaping path", S_OK, + tagLastScript, tagLastLanguage, cglyphMax); + return true; + } + } + if (fRetryWithLargerGlyphBuffer) + break; + } + if (fRetryWithLargerGlyphBuffer) + continue; + + uri.fScriptPlaceFailed = true; + TraceOpenTypeRenderEvent(L"OpenType shaping failed; falling back to classic Uniscribe", + hr, tagLastScript, tagLastLanguage, cglyphMax); + return false; + } +} + // cache of SCRIPT_CACHE values accessed by LgCharRenderProps. UniscribeSegment::FwScriptCache UniscribeSegment::g_fsc; ScrItemVec UniscribeSegment::g_vscri; // vector of script items from ScriptItemize. +OpenTypeTagVec UniscribeSegment::g_votScriptTags; // OpenType script tags parallel to g_vscri. int UniscribeSegment::g_cscri; // number of valid items in ScriptItemize. //:>******************************************************************************************** @@ -62,6 +559,7 @@ UniscribeRunInfo::UniscribeRunInfo(int cglyphMax, int cClusterMax) rcDst = Rect(); pchrp = NULL; psa = NULL; + otTagScript = 0; sc = NULL; dxdWidth = 0; fScriptPlaceFailed = false; @@ -95,6 +593,8 @@ UniscribeRunInfo::UniscribeRunInfo(const UniscribeRunInfo& oriUri) rcSrc = oriUri.rcSrc; rcDst = oriUri.rcDst; psa = oriUri.psa; + otTagScript = oriUri.otTagScript; + stuIcuLocale.Assign(oriUri.stuIcuLocale.Chars(), oriUri.stuIcuLocale.Length()); sc = oriUri.sc; dxdWidth = oriUri.dxdWidth; fScriptPlaceFailed = oriUri.fScriptPlaceFailed; @@ -287,11 +787,16 @@ STDMETHODIMP UniscribeSegment::QueryInterface(REFIID riid, void **ppv) void UniscribeSegment::ShapePlaceRun(UniscribeRunInfo& uri, bool fCreatingSeg) { HRESULT hr; - LayoutPassCache * pLayoutPassCache = IsPath1ShapeCacheEnabled() ? GetCurrentLayoutPassCache() : NULL; + const OLECHAR * prgchFontVar = uri.pchrp ? uri.pchrp->szFontVar : NULL; + Vector vfeatureRecords; + bool fUseOpenTypeFeatures = TryParseFontFeatureRecords(prgchFontVar, vfeatureRecords); + LayoutPassCache * pLayoutPassCache = (!fUseOpenTypeFeatures && IsPath1ShapeCacheEnabled()) ? + GetCurrentLayoutPassCache() : NULL; HFONT hfont = (HFONT)::GetCurrentObject(uri.hdc, OBJ_FONT); if (pLayoutPassCache && uri.psa) { - ShapeRunEntry * pShapeEntry = pLayoutPassCache->ShapeCache().Find(uri.prgch, uri.cch, hfont, *uri.psa); + ShapeRunEntry * pShapeEntry = pLayoutPassCache->ShapeCache().Find(uri.prgch, uri.cch, + hfont, *uri.psa, prgchFontVar); if (pShapeEntry) { uri.sc = g_fsc.FindScriptCache(uri); @@ -315,7 +820,6 @@ void UniscribeSegment::ShapePlaceRun(UniscribeRunInfo& uri, bool fCreatingSeg) uri.UpdateClusterSize(uri.cch + 100); // reduce # of resize calls } SCRIPT_CACHE sc = uri.sc = g_fsc.FindScriptCache(/**uri.pchrp*/uri); - #if !defined(_WIN32) && !defined(_M_X64) // Associate VwGraphics with the cache as Linux uniscribe implementation needs it. IVwGraphicsWin32Ptr qvg32; @@ -323,9 +827,17 @@ void UniscribeSegment::ShapePlaceRun(UniscribeRunInfo& uri, bool fCreatingSeg) SetCachesVwGraphics(&uri.sc, qvg32); #endif + bool fOpenTypePlaced = false; + ABC abcOpenType; // loop to try ScriptShape multiple times for (;;) { + if (fUseOpenTypeFeatures && ShapePlaceRunWithOpenType(uri, cglyphMax, vfeatureRecords, abcOpenType)) + { + fOpenTypePlaced = true; + break; + } + DISABLE_MULTISCRIBE { IgnoreHr(hr = ::ScriptShape(uri.hdc, &uri.sc, uri.prgch, uri.cch, cglyphMax, uri.psa, @@ -383,10 +895,18 @@ void UniscribeSegment::ShapePlaceRun(UniscribeRunInfo& uri, bool fCreatingSeg) // Having generated glyphs, now generate advance widths and combining // offsets. ABC abc; // Run combined ABC - DISABLE_MULTISCRIBE + if (fOpenTypePlaced) { - IgnoreHr(hr = ::ScriptPlace(uri.hdc, &uri.sc, uri.prgGlyph, uri.cglyph, uri.prgsva, - uri.psa, uri.prgAdvance, uri.prgoff, &abc)); + abc = abcOpenType; + hr = S_OK; + } + else + { + DISABLE_MULTISCRIBE + { + IgnoreHr(hr = ::ScriptPlace(uri.hdc, &uri.sc, uri.prgGlyph, uri.cglyph, uri.prgsva, + uri.psa, uri.prgAdvance, uri.prgoff, &abc)); + } } uri.fScriptPlaceFailed = FAILED(hr); if (FAILED(hr)) @@ -439,10 +959,10 @@ void UniscribeSegment::ShapePlaceRun(UniscribeRunInfo& uri, bool fCreatingSeg) { pLayoutPassCache->ShapeCache().AddComputeMs(::GetTickCount() - dwStartMs); pLayoutPassCache->ShapeCache().Store(uri.prgch, uri.cch, hfont, *uri.psa, + prgchFontVar, uri.prgGlyph, uri.prgsva, uri.prgAdvance, uri.prgcst, uri.prgoff, uri.prgCluster, uri.cglyph, uri.dxdWidth, uri.fScriptPlaceFailed); } - if (uri.sc && uri.sc != sc) { g_fsc.StoreScriptCache(/**uri.pchrp, uri.sc*/uri); @@ -2578,7 +3098,9 @@ int UniscribeSegment::CallScriptItemize(OLECHAR * prgchDefBuf, int cchBuf, if (ppAnalysis) *ppAnalysis = NULL; - LayoutPassCache * pLayoutPassCache = IsPath2AnalysisCacheEnabled() ? GetCurrentLayoutPassCache() : NULL; + bool fNeedOpenTypeScriptTags = cch > 0 && TextRangeHasOpenTypeFeatures(pts, ichMin, cch); + LayoutPassCache * pLayoutPassCache = (!fNeedOpenTypeScriptTags && IsPath2AnalysisCacheEnabled()) ? + GetCurrentLayoutPassCache() : NULL; TextAnalysisEntry * pCachedAnalysis = NULL; int cchOrig = cch; int ws = 0; @@ -2598,6 +3120,10 @@ int UniscribeSegment::CallScriptItemize(OLECHAR * prgchDefBuf, int cchBuf, *pfTextIsNfc = pCachedAnalysis->m_fTextIsNfc; *pprgchBuf = pCachedAnalysis->m_vchNfc.Size() > 0 ? pCachedAnalysis->m_vchNfc.Begin() : prgchDefBuf; pCachedAnalysis->CopyScriptItemsTo(g_vscri, citem); + if (g_votScriptTags.Size() < citem) + g_votScriptTags.Resize(citem); + for (int itag = 0; itag < citem; ++itag) + g_votScriptTags[itag] = 0; g_cscri = citem; if (ppAnalysis) *ppAnalysis = pCachedAnalysis; @@ -2669,6 +3195,8 @@ int UniscribeSegment::CallScriptItemize(OLECHAR * prgchDefBuf, int cchBuf, citemMax = 100; // default starting size g_vscri.Resize(citemMax); } + if (g_votScriptTags.Size() < citemMax) + g_votScriptTags.Resize(citemMax); // ComBool fBaseRtl; // CheckHr(pts->GetBaseRtl(&fBaseRtl)); @@ -2715,31 +3243,65 @@ typedef struct tag_SCRIPT_STATE { WORD(fwsRtl ? 1 : 0), false, false, false, false, false, false, fIsArabic, false, false, 0 }; + bool fUsedOpenTypeItemize = false; if (cch) // Can only call if at least one char. { DISABLE_MULTISCRIBE { - for (;;) + FwScriptItemizeOpenTypeProc pfnItemize; + GetOpenTypeProcs(NULL, NULL, &pfnItemize); + if (fNeedOpenTypeScriptTags && pfnItemize) { - HRESULT hr; - IgnoreHr(hr = ::ScriptItemize(*pprgchBuf, cch, citemMax, - &scon, //NULL, // default SCRIPT_CONTROL - &ss, - g_vscri.Begin(), - &citem)); - - if (hr == E_OUTOFMEMORY) + for (;;) { - citemMax *= 2; // try twice as much - g_vscri.Resize(citemMax); // will fail if really out of memory - continue; + HRESULT hr; + IgnoreHr(hr = pfnItemize(*pprgchBuf, cch, citemMax, + &scon, + &ss, + g_vscri.Begin(), + g_votScriptTags.Begin(), + &citem)); + + if (hr == E_OUTOFMEMORY) + { + citemMax *= 2; + g_vscri.Resize(citemMax); + g_votScriptTags.Resize(citemMax); + continue; + } + if (SUCCEEDED(hr)) + fUsedOpenTypeItemize = true; + break; } - if (FAILED(hr)) + } + + if (!fUsedOpenTypeItemize) + { + for (;;) { - ThrowHr(WarnHr(hr), L"ScriptItemize failed"); + HRESULT hr; + IgnoreHr(hr = ::ScriptItemize(*pprgchBuf, cch, citemMax, + &scon, //NULL, // default SCRIPT_CONTROL + &ss, + g_vscri.Begin(), + &citem)); + + if (hr == E_OUTOFMEMORY) + { + citemMax *= 2; // try twice as much + g_vscri.Resize(citemMax); // will fail if really out of memory + g_votScriptTags.Resize(citemMax); + continue; + } + if (FAILED(hr)) + { + ThrowHr(WarnHr(hr), L"ScriptItemize failed"); + } + for (int itag = 0; itag < citem; ++itag) + g_votScriptTags[itag] = 0; + break; } - break; } } } @@ -2748,6 +3310,7 @@ typedef struct tag_SCRIPT_STATE { citem = 0; g_vscri[0].iCharPos = 0; g_vscri[1].iCharPos = 0; + g_votScriptTags[0] = 0; } g_cscri = citem; @@ -2780,6 +3343,21 @@ void UniscribeSegment::InterpretChrp(LgCharRenderProps &chrp) } } +void UniscribeSegment::GetIcuLocale(int ws, StrUni & stuIcuLocale) +{ + stuIcuLocale.Assign(L""); + ILgWritingSystemFactoryPtr qLgWritingSystemFactory; + CheckHr(m_qure->get_WritingSystemFactory(&qLgWritingSystemFactory)); + if (!qLgWritingSystemFactory) + return; + + SmartBstr sbstrLocale; + HRESULT hr; + IgnoreHr(hr = qLgWritingSystemFactory->GetIcuLocaleFromWs(ws, &sbstrLocale)); + if (SUCCEEDED(hr) && BstrLen(sbstrLocale) > 0) + stuIcuLocale.Assign(sbstrLocale.Chars(), BstrLen(sbstrLocale)); +} + /*---------------------------------------------------------------------------------------------- Invoke the functor for every (non-empty) run of the segment. f(uri) is called with the chars of each run covered by the segment. @@ -2866,8 +3444,10 @@ template int UniscribeSegment::DoAllRuns(int ichBase, IVwGraphics * pv int ichMinNfc = ichLimNfc; LgCharRenderProps chrp; + StrUni stuIcuLocale; int ichMinDum; // for GetCharProps to return CheckHr(m_qts->GetCharProps(ichMin, &chrp, &ichMinDum, &ichLim)); + GetIcuLocale(chrp.ws, stuIcuLocale); InterpretChrp(chrp); CheckHr(pvg->SetupGraphics(&chrp)); @@ -2913,6 +3493,10 @@ template int UniscribeSegment::DoAllRuns(int ichBase, IVwGraphics * pv uri.prgch = prgchBuf + ichMinRun; // Get the characters of the run, if any uri.cch = cch; uri.psa = &pscri->a; + int iscriptItem = static_cast(pscri - g_vscri.Begin()); + uri.otTagScript = (iscriptItem >= 0 && iscriptItem < g_votScriptTags.Size()) + ? g_votScriptTags[iscriptItem] : 0; + uri.stuIcuLocale.Assign(stuIcuLocale.Chars(), stuIcuLocale.Length()); uri.dxdStretch = dxdStretchRemaining; // default for last seg uri.fLast = ichLimRun == ichLimNfc && ichLimNfc == cchNfc; // This is the last char group if it reaches the last surface character. diff --git a/Src/views/lib/UniscribeSegment.h b/Src/views/lib/UniscribeSegment.h index 297fbe0bb5..a62b44c3f1 100644 --- a/Src/views/lib/UniscribeSegment.h +++ b/Src/views/lib/UniscribeSegment.h @@ -28,6 +28,7 @@ typedef Vector IntVec; // Hungarian vi (or specific meaning) typedef Vector OffsetVec; // Hungarian voff typedef Vector ScrItemVec; // Hungarian vscri; typedef Vector ScrLogAttrVec; // Hungarian vsla. +typedef Vector OpenTypeTagVec; // Hungarian vot. class TextAnalysisEntry; @@ -63,6 +64,8 @@ class UniscribeRunInfo Rect rcSrc; Rect rcDst; // ccordinate transformation LgCharRenderProps * pchrp; // char props desired for run (already set in pvg) SCRIPT_ANALYSIS * psa; // script analysis for the run + ULONG otTagScript; // OpenType script tag from ScriptItemizeOpenType, or 0. + StrUni stuIcuLocale; // SLDR/ICU locale for language-system selection. int cglyph; WORD * prgGlyph; // cglyph glyphs (from ScriptShape) @@ -244,6 +247,7 @@ class UniscribeSegment : public ILgSegment protected: // Static variables static ScrItemVec g_vscri; // vector of script items from ScriptItemize. + static OpenTypeTagVec g_votScriptTags; // OpenType script tags parallel to g_vscri. static int g_cscri; // number of valid items in ScriptItemize. // Member variables @@ -315,6 +319,7 @@ class UniscribeSegment : public ILgSegment void FindValidIPForward(int ichBase, int * pich); void InterpretChrp(LgCharRenderProps & chrp); + void GetIcuLocale(int ws, StrUni & stuIcuLocale); /*------------------------------------------------------------------------------------------ This internal class wraps a hashmap for caching SCRIPT_CACHE values. diff --git a/Src/xWorks/CssGenerator.cs b/Src/xWorks/CssGenerator.cs index a04819121f..35b8922b3f 100644 --- a/Src/xWorks/CssGenerator.cs +++ b/Src/xWorks/CssGenerator.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; +using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -1542,6 +1543,7 @@ private static void AddFontInfoCss(BaseStyleInfo projectStyle, StyleDeclaration { var wsFontInfo = projectStyle.FontInfoForWs(wsId); var defaultFontInfo = projectStyle.DefaultCharacterStyleInfo; + string defaultFontFeatures = null; // set fontName to the wsFontInfo publicly accessible InheritableStyleProp value if set, otherwise the // defaultFontInfo if set, or null. @@ -1555,7 +1557,10 @@ private static void AddFontInfoCss(BaseStyleInfo projectStyle, StyleDeclaration { var lgWritingSystem = cache.ServiceLocator.WritingSystemManager.get_EngineOrNull(wsId); if(lgWritingSystem != null) + { fontName = lgWritingSystem.DefaultFontName; + defaultFontFeatures = lgWritingSystem.DefaultFontFeatures; + } } if (fontName != null) @@ -1576,7 +1581,7 @@ private static void AddFontInfoCss(BaseStyleInfo projectStyle, StyleDeclaration AddInfoFromWsOrDefaultValue(wsFontInfo.m_fontColor, defaultFontInfo.FontColor, "color", declaration); AddInfoFromWsOrDefaultValue(wsFontInfo.m_backColor, defaultFontInfo.BackColor, "background-color", declaration); AddInfoFromWsOrDefaultValue(wsFontInfo.m_superSub, defaultFontInfo.SuperSub, declaration); - AddFontFeaturesFromWsOrDefaultValue(wsFontInfo.m_features, defaultFontInfo.Features, declaration); + AddFontFeaturesFromWsOrDefaultValue(wsFontInfo.m_features, defaultFontInfo.Features, declaration, defaultFontFeatures); AddInfoForUnderline(wsFontInfo, defaultFontInfo, declaration); } @@ -1649,12 +1654,19 @@ private static void AddInfoFromWsOrDefaultValue(InheritableStyleProp wsFont /// /// private static void AddFontFeaturesFromWsOrDefaultValue(InheritableStyleProp wsFontInfo, IStyleProp defaultFontInfo, - StyleDeclaration declaration) + StyleDeclaration declaration, string fallbackFontValue = null) { if (!GetFontValue(wsFontInfo, defaultFontInfo, out var fontValue)) + { + fontValue = fallbackFontValue; + if (string.IsNullOrEmpty(fontValue)) + return; + } + var cssFeatures = ConvertToCssFeatures(fontValue); + if (cssFeatures == null) return; var fontProp = new Property("font-feature-settings"); - fontProp.Term = ConvertToCssFeatures(fontValue); + fontProp.Term = cssFeatures; declaration.Add(fontProp); } @@ -1665,11 +1677,20 @@ private static void AddFontFeaturesFromWsOrDefaultValue(InheritableStylePropExCss doesn't support this type of attribute well so we build it by hand private static Term ConvertToCssFeatures(string fontValue) { - var features = fontValue.Split(','); - var terms = features.Select(f => $"\"{f.Replace("=", "\" ")}"); + var features = FontFeatureSettings.Parse(fontValue); + if (features.Count == 0) + return null; + + var terms = features.Select(setting => string.Format(CultureInfo.InvariantCulture, + "\"{0}\" {1}", EscapeCssString(setting.Tag), setting.Value)); return new PrimitiveTerm(UnitType.Unknown, string.Join(",", terms)); } + private static string EscapeCssString(string value) + { + return value.Replace("\\", "\\\\").Replace("\"", "\\\""); + } + /// /// Generates css from SuperSub style values using writing system overrides where appropriate /// diff --git a/Src/xWorks/LcmWordGenerator.cs b/Src/xWorks/LcmWordGenerator.cs index d6e0923786..44b2848bc8 100644 --- a/Src/xWorks/LcmWordGenerator.cs +++ b/Src/xWorks/LcmWordGenerator.cs @@ -42,6 +42,8 @@ public class LcmWordGenerator : ILcmContentGenerator, ILcmStylesGenerator private static WordStyleCollection s_styleCollection = null; private static readonly object _collectionLock = new object(); private static readonly object _masterFragmentLock = new object(); + private const string MarkupCompatibilityNamespace = "http://schemas.openxmlformats.org/markup-compatibility/2006"; + private const string Word2010Namespace = "http://schemas.microsoft.com/office/word/2010/wordml"; private ReadOnlyPropertyTable _propertyTable; public static bool IsBidi { get; private set; } @@ -248,6 +250,7 @@ public static void SavePublishedDocx(int[] entryHvos, RecordClerk clerk, Diction // Initialize word doc's styles xml stylePart = AddStylesPartToPackage(MasterFragment.DocFrag); Styles styleSheet = new Styles(); + ConfigureStyleSheetCompatibility(styleSheet); // Add generated styles into the stylesheet from the collections. var paragraphElements = s_styleCollection.GetUsedParagraphElements(); @@ -1991,10 +1994,23 @@ public static StyleDefinitionsPart AddStylesPartToPackage(WordprocessingDocument StyleDefinitionsPart part; part = doc.MainDocumentPart.AddNewPart(); Styles root = new Styles(); + ConfigureStyleSheetCompatibility(root); root.Save(part); return part; } + private static void ConfigureStyleSheetCompatibility(Styles styleSheet) + { + ConfigureWord2010Compatibility(styleSheet); + } + + private static void ConfigureWord2010Compatibility(OpenXmlElement element) + { + element.MCAttributes = new MarkupCompatibilityAttributes { Ignorable = "w14" }; + element.AddNamespaceDeclaration("mc", MarkupCompatibilityNamespace); + element.AddNamespaceDeclaration("w14", Word2010Namespace); + } + // Add a DocumentSettingsPart to the document. Returns a reference to it. public static DocumentSettingsPart AddDocSettingsPartToPackage(WordprocessingDocument doc) { @@ -2009,6 +2025,7 @@ public static NumberingDefinitionsPart AddNumberingPartToPackage(WordprocessingD NumberingDefinitionsPart part; part = doc.MainDocumentPart.AddNewPart(); Numbering numElement = new Numbering(); + ConfigureWord2010Compatibility(numElement); numElement.Save(part); return part; } diff --git a/Src/xWorks/WordStylesGenerator.cs b/Src/xWorks/WordStylesGenerator.cs index 616ad7ee84..5ec10d6e6b 100644 --- a/Src/xWorks/WordStylesGenerator.cs +++ b/Src/xWorks/WordStylesGenerator.cs @@ -1,6 +1,8 @@ +using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Wordprocessing; using ExCSS; using SIL.FieldWorks.Common.Framework; +using SIL.FieldWorks.Common.FwUtils; using SIL.FieldWorks.Common.Widgets; using SIL.LCModel; using SIL.LCModel.Core.KernelInterfaces; @@ -12,6 +14,7 @@ using System.Diagnostics; using System.Linq; using XCore; +using W14 = DocumentFormat.OpenXml.Office2010.Word; namespace SIL.FieldWorks.XWorks { @@ -448,6 +451,7 @@ private static StyleRunProperties AddFontInfoWordStyles(BaseStyleInfo projectSty var wsFontInfo = projectStyle.FontInfoForWs(wsId); var defaultFontInfo = projectStyle.DefaultCharacterStyleInfo; + string defaultFontFeatures = null; // set fontName to the wsFontInfo publicly accessible InheritableStyleProp value if set, otherwise the // defaultFontInfo if set, or null. @@ -468,13 +472,19 @@ private static StyleRunProperties AddFontInfoWordStyles(BaseStyleInfo projectSty { var lgWritingSystem = cache.ServiceLocator.WritingSystemManager.get_EngineOrNull(wsId); if (lgWritingSystem != null) + { fontName = lgWritingSystem.DefaultFontName; + defaultFontFeatures = lgWritingSystem.DefaultFontFeatures; + } else { CoreWritingSystemDefinition defAnalWs = cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem; lgWritingSystem = cache.ServiceLocator.WritingSystemManager.get_EngineOrNull(defAnalWs.Handle); if (lgWritingSystem != null) + { fontName = lgWritingSystem.DefaultFontName; + defaultFontFeatures = lgWritingSystem.DefaultFontFeatures; + } } } @@ -630,7 +640,14 @@ private static StyleRunProperties AddFontInfoWordStyles(BaseStyleInfo projectSty charDefaults.Append(new Strike()); } } - //TODO: handle remaining font features including from ws or default, + string fontFeatures; + if (GetFontValue(wsFontInfo.m_features, defaultFontInfo.Features, out fontFeatures) || + (projectStyle.Name == NormalParagraphStyleName && !string.IsNullOrEmpty(defaultFontFeatures) && + !defaultFontInfo.Features.ValueIsSet && !wsFontInfo.m_features.ValueIsSet && + (fontFeatures = defaultFontFeatures) != null)) + { + AddOpenTypeFontFeatureProperties(charDefaults, fontFeatures); + } return charDefaults; } @@ -763,9 +780,169 @@ public static RunProperties GetExplicitFontProperties(FontInfo fontInfo) runProps.Append(new Strike()); } } + + if (((InheritableStyleProp)fontInfo.Features).IsExplicit) + { + AddOpenTypeFontFeatureProperties(runProps, fontInfo.Features.Value); + } return runProps; } + private static void AddOpenTypeFontFeatureProperties(OpenXmlCompositeElement runProps, string fontFeatures) + { + RemoveOpenTypeFontFeatureProperties(runProps); + + var settings = FontFeatureSettings.Parse(fontFeatures); + int ligatureFlags = 0; + bool hasLigatureSetting = false; + W14.NumberFormValues? numberForm = null; + W14.NumberSpacingValues? numberSpacing = null; + bool? contextualAlternatives = null; + var styleSets = new List(); + + foreach (var setting in settings) + { + switch (setting.Tag) + { + case "liga": + hasLigatureSetting = true; + if (setting.Value != 0) + ligatureFlags |= 1; + break; + case "clig": + hasLigatureSetting = true; + if (setting.Value != 0) + ligatureFlags |= 2; + break; + case "hlig": + hasLigatureSetting = true; + if (setting.Value != 0) + ligatureFlags |= 4; + break; + case "dlig": + hasLigatureSetting = true; + if (setting.Value != 0) + ligatureFlags |= 8; + break; + case "lnum": + if (!numberForm.HasValue && setting.Value == 0) + numberForm = W14.NumberFormValues.Default; + else if (setting.Value != 0) + numberForm = W14.NumberFormValues.Lining; + break; + case "onum": + if (!numberForm.HasValue && setting.Value == 0) + numberForm = W14.NumberFormValues.Default; + else if (setting.Value != 0) + numberForm = W14.NumberFormValues.OldStyle; + break; + case "pnum": + if (!numberSpacing.HasValue && setting.Value == 0) + numberSpacing = W14.NumberSpacingValues.Default; + else if (setting.Value != 0) + numberSpacing = W14.NumberSpacingValues.Proportional; + break; + case "tnum": + if (!numberSpacing.HasValue && setting.Value == 0) + numberSpacing = W14.NumberSpacingValues.Default; + else if (setting.Value != 0) + numberSpacing = W14.NumberSpacingValues.Tabular; + break; + case "calt": + contextualAlternatives = setting.Value != 0; + break; + default: + uint styleSetId; + if (TryGetStylisticSetId(setting.Tag, out styleSetId)) + { + styleSets.Add(new W14.StyleSet { Id = styleSetId, Val = GetOnOffValue(setting.Value != 0) }); + } + break; + } + } + + if (hasLigatureSetting) + runProps.Append(new W14.Ligatures { Val = GetLigaturesValue(ligatureFlags) }); + if (numberForm.HasValue) + runProps.Append(new W14.NumberingFormat { Val = numberForm.Value }); + if (numberSpacing.HasValue) + runProps.Append(new W14.NumberSpacing { Val = numberSpacing.Value }); + if (contextualAlternatives.HasValue) + runProps.Append(new W14.ContextualAlternatives { Val = GetOnOffValue(contextualAlternatives.Value) }); + if (styleSets.Count > 0) + runProps.Append(new W14.StylisticSets(styleSets)); + } + + private static W14.OnOffValues GetOnOffValue(bool value) + { + return value ? W14.OnOffValues.True : W14.OnOffValues.False; + } + + private static void RemoveOpenTypeFontFeatureProperties(OpenXmlCompositeElement runProps) + { + runProps.RemoveAllChildren(); + runProps.RemoveAllChildren(); + runProps.RemoveAllChildren(); + runProps.RemoveAllChildren(); + runProps.RemoveAllChildren(); + } + + private static W14.LigaturesValues GetLigaturesValue(int ligatureFlags) + { + switch (ligatureFlags) + { + case 0: + return W14.LigaturesValues.None; + case 1: + return W14.LigaturesValues.Standard; + case 2: + return W14.LigaturesValues.Contextual; + case 3: + return W14.LigaturesValues.StandardContextual; + case 4: + return W14.LigaturesValues.Historical; + case 5: + return W14.LigaturesValues.StandardHistorical; + case 6: + return W14.LigaturesValues.ContextualHistorical; + case 7: + return W14.LigaturesValues.StandardContextualHistorical; + case 8: + return W14.LigaturesValues.Discretional; + case 9: + return W14.LigaturesValues.StandardDiscretional; + case 10: + return W14.LigaturesValues.ContextualDiscretional; + case 11: + return W14.LigaturesValues.StandardContextualDiscretional; + case 12: + return W14.LigaturesValues.HistoricalDiscretional; + case 13: + return W14.LigaturesValues.StandardHistoricalDiscretional; + case 14: + return W14.LigaturesValues.ContextualHistoricalDiscretional; + case 15: + return W14.LigaturesValues.All; + default: + return W14.LigaturesValues.None; + } + } + + private static bool TryGetStylisticSetId(string tag, out uint styleSetId) + { + styleSetId = 0; + if (tag == null || tag.Length != 4 || tag[0] != 's' || tag[1] != 's') + return false; + + int tens = tag[2] - '0'; + int ones = tag[3] - '0'; + if (tens < 0 || tens > 9 || ones < 0 || ones > 9) + return false; + + styleSetId = (uint)(tens * 10 + ones); + return styleSetId >= 1 && styleSetId <= 20; + } + public static string GetWsString(string wsString) { return LangTagPre + wsString + LangTagPost; diff --git a/Src/xWorks/XhtmlDocView.cs b/Src/xWorks/XhtmlDocView.cs index 15486d2275..7d177afa4a 100644 --- a/Src/xWorks/XhtmlDocView.cs +++ b/Src/xWorks/XhtmlDocView.cs @@ -7,6 +7,7 @@ using SIL.CommandLineProcessing; using SIL.FieldWorks.Common.Framework; using SIL.FieldWorks.Common.FwUtils; +using SIL.FieldWorks.Common.RootSites; using SIL.FieldWorks.Common.Widgets; using SIL.FieldWorks.FwCoreDlgControls; using SIL.FieldWorks.FwCoreDlgs; @@ -36,7 +37,7 @@ namespace SIL.FieldWorks.XWorks /// /// This class handles the display of configured xhtml for a particular publication in a dynamically loadable XWorksView. /// - internal class XhtmlDocView : XWorksViewBase, IFindAndReplaceContext, IPostLayoutInit + internal class XhtmlDocView : XWorksViewBase, IFindAndReplaceContext, IPostLayoutInit, IRefreshableRoot { private XWebBrowser m_mainView; private DictionaryPublicationDecorator m_pubDecorator; @@ -1303,6 +1304,12 @@ public void OnMasterRefresh(object sender) UpdateContent(currentConfig); } + public bool RefreshDisplay() + { + OnMasterRefresh(this); + return true; + } + public virtual bool OnDisplayShowAllEntries(object commandObject, ref UIItemDisplayProperties display) { var pubName = GetCurrentPublication(); diff --git a/Src/xWorks/xWorksTests/CssGeneratorTests.cs b/Src/xWorks/xWorksTests/CssGeneratorTests.cs index 895cd83559..58870627bf 100644 --- a/Src/xWorks/xWorksTests/CssGeneratorTests.cs +++ b/Src/xWorks/xWorksTests/CssGeneratorTests.cs @@ -23,6 +23,7 @@ using SIL.FieldWorks.Common.Widgets; using SIL.LCModel; using SIL.LCModel.DomainServices; +using SIL.WritingSystems; using XCore; // ReSharper disable InconsistentNaming - Justification: Underscores are standard for test names but nowhere else in our code @@ -2346,7 +2347,7 @@ public void GenerateCssForConfiguration_CharStyleFontFeaturesWorks() { ConfiguredLcmGenerator.AssemblyFile = "xWorksTests"; var style = GenerateStyle("underline"); - var fontInfo = new FontInfo { m_features = { ExplicitValue = "smcps=1,Eng=2" } }; + var fontInfo = new FontInfo { m_features = { ExplicitValue = "smcp=1,ss11=2" } }; style.SetWsStyle(fontInfo, Cache.DefaultVernWs); var headwordNode = new ConfigurableDictionaryNode { @@ -2362,7 +2363,55 @@ public void GenerateCssForConfiguration_CharStyleFontFeaturesWorks() //SUT var cssResult = CssGenerator.GenerateCssFromConfiguration(model, m_propertyTable); //make sure that fontinfo with the underline overrides made it into css - VerifyFontInfoInCss(FontColor, FontBGColor, FontName, FontBold, FontItalic, FontSize, cssResult, "\"smcps\" 1,\"Eng\" 2"); + VerifyFontInfoInCss(FontColor, FontBGColor, FontName, FontBold, FontItalic, FontSize, cssResult, "\"smcp\" 1,\"ss11\" 2"); + } + + [Test] + public void GenerateCssForConfiguration_InvalidCharStyleFontFeaturesAreIgnored() + { + ConfiguredLcmGenerator.AssemblyFile = "xWorksTests"; + var style = GenerateStyle("underline"); + var fontInfo = new FontInfo { m_features = { ExplicitValue = "smcps=1,Eng=2" } }; + style.SetWsStyle(fontInfo, Cache.DefaultVernWs); + var headwordNode = new ConfigurableDictionaryNode + { + FieldDescription = "SIL.FieldWorks.XWorks.TestRootClass", + Label = "Headword", + DictionaryNodeOptions = ConfiguredXHTMLGeneratorTests.GetWsOptionsForLanguages(new[] { "fr" }), + Style = "underline", + IsEnabled = true + }; + + var model = new DictionaryConfigurationModel(); + model.Parts = new List { headwordNode }; + var cssResult = CssGenerator.GenerateCssFromConfiguration(model, m_propertyTable); + + Assert.That(cssResult, Does.Not.Contain("font-feature-settings:")); + } + + [Test] + public void GenerateCssForConfiguration_CustomPrintableAsciiFontFeatures_AreEscapedForCss() + { + ConfiguredLcmGenerator.AssemblyFile = "xWorksTests"; + var style = GenerateStyle("underline"); + var fontInfo = new FontInfo { m_features = { ExplicitValue = "!abc=2,a\"b\\=1" } }; + style.SetWsStyle(fontInfo, Cache.DefaultVernWs); + var headwordNode = new ConfigurableDictionaryNode + { + FieldDescription = "SIL.FieldWorks.XWorks.TestRootClass", + Label = "Headword", + DictionaryNodeOptions = ConfiguredXHTMLGeneratorTests.GetWsOptionsForLanguages(new[] { "fr" }), + Style = "underline", + IsEnabled = true + }; + + var model = new DictionaryConfigurationModel(); + model.Parts = new List { headwordNode }; + + var cssResult = CssGenerator.GenerateCssFromConfiguration(model, m_propertyTable); + + Assert.That(cssResult, + Does.Contain("font-feature-settings:\"!abc\" 2,\"a\\\"b\\\\\" 1")); } [Test] @@ -3066,6 +3115,41 @@ public void GenerateCssForConfiguration_WsSpanWithNormalStyle() Assert.That(Regex.Replace(cssResult, @"\t|\n|\r", ""), Contains.Substring(defaultStyle + englishStyle + frenchStyle)); } + [Test] + public void GenerateCssForConfiguration_WsSpanWithNormalStyle_UsesWritingSystemDefaultFontFeatures() + { + var style = GenerateEmptyStyle("Normal"); + style.IsParagraphStyle = true; + + var vernWs = Cache.ServiceLocator.WritingSystemManager.Get(Cache.DefaultVernWs); + vernWs.DefaultFont = new FontDefinition("Charis SIL") { Features = "ss11=1,ss12=1" }; + + var glossNode = new ConfigurableDictionaryNode + { + FieldDescription = "Gloss", + DictionaryNodeOptions = ConfiguredXHTMLGeneratorTests.GetWsOptionsForLanguages(new[] { vernWs.LanguageTag }) + }; + var testSensesNode = new ConfigurableDictionaryNode + { + FieldDescription = "Senses", + Children = new List { glossNode } + }; + var testEntryNode = new ConfigurableDictionaryNode + { + FieldDescription = "LexEntry", + Children = new List { testSensesNode } + }; + var model = new DictionaryConfigurationModel + { + Parts = new List { testEntryNode } + }; + PopulateFieldsForTesting(testEntryNode); + + var cssResult = Regex.Replace(CssGenerator.GenerateCssFromConfiguration(model, m_propertyTable), @"\t|\n|\r", ""); + + Assert.That(cssResult, Contains.Substring("span[lang='" + vernWs.LanguageTag + "']{font-family:'Charis SIL',serif;font-feature-settings:\"ss11\" 1,\"ss12\" 1;")); + } + [Test] public void GenerateCssForConfiguration_NormalStyleForWsDoesNotOverrideNodeStyle() { @@ -4426,4 +4510,4 @@ public void SetBasedOnStyle(BaseStyleInfo parent) SetAllPropertiesToInherited(); } } -} \ No newline at end of file +} diff --git a/Src/xWorks/xWorksTests/LcmWordGeneratorTests.cs b/Src/xWorks/xWorksTests/LcmWordGeneratorTests.cs index 6af984d64e..d5d4920136 100644 --- a/Src/xWorks/xWorksTests/LcmWordGeneratorTests.cs +++ b/Src/xWorks/xWorksTests/LcmWordGeneratorTests.cs @@ -5,9 +5,12 @@ using System; using System.Collections.Generic; using System.IO; +using System.IO.Compression; using System.Linq; using System.Text.RegularExpressions; using System.Xml; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Wordprocessing; using NUnit.Framework; using SIL.FieldWorks.Common.Framework; using SIL.FieldWorks.Common.FwUtils; @@ -16,9 +19,11 @@ using SIL.LCModel.Core.KernelInterfaces; using SIL.LCModel.Core.Text; using SIL.LCModel.DomainServices; +using SIL.WritingSystems; using SIL.TestUtilities; using XCore; using static SIL.FieldWorks.XWorks.LcmWordGenerator; +using W14 = DocumentFormat.OpenXml.Office2010.Word; // ReSharper disable StringLiteralTypo namespace SIL.FieldWorks.XWorks @@ -59,6 +64,7 @@ static LcmWordGeneratorTests() WordNamespaceManager.AddNamespace("w", openXmlSchema); WordNamespaceManager.AddNamespace("r", openXmlSchema); WordNamespaceManager.AddNamespace("wp", openXmlSchema); + WordNamespaceManager.AddNamespace("w14", "http://schemas.microsoft.com/office/word/2010/wordml"); } [OneTimeSetUp] @@ -210,6 +216,272 @@ public void Setup() DefaultSettings.StylesGenerator.AddGlobalStyles(null, new ReadOnlyPropertyTable(m_propertyTable)); } + [Test] + public void GenerateCharacterStyleFromLcmStyleSheet_OpenTypeFontFeatures_AddsWordTypographyProperties() + { + var styleName = "WordFeatureStyle" + Guid.NewGuid().ToString("N"); + var fontInfo = new FontInfo { m_features = { ExplicitValue = "liga=0,lnum=1,pnum=1,calt=0,ss02=0,cv01=2" } }; + var projectStyle = new TestStyle(fontInfo, Cache) { Name = styleName, IsParagraphStyle = false }; + FontHeightAdjuster.StyleSheetFromPropertyTable(m_propertyTable).Styles.Add(projectStyle); + + var style = WordStylesGenerator.GenerateCharacterStyleFromLcmStyleSheet(styleName, Cache.DefaultVernWs, + new ReadOnlyPropertyTable(m_propertyTable)); + + var runProps = style.GetFirstChild(); + AssertWordTypographyProperties(runProps, W14.LigaturesValues.None, W14.NumberFormValues.Lining, + W14.NumberSpacingValues.Proportional, false, 2U, false); + } + + [Test] + public void GetExplicitFontProperties_OpenTypeFontFeatures_AddsWordTypographyProperties() + { + var fontInfo = new FontInfo { m_features = { ExplicitValue = "liga=1,clig=1,onum=1,tnum=1,calt=1,ss03=1,cv01=2" } }; + + var runProps = WordStylesGenerator.GetExplicitFontProperties(fontInfo); + + AssertWordTypographyProperties(runProps, W14.LigaturesValues.StandardContextual, W14.NumberFormValues.OldStyle, + W14.NumberSpacingValues.Tabular, true, 3U, true); + } + + [Test] + public void GetExplicitFontProperties_UnsupportedOrMalformedOpenTypeFontFeatures_DoesNotAddWordTypographyProperties() + { + var fontInfo = new FontInfo { m_features = { ExplicitValue = "!abc=1,cv01=2,kern=1,bad=2,liga=x" } }; + + var runProps = WordStylesGenerator.GetExplicitFontProperties(fontInfo); + + AssertNoWordTypographyProperties(runProps); + } + + [Test] + public void GenerateCharacterStyleFromLcmStyleSheet_NormalStyle_UsesWritingSystemDefaultFontFeatures() + { + var vernWs = Cache.ServiceLocator.WritingSystemManager.Get(Cache.DefaultVernWs); + vernWs.DefaultFont = new FontDefinition("Charis SIL") { Features = "ss11=1,ss12=1" }; + + var style = WordStylesGenerator.GenerateCharacterStyleFromLcmStyleSheet( + WordStylesGenerator.NormalParagraphStyleName, + vernWs.Handle, + new ReadOnlyPropertyTable(m_propertyTable)); + + var runProps = style.GetFirstChild(); + Assert.That(runProps, Is.Not.Null); + + var runFonts = runProps.GetFirstChild(); + Assert.That(runFonts, Is.Not.Null); + Assert.That(runFonts.Ascii?.Value, Is.EqualTo("Charis SIL")); + + var stylisticSets = runProps.GetFirstChild(); + Assert.That(stylisticSets, Is.Not.Null); + + var styleSets = stylisticSets.Elements().OrderBy(styleSet => styleSet.Id?.Value).ToList(); + Assert.That(styleSets.Count, Is.EqualTo(2)); + Assert.That(styleSets.Select(styleSet => styleSet.Id?.Value), Is.EqualTo(new uint?[] { 11U, 12U })); + Assert.That(styleSets.Select(styleSet => styleSet.Val?.Value), + Is.EqualTo(new[] { W14.OnOffValues.True, W14.OnOffValues.True })); + } + + [Test] + [Category("ManualDocx")] + public void GenerateManualDocxArtifact_CharisBaseline_NoFontOptions() + { + var docxPath = GenerateManualDocxArtifact("charis-baseline-no-font-options.docx", null); + + Assert.That(new FileInfo(docxPath).Length, Is.GreaterThan(0)); + Assert.That(GetDocxStyleSetIds(docxPath), Is.Empty); + } + + [Test] + [Category("ManualDocx")] + public void GenerateManualDocxArtifact_CharisSs11Ss12() + { + var docxPath = GenerateManualDocxArtifact("charis-ss11-ss12.docx", "ss11=1,ss12=1"); + + Assert.That(new FileInfo(docxPath).Length, Is.GreaterThan(0)); + var styleSetIds = GetDocxStyleSetIds(docxPath); + Assert.That(styleSetIds, Does.Contain(11U)); + Assert.That(styleSetIds, Does.Contain(12U)); + } + + private static void AssertWordTypographyProperties(OpenXmlCompositeElement runProps, + W14.LigaturesValues ligaturesValue, W14.NumberFormValues numberFormValue, + W14.NumberSpacingValues numberSpacingValue, bool contextualAlternativesValue, + uint stylisticSetId, bool stylisticSetValue) + { + Assert.That(runProps, Is.Not.Null); + var ligatures = runProps.GetFirstChild(); + Assert.That(ligatures, Is.Not.Null); + Assert.That(ligatures.Val.Value, Is.EqualTo(ligaturesValue)); + + var numberForm = runProps.GetFirstChild(); + Assert.That(numberForm, Is.Not.Null); + Assert.That(numberForm.Val.Value, Is.EqualTo(numberFormValue)); + + var numberSpacing = runProps.GetFirstChild(); + Assert.That(numberSpacing, Is.Not.Null); + Assert.That(numberSpacing.Val.Value, Is.EqualTo(numberSpacingValue)); + + var contextualAlternatives = runProps.GetFirstChild(); + Assert.That(contextualAlternatives, Is.Not.Null); + Assert.That(contextualAlternatives.Val.Value, Is.EqualTo(GetOnOffValue(contextualAlternativesValue))); + + var stylisticSets = runProps.GetFirstChild(); + Assert.That(stylisticSets, Is.Not.Null); + var styleSet = stylisticSets.Elements().Single(); + Assert.That(styleSet.Id.Value, Is.EqualTo(stylisticSetId)); + Assert.That(styleSet.Val.Value, Is.EqualTo(GetOnOffValue(stylisticSetValue))); + } + + private static void AssertNoWordTypographyProperties(OpenXmlCompositeElement runProps) + { + Assert.That(runProps, Is.Not.Null); + Assert.That(runProps.GetFirstChild(), Is.Null); + Assert.That(runProps.GetFirstChild(), Is.Null); + Assert.That(runProps.GetFirstChild(), Is.Null); + Assert.That(runProps.GetFirstChild(), Is.Null); + Assert.That(runProps.GetFirstChild(), Is.Null); + } + + private static W14.OnOffValues GetOnOffValue(bool value) + { + return value ? W14.OnOffValues.True : W14.OnOffValues.False; + } + + private string GenerateManualDocxArtifact(string fileName, string fontFeatures) + { + var outputDir = EnsureManualDocxArtifactOutputDirectory(); + var filePath = Path.Combine(outputDir, fileName); + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + + ConfigureManualDocxWritingSystem(Cache.DefaultVernWs, fontFeatures); + ConfigureManualDocxWritingSystem(Cache.DefaultAnalWs, fontFeatures); + EnsureManualDocxStylesAvailable(); + + var entry = ConfiguredXHTMLGeneratorTests.CreateInterestingLexEntry(Cache, "agaga", "again agaga"); + var configuration = CreateManualDocxConfiguration(); + var publicationDecorator = new DictionaryPublicationDecorator(Cache, m_Clerk.VirtualListPublisher, m_Clerk.VirtualFlid); + + LcmWordGenerator.SavePublishedDocx(new[] { entry.Hvo }, m_Clerk, publicationDecorator, int.MaxValue, + configuration, m_propertyTable, filePath); + + TestContext.WriteLine("Generated manual DOCX artifact: " + filePath); + Assert.That(File.Exists(filePath), Is.True); + return filePath; + } + + private DictionaryConfigurationModel CreateManualDocxConfiguration() + { + var headwordNode = new ConfigurableDictionaryNode + { + FieldDescription = "MLHeadWord", + CSSClassNameOverride = "headword", + DictionaryNodeOptions = ConfiguredXHTMLGeneratorTests.GetWsOptionsForLanguages(new[] { "fr" }), + Style = "Dictionary-Headword" + }; + var glossNode = new ConfigurableDictionaryNode + { + FieldDescription = "Gloss", + DictionaryNodeOptions = ConfiguredXHTMLGeneratorTests.GetWsOptionsForLanguages(new[] { "en" }), + Style = DictionaryGlossStyleName + }; + var sensesNode = new ConfigurableDictionaryNode + { + FieldDescription = "Senses", + DictionaryNodeOptions = ConfiguredXHTMLGeneratorTests.GetSenseNodeOptions(), + Children = new List { glossNode }, + Style = SensesParagraphStyleName + }; + var mainEntryNode = new ConfigurableDictionaryNode + { + Children = new List { headwordNode, sensesNode }, + CSSClassNameOverride = "entry", + FieldDescription = "LexEntry", + Style = MainEntryParagraphStyleName + }; + + CssGeneratorTests.PopulateFieldsForTesting(mainEntryNode); + var configuration = new DictionaryConfigurationModel(true) + { + Label = "Manual DOCX", + Parts = new List { mainEntryNode } + }; + DictionaryConfigurationModel.SpecifyParentsAndReferences(configuration.Parts, configuration, configuration.SharedItems); + return configuration; + } + + private void EnsureManualDocxStylesAvailable() + { + var styles = FontHeightAdjuster.StyleSheetFromPropertyTable(m_propertyTable).Styles; + + if (!styles.Contains("Dictionary-Normal")) + styles.Add(new BaseStyleInfo { Name = "Dictionary-Normal", IsParagraphStyle = true }); + if (!styles.Contains(DictionaryNormal)) + styles.Add(new BaseStyleInfo { Name = DictionaryNormal }); + if (!styles.Contains("Dictionary-Headword")) + styles.Add(new BaseStyleInfo { Name = "Dictionary-Headword", IsParagraphStyle = false }); + if (!styles.Contains(DictionaryGlossStyleName)) + styles.Add(new BaseStyleInfo { Name = DictionaryGlossStyleName, IsParagraphStyle = false }); + if (!styles.Contains(MainEntryParagraphStyleName)) + styles.Add(new BaseStyleInfo { Name = MainEntryParagraphStyleName, IsParagraphStyle = true }); + if (!styles.Contains(SensesParagraphStyleName)) + styles.Add(new BaseStyleInfo { Name = SensesParagraphStyleName, IsParagraphStyle = true }); + } + + private void ConfigureManualDocxWritingSystem(int wsHandle, string fontFeatures) + { + var writingSystem = Cache.ServiceLocator.WritingSystemManager.Get(wsHandle); + writingSystem.DefaultFont = new FontDefinition("Charis SIL") { Features = fontFeatures }; + } + + private static IReadOnlyCollection GetDocxStyleSetIds(string filePath) + { + using (var archive = ZipFile.OpenRead(filePath)) + { + var stylesEntry = archive.GetEntry("word/styles.xml"); + Assert.That(stylesEntry, Is.Not.Null); + + var stylesDocument = new XmlDocument(); + using (var stylesStream = stylesEntry.Open()) + { + stylesDocument.Load(stylesStream); + } + + var namespaceManager = new XmlNamespaceManager(stylesDocument.NameTable); + namespaceManager.AddNamespace("w14", "http://schemas.microsoft.com/office/word/2010/wordml"); + + return stylesDocument.SelectNodes("//w14:styleSet", namespaceManager) + .Cast() + .Select(node => node.Attributes?["id", "http://schemas.microsoft.com/office/word/2010/wordml"]?.Value) + .Where(value => uint.TryParse(value, out _)) + .Select(value => uint.Parse(value)) + .Distinct() + .OrderBy(id => id) + .ToArray(); + } + } + + private static string EnsureManualDocxArtifactOutputDirectory() + { + if (!string.Equals(Environment.GetEnvironmentVariable("FW_RUN_MANUAL_DOCX_EXPORT_TESTS"), "1", + StringComparison.Ordinal)) + { + Assert.Ignore("Set FW_RUN_MANUAL_DOCX_EXPORT_TESTS=1 to generate manual DOCX artifacts."); + } + + var outputDir = Environment.GetEnvironmentVariable("FW_MANUAL_DOCX_OUTPUT_DIR"); + if (string.IsNullOrWhiteSpace(outputDir)) + { + outputDir = Path.Combine(Path.GetDirectoryName(typeof(LcmWordGeneratorTests).Assembly.Location), + "ManualDocxArtifacts"); + } + + Directory.CreateDirectory(outputDir); + return outputDir; + } + [Test] public void GenerateWordDocForEntry_OneSenseWithGlossGeneratesCorrectResult() diff --git a/Src/xWorks/xWorksTests/XhtmlDocViewTests.cs b/Src/xWorks/xWorksTests/XhtmlDocViewTests.cs index fd80e1b6d5..d63191bf35 100644 --- a/Src/xWorks/xWorksTests/XhtmlDocViewTests.cs +++ b/Src/xWorks/xWorksTests/XhtmlDocViewTests.cs @@ -7,6 +7,7 @@ using System.IO; using System.Xml; using NUnit.Framework; +using SIL.FieldWorks.Common.RootSites; using SIL.LCModel.Core.Text; using SIL.IO; using SIL.FieldWorks.Common.FwUtils; @@ -38,6 +39,17 @@ public override void FixtureInit() private const string ConfigurationTemplateWithAllPublications = "" + ""; + [Test] + public void XhtmlDocView_ImplementsRefreshableRoot() + { + using (var docView = new TestXhtmlDocView()) + { + // FwXWindow refreshes a view subtree through the first IRefreshableRoot it finds; + // this keeps XHTML dictionary views from being skipped during reconstruct/refresh. + Assert.That(docView, Is.InstanceOf()); + } + } + [Test] public void SplitPublicationsByConfiguration_AllPublicationIsIn() { diff --git a/openspec/changes/add-opentype-font-features/.openspec.yaml b/openspec/changes/add-opentype-font-features/.openspec.yaml new file mode 100644 index 0000000000..0a064c1e4b --- /dev/null +++ b/openspec/changes/add-opentype-font-features/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-28 diff --git a/openspec/changes/add-opentype-font-features/design.md b/openspec/changes/add-opentype-font-features/design.md new file mode 100644 index 0000000000..14936d0767 --- /dev/null +++ b/openspec/changes/add-opentype-font-features/design.md @@ -0,0 +1,190 @@ +## Context + +FieldWorks currently stores font feature strings generically (`tag=value`) but exposes and applies them mostly through Graphite-specific paths. Writing-system default features and style features flow through managed dialogs, `FontInfo.m_features`, `FwTextPropType.ktptFontVariations`, `VwPropertyStore`, and CSS export, but the non-Graphite Views renderer does not apply OpenType features. + +LT-22324 Phase 1 must be implemented after `001-render-speedup` is merged. That branch adds render/layout dirty-state checks, warm rendering paths, and bitmap baseline infrastructure; this change must assume those optimizations exist and must treat font-feature changes as layout-changing. + +The longer product phases are: add OpenType features now, remove Graphite later while retaining WinForms, add Avalonia alongside WinForms, and eventually retire WinForms. This design makes Phase 1 useful to the later phases without making HarfBuzzSharp or Avalonia part of production rendering yet. + +## Goals / Non-Goals + +**Goals:** + +- Support OpenType font features in current WinForms/Views data entry and preview surfaces. +- Split Font Features from the `Enable Graphite` UI concept. +- Prefer OpenType for dual-technology fonts while preserving explicit access to Graphite features. +- Preserve Graphite behavior and existing Graphite feature support during Phase 1. +- Keep persisted feature strings renderer-neutral and compatible with future Avalonia/HarfBuzz-style consumption. +- Accept any syntactically valid OpenType tag and reject malformed tags safely with trace logging. +- Add trace logging for discovery, validation, native shaping, and fallback decisions. +- Keep style/default font-feature loading on the existing inheritance path, with only the minimal compatibility adapter still required by the current build graph. +- Fix truncation and malformed-input robustness gaps in legacy feature-string handling. +- Add tests for UI control behavior and visual rendering differences caused by feature toggles. +- Add test-only HarfBuzzSharp + SkiaSharp comparison tooling for future visual-fidelity confidence. + +**Non-Goals:** + +- Removing Graphite or changing Graphite project data in Phase 1. +- Replacing Views.cpp, WinForms, selection, editing, line breaking, or hit testing. +- Introducing HarfBuzzSharp, SkiaSharp, or Avalonia into production rendering. +- Guaranteeing pixel identity between GDI/Uniscribe and Skia/HarfBuzz output. + +## Clarified Scope From The In-Depth Review + +- Local staged/unstaged churn seen during review is not part of the intended scope. This design assumes the final implementation branch resolves that churn before validation. +- OpenType, not Graphite, is the default feature system for dual-technology fonts in Phase 1. Graphite remains available by explicit user choice. +- Tag acceptance is syntactic, not registry-based. Any valid four-character printable ASCII OpenType tag is accepted; filtering applies to UI exposure and specific export boundaries. +- Logging and robustness are Phase 1 requirements, not deferred cleanup work. +- Existing inheritance/data-flow paths remain authoritative unless a change is explicitly required by this proposal. + +## Decisions + +### 1. Renderer-neutral feature contract first + +**Decision:** Keep FieldWorks feature settings as normalized `tag=value` strings at the model/UI boundary and convert only at renderer boundaries. + +**Rationale:** The same stored value can be used by current Views, CSS export, test-only HarfBuzzSharp, and future Avalonia. Graphite numeric feature IDs remain an implementation detail of the Graphite adapter. + +**Alternatives considered:** Reuse `GraphiteFontFeatures` for OpenType conversion. Rejected because OpenType feature tags should stay four-character tags, not Graphite numeric IDs. + +### 2. Current Views renderer remains production path for Phase 1 + +**Decision:** Apply OpenType features in the existing native Uniscribe path using Microsoft OpenType Uniscribe APIs (`ScriptItemizeOpenType`, `ScriptShapeOpenType`, `ScriptPlaceOpenType`) while preserving the old path for empty feature sets. + +**Rationale:** This is the smallest production change that preserves Views layout, drawing, selection, hit testing, bidi handling, and Graphite split. HarfBuzz is a shaper, not a full FieldWorks renderer. + +**Alternatives considered:** Add a production HarfBuzz engine now. Rejected for Phase 1 because it would require a new renderer contract, COM/build/install work, and broad selection/layout parity validation. + +### 3. Feature application is run/property based, not Graphite-checkbox based + +**Decision:** The renderer SHALL apply OpenType feature strings from `ktptFontVariations` / `LgCharRenderProps` for the run being shaped. Engine-level feature state may be used only if it cannot produce stale style-specific output. + +**Rationale:** Style-specific features and writing-system default features can differ while using the same font. Per-run feature state avoids cache collisions and covers preview, data entry, and style scenarios. + +**Alternatives considered:** Pass writing-system default features to `UniscribeEngine.InitRenderer`. Rejected as insufficient because it misses style-specific `ktptFontVariations`. + +### 4. Font Features UI uses providers, with OpenType preferred by default + +**Decision:** Refactor `FontFeaturesButton` around a feature provider concept: Graphite provider uses existing `IRenderingFeatures`; OpenType provider uses OpenType font/script/language/feature tag discovery; the button is enabled when the selected font has configurable features. When a font exposes both Graphite and OpenType feature sets, the default provider SHALL be OpenType and the UI SHALL expose a clear explicit toggle to switch providers. + +**Rationale:** The control should depend on “has configurable font features,” not “is Graphite.” Making OpenType the default matches the Phase 1 product goal and avoids hiding the new behavior behind Graphite-first heuristics. + +**Alternatives considered:** Continue preferring Graphite implicitly or add OpenType conditions only to `DefaultFontsControl`. Rejected because that would preserve confusing defaults and leave the shared button and style/font dialogs with duplicated logic. + +### 5. HarfBuzzSharp + SkiaSharp are test-only comparison tools + +**Decision:** Add HarfBuzzSharp and SkiaSharp only to test/comparison projects, not production projects. Use them to shape/render known feature scenarios and compare against legacy Views captures with tolerances. + +**Rationale:** This starts migration evidence now and aligns with Avalonia/HarfBuzz direction without destabilizing production rendering. + +**Alternatives considered:** Make HarfBuzzSharp the shared runtime renderer now. Rejected because current Views owns layout, drawing, selection, and editing behavior. + +### 6. Visual baselines are migration assets + +**Decision:** Use the post-`001-render-speedup` render snapshot framework as the golden legacy evidence set for feature-on/feature-off scenarios. + +**Rationale:** Golden WinForms/Views captures help Phase 1 verification and later Avalonia comparison. Exact pixels are appropriate for same-renderer regressions; tolerant or semantic comparisons are appropriate across GDI/Uniscribe and Skia/HarfBuzz. + +### 7. Word DOCX export maps only documented Word typography features + +**Decision:** Map FieldWorks `tag=value` font feature strings to Office 2010 WordprocessingML `w14` typography elements only where Microsoft documents a Word representation: ligatures, number form, number spacing, contextual alternatives, and stylistic sets. + +**Rationale:** CSS can preserve arbitrary OpenType feature tags, but WordprocessingML does not provide a general `font-feature-settings` equivalent. A best-effort documented subset avoids producing invalid DOCX while preserving the features Word can actually display and round-trip. + +**Alternatives considered:** Store arbitrary tags in custom XML or undocumented extension markup. Rejected because Word would not apply those settings to text rendering and the export would give users a false parity signal. + +### 8. OpenType feature discovery filters user-configurable features only + +**Decision:** OpenType feature discovery SHALL filter out required shaping features and other non-user-configurable engine-controlled features before populating the Font Features UI. + +**Rationale:** HarfBuzz and CSS guidance distinguish required/default shaping behavior from optional user-facing feature selection. Presenting all GSUB/GPOS tags as toggles produces confusing and potentially unsafe UI. + +**Alternatives considered:** Expose every raw GSUB/GPOS feature found in the font. Rejected because that treats engine-required features as if they were safe end-user choices. + +### 9. Tag validation is syntactic and liberal; output boundaries stay safe + +**Decision:** FieldWorks SHALL accept any OpenType tag that is exactly four printable ASCII characters (`U+20`-`U+7E`), whether registered or custom. Malformed tags SHALL be ignored with trace logging. Output boundaries such as CSS SHALL escape or otherwise safely serialize valid tags instead of rejecting them. + +**Rationale:** The OpenType/CSS contract is syntactic, not registry-based. Narrowing acceptance to a product-specific allowlist would break legitimate custom tags and weaken renderer-neutral storage. + +**Alternatives considered:** Restrict tags to registered tags only, or restrict accepted characters more narrowly than the published syntax. Rejected because those approaches are incompatible with valid custom/private tags and would create artificial persistence/export divergence. + +### 10. Trace logging is a Phase 1 requirement + +**Decision:** Phase 1 SHALL add trace logging through the existing FieldWorks diagnostics infrastructure for malformed feature input, filtered feature discovery, provider selection/toggle decisions, native shaping failures, and fallback reasons. + +**Rationale:** The feature must degrade gracefully for bad fonts and bad feature strings, but silent fallback makes regressions and field failures hard to diagnose. + +**Alternatives considered:** Defer diagnostics to a later cleanup pass or rely on modal assertions. Rejected because the review explicitly requires graceful continuation plus actionable logs. + +### 11. Existing inheritance paths remain authoritative + +**Decision:** `FontInfo.m_features`, `FwTextPropType.ktptFontVariations`, and style rule round-tripping remain the authoritative inheritance/data-flow path for default and explicit font features. `StyleInfo` retains a minimal compatibility adapter that reads default `ktptFontVariations` from `IStStyle.Rules` because focused validation showed that removing it loses persisted default font features in the current build graph. + +**Rationale:** The local LCM source contains `BaseStyleInfo.ProcessStyleRules` support for `ktptFontVariations`, but the active FieldWorks build/test path still requires the `StyleInfo` adapter to reload persisted defaults. The adapter is therefore a compatibility boundary, not a second policy path. + +**Alternatives considered:** Remove the `StyleInfo` adapter immediately. Rejected for this change because `SaveToDB_DefaultFontFeatures_RoundTripsThroughRules` failed after removal. Broader LCM dependency alignment can retire the adapter later with the same round-trip tests as the gate. + +### 12. Overlong and malformed feature strings fail safe + +**Decision:** When existing storage/truncation logic encounters overlong or malformed feature strings, the code SHALL either make forward progress or abandon safely, and SHALL log the event. It SHALL NOT loop indefinitely. + +**Rationale:** Phase 1 must not crash or hang on malformed feature data from fonts, styles, or legacy persisted values. + +**Alternatives considered:** Preserve comma-based truncation behavior without a no-progress guard. Rejected because the review identified a concrete hang risk. + +### 13. State-of-the-art native fallback behavior is in scope + +**Decision:** Native OpenType shaping SHALL treat `E_OUTOFMEMORY` as retryable, preserve authoritative script tags from `ScriptItemizeOpenType`, and favor an authoritative/generated language-tag mapping strategy over handwritten tables where OS APIs are insufficient. + +**Rationale:** Current platform guidance treats OpenType shaping as an itemize/shape/place pipeline with retryable buffer sizing and explicit script/language inputs. That behavior is part of robust Phase 1 support, not a future renderer rewrite. + +**Alternatives considered:** Fall back immediately on retryable errors or maintain ad hoc locale-to-tag heuristics indefinitely. Rejected because both approaches weaken correctness and diagnosability. + +## Risks / Trade-offs + +| Risk | Mitigation | +|------|------------| +| OpenType APIs produce different metrics or line breaks | Add feature-on/off render baselines and native metric/selection tests. | +| Feature state is omitted from post-speedup caches | Add tasks and tests requiring feature strings in cache/dirty identity. | +| UI exposes required shaping features as toggles | Filter OpenType discovery to user-configurable optional features and provide fallback labels. | +| OpenType feature labels are incomplete or unlocalized | Use resource-backed labels for common tags and fall back to the four-character tag. | +| Test fonts cannot be redistributed | Confirm SIL Open Font License or another redistributable license before adding binaries. | +| HarfBuzz/Skia visual output differs from GDI/Uniscribe | Compare shaping data first; use tolerant image comparisons for cross-renderer evidence. | +| Word DOCX cannot represent every OpenType feature tag | Map only documented `w14` typography elements and document unsupported tags such as character variants and private features. | +| OpenType default preference conflicts with legacy Graphite-first assumptions | Make provider choice explicit in shared UI and cover dual-technology fonts with tests. | +| Accepting all valid tags can create unsafe raw CSS strings | Keep parser/storage liberal, but escape valid tags at CSS output boundaries and test serialization. | +| Silent fallback hides malformed-input and shaping bugs | Add trace switches and testable diagnostics points for filtering, validation, retry, and fallback. | +| Duplicate inheritance loaders drift from persisted style behavior | Keep the `StyleInfo` loader as a minimal compatibility adapter, document why it remains, and gate future removal with reopen/save round-trip tests. | +| Overlong strings without comma boundaries can hang truncation loops | Add no-progress guards and fail-safe truncation behavior with targeted tests. | + +## Migration Plan + +1. Wait until `001-render-speedup` is merged into the target branch. +2. Add parser/normalizer validation, malformed-input logging, and fail-safe truncation coverage before widening feature discovery. +3. Add provider abstractions, OpenType-preferred dual-tech toggle behavior, and shared UI tests. +4. Add filtered OpenType feature discovery for the UI while preserving Graphite provider behavior. +5. Add native OpenType shaping/placing support, retryable-error handling, script/language trace points, and native tests. +6. Attempt to reduce inheritance-path duplication, retain only required compatibility adapters, and verify style/default-feature round-tripping through the authoritative path. +7. Add render snapshot scenarios using the merged render baseline infrastructure. +8. Add test-only HarfBuzzSharp + SkiaSharp comparison tests in FieldWorks test projects. +9. Update help/localized UI text and review-driven docs. +10. Add Word DOCX export mapping for the documented WordprocessingML subset, CSS-safe serialization work, and tests that inspect generated Open XML. + +Rollback strategy: disable the OpenType provider and native OpenType shaping path behind a feature flag or fallback path if regressions are found; Graphite and old Uniscribe behavior remain available. + +## Implementation Update (2026-05-12) + +- `FontFeatureSettings` now accepts any valid four-character printable ASCII tag, ignores malformed entries, and traces ignored input through `FwUtils_FontFeatureSettings`. +- `FontFeaturesButton` now defaults to OpenType provider selection, filters required shaping features from OpenType discovery, and traces provider/filter decisions through `FontFeatures.OpenType`. +- `VwPropertyStore` now stores full `ktptFontVariations`, fixes `get_FontVariations`, and copies only render-safe strings into `LgCharRenderProps.szFontVar`, with fail-safe behavior for overlong strings without comma boundaries. +- `UniscribeSegment` now retries `ScriptShapeOpenType` and `ScriptPlaceOpenType` after `E_OUTOFMEMORY`, traces retries/fallbacks, and keeps classic Uniscribe fallback intact. +- CSS export escapes valid tags inside `font-feature-settings`; DOCX export remains on the documented Word `w14` subset and ignores unsupported valid or malformed entries safely. +- Removing the `StyleInfo` default-font-feature loader was attempted and failed the focused round-trip test, so the loader remains as a documented compatibility adapter. + +## Open Questions + +1. What exact wording and placement should the dual-technology provider toggle use so users understand when they are viewing OpenType versus Graphite features? +2. Can the implementation vendor or reuse an authoritative script/language-tag mapping source, or must it rely only on OS APIs already present in the runtime environment? +3. Should new trace switches be split by area (UI/provider/native/export) or grouped under one OpenType font-features category? +4. Where should the test-only HarfBuzzSharp + SkiaSharp comparison project live after `001-render-speedup`: under RenderVerification, RootSiteTests, or a new dedicated test project? diff --git a/openspec/changes/add-opentype-font-features/evidence/manual-winapp/01-initial-sena3-loaded-clean.png b/openspec/changes/add-opentype-font-features/evidence/manual-winapp/01-initial-sena3-loaded-clean.png new file mode 100644 index 0000000000..970cd8ee90 Binary files /dev/null and b/openspec/changes/add-opentype-font-features/evidence/manual-winapp/01-initial-sena3-loaded-clean.png differ diff --git a/openspec/changes/add-opentype-font-features/evidence/manual-winapp/02-writing-system-font-options-fixed.png b/openspec/changes/add-opentype-font-features/evidence/manual-winapp/02-writing-system-font-options-fixed.png new file mode 100644 index 0000000000..ed5101383a Binary files /dev/null and b/openspec/changes/add-opentype-font-features/evidence/manual-winapp/02-writing-system-font-options-fixed.png differ diff --git a/openspec/changes/add-opentype-font-features/evidence/manual-winapp/03-writing-system-opentype-font-selected.png b/openspec/changes/add-opentype-font-features/evidence/manual-winapp/03-writing-system-opentype-font-selected.png new file mode 100644 index 0000000000..0a7935b0cb Binary files /dev/null and b/openspec/changes/add-opentype-font-features/evidence/manual-winapp/03-writing-system-opentype-font-selected.png differ diff --git a/openspec/changes/add-opentype-font-features/evidence/manual-winapp/05-styles-font-tab-font-features.png b/openspec/changes/add-opentype-font-features/evidence/manual-winapp/05-styles-font-tab-font-features.png new file mode 100644 index 0000000000..c7d82c7c07 Binary files /dev/null and b/openspec/changes/add-opentype-font-features/evidence/manual-winapp/05-styles-font-tab-font-features.png differ diff --git a/openspec/changes/add-opentype-font-features/manual-testing.md b/openspec/changes/add-opentype-font-features/manual-testing.md new file mode 100644 index 0000000000..3f4dd0c068 --- /dev/null +++ b/openspec/changes/add-opentype-font-features/manual-testing.md @@ -0,0 +1,98 @@ +# Manual WinForms/WinApp Testing + +This note records the manual FieldWorks walkthrough for LT-22324 OpenType Font +Features using WinApp MCP and WinForms MCP UIA2. Automated coverage remains the +primary verification for renderer application; these steps capture the live UI +surfaces requested for manual review. + +## Environment + +- Build launched: `Output/Debug/FieldWorks.exe` +- Project: `Sena 3` +- Backup available if restore is needed: `Sena 3 2018-09-11 1145.fwbackup` +- App control: WinApp MCP for the original visible-desktop run; WinForms MCP + UIA2 (`@fnrhombus/winforms-mcp`) for the refreshed evidence run. +- JIRA fetch status: refreshed Atlassian read-only scripts successfully fetched + `LT-22324`, one comment, and no attachments on 2026-04-30. +- JIRA target fonts: `CharisSIL-5.000.zip` and `AbyssinicaSIL-2.201.zip` are + called out as Lorna Evans fonts with both Graphite and OpenType tables. The + live evidence used installed `Charis SIL`; `Abyssinica SIL` was not installed + on this machine. + +## Manual Steps + +1. Launch or attach to `Output/Debug/FieldWorks.exe` with WinApp MCP. +2. Confirm a project is loaded. If no project is loaded, restore + `Sena 3 2018-09-11 1145.fwbackup` from the repository root. +3. Capture the loaded project state. +4. Open `Format` > `Set up Vernacular Writing Systems...`. +5. Select the `Font` tab. +6. Verify the group label is `Font Options`. +7. Verify `Enable Graphite` is unchecked for the selected non-Graphite state. +8. Verify `Font Features` remains enabled when a selected font exposes feature + options. This is the primary fixed behavior; the old bug tied feature + availability too tightly to Graphite enablement. +9. Optionally select an OpenType font such as `Times New Roman` without saving, + confirm `Font Features` remains available, then cancel the dialog. +10. Open `Format` > `Styles...`. +11. Select the `Font` tab. +12. Verify the shared style Font tab exposes the `Font features` control. +13. For WinForms MCP UIA2 evidence, select `Charis SIL`, invoke `Font + Features`, and verify the OpenType feature menu includes entries such as + `Access All Alternates`, `Small Capitals From Capitals`, `Standard + Ligatures`, `Small Capitals`, and `cv*` character-variant entries. +14. Cancel all dialogs used only for evidence capture. + +## Screenshot Evidence + +![Sena 3 loaded in FieldWorks](evidence/manual-winapp/01-initial-sena3-loaded-clean.png) + +![Writing System Properties Font tab with generic Font Options and Font Features enabled while Graphite is unchecked](evidence/manual-winapp/02-writing-system-font-options-fixed.png) + +![Writing System Properties with a temporary OpenType font selected and Font Features still enabled](evidence/manual-winapp/03-writing-system-opentype-font-selected.png) + +![Styles dialog Font tab with the shared Font features control](evidence/manual-winapp/05-styles-font-tab-font-features.png) + +![WinForms MCP UIA2 Writing System Properties Font tab with generic Font Options and Font Features enabled](evidence/manual-winforms/01-uia2-font-options-doulos.png) + +![WinForms MCP UIA2 Writing System Properties Font tab with Jira-recommended Charis SIL selected](evidence/manual-winforms/02-uia2-charis-sil-font-options.png) + +![WinForms MCP UIA2 Charis SIL Font Features menu showing OpenType feature options](evidence/manual-winforms/03-uia2-charis-sil-feature-menu.png) + +## Font and JIRA Evidence + +- `LT-22324` summary: split Font Features from `Enable Graphite` and support + OpenType features. +- `LT-22324` description says `Font Options` should replace Graphite-only + wording, feature enablement should not be tied to `Enable Graphite` unless a + font only has Graphite features, and OpenType features should be listed and + saved/set similarly to Graphite features. +- `LT-22324` suggests considering HarfBuzzSharp. This implementation keeps + production rendering on the existing Views/Uniscribe path and uses + HarfBuzzSharp only in test comparison infrastructure. +- `LT-22324` links `CharisSIL-5.000.zip` and `AbyssinicaSIL-2.201.zip` as fonts + with both Graphite and OpenType tables. Current local inventory has + `Charis SIL`, `Andika`, `Doulos SIL`, `Gentium Plus`, and `Quivira`; it does + not have `Abyssinica SIL`. +- FieldWorks installer inputs include Charis/Andika/Doulos/Gentium Plus 6.101 + font packages and `Quivira.otf`; the exact older JIRA-linked Abyssinica 2.201 + archive is not committed in this workspace. +- Native TestViews now commits `CharisSIL-5.000` regular font data with the OFL + license under `Src/views/Test/TestData/Fonts/CharisSIL-5.000` and uses it for + deterministic end-to-end Uniscribe rendering tests. + +## Before-State Capture + +A true broken-state screenshot was not captured from this workspace because the +active debug build already contains the LT-22324 fix and project data should not +be mutated or the branch reverted during evidence collection. To capture a real +before-state, use a separate pre-fix worktree/build, launch FieldWorks with the +same backup, open `Format` > `Set up Vernacular Writing Systems...` > `Font`, +and capture the Graphite-only/disabled Font Features behavior before switching +back to this fixed build for the after-state screenshots above. + +The UI screenshots prove that Font Options and OpenType feature discovery are +available in the live dialog. Feature application to rendered text is covered by +the native `TestViews` Charis SIL fixture tests and the managed/native render +and cache tests; the evidence session did not save a project data change just to +produce an applied-render screenshot. diff --git a/openspec/changes/add-opentype-font-features/proposal.md b/openspec/changes/add-opentype-font-features/proposal.md new file mode 100644 index 0000000000..73eb77ec12 --- /dev/null +++ b/openspec/changes/add-opentype-font-features/proposal.md @@ -0,0 +1,58 @@ +## Why + +LT-22324 requires FieldWorks to split Font Features from the Graphite-only UI and apply OpenType font features in current WinForms/Views rendering without regressing complex-script and exotic-language support. This is needed before Graphite can be sunset and before future Avalonia work can consume the same feature settings. + +The phase also needs explicit OpenType-first behavior for dual-technology fonts, traceable fallback and validation behavior, and safe handling of malformed or overlong feature strings so bad font-feature input does not crash or hang FieldWorks. + +## What Changes + +- Add renderer-neutral Font Feature behavior for OpenType feature strings such as `smcp=1`, preserving the existing `tag=value` storage format used by writing-system defaults, styles, and export. +- Decouple Font Features UI from `Enable Graphite` in writing-system setup, style/font dialogs, and shared font attribute controls. +- Filter discovered OpenType features so the UI exposes user-configurable optional features and does not offer required shaping features as toggles. +- Prefer OpenType feature discovery and selection by default when a font exposes both OpenType and Graphite feature sets, while still allowing an explicit Graphite choice. +- Preserve existing Graphite rendering and Graphite feature behavior during Phase 1. +- Add OpenType feature discovery for supported fonts and OpenType feature application in the current Views renderer path. +- Update render/cache invalidation rules so feature changes are treated as layout-changing, especially after `001-render-speedup` is merged. +- Add trace logging for malformed feature strings, malformed tags, filtered feature discovery, provider selection, native shaping failures, and fallback decisions. +- Accept any syntactically valid OpenType tag name, including custom/private tags; reject malformed tags safely and log them instead of narrowing accepted tags to a registry allowlist. +- Fix legacy truncation logic so overlong feature strings without comma boundaries fail safe. +- Keep the existing style/font-feature inheritance path authoritative and retain only the minimal `StyleInfo` compatibility adapter needed for the current build graph to reload default `ktptFontVariations` from style rules. +- Add UI/component tests for font-feature controls and high-level visual rendering tests proving feature settings change output. +- Add robustness tests for malformed input, feature filtering, OpenType-preferred behavior, truncation safety, fallback behavior, CSS/DOCX export safety, and inheritance-path round-tripping. +- Add a test-only HarfBuzzSharp + SkiaSharp comparison path for shaping/rendering confidence toward future Avalonia migration; this path is not a production renderer in Phase 1. +- Add Word DOCX export support for the subset of OpenType font features that Microsoft WordprocessingML can represent, and document unsupported feature tags. +- Bring Phase 1 closer to current best practice by retrying retryable OpenType shaping failures, preserving authoritative script/language inputs, and making output boundaries such as CSS safe for all valid tags. +- Document research for later phases: Graphite removal while retaining WinForms, Avalonia alongside WinForms, and eventual WinForms retirement. + +## Non-goals + +- Removing Graphite in Phase 1. +- Replacing Views.cpp, WinForms, or the FieldWorks editing/selection/layout engine in Phase 1. +- Making HarfBuzzSharp or SkiaSharp part of production rendering in Phase 1. +- Delivering Avalonia UI in Phase 1. +- Changing persisted project schema unless implementation discovers an unavoidable compatibility requirement. +- Rejecting valid custom/private OpenType tags solely because they are not in the OpenType registry. + +## Capabilities + +### New Capabilities + +- `font-feature-settings`: User-visible and renderer-visible behavior for OpenType font feature discovery, persistence, application, cache invalidation, and verification while preserving Graphite compatibility. + +### Modified Capabilities + +- `architecture/ui-framework/views-rendering`: Record how current Views rendering must consume renderer-neutral font features and how `001-render-speedup` layout/render caches must treat feature changes. +- `architecture/ui-framework/winforms-patterns`: Record that Font Features UI is not Graphite-gated and must remain resource/localization friendly. +- `architecture/testing/test-strategy`: Record visual rendering baselines and test-only HarfBuzzSharp + SkiaSharp comparisons as migration evidence for future Avalonia work. + +## Impact + +- **Managed C# UI:** `Src/FwCoreDlgs/FwCoreDlgControls/FontFeaturesButton.cs`, `DefaultFontsControl.cs`, `FwFontAttributes.cs`, `FwFontTab.cs`, `Src/FwCoreDlgs/FwFontDialog.cs`, related `.resx` files and tests. +- **Managed rendering bridge:** `Src/Common/SimpleRootSite/RenderEngineFactory.cs` and post-`001-render-speedup` render/cache invalidation paths. +- **Native C++ Views:** `Src/views/lib/UniscribeEngine.cpp`, `UniscribeSegment.cpp`, `Render.idh` only through additive interfaces if needed, and existing Graphite code for regression coverage. +- **Feature string validation and safety:** `Src/Common/FwUtils/FontFeatureSettings.cs`, `Src/views/VwPropertyStore.cpp`, `Src/xWorks/CssGenerator.cs`. +- **Inheritance path validation:** `Src/FwCoreDlgs/FwCoreDlgControls/StyleInfo.cs`, `Localizations/LCM/src/SIL.LCModel/DomainServices/BaseStyleInfo.cs` source alignment, and style round-trip tests. +- **Tests:** FwCore dialog/control tests, SimpleRootSite/render-factory tests, native Views tests, and post-`001-render-speedup` render baseline/snapshot tests. +- **Word DOCX export:** `Src/xWorks/WordStylesGenerator.cs`, configured dictionary/reversal DOCX tests in `Src/xWorks/xWorksTests/LcmWordGeneratorTests.cs`, and OpenType export documentation. +- **Test-only dependencies:** HarfBuzzSharp + SkiaSharp in test/comparison projects only. +- **Documentation/help:** FieldWorks Help and localized UI text for the renamed Font Features/Font Options surfaces, plus review-driven OpenSpec artifacts. diff --git a/openspec/changes/add-opentype-font-features/research.md b/openspec/changes/add-opentype-font-features/research.md new file mode 100644 index 0000000000..7198d123c1 --- /dev/null +++ b/openspec/changes/add-opentype-font-features/research.md @@ -0,0 +1,285 @@ +## Phase Scope + +Phase 1 is LT-22324: add OpenType Font Features to current WinForms/Views while preserving Graphite. Implementation is assumed to start after `001-render-speedup` is merged. + +The native Views feature-by-feature migration inventory is captured in `views-migration-matrix.md`. That matrix treats Views as a document/view engine, not only a renderer, and stages each subsystem across Phase 1 through Phase 4. + +Phases 2-4 are research context only for this change: + +- Phase 2: remove Graphite while retaining WinForms. +- Phase 3: add Avalonia alongside WinForms. +- Phase 4: retire WinForms years later. + +## External Findings + +### LT-22324 JIRA Findings + +The refreshed Atlassian read-only script fetched `LT-22324` successfully on +2026-04-30. The issue asks to split `Font Features` from `Enable Graphite`, +rename the Graphite-only group to `Font Options`, list and save OpenType +features similarly to Graphite features, and keep feature enablement independent +from Graphite unless the selected font only exposes Graphite features. + +The issue's developer note suggests considering HarfBuzzSharp. The Phase 1 +decision remains to keep production rendering on the existing Views/Uniscribe +path and use HarfBuzzSharp only as test comparison infrastructure. + +The issue points to LT-22351 for acceptance testing and says features should +work with both Graphite and OpenType. It specifically names these Lorna Evans +fonts as having both Graphite and OpenType tables: + +- `https://software.sil.org/downloads/r/charis/CharisSIL-5.000.zip` +- `https://software.sil.org/downloads/r/abyssinica/AbyssinicaSIL-2.201.zip` + +The issue has one comment: "This will affect FLEx Help." No attachments were +returned. + +### Uniscribe OpenType + +Microsoft documents the OpenType Uniscribe path as a coordinated API set: `ScriptItemizeOpenType`, `ScriptShapeOpenType`, and `ScriptPlaceOpenType`. OpenType feature data is supplied through `TEXTRANGE_PROPERTIES` and `OPENTYPE_FEATURE_RECORD`. + +Useful references: + +- https://learn.microsoft.com/en-us/windows/win32/intl/displaying-text-with-uniscribe +- https://learn.microsoft.com/en-us/windows/win32/api/usp10/nf-usp10-scriptitemizeopentype +- https://learn.microsoft.com/en-us/windows/win32/api/usp10/nf-usp10-scriptshapeopentype +- https://learn.microsoft.com/en-us/windows/win32/api/usp10/nf-usp10-scriptplaceopentype +- https://learn.microsoft.com/en-us/windows/win32/api/usp10/ns-usp10-textrange_properties +- https://learn.microsoft.com/en-us/windows/win32/api/usp10/ns-usp10-opentype_feature_record + +Feature discovery can use `ScriptGetFontScriptTags`, `ScriptGetFontLanguageTags`, and `ScriptGetFontFeatureTags`. Required shaping features are controlled by the shaping engine and should not be exposed as user toggles. + +### HarfBuzz / HarfBuzzSharp + +HarfBuzz shapes a run of text into glyph IDs, clusters, advances, and offsets. It does not handle bidi paragraph analysis, line breaking, font fallback, drawing, editing, selection, or hit testing by itself. + +Useful references: + +- https://harfbuzz.github.io/what-is-harfbuzz.html +- https://harfbuzz.github.io/what-harfbuzz-doesnt-do.html +- https://harfbuzz.github.io/shaping-opentype-features.html +- https://harfbuzz.github.io/integration-uniscribe.html + +HarfBuzzSharp exposes `Feature.Parse`, `Font.Shape`, `Buffer.GlyphInfos`, and `Buffer.GlyphPositions`, making it useful as a test oracle for feature effects. + +### SkiaSharp / Avalonia + +SkiaSharp.HarfBuzz can shape and render text for comparison images, but its rasterization differs from GDI/Uniscribe. Avalonia has a `FontFeatureCollection` and accepts HarfBuzz-like feature syntax such as `+smcp` and `-liga`. + +Useful references: + +- https://learn.microsoft.com/en-us/dotnet/api/skiasharp.harfbuzz.skshaper +- https://learn.microsoft.com/en-us/dotnet/api/skiasharp.sktextblob +- https://docs.avaloniaui.net/docs/styling/typography +- https://api-docs.avaloniaui.net/docs/T_Avalonia_Media_FontFeatureCollection + +## Phase 2 Research: Remove Graphite, Keep WinForms + +- Keep the renderer-neutral `tag=value` OpenType feature model. +- Remove Graphite UI labels/toggles only after compatibility and migration policy is defined. +- Preserve old project data even when Graphite feature values no longer apply. +- Add warnings or conversion guidance for Graphite-only feature settings. +- Retain visual baselines for no-feature and OpenType-feature rendering to detect unrelated regressions. + +## Phase 3 Research: Avalonia Alongside WinForms + +- Map FieldWorks feature strings to Avalonia `FontFeatureCollection` / `TextRunProperties.FontFeatures`. +- Use the legacy Views golden baseline set as comparison evidence, not as an exact pixel mandate. +- Classify comparisons as exact same-renderer, tolerant cross-renderer, shaping-data, and semantic layout checks. +- Prefer migration of text model and feature metadata before replacing editing/selection behaviors. + +## Phase 4 Research: Retire WinForms + +- Keep OpenSpec requirements and visual scenarios as renderer-agnostic acceptance tests. +- Remove legacy Graphite and Uniscribe adapters only after Avalonia paths pass feature, bidi, selection, and line-layout acceptance checks. +- Retire WinForms UI controls after equivalent Avalonia controls use the same feature provider and parser behavior. + +## Clarifications To Resolve + +- Confirm test font licensing and whether binary font assets may be committed. The current Phase 1 visual test intentionally uses common installed Windows fonts with feature probes and falls back to inconclusive if none produce a visible feature delta; a deterministic redistributable OFL font asset is still the preferred follow-up. +- Confirm whether friendly labels for OpenType features should be limited to common tags or come from font name tables where available. +- Confirm whether Help changes are part of Phase 1 deliverables or tracked in a linked documentation task. + +## Phase 1 Implementation Notes + +- Test font assets: `Src/views/Test/TestData/Fonts/CharisSIL-5.000` commits the JIRA-specified Charis SIL 5.000 regular TTF with its OFL license and README. Native `TestViews` copies this fixture beside `TestViews.exe`, loads it as a private GDI font, and renders small text runs with feature strings off/on through the production Uniscribe `FindBreakPoint` and `ILgSegment::DrawText` path. HarfBuzz/Skia comparison tests still use installed-font probes because they are test-only cross-renderer comparison coverage. +- JIRA font inventory: the current machine has `Charis SIL`, `Andika`, `Doulos SIL`, `Gentium Plus`, and `Quivira` installed; `Abyssinica SIL` was not installed. The repository also commits `DistFiles/Fonts/Raw/Quivira.otf` under raw font assets. Installer targets download/stage `Andika-6.101.zip`, `CharisSIL-6.101.zip`, `DoulosSIL-6.101.zip`, and `GentiumPlus-6.101.zip`, and WiX includes those plus Quivira. The exact older JIRA-linked `AbyssinicaSIL-2.201.zip` archive is not included in this workspace. +- Manual UIA2 evidence: WinForms MCP verified `Writing System Properties > Font` with `Font Options`, `Charis SIL` selected, `Enable Graphite` unchecked/disabled for that OpenType path, and the `Font Features` menu listing OpenType feature entries such as `Access All Alternates`, `Small Capitals From Capitals`, `Standard Ligatures`, `Small Capitals`, and `cv*` variants. Screenshots are under `evidence/manual-winforms/`. +- Cache identity: managed render-engine cache keys include the normalized feature string, and native `ShapeRunCache` entries include `LgCharRenderProps.szFontVar`. The render verification tests now cover writing-system default features, style-level features, and multi-writing-system text to guard stale output reuse. +- Native verification: `TestViews` includes Charis SIL fixture tests for `liga` metric changes, `smcp` rendered pixel changes, and switching feature state off/on without stale rendered output reuse. The tests exercise the updated production code by passing `szFontVar` feature strings into `FindBreakPoint`, drawing the resulting segment into a bitmap, and comparing rendered pixels. +- Test-only comparison: HarfBuzzSharp and SkiaSharp remain isolated to `RenderComparisonTests`. HarfBuzzSharp is used only as a test comparison path for shaping data; production rendering remains Uniscribe/Graphite. +- Export audit: CSS emits `font-feature-settings` through the shared parser and now escapes valid tags that contain CSS string metacharacters. Notebook export preserves writing-system `DefaultFontFeatures` in XML. `WordStylesGenerator` maps the documented Word `w14` subset for ligatures, number form, number spacing, contextual alternatives, and stylistic sets, and focused tests now cover supported mapping, writing-system defaults, and safe ignoring of unsupported valid or malformed tags. +- Help/docs: no existing FieldWorks help source for Font Options was found in this workspace. Phase 1 adds `Docs/opentype-font-features.md` to document the UI, storage model, temporary Graphite role, and export status. + +## Word DOCX Export Analysis + +Microsoft Word support for OpenType features is exposed in DOCX through a fixed Office 2010 WordprocessingML typography subset, not through an arbitrary CSS-style `font-feature-settings` property. The relevant Open XML SDK classes live under `DocumentFormat.OpenXml.Office2010.Word` and serialize into the `w14` namespace (`http://schemas.microsoft.com/office/word/2010/wordml`). + +Authoritative references gathered for the implementation: + +- Microsoft Support: Publisher/Office typography UI covers number styles, ligatures, stylistic sets, swash, stylistic alternates, true small caps, and font-dependent OpenType availability: https://support.microsoft.com/en-us/office/use-typographic-styles-to-increase-the-impact-of-your-publication-10e14096-452f-4d3b-9938-1d537572a377 +- Microsoft Support: Word compatibility notes identify ligatures, stylistic sets, contextual alternative characters, font-based kerning, and number forms/spacing as advanced typography features that may be preserved even when older Word versions do not display them: https://support.microsoft.com/en-us/office/about-ligatures-and-compatibility-64ffd007-6e5c-4d38-b87d-0935f37714fe +- OpenType feature tag registry and definitions: https://learn.microsoft.com/en-us/typography/opentype/spec/featuretags, plus registered descriptions for `calt`, `clig`, `cvXX`, `kern`, `liga`, `lnum`, `onum`, `pnum`, `smcp`, `ss01`-`ss20`, and `tnum`. +- Open XML SDK classes: `Ligatures` (`w14:ligatures`), `NumberingFormat` (`w14:numForm`), `NumberSpacing` (`w14:numSpacing`), `ContextualAlternatives` (`w14:cntxtAlts`), `StylisticSets` (`w14:stylisticSets`), and `StyleSet` (`w14:styleSet`). + +Planned DOCX subset: + +- `liga`, `clig`, `hlig`, and `dlig` map to the aggregate `w14:ligatures` value. +- `lnum` and `onum` map to `w14:numForm` values `lining` and `oldStyle`. +- `pnum` and `tnum` map to `w14:numSpacing` values `proportional` and `tabular`. +- `calt` maps to `w14:cntxtAlts`. +- `ss01` through `ss20` map to `w14:stylisticSets/w14:styleSet` with ids 1 through 20. + +Unsupported tags such as `cv01`-`cv99`, `smcp`, `c2sc`, `kern`, `salt`, `swsh`, and private/vendor tags do not have a documented arbitrary WordprocessingML feature-tag representation. They should be ignored by Word export while remaining valid for rendering and CSS export where those paths can consume them. + +## In-Depth Review Addendum (2026-05-11) + +This addendum records the deeper implementation review that expanded the change +scope after the initial proposal/design/tasks pass. It is planning-only and does +not imply the reviewed branch was merge-ready as-is. + +### Clarification Pass + +- The earlier native `MM` churn finding is intentionally excluded from the scope + of this review addendum. The user confirmed another agent finished that work; + documentation here assumes the final implementation branch resolves local + churn before validation. +- Phase 1 remains the current WinForms/Views renderer and UI stack. No + production HarfBuzz/Avalonia rewrite is added by this review. +- OpenType is now the intended default provider when a font exposes both + OpenType and Graphite feature sets. +- Accepted OpenType tag names are syntactic, not registry-based: valid custom + or private tags remain allowed. +- Logging, safe fallback, and malformed-input handling are now explicit Phase 1 + scope items rather than possible follow-up cleanups. + +### Accepted OpenType Tag Names + +CSS Fonts 4 and MDN both describe OpenType feature tags as four-character +ASCII strings in the printable `U+20`-`U+7E` range. The same section explicitly +allows feature tags that are not registered, provided they follow the OpenType +tag syntax. + +Useful references: + +- https://www.w3.org/TR/css-fonts-4/#font-feature-settings-prop +- https://developer.mozilla.org/en-US/docs/Web/CSS/font-feature-settings +- https://learn.microsoft.com/en-us/typography/opentype/spec/featuretags + +Planning implication: + +- FieldWorks should accept any syntactically valid four-character printable + ASCII tag, including custom/private tags. +- Malformed tags should be ignored and traced. +- CSS export must safely escape valid tags rather than narrowing accepted tag + syntax to avoid serializer problems. + +### HarfBuzz Guidance On Required Versus Optional Features + +HarfBuzz documentation distinguishes between required/default shaping features +and optional user-facing features. Required/default features include shaping and +mark-handling features such as `ccmp`, `locl`, `mark`, `mkmk`, `rlig`, and some +script-specific defaults. HarfBuzz also enables several common optional features +by default in horizontal text, including `calt`, `clig`, `kern`, and `liga`. + +Useful references: + +- https://harfbuzz.github.io/shaping-opentype-features.html +- https://www.w3.org/TR/css-fonts-4/#default-features +- https://www.w3.org/TR/css-fonts-4/#font-feature-settings-prop + +Planning implication: + +- FieldWorks should not expose engine-required shaping features as user toggles. +- Optional user-facing features such as ligatures, kerning, stylistic sets, and + character variants remain valid UI candidates. +- Filtering needs to distinguish required shaping behavior from optional feature + choice, not simply hide every default-enabled feature. + +### Script And Language Tag Selection + +Repository memory and the Uniscribe docs point to a stronger native direction: + +- script tags should come from `ScriptItemizeOpenType` / `SCRIPT_ANALYSIS` +- language tags should not rely on handwritten locale-to-tag tables when a more + authoritative mapping is available + +Useful references: + +- https://learn.microsoft.com/en-us/windows/win32/api/usp10/nf-usp10-scriptitemizeopentype +- https://learn.microsoft.com/en-us/windows/win32/api/usp10/nf-usp10-scriptshapeopentype +- https://learn.microsoft.com/en-us/windows/win32/api/usp10/nf-usp10-scriptplaceopentype +- /memories/repo/fieldworks-opentype-tag-mapping.md + +Planning implication: + +- keep script tags authoritative from `ScriptItemizeOpenType` +- if OS APIs are insufficient for language tags, prefer vendored/generated + mappings over ad hoc handwritten tables +- trace any fallback from authoritative language selection to weaker heuristics + +### Retryable Native Failure Modes + +Microsoft documents `E_OUTOFMEMORY` from `ScriptShapeOpenType` and +`ScriptPlaceOpenType` as a buffer-sizing condition that should be retried with a +larger output buffer before abandoning the OpenType path. + +Useful references: + +- https://learn.microsoft.com/en-us/windows/win32/api/usp10/nf-usp10-scriptshapeopentype +- https://learn.microsoft.com/en-us/windows/win32/api/usp10/nf-usp10-scriptplaceopentype + +Planning implication: + +- native OpenType shaping should retry retryable sizing failures before + downgrading to legacy shaping +- trace logging should record retries, fallback reasons, and final disposition + +### Verified Local Code Findings Folded Into Scope + +The review directly verified these local concerns and turned them into planning +items: + +- `FontFeaturesButton` was still Graphite-first by default and only some shared + font surfaces provided explicit provider context. +- raw OpenType feature discovery was broad enough to risk exposing non-user + shaping features. +- native fallback paths were mostly silent. +- `VwPropertyStore.cpp` truncation logic had a no-comma risk for overlong + strings. +- `StyleInfo` maintained a parallel default-font-feature loading path that could + drift from `BaseStyleInfo`. +- CSS export inserted valid tags into quoted CSS strings without a review-driven + escaping plan. +- `manual-testing.md` referenced `evidence/manual-winforms/` screenshots that + were not present in the checked workspace, so the evidence note needs to be + reconciled with the actual captured artifacts. + +Planning implication: + +- the OpenSpec change now includes OpenType-first UI defaults, explicit toggle + planning, trace logging, truncation safety, inheritance cleanup, and CSS-safe + serialization. + +Implementation follow-up: + +- Removing the `StyleInfo` loader was attempted during implementation and failed + `SaveToDB_DefaultFontFeatures_RoundTripsThroughRules`, while restoring it made + the style/font-tab slice pass. The loader therefore remains as a minimal + compatibility adapter until the active LCM dependency path consumes + `ktptFontVariations` defaults through `BaseStyleInfo.ProcessStyleRules`. + +### Recommended Additional Tests Beyond The Original Plan + +- UI tests for filtered required features versus optional displayed features. +- UI tests for OpenType-default provider choice on dual-technology fonts. +- Parser tests for valid custom tags, malformed tags, duplicate tags, and mixed + valid/invalid strings. +- Native tests for malformed input, `E_OUTOFMEMORY` retry behavior, and traced + fallback paths. +- Robustness tests for overlong strings with and without commas. +- CSS export tests for escaping/serializing all valid accepted tags. +- Notebook export coverage for writing-system default font features. + +These tests are tracked as review-driven tasks rather than optional stretch +coverage. diff --git a/openspec/changes/add-opentype-font-features/specs/architecture/testing/test-strategy/spec.md b/openspec/changes/add-opentype-font-features/specs/architecture/testing/test-strategy/spec.md new file mode 100644 index 0000000000..ad77ac17b2 --- /dev/null +++ b/openspec/changes/add-opentype-font-features/specs/architecture/testing/test-strategy/spec.md @@ -0,0 +1,210 @@ +## ADDED Requirements + +### Requirement: Font-feature behavior has layered tests +FieldWorks SHALL verify font-feature behavior with layered tests covering parser/provider logic, WinForms controls, native shaping, and high-level visual rendering. + +#### Scenario: UI control tests cover OpenType without Graphite +- **WHEN** managed UI tests run for the shared Font Features controls +- **THEN** they SHALL verify OpenType feature availability and persistence without requiring Graphite enablement + +#### Scenario: Native tests cover feature shaping and placement +- **WHEN** native Views tests run for the Uniscribe renderer +- **THEN** they SHALL verify OpenType feature-on and feature-off shaping/placement behavior with deterministic font data + +#### Scenario: Render baselines cover visual feature effects +- **WHEN** render snapshot tests run after `001-render-speedup` is merged +- **THEN** they SHALL include scenarios proving selected font features visibly affect WinForms/Views output + +### Requirement: Visual baselines support future renderer migration +The render baseline test framework SHALL distinguish same-renderer regression baselines from cross-renderer migration comparisons. + +#### Scenario: Legacy renderer uses stricter comparisons +- **WHEN** comparing WinForms/Views output before and after Phase 1 changes +- **THEN** tests MAY use strict or near-strict bitmap comparisons where the same renderer stack is expected + +#### Scenario: HarfBuzzSharp and SkiaSharp comparisons use tolerances +- **WHEN** comparing GDI/Uniscribe output with HarfBuzzSharp/SkiaSharp output +- **THEN** tests SHALL document tolerance rules and prefer shaping-data assertions for exactness + +#### Scenario: Test assets live in FieldWorks test projects +- **WHEN** deterministic fonts, baselines, or comparison specifications are added +- **THEN** they SHALL be committed under FieldWorks test projects or OpenSpec change artifacts with clear licensing and build inclusion rules + +### Requirement: Robustness and diagnostics tests cover malformed input +FieldWorks SHALL add test coverage for malformed, overlong, and mixed-validity font-feature input as part of Phase 1. + +#### Scenario: Malformed tags are tested independently from valid tags +- **WHEN** parser or normalization tests run +- **THEN** they SHALL include malformed tags, valid custom tags, duplicate tags, and mixed valid/invalid feature strings + +#### Scenario: Truncation safety is tested +- **WHEN** legacy feature-string truncation or boundary tests run +- **THEN** they SHALL include overlong values with and without comma boundaries and verify the code does not hang + +### Requirement: UI filter and provider-toggle tests exist +FieldWorks SHALL verify required-feature filtering and OpenType-preferred provider behavior in managed UI tests. + +#### Scenario: Required features are not shown as user toggles +- **WHEN** managed UI tests exercise OpenType feature discovery +- **THEN** they SHALL verify required shaping features are filtered out while optional user-facing features remain available + +#### Scenario: Dual-technology fonts default to OpenType +- **WHEN** managed UI tests exercise fonts that expose both OpenType and Graphite features +- **THEN** they SHALL verify the control defaults to OpenType and still permits explicit provider switching + +### Requirement: Native fallback and retry behavior has targeted tests +FieldWorks SHALL verify retryable OpenType errors, traced fallback behavior, and authoritative script/language handling with native tests where practical. + +#### Scenario: Retryable OpenType failure is covered +- **WHEN** native robustness tests simulate or exercise retryable shaping failure conditions +- **THEN** they SHALL verify retry occurs before fallback is accepted + +#### Scenario: Traced fallback behavior is covered +- **WHEN** native tests exercise malformed input or unsupported valid features +- **THEN** they SHALL verify rendering remains stable and fallback/diagnostic hooks are reachable + +### Requirement: Export regression tests cover safe serialization +FieldWorks SHALL verify that accepted feature strings remain safe at export boundaries. + +#### Scenario: CSS serialization is safe for all valid accepted tags +- **WHEN** CSS export tests run for feature strings that contain any syntactically valid OpenType tag +- **THEN** they SHALL verify the serialized CSS remains valid and safe + +#### Scenario: Notebook and Word export coverage remains explicit +- **WHEN** export tests run +- **THEN** they SHALL cover Notebook preservation of default font features and Word DOCX mapping for the documented supported subset +*** Add File: c:\Users\johnm\Documents\repos\FieldWorks\openspec\changes\add-opentype-font-features\specs\in-depth-review.md +# In-Depth Review + +This note captures the deeper review pass for `add-opentype-font-features` and +turns that review into clarified OpenSpec scope. It is a planning artifact only; +it does not describe completed implementation work. + +## Purpose + +- record the clarified scope after the PR/spec/implementation review +- separate merge-blocking concerns from acceptable follow-up research +- map review findings into proposal, design, capability specs, and tasks + +## Clarification Pass + +- The earlier local native `MM` churn finding is intentionally not part of this + artifact. The user confirmed another agent completed that work, so this note + treats churn reconciliation as a validation/process check rather than feature + scope. +- Phase 1 remains current WinForms plus current Views/Uniscribe, with Graphite + preserved and HarfBuzz/Skia kept test-only. +- OpenType is now the intended default provider for dual-technology fonts. +- Valid OpenType tag acceptance is syntactic: any four-character printable ASCII + tag is acceptable even if it is custom/private. +- Filtering applies to what the UI exposes, not to what storage accepts. +- Logging, graceful fallback, and malformed-input safety are Phase 1 + deliverables. + +## Analysis Pass + +### Overall Verdict + +The core direction remains correct: + +- keep renderer-neutral `tag=value` storage +- apply OpenType in the existing production renderer path +- preserve Graphite behavior during Phase 1 +- use HarfBuzz/Skia only for comparison evidence + +The review identified seven planning gaps that needed to become explicit scope. + +### Verified Planning Gaps + +1. **Raw feature discovery is too broad.** + OpenType UI discovery can expose required or engine-controlled shaping + features if it simply dumps GSUB/GPOS tags. The change now needs an explicit + filtering rule plus tests. + +2. **Dual-technology fonts were still Graphite-first.** + Shared font-feature surfaces did not consistently carry provider context, and + the control still defaulted toward Graphite assumptions. The change now needs + OpenType-first behavior and a clear explicit provider toggle. + +3. **Graceful fallback existed, but diagnostics were weak.** + The feature should keep going on malformed tags, unsupported features, or bad + fonts, but that behavior must be observable in trace logs. + +4. **Legacy truncation logic had a hang risk.** + Overlong feature strings without a comma boundary could leave old truncation + logic without a safe forward-progress guarantee. The change now needs a + fail-safe truncation task and tests. + +5. **Tag acceptance and export safety were misaligned.** + The right contract is to accept any valid OpenType tag while ensuring CSS and + other outputs serialize those valid tags safely. The change now needs liberal + validation plus safe emitters. + +6. **A duplicate inheritance path was present.** + Default font-feature loading existed in more than one place. The change now + needs to make the existing inheritance path authoritative. + +7. **State-of-the-art follow-ups were missing from scope.** + Retryable `E_OUTOFMEMORY`, authoritative script/language inputs, CSS-safe tag + serialization, Notebook export coverage, and explicit fallback diagnostics now + belong in the plan. + +## Planning Pass + +### Required Implementation Workstreams + +1. **Input validation and normalization** + Accept valid tags, trace malformed tags, and make legacy truncation safe. + +2. **Feature discovery and UI filtering** + Expose optional user-configurable features only; keep required shaping + features under engine control. + +3. **OpenType-first shared UI behavior** + Make OpenType the default provider for dual-technology fonts and add an + explicit provider toggle. + +4. **Native shaping robustness and diagnostics** + Retry retryable sizing failures, preserve authoritative script/language + inputs, and trace fallback reasons. + +5. **Inheritance-path cleanup** + Remove duplicate style/default loaders and rely on the authoritative existing + path. + +6. **Export and serializer safety** + Keep storage liberal while ensuring CSS and DOCX outputs remain safe and + documented. + +7. **Layered test coverage** + Add targeted tests for filtering, toggles, malformed input, truncation, + fallback, exports, and cache invalidation. + +### Acceptance Signals + +- required shaping features are not shown as user toggles +- dual-technology fonts default to OpenType in shared UI surfaces +- malformed tags are logged and ignored without blocking valid entries +- overlong/no-comma feature strings do not hang or crash +- native fallback reasons and retry decisions are traceable +- default/inherited font features round-trip through one authoritative path +- CSS and DOCX export behavior is safe for accepted tags and documented subsets +- added tests cover the review-driven risk areas + +### Artifact Mapping + +- `proposal.md`: widened scope and impact +- `design.md`: clarified decisions for OpenType preference, filtering, + validation, logging, inheritance, truncation, and state-of-the-art native + behavior +- `research.md`: external references and review addendum +- `tasks.md`: new unchecked review-driven backlog sections +- capability specs: behavior requirements for provider choice, validation, + fallback, and test coverage + +## Not Planned As Separate Scope + +- reopening the whole Phase 1 architecture around a production HarfBuzz renderer +- removing Graphite in Phase 1 +- treating local review churn as a product requirement instead of a validation + prerequisite diff --git a/openspec/changes/add-opentype-font-features/specs/architecture/ui-framework/views-rendering/spec.md b/openspec/changes/add-opentype-font-features/specs/architecture/ui-framework/views-rendering/spec.md new file mode 100644 index 0000000000..6eb2132f98 --- /dev/null +++ b/openspec/changes/add-opentype-font-features/specs/architecture/ui-framework/views-rendering/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: Views rendering consumes renderer-neutral font features +The Views rendering architecture SHALL treat `ktptFontVariations` / run font-feature strings as renderer-neutral input that may be consumed by Graphite, OpenType Uniscribe, or future renderers. + +#### Scenario: OpenType features are applied at shaping time +- **WHEN** a run reaches the Uniscribe renderer with a non-empty OpenType feature string +- **THEN** shaping and placement SHALL use the feature string when producing glyphs, advances, and offsets + +#### Scenario: Empty feature strings preserve existing behavior +- **WHEN** a run has no feature string +- **THEN** Views rendering SHALL preserve the existing no-feature Uniscribe behavior + +### Requirement: Post-speedup caches include font-feature identity +After `001-render-speedup` is merged, Views rendering caches and dirty-state guards SHALL include font-feature changes anywhere those changes can alter glyphs, metrics, layout, or captured pixels. + +#### Scenario: NeedsReconstruct or layout dirty state observes feature changes +- **WHEN** a writing-system default feature or style feature changes +- **THEN** affected root boxes SHALL be marked for reconstruct/layout as needed before drawing + +#### Scenario: Warm render paths do not reuse stale feature output +- **WHEN** a warm render path or buffered frame path is entered after a feature change +- **THEN** it SHALL not reuse a visual or shaped result created with a different feature string + +### Requirement: Production renderer changes remain additive to COM contracts +OpenType feature support SHALL avoid breaking existing COM vtables and reg-free COM activation. + +#### Scenario: Feature discovery needs additional metadata +- **WHEN** OpenType feature discovery needs metadata not representable by existing interfaces +- **THEN** the implementation SHALL add an additive interface or managed seam rather than changing existing interface method order or signatures + +### Requirement: OpenType shaping failures are observable and safe +Views rendering SHALL log retry and fallback decisions for OpenType shaping failures and SHALL preserve graceful continuation. + +#### Scenario: Retryable buffer sizing errors are retried first +- **WHEN** `ScriptShapeOpenType` or `ScriptPlaceOpenType` returns `E_OUTOFMEMORY` +- **THEN** the renderer SHALL retry with larger buffers before abandoning the OpenType shaping path + +#### Scenario: OpenType fallback reason is traced +- **WHEN** native rendering falls back from the OpenType path to an older shaping path or ignores malformed feature input +- **THEN** the renderer SHALL emit trace diagnostics describing the fallback reason + +### Requirement: Script and language inputs use authoritative sources +Views rendering SHALL preserve authoritative script and language inputs for OpenType shaping wherever practical. + +#### Scenario: Script tags come from itemization +- **WHEN** OpenType shaping is used for a run +- **THEN** the authoritative script tag SHALL come from `ScriptItemizeOpenType` / `SCRIPT_ANALYSIS` rather than a handwritten guess + +#### Scenario: Language-tag fallback is explicit +- **WHEN** the renderer cannot obtain an authoritative language tag directly from its preferred mapping path +- **THEN** it SHALL use a documented fallback strategy and trace that fallback diff --git a/openspec/changes/add-opentype-font-features/specs/architecture/ui-framework/winforms-patterns/spec.md b/openspec/changes/add-opentype-font-features/specs/architecture/ui-framework/winforms-patterns/spec.md new file mode 100644 index 0000000000..54ca68896e --- /dev/null +++ b/openspec/changes/add-opentype-font-features/specs/architecture/ui-framework/winforms-patterns/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: Font-feature controls are not Graphite-gated +WinForms Font Features controls SHALL be composed so feature availability depends on selected-font capabilities and explicit disabled states, not on the Graphite checkbox. + +#### Scenario: Shared control is reused across canonical font surfaces +- **WHEN** Writing System Properties, Styles, or the shared Font dialog need font-feature selection +- **THEN** they SHALL use the shared Font Features control/provider behavior rather than duplicating Graphite or OpenType checks + +#### Scenario: Explicit disabled state still wins +- **WHEN** a caller explicitly disables font-feature selection, such as through an existing always-disable flag +- **THEN** the shared Font Features control SHALL remain disabled even if the selected font has features + +### Requirement: Font-feature UI changes are localization-safe +WinForms UI changes for Font Features SHALL keep user-visible strings in `.resx` resources and avoid unnecessary designer churn. + +#### Scenario: Labels are renamed through resources +- **WHEN** Graphite-specific labels are made generic +- **THEN** the visible text SHALL be updated through resource files instead of hardcoded strings + +#### Scenario: Designer fields remain stable unless necessary +- **WHEN** an existing designer field has a Graphite-specific internal name but only the visible label changes +- **THEN** implementation SHOULD prefer label/resource changes over broad designer renames unless the rename reduces real maintenance risk + +### Requirement: Dual-technology fonts expose a clear provider toggle +WinForms font-feature surfaces SHALL expose a clear OpenType-versus-Graphite provider choice when a selected font supports both feature systems, and SHALL default to OpenType. + +#### Scenario: OpenType is the default provider +- **WHEN** a Writing System, Styles, or shared Font dialog surface loads a font that exposes both OpenType and Graphite features +- **THEN** the shared Font Features control SHALL default to the OpenType provider + +#### Scenario: Provider toggle semantics are consistent across shared surfaces +- **WHEN** a user switches the provider on one canonical font surface +- **THEN** the provider selection rules, labels, and availability semantics SHALL match the other shared font-feature surfaces that use the same control + +#### Scenario: Enable Graphite does not replace provider choice +- **WHEN** `Enable Graphite` is toggled for renderer selection behavior +- **THEN** that control SHALL remain separate from the dual-technology feature-provider choice exposed by the Font Features UI diff --git a/openspec/changes/add-opentype-font-features/specs/font-feature-settings/spec.md b/openspec/changes/add-opentype-font-features/specs/font-feature-settings/spec.md new file mode 100644 index 0000000000..38c0827be5 --- /dev/null +++ b/openspec/changes/add-opentype-font-features/specs/font-feature-settings/spec.md @@ -0,0 +1,161 @@ +## ADDED Requirements + +### Requirement: Font Features are independent from Graphite enablement +FieldWorks SHALL expose Font Features as a generic font capability for Graphite and OpenType fonts, and SHALL NOT require `Enable Graphite` to be checked before OpenType font features can be viewed or selected. + +#### Scenario: OpenType writing-system font enables Font Features without Graphite +- **WHEN** a user selects an OpenType-capable default font in Writing System Properties and `Enable Graphite` is unchecked +- **THEN** the Font Features control SHALL remain available when the font has configurable OpenType features + +#### Scenario: Graphite enablement remains separate +- **WHEN** a user checks or unchecks `Enable Graphite` +- **THEN** FieldWorks SHALL change only Graphite renderer selection behavior and SHALL NOT erase OpenType font-feature settings + +#### Scenario: Non-feature fonts disable only feature selection +- **WHEN** a selected font exposes no configurable Graphite or OpenType features +- **THEN** the Font Features control SHALL be disabled while the rest of the font selection UI remains usable + +### Requirement: Feature strings are stored in a renderer-neutral format +FieldWorks SHALL store user-selected font features as normalized `tag=value` strings suitable for OpenType, CSS export, test comparison tooling, and future Avalonia consumption. + +#### Scenario: OpenType features round-trip through writing-system defaults +- **WHEN** a user selects `smcp=1` for a writing-system default font +- **THEN** the writing system SHALL persist `smcp=1` as the default font feature string and reload it when the dialog is reopened + +#### Scenario: OpenType features round-trip through styles and font dialogs +- **WHEN** a user selects OpenType features in the Styles Font tab or shared Font dialog +- **THEN** the selected feature string SHALL be saved through `FontInfo.m_features` / `ktptFontVariations` and restored when the style or dialog is reopened + +#### Scenario: Graphite conversion is isolated +- **WHEN** Graphite rendering requires numeric feature IDs +- **THEN** conversion from four-character tags to Graphite IDs SHALL occur only at the Graphite renderer boundary + +### Requirement: OpenType feature discovery supports UI selection +FieldWorks SHALL discover user-configurable OpenType feature tags for the selected font and expose them through the existing Font Features UI pattern. + +#### Scenario: OpenType font lists feature tags +- **WHEN** a selected font advertises user-configurable OpenType features +- **THEN** the Font Features control SHALL list those feature tags with resource-backed friendly labels where available and tag fallback labels otherwise + +#### Scenario: Required shaping features are not exposed as toggles +- **WHEN** a feature is required for script shaping and not user-configurable +- **THEN** the Font Features control SHALL NOT present it as a user toggle + +#### Scenario: Existing Graphite feature discovery still works +- **WHEN** a Graphite font is selected and Graphite remains enabled +- **THEN** existing Graphite feature labels and values SHALL continue to be available through the Font Features control + +### Requirement: OpenType features affect current Views rendering +FieldWorks SHALL apply OpenType font features in current WinForms/Views data entry and preview rendering paths. + +#### Scenario: Writing-system default feature changes preview and data entry +- **WHEN** a writing-system default font feature such as `smcp=1` is selected for a supported OpenType font +- **THEN** both preview and data-entry Views rendering SHALL show the corresponding glyph/metric change + +#### Scenario: Style-specific feature changes preview and data entry +- **WHEN** a style such as Normal specifies an OpenType feature for the vernacular writing system +- **THEN** both preview and data-entry Views rendering SHALL show the corresponding glyph/metric change for text using that style + +#### Scenario: Unsupported features are safe +- **WHEN** a feature tag is unsupported by the selected font or script +- **THEN** rendering SHALL remain stable and SHALL NOT crash across managed/native boundaries + +### Requirement: Font-feature changes invalidate render and layout caches +FieldWorks SHALL treat feature strings as part of render/layout identity after `001-render-speedup` is merged. + +#### Scenario: Feature toggle does not reuse stale layout +- **WHEN** a font feature value changes for text already rendered in a root site +- **THEN** subsequent rendering SHALL recompute any affected shaping, layout, line breaks, and cached visual output + +#### Scenario: Same font with different features remains distinct +- **WHEN** two runs use the same font, size, bold, italic, writing system, and direction but different feature strings +- **THEN** renderer and layout caches SHALL NOT conflate their shaped output + +### Requirement: Test-only HarfBuzzSharp and SkiaSharp comparisons exist +FieldWorks SHALL include a test-only comparison path using HarfBuzzSharp and SkiaSharp to support future Avalonia migration confidence; this path SHALL NOT be used by production rendering in Phase 1. + +#### Scenario: HarfBuzzSharp comparison verifies shaping effect +- **WHEN** a deterministic test font and feature string are shaped by the test comparison path +- **THEN** the test SHALL verify glyph IDs, clusters, advances, or offsets differ as expected when the feature is toggled + +#### Scenario: SkiaSharp comparison produces visual evidence +- **WHEN** a comparison render is generated for a supported feature scenario +- **THEN** the test SHALL produce or verify a visual comparison artifact with documented tolerance rules + +#### Scenario: Production assemblies do not reference test-only renderers +- **WHEN** production FieldWorks projects are built +- **THEN** HarfBuzzSharp and SkiaSharp SHALL NOT be required for production rendering or application startup + +### Requirement: Help and localized UI describe Font Features generically +FieldWorks SHALL update user-visible strings and help so Font Features are described as font features, not Graphite-only options. + +#### Scenario: Writing-system UI labels are generic +- **WHEN** a user opens Writing System Properties +- **THEN** labels and help text SHALL describe Font Features or Font Options without implying they only apply to Graphite fonts + +#### Scenario: Help covers OpenType features +- **WHEN** a user opens the relevant FieldWorks Help topic +- **THEN** the Help content SHALL describe OpenType Font Features and their relationship to Graphite during Phase 1 + +### Requirement: Dual-technology fonts default to OpenType features +FieldWorks SHALL prefer OpenType feature discovery and selection when a selected font exposes both OpenType and Graphite feature sets, while still allowing explicit user selection of Graphite features. + +#### Scenario: Dual-technology font defaults to OpenType in shared UI +- **WHEN** a selected font exposes both OpenType and Graphite feature sets +- **THEN** the shared Font Features UI SHALL open on the OpenType provider by default + +#### Scenario: Explicit provider switch remains available +- **WHEN** a user intentionally switches from the OpenType provider to the Graphite provider for a dual-technology font +- **THEN** the UI SHALL update to the selected provider without silently discarding persisted font-feature data before the user confirms changes + +### Requirement: Accepted OpenType tags are syntactically validated +FieldWorks SHALL accept any syntactically valid OpenType feature tag and SHALL reject malformed tags safely with trace logging. + +#### Scenario: Valid custom tags are preserved +- **WHEN** a feature string contains a valid four-character printable ASCII tag that is not in a published registry allowlist +- **THEN** FieldWorks SHALL accept and persist that tag in renderer-neutral `tag=value` form + +#### Scenario: Malformed tags are ignored and traced +- **WHEN** a feature string contains a malformed tag such as the wrong length, control characters, or non-ASCII characters +- **THEN** FieldWorks SHALL ignore the malformed entry, log the validation failure, and continue processing remaining valid entries + +### Requirement: Malformed and overlong feature strings fail safe +FieldWorks SHALL handle malformed or overlong font-feature strings without crashing or hanging. + +#### Scenario: Overlong string without comma boundary does not hang +- **WHEN** legacy truncation or normalization code receives an overlong feature string with no comma boundary +- **THEN** FieldWorks SHALL terminate safely, preserve progress where possible, and SHALL NOT loop indefinitely + +#### Scenario: Mixed malformed input does not block valid settings +- **WHEN** a feature string contains both malformed entries and valid entries +- **THEN** valid entries SHALL continue to round-trip and apply while malformed entries are ignored and traced + +### Requirement: Font-feature inheritance follows the authoritative path +FieldWorks SHALL use the existing style and writing-system inheritance path for default and explicit font features instead of maintaining a duplicate loading path. + +#### Scenario: Default font features load through existing style processing +- **WHEN** a style inherits or overrides default font features +- **THEN** the loaded value SHALL come through the existing `BaseStyleInfo` / `FontInfo.m_features` processing path + +#### Scenario: Style dialogs do not maintain a parallel default-feature loader +- **WHEN** the style dialog loads or saves font features +- **THEN** it SHALL adapt the authoritative inherited value rather than calculating a separate duplicate default-feature value path + +### Requirement: Word DOCX export preserves supported OpenType font features +FieldWorks SHALL export supported OpenType font feature settings into configured dictionary/reversal Word DOCX output using documented Microsoft WordprocessingML typography elements. + +#### Scenario: Character style features are exported to Word typography properties +- **WHEN** a configured Word DOCX export includes a character style with supported OpenType feature settings +- **THEN** the generated Word style run properties SHALL include the equivalent Office 2010 `w14` typography elements for supported features + +#### Scenario: Explicit run features are exported to Word typography properties +- **WHEN** direct run font properties include supported OpenType feature settings +- **THEN** generated run properties SHALL include the equivalent Office 2010 `w14` typography elements for supported features + +#### Scenario: Unsupported feature tags do not break Word export +- **WHEN** a feature string contains tags that WordprocessingML cannot represent, such as `cv01`, `smcp`, or private feature tags +- **THEN** Word export SHALL ignore those unsupported tags without failing the export or removing supported tags from the same feature string + +#### Scenario: Word export uses a documented subset +- **WHEN** documentation describes Word export behavior +- **THEN** it SHALL list the supported WordprocessingML subset and identify arbitrary CSS-style feature tags as unsupported by DOCX export diff --git a/openspec/changes/add-opentype-font-features/specs/in-depth-review.md b/openspec/changes/add-opentype-font-features/specs/in-depth-review.md new file mode 100644 index 0000000000..6e20a24def --- /dev/null +++ b/openspec/changes/add-opentype-font-features/specs/in-depth-review.md @@ -0,0 +1,134 @@ +# In-Depth Review + +This note captures the deeper review pass for `add-opentype-font-features` and +turns that review into clarified OpenSpec scope. It is a planning artifact only; +it does not describe completed implementation work. + +## Purpose + +- record the clarified scope after the PR/spec/implementation review +- separate merge-blocking concerns from acceptable follow-up research +- map review findings into proposal, design, capability specs, and tasks + +## Clarification Pass + +- The earlier local native `MM` churn finding is intentionally not part of this + artifact. The user confirmed another agent completed that work, so this note + treats churn reconciliation as a validation/process check rather than feature + scope. +- Phase 1 remains current WinForms plus current Views/Uniscribe, with Graphite + preserved and HarfBuzz/Skia kept test-only. +- OpenType is now the intended default provider for dual-technology fonts. +- Valid OpenType tag acceptance is syntactic: any four-character printable ASCII + tag is acceptable even if it is custom/private. +- Filtering applies to what the UI exposes, not to what storage accepts. +- Logging, graceful fallback, and malformed-input safety are Phase 1 + deliverables. + +## Analysis Pass + +### Overall Verdict + +The core direction remains correct: + +- keep renderer-neutral `tag=value` storage +- apply OpenType in the existing production renderer path +- preserve Graphite behavior during Phase 1 +- use HarfBuzz/Skia only for comparison evidence + +The review identified seven planning gaps that needed to become explicit scope. + +### Verified Planning Gaps + +1. **Raw feature discovery is too broad.** + OpenType UI discovery can expose required or engine-controlled shaping + features if it simply dumps GSUB/GPOS tags. The change now needs an explicit + filtering rule plus tests. + +2. **Dual-technology fonts were still Graphite-first.** + Shared font-feature surfaces did not consistently carry provider context, and + the control still defaulted toward Graphite assumptions. The change now needs + OpenType-first behavior and a clear explicit provider toggle. + +3. **Graceful fallback existed, but diagnostics were weak.** + The feature should keep going on malformed tags, unsupported features, or bad + fonts, but that behavior must be observable in trace logs. + +4. **Legacy truncation logic had a hang risk.** + Overlong feature strings without a comma boundary could leave old truncation + logic without a safe forward-progress guarantee. The change now needs a + fail-safe truncation task and tests. + +5. **Tag acceptance and export safety were misaligned.** + The right contract is to accept any valid OpenType tag while ensuring CSS and + other outputs serialize those valid tags safely. The change now needs liberal + validation plus safe emitters. + +6. **A duplicate inheritance path was present.** + Default font-feature loading existed in more than one place. The change now + needs to make the existing inheritance path authoritative. + +7. **State-of-the-art follow-ups were missing from scope.** + Retryable `E_OUTOFMEMORY`, authoritative script/language inputs, CSS-safe tag + serialization, Notebook export coverage, and explicit fallback diagnostics now + belong in the plan. + +## Planning Pass + +### Required Implementation Workstreams + +1. **Input validation and normalization** + Accept valid tags, trace malformed tags, and make legacy truncation safe. + +2. **Feature discovery and UI filtering** + Expose optional user-configurable features only; keep required shaping + features under engine control. + +3. **OpenType-first shared UI behavior** + Make OpenType the default provider for dual-technology fonts and add an + explicit provider toggle. + +4. **Native shaping robustness and diagnostics** + Retry retryable sizing failures, preserve authoritative script/language + inputs, and trace fallback reasons. + +5. **Inheritance-path cleanup** + Remove duplicate style/default loaders and rely on the authoritative existing + path. + +6. **Export and serializer safety** + Keep storage liberal while ensuring CSS and DOCX outputs remain safe and + documented. + +7. **Layered test coverage** + Add targeted tests for filtering, toggles, malformed input, truncation, + fallback, exports, and cache invalidation. + +### Acceptance Signals + +- required shaping features are not shown as user toggles +- dual-technology fonts default to OpenType in shared UI surfaces +- malformed tags are logged and ignored without blocking valid entries +- overlong/no-comma feature strings do not hang or crash +- native fallback reasons and retry decisions are traceable +- default/inherited font features round-trip through one authoritative path +- CSS and DOCX export behavior is safe for accepted tags and documented subsets +- added tests cover the review-driven risk areas + +### Artifact Mapping + +- `proposal.md`: widened scope and impact +- `design.md`: clarified decisions for OpenType preference, filtering, + validation, logging, inheritance, truncation, and state-of-the-art native + behavior +- `research.md`: external references and review addendum +- `tasks.md`: new unchecked review-driven backlog sections +- capability specs: behavior requirements for provider choice, validation, + fallback, and test coverage + +## Not Planned As Separate Scope + +- reopening the whole Phase 1 architecture around a production HarfBuzz renderer +- removing Graphite in Phase 1 +- treating local review churn as a product requirement instead of a validation + prerequisite diff --git a/openspec/changes/add-opentype-font-features/tasks.md b/openspec/changes/add-opentype-font-features/tasks.md new file mode 100644 index 0000000000..5eaf10edd1 --- /dev/null +++ b/openspec/changes/add-opentype-font-features/tasks.md @@ -0,0 +1,166 @@ +> Review-driven update (2026-05-11): Sections 1-11 record research, evidence, +> and earlier planning already captured for this change. They are not a +> merge-readiness signal. Sections 12-22 record the review-driven implementation +> backlog and the final status discovered during implementation. + +## 1. Post-Speedup Preflight + +- [x] 1.1 Rebase or merge the implementation branch after `001-render-speedup` is merged, then inspect render/cache changes in `Src/Common/SimpleRootSite/`, `Src/ManagedVwDrawRootBuffered/`, and `Src/views/`. [Managed C# + Native C++] +- [x] 1.2 Verify the render snapshot/baseline infrastructure from `001-render-speedup` is present and runnable before adding LT-22324 visual tests. [Managed C#] +- [x] 1.3 Confirm redistributable test fonts and licenses for OpenType feature scenarios; `CharisSIL-5.000` from the JIRA issue is committed under the native Views test data with its OFL license. [Planning/Test] +- [x] 1.4 Record selected fonts, feature tags, expected visual differences, and licensing notes in the FieldWorks test project assets or OpenSpec research notes. [Docs/Test] +- [x] 1.5 Use `views-migration-matrix.md` as the Views subsystem checklist when selecting Phase 1 visual, selection, cache, and future-migration baseline scenarios. [Planning/Test] + +## 2. Renderer-Neutral Feature Model + +- [x] 2.1 Add or identify a shared managed parser/normalizer for `tag=value` font feature strings, including duplicate, empty, invalid, and ordering behavior. File area: `Src/Common/FwUtils/` or existing font utility location. [Managed C#] +- [x] 2.2 Add unit tests for parser/normalizer behavior, including OpenType tags such as `smcp=1`, `kern=0`, and alternate values such as `cv01=2`. [Managed C#] +- [x] 2.3 Keep Graphite tag-to-ID conversion isolated at the Graphite boundary; do not reuse Graphite numeric conversion for OpenType. Files: `Src/Common/FwUtils/GraphiteFontFeatures.cs`, `Src/views/lib/GraphiteEngine.cpp`. [Managed C# + Native C++] +- [x] 2.4 Define a font-feature provider seam for UI discovery with Graphite and OpenType implementations. Files: `Src/FwCoreDlgs/FwCoreDlgControls/FontFeaturesButton.cs` and nearby controls. [Managed C#] + +## 3. Native OpenType Rendering + +- [x] 3.1 Audit current feature flow through `FwTextPropType.ktptFontVariations`, `VwPropertyStore`, `LgCharRenderProps`, and `UniscribeSegment` to confirm the per-run feature carrier. Files: `Src/views/VwPropertyStore.cpp`, `Src/views/lib/UniscribeSegment.cpp`. [Native C++] +- [x] 3.2 Add native parsing from the run feature string into OpenType feature records suitable for Uniscribe OpenType APIs. Files: `Src/views/lib/UniscribeEngine.cpp`, `Src/views/lib/UniscribeSegment.cpp/.h`. [Native C++] +- [x] 3.3 Replace or branch the no-feature `ScriptShape`/`ScriptPlace` flow with `ScriptItemizeOpenType`, `ScriptShapeOpenType`, and `ScriptPlaceOpenType` when OpenType features are present. File: `Src/views/lib/UniscribeSegment.cpp`. [Native C++] +- [x] 3.4 Preserve old Uniscribe behavior for empty feature strings and preserve Graphite rendering behavior for Graphite fonts. [Native C++] +- [x] 3.5 Add native tests for OpenType feature-on/off shaping, placement, metrics, line breaking, and selection-related placement. Files: `Src/views/Test/TestUniscribeEngine.h` or adjacent native tests. [Native C++] +- [x] 3.6 Run affected native tests after native implementation compiles: `./test.ps1 -SkipManaged -TestProject TestViews -StartedBy agent`. [Validation] + +## 4. WinForms Font Feature UI + +- [x] 4.1 Refactor `FontFeaturesButton` to use the provider seam and enable when the selected font has Graphite or OpenType configurable features. File: `Src/FwCoreDlgs/FwCoreDlgControls/FontFeaturesButton.cs`. [Managed C#] +- [x] 4.2 Decouple `DefaultFontsControl` feature availability from `m_ws.IsGraphiteEnabled`; keep `Enable Graphite` limited to Graphite renderer selection. File: `Src/FwCoreDlgs/FwCoreDlgControls/DefaultFontsControl.cs`. [Managed C# WinForms] +- [x] 4.3 Ensure `FwFontAttributes`, `FwFontTab`, and `FwFontDialog` load/save OpenType feature strings through existing `FontInfo.m_features` paths. Files: `Src/FwCoreDlgs/FwCoreDlgControls/FwFontAttributes.cs`, `FwFontTab.cs`, `Src/FwCoreDlgs/FwFontDialog.cs`. [Managed C# WinForms] +- [x] 4.4 Update `.resx` labels/help strings from Graphite-only wording to generic Font Features/Font Options wording. Files: `Src/FwCoreDlgs/FwCoreDlgControls/*.resx`. [Localization] +- [x] 4.5 Add UI/component tests for `FontFeaturesButton`, `DefaultFontsControl`, `FwFontAttributes`, `FwFontTab`, and `FwFontDialog` covering OpenType features without Graphite enabled. Test project: `Src/FwCoreDlgs/FwCoreDlgsTests/` or existing control test project. [Managed C# Tests] + +## 5. Render Cache and Speedup Integration + +- [x] 5.1 After `001-render-speedup` is merged, identify every cache/guard that can reuse shaped, laid-out, or captured output and document its feature-string identity requirement. [Managed C# + Native C++] +- [x] 5.2 Update renderer, layout, warm-render, and buffered-frame invalidation so feature changes dirty affected output. Files likely include `RenderEngineFactory.cs`, `SimpleRootSite.cs`, `VwRootBox.cpp`, and render verification infrastructure. [Managed C# + Native C++] +- [x] 5.3 Add tests proving toggling a feature does not reuse stale layout, glyph output, line breaks, or cached bitmap output. [Managed C# + Native C++ Tests] +- [x] 5.4 Verify same-font runs with different feature strings remain distinct in rendering and layout. [Tests] + +## 6. Visual Rendering and Comparison Tests + +- [x] 6.1 Add WinForms/Views render baseline scenarios for feature-off and feature-on output using writing-system default features. Use post-`001-render-speedup` render snapshot infrastructure. [Managed C# Tests] +- [x] 6.2 Add WinForms/Views render baseline scenarios for style-specific OpenType features, including Normal style for vernacular writing system. [Managed C# Tests] +- [x] 6.3 Add at least one bidi or multi-writing-system scenario to guard against complex-script regressions. [Managed C# Tests] +- [x] 6.4 Add test-only HarfBuzzSharp + SkiaSharp dependencies to a test/comparison project only; ensure production projects do not reference them. [Managed C# Tests] +- [x] 6.5 Add HarfBuzzSharp shaping-data comparisons for the same feature scenarios, comparing glyph IDs, clusters, advances, or offsets where deterministic. [Managed C# Tests] +- [x] 6.6 Add SkiaSharp visual comparison output with documented tolerance rules for future Avalonia migration evidence. [Managed C# Tests] +- [x] 6.7 Document deterministic font asset and baseline status: native Views end-to-end render tests use committed `CharisSIL-5.000`; HarfBuzz/Skia comparison tests still use installed-font probes for test-only comparison. [Managed C# Tests] + +## 7. Exports, Help, and Documentation + +- [x] 7.1 Verify existing CSS export emits OpenType feature strings correctly; extend `CssGeneratorTests` only if gaps remain. File: `Src/xWorks/CssGenerator.cs` and tests. [Managed C#] +- [x] 7.2 Audit Word/Notebook/export paths for feature-string omissions and file follow-up tasks for non-Phase-1 gaps. Files include `Src/xWorks/WordStylesGenerator.cs`, `NotebookExportDialog.cs`. [Managed C#] +- [x] 7.3 Update FieldWorks Help or context help for OpenType Font Features and the continued temporary role of Graphite. [Docs/Help] +- [x] 7.4 Update OpenSpec research/design notes if implementation discovers a different safe renderer path. [OpenSpec] + +## 8. Validation + +- [x] 8.1 Run affected managed UI/control tests through `./test.ps1` with the relevant test project filters. [Validation] +- [x] 8.2 Run affected render baseline tests and review received/verified images. [Validation] +- [x] 8.3 Run `./build.ps1` after native and managed changes are complete. [Validation] +- [x] 8.4 Run `CI: Full local check` before committing or pushing; commit-message lint still fails on pre-existing commit `c30c1e7d16`, and whitespace was checked separately with no problems. [Validation] +- [x] 8.5 Confirm OpenSpec status is complete and all tasks/spec requirements are still aligned before implementation PR review. [OpenSpec] + +## 9. Manual WinApp Evidence + +- [x] 9.1 Launch `Output/Debug/FieldWorks.exe` with WinApp MCP and confirm the Sena 3 project is loaded, using `Sena 3 2018-09-11 1145.fwbackup` only if restore is needed. [Manual Validation] +- [x] 9.2 Capture Writing System Properties > Font evidence showing `Font Options`, unchecked `Enable Graphite`, and enabled `Font Features`. [Manual Validation] +- [x] 9.3 Capture the Styles dialog > Font tab showing the shared `Font features` control. [Manual Validation] +- [x] 9.4 Record manual test steps, screenshots, and the before-state capture limitation in `manual-testing.md`. [OpenSpec] +- [x] 9.5 Fetch `LT-22324` through the refreshed Atlassian read-only skill and record exact JIRA font recommendations, comments, and attachment status. [Manual Validation] +- [x] 9.6 Inventory local and installer font availability for JIRA-recommended and FieldWorks-bundled fonts. [Manual Validation] +- [x] 9.7 Capture WinForms MCP UIA2 evidence showing `Charis SIL` selected and the OpenType Font Features menu visible. [Manual Validation] + +## 10. Deterministic Font Fixture Rendering + +- [x] 10.1 Add the JIRA-specified `CharisSIL-5.000` regular font, OFL license, and README under `Src/views/Test/TestData/Fonts/CharisSIL-5.000`. [Native C++ Tests] +- [x] 10.2 Update `TestViews.vcxproj` to copy the Charis SIL fixture beside `TestViews.exe` for native test execution. [Native C++ Tests] +- [x] 10.3 Add `TestUniscribeEngine` coverage that loads the Charis SIL fixture privately and verifies feature-off/feature-on OpenType rendering through `FindBreakPoint`, `ILgSegment::DrawText`, and bitmap pixel comparison. [Native C++ Tests] +- [x] 10.4 Add `RenderEngineFactoryTests` coverage proving writing-system default OpenType features are normalized into `LgCharRenderProps.szFontVar`, equivalent feature strings reuse the renderer cache entry, and different feature strings create separate renderer cache entries. [Managed C# Tests] +- [x] 10.5 Run `./test.ps1 -TestProject SimpleRootSiteTests -StartedBy agent`; result: `Total tests: 113`, `Passed: 113`. [Validation] +- [x] 10.6 Run `./test.ps1 -SkipManaged -TestProject TestViews -StartedBy agent`; result: `Tests [Ok-Fail-Error]: [295-0-0]`. [Validation] + +## 11. Word DOCX Export Parity + +- [x] 11.1 Add OpenSpec requirements, research analysis, implementation plan, and tasks for Microsoft Word DOCX OpenType feature subset export. [OpenSpec] +- [x] 11.2 Add failing managed tests proving style and explicit run font features emit documented WordprocessingML `w14` typography elements. [Managed C# Tests] +- [x] 11.3 Implement a Word DOCX feature mapper that reuses the renderer-neutral parser and maps supported tags to `w14` elements. [Managed C#] +- [x] 11.4 Keep unsupported tags non-fatal and document Word export subset behavior in `Docs/opentype-font-features.md`. [Docs] +- [x] 11.5 Run targeted xWorks Word generator tests. [Validation] + +## 12. Review Baseline And Churn Reconciliation + +- [x] 12.1 Confirm the final implementation branch resolves review-only churn before validation; remaining `.serena/project.yml` churn is unrelated local tooling state and generated native test collection changes come from the test build. [Validation/Process] +- [x] 12.2 Re-run targeted managed and native validation against one coherent final tree after the review-driven fixes are implemented. [Validation] + +## 13. Filter OpenType Discovery To User-Configurable Features + +- [x] 13.1 Define discovery rules that keep optional user-facing features and filter required shaping or engine-controlled features before the UI is populated. [Managed C# + Native C++] +- [x] 13.2 Document the Phase 1 discovery constraint: managed UI discovery reads layout-table feature records and filters required shaping features; script/language-aware managed enumeration remains a future enhancement if raw optional tags prove too noisy. [Managed C#] +- [x] 13.3 Add tests covering filtered-out required features and preserved optional features. [Tests] + +## 14. Prefer OpenType With A Clear Explicit Toggle + +- [x] 14.1 Make OpenType the default feature provider for fonts that expose both OpenType and Graphite feature sets. [Managed C# WinForms] +- [x] 14.2 Use the existing Graphite selection surface as the explicit provider switch while defaulting the shared button to OpenType; document remaining copy/help refinements separately from renderer behavior. [Managed C# WinForms] +- [x] 14.3 Thread provider choice through the shared `FontFeaturesButton` path used by default-font and style/font surfaces. [Managed C# WinForms] +- [x] 14.4 Add tests for OpenType-default behavior, provider filtering, inherited default font behavior, and font-feature round-tripping. [Tests] + +## 15. Add Trace Logging + +- [x] 15.1 Define trace switches/messages using the existing FieldWorks diagnostics infrastructure rather than modal assertions. [Managed C# + Native C++] +- [x] 15.2 Log malformed tags and strings, filtered discovery decisions, provider selection decisions, native shaping failures, retry attempts, and fallback reasons. [Managed C# + Native C++] +- [x] 15.3 Add tests or harness checks where practical to verify key diagnostics points without overcoupling to exact message text. [Tests] + +## 16. Fix Truncation Logic Risk + +- [x] 16.1 Replace comma-only truncation loops in `VwPropertyStore.cpp` with fail-safe logic that handles overlong strings with no comma boundary and always makes forward progress or aborts safely. [Native C++] +- [x] 16.2 Preserve compatible behavior for valid legacy multi-tag strings while rejecting or truncating malformed no-progress cases safely. [Native C++] +- [x] 16.3 Add tests for overlong strings with commas, overlong strings without commas, exact-boundary strings, and inherited overlong strings. [Tests] + +## 17. Validate Accepted Tag Names And Trace Malformed Tags + +- [x] 17.1 Align tag validation with published OpenType/CSS syntax: accept any four-character printable ASCII tag and do not restrict support to registered tags only. [Managed C#] +- [x] 17.2 Ignore malformed tags safely, emit trace diagnostics, and continue processing remaining valid entries in the same feature string. [Managed C#] +- [x] 17.3 Keep storage and rendering acceptance liberal for valid custom/private tags while making CSS/DOCX output boundaries safe. [Managed C#] +- [x] 17.4 Add parser/normalizer tests for valid registered tags, valid custom tags, malformed tags, duplicate tags, and mixed valid/invalid input. [Tests] + +## 18. Follow The Existing Inheritance Path + +- [x] 18.1 Attempt removal of the parallel `StyleInfo` loader; validation showed it is still required in the active build graph, so retain it as a minimal compatibility adapter and document the boundary. [Managed C#] +- [x] 18.2 Audit related call sites so writing-system defaults, style overrides, and dialog reloads still round-trip through `FontInfo.m_features` / `ktptFontVariations`. [Managed C#] +- [x] 18.3 Add and run tests for inherited default features, explicit overrides, and reopen/save cycles with the compatibility adapter in place. [Tests] + +## 19. Update OpenSpec Scope And Review Artifacts + +- [x] 19.1 Capture clarification, analysis, and planning passes in OpenSpec artifacts and add a dedicated in-depth review note. [OpenSpec] +- [x] 19.2 Expand proposal, design, research, capability specs, and tasks to include review-driven scope: filtering, OpenType preference, logging, truncation safety, tag validation, inheritance cleanup, and additional state-of-the-art follow-ups. [OpenSpec] + +## 20. Add Recommended Tests + +- [x] 20.1 Add managed UI tests for required-feature filtering, OpenType-preferred behavior, and inherited/default font-feature round-tripping. [Managed C# Tests] +- [x] 20.2 Add native tests for malformed/overlong feature strings, retryable OpenType shaping behavior, script/language handling, and safe traced fallback. [Native C++ Tests] +- [x] 20.3 Add export tests for CSS-safe serialization and DOCX supported-subset mapping; audit Notebook default-font-features preservation through existing XML emission. [Managed C# Tests] +- [x] 20.4 Add cache-identity tests ensuring feature changes invalidate render/layout caches without stale reuse. [Managed + Native Tests] +- [x] 20.5 Add manual/diagnostic checklist coverage for provider behavior and trace collection steps. [OpenSpec/Manual Validation] + +## 21. Apply Review-Driven Architecture Changes + +- [x] 21.1 Introduce an explicit font-feature provider abstraction that separates discovery, selection UI, and renderer choice. [Managed C#] +- [x] 21.2 Keep renderer-neutral `tag=value` strings as the authoritative contract and convert only at renderer/export boundaries. [Managed C# + Native C++] +- [x] 21.3 Make provider choice explicit in UI state instead of implicit in `Enable Graphite`, and document the OpenType-default rule for dual-technology fonts. [Managed C# WinForms] +- [x] 21.4 Keep native Uniscribe changes additive and reg-free-COM-safe while improving diagnostics and robustness. [Native C++] + +## 22. Add Additional State-Of-The-Art Follow-Ups + +- [x] 22.1 Retry `ScriptShapeOpenType` / `ScriptPlaceOpenType` on `E_OUTOFMEMORY` before abandoning OpenType shaping. [Native C++] +- [x] 22.2 Keep script tags authoritative from `ScriptItemizeOpenType`, use available font/locale language candidates, and trace fallback decisions. [Native C++] +- [x] 22.3 Ensure CSS export safely serializes any valid accepted tag, including characters that require escaping in CSS strings. [Managed C#] +- [x] 22.4 Keep Word DOCX export on the documented `w14` subset and document unsupported tags explicitly rather than inventing hidden storage. [Managed C# + Docs] +- [x] 22.5 Audit Notebook export preservation of default font features and close review-documented evidence gaps in OpenSpec notes. [Managed C# Tests + OpenSpec] diff --git a/openspec/changes/add-opentype-font-features/views-migration-matrix.md b/openspec/changes/add-opentype-font-features/views-migration-matrix.md new file mode 100644 index 0000000000..bd48620ce1 --- /dev/null +++ b/openspec/changes/add-opentype-font-features/views-migration-matrix.md @@ -0,0 +1,645 @@ +# Native Views Feature Migration Matrix + +This artifact answers the broader LT-22324 planning question: what custom native Views functionality exists today, what modern library support exists for each feature, and how each piece should be staged toward the Graphite-removal and Avalonia migration path. + +Assumptions: + +- "Views.cpp" means the native Views engine under `Src/views/`, not only one translation unit. +- Phase 1 remains OpenType font features in current WinForms/Views after `001-render-speedup` is merged. +- Phase 2 removes Graphite while retaining WinForms. +- Phase 3 adds Avalonia alongside WinForms. +- Phase 4 retires WinForms and the remaining native Views surface only after parity evidence exists. + +## Library Fit Summary + +No current standard library replaces all of Views. The closest full-stack candidates replace slices: + +- DirectWrite is the best Windows-native replacement candidate for shaping, typography, paragraph layout, drawing callbacks, hit testing, and text metrics. It is strong if FieldWorks stays WinForms/Windows for a long time, but it is not the cross-platform Avalonia end state. +- HarfBuzz is the best shaping core, especially for OpenType and future Graphite removal, but it intentionally does not do bidi, line breaking, rich layout, selection, editing, drawing, or font fallback. +- ICU is the best standard library for bidi and text boundaries that FieldWorks should not keep reimplementing by hand. +- Skia/SkParagraph is the most relevant cross-platform paragraph engine because Avalonia already uses Skia in common configurations and SkParagraph exposes layout, painting, line metrics, hit testing, word boundaries, placeholders, and font fallback hooks. +- Avalonia `TextLayout`, `TextShaper`, `GlyphRun`, `TextElement.FontFeatures`, and automation/input infrastructure are the likely product end-game wrappers, but stock controls will not replace FieldWorks-specific view construction, lazy data loading, interlinear layout, selection semantics, undo, and model notifications by themselves. +- Pango is a strong Linux/Cairo precedent for paragraph layout, shaping, cursor positions, and hit testing, but it is less aligned with the existing Windows and Avalonia direction than DirectWrite or SkParagraph. + +Key evidence: + +- HarfBuzz shapes Unicode runs into glyphs and positions: https://harfbuzz.github.io/what-is-harfbuzz.html +- HarfBuzz explicitly does not do bidi, line breaking, justification, rich runs, or full layout: https://harfbuzz.github.io/what-harfbuzz-doesnt-do.html +- ICU `BreakIterator` supports character, word, line, and sentence boundaries: https://unicode-org.github.io/icu/userguide/boundaryanalysis/ +- ICU `ubidi` implements the Unicode bidi algorithm: https://unicode-org.github.io/icu/userguide/transforms/bidi.html +- DirectWrite supports Unicode layout, advanced OpenType typography, measuring, drawing, and hit testing: https://learn.microsoft.com/en-us/windows/win32/directwrite/direct-write-portal +- DirectWrite `IDWriteTextLayout` exposes formatted text layout, drawing, line metrics, typography, and hit testing: https://learn.microsoft.com/en-us/windows/win32/api/dwrite/nn-dwrite-idwritetextlayout +- DirectWrite `IDWriteTextAnalyzer` exposes bidi, script, line break, glyph, and placement analysis: https://learn.microsoft.com/en-us/windows/win32/api/dwrite/nn-dwrite-idwritetextanalyzer +- DirectWrite `IDWriteTypography` exposes OpenType font features: https://learn.microsoft.com/en-us/windows/win32/api/dwrite/nn-dwrite-idwritetypography +- Avalonia typography exposes inherited font properties and `TextElement.FontFeatures`: https://docs.avaloniaui.net/docs/styling/typography +- Avalonia `TextLayout` exposes multiline layout, drawing, line data, and hit testing: https://api-docs.avaloniaui.net/docs/T_Avalonia_Media_TextFormatting_TextLayout +- Avalonia `TextShaper` shapes text through `ShapeText`: https://api-docs.avaloniaui.net/docs/T_Avalonia_Media_TextFormatting_TextShaper +- Avalonia `GlyphRun` exposes glyph run metrics, bidi level, caret hits, geometry, and intersections: https://api-docs.avaloniaui.net/docs/T_Avalonia_Media_GlyphRun +- SkParagraph `Paragraph`, `ParagraphBuilder`, and `FontCollection` expose paragraph layout, painting, line metrics, rectangles, hit testing, word boundaries, placeholders, font fallback, and paragraph caches: https://raw.githubusercontent.com/google/skia/main/modules/skparagraph/include/Paragraph.h, https://raw.githubusercontent.com/google/skia/main/modules/skparagraph/include/ParagraphBuilder.h, https://raw.githubusercontent.com/google/skia/main/modules/skparagraph/include/FontCollection.h +- Pango `PangoLayout` formats paragraphs with line breaking, justification, alignment, cursor positions, hit testing, and logical/physical conversions: https://docs.gtk.org/Pango/class.Layout.html +- Windows TSF remains the platform service for advanced multilingual text input: https://learn.microsoft.com/en-us/windows/win32/tsf/text-services-framework +- Windows UI Automation is the platform accessibility model for custom controls: https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-uiautomationoverview + +## Feature-by-feature Matrix + +### 1. COM/root-site public contract + +What it is and where it is used: + +- Native Views exposes document rendering/editing through COM-style interfaces such as root boxes, view constructors, view environments, graphics, selections, and render engines. +- Key areas: `Src/views/*.idl`, `Src/views/VwRootBox.cpp`, `Src/views/VwEnv.cpp`, `Src/views/VwSelection.cpp`, `Src/Common/SimpleRootSite/`, and WinForms root-site controls. +- This contract lets managed FieldWorks code construct views and host native layout while registration-free COM keeps deployment local. + +Standard equivalents: + +- No single standard equivalent. Avalonia controls/visuals and `TextLayout` cover UI and text layout pieces, but not the FieldWorks data/view-constructor contract. +- DirectWrite, SkParagraph, and Pango are text engines, not application document contracts. + +Best end game: + +1. Create a managed renderer-neutral document/view contract that can be backed by native Views, DirectWrite experiments, or Avalonia. +2. Keep a compatibility adapter for existing `IVwViewConstructor` callers until each canonical view has a managed/Avalonia equivalent. + +Migration staging: + +- Phase 1: keep COM unchanged except additive feature plumbing. +- Phase 2: isolate render-engine and feature contracts from Graphite/Uniscribe specifics. +- Phase 3: add managed/Avalonia adapters for non-editing preview surfaces first. +- Phase 4: remove native COM contracts after editing, printing, accessibility, and canonical data views have parity. + +### 2. Root box lifecycle, reconstruction, and dirty layout + +What it is and where it is used: + +- `VwRootBox.cpp` owns root objects, view construction, full/partial reconstruction, relayout, invalidation, drawing, selection installation, printing entry points, and data-change response. +- `001-render-speedup` makes this even more important because cache/dirty identity must include feature strings. + +Standard equivalents: + +- Avalonia layout/visual invalidation can replace UI-tree invalidation, but not FieldWorks data reconstruction by itself. +- DirectWrite `IDWriteTextLayout` and SkParagraph `Paragraph` can be marked dirty/rebuilt for paragraph text, but they do not manage root object lifecycles. + +Best end game: + +1. Managed document-root controller that owns data subscriptions and produces renderer-neutral blocks/runs. +2. Avalonia custom control using normal layout invalidation for visible surfaces. + +Migration staging: + +- Phase 1: treat font-feature changes as layout dirty and cache identity changes. +- Phase 3: introduce a managed root controller for read-only or preview surfaces. +- Phase 4: move editing surfaces after selection and undo have parity. + +### 3. View-construction DSL (`VwEnv` and `IVwViewConstructor`) + +What it is and where it is used: + +- `VwEnv.cpp` is an imperative DSL used by managed/native view constructors: `OpenParagraph`, `AddString`, `AddObjProp`, `AddObjVecItems`, `AddLazyVecItems`, `OpenTable`, `OpenTableCell`, and property setters build a custom box tree. +- It is the bridge between FieldWorks model objects and the visual tree. + +Standard equivalents: + +- Avalonia data templates, bindings, panels, and controls are the closest UI framework equivalents. +- HTML/CSS has a similar declarative layout model, but it does not integrate with FieldWorks editing or object notifications. +- No text library replaces this; text libraries consume already-built runs/paragraphs. + +Best end game: + +1. Convert canonical view constructors into managed view models/templates that emit paragraphs, inline objects, tables, and adornments. +2. Keep `VwEnv` as a compatibility adapter while each view moves. + +Migration staging: + +- Phase 1: do not change the DSL beyond feature propagation. +- Phase 3: prototype one read-only canonical surface in Avalonia using renderer-neutral view data. +- Phase 4: replace editing constructors only after command/selection tests pass. + +### 4. Box tree and non-text layout + +What it is and where it is used: + +- Views builds a custom hierarchy of boxes for paragraphs, piles, divisions, inner piles, pictures, tables, table cells, borders, margins, and embedded objects. +- Key areas include `Src/views/VwBox.cpp`, `Src/views/VwTextBoxes.cpp`, `Src/views/VwTableBox.cpp`, and `Src/views/VwEnv.cpp`. + +Standard equivalents: + +- Avalonia panels, `Grid`, custom controls, and layout passes can replace much of the non-text box layout. +- SkParagraph placeholders and DirectWrite inline objects cover embedded inline spaces inside text. +- Pango and SkParagraph are paragraph engines, not general document layout systems. + +Best end game: + +1. Use Avalonia for non-text layout and a paragraph engine for text layout. +2. Preserve a small FieldWorks document-layout layer for interlinear, linguistic, and embedded-object semantics that are not stock UI patterns. + +Migration staging: + +- Phase 3: migrate read-only non-text layout blocks first. +- Phase 4: migrate tables/interlinear/embedded editing after hit testing and accessibility are validated. + +### 5. Paragraph building, wrapping, and line layout + +What it is and where it is used: + +- `VwTextBoxes.cpp` contains `ParaBuilder` and paragraph/string box behavior for line breaking, measuring, fitting, backtracking, justification, drop caps, bullets/numbers, exact line height, widows/orphans, and physical box ordering. +- It is one of the densest custom areas and underlies rendering, selection, printing, search highlights, and overlays. + +Standard equivalents: + +- DirectWrite `IDWriteTextLayout` provides formatted text layout, line metrics, drawing, typography, and hit testing. +- SkParagraph `Paragraph` provides `layout`, `paint`, `getLineMetrics`, `getRectsForRange`, `getGlyphPositionAtCoordinate`, and word boundaries. +- Pango `PangoLayout` provides paragraph formatting, wrapping, justification, alignment, cursor positions, and logical/physical conversions. +- ICU `BreakIterator` provides standard character/word/line break points but not full line layout. + +Best end game: + +1. Use Avalonia/SkParagraph for cross-platform paragraph layout where possible. +2. Keep DirectWrite as a Windows-native spike/fallback candidate if Avalonia text layout cannot satisfy exotic-script or hit-test requirements. + +Migration staging: + +- Phase 1: add feature-on/off visual baselines for paragraphs before changing layout engines. +- Phase 2: remove Graphite only when OpenType/HarfBuzz shaping and line metrics are covered. +- Phase 3: compare legacy Views, Avalonia, and SkParagraph on canonical paragraph cases. +- Phase 4: replace `ParaBuilder` only after bidi, selection, printing, and interlinear cases are covered. + +### 6. Text shaping and glyph placement + +What it is and where it is used: + +- `Src/views/lib/GraphiteEngine.cpp` and `GraphiteSegment` shape Graphite fonts and expose configurable Graphite features. +- `Src/views/lib/UniscribeEngine.cpp` and `UniscribeSegment.cpp` use classic Uniscribe `ScriptItemize`, `ScriptShape`, and `ScriptPlace` for non-Graphite text. +- Phase 1 must add OpenType feature application without replacing the Views layout model. + +Standard equivalents: + +- HarfBuzz is the standard shaping core for OpenType and many scripts, but it is shaping only. +- DirectWrite `IDWriteTextAnalyzer` exposes script, bidi, line-break analysis, glyph mapping, and placement. +- Avalonia `TextShaper` shapes text and `GlyphRun` represents shaped glyph runs. +- SkParagraph performs shaping through its paragraph pipeline and exposes glyph runs through visitors. + +Best end game: + +1. Phase 1 production: Uniscribe OpenType APIs because they are the smallest safe change to current Views. +2. Long term: HarfBuzz through Avalonia/Skia or another managed abstraction, with ICU for bidi/boundaries. + +Migration staging: + +- Phase 1: branch Uniscribe to OpenType APIs only when features are present; keep old behavior for empty features and keep Graphite intact. +- Phase 2: remove Graphite after font compatibility and project-data migration policy are ready. +- Phase 3: compare HarfBuzz/Avalonia shaping data against legacy baselines. + +### 7. Font-feature discovery, storage, and application + +What it is and where it is used: + +- FieldWorks already stores feature strings as `tag=value` in writing systems/styles and passes them through `ktptFontVariations` and `LgCharRenderProps`. +- `FontFeaturesButton.cs` and `DefaultFontsControl.cs` currently gate feature UI mostly on Graphite. +- `GraphiteEngine` supports feature discovery through `IRenderingFeatures`; `UniscribeEngine` currently ignores feature data. + +Standard equivalents: + +- DirectWrite `IDWriteTypography::AddFontFeature` applies OpenType features to text ranges. +- Avalonia exposes `TextElement.FontFeatures` and `FontFeatureCollection` using HarfBuzz-like syntax. +- HarfBuzz supports OpenType features during shaping. +- CSS exposes `font-feature-settings`, which FieldWorks already emits for exports. + +Best end game: + +1. Renderer-neutral feature model in FieldWorks (`tag=value`) with renderer-specific adapters. +2. Avalonia/HarfBuzz syntax adapter for future UI, tests, and rendering. + +Migration staging: + +- Phase 1: shared parser/normalizer, provider-based UI, Uniscribe OpenType adapter, visual tests. +- Phase 2: preserve old Graphite values but remove Graphite-only UI behavior when policy is ready. +- Phase 3: reuse the same feature provider in Avalonia controls. + +### 8. Bidi, script itemization, and number substitution + +What it is and where it is used: + +- Views handles paragraph direction, weak-direction adjustment, physical box ordering, script runs, and Uniscribe itemization as part of paragraph layout and selection. +- Key areas: `VwTextBoxes.cpp`, `UniscribeSegment.cpp`, and selection movement in `VwSelection.cpp`. + +Standard equivalents: + +- DirectWrite `IDWriteTextAnalyzer` has `AnalyzeBidi`, `AnalyzeScript`, `AnalyzeLineBreakpoints`, and `AnalyzeNumberSubstitution`. +- ICU `ubidi` implements the Unicode bidi algorithm. +- Pango and SkParagraph include bidi-aware paragraph layout. + +Best end game: + +1. Let the paragraph/text engine own bidi and script itemization when possible. +2. Use ICU directly for any FieldWorks semantic operations that need text boundaries independent of rendering. + +Migration staging: + +- Phase 1: add at least one bidi or multi-writing-system feature baseline to prevent OpenType regressions. +- Phase 3: compare bidi line layout and caret movement between Views and Avalonia/SkParagraph. +- Phase 4: replace custom bidi movement only after keyboard/selection parity is proven. + +### 9. Grapheme, word, and line boundaries + +What it is and where it is used: + +- `VwSelection.cpp` and `VwTextBoxes.cpp` contain custom word expansion, arrow movement, editable substring selection, search ranges, line boundary adjustment, and boundary-sensitive deletion logic. +- `VwParagraphBox::MakeSourceNfd` and typing logic also reflect normalization-sensitive text behavior. + +Standard equivalents: + +- ICU `BreakIterator` supports grapheme/character, word, line, and sentence boundaries, including dictionary support for languages without spaces. +- SkParagraph exposes `getWordBoundary` and glyph/cluster information. +- Pango exposes cursor movement and logical attributes. +- DirectWrite supports hit testing and line metrics, but ICU is the better standalone boundary service. + +Best end game: + +1. Use ICU for renderer-independent boundaries and word movement rules. +2. Use the active paragraph engine for visual caret and line-position mapping. + +Migration staging: + +- Phase 2: introduce boundary-service tests around current behavior. +- Phase 3: use the same tests for Avalonia/SkParagraph caret and word movement. +- Phase 4: delete custom boundary logic only after language-specific cases pass. + +### 10. Style/property resolution and writing-system defaults + +What it is and where it is used: + +- `VwPropertyStore.cpp` resolves text props, writing-system defaults, style inheritance, font variations, effects, and concrete character/paragraph properties. +- It feeds `LgCharRenderProps` consumed by render engines and paragraph layout. + +Standard equivalents: + +- Avalonia `TextElement` inherited properties and `TextRunProperties` can represent font family, size, style, weight, stretch, decorations, foreground, line height, and font features. +- CSS has a cascade model, but FieldWorks style/writing-system semantics are domain-specific. +- DirectWrite and SkParagraph accept formatted ranges after style resolution; they do not replace the resolver. + +Best end game: + +1. Move style resolution to managed renderer-neutral code that emits text runs and paragraph properties. +2. Keep renderer adapters thin: Views props, DirectWrite ranges, SkParagraph `TextStyle`, and Avalonia `TextRunProperties`. + +Migration staging: + +- Phase 1: ensure `ktptFontVariations` participates in render/layout cache identity. +- Phase 3: create a managed run model for one Avalonia preview/control. +- Phase 4: retire native property-store behavior after all writing-system/style cases pass. + +### 11. Text source model (`ITsString`, runs, embedded objects) + +What it is and where it is used: + +- Views consumes FieldWorks `ITsString`/`TsTextProps` runs, object replacement behavior, writing-system runs, and string properties to build boxes and render text. +- Key areas: `VwEnv.cpp`, `VwTextBoxes.cpp`, `VwPropertyStore.cpp`, and selection string extraction in `VwSelection.cpp`. + +Standard equivalents: + +- Avalonia `ITextSource` and `TextRunProperties` are close structural equivalents for formatted text runs. +- DirectWrite `IDWriteTextLayout` consumes strings plus per-range formatting. +- SkParagraph `ParagraphBuilder` accepts UTF-8/UTF-16 text, pushed `TextStyle`, and placeholders. + +Best end game: + +1. Create a managed adapter from `ITsString` and object placeholders to renderer-neutral text runs. +2. Keep `ITsString` as the domain text model until a separate product decision replaces it. + +Migration staging: + +- Phase 1: pass feature strings through existing run props. +- Phase 3: build `ITsString` to Avalonia/SkParagraph run adapters for previews. +- Phase 4: route editing through the same adapter after round-trip tests are stable. + +### 12. Selection, caret geometry, and hit testing + +What it is and where it is used: + +- `VwSelection.cpp` and `VwTextBoxes.cpp` implement insertion points, ranges, bidirectional caret behavior, physical/logical arrow movement, point-to-offset mapping, range rectangles, selection drawing, picture selections, and editable-position discovery. +- These behaviors are central to data-entry quality. + +Standard equivalents: + +- DirectWrite `IDWriteTextLayout` exposes `HitTestPoint`, `HitTestTextPosition`, and `HitTestTextRange`. +- Avalonia `TextLayout` exposes `HitTestPoint`, `HitTestTextPosition`, and `HitTestTextRange`; `GlyphRun` exposes caret-hit utilities. +- SkParagraph exposes `getGlyphPositionAtCoordinate`, `getRectsForRange`, and glyph cluster APIs. +- Pango exposes `index_to_pos`, `xy_to_index`, cursor positions, and visual cursor movement. + +Best end game: + +1. Use paragraph-engine hit testing for glyph/line geometry. +2. Keep a FieldWorks selection model above it for object paths, editable/non-editable regions, interlinear views, and undo grouping. + +Migration staging: + +- Phase 1: add selection-related OpenType metric/placement tests so features do not stale-cache hit testing. +- Phase 3: compare selection rectangles and caret locations in read-only/limited-edit Avalonia surfaces. +- Phase 4: migrate full editing only after keyboard, bidi, object, and interlinear selection tests pass. + +### 13. Editing commands, typing, normalization, and undo + +What it is and where it is used: + +- `VwSelection.cpp` handles typing, backspace/delete, control-backspace/delete, replacing ranges, property cleanup, editable substring callbacks, undo tasks, and display updates. +- `OnTypingMethod` contains complex handling for combining marks, protected methods, integer fields, and selection updates. + +Standard equivalents: + +- Avalonia and WinUI text controls provide standard editing behavior, but FieldWorks editable views are not plain text boxes. +- Windows TSF provides platform multilingual input services. +- ICU can help with boundaries/normalization, but not FieldWorks data commits or undo semantics. + +Best end game: + +1. Managed FieldWorks editing command layer that owns domain updates, undo, and constraints. +2. Platform text-input integration through Avalonia/WinUI/TSF equivalents rather than native Views internals. + +Migration staging: + +- Phase 2: write characterization tests for typing/deletion in complex-script and combining-mark cases. +- Phase 3: use limited edit prototypes only after selection geometry is stable. +- Phase 4: migrate canonical editable surfaces and keep old Views fallback until undo/input parity is proven. + +### 14. Lazy loading, notifier maps, and data invalidation + +What it is and where it is used: + +- `VwEnv::AddLazyVecItems`, root-box notifier maps, and `VwRootBox::PropChanged` allow large FieldWorks data sets to be displayed incrementally and updated from model changes. +- This is part data binding, part virtualization, part incremental document construction. + +Standard equivalents: + +- Avalonia virtualization, data templates, observable collections, and invalidation can replace UI-framework mechanics. +- No text engine provides FieldWorks object-notifier semantics. + +Best end game: + +1. Managed incremental document/view model with observable invalidation. +2. Avalonia virtualization for visible collections and lazy expansion. + +Migration staging: + +- Phase 3: migrate one read-only lazy vector scenario. +- Phase 4: migrate editable lazy views after notifier and selection path tests exist. + +### 15. Tables, interlinear layout, pictures, and inline objects + +What it is and where it is used: + +- Views supports custom tables, piles, embedded pictures/objects, interlinear-style nested layout, paragraph numbers, and placeholders. +- Key areas include `VwEnv.cpp`, `VwTableBox.cpp`, `VwTextBoxes.cpp`, and `VwSelection.cpp` picture-selection handling. + +Standard equivalents: + +- Avalonia `Grid`, panels, custom controls, and item controls can handle many block/table layouts. +- DirectWrite inline objects and SkParagraph placeholders cover inline embedded items inside paragraphs. +- SkParagraph `addPlaceholder` is a direct paragraph placeholder concept. + +Best end game: + +1. Use Avalonia layout for block/table/interlinear structure. +2. Use paragraph-engine placeholders only for true inline object gaps. + +Migration staging: + +- Phase 3: port read-only table/interlinear preview cases. +- Phase 4: port editing, picture selection, and embedded object hit testing. + +### 16. Overlays, tags, underlines, spellcheck, and search highlighting + +What it is and where it is used: + +- `VwTextBoxes.cpp` draws overlay tags, spelling squiggles, underline effects, search results, selection highlights, and other adornments on top of paragraph geometry. +- `SpellCheckMethod`, `DrawOverlayTags`, `DrawTags`, and `DrawUnderline` are tightly coupled to line boxes. + +Standard equivalents: + +- Avalonia supports text decorations and custom drawing/adorners. +- DirectWrite supports underline/strikethrough, drawing effects, and custom `IDWriteTextRenderer` callbacks. +- SkParagraph returns range rectangles and can be painted under custom overlays. +- Pango supports attributes and layout rectangles. + +Best end game: + +1. A renderer-neutral adornment layer that maps semantic ranges to paragraph-engine rectangles. +2. Avalonia custom drawing for overlays and tags. + +Migration staging: + +- Phase 1: include feature-on/off visual baselines with at least one decoration/highlight scenario if feasible. +- Phase 3: port read-only overlays/search highlights. +- Phase 4: port spellcheck/editing overlays after selection geometry parity. + +### 17. Graphics abstraction and rasterization + +What it is and where it is used: + +- Views draws through `IVwGraphics` abstractions over platform graphics, with native render engines returning glyph geometry and metrics. +- This keeps the legacy engine decoupled from a single drawing backend but also preserves native/GDI-era assumptions. + +Standard equivalents: + +- DirectWrite can draw via Direct2D or custom `IDWriteTextRenderer` callbacks. +- Avalonia draws through `DrawingContext` and commonly uses Skia. +- Skia provides cross-platform 2D drawing, text blobs, and paragraph painting. +- Pango commonly pairs with Cairo. + +Best end game: + +1. Avalonia `DrawingContext`/Skia for product UI. +2. Keep DirectWrite/Direct2D as a Windows-native reference path only if needed for fidelity or migration debugging. + +Migration staging: + +- Phase 1: keep GDI/Uniscribe raster output and add baselines. +- Phase 3: add tolerant cross-renderer comparisons using Skia/Avalonia output. +- Phase 4: remove native graphics abstraction after all rendering consumers migrate. + +### 18. Printing and pagination + +What it is and where it is used: + +- `VwRootBox.cpp` and paragraph boxes support page layout, page printing, page-line extraction, widows/orphans, and print-specific drawing. + +Standard equivalents: + +- DirectWrite can draw `IDWriteTextLayout` to Direct2D/print-compatible targets. +- Skia can render to surfaces and PDF-like outputs depending on integration. +- Avalonia printing support is not a complete replacement for FieldWorks document pagination by itself. + +Best end game: + +1. A shared document pagination service using the same paragraph/layout engine as screen rendering. +2. Platform-specific print output adapters underneath that service. + +Migration staging: + +- Phase 3: keep legacy printing while screen previews migrate. +- Phase 4: migrate printing only after on-screen layout parity and page baseline tests exist. + +### 19. Accessibility and automation + +What it is and where it is used: + +- Views has custom selection, object-path, and rendered-document semantics that assistive technologies and UI tests may depend on, even where support is currently incomplete. +- Any replacement must expose text, caret, selection, tables, embedded objects, and navigation in platform accessibility terms. + +Standard equivalents: + +- Windows UI Automation provides provider/client APIs, control patterns, properties, events, and a tree model. +- Avalonia has automation infrastructure that maps controls to platform accessibility APIs, but custom text surfaces may need custom peers/patterns. + +Best end game: + +1. Use Avalonia automation peers for standard controls. +2. Implement custom text/document automation peers for FieldWorks document surfaces. + +Migration staging: + +- Phase 3: include accessibility checks in Avalonia preview prototypes. +- Phase 4: block retirement of native editable views until UIA/text-pattern parity is tested. + +### 20. IME, TSF, and complex text input + +What it is and where it is used: + +- FieldWorks users rely on complex keyboards, input methods, dead keys, combining marks, and multilingual text entry. Native Views editing is tied to Windows input behavior. + +Standard equivalents: + +- Windows TSF is the platform framework for advanced text input, keyboard processors, handwriting, speech, and multilingual support. +- Avalonia/WinUI text input abstractions can handle standard control input, but custom document editors need careful integration and testing. + +Best end game: + +1. Let the UI framework handle ordinary input plumbing where possible. +2. Keep explicit FieldWorks tests for IME/composition/combining behavior and use platform TSF hooks only where framework support is insufficient. + +Migration staging: + +- Phase 2: characterize current input behavior for exotic-language scenarios. +- Phase 3: test Avalonia input prototypes with real keyboards/IME cases. +- Phase 4: migrate data-entry surfaces only after IME and composition parity. + +### 21. Search, spellcheck, and linguistic services + +What it is and where it is used: + +- Paragraph boxes perform search and spellcheck highlighting with writing-system-specific behavior and dictionary selection. +- These services are semantically FieldWorks-specific even when their visual display is part of Views. + +Standard equivalents: + +- ICU helps with boundaries and collation-like text analysis, but spelling dictionaries and linguistic behavior remain FieldWorks/domain services. +- Modern UI frameworks can draw highlights but do not replace the services. + +Best end game: + +1. Keep search/spellcheck as managed/domain services. +2. Treat rendering as an adornment consumer of semantic ranges. + +Migration staging: + +- Phase 3: separate semantic service output from native drawing for read-only highlighting. +- Phase 4: migrate editing spellcheck underlines after paragraph rectangles and invalidation are stable. + +### 22. Cache/reuse/performance identity + +What it is and where it is used: + +- Views caches/reuses layout boxes, render engines, paragraph fragments, and now post-`001-render-speedup` rendered output or buffered frames. +- Feature strings, font fallback, writing system, style props, direction, and text must be part of any shaped/layout/render cache identity. + +Standard equivalents: + +- SkParagraph has a `ParagraphCache` and font caches. +- Avalonia and DirectWrite have internal layout/font caches, but FieldWorks still owns when data and style changes dirty output. + +Best end game: + +1. Explicit renderer-neutral cache keys for text layout and rendered baselines. +2. Keep caches near the active renderer, but keep invalidation identity in FieldWorks-owned code. + +Migration staging: + +- Phase 1: add cache-invalidation tests for feature toggles. +- Phase 3: reuse the same identity model for Avalonia/SkParagraph comparison tests. + +### 23. Vertical/inverted/physical-order special cases + +What it is and where it is used: + +- Views has `VwInvertedParaBox`, inverted paragraph builders, physical box order helpers, and paragraph direction depth logic. +- These special cases are easy to miss because most common surfaces look horizontal and LTR. + +Standard equivalents: + +- HarfBuzz supports vertical shaping directions, but not paragraph layout. +- DirectWrite, Pango, and SkParagraph have varying support for vertical text and physical/logical mapping. +- Avalonia supports `FlowDirection`, but vertical text should be treated as a separate capability check. + +Best end game: + +1. Decide whether each special case is still product-required. +2. Preserve required cases as explicit acceptance tests before replacement. + +Migration staging: + +- Phase 2: inventory real usage of inverted/vertical-like paths. +- Phase 3: spike required cases in Avalonia/SkParagraph/DirectWrite. +- Phase 4: remove dead special cases only with product sign-off. + +### 24. Graphite-specific rendering and project compatibility + +What it is and where it is used: + +- Graphite support is implemented in `GraphiteEngine`/`GraphiteSegment`, enabled from writing-system settings, and exposed through Graphite-centric UI labels and feature discovery. +- Some existing projects may contain Graphite feature strings or rely on Graphite fonts for exotic scripts. + +Standard equivalents: + +- HarfBuzz/OpenType is the long-term shaping path, but Graphite-specific smart-font behavior may not have a one-to-one OpenType equivalent. +- Graphite2 remains the only direct equivalent for Graphite behavior until fonts and project data are migrated. + +Best end game: + +1. Move users to OpenType fonts/features where equivalents exist. +2. Preserve old project data and provide warnings/migration guidance for Graphite-only settings. + +Migration staging: + +- Phase 1: preserve Graphite while adding OpenType support. +- Phase 2: remove Graphite only after compatibility policy, user messaging, and baseline evidence exist. +- Phase 3/4: do not carry Graphite concepts into Avalonia except as migration metadata. + +## Recommended Staging Across Phases + +### Phase 1: OpenType features in current Views + +- Keep native Views and WinForms as production rendering. +- Add renderer-neutral feature parsing/provider UI. +- Apply OpenType features in the existing Uniscribe path. +- Add high-level UI and visual tests in every canonical font-selection place. +- Add HarfBuzzSharp + SkiaSharp only as test/comparison tooling. +- Record baselines for feature-on/off, bidi/multi-writing-system, selection geometry, and cache invalidation. + +### Phase 2: Remove Graphite, keep WinForms + +- Use Phase 1 baselines to prove OpenType/Uniscribe behavior does not regress. +- Characterize typing, deletion, boundaries, Graphite-only settings, and complex keyboard behavior. +- Add compatibility warnings or migration guidance for Graphite-only project data. +- Introduce renderer-neutral boundary and text-run services where safe. + +### Phase 3: Add Avalonia alongside WinForms + +- Start with read-only surfaces and previews. +- Use managed adapters from `ITsString`/view-constructor output to Avalonia/SkParagraph text runs. +- Compare output to legacy Views baselines with exact checks for legacy renderer and tolerant/semantic checks across renderers. +- Keep native Views for canonical editing and printing until parity is proven. + +### Phase 4: Retire WinForms/native Views + +- Migrate editing surfaces after selection, IME/TSF, undo, accessibility, lazy loading, and printing all have tests. +- Remove native COM and Views box layout only after the managed/Avalonia document surface covers canonical FieldWorks workflows. +- Keep renderer-neutral feature strings and visual/semantic baselines as long-term regression assets.