diff --git a/.gitattributes b/.gitattributes
index 155cbc7df1..8cf8104b2a 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -34,3 +34,6 @@ Src/LexText/ParserCore/ParserCoreTests/**/*.txt -whitespace
Src/Utilities/pcpatrflex/DisambiguateInFLExDB/DisambiguateInFLExDBTests/TestData/*.* -whitespace
Src/Utilities/pcpatrflex/PcPatrBrowserDll/Transforms/*.* -whitespace
Src/Utilities/HCSynthByGloss/HCSynthByGloss/TestData/*.txt -whitespace
+
+# Verify snapshot testing
+*.verified.png binary
diff --git a/.gitignore b/.gitignore
index a0ef3c2c2f..8e17117da2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -100,6 +100,8 @@ _UpgradeReport_Files/
/Release/
Output/
Output
+Output/RenderBenchmarks/
+Output/RenderBenchmarks/**
Output_i686/
Output_x86_64/
__pycache__/
@@ -186,6 +188,7 @@ CrashLog.txt
# VSTest Artifacts
TestResults/
*.trx
+TestResult.xml
# WPF markup compilation temp projects
*_wpftmp.csproj
@@ -201,3 +204,8 @@ FLExInstaller/wix6/cabcache/*
# Claude Code worktrees
.claude/worktrees/*
+
+# Verify snapshot testing - received files are transient
+*.received.png
+*.diff.png
+DataTreeTimingBaselines.json
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index e83806466c..8e14bcddb2 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -85,6 +85,23 @@
}
],
"tasks": [
+ {
+ "label": "Test: RenderBaselineTests",
+ "type": "shell",
+ "command": ".\\test.ps1 -TestProject \"RootSiteTests\" -TestFilter \"FullyQualifiedName~RenderBaselineTests\"",
+ "options": {
+ "shell": {
+ "executable": "powershell.exe",
+ "args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"]
+ }
+ },
+ "problemMatcher": [],
+ "group": "test",
+ "presentation": {
+ "reveal": "always",
+ "panel": "shared"
+ }
+ },
// ==================== Setup Tasks ====================
{
"label": "Setup: Colorize Worktree",
diff --git a/Build/Agent/Run-AllRenders.ps1 b/Build/Agent/Run-AllRenders.ps1
new file mode 100644
index 0000000000..18bf447d06
--- /dev/null
+++ b/Build/Agent/Run-AllRenders.ps1
@@ -0,0 +1,131 @@
+<#
+.SYNOPSIS
+ Runs the render-focused test suites for FieldWorks.
+
+.DESCRIPTION
+ Executes the DetailControls render tests and the RootSite render tests sequentially.
+ This script is intentionally outside the product project graph so developers can use a
+ single entrypoint without introducing a meta test project into the repository architecture.
+
+.PARAMETER Scope
+ Which render suites to run. Default is All.
+
+.PARAMETER Configuration
+ The build configuration to test. Default is Debug.
+
+.PARAMETER NoBuild
+ Skip building before running tests.
+
+.PARAMETER Verbosity
+ Test output verbosity: quiet, minimal, normal, detailed.
+
+.PARAMETER SkipDependencyCheck
+ Skip dependency preflight checks inside test.ps1.
+#>
+[CmdletBinding()]
+param(
+ [ValidateSet('All', 'DetailControls', 'RootSite')]
+ [string]$Scope = 'All',
+ [string]$Configuration = 'Debug',
+ [switch]$NoBuild,
+ [ValidateSet('quiet', 'minimal', 'normal', 'detailed', 'q', 'm', 'n', 'd')]
+ [string]$Verbosity = 'normal',
+ [switch]$SkipDependencyCheck
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
+$buildScript = Join-Path $repoRoot 'build.ps1'
+$testScript = Join-Path $repoRoot 'test.ps1'
+
+if (-not (Test-Path $buildScript)) {
+ throw "build.ps1 not found at $buildScript"
+}
+
+if (-not (Test-Path $testScript)) {
+ throw "test.ps1 not found at $testScript"
+}
+
+$requestedScopes = switch ($Scope) {
+ 'All' { @('DetailControls', 'RootSite') }
+ default { @($Scope) }
+}
+
+function Invoke-RenderSuite {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Name,
+ [Parameter(Mandatory = $true)]
+ [string]$BuildProject,
+ [Parameter(Mandatory = $true)]
+ [string]$TestProject,
+ [Parameter(Mandatory = $true)]
+ [string[]]$TestFilters
+ )
+
+ if (-not $NoBuild) {
+ Write-Output "Building $Name render tests..."
+
+ $buildArgs = @{
+ Configuration = $Configuration
+ Project = $BuildProject
+ Verbosity = $Verbosity
+ }
+
+ if ($SkipDependencyCheck) {
+ $buildArgs['SkipDependencyCheck'] = $true
+ }
+
+ & $buildScript @buildArgs
+ if ($LASTEXITCODE -ne 0) {
+ throw "$Name render build failed with exit code $LASTEXITCODE."
+ }
+ }
+
+ foreach ($testFilter in $TestFilters) {
+ Write-Output "Running $Name render tests with filter '$testFilter'..."
+
+ $testArgs = @{
+ Configuration = $Configuration
+ TestProject = $TestProject
+ TestFilter = $testFilter
+ Verbosity = $Verbosity
+ NoBuild = $true
+ SkipDependencyCheck = $true
+ }
+
+ & $testScript @testArgs
+ if ($LASTEXITCODE -ne 0) {
+ throw "$Name render tests failed with exit code $LASTEXITCODE for filter '$testFilter'."
+ }
+ }
+
+ Write-Output "[OK] $Name render tests passed."
+}
+
+foreach ($requestedScope in $requestedScopes) {
+ switch ($requestedScope) {
+ 'DetailControls' {
+ Invoke-RenderSuite `
+ -Name 'DetailControls' `
+ -BuildProject 'Src/Common/Controls/DetailControls/DetailControlsTests/DetailControlsTests.csproj' `
+ -TestProject 'Src/Common/Controls/DetailControls/DetailControlsTests' `
+ -TestFilters 'FullyQualifiedName~DataTreeRenderTests'
+ }
+ 'RootSite' {
+ Invoke-RenderSuite `
+ -Name 'RootSite' `
+ -BuildProject 'Src/Common/RootSite/RootSiteTests/RootSiteTests.csproj' `
+ -TestProject 'Src/Common/RootSite/RootSiteTests' `
+ -TestFilters @(
+ 'FullyQualifiedName~RenderBaselineTests',
+ 'FullyQualifiedName~RenderTimingSuiteTests',
+ 'FullyQualifiedName~RenderVerifyTests'
+ )
+ }
+ }
+}
+
+Write-Output 'All requested render suites passed.'
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
index a9841ecd2b..9e989c6125 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -148,10 +148,15 @@
=============================================================
-->
+
+
+
+
+
diff --git a/Docs/opentype-font-features.md b/Docs/opentype-font-features.md
new file mode 100644
index 0000000000..90065f9512
--- /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.
\ No newline at end of file
diff --git a/FieldWorks.sln b/FieldWorks.sln
index 12b0c003cf..c7ca074998 100644
--- a/FieldWorks.sln
+++ b/FieldWorks.sln
@@ -18,6 +18,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DetailControls", "Src\Commo
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DetailControlsTests", "Src\Common\Controls\DetailControls\DetailControlsTests\DetailControlsTests.csproj", "{36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RenderTestInfrastructure", "Src\Common\RenderTestInfrastructure\RenderTestInfrastructure.csproj", "{B53C715F-2F97-4E2B-9C10-5BFF1C04A446}"
+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}") = "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}"
@@ -335,6 +339,18 @@ Global
{36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Debug|x64.Build.0 = Debug|x64
{36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Release|x64.ActiveCfg = Release|x64
{36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Release|x64.Build.0 = Release|x64
+ {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Bounds|x64.ActiveCfg = Release|x64
+ {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Bounds|x64.Build.0 = Release|x64
+ {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Debug|x64.ActiveCfg = Debug|x64
+ {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Debug|x64.Build.0 = Debug|x64
+ {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Release|x64.ActiveCfg = Release|x64
+ {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Release|x64.Build.0 = Release|x64
+ {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Bounds|x64.ActiveCfg = Release|x64
+ {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Bounds|x64.Build.0 = Release|x64
+ {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Debug|x64.ActiveCfg = Debug|x64
+ {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
{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/Controls/DetailControls/DataTree.cs b/Src/Common/Controls/DetailControls/DataTree.cs
index 219d8bd882..27ea2ea211 100644
--- a/Src/Common/Controls/DetailControls/DataTree.cs
+++ b/Src/Common/Controls/DetailControls/DataTree.cs
@@ -139,6 +139,7 @@ public class DataTree : UserControl, IVwNotifyChange, IxCoreColleague, IRefresha
// to allow slices to handle events (e.g. InflAffixTemplateSlice)
protected Mediator m_mediator;
protected PropertyTable m_propertyTable;
+ private int m_sliceInstallCreationCount;
protected IRecordChangeHandler m_rch = null;
protected IRecordListUpdater m_rlu = null;
protected string m_listName;
@@ -162,6 +163,34 @@ public class DataTree : UserControl, IVwNotifyChange, IxCoreColleague, IRefresha
public List Slices { get; private set; }
+ public int SliceInstallCreationCount => m_sliceInstallCreationCount;
+
+ public void ResetSliceInstallCreationCount()
+ {
+ m_sliceInstallCreationCount = 0;
+ }
+
+ public void IncrementSliceInstallCreationCount()
+ {
+ m_sliceInstallCreationCount++;
+ }
+
+ ///
+ /// Tracks the highest slice index that has been made visible. Used by MakeSliceVisible
+ /// to avoid re-walking already-visible prefix on sequential calls.
+ /// Reset to -1 in CreateSlices and on any structural mutation of Slices.
+ /// This may be able to be optimized more, but this is the simplest,
+ /// "always works" solution.
+ ///
+ private int m_lastVisibleHighWaterMark = -1;
+ private int m_lastSlicePrewarmScrollTop = int.MinValue;
+ private int m_lastSlicePrewarmScrollDirection;
+ private int m_deferredSlicePrewarmStartIndex = -1;
+ private int m_deferredSlicePrewarmTargetIndex = -1;
+ private int m_deferredSlicePrewarmGeneration;
+ private bool m_deferredSlicePrewarmPending;
+ private bool m_deferredSlicePrewarmQueued;
+
#endregion Data members
#region constants
@@ -206,6 +235,9 @@ public enum LayoutStates : byte
/// Control how much output we send to the application's listeners (e.g. visual studio output window)
///
protected TraceSwitch m_traceSwitch = new TraceSwitch("DataTree", "");
+ private static readonly bool s_enableInteractionTrace = IsOptInPerfFlagEnabled("FW_PERF_INTERACTION_TRACE");
+ private static readonly int s_interactionTraceThresholdMs = GetPerfThresholdMs(
+ "FW_PERF_INTERACTION_TRACE_THRESHOLD_MS", 25);
protected void TraceVerbose(string s)
{
if(m_traceSwitch.TraceVerbose)
@@ -221,8 +253,51 @@ protected void TraceInfoLine(string s)
if(m_traceSwitch.TraceInfo || m_traceSwitch.TraceVerbose)
Trace.WriteLine("DataTreeThreadID="+System.Threading.Thread.CurrentThread.GetHashCode()+": "+s);
}
+ private static bool IsOptInPerfFlagEnabled(string variableName)
+ {
+ var value = Environment.GetEnvironmentVariable(variableName);
+ if (string.IsNullOrEmpty(value))
+ return false;
+
+ return !string.Equals(value, "0", StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(value, "off", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static int GetPerfThresholdMs(string variableName, int defaultValue)
+ {
+ var value = Environment.GetEnvironmentVariable(variableName);
+ if (int.TryParse(value, out var thresholdMs) && thresholdMs >= 0)
+ return thresholdMs;
+
+ return defaultValue;
+ }
+
+ private static void TraceInteractionTiming(string stage, long elapsedMs, string details)
+ {
+ if (!s_enableInteractionTrace || elapsedMs < s_interactionTraceThresholdMs)
+ return;
+
+ Trace.WriteLine(
+ $"[FW_PERF_INTERACTION] [DataTree] Stage={stage} DurationMs={elapsedMs} {details}");
+ }
+
+ private static void TraceInteractionEvent(string stage, string details)
+ {
+ if (!s_enableInteractionTrace)
+ return;
+
+ Trace.WriteLine(
+ $"[FW_PERF_INTERACTION] [DataTree] Stage={stage} {details}");
+ }
#endregion
+ protected virtual int ScrollSlicePrewarmPercentInScrollDirection => 200;
+ protected virtual int ScrollSlicePrewarmPercentAgainstScrollDirection => 0;
+ protected virtual int ScrollSlicePrewarmChunkSize => 10;
+ protected virtual int ScrollSlicePrewarmTimeBudgetMs => 25;
+ protected virtual IdleQueuePriority ScrollSlicePrewarmIdlePriority => IdleQueuePriority.Medium;
+
#region Slice collection manipulation methods
private ToolTip ToolTip
@@ -242,7 +317,11 @@ private ToolTip ToolTip
private void InsertSlice(int index, Slice slice)
{
InstallSlice(slice, index);
- ResetTabIndices(index);
+ InvalidateVisibleSliceHighWaterMark();
+ // Skip per-slice tab index reset during bulk construction; CreateSlices()
+ // performs a single ResetTabIndices(0) after all slices are created.
+ if (!ConstructingSlices)
+ ResetTabIndices(index);
if (m_fSetCurrentSliceNew && !slice.IsHeaderNode)
{
m_fSetCurrentSliceNew = false;
@@ -268,9 +347,12 @@ private void InstallSlice(Slice slice, int index)
SetToolTip(slice);
slice.ResumeLayout();
- // Make sure it isn't added twice.
- SplitContainer sc = slice.SplitCont;
- AdjustSliceSplitPosition(slice);
+ // Skip per-slice splitter adjustment during bulk construction;
+ // HandleLayout1 sets correct widths + positions after construction completes.
+ if (!ConstructingSlices)
+ {
+ AdjustSliceSplitPosition(slice);
+ }
}
///
@@ -283,9 +365,20 @@ private void ForceSliceIndex(Slice slice, int index)
{
Slices.Remove(slice);
Slices.Insert(index, slice);
+ InvalidateVisibleSliceHighWaterMark();
}
}
+ ///
+ /// The MakeSliceVisible cache is only valid while the current Slices ordering stays intact.
+ /// Any insert, remove, or reorder can move an invisible slice into the cached visible prefix,
+ /// so the conservative fix is to drop the cache and rebuild it on demand.
+ ///
+ private void InvalidateVisibleSliceHighWaterMark()
+ {
+ m_lastVisibleHighWaterMark = -1;
+ }
+
private void SetToolTip(Slice slice)
{
if (slice.ToolTip != null)
@@ -317,8 +410,6 @@ void slice_SplitterMoved(object sender, SplitterEventArgs e)
}
}
ResumeLayout(false);
- // This can affect the lines between the slices. We need to redraw them but not the
- // slices themselves.
Invalidate(false);
movedSlice.TakeFocus();
}
@@ -335,6 +426,10 @@ private void AdjustSliceSplitPosition(Slice otherSlice)
protected override void OnSizeChanged(EventArgs e)
{
base.OnSizeChanged(e);
+ // Skip O(N) splitter adjustment during bulk slice construction —
+ // HandleLayout1 will set correct widths + positions after construction.
+ if (ConstructingSlices)
+ return;
foreach (Slice slice in Slices)
{
AdjustSliceSplitPosition(slice);
@@ -393,6 +488,7 @@ private void RemoveSlice(Slice gonner, int index)
internal void RemoveDisposedSlice(Slice gonner)
{
Slices.Remove(gonner);
+ InvalidateVisibleSliceHighWaterMark();
}
private void RemoveSlice(Slice gonner, int index, bool fixToolTips)
@@ -402,6 +498,7 @@ private void RemoveSlice(Slice gonner, int index, bool fixToolTips)
Controls.Remove(gonner);
Debug.Assert(Slices[index] == gonner);
Slices.RemoveAt(index);
+ InvalidateVisibleSliceHighWaterMark();
// Reset CurrentSlice, if appropriate.
if (gonner == m_currentSlice)
@@ -445,7 +542,10 @@ private void RemoveSlice(Slice gonner, int index, bool fixToolTips)
SetToolTip(keeper);
}
- ResetTabIndices(index);
+ // Skip per-slice tab index reset during bulk construction; CreateSlices()
+ // performs a single ResetTabIndices(0) after all slices are created.
+ if (!ConstructingSlices)
+ ResetTabIndices(index);
}
private void SetTabIndex(int index)
@@ -474,6 +574,9 @@ public DataTree()
{
// string objName = ToString() + GetHashCode().ToString();
// Debug.WriteLine("Creating object:" + objName);
+ SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true);
+ UpdateStyles();
+
Slices = new List();
m_autoCustomFieldNodesDocument = new XmlDocument();
m_autoCustomFieldNodesDocRoot = m_autoCustomFieldNodesDocument.CreateElement("root");
@@ -682,8 +785,9 @@ public Slice CurrentSlice
// Tell the old geezer it isn't current anymore.
if (m_currentSlice != null)
{
- m_currentSlice.Validate();
- if (m_currentSlice.Control is ContainerControl)
+ if (m_currentSlice.IsHandleCreated)
+ m_currentSlice.Validate();
+ if (m_currentSlice.IsHandleCreated && m_currentSlice.Control is ContainerControl)
((ContainerControl)m_currentSlice.Control).Validate();
m_currentSlice.SetCurrentState(false);
}
@@ -817,6 +921,7 @@ public void Reset()
}
Controls.Clear(); //clear the controls
Slices.Clear(); //empty the slices collection
+ InvalidateVisibleSliceHighWaterMark();
foreach (var slice in slices) //make sure the slices don't think they are active, dispose them
{
slice.SetCurrentState(false);
@@ -1775,10 +1880,12 @@ private void CreateSlices(bool differentObject)
{
var watch = new Stopwatch();
watch.Start();
+ ResetSliceInstallCreationCount();
bool wasVisible = this.Visible;
var previousSlices = new ObjSeqHashMap();
int oldSliceCount = Slices.Count;
ConstructingSlices = true;
+ m_lastVisibleHighWaterMark = -1; // Reset visibility tracking for fresh slice construction.
try
{
// Bizarrely, calling Hide has been known to cause OnEnter to be called in a slice; we need to suppress this,
@@ -1825,10 +1932,13 @@ private void CreateSlices(bool differentObject)
foreach (Slice keeper in Slices)
SetToolTip(keeper);
}
- ResetTabIndices(0);
}
finally
{
+ // Keep tab indices consistent even if slice generation throws.
+ // This also serves as the single bulk reset for per-slice InsertSlice calls
+ // during ConstructingSlices.
+ ResetTabIndices(0);
ConstructingSlices = false;
}
if (wasVisible)
@@ -1893,13 +2003,10 @@ private static bool IsChildSlice(Slice first, Slice second)
///
/// This actually handles Paint for the contained control that has the slice controls in it.
///
- /// The instance containing the event data.
- void HandlePaintLinesBetweenSlices(PaintEventArgs pea)
+ private void PaintLinesBetweenSlices(Graphics gr, Rectangle clipRect, int width)
{
- Graphics gr = pea.Graphics;
- UserControl uc = this;
- // Where we're drawing.
- int width = uc.Width;
+ // Maximum vertical extent a separator can occupy (heavy rule + above-margin).
+ int maxLineExtent = HeavyweightRuleThickness + HeavyweightRuleAboveMargin;
using (var thinPen = new Pen(Color.LightGray, 1))
using (var thickPen = new Pen(Color.LightGray, 1 + HeavyweightRuleThickness))
{
@@ -1908,13 +2015,21 @@ void HandlePaintLinesBetweenSlices(PaintEventArgs pea)
var slice = Slices[i] as Slice;
if (slice == null)
continue;
- // shouldn't be visible
+
+ // Clip-rect culling: skip separator lines entirely outside the paint region.
+ // Slice positions are monotonically increasing (set sequentially by HandleLayout1),
+ // so once we pass the bottom of the clip rect we can stop.
+ Point loc = slice.Location;
+ int yPos = loc.Y + slice.Height;
+ if (yPos + maxLineExtent < clipRect.Top)
+ continue; // separator is above the paint region
+ if (loc.Y > clipRect.Bottom)
+ break; // all remaining slices are below the paint region
+
Slice nextSlice = null;
if (i < Slices.Count - 1)
nextSlice = Slices[i + 1] as Slice;
Pen linePen = thinPen;
- Point loc = slice.Location;
- int yPos = loc.Y + slice.Height;
int xPos = loc.X + slice.LabelIndent();
if (nextSlice != null)
@@ -1925,19 +2040,18 @@ void HandlePaintLinesBetweenSlices(PaintEventArgs pea)
//drop the next line unless the next slice is going to be a header, too
// (as is the case with empty sections), or isn't indented (as for the line following
// the empty 'Subclasses' heading in each inflection class).
- if (XmlUtils.GetOptionalBooleanAttributeValue(slice.ConfigurationNode, "header", false)
+ if (slice.IsHeader
&& nextSlice.Weight != ObjectWeight.heavy && IsChildSlice(slice, nextSlice))
continue;
//LT-11962 Improvements to display in Info tab.
// (remove the line directly below the Notebook Record header)
- if (XmlUtils.GetOptionalBooleanAttributeValue(slice.ConfigurationNode, "skipSpacerLine", false) &&
- slice is SummarySlice)
+ if (slice.SkipSpacerLine && slice is SummarySlice)
continue;
// Check for attribute that the next slice should be grouped with the current slice
// regardless of whether they represent the same object.
- bool fSameObject = XmlUtils.GetOptionalBooleanAttributeValue(nextSlice.ConfigurationNode, "sameObject", false);
+ bool fSameObject = nextSlice.SameObject;
xPos = Math.Min(xPos, loc.X + nextSlice.LabelIndent());
if (nextSlice.Weight == ObjectWeight.heavy)
@@ -3366,12 +3480,18 @@ public virtual Slice MakeEditorAt(int i)
public Slice FieldAt(int i)
{
CheckDisposed();
+ Stopwatch stopwatch = null;
+ int expansionCount = 0;
+ if (s_enableInteractionTrace)
+ stopwatch = Stopwatch.StartNew();
+
Slice slice = FieldOrDummyAt(i);
// Keep trying until we get a real slice. It's possible, for example, that the first object
// in a sequence expands into an embedded lazy sequence, which in turn needs to have its
// first item made real.
while (!slice.IsRealSlice)
{
+ expansionCount++;
var oldState = m_layoutState;
// guard against OnPaint() while slice is being constructed. Especially dangerous if it is a view,
// which might end up doing a re-entrant call to Construct() the root box. LT-11052.
@@ -3404,6 +3524,12 @@ public Slice FieldAt(int i)
m_layoutState = oldState;
}
}
+ if (stopwatch != null)
+ {
+ stopwatch.Stop();
+ TraceInteractionTiming("FieldAt", stopwatch.ElapsedMilliseconds,
+ $"Index={i} Expansions={expansionCount} IsRealSlice={slice != null && slice.IsRealSlice}");
+ }
return slice;
}
///
@@ -3540,22 +3666,80 @@ protected override void OnLayout(LayoutEventArgs levent)
}
}
+ ///
+ /// Binary search for the index of the first slice whose bottom edge extends
+ /// below . Slices before this index are entirely
+ /// above the viewport and can be skipped in the paint path.
+ /// Returns 0 if no slices can be safely skipped (e.g., all are visible or a
+ /// null entry is encountered during the search).
+ ///
+ /// The Y coordinate of the top of the clip rectangle.
+ /// Index of the first slice that might be visible.
+ private int FindFirstPotentiallyVisibleSlice(int clipTop)
+ {
+ int lo = 0, hi = Slices.Count - 1;
+ int result = 0;
+
+ while (lo <= hi)
+ {
+ int mid = lo + (hi - lo) / 2;
+ var slice = Slices[mid] as Slice;
+ if (slice == null)
+ return result; // Can't binary search past null; use best result so far
+
+ int sliceBottom = slice.Top + slice.Height;
+ if (sliceBottom <= clipTop)
+ {
+ // Slice ends at or before the clip top — entirely above viewport.
+ result = mid + 1;
+ lo = mid + 1;
+ }
+ else
+ {
+ // Slice extends below clip top — could be visible.
+ hi = mid - 1;
+ }
+ }
+
+ // Back up by one slice as a safety margin for heavyweight spacing or
+ // off-by-one edge cases. Cost: one extra loop iteration.
+ return Math.Max(0, result - 1);
+ }
+
///
/// Used both by main layout routine and also by OnPaint to make sure all
/// visible slices are real. For full layout, clipRect is meaningless.
+ /// On the paint path (!fFull), uses a binary search to skip above-viewport
+ /// slices, reducing each paint from O(N) to O(log N + V) where V is the
+ /// number of visible slices.
///
/// if set to true [f full].
/// The clip rect.
///
protected internal int HandleLayout1(bool fFull, Rectangle clipRect)
{
+ Stopwatch layoutStopwatch = null;
+ long fieldAtMs = 0;
+ int fieldAtCount = 0;
+ long makeVisibleMs = 0;
+ int makeVisibleCount = 0;
+ int visibleSliceCount = 0;
+ if (s_enableInteractionTrace && !fFull)
+ layoutStopwatch = Stopwatch.StartNew();
+
if (m_fDisposing)
- return clipRect.Bottom; // don't want to lay out while clearing slices in dispose!
+ {
+ if (layoutStopwatch != null)
+ {
+ layoutStopwatch.Stop();
+ TraceInteractionTiming("HandleLayout1.PaintPath", layoutStopwatch.ElapsedMilliseconds,
+ $"Reason=disposing ClipTop={clipRect.Top} ClipBottom={clipRect.Bottom}");
+ }
+ return clipRect.Bottom;
+ }
int minHeight = GetMinFieldHeight();
int desiredWidth = ClientRectangle.Width;
-
- // FWNX-370: work around https://bugzilla.novell.com/show_bug.cgi?id=609596
if (Platform.IsMono && VerticalScroll.Visible)
desiredWidth -= SystemInformation.VerticalScrollBarWidth;
@@ -3563,77 +3747,282 @@ protected internal int HandleLayout1(bool fFull, Rectangle clipRect)
var desiredScrollPosition = new Point(-oldPos.X, -oldPos.Y);
int yTop = AutoScrollPosition.Y;
- for (int i = 0; i < Slices.Count; i++)
+ int startIndex = 0;
+ int firstIndexBelowViewport = -1;
+ if (!fFull && Slices.Count > 0)
+ {
+ startIndex = FindFirstPotentiallyVisibleSlice(clipRect.Top);
+ if (startIndex > 0)
+ {
+ var startSlice = Slices[startIndex] as Slice;
+ if (startSlice != null)
+ {
+ yTop = startSlice.Top;
+ if (startSlice.Weight == ObjectWeight.heavy)
+ yTop -= (HeavyweightRuleThickness + HeavyweightRuleAboveMargin);
+ }
+ else
+ {
+ startIndex = 0;
+ }
+ }
+ }
+
+ for (int i = startIndex; i < Slices.Count; i++)
{
- // Don't care about items below bottom of clip, if one is specified.
if ((!fFull) && yTop >= clipRect.Bottom)
{
- return yTop - AutoScrollPosition.Y; // not very meaningful in this case, but a result is required.
+ firstIndexBelowViewport = i;
+ QueueDeferredSlicePrewarm(firstIndexBelowViewport);
+ if (layoutStopwatch != null)
+ {
+ layoutStopwatch.Stop();
+ TraceInteractionTiming("HandleLayout1.PaintPath", layoutStopwatch.ElapsedMilliseconds,
+ $"StartIndex={startIndex} VisibleSlices={visibleSliceCount} FieldAtCount={fieldAtCount} FieldAtMs={fieldAtMs} MakeVisibleCount={makeVisibleCount} MakeVisibleMs={makeVisibleMs} ClipTop={clipRect.Top} ClipBottom={clipRect.Bottom} Exit=below-clip");
+ }
+ return yTop - AutoScrollPosition.Y;
}
+
var tci = Slices[i] as Slice;
- // Best guess of its height, before we ensure it's real.
int defHeight = tci == null ? minHeight : tci.Height;
bool fSliceIsVisible = !fFull && yTop + defHeight > clipRect.Top && yTop <= clipRect.Bottom;
- //Debug.WriteLine(String.Format("DataTree.HandleLayout1({3},{4}): fSliceIsVisible = {5}, i = {0}, defHeight = {1}, yTop = {2}, desiredWidth = {7}, tci.Config = {6}",
- // i, defHeight, yTop, fFull, clipRect.ToString(), fSliceIsVisible, tci.ConfigurationNode.OuterXml, desiredWidth));
-
if (fSliceIsVisible)
{
- // We cannot allow slice to be unreal; it's visible, and we're checking
- // for real slices where they're visible
- tci = FieldAt(i); // ensures it becomes real if needed.
- var dummy = tci.Handle; // also force it to get a handle
+ visibleSliceCount++;
+ Stopwatch fieldAtStopwatch = null;
+ if (layoutStopwatch != null)
+ fieldAtStopwatch = Stopwatch.StartNew();
+ tci = FieldAt(i);
+ if (fieldAtStopwatch != null)
+ {
+ fieldAtStopwatch.Stop();
+ fieldAtCount++;
+ fieldAtMs += fieldAtStopwatch.ElapsedMilliseconds;
+ }
+ var dummy = tci.Handle;
if (tci.Control != null)
- dummy = tci.Control.Handle; // and its control must too.
+ dummy = tci.Control.Handle;
if (yTop < 0)
{
- // It starts above the top of the window. We need to adjust the scroll position
- // by the difference between the expected and actual heights.
- // This can have side effects, don't do unless needed.
- // The slice will now handle the conditioanl execution.
- //if (tci.Width != desiredWidth)
- tci.SetWidthForDataTreeLayout(desiredWidth);
+ tci.SetWidthForDataTreeLayout(desiredWidth);
desiredScrollPosition.Y -= (defHeight - tci.Height);
}
}
+
if (tci == null)
{
yTop += minHeight;
}
else
{
- // Move this slice down a little if it needs a heavy rule above it
if (tci.Weight == ObjectWeight.heavy)
yTop += HeavyweightRuleThickness + HeavyweightRuleAboveMargin;
if (tci.Top != yTop)
tci.Top = yTop;
- // This can have side effects, don't do unless needed.
- // The slice will now handle the conditional execution.
- //if (tci.Width != desiredWidth)
+ if (fFull || fSliceIsVisible)
tci.SetWidthForDataTreeLayout(desiredWidth);
yTop += tci.Height + 1;
if (fSliceIsVisible)
{
- MakeSliceVisible(tci);
+ Stopwatch makeVisibleStopwatch = null;
+ if (layoutStopwatch != null)
+ makeVisibleStopwatch = Stopwatch.StartNew();
+ MakeSliceVisible(tci, i);
+ if (makeVisibleStopwatch != null)
+ {
+ makeVisibleStopwatch.Stop();
+ makeVisibleCount++;
+ makeVisibleMs += makeVisibleStopwatch.ElapsedMilliseconds;
+ }
}
}
}
- // In the course of making slices real or adjusting their width they may have changed height (more strictly, its
- // real height may be different from the previous estimated height).
- // If it was previously above the top of the window, this can produce an unwanted
- // change in the visble position of previously visible slices.
- // The scroll position may also have changed as a result of the blankety blank
- // blank undocumented behavior of the UserControl class trying to make what it
- // thinks is the interesting child control visible.
- // In case it changed, try to change it back!
- // (This might not always succeed, if the scroll range changed so as to make the old position invalid.
+
if (-AutoScrollPosition.Y != desiredScrollPosition.Y)
AutoScrollPosition = desiredScrollPosition;
+ if (!fFull)
+ QueueDeferredSlicePrewarm(firstIndexBelowViewport >= 0 ? firstIndexBelowViewport : Slices.Count);
+ if (layoutStopwatch != null)
+ {
+ layoutStopwatch.Stop();
+ TraceInteractionTiming("HandleLayout1.PaintPath", layoutStopwatch.ElapsedMilliseconds,
+ $"StartIndex={startIndex} VisibleSlices={visibleSliceCount} FieldAtCount={fieldAtCount} FieldAtMs={fieldAtMs} MakeVisibleCount={makeVisibleCount} MakeVisibleMs={makeVisibleMs} ClipTop={clipRect.Top} ClipBottom={clipRect.Bottom} Exit=complete");
+ }
return yTop - AutoScrollPosition.Y;
}
- private void MakeSliceRealAt(int i)
+ private int GetSlicePrewarmPixels(int viewportPercent)
+ {
+ if (viewportPercent <= 0 || ClientRectangle.Height <= 0)
+ return 0;
+
+ long pixels = (long)ClientRectangle.Height * viewportPercent / 100;
+ return Math.Max(1, (int)pixels);
+ }
+
+ private void QueueDeferredSlicePrewarm(int firstIndexBelowViewport)
+ {
+ if (m_fDisposing || !Visible || !IsHandleCreated || ClientRectangle.Height <= 0 || Slices.Count == 0)
+ return;
+ if (m_lastSlicePrewarmScrollDirection == 0)
+ return;
+
+ int viewportTop = -AutoScrollPosition.Y;
+ int viewportBottom = viewportTop + ClientRectangle.Height;
+ int topMargin = m_lastSlicePrewarmScrollDirection > 0
+ ? GetSlicePrewarmPixels(ScrollSlicePrewarmPercentAgainstScrollDirection)
+ : GetSlicePrewarmPixels(ScrollSlicePrewarmPercentInScrollDirection);
+ int bottomMargin = m_lastSlicePrewarmScrollDirection > 0
+ ? GetSlicePrewarmPixels(ScrollSlicePrewarmPercentInScrollDirection)
+ : GetSlicePrewarmPixels(ScrollSlicePrewarmPercentAgainstScrollDirection);
+ if (topMargin <= 0 && bottomMargin <= 0)
+ return;
+
+ int targetY = m_lastSlicePrewarmScrollDirection >= 0
+ ? viewportBottom + bottomMargin
+ : Math.Max(0, viewportTop - topMargin);
+ int targetIndex = IndexOfSliceAtY(targetY);
+ if (targetIndex < 0)
+ targetIndex = Slices.Count - 1;
+
+ int startIndex = firstIndexBelowViewport;
+ if (m_lastSlicePrewarmScrollDirection < 0)
+ startIndex = Math.Max(0, IndexOfSliceAtY(Math.Max(0, viewportTop - topMargin)));
+ else if (startIndex < 0)
+ startIndex = Math.Max(0, IndexOfSliceAtY(viewportBottom));
+
+ startIndex = Math.Max(0, Math.Min(startIndex, Slices.Count - 1));
+ targetIndex = Math.Max(0, Math.Min(targetIndex, Slices.Count - 1));
+ int direction = m_lastSlicePrewarmScrollDirection;
+ if ((direction > 0 && startIndex > targetIndex) ||
+ (direction < 0 && targetIndex > startIndex))
+ {
+ return;
+ }
+
+ bool restartingRange = !m_deferredSlicePrewarmPending ||
+ (direction > 0 && targetIndex > m_deferredSlicePrewarmTargetIndex) ||
+ (direction < 0 && targetIndex < m_deferredSlicePrewarmTargetIndex) ||
+ (direction > 0 && startIndex > m_deferredSlicePrewarmTargetIndex) ||
+ (direction < 0 && startIndex < m_deferredSlicePrewarmTargetIndex);
+
+ if (!m_deferredSlicePrewarmPending)
+ {
+ m_deferredSlicePrewarmStartIndex = startIndex;
+ m_deferredSlicePrewarmTargetIndex = targetIndex;
+ m_deferredSlicePrewarmGeneration++;
+ }
+ else if (direction > 0)
+ {
+ m_deferredSlicePrewarmStartIndex = Math.Max(m_deferredSlicePrewarmStartIndex, startIndex);
+ m_deferredSlicePrewarmTargetIndex = Math.Max(m_deferredSlicePrewarmTargetIndex, targetIndex);
+ if (restartingRange)
+ m_deferredSlicePrewarmGeneration++;
+ }
+ else
+ {
+ m_deferredSlicePrewarmStartIndex = Math.Min(m_deferredSlicePrewarmStartIndex, startIndex);
+ m_deferredSlicePrewarmTargetIndex = Math.Min(m_deferredSlicePrewarmTargetIndex, targetIndex);
+ if (restartingRange)
+ m_deferredSlicePrewarmGeneration++;
+ }
+
+ if ((direction > 0 && m_deferredSlicePrewarmStartIndex > m_deferredSlicePrewarmTargetIndex) ||
+ (direction < 0 && m_deferredSlicePrewarmStartIndex < m_deferredSlicePrewarmTargetIndex))
+ {
+ m_deferredSlicePrewarmPending = false;
+ return;
+ }
+
+ m_deferredSlicePrewarmPending = true;
+ TraceInteractionEvent(
+ "DeferredSlicePrewarm.Plan",
+ $"Direction={direction} StartIndex={m_deferredSlicePrewarmStartIndex} TargetIndex={m_deferredSlicePrewarmTargetIndex} ViewTop={viewportTop} ViewBottom={viewportBottom} FirstBelowViewport={firstIndexBelowViewport} ChunkSize={ScrollSlicePrewarmChunkSize} BudgetMs={ScrollSlicePrewarmTimeBudgetMs} Restarting={restartingRange}");
+ QueueDeferredSlicePrewarmOnIdle();
+ }
+
+ private void QueueDeferredSlicePrewarmOnIdle()
+ {
+ if (!m_deferredSlicePrewarmPending || m_fDisposing || !Visible || !IsHandleCreated)
+ return;
+
+ if (m_mediator != null)
+ {
+ m_mediator.IdleQueue.Add(
+ ScrollSlicePrewarmIdlePriority,
+ ContinueDeferredSlicePrewarmOnIdle,
+ m_deferredSlicePrewarmGeneration,
+ true);
+ return;
+ }
+
+ if (m_deferredSlicePrewarmQueued)
+ return;
+
+ m_deferredSlicePrewarmQueued = true;
+ BeginInvoke((MethodInvoker)delegate
+ {
+ m_deferredSlicePrewarmQueued = false;
+ if (!ContinueDeferredSlicePrewarmOnIdle(m_deferredSlicePrewarmGeneration))
+ QueueDeferredSlicePrewarmOnIdle();
+ });
+ }
+
+ private bool ContinueDeferredSlicePrewarmOnIdle(object generationState)
+ {
+ if (!m_deferredSlicePrewarmPending || m_fDisposing)
+ return true;
+ if (!Visible || !IsHandleCreated || m_layoutState != LayoutStates.klsNormal)
+ return false;
+
+ int generation = generationState is int value ? value : m_deferredSlicePrewarmGeneration;
+ if (generation != m_deferredSlicePrewarmGeneration)
+ return true;
+
+ Stopwatch stopwatch = Stopwatch.StartNew();
+ int slicesWarmed = 0;
+ int initialIndex = m_deferredSlicePrewarmStartIndex;
+ while (m_deferredSlicePrewarmPending && slicesWarmed < ScrollSlicePrewarmChunkSize)
+ {
+ if (stopwatch.ElapsedMilliseconds >= ScrollSlicePrewarmTimeBudgetMs)
+ break;
+
+ int index = m_deferredSlicePrewarmStartIndex;
+ if (index < 0 || index >= Slices.Count)
+ {
+ m_deferredSlicePrewarmPending = false;
+ break;
+ }
+
+ if (m_lastSlicePrewarmScrollDirection > 0 && index > m_deferredSlicePrewarmTargetIndex)
+ {
+ m_deferredSlicePrewarmPending = false;
+ break;
+ }
+ if (m_lastSlicePrewarmScrollDirection < 0 && index < m_deferredSlicePrewarmTargetIndex)
+ {
+ m_deferredSlicePrewarmPending = false;
+ break;
+ }
+
+ if (Slices[index] is Slice)
+ MakeSliceRealAt(index, false);
+
+ m_deferredSlicePrewarmStartIndex += m_lastSlicePrewarmScrollDirection >= 0 ? 1 : -1;
+ slicesWarmed++;
+ }
+
+ stopwatch.Stop();
+ TraceInteractionTiming(
+ "DeferredSlicePrewarm.Idle",
+ stopwatch.ElapsedMilliseconds,
+ $"Direction={m_lastSlicePrewarmScrollDirection} InitialIndex={initialIndex} NextIndex={m_deferredSlicePrewarmStartIndex} TargetIndex={m_deferredSlicePrewarmTargetIndex} SlicesWarmed={slicesWarmed} Pending={m_deferredSlicePrewarmPending}");
+
+ return !m_deferredSlicePrewarmPending;
+ }
+
+ private void MakeSliceRealAt(int i, bool ensureVisible = true)
{
// We cannot allow slice to be unreal; it's visible, and we're checking
// for real slices where they're visible
@@ -3657,9 +4046,12 @@ private void MakeSliceRealAt(int i)
var desiredScrollPosition = new Point(-oldPos.X, -oldPos.Y);
// topAbs is the position of the slice relative to the top of the whole view contents now.
int topAbs = tci.Top - AutoScrollPosition.Y;
- MakeSliceVisible(tci); // also required for it to be a real tab stop.
+ if (ensureVisible)
+ MakeSliceVisible(tci); // also required for it to be a real tab stop.
+ else
+ tci.ShowSubControls();
- if (topAbs < desiredScrollPosition.Y)
+ if (ensureVisible && topAbs < desiredScrollPosition.Y)
{
// It was above the top of the window. We need to adjust the scroll position
// by the difference between the expected and actual heights.
@@ -3673,23 +4065,41 @@ private void MakeSliceRealAt(int i)
/// Make a slice visible, either because it needs to be drawn, or because it needs to be
/// focused.
///
- ///
- internal static void MakeSliceVisible(Slice tci)
+ /// The slice to make visible.
+ ///
+ /// The slice's known index in Slices, or -1 to look it up via IndexOf.
+ /// Passing the index avoids an O(N) IndexOf call when the caller already knows it.
+ ///
+ internal void MakeSliceVisible(Slice tci, int knownIndex = -1)
{
+ Stopwatch stopwatch = null;
+ int newlyVisibleSiblingCount = 0;
+ bool sliceAlreadyVisible = tci.Visible;
+ if (s_enableInteractionTrace)
+ stopwatch = Stopwatch.StartNew();
+
// It intersects the screen so it needs to be visible.
if (!tci.Visible)
{
- int index = tci.IndexInContainer;
+ int index = knownIndex >= 0 ? knownIndex : Slices.IndexOf(tci);
// All previous slices must be "visible". Otherwise, the index of the current
// slice gets changed when it becomes visible due to what is presumably a bug
// in the dotnet framework.
- for (int i = 0; i < index; ++i)
+ // Optimization: start from m_lastVisibleHighWaterMark + 1 instead of 0,
+ // since slices before the high-water mark are already visible from prior calls.
+ int start = Math.Max(0, m_lastVisibleHighWaterMark + 1);
+ for (int i = start; i < index; ++i)
{
- Control ctrl = tci.ContainingDataTree.Slices[i];
+ Control ctrl = Slices[i];
if (ctrl != null && !ctrl.Visible)
+ {
ctrl.Visible = true;
+ newlyVisibleSiblingCount++;
+ }
}
tci.Visible = true;
+ if (index > m_lastVisibleHighWaterMark)
+ m_lastVisibleHighWaterMark = index;
Debug.Assert(tci.IndexInContainer == index,
String.Format("MakeSliceVisible: slice '{0}' at index({2}) should not have changed to index ({1})." +
" This can occur when making slices visible in an order different than their order in DataTree.Slices. See LT-7307.",
@@ -3702,6 +4112,12 @@ internal static void MakeSliceVisible(Slice tci)
tci.Control.AccessibilityObject.Name = tci.Label;// + "ZZZ_Slice";
}
tci.ShowSubControls();
+ if (stopwatch != null)
+ {
+ stopwatch.Stop();
+ TraceInteractionTiming("MakeSliceVisible", stopwatch.ElapsedMilliseconds,
+ $"SliceLabel={tci.Label ?? "(null)"} AlreadyVisible={sliceAlreadyVisible} NewlyVisibleSiblings={newlyVisibleSiblingCount}");
+ }
}
public int GetMinFieldHeight()
@@ -3798,6 +4214,14 @@ public int IndexOfSliceAtY(int yp)
protected override void OnPaint(PaintEventArgs e)
{
+ int previousScrollTop = m_lastSlicePrewarmScrollTop == int.MinValue
+ ? -AutoScrollPosition.Y
+ : m_lastSlicePrewarmScrollTop;
+ int currentScrollTop = -AutoScrollPosition.Y;
+ if (currentScrollTop != previousScrollTop)
+ m_lastSlicePrewarmScrollDirection = Math.Sign(currentScrollTop - previousScrollTop);
+ m_lastSlicePrewarmScrollTop = currentScrollTop;
+
if (m_layoutState != LayoutStates.klsNormal)
{
// re-entrant call, in the middle of doing layout! Suppress it. But, we need to paint sometime...
@@ -3813,8 +4237,8 @@ protected override void OnPaint(PaintEventArgs e)
}
try
{
- // Optimize JohnT: Could we do a binary search for the
- // slice at the top? But the chop point slices may not be real...
+ // Paint-path binary search: HandleLayout1 now uses FindFirstPotentiallyVisibleSlice
+ // to skip above-viewport slices (addresses JohnT's original TODO).
m_layoutState = LayoutStates.klsChecking;
Rectangle requiredReal = ClientRectangle; // all slices in this must be real
HandleLayout1(false, requiredReal);
@@ -3828,11 +4252,15 @@ protected override void OnPaint(PaintEventArgs e)
PerformLayout();
if (AutoScrollPosition != oldPos)
AutoScrollPosition = new Point(-oldPos.X, -oldPos.Y);
+ // Layout recovery can complete this paint pass without reaching the normal
+ // line-paint path below. Redraw separators now so they do not disappear
+ // until a follow-up paint arrives.
+ PaintLinesBetweenSlices(e.Graphics, ClientRectangle, ClientRectangle.Width);
}
else
{
base.OnPaint(e);
- HandlePaintLinesBetweenSlices(e);
+ PaintLinesBetweenSlices(e.Graphics, e.ClipRectangle, ClientRectangle.Width);
}
}
finally
@@ -3859,6 +4287,33 @@ protected override void OnPaint(PaintEventArgs e)
}
}
+ protected override void WndProc(ref Message m)
+ {
+ base.WndProc(ref m);
+ // After any scroll input (scrollbar drag, mouse wheel, horizontal wheel),
+ // force the parent background to repaint so separator lines are redrawn at
+ // correct positions. Without this, Windows bitblts stale line pixels
+ // from the old scroll position and only repaints the newly-exposed strip.
+ // Invalidate(false) skips child invalidation — slice HWNDs repaint
+ // themselves — so only the gap areas between slices are redrawn.
+ // Update() forces synchronous processing so stale lines don't accumulate
+ // across multiple scroll events before the low-priority WM_PAINT fires.
+ const int WM_VSCROLL = 0x0115;
+ const int WM_HSCROLL = 0x0114;
+ const int WM_MOUSEWHEEL = 0x020A;
+ const int WM_MOUSEHWHEEL = 0x020E;
+ switch (m.Msg)
+ {
+ case WM_VSCROLL:
+ case WM_HSCROLL:
+ case WM_MOUSEWHEEL:
+ case WM_MOUSEHWHEEL:
+ Invalidate(false);
+ Update();
+ break;
+ }
+ }
+
#region automated tree navigation
///
@@ -3899,7 +4354,7 @@ internal bool GotoNextSliceAfterIndex(int index)
while (index >= 0 && index < Slices.Count)
{
Slice current = FieldAt(index);
- MakeSliceVisible(current);
+ MakeSliceVisible(current, index);
if (current.TakeFocus(false))
{
if (m_currentSlice != current)
@@ -3921,7 +4376,7 @@ public bool GotoPreviousSliceBeforeIndex(int index)
while (index >= 0 && index < Slices.Count)
{
Slice current = FieldAt(index);
- MakeSliceVisible(current);
+ MakeSliceVisible(current, index);
if (current.TakeFocus(false))
{
if (m_currentSlice != current)
@@ -4959,14 +5414,24 @@ public override Slice BecomeReal(int index)
// Save these, we may get disposed soon, can't get them from member data any more.
DataTree containingTree = ContainingDataTree;
- Control parent = Parent;
var parentSlice = ParentSlice;
path.Add(hvo);
var objItem = ContainingDataTree.Cache.ServiceLocator.GetInstance().GetObject(hvo);
Point oldPos = ContainingDataTree.AutoScrollPosition;
- ContainingDataTree.CreateSlicesFor(objItem, parentSlice, m_layoutName, m_layoutChoiceField, m_indent, index + 1, path,
- new ObjSeqHashMap(), m_caller);
+ // Suspend layout during lazy expansion to avoid per-slice layout passes.
+ // ShowObject and RefreshList already do this, but BecomeReal is called
+ // independently when dummy slices become visible during scrolling.
+ containingTree.DeepSuspendLayout();
+ try
+ {
+ ContainingDataTree.CreateSlicesFor(objItem, parentSlice, m_layoutName, m_layoutChoiceField, m_indent, index + 1, path,
+ new ObjSeqHashMap(), m_caller);
+ }
+ finally
+ {
+ containingTree.DeepResumeLayout();
+ }
// If inserting slices somehow altered the scroll position, for example as the
// silly Panel tries to make the selected control visible, put it back!
if (containingTree.AutoScrollPosition != oldPos)
@@ -4976,12 +5441,5 @@ public override Slice BecomeReal(int index)
return containingTree.Slices.Count > index + 1 ? containingTree.Slices[index + 1] as Slice : null;
}
- protected override void WndProc(ref Message m)
- {
- int aspY = AutoScrollPosition.Y;
- base.WndProc (ref m);
- if (aspY != AutoScrollPosition.Y)
- Debug.WriteLine("ASP changed during processing message " + m.Msg);
- }
}
}
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_collapsed.verified.png b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_collapsed.verified.png
new file mode 100644
index 0000000000..cf8ea69411
Binary files /dev/null and b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_collapsed.verified.png differ
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_deep.verified.png b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_deep.verified.png
new file mode 100644
index 0000000000..e4492113d8
Binary files /dev/null and b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_deep.verified.png differ
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_expanded.verified.png b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_expanded.verified.png
new file mode 100644
index 0000000000..b64df46cc1
Binary files /dev/null and b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_expanded.verified.png differ
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_extreme.verified.png b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_extreme.verified.png
new file mode 100644
index 0000000000..3e59d1b973
Binary files /dev/null and b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_extreme.verified.png differ
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_multiws.verified.png b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_multiws.verified.png
new file mode 100644
index 0000000000..a0c9cb6d3d
Binary files /dev/null and b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_multiws.verified.png differ
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_simple.verified.png b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_simple.verified.png
new file mode 100644
index 0000000000..e3df08f2d3
Binary files /dev/null and b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_simple.verified.png differ
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_subsubsub-hidden-productionlike.verified.json b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_subsubsub-hidden-productionlike.verified.json
new file mode 100644
index 0000000000..d3a7d9aec1
--- /dev/null
+++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_subsubsub-hidden-productionlike.verified.json
@@ -0,0 +1,25 @@
+{
+ "ScenarioId": "subsubsub-hidden-productionlike",
+ "SnapshotName": "DataTreeRenderTests.DataTreeRender_subsubsub-hidden-productionlike",
+ "CapturedAtUtc": "2026-04-07T19:31:52.092415Z",
+ "ImageWidth": 1024,
+ "ImageHeight": 1296,
+ "MachineName": "SIL-XPS",
+ "OsVersion": "Microsoft Windows NT 10.0.26200.0",
+ "EnvironmentHash": "3sUMmJxy5F2yJfd/zkW+tp+Srfbv4NmGLzt3D19Uu7c=",
+ "Environment": {
+ "DpiX": 96,
+ "DpiY": 96,
+ "FontSmoothing": true,
+ "ClearTypeEnabled": true,
+ "ThemeName": "Light",
+ "TextScaleFactor": 1.0,
+ "ScreenWidth": 1366,
+ "ScreenHeight": 768,
+ "CultureName": "en-US"
+ },
+ "DpiAwareness": "Unaware",
+ "FontQuality": "4",
+ "DeterministicFontFamily": "Segoe UI",
+ "DeterministicFontInstalled": true
+}
\ No newline at end of file
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_subsubsub-hidden-productionlike.verified.png b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_subsubsub-hidden-productionlike.verified.png
new file mode 100644
index 0000000000..771721543b
Binary files /dev/null and b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_subsubsub-hidden-productionlike.verified.png differ
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_subsubsub-hidden.verified.json b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_subsubsub-hidden.verified.json
new file mode 100644
index 0000000000..7c03860224
--- /dev/null
+++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_subsubsub-hidden.verified.json
@@ -0,0 +1,25 @@
+{
+ "ScenarioId": "subsubsub-hidden",
+ "SnapshotName": "DataTreeRenderTests.DataTreeRender_subsubsub-hidden",
+ "CapturedAtUtc": "2026-04-07T19:31:49.8258833Z",
+ "ImageWidth": 1024,
+ "ImageHeight": 1259,
+ "MachineName": "SIL-XPS",
+ "OsVersion": "Microsoft Windows NT 10.0.26200.0",
+ "EnvironmentHash": "3sUMmJxy5F2yJfd/zkW+tp+Srfbv4NmGLzt3D19Uu7c=",
+ "Environment": {
+ "DpiX": 96,
+ "DpiY": 96,
+ "FontSmoothing": true,
+ "ClearTypeEnabled": true,
+ "ThemeName": "Light",
+ "TextScaleFactor": 1.0,
+ "ScreenWidth": 1366,
+ "ScreenHeight": 768,
+ "CultureName": "en-US"
+ },
+ "DpiAwareness": "Unaware",
+ "FontQuality": "4",
+ "DeterministicFontFamily": "Segoe UI",
+ "DeterministicFontInstalled": true
+}
\ No newline at end of file
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_subsubsub-hidden.verified.png b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_subsubsub-hidden.verified.png
new file mode 100644
index 0000000000..c8b16f76c6
Binary files /dev/null and b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.DataTreeRender_subsubsub-hidden.verified.png differ
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.cs
new file mode 100644
index 0000000000..03c8336c74
--- /dev/null
+++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.cs
@@ -0,0 +1,1642 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using NUnit.Framework;
+using SIL.LCModel;
+using SIL.LCModel.Core.KernelInterfaces;
+using SIL.LCModel.Core.Text;
+using SIL.LCModel.Core.WritingSystems;
+using SIL.FieldWorks.Common.RenderVerification;
+using SIL.Utils;
+using SIL.WritingSystems;
+
+namespace SIL.FieldWorks.Common.Framework.DetailControls
+{
+ ///
+ /// Snapshot tests using Verify for pixel-perfect validation of the full DataTree edit view,
+ /// including WinForms chrome (grey labels, icons, section headers, separators) and
+ /// Views engine text content (rendered via VwDrawRootBuffered overlay).
+ ///
+ ///
+ /// These tests exercise the production DataTree/Slice rendering pipeline that FLEx uses
+ /// to display the lexical entry edit view. Unlike the RootSiteTests lex entry scenarios
+ /// (which only test Views engine text rendering), these capture the full UI composition.
+ /// Baselines are committed PNG files stored next to this test source.
+ ///
+ [TestFixture]
+ public class DataTreeRenderTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase
+ {
+ private const string DeterministicRenderFontFamily = "Segoe UI";
+ private ILexEntry m_entry;
+
+ [SetUp]
+ public void UseDeterministicWritingSystemFonts()
+ {
+ NormalizeDeterministicWritingSystemFonts(
+ Cache.ServiceLocator.WritingSystems.DefaultVernacularWritingSystem,
+ Cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem);
+ }
+
+ private void NormalizeDeterministicWritingSystemFonts(params CoreWritingSystemDefinition[] additionalWritingSystems)
+ {
+ var writingSystems = new[]
+ {
+ Cache.ServiceLocator.WritingSystems.DefaultVernacularWritingSystem,
+ Cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem
+ }
+ .Concat(Cache.ServiceLocator.WritingSystems.CurrentVernacularWritingSystems)
+ .Concat(Cache.ServiceLocator.WritingSystems.CurrentAnalysisWritingSystems)
+ .Concat(additionalWritingSystems)
+ .Where(ws => ws != null)
+ .GroupBy(ws => ws.Handle)
+ .Select(group => group.First());
+
+ foreach (var writingSystem in writingSystems)
+ {
+ writingSystem.DefaultFont = new FontDefinition(DeterministicRenderFontFamily);
+ writingSystem.IsGraphiteEnabled = false;
+ }
+ }
+
+ private static ITsString MakeRenderString(string value, int writingSystemHandle)
+ {
+ var propsBuilder = TsStringUtils.MakePropsBldr();
+ propsBuilder.SetIntPropValues((int)FwTextPropType.ktptWs,
+ (int)FwTextPropVar.ktpvDefault, writingSystemHandle);
+ propsBuilder.SetStrPropValue((int)FwTextPropType.ktptFontFamily,
+ DeterministicRenderFontFamily);
+
+ var stringBuilder = TsStringUtils.MakeStrBldr();
+ stringBuilder.Replace(0, 0, value, propsBuilder.GetTextProps());
+ return stringBuilder.GetString();
+ }
+
+ #region Scenario Data Creation
+
+ ///
+ /// Creates a simple lex entry with 3 senses for the "simple" scenario.
+ /// All fields filled with predictable text: "FieldName - simple".
+ ///
+ private void CreateSimpleEntry()
+ {
+ const string testName = "simple";
+ m_entry = Cache.ServiceLocator.GetInstance().Create();
+
+ var morphFactory = Cache.ServiceLocator.GetInstance();
+ var morph = morphFactory.Create();
+ m_entry.LexemeFormOA = morph;
+ morph.Form.VernacularDefaultWritingSystem = MakeRenderString(
+ $"LexemeForm - {testName}", Cache.DefaultVernWs);
+
+ m_entry.CitationForm.VernacularDefaultWritingSystem = MakeRenderString(
+ $"CitationForm - {testName}", Cache.DefaultVernWs);
+
+ // Add 3 senses with predictable text
+ var senseFactory = Cache.ServiceLocator.GetInstance();
+ for (int i = 1; i <= 3; i++)
+ {
+ var sense = senseFactory.Create();
+ m_entry.SensesOS.Add(sense);
+ FillSenseFields(sense, $"{i}", testName);
+ }
+
+ EnrichEntry(m_entry, testName);
+ }
+
+ ///
+ /// Creates a lex entry with triple-nested senses (depth 3, breadth 2).
+ /// 2 senses × 2 subsenses × 2 sub-sub-senses = 14 total senses (2+4+8).
+ /// This is the "slow" scenario — realistic deeply nested entry.
+ ///
+ private void CreateDeepEntry()
+ {
+ const string testName = "deep";
+ m_entry = Cache.ServiceLocator.GetInstance().Create();
+
+ var morphFactory = Cache.ServiceLocator.GetInstance();
+ var morph = morphFactory.Create();
+ m_entry.LexemeFormOA = morph;
+ morph.Form.VernacularDefaultWritingSystem = MakeRenderString(
+ $"LexemeForm - {testName}", Cache.DefaultVernWs);
+
+ m_entry.CitationForm.VernacularDefaultWritingSystem = MakeRenderString(
+ $"CitationForm - {testName}", Cache.DefaultVernWs);
+
+ var senseFactory = Cache.ServiceLocator.GetInstance();
+ CreateNestedSenses(m_entry, senseFactory, 3, 2, "", 1, testName);
+
+ EnrichEntry(m_entry, testName);
+ }
+
+ ///
+ /// Creates a lex entry with sub-sub-sub senses (depth 4, breadth 2).
+ /// 2 + 4 + 8 + 16 = 30 senses total.
+ /// Used to validate deeper recursive rendering with hidden fields enabled.
+ ///
+ private void CreateSubSubSubEntry()
+ {
+ const string testName = "subsubsub-hidden";
+ m_entry = Cache.ServiceLocator.GetInstance().Create();
+
+ var morphFactory = Cache.ServiceLocator.GetInstance();
+ var morph = morphFactory.Create();
+ m_entry.LexemeFormOA = morph;
+ morph.Form.VernacularDefaultWritingSystem = MakeRenderString(
+ $"LexemeForm - {testName}", Cache.DefaultVernWs);
+
+ m_entry.CitationForm.VernacularDefaultWritingSystem = MakeRenderString(
+ $"CitationForm - {testName}", Cache.DefaultVernWs);
+
+ var senseFactory = Cache.ServiceLocator.GetInstance();
+ CreateNestedSenses(m_entry, senseFactory, 4, 2, "", 1, testName);
+
+ EnrichEntry(m_entry, testName);
+ }
+
+ ///
+ /// Creates a lex entry with extreme nesting (6 levels deep, 2 wide = 126 senses).
+ /// Stress test for the DataTree slice rendering pipeline.
+ ///
+ private void CreateExtremeEntry()
+ {
+ const string testName = "extreme";
+ m_entry = Cache.ServiceLocator.GetInstance().Create();
+
+ var morphFactory = Cache.ServiceLocator.GetInstance();
+ var morph = morphFactory.Create();
+ m_entry.LexemeFormOA = morph;
+ morph.Form.VernacularDefaultWritingSystem = MakeRenderString(
+ $"LexemeForm - {testName}", Cache.DefaultVernWs);
+
+ m_entry.CitationForm.VernacularDefaultWritingSystem = MakeRenderString(
+ $"CitationForm - {testName}", Cache.DefaultVernWs);
+
+ var senseFactory = Cache.ServiceLocator.GetInstance();
+ CreateNestedSenses(m_entry, senseFactory, 6, 2, "", 1, testName);
+
+ EnrichEntry(m_entry, testName);
+ }
+
+ private void CreateNestedSenses(ICmObject owner, ILexSenseFactory senseFactory,
+ int remainingDepth, int breadth, string prefix, int startNumber, string testName)
+ {
+ if (remainingDepth <= 0) return;
+
+ for (int i = 0; i < breadth; i++)
+ {
+ int num = startNumber + i;
+ string senseNum = string.IsNullOrEmpty(prefix) ? num.ToString() : $"{prefix}.{num}";
+
+ var sense = senseFactory.Create();
+
+ if (owner is ILexEntry entry)
+ entry.SensesOS.Add(sense);
+ else if (owner is ILexSense parentSense)
+ parentSense.SensesOS.Add(sense);
+
+ FillSenseFields(sense, senseNum, testName);
+
+ // Recurse into subsenses
+ CreateNestedSenses(sense, senseFactory, remainingDepth - 1, breadth, senseNum, 1, testName);
+ }
+ }
+
+ ///
+ /// Fills sense-level fields with predictable text: "FieldName - testName sense N".
+ ///
+ private void FillSenseFields(ILexSense sense, string senseNum, string testName)
+ {
+ sense.Gloss.AnalysisDefaultWritingSystem = MakeRenderString(
+ $"Gloss - {testName} sense {senseNum}", Cache.DefaultAnalWs);
+ sense.Definition.AnalysisDefaultWritingSystem = MakeRenderString(
+ $"Definition - {testName} sense {senseNum}", Cache.DefaultAnalWs);
+ sense.ScientificName = MakeRenderString(
+ $"ScientificName - {testName} sense {senseNum}", Cache.DefaultAnalWs);
+ }
+
+ ///
+ /// Enriches a lex entry with additional fields that trigger ifdata layout parts.
+ /// All fields use predictable text: "FieldName - testName".
+ /// Populates: Pronunciation, LiteralMeaning, Bibliography, Restrictions,
+ /// SummaryDefinition, and Comment.
+ /// Etymology is intentionally excluded because its SummarySlice creates native
+ /// COM views that crash in test context.
+ ///
+ private void EnrichEntry(ILexEntry entry, string testName)
+ {
+ // Pronunciation (ifdata, owned sequence object)
+ var pronFactory = Cache.ServiceLocator.GetInstance();
+ var pronunciation = pronFactory.Create();
+ entry.PronunciationsOS.Add(pronunciation);
+ pronunciation.Form.VernacularDefaultWritingSystem = MakeRenderString(
+ $"Pronunciation - {testName}", Cache.DefaultVernWs);
+
+ // MultiString ifdata fields
+ entry.LiteralMeaning.AnalysisDefaultWritingSystem = MakeRenderString(
+ $"LiteralMeaning - {testName}", Cache.DefaultAnalWs);
+ entry.Bibliography.AnalysisDefaultWritingSystem = MakeRenderString(
+ $"Bibliography - {testName}", Cache.DefaultAnalWs);
+ entry.Restrictions.AnalysisDefaultWritingSystem = MakeRenderString(
+ $"Restrictions - {testName}", Cache.DefaultAnalWs);
+ entry.SummaryDefinition.AnalysisDefaultWritingSystem = MakeRenderString(
+ $"SummaryDefinition - {testName}", Cache.DefaultAnalWs);
+ entry.Comment.AnalysisDefaultWritingSystem = MakeRenderString(
+ $"Comment - {testName}", Cache.DefaultAnalWs);
+ }
+
+ ///
+ /// Creates a minimal lex entry with a single sense and no optional fields.
+ /// Exercises the "collapsed" view — bare minimum rendering path.
+ ///
+ private void CreateCollapsedEntry()
+ {
+ const string testName = "collapsed";
+ m_entry = Cache.ServiceLocator.GetInstance().Create();
+
+ var morphFactory = Cache.ServiceLocator.GetInstance();
+ var morph = morphFactory.Create();
+ m_entry.LexemeFormOA = morph;
+ morph.Form.VernacularDefaultWritingSystem = MakeRenderString(
+ $"LexemeForm - {testName}", Cache.DefaultVernWs);
+
+ m_entry.CitationForm.VernacularDefaultWritingSystem = MakeRenderString(
+ $"CitationForm - {testName}", Cache.DefaultVernWs);
+
+ // Single sense — minimal entry, no enrichment
+ var senseFactory = Cache.ServiceLocator.GetInstance();
+ var sense = senseFactory.Create();
+ m_entry.SensesOS.Add(sense);
+ FillSenseFields(sense, "1", testName);
+ }
+
+ ///
+ /// Creates a fully enriched lex entry with all available optional fields populated.
+ /// 4 senses with all sense-level fields, plus full entry enrichment.
+ /// Exercises the "expanded" view — maximum slice count for fields we can safely render.
+ ///
+ private void CreateExpandedEntry()
+ {
+ const string testName = "expanded";
+ m_entry = Cache.ServiceLocator.GetInstance().Create();
+
+ var morphFactory = Cache.ServiceLocator.GetInstance();
+ var morph = morphFactory.Create();
+ m_entry.LexemeFormOA = morph;
+ morph.Form.VernacularDefaultWritingSystem = MakeRenderString(
+ $"LexemeForm - {testName}", Cache.DefaultVernWs);
+
+ m_entry.CitationForm.VernacularDefaultWritingSystem = MakeRenderString(
+ $"CitationForm - {testName}", Cache.DefaultVernWs);
+
+ // Multiple senses with all fields
+ var senseFactory = Cache.ServiceLocator.GetInstance();
+ for (int i = 1; i <= 4; i++)
+ {
+ var sense = senseFactory.Create();
+ m_entry.SensesOS.Add(sense);
+ FillSenseFields(sense, $"{i}", testName);
+ }
+
+ // Full enrichment
+ EnrichEntry(m_entry, testName);
+
+ // Add a second pronunciation
+ var pronFactory = Cache.ServiceLocator.GetInstance();
+ var pron2 = pronFactory.Create();
+ m_entry.PronunciationsOS.Add(pron2);
+ pron2.Form.VernacularDefaultWritingSystem = MakeRenderString(
+ $"Pronunciation2 - {testName}", Cache.DefaultVernWs);
+ }
+
+ ///
+ /// Creates a lex entry with values in multiple writing systems.
+ /// Exercises the MultiStringSlice rendering with WS indicators.
+ /// Fields filled with predictable text in both English and French.
+ ///
+ private void CreateMultiWsEntry()
+ {
+ const string testName = "multiws";
+ m_entry = Cache.ServiceLocator.GetInstance().Create();
+
+ var morphFactory = Cache.ServiceLocator.GetInstance();
+ var morph = morphFactory.Create();
+ m_entry.LexemeFormOA = morph;
+
+ morph.Form.VernacularDefaultWritingSystem = MakeRenderString(
+ $"LexemeForm - {testName}", Cache.DefaultVernWs);
+
+ m_entry.CitationForm.VernacularDefaultWritingSystem = MakeRenderString(
+ $"CitationForm - {testName}", Cache.DefaultVernWs);
+
+ int analWs = Cache.DefaultAnalWs;
+
+ // Add a French writing system
+ int frWs = analWs;
+ try
+ {
+ var wsManager = Cache.ServiceLocator.WritingSystemManager;
+ CoreWritingSystemDefinition frWsDef;
+ wsManager.GetOrSet("fr", out frWsDef);
+ frWs = frWsDef.Handle;
+ Cache.LanguageProject.AddToCurrentAnalysisWritingSystems(frWsDef);
+ NormalizeDeterministicWritingSystemFonts(frWsDef);
+ }
+ catch
+ {
+ // If we can't create French WS, proceed with default analysis WS
+ }
+
+ // Senses with multi-WS text
+ var senseFactory = Cache.ServiceLocator.GetInstance();
+ for (int i = 1; i <= 2; i++)
+ {
+ var sense = senseFactory.Create();
+ m_entry.SensesOS.Add(sense);
+ sense.Gloss.set_String(analWs, MakeRenderString(
+ $"Gloss - {testName} sense {i} (en)", analWs));
+ if (frWs != analWs)
+ {
+ sense.Gloss.set_String(frWs, MakeRenderString(
+ $"Gloss - {testName} sens {i} (fr)", frWs));
+ }
+ sense.Definition.set_String(analWs, MakeRenderString(
+ $"Definition - {testName} sense {i} (en)", analWs));
+ if (frWs != analWs)
+ {
+ sense.Definition.set_String(frWs, MakeRenderString(
+ $"Definition - {testName} sens {i} (fr)", frWs));
+ }
+ }
+
+ // Multi-WS entry-level fields
+ m_entry.LiteralMeaning.set_String(analWs, MakeRenderString(
+ $"LiteralMeaning - {testName} (en)", analWs));
+ if (frWs != analWs)
+ {
+ m_entry.LiteralMeaning.set_String(frWs, MakeRenderString(
+ $"LiteralMeaning - {testName} (fr)", frWs));
+ }
+
+ m_entry.SummaryDefinition.set_String(analWs, MakeRenderString(
+ $"SummaryDefinition - {testName} (en)", analWs));
+ if (frWs != analWs)
+ {
+ m_entry.SummaryDefinition.set_String(frWs, MakeRenderString(
+ $"SummaryDefinition - {testName} (fr)", frWs));
+ }
+
+ // Pronunciation
+ var pronFactory = Cache.ServiceLocator.GetInstance();
+ var pronunciation = pronFactory.Create();
+ m_entry.PronunciationsOS.Add(pronunciation);
+ pronunciation.Form.VernacularDefaultWritingSystem = MakeRenderString(
+ $"Pronunciation - {testName}", Cache.DefaultVernWs);
+ }
+
+ #endregion
+
+ #region Verify Infrastructure
+
+ ///
+ /// Returns the directory containing this source file (resolved at compile time).
+ /// RenderSnapshotVerifier stores approved .verified.png baselines alongside the test source file.
+ ///
+ private async Task VerifyDataTreeBitmap(Bitmap bitmap, string scenarioId)
+ {
+ string directory = RenderSnapshotVerifier.GetSourceFileDirectory();
+ string name = $"DataTreeRenderTests.DataTreeRender_{scenarioId}";
+ var verification = RenderSnapshotVerifier.Verify(bitmap, directory, name, scenarioId);
+ if (!verification.Passed)
+ Assert.Fail(verification.FailureMessage);
+
+ await Task.CompletedTask;
+ }
+
+ #endregion
+
+ #region Snapshot Tests
+
+ ///
+ /// Verifies a production-like DataTree rendering for a simple lex entry with 3 senses.
+ /// Captures grey labels, WS indicators, sense summaries, all WinForms chrome.
+ /// Uses DistFiles layouts plus test-time exclusions for unsupported/crashy parts.
+ ///
+ [Test]
+ public async Task DataTreeRender_Simple()
+ {
+ CreateSimpleEntry();
+
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 768, true);
+ DumpSliceDiagnostics(harness, "Simple");
+
+ Assert.That(harness.SliceCount, Is.GreaterThan(0),
+ "DataTree should have populated some slices");
+
+ var bitmap = harness.CaptureCompositeBitmap();
+ Assert.That(bitmap, Is.Not.Null, "Composite bitmap capture should succeed");
+
+ double density = CalculateNonWhiteDensity(bitmap);
+ Console.WriteLine($"[DATATREE] Non-white density: {density:F2}%");
+ Console.WriteLine($"[DATATREE] Bitmap size: {bitmap.Width}x{bitmap.Height}");
+
+ RecordTiming("simple", 1, 3, harness.LastTiming, density);
+ await VerifyDataTreeBitmap(bitmap, "simple");
+ }
+ }
+
+ ///
+ /// Verifies the full DataTree rendering for a triple-nested lex entry.
+ /// 2 senses × 2 subsenses × 2 sub-sub-senses = 14 total senses.
+ /// This is the "slow" scenario for realistic deep nesting.
+ ///
+ [Test]
+ public async Task DataTreeRender_Deep()
+ {
+ CreateDeepEntry();
+
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 1200, true);
+ DumpSliceDiagnostics(harness, "Deep");
+
+ Assert.That(harness.SliceCount, Is.GreaterThan(0),
+ "DataTree should have populated some slices");
+
+ var bitmap = harness.CaptureCompositeBitmap();
+ Assert.That(bitmap, Is.Not.Null, "Composite bitmap capture should succeed");
+
+ double density = CalculateNonWhiteDensity(bitmap);
+ Console.WriteLine($"[DATATREE] Non-white density: {density:F2}%");
+ Console.WriteLine($"[DATATREE] Bitmap size: {bitmap.Width}x{bitmap.Height}");
+
+ RecordTiming("deep", 3, 2, harness.LastTiming, density);
+ await VerifyDataTreeBitmap(bitmap, "deep");
+ }
+ }
+
+ ///
+ /// Verifies a depth-4 lexeme edit tree can render recursive senses while hidden
+ /// entry fields are forced visible. The hidden-field layout is test-only, but the
+ /// render path is the real DataTree pipeline.
+ ///
+ [Test]
+ public async Task DataTreeRender_SubSubSubSenses_ShowHiddenFields()
+ {
+ CreateSubSubSubEntry();
+ const string layoutName = "NormalWithHiddenFieldsIndented";
+ var hiddenLabels = new[]
+ {
+ "Bibliography",
+ "Comment",
+ "Literal Meaning",
+ "Restrictions",
+ "Summary Definition"
+ };
+
+ using (var withoutHiddenFields = new DataTreeRenderHarness(Cache, m_entry, layoutName))
+ using (var withHiddenFields = new DataTreeRenderHarness(Cache, m_entry, layoutName, showHiddenFields: true))
+ {
+ withoutHiddenFields.PopulateSlices(1024, 2400, false);
+ withHiddenFields.PopulateSlices(1024, 2400, false);
+
+ var labelsWithoutHiddenFields = withoutHiddenFields.LastTiming.SliceDiagnostics
+ .Select(diag => diag.Label)
+ .Where(label => !string.IsNullOrEmpty(label))
+ .ToList();
+ var labelsWithHiddenFields = withHiddenFields.LastTiming.SliceDiagnostics
+ .Select(diag => diag.Label)
+ .Where(label => !string.IsNullOrEmpty(label))
+ .ToList();
+
+ foreach (var hiddenLabel in hiddenLabels)
+ {
+ Assert.That(labelsWithoutHiddenFields, Does.Not.Contain(hiddenLabel),
+ $"{hiddenLabel} should stay hidden when ShowHiddenFields is off.");
+ Assert.That(labelsWithHiddenFields, Does.Contain(hiddenLabel),
+ $"{hiddenLabel} should be rendered when ShowHiddenFields is enabled.");
+ }
+
+ Assert.That(withHiddenFields.SliceCount, Is.GreaterThan(withoutHiddenFields.SliceCount),
+ "Enabling hidden fields should increase the number of rendered slices.");
+
+ int glossSliceCount = labelsWithHiddenFields.Count(label => label == "Gloss");
+ int scientificNameSliceCount = labelsWithHiddenFields.Count(label => label == "ScientificName");
+ Assert.That(glossSliceCount, Is.GreaterThanOrEqualTo(30),
+ $"Expected at least 30 gloss slices for the depth-4 sense tree, but saw {glossSliceCount}.");
+ Assert.That(scientificNameSliceCount, Is.GreaterThanOrEqualTo(30),
+ $"Expected at least 30 scientific-name slices for the depth-4 sense tree, but saw {scientificNameSliceCount}.");
+
+ int maxIndent = withHiddenFields.DataTree.Slices.Cast().Max(slice => slice.Indent);
+ Assert.That(maxIndent, Is.GreaterThanOrEqualTo(3),
+ $"Expected nested subsenses to create at least 3 levels of indentation, but saw {maxIndent}.");
+
+ // Warm the composite capture once so deep hidden-field trees snapshot after layout convergence.
+ var warmup = withHiddenFields.CaptureCompositeBitmap();
+ Assert.That(warmup, Is.Not.Null, "Warm-up capture should succeed with hidden fields enabled.");
+ warmup.Dispose();
+
+ var bitmap = withHiddenFields.CaptureCompositeBitmap();
+ Assert.That(bitmap, Is.Not.Null, "Composite bitmap capture should succeed with hidden fields enabled.");
+ double density = CalculateNonWhiteDensity(bitmap);
+ RecordTiming("subsubsub-hidden", 4, 2, withHiddenFields.LastTiming, density);
+ await VerifyDataTreeBitmap(bitmap, "subsubsub-hidden");
+ bitmap.Dispose();
+ }
+ }
+
+ ///
+ /// Verifies a production-like lexeme edit snapshot for the depth-4 hidden-fields scenario.
+ /// This keeps the focused hidden-field regression separate while adding top-matter coverage
+ /// closer to the real lexeme edit view.
+ ///
+ [Test]
+ public async Task DataTreeRender_SubSubSubSenses_ShowHiddenFields_ProductionLike()
+ {
+ CreateSubSubSubEntry();
+ const string layoutName = "ProductionLikeWithHiddenFieldsIndented";
+
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, layoutName, showHiddenFields: true))
+ {
+ harness.PopulateSlices(1024, 2600, false);
+
+ var labels = harness.LastTiming.SliceDiagnostics
+ .Select(diag => diag.Label)
+ .Where(label => !string.IsNullOrEmpty(label))
+ .ToList();
+
+ Assert.That(labels, Does.Contain("Lexeme Form"),
+ "Production-like scenario should include the lexeme form top matter.");
+ Assert.That(labels, Does.Contain("Citation Form"),
+ "Production-like scenario should include the citation form top matter.");
+ Assert.That(labels, Does.Contain("Pronunciation"),
+ "Production-like scenario should include pronunciation top matter.");
+ Assert.That(labels, Does.Contain("Bibliography"));
+ Assert.That(labels, Does.Contain("Comment"));
+ Assert.That(labels, Does.Contain("Literal Meaning"));
+ Assert.That(labels, Does.Contain("Restrictions"));
+ Assert.That(labels, Does.Contain("Summary Definition"));
+
+ int maxIndent = harness.DataTree.Slices.Cast().Max(slice => slice.Indent);
+ Assert.That(maxIndent, Is.GreaterThanOrEqualTo(3),
+ $"Expected nested subsenses to create at least 3 levels of indentation, but saw {maxIndent}.");
+
+ // Warm the composite capture once so deep hidden-field trees snapshot after layout convergence.
+ var warmup = harness.CaptureCompositeBitmap();
+ Assert.That(warmup, Is.Not.Null, "Warm-up capture should succeed for the production-like layout.");
+ warmup.Dispose();
+
+ var bitmap = harness.CaptureCompositeBitmap();
+ Assert.That(bitmap, Is.Not.Null, "Composite bitmap capture should succeed for the production-like layout.");
+
+ double density = CalculateNonWhiteDensity(bitmap);
+ RecordTiming("subsubsub-hidden-productionlike", 4, 2, harness.LastTiming, density);
+ await VerifyDataTreeBitmap(bitmap, "subsubsub-hidden-productionlike");
+ bitmap.Dispose();
+ }
+ }
+
+ [Test]
+ public void DataTreeRender_CaptureUsesRequestedWidth_WhenClientWidthDrifts()
+ {
+ CreateSimpleEntry();
+
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 768, false);
+ harness.DataTree.ClientSize = new Size(1007, harness.DataTree.ClientSize.Height);
+ harness.DataTree.PerformLayout();
+
+ var bitmap = harness.CaptureCompositeBitmap();
+ Assert.That(bitmap, Is.Not.Null, "Composite bitmap capture should succeed when the live client width drifts.");
+ Assert.That(bitmap.Width, Is.EqualTo(1024),
+ "Capture should use the requested host width instead of the current DataTree client width.");
+ bitmap.Dispose();
+ }
+ }
+
+ ///
+ /// Verifies the full DataTree rendering for an extreme nesting scenario.
+ /// 6-level nesting with 126 senses exercises the full slice rendering pipeline.
+ ///
+ [Test]
+ public async Task DataTreeRender_Extreme()
+ {
+ CreateExtremeEntry();
+
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 2400, true);
+ DumpSliceDiagnostics(harness, "Extreme");
+
+ Assert.That(harness.SliceCount, Is.GreaterThan(0),
+ "DataTree should have populated some slices");
+
+ var bitmap = harness.CaptureCompositeBitmap();
+ Assert.That(bitmap, Is.Not.Null, "Composite bitmap capture should succeed");
+
+ double density = CalculateNonWhiteDensity(bitmap);
+ Console.WriteLine($"[DATATREE] Non-white density: {density:F2}%");
+ Console.WriteLine($"[DATATREE] Bitmap size: {bitmap.Width}x{bitmap.Height}");
+
+ RecordTiming("extreme", 6, 2, harness.LastTiming, density);
+ await VerifyDataTreeBitmap(bitmap, "extreme");
+ }
+ }
+
+ ///
+ /// Verifies the DataTree rendering for a minimal entry with a single sense.
+ /// Exercises the bare minimum rendering path — collapsed view.
+ ///
+ [Test]
+ public async Task DataTreeRender_Collapsed()
+ {
+ CreateCollapsedEntry();
+
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 400, true);
+ DumpSliceDiagnostics(harness, "Collapsed");
+
+ Assert.That(harness.SliceCount, Is.GreaterThan(0),
+ "DataTree should have populated some slices");
+
+ var bitmap = harness.CaptureCompositeBitmap();
+ Assert.That(bitmap, Is.Not.Null, "Composite bitmap capture should succeed");
+
+ double density = CalculateNonWhiteDensity(bitmap);
+ Console.WriteLine($"[DATATREE] Non-white density: {density:F2}%");
+ Console.WriteLine($"[DATATREE] Bitmap size: {bitmap.Width}x{bitmap.Height}");
+
+ RecordTiming("collapsed", 1, 1, harness.LastTiming, density);
+ await VerifyDataTreeBitmap(bitmap, "collapsed");
+ }
+ }
+
+ ///
+ /// Verifies the DataTree rendering for a fully enriched entry with all optional
+ /// fields populated. Maximum slice count with multiple pronunciations,
+ /// scientific names, and extended definitions.
+ ///
+ [Test]
+ public async Task DataTreeRender_Expanded()
+ {
+ CreateExpandedEntry();
+
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 1200, true);
+ DumpSliceDiagnostics(harness, "Expanded");
+
+ Assert.That(harness.SliceCount, Is.GreaterThan(0),
+ "DataTree should have populated some slices");
+
+ var bitmap = harness.CaptureCompositeBitmap();
+ Assert.That(bitmap, Is.Not.Null, "Composite bitmap capture should succeed");
+
+ double density = CalculateNonWhiteDensity(bitmap);
+ Console.WriteLine($"[DATATREE] Non-white density: {density:F2}%");
+ Console.WriteLine($"[DATATREE] Bitmap size: {bitmap.Width}x{bitmap.Height}");
+
+ RecordTiming("expanded", 1, 4, harness.LastTiming, density);
+ await VerifyDataTreeBitmap(bitmap, "expanded");
+ }
+ }
+
+ ///
+ /// Verifies the DataTree rendering for an entry with multiple writing systems.
+ /// Exercises MultiStringSlice WS indicators and font fallback across French and English.
+ ///
+ [Test]
+ public async Task DataTreeRender_MultiWs()
+ {
+ CreateMultiWsEntry();
+
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 768, true);
+ DumpSliceDiagnostics(harness, "MultiWs");
+
+ Assert.That(harness.SliceCount, Is.GreaterThan(0),
+ "DataTree should have populated some slices");
+
+ var bitmap = harness.CaptureCompositeBitmap();
+ Assert.That(bitmap, Is.Not.Null, "Composite bitmap capture should succeed");
+
+ double density = CalculateNonWhiteDensity(bitmap);
+ Console.WriteLine($"[DATATREE] Non-white density: {density:F2}%");
+ Console.WriteLine($"[DATATREE] Bitmap size: {bitmap.Width}x{bitmap.Height}");
+
+ RecordTiming("multiws", 1, 2, harness.LastTiming, density);
+ await VerifyDataTreeBitmap(bitmap, "multiws");
+ }
+ }
+
+ #endregion
+
+ #region Timing Tests
+
+ ///
+ /// Benchmarks DataTree population time at varying nesting depths.
+ /// Reports the exponential growth in slice creation and rendering time.
+ /// Writes timing results to Output/RenderBenchmarks/datatree-timings.json.
+ ///
+ [Test]
+ [TestCase(2, 3, "shallow", Description = "Depth 2, breadth 3 = 12 senses")]
+ [TestCase(3, 2, "deep", Description = "Depth 3, breadth 2 = 14 senses (triple-nested)")]
+ [TestCase(6, 2, "extreme", Description = "Depth 6, breadth 2 = 126 senses")]
+ public void DataTreeTiming(int depth, int breadth, string label)
+ {
+ m_entry = Cache.ServiceLocator.GetInstance().Create();
+ var morphFactory = Cache.ServiceLocator.GetInstance();
+ var morph = morphFactory.Create();
+ m_entry.LexemeFormOA = morph;
+ morph.Form.VernacularDefaultWritingSystem = MakeRenderString(
+ $"LexemeForm - timing-{label}", Cache.DefaultVernWs);
+
+ var senseFactory = Cache.ServiceLocator.GetInstance();
+ CreateNestedSenses(m_entry, senseFactory, depth, breadth, "", 1, $"timing-{label}");
+ EnrichEntry(m_entry, $"timing-{label}");
+
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ // Use test inventories for timing to keep recursive sense growth active
+ // while avoiding known production-layout crash-only parts in test context.
+ harness.PopulateSlices(1024, 2400, false);
+
+ Console.WriteLine($"[DATATREE-TIMING] {label}: " +
+ $"Init={harness.LastTiming.InitializationMs:F1}ms, " +
+ $"Populate={harness.LastTiming.PopulateSlicesMs:F1}ms, " +
+ $"Total={harness.LastTiming.TotalMs:F1}ms, " +
+ $"Slices={harness.LastTiming.SliceCount}");
+
+ Assert.That(harness.SliceCount, Is.GreaterThan(0),
+ $"DataTree should create slices for {label} scenario");
+
+ // Capture bitmap to exercise the full pipeline
+ var bitmap = harness.CaptureCompositeBitmap();
+ Assert.That(bitmap, Is.Not.Null, "Composite capture should succeed");
+
+ double density = CalculateNonWhiteDensity(bitmap);
+ Console.WriteLine($"[DATATREE-TIMING] {label}: Non-white density={density:F2}%");
+
+ // Record timing to file
+ RecordTiming($"timing-{label}", depth, breadth, harness.LastTiming, density);
+ }
+ }
+
+ ///
+ /// Measures paint/capture time for the extreme scenario.
+ /// Exercises the full OnPaint → HandlePaintLinesBetweenSlices pipeline
+ /// via DrawToBitmap. This provides a baseline for paint optimizations
+ /// (clip-rect culling, double-buffering).
+ ///
+ [Test]
+ public void DataTreeTiming_PaintPerformance()
+ {
+ CreateExtremeEntry();
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 2400, false);
+ Assert.That(harness.SliceCount, Is.GreaterThan(0),
+ "DataTree should create slices for extreme scenario");
+
+ // Warm-up capture (first paint triggers handle creation, layout convergence, etc.)
+ var warmup = harness.CaptureCompositeBitmap();
+ Assert.That(warmup, Is.Not.Null, "Warm-up capture should succeed");
+ warmup.Dispose();
+
+ // Timed capture: DrawToBitmap → OnPaint → HandlePaintLinesBetweenSlices
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ var bitmap = harness.CaptureCompositeBitmap();
+ sw.Stop();
+
+ Assert.That(bitmap, Is.Not.Null, "Timed capture should succeed");
+ double captureMs = sw.Elapsed.TotalMilliseconds;
+ Console.WriteLine($"[PAINT-TIMING] Extreme scenario capture: {captureMs:F1}ms");
+ Console.WriteLine($"[PAINT-TIMING] Slices: {harness.SliceCount}, Bitmap: {bitmap.Width}x{bitmap.Height}");
+
+ RecordTiming("paint-extreme", 6, 2, harness.LastTiming,
+ CalculateNonWhiteDensity(bitmap));
+ bitmap.Dispose();
+ }
+ }
+
+ ///
+ /// Verifies benchmark workload grows with scenario complexity.
+ /// This guards against timing tests that accidentally stop exercising deeper data.
+ ///
+ [Test]
+ public void DataTreeTiming_WorkloadGrowsWithComplexity()
+ {
+ int shallowSlices = RunTimingScenarioAndGetSliceCount(2, 3, "growth-shallow");
+ int deepSlices = RunTimingScenarioAndGetSliceCount(3, 2, "growth-deep");
+ int extremeSlices = RunTimingScenarioAndGetSliceCount(6, 2, "growth-extreme");
+
+ Assert.That(deepSlices, Is.GreaterThan(shallowSlices),
+ $"Expected deep workload to exceed shallow workload, but got shallow={shallowSlices}, deep={deepSlices}");
+ Assert.That(extremeSlices, Is.GreaterThan(deepSlices),
+ $"Expected extreme workload to exceed deep workload, but got deep={deepSlices}, extreme={extremeSlices}");
+ }
+
+ [Test]
+ public void DataTreeTimingBaselines_CoverAllSnapshotScenarios()
+ {
+ DataTreeTimingBaselineCatalog.AssertSnapshotCoverage();
+ }
+
+ #endregion
+
+ #region Optimization Regression Tests
+
+ ///
+ /// Verifies that all visible slices in the viewport have consistent width after
+ /// layout converges. Exercises Enhancement 5 (SetWidthForDataTreeLayout early-exit):
+ /// after the first layout pass sets widths, subsequent passes should be no-ops.
+ ///
+ [Test]
+ public void DataTreeOpt_WidthStabilityAfterLayout()
+ {
+ CreateDeepEntry();
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 2400, false);
+ Assert.That(harness.SliceCount, Is.GreaterThan(0),
+ "Should have slices");
+
+ // Record widths after initial layout convergence
+ var dt = harness.DataTree;
+ int desiredWidth = dt.ClientRectangle.Width;
+ var initialWidths = new int[dt.Slices.Count];
+ for (int i = 0; i < dt.Slices.Count; i++)
+ initialWidths[i] = ((Slice)dt.Slices[i]).Width;
+
+ // Force a second paint/layout pass — widths should remain identical
+ var bitmap = harness.CaptureCompositeBitmap();
+ Assert.That(bitmap, Is.Not.Null, "Second paint should succeed");
+ bitmap.Dispose();
+
+ for (int i = 0; i < dt.Slices.Count; i++)
+ {
+ int currentWidth = ((Slice)dt.Slices[i]).Width;
+ Assert.That(currentWidth, Is.EqualTo(initialWidths[i]),
+ $"Slice [{i}] width changed after second layout pass " +
+ $"(was {initialWidths[i]}, now {currentWidth}). " +
+ $"Enhancement 5 early-exit should prevent width changes when stable.");
+ }
+
+ Console.WriteLine($"[OPT-TEST] Width stability: {dt.Slices.Count} slices " +
+ $"all stable at width={desiredWidth}");
+ }
+ }
+
+ ///
+ /// Verifies that all slices in the viewport are marked Visible=true after layout.
+ /// Exercises Enhancement 9 (MakeSliceVisible high-water mark): the optimization
+ /// must not skip making any slice visible that should be visible.
+ ///
+ [Test]
+ public void DataTreeOpt_AllViewportSlicesVisible()
+ {
+ CreateExtremeEntry();
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ // Use a viewport smaller than total content to exercise partial visibility
+ harness.PopulateSlices(1024, 800, false);
+ Assert.That(harness.SliceCount, Is.GreaterThan(0),
+ "Should have slices");
+
+ // Force layout + paint to trigger MakeSliceVisible calls
+ var bitmap = harness.CaptureCompositeBitmap();
+ Assert.That(bitmap, Is.Not.Null, "Paint should succeed");
+ bitmap.Dispose();
+
+ var dt = harness.DataTree;
+ var diagnostics = harness.LastTiming.SliceDiagnostics;
+
+ // Every slice that is within the viewport bounds should be Visible
+ int viewportBottom = dt.ClientRectangle.Height;
+ int visibleCount = 0;
+ int totalCount = 0;
+ for (int i = 0; i < dt.Slices.Count; i++)
+ {
+ var slice = (Slice)dt.Slices[i];
+ totalCount++;
+ int sliceTop = slice.Top;
+ int sliceBottom = sliceTop + slice.Height;
+
+ // Slice intersects viewport
+ if (sliceBottom > 0 && sliceTop < viewportBottom)
+ {
+ Assert.That(slice.Visible, Is.True,
+ $"Slice [{i}] ({slice.GetType().Name}, Label=\"{slice.Label}\") " +
+ $"at Y={sliceTop}-{sliceBottom} is in viewport (0-{viewportBottom}) " +
+ $"but Visible=false. Enhancement 9 high-water mark may have skipped it.");
+ visibleCount++;
+ }
+ }
+
+ Assert.That(visibleCount, Is.GreaterThan(0),
+ "At least one slice should be visible in the viewport");
+ Console.WriteLine($"[OPT-TEST] Visibility: {visibleCount}/{totalCount} slices " +
+ $"visible in viewport 0-{viewportBottom}");
+ }
+ }
+
+ ///
+ /// Verifies that the XML attribute cache on Slice returns the same results as
+ /// direct XML parsing. Exercises Enhancement 8 (cached IsHeader/SkipSpacerLine/SameObject):
+ /// the cache must be correct and must be invalidated when ConfigurationNode changes.
+ ///
+ [Test]
+ public void DataTreeOpt_XmlCacheConsistency()
+ {
+ CreateSimpleEntry();
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 1200, false);
+ Assert.That(harness.SliceCount, Is.GreaterThan(0),
+ "Should have slices");
+
+ var dt = harness.DataTree;
+ int checkedCount = 0;
+
+ for (int i = 0; i < dt.Slices.Count; i++)
+ {
+ var slice = (Slice)dt.Slices[i];
+ if (slice.ConfigurationNode == null)
+ continue;
+
+ // Get the cached property values
+ bool cachedIsHeader = slice.IsHeader;
+ bool cachedSkipSpacer = slice.SkipSpacerLine;
+ bool cachedSameObj = slice.SameObject;
+
+ // Get the ground truth from direct XML parsing
+ bool directIsHeader = SIL.Utils.XmlUtils.GetOptionalBooleanAttributeValue(
+ slice.ConfigurationNode, "header", false);
+ bool directSkipSpacer = SIL.Utils.XmlUtils.GetOptionalBooleanAttributeValue(
+ slice.ConfigurationNode, "skipSpacerLine", false);
+ bool directSameObj = SIL.Utils.XmlUtils.GetOptionalBooleanAttributeValue(
+ slice.ConfigurationNode, "sameObject", false);
+
+ Assert.That(cachedIsHeader, Is.EqualTo(directIsHeader),
+ $"Slice [{i}] IsHeader cache mismatch: cached={cachedIsHeader}, " +
+ $"XML={directIsHeader}");
+ Assert.That(cachedSkipSpacer, Is.EqualTo(directSkipSpacer),
+ $"Slice [{i}] SkipSpacerLine cache mismatch: cached={cachedSkipSpacer}, " +
+ $"XML={directSkipSpacer}");
+ Assert.That(cachedSameObj, Is.EqualTo(directSameObj),
+ $"Slice [{i}] SameObject cache mismatch: cached={cachedSameObj}, " +
+ $"XML={directSameObj}");
+
+ checkedCount++;
+ }
+
+ Assert.That(checkedCount, Is.GreaterThan(0),
+ "Should have checked at least one slice's XML cache");
+ Console.WriteLine($"[OPT-TEST] XML cache consistency: {checkedCount} slices verified");
+ }
+ }
+
+ ///
+ /// Verifies that ConfigurationNode setter invalidates the XML attribute cache.
+ /// Re-setting the same ConfigurationNode should still produce correct results
+ /// (cache re-populated from XML). Exercises Enhancement 8 cache invalidation.
+ ///
+ [Test]
+ public void DataTreeOpt_XmlCacheInvalidationOnConfigChange()
+ {
+ CreateSimpleEntry();
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 1200, false);
+ Assert.That(harness.SliceCount, Is.GreaterThan(0),
+ "Should have slices");
+
+ var dt = harness.DataTree;
+
+ // Find a slice with a ConfigurationNode to test cache invalidation
+ Slice testSlice = null;
+ for (int i = 0; i < dt.Slices.Count; i++)
+ {
+ var slice = (Slice)dt.Slices[i];
+ if (slice.ConfigurationNode != null)
+ {
+ testSlice = slice;
+ break;
+ }
+ }
+ Assert.That(testSlice, Is.Not.Null,
+ "Should find at least one slice with a ConfigurationNode");
+
+ // Prime the cache by accessing each property
+ bool originalIsHeader = testSlice.IsHeader;
+ bool originalSkipSpacer = testSlice.SkipSpacerLine;
+ bool originalSameObj = testSlice.SameObject;
+
+ // Re-set the ConfigurationNode (triggers cache invalidation)
+ var savedNode = testSlice.ConfigurationNode;
+ testSlice.ConfigurationNode = savedNode;
+
+ // Cache should be re-populated with same values
+ Assert.That(testSlice.IsHeader, Is.EqualTo(originalIsHeader),
+ "IsHeader should match after ConfigurationNode reset");
+ Assert.That(testSlice.SkipSpacerLine, Is.EqualTo(originalSkipSpacer),
+ "SkipSpacerLine should match after ConfigurationNode reset");
+ Assert.That(testSlice.SameObject, Is.EqualTo(originalSameObj),
+ "SameObject should match after ConfigurationNode reset");
+
+ Console.WriteLine($"[OPT-TEST] Cache invalidation: verified on slice " +
+ $"'{testSlice.Label}' ({testSlice.GetType().Name})");
+ }
+ }
+
+ ///
+ /// Verifies that multiple sequential paint captures produce identical output.
+ /// Exercises all paint-path optimizations (Enhancement 3 clip-rect culling,
+ /// Enhancement 4 double-buffering, Enhancement 7 paint-path width skip,
+ /// Enhancement 8 XML caching): repeated paints must be deterministic.
+ ///
+ [Test]
+ public void DataTreeOpt_SequentialPaintsProduceIdenticalOutput()
+ {
+ CreateDeepEntry();
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 1200, false);
+ Assert.That(harness.SliceCount, Is.GreaterThan(0),
+ "Should have slices");
+
+ // Warm-up to get past initial layout convergence
+ var warmup = harness.CaptureCompositeBitmap();
+ Assert.That(warmup, Is.Not.Null);
+ warmup.Dispose();
+
+ // Capture twice
+ var capture1 = harness.CaptureCompositeBitmap();
+ var capture2 = harness.CaptureCompositeBitmap();
+
+ Assert.That(capture1, Is.Not.Null, "First capture should succeed");
+ Assert.That(capture2, Is.Not.Null, "Second capture should succeed");
+
+ Assert.That(capture2.Width, Is.EqualTo(capture1.Width),
+ "Bitmap widths should match");
+ Assert.That(capture2.Height, Is.EqualTo(capture1.Height),
+ "Bitmap heights should match");
+
+ // Compare pixel-by-pixel — paint must be deterministic
+ int mismatchCount = 0;
+ for (int y = 0; y < capture1.Height; y++)
+ {
+ for (int x = 0; x < capture1.Width; x++)
+ {
+ if (capture1.GetPixel(x, y) != capture2.GetPixel(x, y))
+ mismatchCount++;
+ }
+ }
+
+ double mismatchRate = (double)mismatchCount / (capture1.Width * capture1.Height) * 100;
+ Assert.That(mismatchRate, Is.LessThan(0.1),
+ $"Sequential paints differ in {mismatchCount} pixels ({mismatchRate:F3}%). " +
+ $"Paint optimizations must produce deterministic output.");
+
+ Console.WriteLine($"[OPT-TEST] Paint determinism: {capture1.Width}x{capture1.Height}, " +
+ $"mismatch={mismatchCount} ({mismatchRate:F3}%)");
+
+ capture1.Dispose();
+ capture2.Dispose();
+ }
+ }
+
+ ///
+ /// Verifies that slice positions are monotonically increasing (each slice's Top
+ /// is >= previous slice's Top + Height). This is a prerequisite for Enhancement 3's
+ /// clip-rect culling early-break optimization in HandlePaintLinesBetweenSlices.
+ ///
+ [Test]
+ public void DataTreeOpt_SlicePositionsMonotonicallyIncreasing()
+ {
+ CreateExtremeEntry();
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 2400, false);
+ Assert.That(harness.SliceCount, Is.GreaterThan(1),
+ "Should have multiple slices to test ordering");
+
+ var dt = harness.DataTree;
+ int previousBottom = int.MinValue;
+ int checkedCount = 0;
+
+ for (int i = 0; i < dt.Slices.Count; i++)
+ {
+ var slice = (Slice)dt.Slices[i];
+ int sliceTop = slice.Top;
+
+ if (i > 0)
+ {
+ Assert.That(sliceTop, Is.GreaterThanOrEqualTo(previousBottom),
+ $"Slice [{i}] ({slice.GetType().Name}, Label=\"{slice.Label}\") " +
+ $"Top={sliceTop} overlaps previous slice bottom={previousBottom}. " +
+ $"Monotonic ordering required for clip-rect culling (Enhancement 3).");
+ }
+
+ previousBottom = sliceTop + slice.Height;
+ checkedCount++;
+ }
+
+ Console.WriteLine($"[OPT-TEST] Monotonic ordering: {checkedCount} slices verified");
+ }
+ }
+
+ ///
+ /// Verifies IsHeaderNode delegates correctly to the cached IsHeader property.
+ /// The public IsHeaderNode property should always agree with the internal
+ /// cached IsHeader value. Exercises Enhancement 8 delegation.
+ ///
+ [Test]
+ public void DataTreeOpt_IsHeaderNodeDelegatesToCachedProperty()
+ {
+ CreateSimpleEntry();
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 1200, false);
+ Assert.That(harness.SliceCount, Is.GreaterThan(0),
+ "Should have slices");
+
+ var dt = harness.DataTree;
+ int checkedCount = 0;
+
+ for (int i = 0; i < dt.Slices.Count; i++)
+ {
+ var slice = (Slice)dt.Slices[i];
+ if (slice.ConfigurationNode == null)
+ continue;
+
+ // IsHeaderNode (public) should delegate to IsHeader (internal cached)
+ Assert.That(slice.IsHeaderNode, Is.EqualTo(slice.IsHeader),
+ $"Slice [{i}] IsHeaderNode/IsHeader mismatch");
+ checkedCount++;
+ }
+
+ Assert.That(checkedCount, Is.GreaterThan(0),
+ "Should have checked at least one slice");
+ Console.WriteLine($"[OPT-TEST] IsHeaderNode delegation: {checkedCount} slices verified");
+ }
+ }
+
+ ///
+ /// Verifies that slice positions set by the full layout pass (fFull=true,
+ /// called from OnLayout) agree with the accumulated yTop values computed by
+ /// the paint path (fFull=false, called from OnPaint). This is the core safety
+ /// invariant for a future binary-search optimization: if the paint path skips
+ /// iterating above-viewport slices, the positions it uses must match what
+ /// the full layout established.
+ /// Failure mode: yTop accumulator drift when the paint path doesn't walk all slices.
+ ///
+ [Test]
+ public void DataTreeOpt_FullLayoutAndPaintPathPositionsAgree()
+ {
+ CreateExtremeEntry();
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 2400, false);
+ Assert.That(harness.SliceCount, Is.GreaterThan(0), "Should have slices");
+
+ var dt = harness.DataTree;
+
+ // Record positions after full layout (set by OnLayout → HandleLayout1(fFull=true))
+ var fullLayoutPositions = new int[dt.Slices.Count];
+ var fullLayoutHeights = new int[dt.Slices.Count];
+ for (int i = 0; i < dt.Slices.Count; i++)
+ {
+ var slice = (Slice)dt.Slices[i];
+ fullLayoutPositions[i] = slice.Top;
+ fullLayoutHeights[i] = slice.Height;
+ }
+
+ // Force another paint-path layout by invalidating and pumping
+ dt.Invalidate();
+ System.Windows.Forms.Application.DoEvents();
+
+ // Verify positions haven't drifted
+ int checkedCount = 0;
+ for (int i = 0; i < dt.Slices.Count; i++)
+ {
+ var slice = (Slice)dt.Slices[i];
+ Assert.That(slice.Top, Is.EqualTo(fullLayoutPositions[i]),
+ $"Slice [{i}] ({slice.GetType().Name}, Label=\"{slice.Label}\") " +
+ $"Top drifted from {fullLayoutPositions[i]} to {slice.Top} " +
+ $"after paint-path layout. Full→paint position agreement broken.");
+ checkedCount++;
+ }
+
+ Console.WriteLine($"[OPT-TEST] Position agreement: {checkedCount} slices verified");
+ }
+ }
+
+ ///
+ /// Verifies that AutoScrollPosition does not drift across multiple paint passes.
+ /// The paint path adjusts scroll position when slices above the viewport change
+ /// height (e.g., DummyObjectSlice → real slice). After initial convergence,
+ /// scroll position must be stable.
+ /// Failure mode: binary search skips the desiredScrollPosition adjustment for
+ /// above-viewport slices, causing scroll jumps.
+ ///
+ [Test]
+ public void DataTreeOpt_ScrollPositionStableAcrossPaints()
+ {
+ CreateExtremeEntry();
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ // Use a small viewport so most slices are below the fold
+ harness.PopulateSlices(1024, 400, false);
+ Assert.That(harness.SliceCount, Is.GreaterThan(0), "Should have slices");
+
+ var dt = harness.DataTree;
+
+ // Warm up — first paint triggers layout convergence
+ var warmup = harness.CaptureCompositeBitmap();
+ Assert.That(warmup, Is.Not.Null);
+ warmup.Dispose();
+
+ // Record scroll position after convergence
+ var scrollAfterConvergence = dt.AutoScrollPosition;
+
+ // Force multiple paint passes
+ for (int pass = 0; pass < 3; pass++)
+ {
+ dt.Invalidate();
+ System.Windows.Forms.Application.DoEvents();
+ }
+
+ Assert.That(dt.AutoScrollPosition, Is.EqualTo(scrollAfterConvergence),
+ $"AutoScrollPosition drifted from {scrollAfterConvergence} to " +
+ $"{dt.AutoScrollPosition} after 3 additional paint passes. " +
+ $"Scroll stability is essential for binary-search correctness.");
+
+ Console.WriteLine($"[OPT-TEST] Scroll stability: position={dt.AutoScrollPosition} " +
+ $"stable across 3 additional paints");
+ }
+ }
+
+ ///
+ /// Verifies that all slices at or before the last visible slice are also visible.
+ /// The .NET Framework has a bug (LT-7307) where making a slice visible when
+ /// prior slices are invisible causes index corruption. The MakeSliceVisible
+ /// method guarantees all preceding slices are visible before making the target
+ /// visible. A binary search that starts mid-list must preserve this invariant.
+ /// Failure mode: binary search skips MakeSliceVisible for slices 0..N-1, leaving
+ /// gaps in the visibility sequence.
+ ///
+ [Test]
+ public void DataTreeOpt_VisibilitySequenceHasNoGaps()
+ {
+ CreateExtremeEntry();
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 800, false);
+ Assert.That(harness.SliceCount, Is.GreaterThan(0), "Should have slices");
+
+ // Force paint to trigger MakeSliceVisible
+ var bitmap = harness.CaptureCompositeBitmap();
+ Assert.That(bitmap, Is.Not.Null);
+ bitmap.Dispose();
+
+ var dt = harness.DataTree;
+
+ // Find the highest-index visible slice
+ int highestVisibleIndex = -1;
+ for (int i = 0; i < dt.Slices.Count; i++)
+ {
+ if (((Slice)dt.Slices[i]).Visible)
+ highestVisibleIndex = i;
+ }
+
+ Assert.That(highestVisibleIndex, Is.GreaterThan(0),
+ "Should have multiple visible slices");
+
+ // All slices 0..highestVisibleIndex must be visible (no gaps)
+ int gapCount = 0;
+ for (int i = 0; i <= highestVisibleIndex; i++)
+ {
+ var slice = (Slice)dt.Slices[i];
+ if (!slice.Visible)
+ {
+ gapCount++;
+ Console.WriteLine($"[OPT-TEST] Visibility gap at [{i}] " +
+ $"({slice.GetType().Name}, Label=\"{slice.Label}\")");
+ }
+ }
+
+ Assert.That(gapCount, Is.EqualTo(0),
+ $"Found {gapCount} invisible slices before the last visible slice " +
+ $"(index {highestVisibleIndex}). LT-7307: all preceding slices must " +
+ $"be visible to prevent index corruption.");
+
+ Console.WriteLine($"[OPT-TEST] Visibility sequence: no gaps in 0..{highestVisibleIndex}");
+ }
+ }
+
+ ///
+ /// Removing the last visible slice shifts the first invisible off-screen slice into the
+ /// visible prefix. MakeSliceVisible must rebuild that prefix from the start on the next call;
+ /// otherwise LT-7307 can be violated by skipping the shifted slice.
+ ///
+ [Test]
+ public void DataTreeOpt_RemoveVisibleSliceInvalidatesHighWaterMark()
+ {
+ CreateExtremeEntry();
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 800, false);
+ Assert.That(harness.SliceCount, Is.GreaterThan(0), "Should have slices");
+
+ var dt = harness.DataTree;
+ const int cachedPrefixEnd = 4;
+ Assert.That(dt.Slices.Count, Is.GreaterThan(cachedPrefixEnd + 2),
+ "Need enough slices to remove one cached-visible slice and then show a later one");
+
+ for (int i = 0; i < dt.Slices.Count; i++)
+ {
+ ((Slice)dt.Slices[i]).Visible = i <= cachedPrefixEnd;
+ }
+
+ var highWaterMarkField = typeof(DataTree).GetField("m_lastVisibleHighWaterMark",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.That(highWaterMarkField, Is.Not.Null,
+ "Test must be able to seed the private visibility cache deterministically");
+ highWaterMarkField.SetValue(dt, cachedPrefixEnd);
+
+ dt.RemoveSliceAt(cachedPrefixEnd);
+
+ var shiftedSlice = (Slice)dt.Slices[cachedPrefixEnd];
+ Assert.That(shiftedSlice.Visible, Is.False,
+ "Removing the cached frontier should shift an off-screen slice into that slot");
+
+ int targetIndex = cachedPrefixEnd + 1;
+ Assert.That(((Slice)dt.Slices[targetIndex]).Visible, Is.False,
+ "The later target slice should begin off-screen for this regression test");
+
+ dt.MakeSliceVisible((Slice)dt.Slices[targetIndex], targetIndex);
+
+ Assert.That(((Slice)dt.Slices[cachedPrefixEnd]).Visible, Is.True,
+ "Removing a slice must invalidate the high-water mark so MakeSliceVisible repairs the shifted prefix");
+ Assert.That(((Slice)dt.Slices[targetIndex]).Visible, Is.True,
+ "The requested target slice should still be made visible");
+ }
+ }
+
+ ///
+ /// Verifies that no DummyObjectSlice remains in the viewport after paint.
+ /// The paint path must make all viewport slices real via FieldAt(). A binary
+ /// search that miscalculates which slices are in the viewport could leave
+ /// DummyObjectSlices un-expanded.
+ /// Failure mode: binary search starts too late, leaving slices at the top edge
+ /// of the viewport as dummies.
+ ///
+ [Test]
+ public void DataTreeOpt_NoDummySlicesInViewportAfterPaint()
+ {
+ CreateExtremeEntry();
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 800, false);
+ Assert.That(harness.SliceCount, Is.GreaterThan(0), "Should have slices");
+
+ // Force full paint cycle
+ var bitmap = harness.CaptureCompositeBitmap();
+ Assert.That(bitmap, Is.Not.Null);
+ bitmap.Dispose();
+
+ var dt = harness.DataTree;
+ int viewportHeight = dt.ClientRectangle.Height;
+ int dummyCount = 0;
+
+ for (int i = 0; i < dt.Slices.Count; i++)
+ {
+ var slice = (Slice)dt.Slices[i];
+ int sliceTop = slice.Top;
+ int sliceBottom = sliceTop + slice.Height;
+
+ // Slice intersects the viewport
+ if (sliceBottom > 0 && sliceTop < viewportHeight)
+ {
+ if (!slice.IsRealSlice)
+ {
+ dummyCount++;
+ Console.WriteLine($"[OPT-TEST] Dummy in viewport at [{i}]: " +
+ $"{slice.GetType().Name} Y={sliceTop}-{sliceBottom}");
+ }
+ }
+ }
+
+ Assert.That(dummyCount, Is.EqualTo(0),
+ $"Found {dummyCount} DummyObjectSlice(s) in viewport (0-{viewportHeight}). " +
+ $"Paint path must make all viewport slices real via FieldAt().");
+
+ Console.WriteLine($"[OPT-TEST] No dummies in viewport 0-{viewportHeight}");
+ }
+ }
+
+ ///
+ /// Verifies that slice heights are stable after layout convergence.
+ /// A binary search for the first visible slice depends on accumulated
+ /// heights being deterministic: if heights change between paint calls
+ /// (e.g., because DummyObjectSlice→real changes weren't finalized),
+ /// the binary search would compute wrong yTop offsets and skip or
+ /// double-show slices.
+ /// After the initial full-layout pass, heights should never change
+ /// on subsequent paint passes (since all viewport slices are already real).
+ /// Failure mode: binary search pre-computes yTop from stale heights,
+ /// causing slices to render at wrong positions.
+ ///
+ [Test]
+ public void DataTreeOpt_SliceHeightsStableAfterConvergence()
+ {
+ CreateExtremeEntry();
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 800, false);
+ Assert.That(harness.SliceCount, Is.GreaterThan(0), "Should have slices");
+
+ // Force full convergence — first paint makes everything real
+ var warmup = harness.CaptureCompositeBitmap();
+ Assert.That(warmup, Is.Not.Null);
+ warmup.Dispose();
+
+ var dt = harness.DataTree;
+
+ // Record converged heights
+ var convergedHeights = new int[dt.Slices.Count];
+ for (int i = 0; i < dt.Slices.Count; i++)
+ convergedHeights[i] = ((Slice)dt.Slices[i]).Height;
+
+ // Force 3 more paint cycles
+ for (int pass = 0; pass < 3; pass++)
+ {
+ dt.Invalidate();
+ System.Windows.Forms.Application.DoEvents();
+ }
+
+ // Verify heights haven't changed
+ int driftCount = 0;
+ for (int i = 0; i < dt.Slices.Count; i++)
+ {
+ var slice = (Slice)dt.Slices[i];
+ if (slice.Height != convergedHeights[i])
+ {
+ driftCount++;
+ Console.WriteLine($"[OPT-TEST] Height drift at [{i}] " +
+ $"({slice.GetType().Name}): {convergedHeights[i]} → {slice.Height}");
+ }
+ }
+
+ Assert.That(driftCount, Is.EqualTo(0),
+ $"{driftCount} slice(s) changed height after convergence. " +
+ $"Binary search requires stable heights to compute accurate yTop offsets.");
+
+ Console.WriteLine($"[OPT-TEST] Height stability: {dt.Slices.Count} slices " +
+ $"stable across 3 post-convergence paint passes");
+ }
+ }
+
+ #endregion
+
+ #region Helpers
+
+ ///
+ /// Dumps detailed slice diagnostic information to console output for debugging.
+ ///
+ private static void DumpSliceDiagnostics(DataTreeRenderHarness harness, string label)
+ {
+ Console.WriteLine($"[DATATREE] === {label} Diagnostics ===");
+ Console.WriteLine($"[DATATREE] Slice count: {harness.SliceCount}");
+ Console.WriteLine($"[DATATREE] Populate time: {harness.LastTiming.PopulateSlicesMs:F1}ms");
+ Console.WriteLine($"[DATATREE] Init time: {harness.LastTiming.InitializationMs:F1}ms");
+
+ // Summary by type
+ var typeCounts = harness.LastTiming.SliceDiagnostics
+ .GroupBy(d => d.TypeName)
+ .OrderByDescending(g => g.Count());
+ foreach (var group in typeCounts)
+ {
+ Console.WriteLine($"[DATATREE] {group.Key}: {group.Count()}");
+ }
+
+ // Individual slice details (first 30)
+ int limit = Math.Min(harness.LastTiming.SliceDiagnostics.Count, 30);
+ for (int i = 0; i < limit; i++)
+ {
+ var d = harness.LastTiming.SliceDiagnostics[i];
+ Console.WriteLine($"[DATATREE] [{d.Index}] {d.TypeName,-25} " +
+ $"Label=\"{d.Label}\" Visible={d.Visible} " +
+ $"Bounds={d.Bounds} HasRootBox={d.HasRootBox}");
+ }
+ if (harness.LastTiming.SliceDiagnostics.Count > 30)
+ {
+ Console.WriteLine($"[DATATREE] ... ({harness.LastTiming.SliceDiagnostics.Count - 30} more)");
+ }
+ }
+
+ private int RunTimingScenarioAndGetSliceCount(int depth, int breadth, string label)
+ {
+ m_entry = Cache.ServiceLocator.GetInstance().Create();
+ var morphFactory = Cache.ServiceLocator.GetInstance();
+ var morph = morphFactory.Create();
+ m_entry.LexemeFormOA = morph;
+ morph.Form.VernacularDefaultWritingSystem = MakeRenderString(
+ $"LexemeForm - timing-{label}", Cache.DefaultVernWs);
+
+ var senseFactory = Cache.ServiceLocator.GetInstance();
+ CreateNestedSenses(m_entry, senseFactory, depth, breadth, "", 1, $"timing-{label}");
+ EnrichEntry(m_entry, $"timing-{label}");
+
+ using (var harness = new DataTreeRenderHarness(Cache, m_entry, "Normal"))
+ {
+ harness.PopulateSlices(1024, 2400, false);
+ Assert.That(harness.SliceCount, Is.GreaterThan(0),
+ $"DataTree should create slices for growth scenario {label}");
+ Console.WriteLine($"[DATATREE-TIMING] growth check {label}: Slices={harness.SliceCount}");
+ return harness.SliceCount;
+ }
+ }
+
+ private static double CalculateNonWhiteDensity(Bitmap bitmap)
+ {
+ int nonWhiteCount = 0;
+ int totalPixels = bitmap.Width * bitmap.Height;
+
+ for (int y = 0; y < bitmap.Height; y++)
+ {
+ for (int x = 0; x < bitmap.Width; x++)
+ {
+ var pixel = bitmap.GetPixel(x, y);
+ if (pixel.R < 250 || pixel.G < 250 || pixel.B < 250)
+ nonWhiteCount++;
+ }
+ }
+
+ return totalPixels > 0 ? (nonWhiteCount * 100.0 / totalPixels) : 0.0;
+ }
+
+ ///
+ /// Records timing data for a scenario to Output/RenderBenchmarks/datatree-timings.json.
+ /// The file accumulates entries keyed by scenario name, updating on each run.
+ ///
+ private static void RecordTiming(string scenario, int depth, int breadth,
+ DataTreeTimingInfo timing, double density)
+ {
+ DataTreeTimingBaselineCatalog.AssertMatches(scenario, depth, breadth, timing, density);
+
+ string outputDir = Path.Combine(
+ AppDomain.CurrentDomain.BaseDirectory, "..", "..", "Output", "RenderBenchmarks");
+ if (!Directory.Exists(outputDir))
+ Directory.CreateDirectory(outputDir);
+
+ string filePath = Path.Combine(outputDir, "datatree-timings.json");
+
+ // Load existing data or start fresh
+ Dictionary allTimings;
+ if (File.Exists(filePath))
+ {
+ string existing = File.ReadAllText(filePath);
+ allTimings = JsonConvert.DeserializeObject>(existing)
+ ?? new Dictionary();
+ }
+ else
+ {
+ allTimings = new Dictionary();
+ }
+
+ // Update entry for this scenario
+ allTimings[scenario] = new
+ {
+ depth,
+ breadth,
+ slices = timing.SliceCount,
+ initMs = Math.Round(timing.InitializationMs, 1),
+ populateMs = Math.Round(timing.PopulateSlicesMs, 1),
+ totalMs = Math.Round(timing.TotalMs, 1),
+ density = Math.Round(density, 2),
+ timestamp = DateTime.UtcNow.ToString("o")
+ };
+
+ string json = JsonConvert.SerializeObject(allTimings, Formatting.Indented);
+ File.WriteAllText(filePath, json);
+ Console.WriteLine($"[DATATREE-TIMING] Wrote timing for '{scenario}' to {filePath}");
+ }
+
+ #endregion
+ }
+}
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs
index 555e744b98..0f74fe0003 100644
--- a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs
+++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs
@@ -264,7 +264,7 @@ public void RemoveDuplicateCustomFields()
m_dtree.Initialize(Cache, false, m_layouts, m_parts);
m_dtree.ShowObject(m_entry, "Normal", null, m_entry, false);
var template = m_dtree.GetTemplateForObjLayout(m_entry, "Normal", null);
- var expected = "";
+ var expected = "";
Assert.That(expected, Is.EqualTo(template.OuterXml), "Exactly one part with a _CustomFieldPlaceholder ref attribute should exist.");
}
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTimingBaselineCatalog.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTimingBaselineCatalog.cs
new file mode 100644
index 0000000000..1445c6ec45
--- /dev/null
+++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTimingBaselineCatalog.cs
@@ -0,0 +1,167 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using Newtonsoft.Json;
+using NUnit.Framework;
+using SIL.FieldWorks.Common.RenderVerification;
+
+namespace SIL.FieldWorks.Common.Framework.DetailControls
+{
+ internal sealed class DataTreeTimingBaseline
+ {
+ public int Depth { get; set; }
+ public int Breadth { get; set; }
+ public int Slices { get; set; }
+ public double MaxInitMs { get; set; }
+ public double MaxPopulateMs { get; set; }
+ public double MaxTotalMs { get; set; }
+ public double MinDensity { get; set; }
+ public double MaxDensity { get; set; }
+ }
+
+ internal static class DataTreeTimingBaselineCatalog
+ {
+ private const string ReportTimingBaselinesEnvVar = "FW_REPORT_TIMING_BASELINES";
+ private static readonly Lazy> s_baselines =
+ new Lazy>(LoadBaselines);
+
+ internal static IReadOnlyDictionary Baselines => s_baselines.Value;
+
+ internal static string BaselineFilePath => Path.Combine(GetSourceFileDirectory(), "DataTreeTimingBaselines.json");
+
+ internal static void AssertMatches(string scenario, int depth, int breadth, DataTreeTimingInfo timing, double density)
+ {
+ if (!Baselines.TryGetValue(scenario, out var baseline))
+ {
+ WriteTimingReport(
+ $"Missing local timing baseline for scenario '{scenario}'. " +
+ $"Skipping timing threshold checks. Expected file: {BaselineFilePath}");
+ return;
+ }
+
+ WarnIfValueDiffers(scenario, "Depth", depth, baseline.Depth);
+ WarnIfValueDiffers(scenario, "Breadth", breadth, baseline.Breadth);
+ WarnIfValueDiffers(scenario, "Slice count", timing.SliceCount, baseline.Slices);
+ WarnIfTimingExceedsBaseline(scenario, "Init", timing.InitializationMs, baseline.MaxInitMs);
+ WarnIfTimingExceedsBaseline(scenario, "Populate", timing.PopulateSlicesMs, baseline.MaxPopulateMs);
+ WarnIfTimingExceedsBaseline(scenario, "Total", timing.TotalMs, baseline.MaxTotalMs);
+ WarnIfDensityOutOfRange(scenario, density, baseline.MinDensity, baseline.MaxDensity);
+ }
+
+ internal static void AssertSnapshotCoverage()
+ {
+ if (Baselines.Count == 0)
+ {
+ WriteTimingReport(
+ $"No local timing baselines loaded from {BaselineFilePath}. " +
+ "Skipping timing baseline coverage assertion.");
+ return;
+ }
+
+ var snapshotScenarioIds = Directory
+ .GetFiles(GetSourceFileDirectory(), "DataTreeRenderTests.DataTreeRender_*.verified.png")
+ .Select(path => Path.GetFileName(path))
+ .Select(name => name.Substring(
+ "DataTreeRenderTests.DataTreeRender_".Length,
+ name.Length - "DataTreeRenderTests.DataTreeRender_".Length - ".verified.png".Length))
+ .OrderBy(name => name, StringComparer.Ordinal)
+ .ToList();
+
+ var missingScenarioIds = snapshotScenarioIds
+ .Where(id => !Baselines.ContainsKey(id))
+ .ToList();
+
+ if (missingScenarioIds.Count > 0)
+ {
+ WriteTimingReport(
+ $"Committed snapshot scenarios are missing timing baselines in {BaselineFilePath}: " +
+ string.Join(", ", missingScenarioIds));
+ }
+ }
+
+ private static IReadOnlyDictionary LoadBaselines()
+ {
+ if (!File.Exists(BaselineFilePath))
+ {
+ WriteTimingReport(
+ $"Timing baseline file not found at {BaselineFilePath}. " +
+ "Using empty baseline catalog.");
+ return new Dictionary(StringComparer.Ordinal);
+ }
+
+ var json = File.ReadAllText(BaselineFilePath);
+ var baselines = JsonConvert.DeserializeObject>(json);
+ return baselines ?? new Dictionary(StringComparer.Ordinal);
+ }
+
+ private static string GetSourceFileDirectory([CallerFilePath] string sourceFile = "")
+ {
+ return Path.GetDirectoryName(sourceFile);
+ }
+
+ private static bool IsTimingReportingEnabled()
+ {
+ return string.Equals(
+ Environment.GetEnvironmentVariable(ReportTimingBaselinesEnvVar),
+ "1",
+ StringComparison.Ordinal);
+ }
+
+ private static bool IsRunningInCi()
+ {
+ return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"));
+ }
+
+ private static void WarnIfTimingExceedsBaseline(string scenario, string metricName, double actualMs, double baselineMs)
+ {
+ if (actualMs <= baselineMs)
+ return;
+
+ WriteTimingReport(
+ $"{scenario} {metricName} exceeded local baseline: " +
+ $"actual={actualMs:F2}ms baseline={baselineMs:F2}ms");
+ }
+
+ private static void WarnIfValueDiffers(string scenario, string metricName, int actualValue, int baselineValue)
+ {
+ if (actualValue == baselineValue)
+ return;
+
+ WriteTimingReport(
+ $"{scenario} {metricName} differs from local baseline: " +
+ $"actual={actualValue} baseline={baselineValue}");
+ }
+
+ private static void WarnIfDensityOutOfRange(string scenario, double actualDensity, double minDensity, double maxDensity)
+ {
+ if (actualDensity < minDensity)
+ {
+ WriteTimingReport(
+ $"{scenario} rendered less content than its local timing baseline: " +
+ $"actual={actualDensity:F2}% min={minDensity:F2}%");
+ return;
+ }
+
+ if (actualDensity > maxDensity)
+ {
+ WriteTimingReport(
+ $"{scenario} rendered more content than its local timing baseline: " +
+ $"actual={actualDensity:F2}% max={maxDensity:F2}%");
+ }
+ }
+
+ private static void WriteTimingReport(string message)
+ {
+ if (IsRunningInCi() || !IsTimingReportingEnabled())
+ return;
+
+ TestContext.Progress.WriteLine($"[DATATREE-TIMING] {message}");
+ }
+ }
+}
\ No newline at end of file
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DetailControlsTests.csproj b/Src/Common/Controls/DetailControls/DetailControlsTests/DetailControlsTests.csproj
index ced8c89b00..cfd5ff4782 100644
--- a/Src/Common/Controls/DetailControls/DetailControlsTests/DetailControlsTests.csproj
+++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DetailControlsTests.csproj
@@ -1,11 +1,12 @@
-
+
DetailControlsTests
SIL.FieldWorks.Common.Framework.DetailControls
net48
Library
- true 168,169,219,414,649,1635,1702,1701
+ true
+ 168,169,219,414,649,1635,1702,1701
false
false
@@ -29,8 +30,10 @@
+
+
@@ -44,10 +47,12 @@
+
+
Properties\CommonAssemblyInfo.cs
-
\ No newline at end of file
+
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/RenderTestAssemblySetup.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/RenderTestAssemblySetup.cs
new file mode 100644
index 0000000000..1188266848
--- /dev/null
+++ b/Src/Common/Controls/DetailControls/DetailControlsTests/RenderTestAssemblySetup.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Drawing.Text;
+using System.Linq;
+using System.Runtime.InteropServices;
+using NUnit.Framework;
+
+namespace SIL.FieldWorks.Common.Framework.DetailControls
+{
+ [SetUpFixture]
+ public sealed class RenderTestAssemblySetup
+ {
+ private const int DpiAwarenessContextUnaware = -1;
+ private const string DeterministicRenderFontFamily = "Segoe UI";
+
+ [DllImport("User32.dll")]
+ private static extern bool SetProcessDpiAwarenessContext(int dpiFlag);
+
+ [OneTimeSetUp]
+ public void OneTimeSetup()
+ {
+ // Force grayscale antialiasing (ANTIALIASED_QUALITY=4) for deterministic
+ // rendering across dev machines and CI (Windows Server 2025).
+ // The native VwGraphics reads this env var when creating GDI fonts.
+ Environment.SetEnvironmentVariable("FW_FONT_QUALITY", "4");
+
+ try
+ {
+ SetProcessDpiAwarenessContext(DpiAwarenessContextUnaware);
+ }
+ catch (DllNotFoundException)
+ {
+ }
+ catch (EntryPointNotFoundException)
+ {
+ }
+
+ using (var installedFonts = new InstalledFontCollection())
+ {
+ bool hasDeterministicFont = installedFonts.Families.Any(
+ family => string.Equals(family.Name, DeterministicRenderFontFamily, StringComparison.OrdinalIgnoreCase));
+ TestContext.Progress.WriteLine(
+ $"[RENDER-SETUP] DPI unaware requested. Font '{DeterministicRenderFontFamily}' installed={hasDeterministicFont}");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/Test.fwlayout b/Src/Common/Controls/DetailControls/DetailControlsTests/Test.fwlayout
index 3684e911d3..c9eba8797f 100644
--- a/Src/Common/Controls/DetailControls/DetailControlsTests/Test.fwlayout
+++ b/Src/Common/Controls/DetailControls/DetailControlsTests/Test.fwlayout
@@ -24,6 +24,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -34,9 +48,39 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -46,4 +90,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/TestParts.xml b/Src/Common/Controls/DetailControls/DetailControlsTests/TestParts.xml
index ab64e5f17b..66c55b8a99 100644
--- a/Src/Common/Controls/DetailControls/DetailControlsTests/TestParts.xml
+++ b/Src/Common/Controls/DetailControls/DetailControlsTests/TestParts.xml
@@ -4,9 +4,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -19,6 +37,15 @@
+
+
+
+
+
+
+
+
+
@@ -34,6 +61,11 @@
+
+
+
+
+
diff --git a/Src/Common/Controls/DetailControls/PossibilityVectorReferenceView.cs b/Src/Common/Controls/DetailControls/PossibilityVectorReferenceView.cs
index 73d00d214a..4bd5668385 100644
--- a/Src/Common/Controls/DetailControls/PossibilityVectorReferenceView.cs
+++ b/Src/Common/Controls/DetailControls/PossibilityVectorReferenceView.cs
@@ -321,7 +321,9 @@ public override void DisplayVec(IVwEnv vwenv, int hvo, int tag, int frag)
for (int i = 0; i < count; ++i)
{
vwenv.AddObj(da.get_VecItem(hvo, tag, i), this, VectorReferenceView.kfragTargetObj);
- vwenv.AddSeparatorBar();
+ // Only add separator between items, not after the last one.
+ if (i < count - 1)
+ vwenv.AddSeparatorBar();
}
vwenv.AddObj(PossibilityVectorReferenceView.khvoFake, this, VectorReferenceView.kfragTargetObj);
}
diff --git a/Src/Common/Controls/DetailControls/Slice.cs b/Src/Common/Controls/DetailControls/Slice.cs
index 10e1b16cfe..6faaacb111 100644
--- a/Src/Common/Controls/DetailControls/Slice.cs
+++ b/Src/Common/Controls/DetailControls/Slice.cs
@@ -91,6 +91,12 @@ public class Slice : UserControl, IxCoreColleague
protected bool m_widthHasBeenSetByDataTree = false;
protected IPersistenceProvider m_persistenceProvider;
+ // Cached XML configuration attributes — parsed once from ConfigurationNode on first access.
+ // Invalidated when ConfigurationNode is re-set (rare).
+ private bool? m_cachedIsHeader;
+ private bool? m_cachedSkipSpacerLine;
+ private bool? m_cachedSameObject;
+
protected Slice m_parentSlice;
private readonly SplitContainer m_splitter;
@@ -285,6 +291,10 @@ public XmlNode ConfigurationNode
CheckDisposed();
m_configurationNode = value;
+ // Invalidate cached XML attribute values.
+ m_cachedIsHeader = null;
+ m_cachedSkipSpacerLine = null;
+ m_cachedSameObject = null;
}
}
@@ -417,7 +427,49 @@ public bool IsHeaderNode
{
CheckDisposed();
- return XmlUtils.GetOptionalAttributeValue(ConfigurationNode, "header") == "true";
+ return IsHeader;
+ }
+ }
+
+ ///
+ /// Cached: whether ConfigurationNode has header="true". Parsed once per ConfigurationNode.
+ ///
+ internal bool IsHeader
+ {
+ get
+ {
+ if (!m_cachedIsHeader.HasValue)
+ m_cachedIsHeader = XmlUtils.GetOptionalBooleanAttributeValue(
+ ConfigurationNode, "header", false);
+ return m_cachedIsHeader.Value;
+ }
+ }
+
+ ///
+ /// Cached: whether ConfigurationNode has skipSpacerLine="true". Parsed once per ConfigurationNode.
+ ///
+ internal bool SkipSpacerLine
+ {
+ get
+ {
+ if (!m_cachedSkipSpacerLine.HasValue)
+ m_cachedSkipSpacerLine = XmlUtils.GetOptionalBooleanAttributeValue(
+ ConfigurationNode, "skipSpacerLine", false);
+ return m_cachedSkipSpacerLine.Value;
+ }
+ }
+
+ ///
+ /// Cached: whether ConfigurationNode has sameObject="true". Parsed once per ConfigurationNode.
+ ///
+ internal bool SameObject
+ {
+ get
+ {
+ if (!m_cachedSameObject.HasValue)
+ m_cachedSameObject = XmlUtils.GetOptionalBooleanAttributeValue(
+ ConfigurationNode, "sameObject", false);
+ return m_cachedSameObject.Value;
}
}
@@ -603,7 +655,7 @@ public bool TakeFocus(bool fOkToFocusTreeNode)
{
// We very possibly want to focus this node, but .NET won't let us focus it till it is visible.
// Make it so.
- DataTree.MakeSliceVisible(this);
+ ContainingDataTree.MakeSliceVisible(this);
}
}
@@ -633,7 +685,7 @@ protected override void OnGotFocus(EventArgs e)
CheckDisposed();
if (Disposing)
return;
- DataTree.MakeSliceVisible(this); // otherwise no change our control can take focus.
+ ContainingDataTree.MakeSliceVisible(this); // otherwise no change our control can take focus.
base.OnGotFocus(e);
if (Control != null && Control.CanFocus)
Control.Focus();
@@ -839,6 +891,7 @@ public virtual void Install(DataTree parent)
// REVIEW (Hasso) 2018.07: would it be better to check !parent.Controls.Contains(this)?
if (!isBeingReused)
{
+ parent.IncrementSliceInstallCreationCount();
parent.Controls.Add(this); // Parent will have to move it into the right place.
parent.Slices.Add(this);
}
@@ -2311,14 +2364,14 @@ public Slice FocusSliceOrChild()
{
Slice slice = containingDT.FieldAt(i); // make it real.
if (!slice.Visible) // make it visible.
- DataTree.MakeSliceVisible(slice);
+ containingDT.MakeSliceVisible(slice);
}
int cslice = containingDT.Slices.Count;
Slice sliceRetVal = null;
for (int islice = myIndex; islice < cslice; ++islice)
{
Slice slice = containingDT.FieldAt(islice);
- DataTree.MakeSliceVisible(slice); // otherwise it can't take focus
+ containingDT.MakeSliceVisible(slice); // otherwise it can't take focus
if (slice.TakeFocus(false))
{
sliceRetVal = slice;
@@ -3278,6 +3331,13 @@ protected internal virtual void SetWidthForDataTreeLayout(int width)
{
CheckDisposed();
+ // Fast path: if width hasn't changed and we've already been initialized,
+ // skip splitter resize and event handler manipulation. This avoids O(N)
+ // unnecessary work in HandleLayout1 when width is stable (every layout
+ // pass after the first).
+ if (m_widthHasBeenSetByDataTree && Width == width)
+ return;
+
if (Width != width)
Width = width;
diff --git a/Src/Common/Controls/DetailControls/SliceTreeNode.cs b/Src/Common/Controls/DetailControls/SliceTreeNode.cs
index f614bf9d5e..8e7de27282 100644
--- a/Src/Common/Controls/DetailControls/SliceTreeNode.cs
+++ b/Src/Common/Controls/DetailControls/SliceTreeNode.cs
@@ -332,51 +332,27 @@ void HandlePaint(object sender, PaintEventArgs pea)
Graphics gr = pea.Graphics;
Color lineColor = Color.FromKnownColor(KnownColor.ControlDark);
- using (Pen linePen = new Pen(lineColor, 1))
+ using (Pen boxLinePen = new Pen(lineColor, 1))
+ using (Brush backgroundBrush = new SolidBrush(Slice.ContainingDataTree.BackColor))
+ using (Brush lineBrush = new SolidBrush(lineColor))
{
- linePen.DashStyle = System.Drawing.Drawing2D.DashStyle.Dot;
- using (Pen boxLinePen = new Pen(lineColor, 1))
- using (Brush backgroundBrush = new SolidBrush(Slice.ContainingDataTree.BackColor))
- using (Brush lineBrush = new SolidBrush(lineColor))
- {
- int nIndent = Slice.Indent;
- DataTree.TreeItemState tis = Slice.Expansion;
- // Drawing within a control that covers the tree node portion of this slice, we always
- // draw relative to a top-of-slice that is 0. I'm keeping the variable just in case
- // we ever go back to drawing in the parent window.
- int ypTopOfSlice = 0;
- // int ypTopOfNextSlice = this.Height; // CS2019
- int iSlice = Slice.ContainingDataTree.Slices.IndexOf(Slice);
- // Go through the indents. This used to draw the correct tree structure at each level.
- // Now we leave out the structue, but this figures out some stuff we need if we end up
- // drawing a box. This could be optimized if we really never want the tree diagram.
- for (int nInd = 0; nInd <= nIndent; ++nInd)
- {
- // int ypTreeTop = ypTopOfSlice; // CS2019
- int xpBoxLeft = kdxpLeftMargin + nInd * kdxpIndDist;
+ int nIndent = Slice.Indent;
+ DataTree.TreeItemState tis = Slice.Expansion;
+ // Drawing within a control that covers the tree node portion of this slice, we always
+ // draw relative to a top-of-slice that is 0. I'm keeping the variable just in case
+ // we ever go back to drawing in the parent window.
+ int ypTopOfSlice = 0;
+
+ // We intentionally do not draw connector line patterns here.
+ // Keep only the geometry needed to position the expand/collapse box.
+ int xpBoxLeft = kdxpLeftMargin + nIndent * kdxpIndDist;
int xpBoxCtr = xpBoxLeft + kdxpBoxCtr;
- // Enhance JohnT: 2nd argument of max should be label height.
int dypBranchHeight = Slice.GetBranchHeight();
int dypLeftOver = Math.Max(kdypBoxHeight / 2, dypBranchHeight) - kdypBoxHeight / 2;
int ypBoxTop = ypTopOfSlice + dypLeftOver;
int ypBoxCtr = ypBoxTop + kdypBoxHeight / 2;
- // int xpRtLineEnd = xpBoxCtr + kdxpLongLineLen; // CS2019
-
- // There are two possible locations for the start and stop points for the
- // vertical line. That will produce three different results which I have
- // attempted to illustrate below. In case that's unclear they are:
- // an L - shaped right angle, a T - shape rotated counter-clockwise by
- // 90 degrees and an inverted L shape (i.e. flipped vertically).
- //
- // |_ > ypStart = top of field, ypStop = center point of +/- box.
- // |- > ypStart = top of field, ypStop = bottom of field.
- // | > ypStart = center point of +/- box, ypStop = bottom of field.
- //
- // Draw the vertical line.
- bool fMoreFieldsAtLevel = (Slice.ContainingDataTree.NextFieldAtIndent(nInd, iSlice) != 0);
-
- // Process a terminal level with a box.
- if (ShowPlusMinus && nInd == nIndent && tis != DataTree.TreeItemState.ktisFixed)
+
+ if (ShowPlusMinus && tis != DataTree.TreeItemState.ktisFixed)
{
// Draw the box.
Rectangle rcBox = new Rectangle(xpBoxLeft, ypBoxTop, kdxpBoxWid, kdypBoxHeight);
@@ -399,7 +375,6 @@ void HandlePaint(object sender, PaintEventArgs pea)
}
}
}
- }
// // If the height of the slice is greater then one line (1.5 * LabelHeight) and
// // the slice has a child, then we need to draw a line to that child. (fixes a
@@ -432,7 +407,6 @@ void HandlePaint(object sender, PaintEventArgs pea)
// gr.DrawLine(borderPen, xIndent, yPos, this.Width, yPos);
Slice.DrawLabel(ypTopOfSlice, gr, pea.ClipRectangle.Width);
- }
}
}
diff --git a/Src/Common/Controls/DetailControls/VectorReferenceView.cs b/Src/Common/Controls/DetailControls/VectorReferenceView.cs
index 2d9c139faf..e7f2ec4f7b 100644
--- a/Src/Common/Controls/DetailControls/VectorReferenceView.cs
+++ b/Src/Common/Controls/DetailControls/VectorReferenceView.cs
@@ -858,7 +858,11 @@ public override void DisplayVec(IVwEnv vwenv, int hvo, int tag, int frag)
{
vwenv.AddObj(da.get_VecItem(hvo, tag, i), this,
VectorReferenceView.kfragTargetObj);
- vwenv.AddSeparatorBar();
+ // Only add separator between items, not after the last one.
+ // A trailing separator draws an unwanted grey bar after the
+ // final item (e.g. after "Main Dictionary").
+ if (i < count - 1)
+ vwenv.AddSeparatorBar();
}
}
public string TextStyle
diff --git a/Src/Common/Controls/Widgets/LabeledMultiStringView.cs b/Src/Common/Controls/Widgets/LabeledMultiStringView.cs
index 47073c3280..18c7b50812 100644
--- a/Src/Common/Controls/Widgets/LabeledMultiStringView.cs
+++ b/Src/Common/Controls/Widgets/LabeledMultiStringView.cs
@@ -150,6 +150,12 @@ protected override void Dispose(bool disposing)
if (IsDisposed)
return;
+ if (disposing && m_innerView != null)
+ {
+ m_innerView.AllowPainting = false;
+ m_innerView.AllowLayout = false;
+ }
+
base.Dispose(disposing);
if (disposing)
@@ -253,6 +259,8 @@ CoreWritingSystemDefinition WsForSoundField(ShortSoundFieldControl sc, out int w
protected override void OnLayout(LayoutEventArgs levent)
{
base.OnLayout(levent);
+ if (IsDisposed || Disposing || !IsHandleCreated)
+ return;
if (m_innerView.VC == null || m_innerView.RootBox == null) // We can come in with no rootb from a dispose call.
return;
if (Visible)
diff --git a/Src/Common/Controls/XMLViews/XMLViewsTests/XmlBrowseViewBaseTests.cs b/Src/Common/Controls/XMLViews/XMLViewsTests/XmlBrowseViewBaseTests.cs
index e68d8fa5a5..46babcaadd 100644
--- a/Src/Common/Controls/XMLViews/XMLViewsTests/XmlBrowseViewBaseTests.cs
+++ b/Src/Common/Controls/XMLViews/XMLViewsTests/XmlBrowseViewBaseTests.cs
@@ -75,6 +75,12 @@ public class FakeRootBox : IVwRootBox
///
public XmlBrowseViewBase m_xmlBrowseViewBase;
+ public int ReconstructCallCount { get; private set; }
+ public int LastSetRootObjectHvo { get; private set; }
+ public IVwViewConstructor LastSetRootObjectVc { get; private set; }
+ public int LastSetRootObjectFrag { get; private set; }
+ public IVwStylesheet LastSetRootObjectStylesheet { get; private set; }
+
///
/// null unless manually set
///
@@ -189,6 +195,12 @@ public bool IsPropChangedInProgress
get { throw new NotImplementedException();}
}
+ ///
+ public bool NeedsReconstruct
+ {
+ get { throw new NotImplementedException();}
+ }
+
///
public void PropChanged(int hvo, int tag, int ivMin, int cvIns, int cvDel)
{
@@ -210,7 +222,10 @@ public void SetRootObjects(int[] _rghvo, IVwViewConstructor[] _rgpvwvc, int[] _r
///
public void SetRootObject(int hvo, IVwViewConstructor _vwvc, int frag, IVwStylesheet _ss)
{
- throw new NotImplementedException();
+ LastSetRootObjectHvo = hvo;
+ LastSetRootObjectVc = _vwvc;
+ LastSetRootObjectFrag = frag;
+ LastSetRootObjectStylesheet = _ss;
}
///
@@ -426,7 +441,7 @@ public void PrintSinglePage(IVwPrintContext _vpc, int nPageNo)
///
public bool LoseFocus()
{
- throw new NotImplementedException();
+ return true;
}
///
@@ -438,7 +453,7 @@ public void Close()
///
public void Reconstruct()
{
- throw new NotImplementedException();
+ ReconstructCallCount++;
}
///
diff --git a/Src/Common/Controls/XMLViews/XMLViewsTests/XmlViewRefreshPolicyTests.cs b/Src/Common/Controls/XMLViews/XMLViewsTests/XmlViewRefreshPolicyTests.cs
new file mode 100644
index 0000000000..76eee9143a
--- /dev/null
+++ b/Src/Common/Controls/XMLViews/XMLViewsTests/XmlViewRefreshPolicyTests.cs
@@ -0,0 +1,185 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using NUnit.Framework;
+using SIL.FieldWorks.Common.ViewsInterfaces;
+using SIL.LCModel;
+using SIL.LCModel.Core.KernelInterfaces;
+using XCore;
+
+namespace XMLViewsTests
+{
+ internal sealed class TestXmlView : SIL.FieldWorks.Common.Controls.XmlView
+ {
+ public void InstallRootBoxForTest(IVwRootBox rootBox)
+ {
+ m_rootb = rootBox;
+ }
+
+ public void InstallXmlVcForTest(SIL.FieldWorks.Common.Controls.XmlVc xmlVc)
+ {
+ m_xmlVc = xmlVc;
+ }
+
+ public string LayoutNameForTest => m_layoutName;
+ }
+
+ internal sealed class TestXmlSeqView : SIL.FieldWorks.Common.Controls.XmlSeqView
+ {
+ public void InstallRootBoxForTest(IVwRootBox rootBox)
+ {
+ m_rootb = rootBox;
+ }
+
+ public void InstallXmlVcForTest(SIL.FieldWorks.Common.Controls.XmlVc xmlVc)
+ {
+ m_xmlVc = xmlVc;
+ }
+
+ public void InstallPropertyTableForTest(PropertyTable propertyTable)
+ {
+ m_propertyTable = propertyTable;
+ }
+ }
+
+ [TestFixture]
+ public class XmlViewRefreshPolicyTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase
+ {
+ private static void EnsureTestInventoriesLoaded(string databaseName)
+ {
+ if (Inventory.GetInventory("layouts", databaseName) != null &&
+ Inventory.GetInventory("parts", databaseName) != null)
+ {
+ return;
+ }
+
+ var layoutKeyAttributes = new System.Collections.Generic.Dictionary();
+ layoutKeyAttributes["layout"] = new[] { "class", "type", "name", "choiceGuid" };
+ layoutKeyAttributes["group"] = new[] { "label" };
+ layoutKeyAttributes["part"] = new[] { "ref" };
+
+ var layoutInventory = new Inventory("*.fwlayout", "/LayoutInventory/*", layoutKeyAttributes, "test", "nowhere");
+ layoutInventory.LoadElements(Resources.Layouts_xml, 1);
+ Inventory.SetInventory("layouts", databaseName, layoutInventory);
+
+ var partKeyAttributes = new System.Collections.Generic.Dictionary();
+ partKeyAttributes["part"] = new[] { "id" };
+
+ var partInventory = new Inventory("*Parts.xml", "/PartInventory/bin/*", partKeyAttributes, "test", "nowhere");
+ partInventory.LoadElements(Resources.Parts_xml, 1);
+ Inventory.SetInventory("parts", databaseName, partInventory);
+ }
+
+ private SIL.FieldWorks.Common.Controls.XmlVc CreateConfiguredXmlVc(SIL.FieldWorks.Common.RootSites.SimpleRootSite rootSite, bool editable)
+ {
+ EnsureTestInventoriesLoaded(Cache.ProjectId.Name);
+
+ var xmlVc = new SIL.FieldWorks.Common.Controls.XmlVc("root", editable, rootSite, null, Cache.DomainDataByFlid);
+ xmlVc.SetCache(Cache);
+ xmlVc.DataAccess = Cache.DomainDataByFlid;
+ return xmlVc;
+ }
+
+ [Test]
+ public void XmlViewResetTables_ReconstructsRootBox()
+ {
+ using (var view = new TestXmlView())
+ {
+ var rootBox = new FakeXmlBrowseViewBase.FakeRootBox();
+ view.InstallRootBoxForTest(rootBox);
+ view.InstallXmlVcForTest(CreateConfiguredXmlVc(view, true));
+
+ view.ResetTables();
+
+ Assert.That(rootBox.ReconstructCallCount, Is.EqualTo(1));
+ }
+ }
+
+ [Test]
+ public void XmlViewResetTablesWithNewLayout_ReconstructsRootBoxAndStoresLayoutWithoutVc()
+ {
+ using (var view = new TestXmlView())
+ {
+ var rootBox = new FakeXmlBrowseViewBase.FakeRootBox();
+ view.InstallRootBoxForTest(rootBox);
+
+ view.ResetTables("updated-layout");
+
+ Assert.That(rootBox.ReconstructCallCount, Is.EqualTo(1));
+ Assert.That(view.LayoutNameForTest, Is.EqualTo("updated-layout"));
+ }
+ }
+
+ [Test]
+ public void XmlSeqViewResetTablesWithLayout_ReconstructsRootBoxWithoutVc()
+ {
+ using (var view = new TestXmlSeqView())
+ {
+ var rootBox = new FakeXmlBrowseViewBase.FakeRootBox();
+ view.InstallRootBoxForTest(rootBox);
+
+ view.ResetTables("updated-layout");
+
+ Assert.That(rootBox.ReconstructCallCount, Is.EqualTo(1));
+ }
+ }
+
+ [Test]
+ public void XmlSeqViewResetRoot_ReassignsRootObjectAndReconstructs()
+ {
+ using (var view = new TestXmlSeqView())
+ {
+ var rootBox = new FakeXmlBrowseViewBase.FakeRootBox();
+ var xmlVc = new SIL.FieldWorks.Common.Controls.XmlVc();
+ view.InstallRootBoxForTest(rootBox);
+ view.InstallXmlVcForTest(xmlVc);
+
+ view.ResetRoot(42);
+
+ Assert.That(rootBox.LastSetRootObjectHvo, Is.EqualTo(42));
+ Assert.That(rootBox.LastSetRootObjectVc, Is.SameAs(xmlVc));
+ Assert.That(rootBox.LastSetRootObjectFrag, Is.EqualTo(view.RootFrag));
+ Assert.That(rootBox.ReconstructCallCount, Is.EqualTo(1));
+ }
+ }
+
+ [Test]
+ public void XmlSeqViewOnPropertyChanged_ShowFailingItemsChange_ReconstructsRootBox()
+ {
+ using (var view = new TestXmlSeqView())
+ using (var propertyTable = new PropertyTable(null))
+ {
+ var rootBox = new FakeXmlBrowseViewBase.FakeRootBox();
+ view.InstallRootBoxForTest(rootBox);
+ view.InstallXmlVcForTest(new SIL.FieldWorks.Common.Controls.XmlVc("root", false, view, null, (ISilDataAccess)null));
+ view.InstallPropertyTableForTest(propertyTable);
+ propertyTable.SetProperty("currentContentControl", "tool", false);
+ propertyTable.SetProperty("ShowFailingItems-tool", true, false);
+
+ view.OnPropertyChanged("ShowFailingItems-tool");
+
+ Assert.That(rootBox.ReconstructCallCount, Is.EqualTo(1));
+ }
+ }
+
+ [Test]
+ public void XmlSeqViewOnPropertyChanged_ShowFailingItemsUnchanged_DoesNotReconstructRootBox()
+ {
+ using (var view = new TestXmlSeqView())
+ using (var propertyTable = new PropertyTable(null))
+ {
+ var rootBox = new FakeXmlBrowseViewBase.FakeRootBox();
+ view.InstallRootBoxForTest(rootBox);
+ view.InstallXmlVcForTest(new SIL.FieldWorks.Common.Controls.XmlVc("root", false, view, null, (ISilDataAccess)null));
+ view.InstallPropertyTableForTest(propertyTable);
+ propertyTable.SetProperty("currentContentControl", "tool", false);
+ propertyTable.SetProperty("ShowFailingItems-tool", false, false);
+
+ view.OnPropertyChanged("ShowFailingItems-tool");
+
+ Assert.That(rootBox.ReconstructCallCount, Is.EqualTo(0));
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Src/Common/FwUtils/FontFeatureSettings.cs b/Src/Common/FwUtils/FontFeatureSettings.cs
new file mode 100644
index 0000000000..b03c968a48
--- /dev/null
+++ b/Src/Common/FwUtils/FontFeatureSettings.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+
+namespace SIL.FieldWorks.Common.FwUtils
+{
+ ///
+ /// Parses and normalizes renderer-neutral font feature strings of the form tag=value.
+ ///
+ public static class FontFeatureSettings
+ {
+ ///
+ /// 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)
+ continue;
+
+ var tag = part.Substring(0, equalsIndex).Trim();
+ var valueText = part.Substring(equalsIndex + 1).Trim();
+ if (!IsValidOpenTypeTag(tag))
+ continue;
+
+ int value;
+ if (!int.TryParse(valueText, NumberStyles.Integer, CultureInfo.InvariantCulture, out value) || value < 0)
+ 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 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);
+ }
+ }
+
+ ///
+ /// 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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Src/Common/FwUtils/FwUtilsTests/FontFeatureSettingsTests.cs b/Src/Common/FwUtils/FwUtilsTests/FontFeatureSettingsTests.cs
new file mode 100644
index 0000000000..1a919075e2
--- /dev/null
+++ b/Src/Common/FwUtils/FwUtilsTests/FontFeatureSettingsTests.cs
@@ -0,0 +1,42 @@
+using System.Linq;
+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 Normalize_ReturnsDeterministicRendererNeutralString()
+ {
+ Assert.That(FontFeatureSettings.Normalize(" smcp = 1, kern=0 "), Is.EqualTo("kern=0,smcp=1"));
+ }
+ }
+}
\ No newline at end of file
diff --git a/Src/Common/RenderTestInfrastructure/RenderTestInfrastructure.csproj b/Src/Common/RenderTestInfrastructure/RenderTestInfrastructure.csproj
new file mode 100644
index 0000000000..de4cb0056e
--- /dev/null
+++ b/Src/Common/RenderTestInfrastructure/RenderTestInfrastructure.csproj
@@ -0,0 +1,74 @@
+
+
+
+ RenderTestInfrastructure
+ SIL.FieldWorks.Common.RenderVerification
+ net48
+ Library
+ 168,169,219,414,649,1635,1702,1701
+ false
+ false
+ false
+
+
+ DEBUG;TRACE
+ true
+ false
+ portable
+
+
+ TRACE
+ true
+ true
+ portable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ RenderBenchmarkComparer.cs
+
+
+ RenderBenchmarkReportWriter.cs
+
+
+ RenderBenchmarkResults.cs
+
+
+ RenderDiagnosticsToggle.cs
+
+
+ RenderSnapshotVerifier.cs
+
+
+ RenderEnvironmentValidator.cs
+
+
+ RenderModels.cs
+
+
+ RenderScenarioDataBuilder.cs
+
+
+ RenderTraceParser.cs
+
+
+ Properties\CommonAssemblyInfo.cs
+
+
+
\ No newline at end of file
diff --git a/Src/Common/RenderVerification/CompositeViewCapture.cs b/Src/Common/RenderVerification/CompositeViewCapture.cs
new file mode 100644
index 0000000000..522a68490f
--- /dev/null
+++ b/Src/Common/RenderVerification/CompositeViewCapture.cs
@@ -0,0 +1,400 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Diagnostics;
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.Reflection;
+using System.Windows.Forms;
+using SIL.FieldWorks.Common.Framework.DetailControls;
+using SIL.FieldWorks.Common.RootSites;
+using SIL.FieldWorks.Common.ViewsInterfaces;
+
+namespace SIL.FieldWorks.Common.RenderVerification
+{
+ ///
+ /// Captures a composite bitmap from a DataTree control. This solves the fundamental problem
+ /// that WinForms DrawToBitmap works for standard controls (grey labels, icons,
+ /// splitters, section headers) but produces black rectangles for Views engine content
+ /// inside controls. The fix is a two-pass approach:
+ ///
+ /// - Capture each slice locally via DrawToBitmap and composite it into the
+ /// full bitmap (gets per-slice WinForms chrome without relying on giant control coordinates)
+ /// - Repaint DataTree-owned separator rules directly onto the bitmap, since those
+ /// thin gray lines are drawn by the parent control rather than by each slice
+ /// - For each , render its RootBox via
+ /// VwDrawRootBuffered into the correct region, overlaying the black rectangles
+ ///
+ ///
+ public static class CompositeViewCapture
+ {
+ ///
+ /// Captures a composite bitmap of the DataTree, including both WinForms chrome
+ /// and Views engine rendered text.
+ ///
+ /// The populated DataTree to capture.
+ /// A composite bitmap.
+ public static Bitmap CaptureDataTree(DataTree dataTree, int captureWidth = 0)
+ {
+ if (dataTree == null)
+ throw new ArgumentNullException(nameof(dataTree));
+
+ // Initial totalHeight estimate from pre-init slice positions.
+ int totalHeight = CalculateTotalHeight(dataTree);
+ int width = captureWidth > 0 ? captureWidth : dataTree.ClientSize.Width;
+
+ if (width <= 0 || totalHeight <= 0)
+ {
+ throw new InvalidOperationException(
+ $"DataTree has invalid dimensions ({width}x{totalHeight}). " +
+ $"Is it populated and laid out?");
+ }
+
+ // Ensure the DataTree is large enough to contain all slices
+ // (normally it would scroll, but we want to capture everything)
+ var originalSize = dataTree.ClientSize;
+ var originalAutoScroll = dataTree.AutoScroll;
+ try
+ {
+ dataTree.AutoScroll = false;
+ dataTree.ClientSize = new Size(width, totalHeight);
+ dataTree.PerformLayout();
+
+ // Force all slices to create handles and RootBoxes.
+ // PerformLayout calls HandleLayout1(fFull=true) which positions slices
+ // but does NOT call MakeSliceVisible (because fSliceIsVisible is always
+ // false on the full-layout path). Without explicit initialization here,
+ // ViewSlices that never received a paint-path MakeSliceVisible call would
+ // have null RootBoxes, and the VwDrawRootBuffered overlay in Pass 2 would
+ // silently skip them, leaving blank/empty field areas in the bitmap.
+ //
+ // We use the same sequence as HandleLayout1's fSliceIsVisible block:
+ // FieldAt(i) to convert dummies → force Handle creation on slice and its
+ // Control (which triggers MakeRoot via OnHandleCreated) → set Visible.
+ EnsureAllSlicesInitialized(dataTree);
+
+ // Recompute height after initialization — slices may have changed
+ // height during BecomeRealInPlace (VwRootBox construction adjusts
+ // slice heights to match content). Use the content-tight height
+ // so the bitmap fits exactly around the rendered content without
+ // depending on DataTree.ClientSize.Height, which can vary based on
+ // WinForms internal auto-scroll state.
+ totalHeight = CalculateTotalHeight(dataTree);
+ dataTree.ClientSize = new Size(width, totalHeight);
+ dataTree.PerformLayout();
+
+ // Pass 1: Capture WinForms chrome slice-by-slice.
+ // A single DrawToBitmap over a very tall DataTree can drop left-side
+ // labels and separator lines for slices near the bottom of the image.
+ // Compositing each slice locally avoids that large-coordinate WinForms
+ // capture failure while preserving the existing root-box overlay pass.
+ var bitmap = new Bitmap(width, totalHeight, PixelFormat.Format32bppArgb);
+ CaptureSliceChrome(dataTree, bitmap);
+
+ // Pass 2: Overlay Views engine content for each ViewSlice
+ OverlayViewSliceContent(dataTree, bitmap);
+
+ // Pass 3: Repaint parent-drawn separator rules.
+ // These thin gray lines come from DataTree.OnPaint rather than from the
+ // slice controls, so the slice-by-slice pass above will not capture them.
+ PaintDataTreeSeparators(dataTree, bitmap, width, totalHeight);
+
+ return bitmap;
+ }
+ finally
+ {
+ // Restore original size
+ dataTree.ClientSize = originalSize;
+ dataTree.AutoScroll = originalAutoScroll;
+ }
+ }
+
+ ///
+ /// Calculates the content-tight height from slice positions.
+ /// Returns the bottom edge of the lowest slice, producing a bitmap
+ /// that fits exactly around the rendered content without padding.
+ /// This avoids depending on which
+ /// varies with WinForms auto-scroll state, form size, and other
+ /// non-deterministic factors.
+ ///
+ private static int CalculateTotalHeight(DataTree dataTree)
+ {
+ int maxBottom = 0;
+ if (dataTree.Slices != null)
+ {
+ foreach (Slice slice in dataTree.Slices)
+ {
+ int bottom = slice.Top + slice.Height;
+ if (bottom > maxBottom)
+ maxBottom = bottom;
+ }
+ }
+ return Math.Max(maxBottom, 1);
+ }
+
+ ///
+ /// Deterministically initializes every slice so that ViewSlice RootBoxes
+ /// are fully laid out and available for the pass.
+ ///
+ /// Uses the production path which:
+ /// 1. Forces Handle creation (triggers OnHandleCreated → MakeRoot → VwRootBox)
+ /// 2. Sets AllowLayout = true (triggers PerformLayout → DoLayout → rootBox.Layout)
+ /// 3. Adjusts slice height to match content
+ ///
+ /// Without BecomeRealInPlace, AllowLayout remains false (set in ViewSlice.Control setter),
+ /// rootBox.Layout is never called, and VwDrawRootBuffered renders un-laid-out boxes
+ /// producing empty or clipped field content.
+ ///
+ private static void EnsureAllSlicesInitialized(DataTree dataTree)
+ {
+ if (dataTree.Slices == null) return;
+
+ for (int i = 0; i < dataTree.Slices.Count; i++)
+ {
+ Slice slice;
+ try
+ {
+ // FieldAt converts dummy slices → real slices (may change Slices.Count).
+ slice = dataTree.FieldAt(i);
+ }
+ catch (Exception ex)
+ {
+ Trace.TraceWarning(
+ $"[CompositeViewCapture] FieldAt({i}) failed: {ex.Message}");
+ continue;
+ }
+ if (slice == null) continue;
+
+ try
+ {
+ // Ensure the slice has a window handle.
+ if (!slice.IsHandleCreated)
+ {
+ var h = slice.Handle;
+ }
+
+ // Use the production initialization path (BecomeRealInPlace).
+ // For ViewSlice this creates the RootBox handle, sets AllowLayout = true
+ // (which triggers rootBox.Layout with the correct width), and adjusts
+ // the slice height to match the laid-out content.
+ if (!slice.IsRealSlice)
+ slice.BecomeRealInPlace();
+
+ // Set the slice visible (required for DrawToBitmap to include it).
+ if (!slice.Visible)
+ slice.Visible = true;
+
+ var viewSlice = slice as ViewSlice;
+ if (viewSlice != null)
+ {
+ var rootSite = viewSlice.RootSite;
+ if (rootSite != null && rootSite.RootBox != null)
+ rootSite.AllowLayout = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ Trace.TraceWarning(
+ $"[CompositeViewCapture] Failed to init slice '{slice.Label}' at {i}: {ex.Message}");
+ }
+ }
+
+ // After making all slices real and visible, run a full layout pass to
+ // reposition slices correctly with their updated heights.
+ dataTree.PerformLayout();
+ }
+
+ ///
+ /// Captures WinForms chrome one slice at a time and composites the result into the
+ /// final bitmap. This avoids whole-control DrawToBitmap failures when slice
+ /// coordinates become very large in tall DataTree captures.
+ ///
+ private static void CaptureSliceChrome(DataTree dataTree, Bitmap bitmap)
+ {
+ using (var mainGraphics = Graphics.FromImage(bitmap))
+ {
+ mainGraphics.Clear(Color.White);
+
+ if (dataTree.Slices == null)
+ return;
+
+ foreach (Slice slice in dataTree.Slices)
+ {
+ if (slice == null)
+ continue;
+
+ try
+ {
+ CaptureSingleSliceChrome(slice, dataTree, mainGraphics);
+ }
+ catch (Exception ex)
+ {
+ Trace.TraceWarning(
+ $"[CompositeViewCapture] Failed to capture slice chrome '{slice.Label}': {ex.Message}");
+ }
+ }
+ }
+ }
+
+ private static void CaptureSingleSliceChrome(Slice slice, DataTree dataTree, Graphics mainGraphics)
+ {
+ var sliceRect = GetControlRectRelativeTo(slice, dataTree);
+ if (sliceRect.Width <= 0 || sliceRect.Height <= 0)
+ return;
+
+ using (var sliceBitmap = new Bitmap(sliceRect.Width, sliceRect.Height, PixelFormat.Format32bppArgb))
+ {
+ using (var sliceGraphics = Graphics.FromImage(sliceBitmap))
+ {
+ sliceGraphics.Clear(Color.White);
+ }
+
+ slice.DrawToBitmap(sliceBitmap, new Rectangle(0, 0, sliceRect.Width, sliceRect.Height));
+ mainGraphics.DrawImageUnscaled(sliceBitmap, sliceRect.Location);
+ }
+ }
+
+ private static void PaintDataTreeSeparators(DataTree dataTree, Bitmap bitmap, int width, int totalHeight)
+ {
+ const BindingFlags privateInstance = BindingFlags.Instance | BindingFlags.NonPublic;
+ var paintLinesMethod = typeof(DataTree).GetMethod("PaintLinesBetweenSlices", privateInstance);
+ if (paintLinesMethod == null)
+ {
+ Trace.TraceWarning("[CompositeViewCapture] Could not locate DataTree.PaintLinesBetweenSlices.");
+ return;
+ }
+
+ using (var graphics = Graphics.FromImage(bitmap))
+ {
+ try
+ {
+ paintLinesMethod.Invoke(dataTree, new object[]
+ {
+ graphics,
+ new Rectangle(0, 0, width, totalHeight),
+ width,
+ });
+ }
+ catch (TargetInvocationException ex)
+ {
+ Trace.TraceWarning(
+ $"[CompositeViewCapture] Failed to paint DataTree separators: {ex.InnerException?.Message ?? ex.Message}");
+ }
+ catch (Exception ex)
+ {
+ Trace.TraceWarning(
+ $"[CompositeViewCapture] Failed to paint DataTree separators: {ex.Message}");
+ }
+ }
+ }
+
+ ///
+ /// Iterates all ViewSlice descendants and renders their RootBox content
+ /// via VwDrawRootBuffered into the correct region of the bitmap.
+ ///
+ private static void OverlayViewSliceContent(DataTree dataTree, Bitmap bitmap)
+ {
+ if (dataTree.Slices == null) return;
+
+ foreach (Slice slice in dataTree.Slices)
+ {
+ var viewSlice = slice as ViewSlice;
+ if (viewSlice == null) continue;
+
+ try
+ {
+ OverlaySingleViewSlice(viewSlice, dataTree, bitmap);
+ }
+ catch (Exception ex)
+ {
+ // Don't fail the entire capture for one bad slice
+ Trace.TraceWarning(
+ $"[CompositeViewCapture] Failed to overlay ViewSlice '{slice.Label}': {ex.Message}");
+ }
+ }
+ }
+
+ ///
+ /// Renders a single ViewSlice's RootBox into the correct region of the bitmap.
+ ///
+ /// Key insight: VwDrawRootBuffered.DrawTheRoot calls rootSite.GetGraphics() to get
+ /// coordinate transform rectangles (rcSrc/rcDst). GetCoordRects returns rcDst in
+ /// rootSite-local coordinates (origin at (HorizMargin, 0)). If we pass a clientRect
+ /// with the rootSite's position in the *DataTree* (e.g. X=175), VwDrawRootBuffered
+ /// offsets rcDst by (-175, -y), placing content at negative X — clipping it.
+ ///
+ /// Fix: render into a temporary bitmap using rootSite-local coordinates (0,0,w,h),
+ /// then composite the result into the main bitmap at the correct DataTree-relative position.
+ ///
+ private static void OverlaySingleViewSlice(ViewSlice viewSlice, DataTree dataTree, Bitmap bitmap)
+ {
+ RootSite rootSite = viewSlice.RootSite;
+ if (rootSite == null) return;
+
+ IVwRootBox rootBox = null;
+ try
+ {
+ rootBox = rootSite.RootBox;
+ }
+ catch
+ {
+ return;
+ }
+ if (rootBox == null) return;
+
+ // Where in the DataTree bitmap this rootSite should appear
+ var rootSiteRect = GetControlRectRelativeTo(rootSite, dataTree);
+ if (rootSiteRect.Width <= 0 || rootSiteRect.Height <= 0) return;
+
+ // Render into a temp bitmap using rootSite-local coordinates.
+ // This matches what GetCoordRects returns (origin at the rootSite control, not
+ // the DataTree), so VwDrawRootBuffered produces correct content.
+ using (var tempBitmap = new Bitmap(rootSiteRect.Width, rootSiteRect.Height, PixelFormat.Format32bppArgb))
+ {
+ using (var tempGraphics = Graphics.FromImage(tempBitmap))
+ {
+ IntPtr tempHdc = tempGraphics.GetHdc();
+ try
+ {
+ using (var vdrb = new SIL.FieldWorks.Views.VwDrawRootBuffered())
+ {
+ var localRect = new Rect(0, 0, rootSiteRect.Width, rootSiteRect.Height);
+ const uint whiteColor = 0x00FFFFFF;
+ vdrb.DrawTheRoot(rootBox, tempHdc, localRect, whiteColor, true, rootSite);
+ }
+ }
+ finally
+ {
+ tempGraphics.ReleaseHdc(tempHdc);
+ }
+ }
+
+ // Composite the rendered rootSite content into the main bitmap
+ using (var mainGraphics = Graphics.FromImage(bitmap))
+ {
+ // Clear the area first (DrawToBitmap may have left a black rect)
+ mainGraphics.FillRectangle(Brushes.White, rootSiteRect);
+
+ mainGraphics.DrawImage(tempBitmap, rootSiteRect.X, rootSiteRect.Y);
+ }
+ }
+ }
+
+ ///
+ /// Gets the bounding rectangle of a child control relative to an ancestor control.
+ ///
+ private static Rectangle GetControlRectRelativeTo(Control child, Control ancestor)
+ {
+ var location = child.PointToScreen(Point.Empty);
+ var ancestorOrigin = ancestor.PointToScreen(Point.Empty);
+
+ return new Rectangle(
+ location.X - ancestorOrigin.X,
+ location.Y - ancestorOrigin.Y,
+ child.Width,
+ child.Height);
+ }
+ }
+}
diff --git a/Src/Common/RenderVerification/DataTreeRenderHarness.cs b/Src/Common/RenderVerification/DataTreeRenderHarness.cs
new file mode 100644
index 0000000000..714958f527
--- /dev/null
+++ b/Src/Common/RenderVerification/DataTreeRenderHarness.cs
@@ -0,0 +1,685 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.IO;
+using System.Windows.Forms;
+using System.Xml;
+using SIL.FieldWorks.Common.Framework.DetailControls;
+using SIL.FieldWorks.Common.FwUtils;
+using SIL.FieldWorks.Common.RootSites;
+using SIL.FieldWorks.Common.ViewsInterfaces;
+using SIL.LCModel;
+using XCore;
+
+namespace SIL.FieldWorks.Common.RenderVerification
+{
+ ///
+ /// Harness for rendering a full DataTree (the lex entry edit view with all WinForms chrome)
+ /// and capturing composite bitmaps that include grey labels, icons, expand/collapse buttons,
+ /// writing system indicators, section headers, and Views engine text content.
+ ///
+ ///
+ /// The DataTree is the real FLEx control that composes Slices from layout XML.
+ /// This harness creates one programmatically, populates it via ShowObject(), and
+ /// captures the result using CompositeViewCapture (DrawToBitmap + VwDrawRootBuffered overlay).
+ ///
+ public class DataTreeRenderHarness : IDisposable
+ {
+ private readonly LcmCache m_cache;
+ private readonly ICmObject m_rootObject;
+ private readonly string m_layoutName;
+ private readonly bool m_showHiddenFields;
+
+ private DataTree m_dataTree;
+ private Mediator m_mediator;
+ private PropertyTable m_propertyTable;
+ private Form m_hostForm;
+ private Inventory m_layoutInventory;
+ private Inventory m_partInventory;
+ private int m_captureWidth;
+ private bool m_disposed;
+
+ ///
+ /// Gets the number of slices populated by ShowObject.
+ ///
+ public int SliceCount => m_dataTree?.Slices?.Count ?? 0;
+
+ ///
+ /// Gets the DataTree control for inspection.
+ ///
+ public DataTree DataTree => m_dataTree;
+
+ ///
+ /// Gets the last captured bitmap.
+ ///
+ public Bitmap LastCapture { get; private set; }
+
+ ///
+ /// Gets timing information from the last population.
+ ///
+ public DataTreeTimingInfo LastTiming { get; private set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The LCM data cache.
+ /// The root object to display (e.g. an ILexEntry).
+ /// The layout name (e.g. "Normal").
+ public DataTreeRenderHarness(LcmCache cache, ICmObject rootObject, string layoutName = "Normal",
+ bool showHiddenFields = false)
+ {
+ m_cache = cache ?? throw new ArgumentNullException(nameof(cache));
+ m_rootObject = rootObject ?? throw new ArgumentNullException(nameof(rootObject));
+ m_layoutName = layoutName ?? "Normal";
+ m_showHiddenFields = showHiddenFields;
+ }
+
+ ///
+ /// Initializes the DataTree and populates it with slices for the root object.
+ /// This is the equivalent of what FLEx does when you navigate to a lex entry.
+ ///
+ /// Width of the host form in pixels.
+ /// Height of the host form in pixels.
+ /// If true, loads production layout XML from DistFiles.
+ /// If false, uses test layouts from DetailControlsTests (simpler but less realistic).
+ public void PopulateSlices(int width = 1024, int height = 768, bool useProductionLayouts = true)
+ {
+ DisposeResources();
+ m_captureWidth = width;
+
+ var stopwatch = Stopwatch.StartNew();
+
+ // Create XCore infrastructure required by DataTree
+ m_mediator = new Mediator();
+ m_propertyTable = new PropertyTable(m_mediator);
+
+ if (m_showHiddenFields)
+ {
+ var toolName = m_rootObject is ILexEntry ? "lexiconEdit" :
+ m_propertyTable.GetStringProperty("currentContentControl", null);
+ if (!string.IsNullOrEmpty(toolName))
+ {
+ m_propertyTable.SetProperty(
+ "ShowHiddenFields-" + toolName,
+ true,
+ PropertyTable.SettingsGroup.LocalSettings,
+ false);
+ }
+ }
+
+ // Create the DataTree
+ m_dataTree = new DataTree();
+ m_dataTree.Init(m_mediator, m_propertyTable, null);
+
+ // Host in a form for proper layout context. The form is shown offscreen
+ // (Opacity=0) after ShowObject to trigger the full slice lifecycle:
+ // OnPaint → HandleLayout1(fFull=false) → MakeSliceVisible → handle
+ // creation → MakeRoot → VwRootBox creation.
+ m_hostForm = new Form
+ {
+ FormBorderStyle = FormBorderStyle.None,
+ ShowInTaskbar = false,
+ ClientSize = new Size(width, height),
+ StartPosition = FormStartPosition.Manual,
+ Location = new Point(-2000, -2000) // offscreen
+ };
+ m_hostForm.Controls.Add(m_dataTree);
+ m_dataTree.Dock = DockStyle.Fill;
+
+ // Load layout inventories
+ if (useProductionLayouts)
+ {
+ LoadProductionInventories();
+ }
+ else
+ {
+ LoadTestInventories();
+ }
+
+ // Set up the stylesheet
+ var ss = new SIL.LCModel.DomainServices.LcmStyleSheet();
+ ss.Init(m_cache, m_cache.LangProject.Hvo, LangProjectTags.kflidStyles);
+ m_dataTree.StyleSheet = ss;
+
+ // Strip layout parts that cause native crashes or managed exceptions in
+ // test context. Must be done after inventories are loaded but before
+ // ShowObject, which reads from the layout inventory.
+ if (useProductionLayouts)
+ {
+ StripProblematicLayoutParts();
+ }
+
+ // Initialize the DataTree with cache and inventories
+ m_dataTree.Initialize(m_cache, false, m_layoutInventory, m_partInventory);
+
+ // Create the form handle so controls can paint.
+ m_hostForm.CreateControl();
+
+ var initMs = stopwatch.Elapsed.TotalMilliseconds;
+
+ // Populate the slices (this is the expensive operation we want to benchmark).
+ // After ShowObject, slices exist but are Visible=false because CreateSlices
+ // checks wasVisible = this.Visible at the start, and since the form isn't
+ // shown, wasVisible is false and Show() is skipped at the end.
+ m_dataTree.ResetSliceInstallCreationCount();
+ var populateStopwatch = Stopwatch.StartNew();
+ try
+ {
+ m_dataTree.ShowObject(m_rootObject, m_layoutName, null, m_rootObject, true);
+ }
+ catch (ApplicationException ex)
+ {
+ // Even after stripping known problematic parts, other parts may still fail
+ // in the test harness because the full production stack is not loaded.
+ // DataTree creates slices as it encounters them, so the slices created
+ // before the failure are still usable for production-like snapshot capture.
+ Trace.TraceWarning(
+ $"[DataTreeRenderHarness] ShowObject partially failed (continuing with " +
+ $"{m_dataTree.Slices?.Count ?? 0} slices already created): {ex.Message}");
+ }
+ populateStopwatch.Stop();
+
+ // Show the form to trigger the full WinForms lifecycle:
+ // OnLayout → HandleLayout1 positions slices but does NOT make them visible
+ // (fFull=true path). Only OnPaint → HandleLayout1(fFull=false) makes slices
+ // visible. So we need to:
+ // 1. Show the form (with Opacity=0 to avoid flicker)
+ // 2. Make the DataTree visible (CreateSlices called Hide() on it)
+ // 3. Pump paint messages so OnPaint fires
+ m_hostForm.Opacity = 0;
+ m_hostForm.Show();
+ m_dataTree.Visible = true;
+ m_dataTree.Invalidate();
+ System.Windows.Forms.Application.DoEvents();
+ ExpandHostFormToContent(width, height);
+
+ stopwatch.Stop();
+
+ LastTiming = new DataTreeTimingInfo
+ {
+ InitializationMs = initMs,
+ PopulateSlicesMs = populateStopwatch.Elapsed.TotalMilliseconds,
+ TotalMs = stopwatch.Elapsed.TotalMilliseconds,
+ SliceCount = m_dataTree.Slices?.Count ?? 0,
+ SliceInstallCreationCount = m_dataTree.SliceInstallCreationCount,
+ Timestamp = DateTime.UtcNow
+ };
+
+ // Collect slice diagnostics for debugging
+ if (m_dataTree.Slices != null)
+ {
+ for (int i = 0; i < m_dataTree.Slices.Count; i++)
+ {
+ var slice = m_dataTree.Slices[i];
+ var diag = new SliceDiagnosticInfo
+ {
+ Index = i,
+ TypeName = slice.GetType().Name,
+ Label = slice.Label ?? "(null)",
+ Bounds = new Rectangle(slice.Location, slice.Size),
+ Visible = slice.Visible,
+ };
+ // Check if it's a ViewSlice with a RootBox
+ var viewSlice = slice as ViewSlice;
+ if (viewSlice != null)
+ {
+ try { diag.HasRootBox = viewSlice.RootSite?.RootBox != null; }
+ catch { diag.HasRootBox = false; }
+ }
+ LastTiming.SliceDiagnostics.Add(diag);
+ }
+ }
+ }
+
+ private void ExpandHostFormToContent(int width, int minimumHeight)
+ {
+ int currentHeight = Math.Max(minimumHeight, 1);
+ for (int pass = 0; pass < 3; pass++)
+ {
+ int requiredHeight = Math.Max(currentHeight, CalculateSliceContentHeight());
+ if (requiredHeight <= currentHeight)
+ break;
+
+ currentHeight = requiredHeight;
+ m_hostForm.ClientSize = new Size(width, currentHeight);
+ m_hostForm.PerformLayout();
+ m_dataTree.PerformLayout();
+ m_dataTree.Invalidate();
+ System.Windows.Forms.Application.DoEvents();
+ }
+ }
+
+ private int CalculateSliceContentHeight()
+ {
+ int maxBottom = 0;
+ if (m_dataTree?.Slices != null)
+ {
+ foreach (Slice slice in m_dataTree.Slices)
+ {
+ if (slice == null)
+ continue;
+
+ int bottom = slice.Top + slice.Height;
+ if (bottom > maxBottom)
+ maxBottom = bottom;
+ }
+ }
+
+ return Math.Max(maxBottom, 1);
+ }
+
+ ///
+ /// Captures the DataTree as a composite bitmap. WinForms chrome (grey labels, icons,
+ /// section headers, separators) is captured via DrawToBitmap; Views engine content
+ /// inside ViewSlices is overlaid via VwDrawRootBuffered for each RootSite.
+ ///
+ /// The composite bitmap, or null if capture failed.
+ public Bitmap CaptureCompositeBitmap()
+ {
+ if (m_dataTree == null)
+ throw new InvalidOperationException("Call PopulateSlices before capturing.");
+
+ bool wasVisible = m_dataTree.Visible;
+ try
+ {
+ m_dataTree.Visible = true;
+ var bitmap = CompositeViewCapture.CaptureDataTree(m_dataTree, m_captureWidth);
+ LastCapture = bitmap;
+ return bitmap;
+ }
+ catch (Exception ex)
+ {
+ Trace.TraceWarning($"[DataTreeRenderHarness] Composite capture failed: {ex.Message}");
+ return null;
+ }
+ finally
+ {
+ if (m_dataTree != null)
+ m_dataTree.Visible = wasVisible;
+ }
+ }
+
+ ///
+ /// Saves the last captured bitmap to the specified path.
+ ///
+ public void SaveCapture(string outputPath, ImageFormat format = null)
+ {
+ if (LastCapture == null)
+ throw new InvalidOperationException("No capture available. Call CaptureCompositeBitmap first.");
+
+ var directory = Path.GetDirectoryName(outputPath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ Directory.CreateDirectory(directory);
+
+ LastCapture.Save(outputPath, format ?? ImageFormat.Png);
+ }
+
+ #region Layout Inventory Loading
+
+ ///
+ /// Removes part refs from the loaded layout inventory that reference slices with
+ /// heavy external dependencies unavailable in test context. This prevents
+ /// ProcessSubpartNode from throwing ApplicationException which would kill the
+ /// entire ApplyLayout loop and lose all subsequent parts.
+ ///
+ ///
+ /// Known problematic parts:
+ /// - "Etymologies" → SummarySlice → SummaryXmlView → native COM VwRootBox creation
+ /// that crashes the test host with unrecoverable native exceptions.
+ /// - "Messages" → MessageSlice (LexEdDll.dll) → ChorusSystem → L10NSharp.
+ /// Throws managed ApplicationException.
+ /// - "Senses", Section parts (VariantFormsSection, AlternateFormsSection,
+ /// GrammaticalFunctionsSection, PublicationSection) → Create complex slice
+ /// hierarchies with DynamicLoader, native COM Views, and expanding sections.
+ /// These crash the test host with unhandled native exceptions in test context
+ /// because the full FLEx COM infrastructure isn't initialized.
+ ///
+ private void StripProblematicLayoutParts()
+ {
+ // Parts that crash or throw in test context. This includes all parts that
+ // come after and including Messages in the Normal layout, since they involve
+ // complex slice types (Senses with recursive sub-slices, expanding sections
+ // with menus, DynamicLoader-loaded custom slices, etc.).
+ var problematicPartRefs = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "Etymologies", // SummarySlice → SummaryXmlView → native COM VwRootBox
+ "Messages", // L10NSharp/Chorus dependency
+ "Senses", // Recursive sense expansion → native COM crash
+ "VariantFormsSection", // Expanding section → native crash
+ "AlternateFormsSection", // Expanding section → native crash
+ "GrammaticalFunctionsSection", // Expanding section → native crash
+ "PublicationSection" // Expanding section → native crash
+ };
+
+ // Get the LexEntry detail Normal layout node from the inventory
+ XmlNode layout = m_layoutInventory.GetElement("layout",
+ new[] { "LexEntry", "detail", m_layoutName });
+ if (layout == null)
+ return;
+
+ var toRemove = new List();
+ foreach (XmlNode child in layout.ChildNodes)
+ {
+ if (child.NodeType != XmlNodeType.Element || child.Name != "part")
+ continue;
+ string refAttr = child.Attributes?["ref"]?.Value;
+ if (refAttr != null && problematicPartRefs.Contains(refAttr))
+ {
+ toRemove.Add(child);
+ }
+ }
+
+ foreach (var node in toRemove)
+ {
+ Trace.TraceInformation(
+ $"[DataTreeRenderHarness] Stripping problematic part ref=\"{node.Attributes?["ref"]?.Value}\" " +
+ $"from {m_layoutName} layout (external dependency not available in test context).");
+ layout.RemoveChild(node);
+ }
+ }
+
+ private void LoadProductionInventories()
+ {
+ // The production path: DistFiles/Language Explorer/Configuration/Parts/
+ string partDirectory = Path.Combine(FwDirectoryFinder.FlexFolder,
+ Path.Combine("Configuration", "Parts"));
+
+ if (!Directory.Exists(partDirectory))
+ {
+ throw new DirectoryNotFoundException(
+ $"Production layout directory not found: {partDirectory}. " +
+ $"FwDirectoryFinder.FlexFolder = {FwDirectoryFinder.FlexFolder}");
+ }
+
+ // Layout inventory: keyed by class+type+name, group label, part ref
+ var layoutKeyAttrs = new Dictionary
+ {
+ ["layout"] = new[] { "class", "type", "name" },
+ ["group"] = new[] { "label" },
+ ["part"] = new[] { "ref" }
+ };
+ m_layoutInventory = new Inventory(new[] { partDirectory },
+ "*.fwlayout", "/LayoutInventory/*", layoutKeyAttrs,
+ "RenderVerification", Path.GetTempPath());
+
+ // Parts inventory: keyed by part id
+ var partKeyAttrs = new Dictionary
+ {
+ ["part"] = new[] { "id" }
+ };
+ m_partInventory = new Inventory(new[] { partDirectory },
+ "*Parts.xml", "/PartInventory/bin/*", partKeyAttrs,
+ "RenderVerification", Path.GetTempPath());
+ }
+
+ private void LoadTestInventories()
+ {
+ // Same pattern as DataTreeTests — load from DetailControlsTests test XML
+ string partDirectory = Path.Combine(FwDirectoryFinder.SourceDirectory,
+ @"Common/Controls/DetailControls/DetailControlsTests");
+
+ var layoutKeyAttrs = new Dictionary
+ {
+ ["layout"] = new[] { "class", "type", "name" },
+ ["group"] = new[] { "label" },
+ ["part"] = new[] { "ref" }
+ };
+ m_layoutInventory = new Inventory(new[] { partDirectory },
+ "*.fwlayout", "/LayoutInventory/*", layoutKeyAttrs,
+ "RenderVerification", Path.GetTempPath());
+
+ var partKeyAttrs = new Dictionary
+ {
+ ["part"] = new[] { "id" }
+ };
+ m_partInventory = new Inventory(new[] { partDirectory },
+ "*Parts.xml", "/PartInventory/bin/*", partKeyAttrs,
+ "RenderVerification", Path.GetTempPath());
+ }
+
+ #endregion
+
+ #region Dispose
+
+ private void DisposeResources()
+ {
+ if (m_hostForm != null)
+ {
+ if (m_dataTree != null)
+ {
+ m_dataTree.Enabled = false;
+ m_dataTree.Visible = false;
+ m_hostForm.Visible = false;
+ SuppressHostedRootSiteDrawing(m_dataTree);
+ DrainHostedRootBoxDrawingErrors(m_dataTree);
+ QuiesceHostedRootSites(m_dataTree);
+ m_dataTree.Reset();
+ }
+
+ m_hostForm.Close();
+ m_hostForm.Dispose();
+ m_hostForm = null;
+ }
+
+ if (m_dataTree != null)
+ {
+ // DataTree gets disposed by form.Close since it's in Controls
+ m_dataTree = null;
+ }
+
+ if (m_propertyTable != null)
+ {
+ m_propertyTable.Dispose();
+ m_propertyTable = null;
+ }
+
+ if (m_mediator != null)
+ {
+ m_mediator.Dispose();
+ m_mediator = null;
+ }
+ }
+
+ private void QuiesceHostedRootSites(DataTree dataTree)
+ {
+ var rootSites = new HashSet();
+ CollectHostedRootSites(dataTree, rootSites);
+ foreach (var rootSite in rootSites)
+ QuiesceRootSite(rootSite);
+ }
+
+ private void DrainHostedRootBoxDrawingErrors(DataTree dataTree)
+ {
+ var rootSites = new HashSet();
+ CollectHostedRootSites(dataTree, rootSites);
+ foreach (var rootSite in rootSites)
+ DrainRootBoxDrawingErrors(rootSite);
+ }
+
+ private void SuppressHostedRootSiteDrawing(DataTree dataTree)
+ {
+ var rootSites = new HashSet();
+ CollectHostedRootSites(dataTree, rootSites);
+ foreach (var rootSite in rootSites)
+ SuppressRootSiteDrawing(rootSite);
+ }
+
+ private void CollectHostedRootSites(DataTree dataTree, ISet rootSites)
+ {
+ if (dataTree.Slices != null)
+ {
+ foreach (Slice slice in dataTree.Slices)
+ {
+ var viewSlice = slice as ViewSlice;
+ if (viewSlice == null)
+ continue;
+
+ try
+ {
+ var rootSite = viewSlice.RootSite;
+ if (rootSite != null)
+ rootSites.Add(rootSite);
+ }
+ catch
+ {
+ // Shutdown path only: slices may already be partially disposed.
+ }
+ }
+ }
+
+ CollectRootSites(dataTree, rootSites);
+ }
+
+ private void CollectRootSites(Control control, ISet rootSites)
+ {
+ if (control == null)
+ return;
+
+ var rootSite = control as SimpleRootSite;
+ if (rootSite != null)
+ rootSites.Add(rootSite);
+
+ foreach (Control child in control.Controls)
+ CollectRootSites(child, rootSites);
+ }
+
+ private void QuiesceRootSite(SimpleRootSite rootSite)
+ {
+ try
+ {
+ SuppressRootSiteDrawing(rootSite);
+ rootSite.AboutToDiscard();
+ rootSite.CloseRootBox();
+ }
+ catch
+ {
+ // Shutdown path only: controls may already be partially disposed.
+ }
+ }
+
+ private void SuppressRootSiteDrawing(SimpleRootSite rootSite)
+ {
+ try
+ {
+ rootSite.AllowPainting = false;
+ rootSite.AllowLayout = false;
+ rootSite.Visible = false;
+ }
+ catch
+ {
+ // Shutdown path only: controls may already be partially disposed.
+ }
+ }
+
+ private void DrainRootBoxDrawingErrors(SimpleRootSite rootSite)
+ {
+ IVwRootBox rootBox = null;
+ try
+ {
+ rootBox = rootSite.RootBox;
+ }
+ catch
+ {
+ return;
+ }
+
+ if (rootBox == null)
+ return;
+
+ IVwGraphics graphics = null;
+ try
+ {
+ Rect sourceRectangle;
+ Rect destinationRectangle;
+ rootSite.GetGraphics(rootBox, out graphics, out sourceRectangle, out destinationRectangle);
+ rootBox.DrawingErrors(graphics);
+ }
+ catch (Exception ex)
+ {
+ Trace.TraceWarning(
+ $"[DataTreeRenderHarness] Suppressed deferred drawing error for '{rootSite.Name}': {ex.Message}");
+ }
+ finally
+ {
+ if (graphics != null)
+ rootSite.ReleaseGraphics(rootBox, graphics);
+ }
+ }
+
+ /// Releases all resources used by the harness.
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ /// Releases the unmanaged resources and optionally releases the managed resources.
+ protected virtual void Dispose(bool disposing)
+ {
+ if (m_disposed) return;
+
+ if (disposing)
+ {
+ DisposeResources();
+ LastCapture?.Dispose();
+ LastCapture = null;
+ }
+
+ m_disposed = true;
+ }
+
+ #endregion
+ }
+
+ ///
+ /// Timing information for a DataTree population operation.
+ ///
+ public class DataTreeTimingInfo
+ {
+ /// Time to create DataTree, Mediator, load inventories, and create form.
+ public double InitializationMs { get; set; }
+
+ /// Time for ShowObject (slice creation and layout).
+ public double PopulateSlicesMs { get; set; }
+
+ /// Total wall-clock time including initialization and population.
+ public double TotalMs { get; set; }
+
+ /// Number of slices created.
+ public int SliceCount { get; set; }
+
+ /// Number of slice installs that created hosted controls.
+ public int SliceInstallCreationCount { get; set; }
+
+ /// Timestamp of the operation.
+ public DateTime Timestamp { get; set; }
+
+ /// Diagnostic information about each slice.
+ public List SliceDiagnostics { get; set; } = new List();
+ }
+
+ ///
+ /// Diagnostic information about a single slice for debugging render capture issues.
+ ///
+ public class SliceDiagnosticInfo
+ {
+ /// Zero-based index.
+ public int Index { get; set; }
+ /// Concrete type name (e.g. ViewSlice, MultiStringSlice).
+ public string TypeName { get; set; }
+ /// Grey label text.
+ public string Label { get; set; }
+ /// Bounds in DataTree coordinates.
+ public Rectangle Bounds { get; set; }
+ /// Whether the slice is visible.
+ public bool Visible { get; set; }
+ /// Whether the slice is a ViewSlice with a RootBox.
+ public bool HasRootBox { get; set; }
+ }
+}
diff --git a/Src/Common/RenderVerification/RenderBenchmarkComparer.cs b/Src/Common/RenderVerification/RenderBenchmarkComparer.cs
new file mode 100644
index 0000000000..20b766d6f3
--- /dev/null
+++ b/Src/Common/RenderVerification/RenderBenchmarkComparer.cs
@@ -0,0 +1,284 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace SIL.FieldWorks.Common.RenderVerification
+{
+ ///
+ /// Compares benchmark runs to detect performance regressions.
+ ///
+ public class RenderBenchmarkComparer
+ {
+ ///
+ /// Gets or sets the threshold percentage for detecting cold render regressions.
+ /// Default is 10% (0.10).
+ ///
+ public double ColdRenderRegressionThreshold { get; set; } = 0.10;
+
+ ///
+ /// Gets or sets the threshold percentage for detecting warm render regressions.
+ /// Default is 15% (0.15).
+ ///
+ public double WarmRenderRegressionThreshold { get; set; } = 0.15;
+
+ ///
+ /// Gets or sets the minimum absolute difference (in ms) to consider a regression.
+ /// Avoids false positives for very fast operations.
+ ///
+ public double MinAbsoluteDifferenceMs { get; set; } = 5.0;
+
+ ///
+ /// Compares two benchmark runs and identifies regressions.
+ ///
+ /// The baseline run to compare against.
+ /// The current run being evaluated.
+ /// A comparison result with regression details.
+ public BenchmarkComparisonResult Compare(BenchmarkRun baseline, BenchmarkRun current)
+ {
+ if (baseline == null)
+ throw new ArgumentNullException(nameof(baseline));
+ if (current == null)
+ throw new ArgumentNullException(nameof(current));
+
+ var result = new BenchmarkComparisonResult
+ {
+ BaselineRunId = baseline.Id,
+ CurrentRunId = current.Id,
+ BaselineTimestamp = baseline.RunAt,
+ CurrentTimestamp = current.RunAt
+ };
+
+ // Build lookup for baseline results
+ var baselineByScenario = baseline.Results.ToDictionary(r => r.ScenarioId, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var currentResult in current.Results)
+ {
+ if (!baselineByScenario.TryGetValue(currentResult.ScenarioId, out var baselineResult))
+ {
+ // New scenario, no comparison possible
+ result.NewScenarios.Add(currentResult.ScenarioId);
+ continue;
+ }
+
+ // Check cold render regression
+ var coldDiff = currentResult.ColdRenderMs - baselineResult.ColdRenderMs;
+ var coldPercent = baselineResult.ColdRenderMs > 0
+ ? coldDiff / baselineResult.ColdRenderMs
+ : 0;
+
+ if (coldDiff > MinAbsoluteDifferenceMs && coldPercent > ColdRenderRegressionThreshold)
+ {
+ result.Regressions.Add(new RegressionInfo
+ {
+ ScenarioId = currentResult.ScenarioId,
+ Metric = "ColdRender",
+ BaselineValue = baselineResult.ColdRenderMs,
+ CurrentValue = currentResult.ColdRenderMs,
+ RegressionPercent = coldPercent * 100
+ });
+ }
+
+ // Check warm render regression
+ var warmDiff = currentResult.WarmRenderMs - baselineResult.WarmRenderMs;
+ var warmPercent = baselineResult.WarmRenderMs > 0
+ ? warmDiff / baselineResult.WarmRenderMs
+ : 0;
+
+ if (warmDiff > MinAbsoluteDifferenceMs && warmPercent > WarmRenderRegressionThreshold)
+ {
+ result.Regressions.Add(new RegressionInfo
+ {
+ ScenarioId = currentResult.ScenarioId,
+ Metric = "WarmRender",
+ BaselineValue = baselineResult.WarmRenderMs,
+ CurrentValue = currentResult.WarmRenderMs,
+ RegressionPercent = warmPercent * 100
+ });
+ }
+
+ // Check pixel validation regression (pass -> fail)
+ if (baselineResult.PixelPerfectPass && !currentResult.PixelPerfectPass)
+ {
+ result.PixelValidationRegressions.Add(currentResult.ScenarioId);
+ }
+
+ // Track improvements (inverse of regression)
+ if (coldDiff < -MinAbsoluteDifferenceMs && coldPercent < -ColdRenderRegressionThreshold)
+ {
+ result.Improvements.Add(new RegressionInfo
+ {
+ ScenarioId = currentResult.ScenarioId,
+ Metric = "ColdRender",
+ BaselineValue = baselineResult.ColdRenderMs,
+ CurrentValue = currentResult.ColdRenderMs,
+ RegressionPercent = coldPercent * 100 // Negative = improvement
+ });
+ }
+
+ if (warmDiff < -MinAbsoluteDifferenceMs && warmPercent < -WarmRenderRegressionThreshold)
+ {
+ result.Improvements.Add(new RegressionInfo
+ {
+ ScenarioId = currentResult.ScenarioId,
+ Metric = "WarmRender",
+ BaselineValue = baselineResult.WarmRenderMs,
+ CurrentValue = currentResult.WarmRenderMs,
+ RegressionPercent = warmPercent * 100
+ });
+ }
+ }
+
+ // Find missing scenarios (in baseline but not in current)
+ var currentScenarios = new HashSet(current.Results.Select(r => r.ScenarioId), StringComparer.OrdinalIgnoreCase);
+ result.MissingScenarios = baseline.Results
+ .Where(r => !currentScenarios.Contains(r.ScenarioId))
+ .Select(r => r.ScenarioId)
+ .ToList();
+
+ result.HasRegressions = result.Regressions.Any() || result.PixelValidationRegressions.Any();
+ result.HasImprovements = result.Improvements.Any();
+
+ return result;
+ }
+
+ ///
+ /// Compares a current run against a baseline file.
+ ///
+ /// Path to the baseline results JSON file.
+ /// The current run.
+ /// The comparison result.
+ public BenchmarkComparisonResult CompareToBaseline(string baselineFilePath, BenchmarkRun current)
+ {
+ var baseline = BenchmarkRun.LoadFromFile(baselineFilePath);
+ return Compare(baseline, current);
+ }
+
+ ///
+ /// Generates a summary report of the comparison.
+ ///
+ /// The comparison result.
+ /// A formatted summary string.
+ public string GenerateSummary(BenchmarkComparisonResult result)
+ {
+ var lines = new List
+ {
+ "# Benchmark Comparison Summary",
+ "",
+ $"**Baseline**: {result.BaselineRunId} ({result.BaselineTimestamp:u})",
+ $"**Current**: {result.CurrentRunId} ({result.CurrentTimestamp:u})",
+ ""
+ };
+
+ if (result.HasRegressions)
+ {
+ lines.Add("## ⚠️ Regressions Detected");
+ lines.Add("");
+
+ foreach (var reg in result.Regressions)
+ {
+ lines.Add($"- **{reg.ScenarioId}** ({reg.Metric}): {reg.BaselineValue:F2}ms → {reg.CurrentValue:F2}ms (+{reg.RegressionPercent:F1}%)");
+ }
+
+ if (result.PixelValidationRegressions.Any())
+ {
+ lines.Add("");
+ lines.Add("### Pixel Validation Failures");
+ foreach (var scenario in result.PixelValidationRegressions)
+ {
+ lines.Add($"- {scenario}");
+ }
+ }
+
+ lines.Add("");
+ }
+
+ if (result.HasImprovements)
+ {
+ lines.Add("## ✅ Improvements");
+ lines.Add("");
+
+ foreach (var imp in result.Improvements)
+ {
+ lines.Add($"- **{imp.ScenarioId}** ({imp.Metric}): {imp.BaselineValue:F2}ms → {imp.CurrentValue:F2}ms ({imp.RegressionPercent:F1}%)");
+ }
+
+ lines.Add("");
+ }
+
+ if (!result.HasRegressions && !result.HasImprovements)
+ {
+ lines.Add("## ✅ No Significant Changes");
+ lines.Add("");
+ lines.Add("All scenarios are within acceptable tolerance.");
+ lines.Add("");
+ }
+
+ if (result.NewScenarios.Any())
+ {
+ lines.Add("## New Scenarios (no baseline)");
+ lines.Add("");
+ foreach (var scenario in result.NewScenarios)
+ {
+ lines.Add($"- {scenario}");
+ }
+ lines.Add("");
+ }
+
+ if (result.MissingScenarios.Any())
+ {
+ lines.Add("## Missing Scenarios (in baseline, not in current)");
+ lines.Add("");
+ foreach (var scenario in result.MissingScenarios)
+ {
+ lines.Add($"- {scenario}");
+ }
+ lines.Add("");
+ }
+
+ return string.Join(Environment.NewLine, lines);
+ }
+ }
+
+ ///
+ /// Contains the result of comparing two benchmark runs.
+ ///
+ public class BenchmarkComparisonResult
+ {
+ /// Gets or sets the baseline run identifier.
+ public string BaselineRunId { get; set; }
+
+ /// Gets or sets the current run identifier.
+ public string CurrentRunId { get; set; }
+
+ /// Gets or sets the baseline timestamp.
+ public DateTime BaselineTimestamp { get; set; }
+
+ /// Gets or sets the current timestamp.
+ public DateTime CurrentTimestamp { get; set; }
+
+ /// Gets or sets whether any regressions were detected.
+ public bool HasRegressions { get; set; }
+
+ /// Gets or sets whether any improvements were detected.
+ public bool HasImprovements { get; set; }
+
+ /// Gets or sets the list of timing regressions.
+ public List Regressions { get; set; } = new List();
+
+ /// Gets or sets the list of timing improvements.
+ public List Improvements { get; set; } = new List();
+
+ /// Gets or sets scenarios that failed pixel validation but previously passed.
+ public List PixelValidationRegressions { get; set; } = new List();
+
+ /// Gets or sets new scenarios not in baseline.
+ public List NewScenarios { get; set; } = new List();
+
+ /// Gets or sets scenarios in baseline but missing from current.
+ public List MissingScenarios { get; set; } = new List();
+ }
+}
diff --git a/Src/Common/RenderVerification/RenderBenchmarkReportWriter.cs b/Src/Common/RenderVerification/RenderBenchmarkReportWriter.cs
new file mode 100644
index 0000000000..4ad928f29d
--- /dev/null
+++ b/Src/Common/RenderVerification/RenderBenchmarkReportWriter.cs
@@ -0,0 +1,328 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+
+namespace SIL.FieldWorks.Common.RenderVerification
+{
+ ///
+ /// Generates summary reports for benchmark runs.
+ /// Outputs results in JSON and Markdown formats.
+ ///
+ public class RenderBenchmarkReportWriter
+ {
+ private readonly RenderBenchmarkComparer m_comparer;
+ private readonly RenderTraceParser m_traceParser;
+
+ ///
+ /// Gets or sets the output directory for reports.
+ ///
+ public string OutputDirectory { get; set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The output directory for reports.
+ public RenderBenchmarkReportWriter(string outputDirectory = null)
+ {
+ OutputDirectory = outputDirectory ?? RenderDiagnosticsToggle.DefaultOutputDirectory;
+ m_comparer = new RenderBenchmarkComparer();
+ m_traceParser = new RenderTraceParser();
+
+ if (!Directory.Exists(OutputDirectory))
+ {
+ Directory.CreateDirectory(OutputDirectory);
+ }
+ }
+
+ ///
+ /// Writes a complete benchmark run to JSON and Markdown files.
+ ///
+ /// The benchmark run to write.
+ /// Optional baseline run for comparison.
+ public void WriteReport(BenchmarkRun run, BenchmarkRun baselineRun = null)
+ {
+ if (run == null)
+ throw new ArgumentNullException(nameof(run));
+
+ // Generate summary if not present
+ if (run.Summary == null)
+ {
+ run.Summary = GenerateSummary(run);
+ }
+
+ // Compare to baseline if provided
+ BenchmarkComparisonResult comparison = null;
+ if (baselineRun != null)
+ {
+ comparison = m_comparer.Compare(baselineRun, run);
+ run.Summary.HasRegressions = comparison.HasRegressions;
+ run.Summary.Regressions = comparison.Regressions;
+ }
+
+ // Write JSON results
+ var jsonPath = Path.Combine(OutputDirectory, "results.json");
+ run.SaveToFile(jsonPath);
+
+ // Write Markdown summary
+ var markdownPath = Path.Combine(OutputDirectory, "summary.md");
+ WriteSummaryMarkdown(run, comparison, markdownPath);
+ }
+
+ ///
+ /// Generates an analysis summary for a benchmark run.
+ ///
+ /// The benchmark run.
+ /// The generated summary.
+ public AnalysisSummary GenerateSummary(BenchmarkRun run)
+ {
+ var summary = new AnalysisSummary
+ {
+ RunId = run.Id,
+ TotalScenarios = run.Results.Count,
+ PassingScenarios = run.Results.Count(r => r.PixelPerfectPass),
+ FailingScenarios = run.Results.Count(r => !r.PixelPerfectPass)
+ };
+
+ // Calculate averages
+ if (run.Results.Any())
+ {
+ summary.AverageColdRenderMs = run.Results.Average(r => r.ColdRenderMs);
+ summary.AverageWarmRenderMs = run.Results.Average(r => r.WarmRenderMs);
+ summary.AverageColdPerformOffscreenLayoutMs = run.Results.Average(r => r.ColdPerformOffscreenLayoutMs);
+ summary.AverageWarmPerformOffscreenLayoutMs = run.Results.Average(r => r.WarmPerformOffscreenLayoutMs);
+ }
+
+ // Aggregate trace events for top contributors
+ var allTraceEvents = run.Results
+ .Where(r => r.TraceEvents != null)
+ .SelectMany(r => r.TraceEvents)
+ .ToList();
+
+ if (allTraceEvents.Any())
+ {
+ summary.StageBreakdown = m_traceParser
+ .AggregateByStage(allTraceEvents)
+ .Values
+ .OrderByDescending(s => s.TotalDurationMs)
+ .Select(s => new StageBreakdown
+ {
+ Stage = s.Stage,
+ Calls = s.Count,
+ TotalDurationMs = s.TotalDurationMs,
+ AverageDurationMs = s.AverageDurationMs,
+ MinDurationMs = s.MinDurationMs,
+ MaxDurationMs = s.MaxDurationMs
+ })
+ .ToList();
+
+ summary.TopContributors = m_traceParser.GetTopContributors(allTraceEvents, count: 5);
+ }
+
+ // Generate recommendations
+ summary.Recommendations = GenerateRecommendations(run, summary);
+
+ return summary;
+ }
+
+ ///
+ /// Writes the summary in Markdown format.
+ ///
+ /// The benchmark run.
+ /// Optional comparison result.
+ /// The output file path.
+ public void WriteSummaryMarkdown(BenchmarkRun run, BenchmarkComparisonResult comparison, string outputPath)
+ {
+ var sb = new StringBuilder();
+
+ sb.AppendLine("# Render Benchmark Summary");
+ sb.AppendLine();
+ sb.AppendLine($"**Run ID**: {run.Id}");
+ sb.AppendLine($"**Timestamp**: {run.RunAt:u}");
+ sb.AppendLine($"**Machine**: {run.MachineName}");
+ sb.AppendLine($"**Configuration**: {run.Configuration ?? "Debug"}");
+ sb.AppendLine($"**Environment Hash**: `{run.EnvironmentHash}`");
+ sb.AppendLine();
+
+ if (run.FeatureFlags?.Any() == true)
+ {
+ sb.AppendLine("## Feature Flags");
+ sb.AppendLine();
+ foreach (var flag in run.FeatureFlags.OrderBy(kvp => kvp.Key))
+ {
+ sb.AppendLine($"- **{flag.Key}**: {flag.Value}");
+ }
+ sb.AppendLine();
+ }
+
+ // Overall Status
+ var summary = run.Summary;
+ if (summary != null)
+ {
+ var statusIcon = summary.FailingScenarios == 0 ? "✅" : "⚠️";
+ sb.AppendLine($"## Status {statusIcon}");
+ sb.AppendLine();
+ sb.AppendLine($"- **Total Scenarios**: {summary.TotalScenarios}");
+ sb.AppendLine($"- **Passing**: {summary.PassingScenarios}");
+ sb.AppendLine($"- **Failing**: {summary.FailingScenarios}");
+ sb.AppendLine($"- **Avg Cold Render**: {summary.AverageColdRenderMs:F2}ms");
+ sb.AppendLine($"- **Avg Warm Render**: {summary.AverageWarmRenderMs:F2}ms");
+ sb.AppendLine($"- **Avg Cold PerformOffscreenLayout**: {summary.AverageColdPerformOffscreenLayoutMs:F2}ms");
+ sb.AppendLine($"- **Avg Warm PerformOffscreenLayout**: {summary.AverageWarmPerformOffscreenLayoutMs:F2}ms");
+ sb.AppendLine();
+ }
+
+ // Regression Status
+ if (comparison != null)
+ {
+ if (comparison.HasRegressions)
+ {
+ sb.AppendLine("## ⚠️ Regressions Detected");
+ sb.AppendLine();
+ foreach (var reg in comparison.Regressions)
+ {
+ sb.AppendLine($"- **{reg.ScenarioId}** ({reg.Metric}): {reg.BaselineValue:F2}ms → {reg.CurrentValue:F2}ms (+{reg.RegressionPercent:F1}%)");
+ }
+ sb.AppendLine();
+ }
+
+ if (comparison.HasImprovements)
+ {
+ sb.AppendLine("## ✅ Improvements");
+ sb.AppendLine();
+ foreach (var imp in comparison.Improvements)
+ {
+ sb.AppendLine($"- **{imp.ScenarioId}** ({imp.Metric}): {imp.BaselineValue:F2}ms → {imp.CurrentValue:F2}ms ({imp.RegressionPercent:F1}%)");
+ }
+ sb.AppendLine();
+ }
+ }
+
+ // Scenario Details Table
+ sb.AppendLine("## Scenario Results");
+ sb.AppendLine();
+ sb.AppendLine("| Scenario | Cold (ms) | Warm (ms) | Cold Layout (ms) | Warm Layout (ms) | Pixel Pass | Variance |");
+ sb.AppendLine("|----------|-----------|-----------|------------------|------------------|------------|----------|");
+
+ foreach (var result in run.Results.OrderBy(r => r.ScenarioId))
+ {
+ var passIcon = result.PixelPerfectPass ? "✅" : "❌";
+ sb.AppendLine($"| {result.ScenarioId} | {result.ColdRenderMs:F2} | {result.WarmRenderMs:F2} | {result.ColdPerformOffscreenLayoutMs:F2} | {result.WarmPerformOffscreenLayoutMs:F2} | {passIcon} | {result.VariancePercent:F1}% |");
+ }
+ sb.AppendLine();
+
+ // Top Contributors
+ if (summary?.TopContributors?.Any() == true)
+ {
+ sb.AppendLine("## Top Time Contributors");
+ sb.AppendLine();
+ sb.AppendLine("| Stage | Avg Duration (ms) | Share % |");
+ sb.AppendLine("|-------|-------------------|---------|");
+
+ foreach (var contributor in summary.TopContributors)
+ {
+ sb.AppendLine($"| {contributor.Stage} | {contributor.AverageDurationMs:F2} | {contributor.SharePercent:F1}% |");
+ }
+ sb.AppendLine();
+ }
+
+ if (summary?.StageBreakdown?.Any() == true)
+ {
+ sb.AppendLine("## Stage Breakdown");
+ sb.AppendLine();
+ sb.AppendLine("| Stage | Calls | Total (ms) | Avg (ms) | Min (ms) | Max (ms) |");
+ sb.AppendLine("|-------|------:|-----------:|---------:|---------:|---------:|");
+
+ foreach (var stage in summary.StageBreakdown)
+ {
+ sb.AppendLine($"| {stage.Stage} | {stage.Calls} | {stage.TotalDurationMs:F2} | {stage.AverageDurationMs:F2} | {stage.MinDurationMs:F2} | {stage.MaxDurationMs:F2} |");
+ }
+ sb.AppendLine();
+ }
+
+ // Recommendations
+ if (summary?.Recommendations?.Any() == true)
+ {
+ sb.AppendLine("## Recommendations");
+ sb.AppendLine();
+ foreach (var rec in summary.Recommendations)
+ {
+ sb.AppendLine($"- {rec}");
+ }
+ sb.AppendLine();
+ }
+
+ // Write to file
+ File.WriteAllText(outputPath, sb.ToString(), Encoding.UTF8);
+ }
+
+ ///
+ /// Writes a comparison report between baseline and current run.
+ ///
+ /// Path to the baseline results JSON.
+ /// The current benchmark run.
+ /// The output path for the comparison report.
+ public void WriteComparisonReport(string baselineFilePath, BenchmarkRun current, string outputPath = null)
+ {
+ var baseline = BenchmarkRun.LoadFromFile(baselineFilePath);
+ var comparison = m_comparer.Compare(baseline, current);
+
+ outputPath = outputPath ?? Path.Combine(OutputDirectory, "comparison.md");
+ var content = m_comparer.GenerateSummary(comparison);
+ File.WriteAllText(outputPath, content, Encoding.UTF8);
+ }
+
+ private List GenerateRecommendations(BenchmarkRun run, AnalysisSummary summary)
+ {
+ var recommendations = new List();
+
+ // Check for high cold/warm ratio
+ if (summary.AverageColdRenderMs > 0 && summary.AverageWarmRenderMs > 0)
+ {
+ var ratio = summary.AverageColdRenderMs / summary.AverageWarmRenderMs;
+ if (ratio > 5)
+ {
+ recommendations.Add("High cold/warm ratio suggests initialization overhead. Consider lazy loading or caching.");
+ }
+ }
+
+ // Check for failing scenarios
+ if (summary.FailingScenarios > 0)
+ {
+ recommendations.Add($"Fix {summary.FailingScenarios} failing pixel-perfect validation(s) before optimizing.");
+ }
+
+ // Check for high variance
+ var highVarianceScenarios = run.Results.Where(r => r.VariancePercent > 10).ToList();
+ if (highVarianceScenarios.Any())
+ {
+ recommendations.Add($"{highVarianceScenarios.Count} scenario(s) have >10% variance. Consider more test iterations or environment stabilization.");
+ }
+
+ // Check top contributors for optimization opportunities
+ if (summary.TopContributors?.Any() == true)
+ {
+ var topStage = summary.TopContributors.First();
+ if (topStage.SharePercent > 40)
+ {
+ recommendations.Add($"'{topStage.Stage}' contributes {topStage.SharePercent:F1}% of render time. Prioritize optimization here.");
+ }
+ }
+
+ // Default recommendations if none generated
+ if (!recommendations.Any())
+ {
+ recommendations.Add("No immediate optimization targets identified. Consider profiling deeper stages.");
+ recommendations.Add("Enable trace diagnostics for detailed stage-level timing.");
+ recommendations.Add("Review lazy expansion patterns for complex/deep-nested scenarios.");
+ }
+
+ return recommendations;
+ }
+ }
+}
diff --git a/Src/Common/RenderVerification/RenderBenchmarkResults.cs b/Src/Common/RenderVerification/RenderBenchmarkResults.cs
new file mode 100644
index 0000000000..318a556238
--- /dev/null
+++ b/Src/Common/RenderVerification/RenderBenchmarkResults.cs
@@ -0,0 +1,271 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+
+namespace SIL.FieldWorks.Common.RenderVerification
+{
+ ///
+ /// Contains the full results of a benchmark run including all scenario timings.
+ ///
+ public class BenchmarkRun
+ {
+ /// Gets or sets the unique run identifier.
+ public string Id { get; set; } = Guid.NewGuid().ToString("N");
+
+ /// Gets or sets the run timestamp.
+ public DateTime RunAt { get; set; } = DateTime.UtcNow;
+
+ /// Gets or sets the build configuration (Debug/Release).
+ public string Configuration { get; set; }
+
+ /// Gets or sets the environment hash for deterministic validation.
+ public string EnvironmentHash { get; set; }
+
+ /// Gets or sets the machine name.
+ public string MachineName { get; set; } = Environment.MachineName;
+
+ /// Gets or sets the active performance feature-flag states for this run.
+ public Dictionary FeatureFlags { get; set; } = new Dictionary();
+
+ /// Gets or sets the list of scenario results.
+ public List Results { get; set; } = new List();
+
+ /// Gets or sets the analysis summary.
+ public AnalysisSummary Summary { get; set; }
+
+ ///
+ /// Saves the benchmark run to a JSON file.
+ ///
+ /// The output file path.
+ public void SaveToFile(string outputPath)
+ {
+ var directory = Path.GetDirectoryName(outputPath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var settings = new JsonSerializerSettings
+ {
+ Formatting = Formatting.Indented,
+ NullValueHandling = NullValueHandling.Ignore,
+ Converters = { new StringEnumConverter() }
+ };
+
+ var json = JsonConvert.SerializeObject(this, settings);
+ File.WriteAllText(outputPath, json, Encoding.UTF8);
+ }
+
+ ///
+ /// Loads a benchmark run from a JSON file.
+ ///
+ /// The input file path.
+ /// The loaded benchmark run.
+ public static BenchmarkRun LoadFromFile(string inputPath)
+ {
+ if (!File.Exists(inputPath))
+ throw new FileNotFoundException("Benchmark results file not found.", inputPath);
+
+ var json = File.ReadAllText(inputPath, Encoding.UTF8);
+ return JsonConvert.DeserializeObject(json);
+ }
+ }
+
+ ///
+ /// Contains the timing and validation results for a single scenario.
+ ///
+ public class BenchmarkResult
+ {
+ /// Gets or sets the scenario identifier.
+ public string ScenarioId { get; set; }
+
+ /// Gets or sets the scenario description.
+ public string ScenarioDescription { get; set; }
+
+ /// Gets or sets the cold render duration in milliseconds.
+ public double ColdRenderMs { get; set; }
+
+ /// Gets or sets the warm render duration in milliseconds.
+ public double WarmRenderMs { get; set; }
+
+ /// Gets or sets the total cold-phase PerformOffscreenLayout duration in milliseconds.
+ public double ColdPerformOffscreenLayoutMs { get; set; }
+
+ /// Gets or sets the total warm-phase PerformOffscreenLayout duration in milliseconds.
+ public double WarmPerformOffscreenLayoutMs { get; set; }
+
+ /// Gets or sets the variance percentage across multiple runs.
+ public double VariancePercent { get; set; }
+
+ /// Gets or sets whether the pixel-perfect validation passed.
+ public bool PixelPerfectPass { get; set; }
+
+ /// Gets or sets the mismatch details if validation failed.
+ public string MismatchDetails { get; set; }
+
+ /// Gets or sets the snapshot path used for comparison.
+ public string SnapshotPath { get; set; }
+
+ /// Gets or sets the trace events for this scenario (if diagnostics enabled).
+ public List TraceEvents { get; set; }
+ }
+
+ ///
+ /// Captures a single rendering stage trace event.
+ ///
+ public class TraceEvent
+ {
+ /// Gets or sets the rendering stage name.
+ public string Stage { get; set; }
+
+ /// Gets or sets the stage start time (relative to render start).
+ public double StartTimeMs { get; set; }
+
+ /// Gets or sets the stage duration in milliseconds.
+ public double DurationMs { get; set; }
+
+ /// Gets or sets additional context metadata.
+ public Dictionary Context { get; set; }
+ }
+
+ ///
+ /// Summarizes the benchmark run with top contributors and recommendations.
+ ///
+ public class AnalysisSummary
+ {
+ /// Gets or sets the benchmark run identifier.
+ public string RunId { get; set; }
+
+ /// Gets or sets the total scenarios executed.
+ public int TotalScenarios { get; set; }
+
+ /// Gets or sets the number of passing scenarios.
+ public int PassingScenarios { get; set; }
+
+ /// Gets or sets the number of failing scenarios.
+ public int FailingScenarios { get; set; }
+
+ /// Gets or sets the average cold render time across scenarios.
+ public double AverageColdRenderMs { get; set; }
+
+ /// Gets or sets the average warm render time across scenarios.
+ public double AverageWarmRenderMs { get; set; }
+
+ /// Gets or sets the average cold-phase PerformOffscreenLayout time across scenarios.
+ public double AverageColdPerformOffscreenLayoutMs { get; set; }
+
+ /// Gets or sets the average warm-phase PerformOffscreenLayout time across scenarios.
+ public double AverageWarmPerformOffscreenLayoutMs { get; set; }
+
+ /// Gets or sets the top time contributors by stage.
+ public List TopContributors { get; set; } = new List();
+
+ /// Gets or sets aggregated per-stage timing and call statistics.
+ public List StageBreakdown { get; set; } = new List();
+
+ /// Gets or sets optimization recommendations.
+ public List Recommendations { get; set; } = new List();
+
+ /// Gets or sets whether any regressions were detected compared to baseline.
+ public bool HasRegressions { get; set; }
+
+ /// Gets or sets regression details if any were detected.
+ public List Regressions { get; set; } = new List();
+ }
+
+ ///
+ /// Represents a ranked timing contributor.
+ ///
+ public class Contributor
+ {
+ /// Gets or sets the rendering stage name.
+ public string Stage { get; set; }
+
+ /// Gets or sets the average duration in milliseconds.
+ public double AverageDurationMs { get; set; }
+
+ /// Gets or sets the percentage share of total render time.
+ public double SharePercent { get; set; }
+ }
+
+ ///
+ /// Aggregated timing metrics for a single stage/function.
+ ///
+ public class StageBreakdown
+ {
+ /// Gets or sets the stage/function name.
+ public string Stage { get; set; }
+
+ /// Gets or sets how many times the stage executed.
+ public int Calls { get; set; }
+
+ /// Gets or sets total duration for the stage across all calls.
+ public double TotalDurationMs { get; set; }
+
+ /// Gets or sets average duration per call.
+ public double AverageDurationMs { get; set; }
+
+ /// Gets or sets minimum observed call duration.
+ public double MinDurationMs { get; set; }
+
+ /// Gets or sets maximum observed call duration.
+ public double MaxDurationMs { get; set; }
+ }
+
+ ///
+ /// Contains information about a detected regression.
+ ///
+ public class RegressionInfo
+ {
+ /// Gets or sets the scenario identifier.
+ public string ScenarioId { get; set; }
+
+ /// Gets or sets the metric that regressed.
+ public string Metric { get; set; }
+
+ /// Gets or sets the baseline value.
+ public double BaselineValue { get; set; }
+
+ /// Gets or sets the current value.
+ public double CurrentValue { get; set; }
+
+ /// Gets or sets the regression percentage.
+ public double RegressionPercent { get; set; }
+ }
+
+ ///
+ /// Configuration flags for benchmark execution.
+ ///
+ public class BenchmarkFlags
+ {
+ /// Gets or sets whether diagnostics logging is enabled.
+ public bool DiagnosticsEnabled { get; set; }
+
+ /// Gets or sets whether trace output is enabled.
+ public bool TraceEnabled { get; set; }
+
+ /// Gets or sets the capture mode (DrawToBitmap, etc.).
+ public string CaptureMode { get; set; } = "DrawToBitmap";
+
+ ///
+ /// Loads flags from a JSON file.
+ ///
+ /// The flags file path.
+ /// The loaded flags, or defaults if file not found.
+ public static BenchmarkFlags LoadFromFile(string path)
+ {
+ if (!File.Exists(path))
+ return new BenchmarkFlags();
+
+ var json = File.ReadAllText(path, Encoding.UTF8);
+ return JsonConvert.DeserializeObject(json) ?? new BenchmarkFlags();
+ }
+ }
+}
diff --git a/Src/Common/RenderVerification/RenderComparisonTests/HarfBuzzSkiaComparisonTests.cs b/Src/Common/RenderVerification/RenderComparisonTests/HarfBuzzSkiaComparisonTests.cs
new file mode 100644
index 0000000000..38d9416f55
--- /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;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Src/Common/RenderVerification/RenderComparisonTests/RenderComparisonTests.csproj b/Src/Common/RenderVerification/RenderComparisonTests/RenderComparisonTests.csproj
new file mode 100644
index 0000000000..9868362c17
--- /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
+
+
+
\ No newline at end of file
diff --git a/Src/Common/RenderVerification/RenderDiagnosticsToggle.cs b/Src/Common/RenderVerification/RenderDiagnosticsToggle.cs
new file mode 100644
index 0000000000..7a7dd10b62
--- /dev/null
+++ b/Src/Common/RenderVerification/RenderDiagnosticsToggle.cs
@@ -0,0 +1,355 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+using Newtonsoft.Json;
+
+namespace SIL.FieldWorks.Common.RenderVerification
+{
+ ///
+ /// Manages the diagnostics and trace output toggle for render benchmarks.
+ /// Provides methods to enable/disable trace output and configure trace listeners.
+ ///
+ public class RenderDiagnosticsToggle : IDisposable
+ {
+ private const string TraceListenerName = "RenderBenchmark";
+ private static readonly object s_traceListenerSync = new object();
+ private static TextWriterTraceListener s_sharedTraceListener;
+ private static StreamWriter s_sharedTraceWriter;
+ private static int s_sharedTraceListenerRefCount;
+ private readonly string m_flagsFilePath;
+ private readonly string m_traceLogPath;
+ private TextWriterTraceListener m_traceListener;
+ private StreamWriter m_traceWriter;
+ private bool m_disposed;
+ private bool m_originalDiagnosticsState;
+
+ ///
+ /// Gets whether diagnostics are currently enabled.
+ ///
+ public bool DiagnosticsEnabled { get; private set; }
+
+ ///
+ /// Gets whether trace output is currently enabled.
+ ///
+ public bool TraceEnabled { get; private set; }
+
+ ///
+ /// Gets the path to the trace log file.
+ ///
+ public string TraceLogPath => m_traceLogPath;
+
+ ///
+ /// Gets the default output directory for benchmark artifacts.
+ ///
+ public static string DefaultOutputDirectory => Path.Combine(
+ AppDomain.CurrentDomain.BaseDirectory,
+ "..",
+ "..",
+ "..",
+ "Output",
+ "RenderBenchmarks");
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Path to the flags JSON file.
+ /// Path for trace log output.
+ public RenderDiagnosticsToggle(string flagsFilePath = null, string traceLogPath = null)
+ {
+ m_flagsFilePath = flagsFilePath ?? RenderScenarioDataBuilder.DefaultFlagsPath;
+ m_traceLogPath = traceLogPath ?? Path.Combine(
+ DefaultOutputDirectory,
+ "render-trace.log");
+
+ LoadFlags();
+ }
+
+ ///
+ /// Enables diagnostics and trace output.
+ ///
+ /// Whether to persist the change to the flags file.
+ public void EnableDiagnostics(bool persist = false)
+ {
+ ApplyDiagnosticsState(enabled: true, persist: persist);
+ }
+
+ ///
+ /// Disables diagnostics and trace output.
+ ///
+ /// Whether to persist the change to the flags file.
+ public void DisableDiagnostics(bool persist = false)
+ {
+ ApplyDiagnosticsState(enabled: false, persist: persist);
+ }
+
+ ///
+ /// Restores the original diagnostics state before Enable/Disable was called.
+ ///
+ public void RestoreOriginalState()
+ {
+ ApplyDiagnosticsState(enabled: m_originalDiagnosticsState, persist: false);
+ }
+
+ ///
+ /// Writes a render trace entry to the trace log.
+ ///
+ /// The rendering stage name.
+ /// The stage duration in milliseconds.
+ /// Optional context information.
+ public void WriteTraceEntry(string stage, double durationMs, string context = null)
+ {
+ if (!TraceEnabled)
+ return;
+
+ var timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fff");
+ var contextPart = string.IsNullOrEmpty(context) ? "" : $" Context={context}";
+ var entry = $"[{timestamp}] [RENDER] Stage={stage} Duration={durationMs:F3}ms{contextPart}";
+
+ Trace.WriteLine(entry);
+ }
+
+ ///
+ /// Writes an informational message to the trace log.
+ ///
+ /// The message to write.
+ public void WriteInfo(string message)
+ {
+ if (!DiagnosticsEnabled)
+ return;
+
+ var timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fff");
+ Trace.WriteLine($"[{timestamp}] [INFO] {message}");
+ }
+
+ ///
+ /// Writes a warning message to the trace log.
+ ///
+ /// The warning message.
+ public void WriteWarning(string message)
+ {
+ if (!DiagnosticsEnabled)
+ return;
+
+ var timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fff");
+ Trace.TraceWarning($"[{timestamp}] [WARN] {message}");
+ }
+
+ ///
+ /// Flushes any buffered trace output.
+ ///
+ public void Flush()
+ {
+ m_traceListener?.Flush();
+ m_traceWriter?.Flush();
+ Trace.Flush();
+ }
+
+ ///
+ /// Gets the contents of the trace log file.
+ ///
+ /// The trace log content, or empty string if not available.
+ public string GetTraceLogContent()
+ {
+ Flush();
+
+ if (!File.Exists(m_traceLogPath))
+ return string.Empty;
+
+ try
+ {
+ // Need to read without locking since we may still have the file open
+ using (var stream = new FileStream(
+ m_traceLogPath,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.ReadWrite))
+ using (var reader = new StreamReader(stream))
+ {
+ return reader.ReadToEnd();
+ }
+ }
+ catch
+ {
+ return string.Empty;
+ }
+ }
+
+ ///
+ /// Clears the trace log file.
+ ///
+ public void ClearTraceLog()
+ {
+ RemoveTraceListener();
+
+ if (File.Exists(m_traceLogPath))
+ {
+ try
+ {
+ File.Delete(m_traceLogPath);
+ }
+ catch
+ {
+ // Ignore deletion errors
+ }
+ }
+
+ if (TraceEnabled)
+ {
+ SetupTraceListener();
+ }
+ }
+
+ private void LoadFlags()
+ {
+ var flags = BenchmarkFlags.LoadFromFile(m_flagsFilePath);
+ DiagnosticsEnabled = flags.DiagnosticsEnabled;
+ TraceEnabled = flags.TraceEnabled;
+ m_originalDiagnosticsState = DiagnosticsEnabled;
+
+ if (TraceEnabled)
+ {
+ SetupTraceListener();
+ }
+ }
+
+ private void SaveFlags()
+ {
+ var flags = new BenchmarkFlags
+ {
+ DiagnosticsEnabled = DiagnosticsEnabled,
+ TraceEnabled = TraceEnabled,
+ CaptureMode = "DrawToBitmap"
+ };
+
+ var directory = Path.GetDirectoryName(m_flagsFilePath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var json = JsonConvert.SerializeObject(flags, Formatting.Indented);
+ File.WriteAllText(m_flagsFilePath, json, Encoding.UTF8);
+ }
+
+ private void ApplyDiagnosticsState(bool enabled, bool persist)
+ {
+ DiagnosticsEnabled = enabled;
+ TraceEnabled = enabled;
+
+ if (persist)
+ {
+ SaveFlags();
+ }
+
+ if (enabled)
+ {
+ SetupTraceListener();
+ }
+ else
+ {
+ RemoveTraceListener();
+ }
+ }
+
+ private void SetupTraceListener()
+ {
+ lock (s_traceListenerSync)
+ {
+ if (m_traceListener != null)
+ return; // Already set up
+
+ // Trace.Listeners is process-wide. Share a single render benchmark
+ // listener so multiple toggle instances do not duplicate log entries.
+ if (s_sharedTraceListener == null)
+ {
+ var existingListener = Trace.Listeners[TraceListenerName];
+ if (existingListener != null)
+ {
+ Trace.Listeners.Remove(existingListener);
+ existingListener.Dispose();
+ }
+
+ var directory = Path.GetDirectoryName(m_traceLogPath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ s_sharedTraceWriter = new StreamWriter(
+ m_traceLogPath,
+ append: true,
+ encoding: Encoding.UTF8)
+ {
+ AutoFlush = true
+ };
+
+ s_sharedTraceListener = new TextWriterTraceListener(s_sharedTraceWriter, TraceListenerName);
+ Trace.Listeners.Add(s_sharedTraceListener);
+ }
+
+ m_traceListener = s_sharedTraceListener;
+ m_traceWriter = s_sharedTraceWriter;
+ s_sharedTraceListenerRefCount++;
+ }
+ }
+
+ private void RemoveTraceListener()
+ {
+ lock (s_traceListenerSync)
+ {
+ if (m_traceListener != null)
+ {
+ if (s_sharedTraceListenerRefCount > 0)
+ s_sharedTraceListenerRefCount--;
+
+ if (ReferenceEquals(m_traceListener, s_sharedTraceListener) && s_sharedTraceListenerRefCount == 0)
+ {
+ Trace.Listeners.Remove(s_sharedTraceListener);
+ s_sharedTraceListener.Dispose();
+ s_sharedTraceListener = null;
+ s_sharedTraceWriter.Dispose();
+ s_sharedTraceWriter = null;
+ }
+
+ m_traceListener = null;
+ }
+
+ if (m_traceWriter != null)
+ {
+ m_traceWriter = null;
+ }
+ }
+ }
+
+ ///
+ /// Releases all resources used by the toggle.
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Releases the unmanaged resources and optionally releases the managed resources.
+ ///
+ /// True to release both managed and unmanaged resources.
+ protected virtual void Dispose(bool disposing)
+ {
+ if (m_disposed)
+ return;
+
+ if (disposing)
+ {
+ RemoveTraceListener();
+ }
+
+ m_disposed = true;
+ }
+ }
+}
diff --git a/Src/Common/RenderVerification/RenderEnvironmentValidator.cs b/Src/Common/RenderVerification/RenderEnvironmentValidator.cs
new file mode 100644
index 0000000000..b6cff89d52
--- /dev/null
+++ b/Src/Common/RenderVerification/RenderEnvironmentValidator.cs
@@ -0,0 +1,326 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Globalization;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography;
+using System.Text;
+using System.Windows.Forms;
+using Microsoft.Win32;
+
+namespace SIL.FieldWorks.Common.RenderVerification
+{
+ ///
+ /// Validates that the rendering environment is deterministic for pixel-perfect comparisons.
+ /// Checks fonts, DPI, theme settings, and other factors that affect rendering output.
+ ///
+ public class RenderEnvironmentValidator
+ {
+ ///
+ /// Gets the current environment settings.
+ ///
+ public EnvironmentSettings CurrentSettings { get; private set; }
+
+ ///
+ /// Initializes a new instance and captures current environment settings.
+ ///
+ public RenderEnvironmentValidator()
+ {
+ CurrentSettings = CaptureCurrentSettings();
+ }
+
+ ///
+ /// Gets a hash of the current environment settings.
+ ///
+ /// A SHA256 hash string of the environment settings.
+ public string GetEnvironmentHash()
+ {
+ var settingsPayload = BuildStableSettingsPayload(CurrentSettings);
+ using (var sha256 = SHA256.Create())
+ {
+ var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(settingsPayload));
+ return Convert.ToBase64String(hashBytes);
+ }
+ }
+
+ ///
+ /// Validates that the current environment matches the expected hash.
+ ///
+ /// The expected environment hash.
+ /// True if the environment matches; otherwise, false.
+ public bool Validate(string expectedHash)
+ {
+ if (string.IsNullOrEmpty(expectedHash))
+ return true; // No validation required
+
+ return GetEnvironmentHash() == expectedHash;
+ }
+
+ ///
+ /// Gets a detailed comparison between current and expected environment.
+ ///
+ /// The expected settings to compare against.
+ /// A list of differences found.
+ public List Compare(EnvironmentSettings expectedSettings)
+ {
+ var differences = new List();
+
+ if (expectedSettings == null)
+ return differences;
+
+ if (CurrentSettings.DpiX != expectedSettings.DpiX || CurrentSettings.DpiY != expectedSettings.DpiY)
+ {
+ differences.Add(new EnvironmentDifference
+ {
+ Setting = "DPI",
+ Expected = $"{expectedSettings.DpiX}x{expectedSettings.DpiY}",
+ Actual = $"{CurrentSettings.DpiX}x{CurrentSettings.DpiY}"
+ });
+ }
+
+ if (CurrentSettings.FontSmoothing != expectedSettings.FontSmoothing)
+ {
+ differences.Add(new EnvironmentDifference
+ {
+ Setting = "FontSmoothing",
+ Expected = expectedSettings.FontSmoothing.ToString(),
+ Actual = CurrentSettings.FontSmoothing.ToString()
+ });
+ }
+
+ if (CurrentSettings.ClearTypeEnabled != expectedSettings.ClearTypeEnabled)
+ {
+ differences.Add(new EnvironmentDifference
+ {
+ Setting = "ClearType",
+ Expected = expectedSettings.ClearTypeEnabled.ToString(),
+ Actual = CurrentSettings.ClearTypeEnabled.ToString()
+ });
+ }
+
+ if (CurrentSettings.ThemeName != expectedSettings.ThemeName)
+ {
+ differences.Add(new EnvironmentDifference
+ {
+ Setting = "Theme",
+ Expected = expectedSettings.ThemeName,
+ Actual = CurrentSettings.ThemeName
+ });
+ }
+
+ if (CurrentSettings.TextScaleFactor != expectedSettings.TextScaleFactor)
+ {
+ differences.Add(new EnvironmentDifference
+ {
+ Setting = "TextScaleFactor",
+ Expected = expectedSettings.TextScaleFactor.ToString(CultureInfo.InvariantCulture),
+ Actual = CurrentSettings.TextScaleFactor.ToString(CultureInfo.InvariantCulture)
+ });
+ }
+
+ return differences;
+ }
+
+ ///
+ /// Refreshes the current environment settings.
+ ///
+ public void Refresh()
+ {
+ CurrentSettings = CaptureCurrentSettings();
+ }
+
+ private static string BuildStableSettingsPayload(EnvironmentSettings settings)
+ {
+ if (settings == null)
+ return string.Empty;
+
+ var builder = new StringBuilder();
+ builder.Append("DpiX=").Append(settings.DpiX.ToString(CultureInfo.InvariantCulture)).Append('\n');
+ builder.Append("DpiY=").Append(settings.DpiY.ToString(CultureInfo.InvariantCulture)).Append('\n');
+ builder.Append("FontSmoothing=").Append(settings.FontSmoothing ? "1" : "0").Append('\n');
+ builder.Append("ClearTypeEnabled=").Append(settings.ClearTypeEnabled ? "1" : "0").Append('\n');
+ builder.Append("ThemeName=").Append(settings.ThemeName ?? string.Empty).Append('\n');
+ builder.Append("TextScaleFactor=").Append(settings.TextScaleFactor.ToString("R", CultureInfo.InvariantCulture)).Append('\n');
+ builder.Append("ScreenWidth=").Append(settings.ScreenWidth.ToString(CultureInfo.InvariantCulture)).Append('\n');
+ builder.Append("ScreenHeight=").Append(settings.ScreenHeight.ToString(CultureInfo.InvariantCulture)).Append('\n');
+ builder.Append("CultureName=").Append(settings.CultureName ?? string.Empty);
+ return builder.ToString();
+ }
+
+ private EnvironmentSettings CaptureCurrentSettings()
+ {
+ var settings = new EnvironmentSettings();
+
+ // Capture DPI settings
+ using (var graphics = Graphics.FromHwnd(IntPtr.Zero))
+ {
+ settings.DpiX = (int)graphics.DpiX;
+ settings.DpiY = (int)graphics.DpiY;
+ }
+
+ // Capture font smoothing
+ settings.FontSmoothing = GetFontSmoothing();
+ settings.ClearTypeEnabled = GetClearTypeEnabled();
+
+ // Capture theme
+ settings.ThemeName = GetCurrentTheme();
+
+ // Capture text scale factor
+ settings.TextScaleFactor = GetTextScaleFactor();
+
+ // Capture screen info
+ var screen = Screen.PrimaryScreen;
+ settings.ScreenWidth = screen.Bounds.Width;
+ settings.ScreenHeight = screen.Bounds.Height;
+
+ // Capture culture
+ settings.CultureName = CultureInfo.CurrentCulture.Name;
+
+ return settings;
+ }
+
+ private bool GetFontSmoothing()
+ {
+ try
+ {
+ bool smoothing = false;
+ SystemParametersInfo(SPI_GETFONTSMOOTHING, 0, ref smoothing, 0);
+ return smoothing;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private bool GetClearTypeEnabled()
+ {
+ try
+ {
+ int type = 0;
+ SystemParametersInfo(SPI_GETFONTSMOOTHINGTYPE, 0, ref type, 0);
+ return type == FE_FONTSMOOTHINGCLEARTYPE;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private string GetCurrentTheme()
+ {
+ try
+ {
+ using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"))
+ {
+ if (key != null)
+ {
+ var appsUseLightTheme = key.GetValue("AppsUseLightTheme");
+ if (appsUseLightTheme != null)
+ {
+ return (int)appsUseLightTheme == 1 ? "Light" : "Dark";
+ }
+ }
+ }
+ }
+ catch
+ {
+ // Ignore errors reading theme
+ }
+ return "Unknown";
+ }
+
+ private double GetTextScaleFactor()
+ {
+ try
+ {
+ using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Accessibility"))
+ {
+ if (key != null)
+ {
+ var textScaleFactor = key.GetValue("TextScaleFactor");
+ if (textScaleFactor != null)
+ {
+ return (int)textScaleFactor / 100.0;
+ }
+ }
+ }
+ }
+ catch
+ {
+ // Ignore errors
+ }
+ return 1.0;
+ }
+
+ #region Native methods
+ private const int SPI_GETFONTSMOOTHING = 0x004A;
+ private const int SPI_GETFONTSMOOTHINGTYPE = 0x200A;
+ private const int FE_FONTSMOOTHINGCLEARTYPE = 0x0002;
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern bool SystemParametersInfo(int uiAction, int uiParam, ref bool pvParam, int fWinIni);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern bool SystemParametersInfo(int uiAction, int uiParam, ref int pvParam, int fWinIni);
+ #endregion
+ }
+
+ ///
+ /// Captures the current rendering environment settings.
+ ///
+ public class EnvironmentSettings
+ {
+ /// Gets or sets the horizontal DPI.
+ public int DpiX { get; set; }
+
+ /// Gets or sets the vertical DPI.
+ public int DpiY { get; set; }
+
+ /// Gets or sets whether font smoothing is enabled.
+ public bool FontSmoothing { get; set; }
+
+ /// Gets or sets whether ClearType is enabled.
+ public bool ClearTypeEnabled { get; set; }
+
+ /// Gets or sets the current theme name.
+ public string ThemeName { get; set; }
+
+ /// Gets or sets the text scale factor.
+ public double TextScaleFactor { get; set; }
+
+ /// Gets or sets the primary screen width.
+ public int ScreenWidth { get; set; }
+
+ /// Gets or sets the primary screen height.
+ public int ScreenHeight { get; set; }
+
+ /// Gets or sets the culture name.
+ public string CultureName { get; set; }
+ }
+
+ ///
+ /// Represents a difference between expected and actual environment settings.
+ ///
+ public class EnvironmentDifference
+ {
+ /// Gets or sets the setting name.
+ public string Setting { get; set; }
+
+ /// Gets or sets the expected value.
+ public string Expected { get; set; }
+
+ /// Gets or sets the actual value.
+ public string Actual { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"{Setting}: expected '{Expected}', got '{Actual}'";
+ }
+ }
+}
diff --git a/Src/Common/RenderVerification/RenderModels.cs b/Src/Common/RenderVerification/RenderModels.cs
new file mode 100644
index 0000000000..87efe57aa9
--- /dev/null
+++ b/Src/Common/RenderVerification/RenderModels.cs
@@ -0,0 +1,77 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+
+namespace SIL.FieldWorks.Common.RenderVerification
+{
+ ///
+ /// Represents the timing result for a single render operation.
+ ///
+ public class RenderTimingResult
+ {
+ /// Gets or sets the scenario identifier.
+ public string ScenarioId { get; set; }
+
+ /// Gets or sets whether this was a cold render.
+ public bool IsColdRender { get; set; }
+
+ /// Gets or sets the render duration in milliseconds.
+ public double DurationMs { get; set; }
+
+ /// Gets or sets the timestamp of the render.
+ public DateTime Timestamp { get; set; }
+ }
+
+ ///
+ /// Specifies which view constructor pipeline a scenario exercises.
+ ///
+ public enum RenderViewType
+ {
+ /// Scripture view (StVc / GenericScriptureVc).
+ Scripture,
+
+ /// Lexical entry view (LexEntryVc with nested senses).
+ LexEntry
+ }
+
+ ///
+ /// Represents a render scenario configuration.
+ ///
+ public class RenderScenario
+ {
+ /// Gets or sets the unique scenario identifier.
+ public string Id { get; set; }
+
+ /// Gets or sets the human-readable description.
+ public string Description { get; set; }
+
+ /// Gets or sets the root object HVO for the view.
+ public int RootObjectHvo { get; set; }
+
+ /// Gets or sets the root field ID.
+ public int RootFlid { get; set; }
+
+ /// Gets or sets the fragment ID for the view constructor.
+ public int FragmentId { get; set; } = 1;
+
+ /// Gets or sets the path to the expected snapshot image.
+ public string ExpectedSnapshotPath { get; set; }
+
+ /// Gets or sets category tags for filtering.
+ public string[] Tags { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets the view type (Scripture or LexEntry).
+ /// Determines which view constructor pipeline is used for rendering.
+ ///
+ public RenderViewType ViewType { get; set; } = RenderViewType.Scripture;
+
+ ///
+ /// Gets or sets whether to simulate the XmlVc ifdata double-render pattern.
+ /// Only applies to scenarios.
+ ///
+ public bool SimulateIfDataDoubleRender { get; set; }
+ }
+}
diff --git a/Src/Common/RenderVerification/RenderScenarioDataBuilder.cs b/Src/Common/RenderVerification/RenderScenarioDataBuilder.cs
new file mode 100644
index 0000000000..055dfb1fd5
--- /dev/null
+++ b/Src/Common/RenderVerification/RenderScenarioDataBuilder.cs
@@ -0,0 +1,260 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using Newtonsoft.Json;
+
+namespace SIL.FieldWorks.Common.RenderVerification
+{
+ ///
+ /// Provides helpers for building and loading render scenario configurations.
+ ///
+ public class RenderScenarioDataBuilder
+ {
+ private readonly List m_scenarios = new List();
+
+ ///
+ /// Gets the test data directory path.
+ ///
+ public static string TestDataDirectory
+ {
+ get
+ {
+ // Handle different output path depths (e.g. Output/Debug vs. bin/Debug/net472)
+ var baseDir = AppDomain.CurrentDomain.BaseDirectory;
+
+ // Try 2 levels up (Output/Debug -> Root)
+ var path2 = Path.Combine(baseDir, "..", "..", "Src", "Common", "RootSite", "RootSiteTests", "TestData");
+ if (Directory.Exists(path2)) return Path.GetFullPath(path2);
+
+ // Try 3 levels up (bin/Debug/net -> Root)
+ var path3 = Path.Combine(baseDir, "..", "..", "..", "Src", "Common", "RootSite", "RootSiteTests", "TestData");
+ if (Directory.Exists(path3)) return Path.GetFullPath(path3);
+
+ // Fallback to 2 levels and let it fail with full path for debugging
+ return Path.GetFullPath(path2);
+ }
+ }
+
+ ///
+ /// Gets the default scenarios file path.
+ ///
+ public static string DefaultScenariosPath => Path.Combine(TestDataDirectory, "RenderBenchmarkScenarios.json");
+
+ ///
+ /// Gets the default flags file path.
+ ///
+ public static string DefaultFlagsPath => Path.Combine(TestDataDirectory, "RenderBenchmarkFlags.json");
+
+ ///
+ /// Gets the snapshots directory path.
+ ///
+ public static string SnapshotsDirectory => Path.Combine(TestDataDirectory, "RenderSnapshots");
+
+ ///
+ /// Creates a new scenario builder.
+ ///
+ /// A new builder instance.
+ public static RenderScenarioDataBuilder Create()
+ {
+ return new RenderScenarioDataBuilder();
+ }
+
+ ///
+ /// Adds a simple scenario (minimal entry with one sense).
+ ///
+ /// The root object HVO.
+ /// The root field ID.
+ /// The builder for chaining.
+ public RenderScenarioDataBuilder AddSimpleScenario(int rootHvo, int rootFlid)
+ {
+ m_scenarios.Add(new RenderScenario
+ {
+ Id = "simple",
+ Description = "Minimal lexical entry with one sense, one definition",
+ RootObjectHvo = rootHvo,
+ RootFlid = rootFlid,
+ ExpectedSnapshotPath = Path.Combine(SnapshotsDirectory, "simple.png"),
+ Tags = new[] { "baseline", "minimal" }
+ });
+ return this;
+ }
+
+ ///
+ /// Adds a medium complexity scenario.
+ ///
+ /// The root object HVO.
+ /// The root field ID.
+ /// The builder for chaining.
+ public RenderScenarioDataBuilder AddMediumScenario(int rootHvo, int rootFlid)
+ {
+ m_scenarios.Add(new RenderScenario
+ {
+ Id = "medium",
+ Description = "Entry with 3 senses, multiple definitions, example sentences",
+ RootObjectHvo = rootHvo,
+ RootFlid = rootFlid,
+ ExpectedSnapshotPath = Path.Combine(SnapshotsDirectory, "medium.png"),
+ Tags = new[] { "typical", "multi-sense" }
+ });
+ return this;
+ }
+
+ ///
+ /// Adds a complex scenario with many senses.
+ ///
+ /// The root object HVO.
+ /// The root field ID.
+ /// The builder for chaining.
+ public RenderScenarioDataBuilder AddComplexScenario(int rootHvo, int rootFlid)
+ {
+ m_scenarios.Add(new RenderScenario
+ {
+ Id = "complex",
+ Description = "Entry with 10+ senses, subsenses, extensive cross-references",
+ RootObjectHvo = rootHvo,
+ RootFlid = rootFlid,
+ ExpectedSnapshotPath = Path.Combine(SnapshotsDirectory, "complex.png"),
+ Tags = new[] { "stress", "multi-sense", "cross-refs" }
+ });
+ return this;
+ }
+
+ ///
+ /// Adds a deep-nested scenario.
+ ///
+ /// The root object HVO.
+ /// The root field ID.
+ /// The builder for chaining.
+ public RenderScenarioDataBuilder AddDeepNestedScenario(int rootHvo, int rootFlid)
+ {
+ m_scenarios.Add(new RenderScenario
+ {
+ Id = "deep-nested",
+ Description = "Entry with deeply nested subsenses (5+ levels)",
+ RootObjectHvo = rootHvo,
+ RootFlid = rootFlid,
+ ExpectedSnapshotPath = Path.Combine(SnapshotsDirectory, "deep-nested.png"),
+ Tags = new[] { "nested", "hierarchy", "stress" }
+ });
+ return this;
+ }
+
+ ///
+ /// Adds a custom-field-heavy scenario.
+ ///
+ /// The root object HVO.
+ /// The root field ID.
+ /// The builder for chaining.
+ public RenderScenarioDataBuilder AddCustomFieldHeavyScenario(int rootHvo, int rootFlid)
+ {
+ m_scenarios.Add(new RenderScenario
+ {
+ Id = "custom-field-heavy",
+ Description = "Entry with many custom fields of various types",
+ RootObjectHvo = rootHvo,
+ RootFlid = rootFlid,
+ ExpectedSnapshotPath = Path.Combine(SnapshotsDirectory, "custom-field-heavy.png"),
+ Tags = new[] { "custom-fields", "extensibility" }
+ });
+ return this;
+ }
+
+ ///
+ /// Adds a custom scenario.
+ ///
+ /// The scenario to add.
+ /// The builder for chaining.
+ public RenderScenarioDataBuilder AddScenario(RenderScenario scenario)
+ {
+ if (scenario == null)
+ throw new ArgumentNullException(nameof(scenario));
+
+ m_scenarios.Add(scenario);
+ return this;
+ }
+
+ ///
+ /// Builds and returns the list of scenarios.
+ ///
+ /// The built scenarios.
+ public List Build()
+ {
+ return m_scenarios.ToList();
+ }
+
+ ///
+ /// Saves the scenarios to a JSON file.
+ ///
+ /// The output file path.
+ public void SaveToFile(string outputPath = null)
+ {
+ outputPath = outputPath ?? DefaultScenariosPath;
+
+ var directory = Path.GetDirectoryName(outputPath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var wrapper = new ScenariosWrapper { Scenarios = m_scenarios };
+ var json = JsonConvert.SerializeObject(wrapper, Formatting.Indented);
+ File.WriteAllText(outputPath, json, Encoding.UTF8);
+ }
+
+ ///
+ /// Loads scenarios from a JSON file.
+ ///
+ /// The input file path.
+ /// The loaded scenarios, or empty list if file not found.
+ public static List LoadFromFile(string inputPath = null)
+ {
+ inputPath = inputPath ?? DefaultScenariosPath;
+
+ if (!File.Exists(inputPath))
+ return new List();
+
+ var json = File.ReadAllText(inputPath, Encoding.UTF8);
+ var wrapper = JsonConvert.DeserializeObject(json);
+ return wrapper?.Scenarios ?? new List();
+ }
+
+ ///
+ /// Creates the standard five-scenario suite.
+ ///
+ /// The root object HVO for all scenarios.
+ /// The root field ID for all scenarios.
+ /// A builder with all five standard scenarios.
+ public static RenderScenarioDataBuilder CreateStandardSuite(int rootHvo, int rootFlid)
+ {
+ return Create()
+ .AddSimpleScenario(rootHvo, rootFlid)
+ .AddMediumScenario(rootHvo, rootFlid)
+ .AddComplexScenario(rootHvo, rootFlid)
+ .AddDeepNestedScenario(rootHvo, rootFlid)
+ .AddCustomFieldHeavyScenario(rootHvo, rootFlid);
+ }
+
+ ///
+ /// Gets scenarios filtered by tag.
+ ///
+ /// The scenarios to filter.
+ /// The tag to filter by.
+ /// Scenarios matching the tag.
+ public static IEnumerable FilterByTag(IEnumerable scenarios, string tag)
+ {
+ return scenarios.Where(s => s.Tags != null && s.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase));
+ }
+
+ private class ScenariosWrapper
+ {
+ [JsonProperty("scenarios")]
+ public List Scenarios { get; set; } = new List();
+ }
+ }
+}
diff --git a/Src/Common/RenderVerification/RenderSnapshotVerifier.cs b/Src/Common/RenderVerification/RenderSnapshotVerifier.cs
new file mode 100644
index 0000000000..7cf86e6588
--- /dev/null
+++ b/Src/Common/RenderVerification/RenderSnapshotVerifier.cs
@@ -0,0 +1,692 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.Drawing.Text;
+using System.Globalization;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Text;
+using Newtonsoft.Json;
+
+namespace SIL.FieldWorks.Common.RenderVerification
+{
+ public static class RenderSnapshotVerifier
+ {
+ private const string UpdateBaselinesEnvVar = "FW_UPDATE_RENDER_BASELINES";
+ private const string FontQualityEnvVar = "FW_FONT_QUALITY";
+ private const int MaxAllowedPixelDifferences = 4;
+ private const string DeterministicRenderFontFamily = "Segoe UI";
+ private const int DpiAwarenessInvalid = -1;
+ private const int DpiAwarenessUnaware = 0;
+ private const int DpiAwarenessSystemAware = 1;
+ private const int DpiAwarenessPerMonitorAware = 2;
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr GetThreadDpiAwarenessContext();
+
+ [DllImport("user32.dll")]
+ private static extern int GetAwarenessFromDpiAwarenessContext(IntPtr dpiContext);
+
+ public static string GetSourceFileDirectory([CallerFilePath] string sourceFile = "")
+ {
+ return Path.GetDirectoryName(sourceFile);
+ }
+
+ public static RenderBaselineVerificationResult Verify(Bitmap actualBitmap, string directory, string name, string scenarioId)
+ {
+ if (actualBitmap == null)
+ throw new ArgumentNullException(nameof(actualBitmap));
+ if (string.IsNullOrEmpty(directory))
+ throw new ArgumentException("A snapshot directory is required.", nameof(directory));
+ if (string.IsNullOrEmpty(name))
+ throw new ArgumentException("A snapshot name is required.", nameof(name));
+ if (string.IsNullOrEmpty(scenarioId))
+ throw new ArgumentException("A scenario identifier is required.", nameof(scenarioId));
+
+ string snapshotBasePath = Path.Combine(directory, name);
+ string verifiedPath = snapshotBasePath + ".verified.png";
+ string verifiedMetadataPath = snapshotBasePath + ".verified.json";
+ string receivedPath = snapshotBasePath + ".received.png";
+ string receivedMetadataPath = snapshotBasePath + ".received.json";
+ string diffPath = snapshotBasePath + ".diff.png";
+ string diffMetadataPath = snapshotBasePath + ".diff.json";
+
+ var currentArtifact = CreateCurrentArtifact(actualBitmap, scenarioId, name, receivedPath, receivedMetadataPath);
+ RefreshVerifiedBaselineIfRequested(actualBitmap, currentArtifact, verifiedPath, verifiedMetadataPath);
+
+ DeleteIfPresent(diffPath);
+ DeleteIfPresent(diffMetadataPath);
+
+ if (!File.Exists(verifiedPath))
+ {
+ actualBitmap.Save(receivedPath, ImageFormat.Png);
+ SaveJson(receivedMetadataPath, currentArtifact.Metadata);
+
+ return new RenderBaselineVerificationResult
+ {
+ Passed = false,
+ FailureMessage = BuildMissingBaselineMessage(scenarioId, verifiedPath, currentArtifact),
+ VerifiedPath = verifiedPath,
+ VerifiedMetadataPath = verifiedMetadataPath,
+ ReceivedPath = receivedPath,
+ ReceivedMetadataPath = receivedMetadataPath,
+ DiffPath = diffPath,
+ DiffMetadataPath = diffMetadataPath
+ };
+ }
+
+ using (var expectedBitmap = new Bitmap(verifiedPath))
+ {
+ var savedArtifact = LoadSavedArtifact(expectedBitmap, verifiedPath, verifiedMetadataPath);
+ var diffSummary = CompareBitmaps(expectedBitmap, actualBitmap);
+ if (diffSummary.DifferentPixelCount <= MaxAllowedPixelDifferences)
+ {
+ DeleteIfPresent(receivedPath);
+ DeleteIfPresent(receivedMetadataPath);
+ DeleteIfPresent(diffPath);
+ DeleteIfPresent(diffMetadataPath);
+ return new RenderBaselineVerificationResult
+ {
+ Passed = true,
+ VerifiedPath = verifiedPath,
+ VerifiedMetadataPath = verifiedMetadataPath,
+ ReceivedPath = receivedPath,
+ ReceivedMetadataPath = receivedMetadataPath,
+ DiffPath = diffPath,
+ DiffMetadataPath = diffMetadataPath,
+ DiffSummary = diffSummary
+ };
+ }
+
+ using (var diffBitmap = CreateDiffBitmap(expectedBitmap, actualBitmap))
+ {
+ diffBitmap.Save(diffPath, ImageFormat.Png);
+ }
+
+ actualBitmap.Save(receivedPath, ImageFormat.Png);
+ SaveJson(receivedMetadataPath, currentArtifact.Metadata);
+
+ var comparisonReport = new RenderSnapshotComparisonReport
+ {
+ ScenarioId = scenarioId,
+ SnapshotName = name,
+ AllowedDifferentPixelCount = MaxAllowedPixelDifferences,
+ SavedBaseline = savedArtifact,
+ CurrentRun = currentArtifact,
+ Diff = diffSummary,
+ Differences = BuildDifferences(savedArtifact, currentArtifact)
+ };
+ SaveJson(diffMetadataPath, comparisonReport);
+
+ return new RenderBaselineVerificationResult
+ {
+ Passed = false,
+ FailureMessage = BuildFailureMessage(scenarioId, diffPath, diffMetadataPath, comparisonReport),
+ VerifiedPath = verifiedPath,
+ VerifiedMetadataPath = verifiedMetadataPath,
+ ReceivedPath = receivedPath,
+ ReceivedMetadataPath = receivedMetadataPath,
+ DiffPath = diffPath,
+ DiffMetadataPath = diffMetadataPath,
+ DiffSummary = diffSummary
+ };
+ }
+ }
+
+ private static RenderSnapshotArtifact CreateCurrentArtifact(
+ Bitmap actualBitmap,
+ string scenarioId,
+ string snapshotName,
+ string receivedPath,
+ string receivedMetadataPath)
+ {
+ return new RenderSnapshotArtifact
+ {
+ ArtifactKind = "current",
+ ImagePath = receivedPath,
+ MetadataPath = receivedMetadataPath,
+ ImageWidth = actualBitmap.Width,
+ ImageHeight = actualBitmap.Height,
+ MetadataAvailable = true,
+ Metadata = CaptureMetadata(actualBitmap, scenarioId, snapshotName)
+ };
+ }
+
+ private static RenderSnapshotArtifact LoadSavedArtifact(Bitmap expectedBitmap, string verifiedPath, string verifiedMetadataPath)
+ {
+ var artifact = new RenderSnapshotArtifact
+ {
+ ArtifactKind = "saved baseline",
+ ImagePath = verifiedPath,
+ MetadataPath = verifiedMetadataPath,
+ ImageWidth = expectedBitmap.Width,
+ ImageHeight = expectedBitmap.Height
+ };
+
+ var fileInfo = new FileInfo(verifiedPath);
+ if (fileInfo.Exists)
+ artifact.FileLastWriteUtc = fileInfo.LastWriteTimeUtc;
+
+ artifact.Metadata = LoadMetadata(verifiedMetadataPath, out var loadError);
+ artifact.MetadataAvailable = artifact.Metadata != null;
+ artifact.MetadataLoadError = loadError;
+ return artifact;
+ }
+
+ private static RenderSnapshotMetadata CaptureMetadata(Bitmap actualBitmap, string scenarioId, string snapshotName)
+ {
+ var validator = new RenderEnvironmentValidator();
+ return new RenderSnapshotMetadata
+ {
+ ScenarioId = scenarioId,
+ SnapshotName = snapshotName,
+ CapturedAtUtc = DateTime.UtcNow,
+ ImageWidth = actualBitmap.Width,
+ ImageHeight = actualBitmap.Height,
+ MachineName = Environment.MachineName,
+ OsVersion = Environment.OSVersion.VersionString,
+ EnvironmentHash = validator.GetEnvironmentHash(),
+ Environment = validator.CurrentSettings,
+ DpiAwareness = GetDpiAwarenessDescription(),
+ FontQuality = Environment.GetEnvironmentVariable(FontQualityEnvVar) ?? string.Empty,
+ DeterministicFontFamily = DeterministicRenderFontFamily,
+ DeterministicFontInstalled = IsFontInstalled(DeterministicRenderFontFamily)
+ };
+ }
+
+ private static void RefreshVerifiedBaselineIfRequested(
+ Bitmap bitmap,
+ RenderSnapshotArtifact currentArtifact,
+ string verifiedPath,
+ string verifiedMetadataPath)
+ {
+ if (!string.Equals(Environment.GetEnvironmentVariable(UpdateBaselinesEnvVar), "1", StringComparison.Ordinal))
+ return;
+
+ bitmap.Save(verifiedPath, ImageFormat.Png);
+ SaveJson(verifiedMetadataPath, CloneMetadata(currentArtifact.Metadata));
+ }
+
+ private static RenderSnapshotMetadata CloneMetadata(RenderSnapshotMetadata metadata)
+ {
+ if (metadata == null)
+ return null;
+
+ return new RenderSnapshotMetadata
+ {
+ ScenarioId = metadata.ScenarioId,
+ SnapshotName = metadata.SnapshotName,
+ CapturedAtUtc = metadata.CapturedAtUtc,
+ ImageWidth = metadata.ImageWidth,
+ ImageHeight = metadata.ImageHeight,
+ MachineName = metadata.MachineName,
+ OsVersion = metadata.OsVersion,
+ EnvironmentHash = metadata.EnvironmentHash,
+ Environment = metadata.Environment == null
+ ? null
+ : new EnvironmentSettings
+ {
+ DpiX = metadata.Environment.DpiX,
+ DpiY = metadata.Environment.DpiY,
+ FontSmoothing = metadata.Environment.FontSmoothing,
+ ClearTypeEnabled = metadata.Environment.ClearTypeEnabled,
+ ThemeName = metadata.Environment.ThemeName,
+ TextScaleFactor = metadata.Environment.TextScaleFactor,
+ ScreenWidth = metadata.Environment.ScreenWidth,
+ ScreenHeight = metadata.Environment.ScreenHeight,
+ CultureName = metadata.Environment.CultureName
+ },
+ DpiAwareness = metadata.DpiAwareness,
+ FontQuality = metadata.FontQuality,
+ DeterministicFontFamily = metadata.DeterministicFontFamily,
+ DeterministicFontInstalled = metadata.DeterministicFontInstalled
+ };
+ }
+
+ private static List BuildDifferences(RenderSnapshotArtifact savedArtifact, RenderSnapshotArtifact currentArtifact)
+ {
+ var differences = new List();
+ AddDifference(differences, "image", FormatImageSize(savedArtifact.ImageWidth, savedArtifact.ImageHeight), FormatImageSize(currentArtifact.ImageWidth, currentArtifact.ImageHeight));
+
+ if (!savedArtifact.MetadataAvailable || currentArtifact.Metadata == null)
+ return differences;
+
+ AddDifference(differences, "environmentHash", Shorten(savedArtifact.Metadata.EnvironmentHash), Shorten(currentArtifact.Metadata.EnvironmentHash));
+ AddDifference(differences, "DPI", FormatDpi(savedArtifact.Metadata.Environment), FormatDpi(currentArtifact.Metadata.Environment));
+ AddDifference(differences, "screen", FormatScreen(savedArtifact.Metadata.Environment), FormatScreen(currentArtifact.Metadata.Environment));
+ AddDifference(differences, "textScale", FormatTextScale(savedArtifact.Metadata.Environment), FormatTextScale(currentArtifact.Metadata.Environment));
+ AddDifference(differences, "dpiAwareness", savedArtifact.Metadata.DpiAwareness, currentArtifact.Metadata.DpiAwareness);
+ AddDifference(differences, "fontSmoothing", FormatBoolean(savedArtifact.Metadata.Environment != null && savedArtifact.Metadata.Environment.FontSmoothing), FormatBoolean(currentArtifact.Metadata.Environment != null && currentArtifact.Metadata.Environment.FontSmoothing));
+ AddDifference(differences, "clearType", FormatBoolean(savedArtifact.Metadata.Environment != null && savedArtifact.Metadata.Environment.ClearTypeEnabled), FormatBoolean(currentArtifact.Metadata.Environment != null && currentArtifact.Metadata.Environment.ClearTypeEnabled));
+ AddDifference(differences, "theme", savedArtifact.Metadata.Environment != null ? savedArtifact.Metadata.Environment.ThemeName : string.Empty, currentArtifact.Metadata.Environment != null ? currentArtifact.Metadata.Environment.ThemeName : string.Empty);
+ AddDifference(differences, "culture", savedArtifact.Metadata.Environment != null ? savedArtifact.Metadata.Environment.CultureName : string.Empty, currentArtifact.Metadata.Environment != null ? currentArtifact.Metadata.Environment.CultureName : string.Empty);
+ AddDifference(differences, "FW_FONT_QUALITY", savedArtifact.Metadata.FontQuality, currentArtifact.Metadata.FontQuality);
+ AddDifference(differences, "deterministicFontInstalled", FormatBoolean(savedArtifact.Metadata.DeterministicFontInstalled), FormatBoolean(currentArtifact.Metadata.DeterministicFontInstalled));
+ return differences;
+ }
+
+ private static void AddDifference(List differences, string label, string savedValue, string currentValue)
+ {
+ if (string.Equals(savedValue ?? string.Empty, currentValue ?? string.Empty, StringComparison.Ordinal))
+ return;
+
+ differences.Add(string.Format(CultureInfo.InvariantCulture, "{0} saved={1} current={2}", label, savedValue ?? "(null)", currentValue ?? "(null)"));
+ }
+
+ private static string BuildMissingBaselineMessage(string scenarioId, string verifiedPath, RenderSnapshotArtifact currentArtifact)
+ {
+ var builder = new StringBuilder();
+ builder.AppendFormat(CultureInfo.InvariantCulture,
+ "Missing verified render baseline for '{0}'. Review and accept {1} as the new baseline.",
+ scenarioId,
+ currentArtifact.ImagePath);
+ builder.AppendLine();
+ builder.AppendLine(FormatArtifactLine("Current run", currentArtifact));
+ builder.AppendFormat(CultureInfo.InvariantCulture,
+ "Expected baseline location: {0}. Current metadata: {1}.",
+ verifiedPath,
+ currentArtifact.MetadataPath);
+ return builder.ToString();
+ }
+
+ private static string BuildFailureMessage(
+ string scenarioId,
+ string diffPath,
+ string diffMetadataPath,
+ RenderSnapshotComparisonReport report)
+ {
+ var builder = new StringBuilder();
+ builder.AppendFormat(CultureInfo.InvariantCulture,
+ "Render output for '{0}' differed from baseline by {1} pixels; {2} or fewer differences are allowed.",
+ scenarioId,
+ report.Diff.DifferentPixelCount,
+ report.AllowedDifferentPixelCount);
+ builder.AppendLine();
+ builder.AppendLine(FormatArtifactLine("Saved baseline", report.SavedBaseline));
+ builder.AppendLine(FormatArtifactLine("Current run", report.CurrentRun));
+ builder.AppendFormat(CultureInfo.InvariantCulture,
+ "Diff composition: inBounds={0}; savedOnly={1}; currentOnly={2}; region={3}.",
+ report.Diff.InBoundsPixelDifferences,
+ report.Diff.ExpectedOnlyPixelDifferences,
+ report.Diff.ActualOnlyPixelDifferences,
+ FormatDiffRegion(report.Diff));
+ builder.AppendLine();
+ if (report.Differences.Count > 0)
+ {
+ builder.Append("Key differences: ");
+ builder.Append(string.Join("; ", report.Differences));
+ builder.AppendLine();
+ }
+ builder.AppendFormat(CultureInfo.InvariantCulture,
+ "Artifacts: diff={0}; received={1}; currentMetadata={2}; comparison={3}.",
+ diffPath,
+ report.CurrentRun.ImagePath,
+ report.CurrentRun.MetadataPath,
+ diffMetadataPath);
+ return builder.ToString();
+ }
+
+ private static string FormatArtifactLine(string label, RenderSnapshotArtifact artifact)
+ {
+ var builder = new StringBuilder();
+ builder.Append(label);
+ builder.Append(": image=");
+ builder.Append(FormatImageSize(artifact.ImageWidth, artifact.ImageHeight));
+ if (artifact.FileLastWriteUtc.HasValue)
+ {
+ builder.Append("; lastWriteUtc=");
+ builder.Append(artifact.FileLastWriteUtc.Value.ToString("O", CultureInfo.InvariantCulture));
+ }
+ if (!artifact.MetadataAvailable || artifact.Metadata == null)
+ {
+ builder.Append("; metadata unavailable");
+ if (!string.IsNullOrEmpty(artifact.MetadataLoadError))
+ {
+ builder.Append(" (");
+ builder.Append(artifact.MetadataLoadError);
+ builder.Append(")");
+ }
+ return builder.ToString();
+ }
+
+ builder.Append("; captured=");
+ builder.Append(artifact.Metadata.CapturedAtUtc.ToString("O", CultureInfo.InvariantCulture));
+ builder.Append("; envHash=");
+ builder.Append(Shorten(artifact.Metadata.EnvironmentHash));
+ builder.Append("; DPI=");
+ builder.Append(FormatDpi(artifact.Metadata.Environment));
+ builder.Append("; screen=");
+ builder.Append(FormatScreen(artifact.Metadata.Environment));
+ builder.Append("; textScale=");
+ builder.Append(FormatTextScale(artifact.Metadata.Environment));
+ builder.Append("; dpiAwareness=");
+ builder.Append(artifact.Metadata.DpiAwareness);
+ builder.Append("; fontSmoothing=");
+ builder.Append(FormatBoolean(artifact.Metadata.Environment != null && artifact.Metadata.Environment.FontSmoothing));
+ builder.Append("; clearType=");
+ builder.Append(FormatBoolean(artifact.Metadata.Environment != null && artifact.Metadata.Environment.ClearTypeEnabled));
+ builder.Append("; theme=");
+ builder.Append(artifact.Metadata.Environment != null ? artifact.Metadata.Environment.ThemeName : string.Empty);
+ builder.Append("; culture=");
+ builder.Append(artifact.Metadata.Environment != null ? artifact.Metadata.Environment.CultureName : string.Empty);
+ builder.Append("; FW_FONT_QUALITY=");
+ builder.Append(string.IsNullOrEmpty(artifact.Metadata.FontQuality) ? "(unset)" : artifact.Metadata.FontQuality);
+ builder.Append("; font='");
+ builder.Append(artifact.Metadata.DeterministicFontFamily);
+ builder.Append("' installed=");
+ builder.Append(FormatBoolean(artifact.Metadata.DeterministicFontInstalled));
+ builder.Append("; machine=");
+ builder.Append(artifact.Metadata.MachineName);
+ builder.Append("; os=");
+ builder.Append(artifact.Metadata.OsVersion);
+ return builder.ToString();
+ }
+
+ private static string FormatDiffRegion(RenderPixelDiffSummary diffSummary)
+ {
+ if (!diffSummary.MinX.HasValue || !diffSummary.MinY.HasValue || !diffSummary.MaxX.HasValue || !diffSummary.MaxY.HasValue)
+ return "none";
+
+ return string.Format(
+ CultureInfo.InvariantCulture,
+ "x={0}..{1}, y={2}..{3}, size={4}x{5}",
+ diffSummary.MinX.Value,
+ diffSummary.MaxX.Value,
+ diffSummary.MinY.Value,
+ diffSummary.MaxY.Value,
+ diffSummary.DiffRegionWidth,
+ diffSummary.DiffRegionHeight);
+ }
+
+ private static string FormatImageSize(int width, int height)
+ {
+ return string.Format(CultureInfo.InvariantCulture, "{0}x{1}", width, height);
+ }
+
+ private static string FormatDpi(EnvironmentSettings settings)
+ {
+ if (settings == null)
+ return "unknown";
+ return string.Format(CultureInfo.InvariantCulture, "{0}x{1}", settings.DpiX, settings.DpiY);
+ }
+
+ private static string FormatScreen(EnvironmentSettings settings)
+ {
+ if (settings == null)
+ return "unknown";
+ return string.Format(CultureInfo.InvariantCulture, "{0}x{1}", settings.ScreenWidth, settings.ScreenHeight);
+ }
+
+ private static string FormatTextScale(EnvironmentSettings settings)
+ {
+ if (settings == null)
+ return "unknown";
+ return settings.TextScaleFactor.ToString("0.###", CultureInfo.InvariantCulture);
+ }
+
+ private static string FormatBoolean(bool value)
+ {
+ return value ? "true" : "false";
+ }
+
+ private static string Shorten(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return "unknown";
+ return value.Length <= 12 ? value : value.Substring(0, 12);
+ }
+
+ private static string GetDpiAwarenessDescription()
+ {
+ try
+ {
+ var context = GetThreadDpiAwarenessContext();
+ int awareness = GetAwarenessFromDpiAwarenessContext(context);
+ switch (awareness)
+ {
+ case DpiAwarenessUnaware:
+ return "Unaware";
+ case DpiAwarenessSystemAware:
+ return "SystemAware";
+ case DpiAwarenessPerMonitorAware:
+ return "PerMonitorAware";
+ case DpiAwarenessInvalid:
+ return "Invalid";
+ default:
+ return string.Format(CultureInfo.InvariantCulture, "Unknown({0})", awareness);
+ }
+ }
+ catch (DllNotFoundException)
+ {
+ return "Unavailable";
+ }
+ catch (EntryPointNotFoundException)
+ {
+ return "Unavailable";
+ }
+ catch
+ {
+ return "Unknown";
+ }
+ }
+
+ private static bool IsFontInstalled(string fontFamily)
+ {
+ try
+ {
+ using (var installedFonts = new InstalledFontCollection())
+ {
+ foreach (var family in installedFonts.Families)
+ {
+ if (string.Equals(family.Name, fontFamily, StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+ }
+ }
+ catch
+ {
+ }
+
+ return false;
+ }
+
+ private static RenderSnapshotMetadata LoadMetadata(string metadataPath, out string loadError)
+ {
+ loadError = null;
+ if (!File.Exists(metadataPath))
+ return null;
+
+ try
+ {
+ return JsonConvert.DeserializeObject(File.ReadAllText(metadataPath, Encoding.UTF8));
+ }
+ catch (Exception ex)
+ {
+ loadError = ex.GetType().Name + ": " + ex.Message;
+ return null;
+ }
+ }
+
+ private static void SaveJson(string path, T value)
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ Directory.CreateDirectory(directory);
+
+ File.WriteAllText(
+ path,
+ JsonConvert.SerializeObject(value, Formatting.Indented, new JsonSerializerSettings
+ {
+ NullValueHandling = NullValueHandling.Ignore
+ }),
+ Encoding.UTF8);
+ }
+
+ private static void DeleteIfPresent(string path)
+ {
+ if (File.Exists(path))
+ File.Delete(path);
+ }
+
+ private static RenderPixelDiffSummary CompareBitmaps(Bitmap expectedBitmap, Bitmap actualBitmap)
+ {
+ int maxWidth = Math.Max(expectedBitmap.Width, actualBitmap.Width);
+ int maxHeight = Math.Max(expectedBitmap.Height, actualBitmap.Height);
+ var summary = new RenderPixelDiffSummary();
+
+ for (int y = 0; y < maxHeight; y++)
+ {
+ for (int x = 0; x < maxWidth; x++)
+ {
+ bool expectedInBounds = x < expectedBitmap.Width && y < expectedBitmap.Height;
+ bool actualInBounds = x < actualBitmap.Width && y < actualBitmap.Height;
+
+ if (!expectedInBounds || !actualInBounds)
+ {
+ summary.DifferentPixelCount++;
+ if (expectedInBounds)
+ summary.ExpectedOnlyPixelDifferences++;
+ else if (actualInBounds)
+ summary.ActualOnlyPixelDifferences++;
+ UpdateDiffBounds(summary, x, y);
+ continue;
+ }
+
+ if (expectedBitmap.GetPixel(x, y) == actualBitmap.GetPixel(x, y))
+ continue;
+
+ summary.DifferentPixelCount++;
+ summary.InBoundsPixelDifferences++;
+ UpdateDiffBounds(summary, x, y);
+ }
+ }
+
+ if (summary.MinX.HasValue && summary.MaxX.HasValue)
+ summary.DiffRegionWidth = summary.MaxX.Value - summary.MinX.Value + 1;
+ if (summary.MinY.HasValue && summary.MaxY.HasValue)
+ summary.DiffRegionHeight = summary.MaxY.Value - summary.MinY.Value + 1;
+
+ return summary;
+ }
+
+ private static void UpdateDiffBounds(RenderPixelDiffSummary summary, int x, int y)
+ {
+ if (!summary.MinX.HasValue || x < summary.MinX.Value)
+ summary.MinX = x;
+ if (!summary.MaxX.HasValue || x > summary.MaxX.Value)
+ summary.MaxX = x;
+ if (!summary.MinY.HasValue || y < summary.MinY.Value)
+ summary.MinY = y;
+ if (!summary.MaxY.HasValue || y > summary.MaxY.Value)
+ summary.MaxY = y;
+ }
+
+ private static Bitmap CreateDiffBitmap(Bitmap expectedBitmap, Bitmap actualBitmap)
+ {
+ int maxWidth = Math.Max(expectedBitmap.Width, actualBitmap.Width);
+ int maxHeight = Math.Max(expectedBitmap.Height, actualBitmap.Height);
+ var diffBitmap = new Bitmap(maxWidth, maxHeight);
+
+ for (int y = 0; y < maxHeight; y++)
+ {
+ for (int x = 0; x < maxWidth; x++)
+ {
+ Color expected = x < expectedBitmap.Width && y < expectedBitmap.Height
+ ? expectedBitmap.GetPixel(x, y)
+ : Color.White;
+ Color actual = x < actualBitmap.Width && y < actualBitmap.Height
+ ? actualBitmap.GetPixel(x, y)
+ : Color.White;
+
+ diffBitmap.SetPixel(x, y, CreateDiffPixel(expected, actual));
+ }
+ }
+
+ return diffBitmap;
+ }
+
+ private static Color CreateDiffPixel(Color expected, Color actual)
+ {
+ return Color.FromArgb(
+ 255,
+ ScaleDiffChannel(expected.R, actual.R),
+ ScaleDiffChannel(expected.G, actual.G),
+ ScaleDiffChannel(expected.B, actual.B));
+ }
+
+ private static int ScaleDiffChannel(int expected, int actual)
+ {
+ return Math.Min(255, Math.Abs(expected - actual) * 4);
+ }
+ }
+
+ public sealed class RenderBaselineVerificationResult
+ {
+ public bool Passed { get; set; }
+ public string FailureMessage { get; set; }
+ public string VerifiedPath { get; set; }
+ public string VerifiedMetadataPath { get; set; }
+ public string ReceivedPath { get; set; }
+ public string ReceivedMetadataPath { get; set; }
+ public string DiffPath { get; set; }
+ public string DiffMetadataPath { get; set; }
+ public RenderPixelDiffSummary DiffSummary { get; set; }
+ }
+
+ public sealed class RenderSnapshotMetadata
+ {
+ public string ScenarioId { get; set; }
+ public string SnapshotName { get; set; }
+ public DateTime CapturedAtUtc { get; set; }
+ public int ImageWidth { get; set; }
+ public int ImageHeight { get; set; }
+ public string MachineName { get; set; }
+ public string OsVersion { get; set; }
+ public string EnvironmentHash { get; set; }
+ public EnvironmentSettings Environment { get; set; }
+ public string DpiAwareness { get; set; }
+ public string FontQuality { get; set; }
+ public string DeterministicFontFamily { get; set; }
+ public bool DeterministicFontInstalled { get; set; }
+ }
+
+ public sealed class RenderSnapshotArtifact
+ {
+ public string ArtifactKind { get; set; }
+ public string ImagePath { get; set; }
+ public string MetadataPath { get; set; }
+ public int ImageWidth { get; set; }
+ public int ImageHeight { get; set; }
+ public DateTime? FileLastWriteUtc { get; set; }
+ public bool MetadataAvailable { get; set; }
+ public string MetadataLoadError { get; set; }
+ public RenderSnapshotMetadata Metadata { get; set; }
+ }
+
+ public sealed class RenderSnapshotComparisonReport
+ {
+ public string ScenarioId { get; set; }
+ public string SnapshotName { get; set; }
+ public int AllowedDifferentPixelCount { get; set; }
+ public RenderSnapshotArtifact SavedBaseline { get; set; }
+ public RenderSnapshotArtifact CurrentRun { get; set; }
+ public RenderPixelDiffSummary Diff { get; set; }
+ public List Differences { get; set; } = new List();
+ }
+
+ public sealed class RenderPixelDiffSummary
+ {
+ public int DifferentPixelCount { get; set; }
+ public int InBoundsPixelDifferences { get; set; }
+ public int ExpectedOnlyPixelDifferences { get; set; }
+ public int ActualOnlyPixelDifferences { get; set; }
+ public int? MinX { get; set; }
+ public int? MinY { get; set; }
+ public int? MaxX { get; set; }
+ public int? MaxY { get; set; }
+ public int DiffRegionWidth { get; set; }
+ public int DiffRegionHeight { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/Src/Common/RenderVerification/RenderTraceParser.cs b/Src/Common/RenderVerification/RenderTraceParser.cs
new file mode 100644
index 0000000000..0dad2492e0
--- /dev/null
+++ b/Src/Common/RenderVerification/RenderTraceParser.cs
@@ -0,0 +1,259 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace SIL.FieldWorks.Common.RenderVerification
+{
+ ///
+ /// Parses trace log files to extract rendering stage durations.
+ /// Expects trace entries in the format: [RENDER] Stage=StageName Duration=123.45ms Context=optional
+ ///
+ public class RenderTraceParser
+ {
+ ///
+ /// The regex pattern for parsing render trace entries.
+ /// Format: [RENDER] Stage=StageName Duration=123.45ms [Context=value]
+ ///
+ private static readonly Regex TraceEntryPattern = new Regex(
+ @"\[RENDER\]\s+Stage=(?\S+)\s+Duration=(?[\d.]+)ms(?:\s+Context=(?.+))?",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ ///
+ /// The regex pattern for parsing timestamped trace entries.
+ /// Format: [2026-01-22T12:34:56.789] [RENDER] Stage=...
+ ///
+ private static readonly Regex TimestampedEntryPattern = new Regex(
+ @"\[(?[\d\-T:.]+)\]\s+\[RENDER\]\s+Stage=(?\S+)\s+Duration=(?[\d.]+)ms(?:\s+Context=(?.+))?",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ ///
+ /// Known rendering stages in order of expected execution.
+ ///
+ public static readonly string[] KnownStages = new[]
+ {
+ "MakeRoot",
+ "Layout",
+ "PrepareToDraw",
+ "DrawRoot",
+ "PropChanged",
+ "LazyExpand",
+ "Reconstruct"
+ };
+
+ ///
+ /// Parses trace entries from a log file.
+ ///
+ /// Path to the trace log file.
+ /// A list of parsed trace events.
+ public List ParseFile(string logFilePath)
+ {
+ if (!File.Exists(logFilePath))
+ return new List();
+
+ var lines = File.ReadAllLines(logFilePath);
+ return ParseLines(lines);
+ }
+
+ ///
+ /// Parses trace entries from log content.
+ ///
+ /// The log content string.
+ /// A list of parsed trace events.
+ public List ParseContent(string logContent)
+ {
+ if (string.IsNullOrEmpty(logContent))
+ return new List();
+
+ var lines = logContent.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
+ return ParseLines(lines);
+ }
+
+ ///
+ /// Parses trace entries from an array of log lines.
+ ///
+ /// The log lines.
+ /// A list of parsed trace events.
+ public List ParseLines(string[] lines)
+ {
+ var events = new List();
+ double cumulativeTime = 0;
+
+ foreach (var line in lines)
+ {
+ var evt = ParseLine(line, ref cumulativeTime);
+ if (evt != null)
+ {
+ events.Add(evt);
+ }
+ }
+
+ return events;
+ }
+
+ ///
+ /// Parses a single trace log line.
+ ///
+ /// The log line.
+ /// Running cumulative time for calculating start times.
+ /// The parsed trace event, or null if line doesn't match.
+ public TraceEvent ParseLine(string line, ref double cumulativeTime)
+ {
+ if (string.IsNullOrWhiteSpace(line))
+ return null;
+
+ // Try timestamped pattern first
+ var match = TimestampedEntryPattern.Match(line);
+ if (!match.Success)
+ {
+ // Fall back to simple pattern
+ match = TraceEntryPattern.Match(line);
+ }
+
+ if (!match.Success)
+ return null;
+
+ var stage = match.Groups["stage"].Value;
+ if (!double.TryParse(match.Groups["duration"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double duration))
+ return null;
+
+ var evt = new TraceEvent
+ {
+ Stage = stage,
+ StartTimeMs = cumulativeTime,
+ DurationMs = duration
+ };
+
+ // Parse context if present
+ if (match.Groups["context"].Success && !string.IsNullOrWhiteSpace(match.Groups["context"].Value))
+ {
+ evt.Context = ParseContext(match.Groups["context"].Value);
+ }
+
+ cumulativeTime += duration;
+ return evt;
+ }
+
+ ///
+ /// Aggregates trace events by stage.
+ ///
+ /// The trace events to aggregate.
+ /// A dictionary of stage name to aggregated statistics.
+ public Dictionary AggregateByStage(IEnumerable events)
+ {
+ var stats = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var evt in events)
+ {
+ if (!stats.TryGetValue(evt.Stage, out var stageStat))
+ {
+ stageStat = new StageStatistics { Stage = evt.Stage };
+ stats[evt.Stage] = stageStat;
+ }
+
+ stageStat.Count++;
+ stageStat.TotalDurationMs += evt.DurationMs;
+ stageStat.MinDurationMs = Math.Min(stageStat.MinDurationMs, evt.DurationMs);
+ stageStat.MaxDurationMs = Math.Max(stageStat.MaxDurationMs, evt.DurationMs);
+ }
+
+ // Calculate averages
+ foreach (var stat in stats.Values)
+ {
+ stat.AverageDurationMs = stat.Count > 0 ? stat.TotalDurationMs / stat.Count : 0;
+ }
+
+ return stats;
+ }
+
+ ///
+ /// Gets the top time contributors from trace events.
+ ///
+ /// The trace events.
+ /// Number of top contributors to return.
+ /// A list of contributors sorted by share percentage.
+ public List GetTopContributors(IEnumerable events, int count = 5)
+ {
+ var eventList = events.ToList();
+ var totalTime = eventList.Sum(e => e.DurationMs);
+
+ if (totalTime <= 0)
+ return new List();
+
+ var stats = AggregateByStage(eventList);
+
+ return stats.Values
+ .OrderByDescending(s => s.TotalDurationMs)
+ .Take(count)
+ .Select(s => new Contributor
+ {
+ Stage = s.Stage,
+ AverageDurationMs = s.AverageDurationMs,
+ SharePercent = s.TotalDurationMs / totalTime * 100
+ })
+ .ToList();
+ }
+
+ ///
+ /// Validates that all expected trace stages are present.
+ ///
+ /// The trace events to validate.
+ /// The required stages (defaults to KnownStages).
+ /// A list of missing stage names.
+ public List ValidateStages(IEnumerable events, string[] requiredStages = null)
+ {
+ requiredStages = requiredStages ?? new[] { "Layout", "DrawRoot" }; // Minimum required
+ var presentStages = new HashSet(events.Select(e => e.Stage), StringComparer.OrdinalIgnoreCase);
+
+ return requiredStages.Where(s => !presentStages.Contains(s)).ToList();
+ }
+
+ private Dictionary ParseContext(string contextString)
+ {
+ var context = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ // Parse key=value pairs separated by spaces or semicolons
+ var pairs = contextString.Split(new[] { ';', ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ foreach (var pair in pairs)
+ {
+ var parts = pair.Split(new[] { '=' }, 2);
+ if (parts.Length == 2)
+ {
+ context[parts[0].Trim()] = parts[1].Trim();
+ }
+ }
+
+ return context;
+ }
+ }
+
+ ///
+ /// Contains aggregated statistics for a rendering stage.
+ ///
+ public class StageStatistics
+ {
+ /// Gets or sets the stage name.
+ public string Stage { get; set; }
+
+ /// Gets or sets the number of occurrences.
+ public int Count { get; set; }
+
+ /// Gets or sets the total duration across all occurrences.
+ public double TotalDurationMs { get; set; }
+
+ /// Gets or sets the average duration.
+ public double AverageDurationMs { get; set; }
+
+ /// Gets or sets the minimum duration.
+ public double MinDurationMs { get; set; } = double.MaxValue;
+
+ /// Gets or sets the maximum duration.
+ public double MaxDurationMs { get; set; } = double.MinValue;
+ }
+}
diff --git a/Src/Common/RenderVerification/RenderVerification.csproj b/Src/Common/RenderVerification/RenderVerification.csproj
new file mode 100644
index 0000000000..1b7b619495
--- /dev/null
+++ b/Src/Common/RenderVerification/RenderVerification.csproj
@@ -0,0 +1,53 @@
+
+
+
+ RenderVerification
+ SIL.FieldWorks.Common.RenderVerification
+ net48
+ Library
+ 168,169,219,414,649,1635,1702,1701
+ false
+ false
+ false
+
+
+ DEBUG;TRACE
+ true
+ false
+ portable
+
+
+ TRACE
+ true
+ true
+ portable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Properties\CommonAssemblyInfo.cs
+
+
+
diff --git a/Src/Common/RootSite/RootSiteTests/App.config b/Src/Common/RootSite/RootSiteTests/App.config
new file mode 100644
index 0000000000..d895f0dfac
--- /dev/null
+++ b/Src/Common/RootSite/RootSiteTests/App.config
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Src/Common/RootSite/RootSiteTests/DummyBasicView.cs b/Src/Common/RootSite/RootSiteTests/DummyBasicView.cs
index ed76b3e131..5cde473061 100644
--- a/Src/Common/RootSite/RootSiteTests/DummyBasicView.cs
+++ b/Src/Common/RootSite/RootSiteTests/DummyBasicView.cs
@@ -310,6 +310,21 @@ public virtual void CallLayout()
OnLayout(new LayoutEventArgs(this, string.Empty));
}
+ ///
+ /// Force layout with a specific width, useful for headless testing.
+ ///
+ public void ForceLayout(int width)
+ {
+ CheckDisposed();
+ if (m_rootb != null && m_graphicsManager != null)
+ {
+ using (new HoldGraphics(this))
+ {
+ m_rootb.Layout(m_graphicsManager.VwGraphics, width);
+ }
+ }
+ }
+
/// ------------------------------------------------------------------------------------
///
/// Add English paragraphs
@@ -484,6 +499,22 @@ public override void MakeRoot()
MakeRoot(m_hvoRoot, m_flid); //, DummyBasicViewVc.kflidTestDummy);
}
+ /// ------------------------------------------------------------------------------------
+ ///
+ /// Override to provide width even if control is not fully realized/visible (headless tests).
+ ///
+ /// ------------------------------------------------------------------------------------
+ public override int GetAvailWidth(IVwRootBox prootb)
+ {
+ // If base returns 0 (likely due to ClientRectangle.Width=0 in headless mode),
+ // try to return the explicit Width property.
+ int width = base.GetAvailWidth(prootb);
+ if (width <= 0 && Width > 0)
+ return Width;
+
+ return width;
+ }
+
/// ------------------------------------------------------------------------------------
///
/// Creates a new DummyEditingHelper
diff --git a/Src/Common/RootSite/RootSiteTests/DummyBasicViewVc.cs b/Src/Common/RootSite/RootSiteTests/DummyBasicViewVc.cs
index a5352cb9d8..405986a056 100644
--- a/Src/Common/RootSite/RootSiteTests/DummyBasicViewVc.cs
+++ b/Src/Common/RootSite/RootSiteTests/DummyBasicViewVc.cs
@@ -172,6 +172,19 @@ public override void Display(IVwEnv vwenv, int hvo, int frag)
m_wsDefault);
vwenv.AddString(tss);
break;
+ case 20: // Full book (Sections + Footnotes)
+ // Main body (sections)
+ vwenv.AddObjVecItems(ScrBookTags.kflidSections, this, 21);
+ // Footnotes
+ vwenv.OpenDiv();
+ vwenv.AddObjVecItems(ScrBookTags.kflidFootnotes, this, 8);
+ vwenv.CloseDiv();
+ break;
+ case 21: // Function used to display a Section
+ // Just display the content (paragraphs) for now, skipping heading to match test data structure
+ // (The test data setup AddParaToMockedSectionContent puts text in Content, not Heading)
+ vwenv.AddObjProp(ScrSectionTags.kflidContent, this, 3);
+ break;
case ScrBookTags.kflidSections:
vwenv.AddObjVecItems(ScrBookTags.kflidSections, this,
ScrSectionTags.kflidContent);
diff --git a/Src/Common/RootSite/RootSiteTests/GenericLexEntryView.cs b/Src/Common/RootSite/RootSiteTests/GenericLexEntryView.cs
new file mode 100644
index 0000000000..c9fd361585
--- /dev/null
+++ b/Src/Common/RootSite/RootSiteTests/GenericLexEntryView.cs
@@ -0,0 +1,229 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using SIL.FieldWorks.Common.ViewsInterfaces;
+using SIL.LCModel;
+using SIL.LCModel.Core.KernelInterfaces;
+using SIL.LCModel.Core.Text;
+
+namespace SIL.FieldWorks.Common.RootSites.RenderBenchmark
+{
+ ///
+ /// A benchmark view that renders lexical entries with nested senses for timing tests.
+ /// The exercises the same recursive nested-field pattern
+ /// that causes exponential rendering overhead in the production XmlVc
+ /// (visibility="ifdata" double-render at each level of LexSense → Senses).
+ ///
+ public class GenericLexEntryView : DummyBasicView
+ {
+ private readonly int m_rootHvo;
+ private readonly int m_rootFlid;
+ private bool m_simulateIfDataDoubleRender;
+
+ ///
+ /// Gets or sets the root fragment ID for this view.
+ ///
+ public int RootFragmentId { get; set; } = LexEntryVc.kFragEntry;
+
+ ///
+ /// Gets or sets whether to simulate the XmlVc ifdata double-render pattern.
+ /// When true, each sense level renders its children twice (once as a visibility
+ /// test, once for real output), modelling the O(N · 2^d) growth.
+ /// When false, renders once per level — the target after optimization.
+ ///
+ public bool SimulateIfDataDoubleRender
+ {
+ get => m_simulateIfDataDoubleRender;
+ set => m_simulateIfDataDoubleRender = value;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The HVO of the root lex entry.
+ /// The field ID (typically ).
+ public GenericLexEntryView(int hvoRoot, int flid) : base(hvoRoot, flid)
+ {
+ m_rootHvo = hvoRoot;
+ m_rootFlid = flid;
+ m_fMakeRootWhenHandleIsCreated = false;
+ }
+
+ ///
+ public override void MakeRoot()
+ {
+ CheckDisposed();
+ MakeRoot(m_rootHvo, m_rootFlid, RootFragmentId);
+ }
+
+ ///
+ /// Creates the view constructor for lexical entry rendering.
+ ///
+ protected override VwBaseVc CreateVc(int flid)
+ {
+ int defaultWs = m_cache.ServiceLocator.WritingSystems.DefaultVernacularWritingSystem.Handle;
+ int analysisWs = m_cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem.Handle;
+ var vc = new LexEntryVc(defaultWs, analysisWs)
+ {
+ SimulateIfDataDoubleRender = m_simulateIfDataDoubleRender
+ };
+ return vc;
+ }
+ }
+
+ ///
+ /// View constructor that renders objects with recursive sense nesting.
+ /// This exercises the same Views engine pattern that causes exponential overhead in XmlVc:
+ /// at each sense level, the engine calls back into for each subsense,
+ /// creating O(branching^depth) total Display calls.
+ ///
+ ///
+ ///
+ /// When is true, each sense's subsense vector
+ /// is processed twice — once via a pass (testing whether
+ /// data exists), then again for real rendering. This models the production XmlVc behaviour
+ /// where visibility="ifdata" parts call ProcessChildren into a throw-away
+ /// environment before re-rendering into the real .
+ ///
+ ///
+ /// Toggling off shows the target performance
+ /// after the ifdata optimization ships.
+ ///
+ ///
+ public class LexEntryVc : VwBaseVc
+ {
+ /// Fragment: root-level entry display.
+ public const int kFragEntry = 200;
+ /// Fragment: a single sense (recursive for subsenses).
+ public const int kFragSense = 201;
+ /// Fragment: the morpheme form (headword) of an entry.
+ public const int kFragMoForm = 202;
+
+ private readonly int m_wsVern;
+ private readonly int m_wsAnalysis;
+
+ ///
+ /// When true, each sense level renders its subsense vector twice to simulate
+ /// the XmlVc visibility="ifdata" double-render pattern.
+ ///
+ public bool SimulateIfDataDoubleRender { get; set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Default vernacular writing system handle.
+ /// Default analysis writing system handle.
+ public LexEntryVc(int wsVern, int wsAnalysis)
+ {
+ m_wsVern = wsVern;
+ m_wsAnalysis = wsAnalysis;
+ }
+
+ ///
+ /// Main display method — dispatches on fragment ID.
+ ///
+ public override void Display(IVwEnv vwenv, int hvo, int frag)
+ {
+ switch (frag)
+ {
+ case kFragEntry:
+ DisplayEntry(vwenv, hvo);
+ break;
+
+ case kFragSense:
+ DisplaySense(vwenv, hvo);
+ break;
+
+ case kFragMoForm:
+ DisplayMoForm(vwenv, hvo);
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ ///
+ /// Renders a lexical entry: bold headword, then numbered senses.
+ ///
+ private void DisplayEntry(IVwEnv vwenv, int hvo)
+ {
+ vwenv.OpenDiv();
+
+ // --- Headword (bold) ---
+ vwenv.OpenParagraph();
+ var bldr = TsStringUtils.MakePropsBldr();
+ bldr.SetIntPropValues((int)FwTextPropType.ktptBold,
+ (int)FwTextPropVar.ktpvEnum, (int)FwTextToggleVal.kttvForceOn);
+ bldr.SetIntPropValues((int)FwTextPropType.ktptFontSize,
+ (int)FwTextPropVar.ktpvMilliPoint, 14000); // 14pt
+ vwenv.set_IntProperty((int)FwTextPropType.ktptBold,
+ (int)FwTextPropVar.ktpvEnum, (int)FwTextToggleVal.kttvForceOn);
+ vwenv.set_IntProperty((int)FwTextPropType.ktptFontSize,
+ (int)FwTextPropVar.ktpvMilliPoint, 14000);
+
+ // Display LexemeForm → MoForm
+ vwenv.AddObjProp(LexEntryTags.kflidLexemeForm, this, kFragMoForm);
+ vwenv.CloseParagraph();
+
+ // --- Senses ---
+ vwenv.AddObjVecItems(LexEntryTags.kflidSenses, this, kFragSense);
+
+ vwenv.CloseDiv();
+ }
+
+ ///
+ /// Renders a single sense: gloss, definition, then recursive subsenses with
+ /// indentation. When is true, the
+ /// subsense vector is iterated twice per level to model the XmlVc ifdata cost.
+ ///
+ private void DisplaySense(IVwEnv vwenv, int hvo)
+ {
+ // Indent each nesting level by 18px
+ vwenv.set_IntProperty((int)FwTextPropType.ktptLeadingIndent,
+ (int)FwTextPropVar.ktpvMilliPoint, 18000); // 18pt indent
+
+ vwenv.OpenDiv();
+
+ // --- Gloss line ---
+ vwenv.OpenParagraph();
+ vwenv.AddStringAltMember(LexSenseTags.kflidGloss, m_wsAnalysis, this);
+ vwenv.CloseParagraph();
+
+ // --- Definition line ---
+ vwenv.OpenParagraph();
+ vwenv.set_IntProperty((int)FwTextPropType.ktptItalic,
+ (int)FwTextPropVar.ktpvEnum, (int)FwTextToggleVal.kttvForceOn);
+ vwenv.AddStringAltMember(LexSenseTags.kflidDefinition, m_wsAnalysis, this);
+ vwenv.CloseParagraph();
+
+ // --- Subsenses (recursive!) ---
+ // This is the critical path: each level of nesting causes the Views engine
+ // to call Display(kFragSense) for each child sense, creating O(b^d) calls.
+ if (SimulateIfDataDoubleRender)
+ {
+ // SIMULATION of XmlVc visibility="ifdata":
+ // First pass — iterate subsenses to "test" whether data exists.
+ // XmlVc does this via TestCollectorEnv which is a full ProcessChildren traversal.
+ // We simulate by doing AddObjVecItems into a discarded context.
+ // The Views engine still walks the vector and calls Display for each item.
+ vwenv.AddObjVecItems(LexSenseTags.kflidSenses, this, kFragSense);
+ }
+
+ // Real render pass — always done
+ vwenv.AddObjVecItems(LexSenseTags.kflidSenses, this, kFragSense);
+
+ vwenv.CloseDiv();
+ }
+
+ ///
+ /// Renders the morpheme form (headword text).
+ ///
+ private void DisplayMoForm(IVwEnv vwenv, int hvo)
+ {
+ vwenv.AddStringAltMember(MoFormTags.kflidForm, m_wsVern, this);
+ }
+ }
+}
diff --git a/Src/Common/RootSite/RootSiteTests/GenericScriptureView.cs b/Src/Common/RootSite/RootSiteTests/GenericScriptureView.cs
new file mode 100644
index 0000000000..1d56e2b062
--- /dev/null
+++ b/Src/Common/RootSite/RootSiteTests/GenericScriptureView.cs
@@ -0,0 +1,80 @@
+using System;
+using SIL.FieldWorks.Common.ViewsInterfaces;
+using SIL.LCModel;
+using SIL.LCModel.Core.KernelInterfaces;
+using SIL.LCModel.Core.Scripture;
+
+namespace SIL.FieldWorks.Common.RootSites.RenderBenchmark
+{
+ ///
+ /// A benchmark view that uses the production-grade StVc (Standard View Constructor)
+ /// instead of the simplified DummyBasicViewVc. This ensures rendering matches
+ /// actual FieldWorks document views (margins, styles, writing systems).
+ ///
+ public class GenericScriptureView : DummyBasicView
+ {
+ private readonly int m_rootHvo;
+ private readonly int m_rootFlid;
+
+ public int RootFragmentId { get; set; } = 1;
+
+ public GenericScriptureView(int hvoRoot, int flid) : base(hvoRoot, flid)
+ {
+ m_rootHvo = hvoRoot;
+ m_rootFlid = flid;
+ m_fMakeRootWhenHandleIsCreated = false;
+ }
+
+ public override void MakeRoot()
+ {
+ CheckDisposed();
+ MakeRoot(m_rootHvo, m_rootFlid, RootFragmentId);
+ }
+
+ protected override VwBaseVc CreateVc(int flid)
+ {
+ // We define the VC inline or via helper to handle the Book -> Text bridge
+ int defaultWs = m_cache.ServiceLocator.WritingSystems.DefaultVernacularWritingSystem.Handle;
+ var vc = new GenericScriptureVc("Normal", defaultWs);
+ vc.Cache = m_cache; // Inject cache
+ return vc;
+ }
+ }
+
+ ///
+ /// Extends StVc to handle Scripture hierarchy (Book -> Sections -> StText).
+ /// When it reaches StText, it falls back to standard StVc formatting.
+ ///
+ public class GenericScriptureVc : StVc
+ {
+ private const int kFragRoot = 1;
+ private const int kFragSection = 21;
+
+ public GenericScriptureVc(string style, int ws) : base(style, ws)
+ {
+ }
+
+ public override void Display(IVwEnv vwenv, int hvo, int frag)
+ {
+ // Handle Scripture Hierarchy
+ switch (frag)
+ {
+ case 100: // Matches m_frag in RenderBenchmarkTestsBase
+ // Assume Root is Book, iterate sections
+ vwenv.AddObjVecItems(ScrBookTags.kflidSections, this, kFragSection);
+ break;
+
+ case kFragSection:
+ // Section: display heading first, then content body
+ vwenv.AddObjProp(ScrSectionTags.kflidHeading, this, (int)StTextFrags.kfrText);
+ vwenv.AddObjProp(ScrSectionTags.kflidContent, this, (int)StTextFrags.kfrText);
+ break;
+
+ default:
+ // Delegate to StVc for standard StText/Para handling
+ base.Display(vwenv, hvo, frag);
+ break;
+ }
+ }
+ }
+}
diff --git a/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs b/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs
new file mode 100644
index 0000000000..d3327eaeda
--- /dev/null
+++ b/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs
@@ -0,0 +1,113 @@
+using System;
+using System.IO;
+using NUnit.Framework;
+using SIL.FieldWorks.FwCoreDlgs;
+using SIL.FieldWorks.Common.FwUtils;
+using SIL.LCModel;
+using SIL.LCModel.Core.KernelInterfaces;
+using SIL.LCModel.Infrastructure;
+using SIL.TestUtilities;
+using SIL.WritingSystems;
+
+namespace SIL.FieldWorks.Common.RootSites.RootSiteTests
+{
+ ///
+ /// Base class for tests requiring a real file-backed LcmCache (not Mock/Memory).
+ /// This ensures full schema validation and behaviors (like StVc) work correctly.
+ ///
+ [TestFixture]
+ public abstract class RealDataTestsBase
+ {
+ protected FwNewLangProjectModel m_model;
+ protected LcmCache Cache;
+ protected string m_dbName;
+
+ [SetUp]
+ public virtual void TestSetup()
+ {
+ m_dbName = "RealDataTest_" + Guid.NewGuid().ToString("N");
+ var dbPath = DbFilename(m_dbName);
+ if (File.Exists(dbPath))
+ File.Delete(dbPath);
+
+ // Init New Lang Project Model (headless)
+ m_model = new FwNewLangProjectModel(true)
+ {
+ LoadProjectNameSetup = () => { },
+ LoadVernacularSetup = () => { },
+ LoadAnalysisSetup = () => { },
+ AnthroModel = new FwChooseAnthroListModel { CurrentList = FwChooseAnthroListModel.ListChoice.UserDef }
+ };
+
+ string createdPath;
+ using (var threadHelper = new ThreadHelper())
+ {
+ m_model.ProjectName = m_dbName;
+ m_model.Next(); // To Vernacular WS Setup
+ m_model.SetDefaultWs(new LanguageInfo { LanguageTag = "qaa", DesiredName = "Vernacular" });
+ m_model.Next(); // To Analysis WS Setup
+ m_model.SetDefaultWs(new LanguageInfo { LanguageTag = "en", DesiredName = "English" });
+ createdPath = m_model.CreateNewLangProj(new DummyProgressDlg(), threadHelper);
+ }
+
+ // Load the cache from the newly created .fwdata file
+ Cache = LcmCache.CreateCacheFromExistingData(
+ new TestProjectId(BackendProviderType.kXMLWithMemoryOnlyWsMgr, createdPath),
+ "en",
+ new DummyLcmUI(),
+ FwDirectoryFinder.LcmDirectories,
+ new LcmSettings(),
+ new DummyProgressDlg());
+
+ try
+ {
+ using (var undoWatcher = new UndoableUnitOfWorkHelper(Cache.ActionHandlerAccessor, "Test Setup", "Undo Test Setup"))
+ {
+ InitializeProjectData();
+ CreateTestData();
+ undoWatcher.RollBack = false;
+ }
+ }
+ catch (Exception)
+ {
+ // If setup fails, ensure we don't leave a locked DB
+ if (Cache != null)
+ {
+ Cache.Dispose();
+ Cache = null;
+ }
+ throw;
+ }
+ }
+
+ protected virtual void InitializeProjectData()
+ {
+ // Override to add basic project data before CreateTestData
+ }
+
+ protected virtual void CreateTestData()
+ {
+ // Override in subclasses to populate the DB
+ }
+
+ [TearDown]
+ public virtual void TestTearDown()
+ {
+ if (Cache != null)
+ {
+ Cache.Dispose();
+ Cache = null;
+ }
+ var dbPath = DbFilename(m_dbName);
+ if (File.Exists(dbPath))
+ {
+ try { File.Delete(dbPath); } catch { }
+ }
+ }
+
+ protected string DbFilename(string name)
+ {
+ return Path.Combine(Path.GetTempPath(), name + ".fwdata");
+ }
+ }
+}
diff --git a/Src/Common/RootSite/RootSiteTests/RenderBaselineTests.cs b/Src/Common/RootSite/RootSiteTests/RenderBaselineTests.cs
new file mode 100644
index 0000000000..077c521261
--- /dev/null
+++ b/Src/Common/RootSite/RootSiteTests/RenderBaselineTests.cs
@@ -0,0 +1,356 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.IO;
+using NUnit.Framework;
+using SIL.FieldWorks.Common.RootSites.RenderBenchmark;
+using SIL.FieldWorks.Common.RenderVerification;
+
+namespace SIL.FieldWorks.Common.RootSites
+{
+ ///
+ /// Render harness and infrastructure tests.
+ /// Validates that the capture pipeline, environment validator, and diagnostics
+ /// toggle work correctly. Pixel-perfect snapshot regression is handled by
+ /// using committed PNG baselines.
+ ///
+ [TestFixture]
+ [Category("RenderBenchmark")]
+ public class RenderBaselineTests : RenderBenchmark.RenderBenchmarkTestsBase
+ {
+ private RenderEnvironmentValidator m_environmentValidator;
+ private RenderDiagnosticsToggle m_diagnostics;
+
+ ///
+ /// Creates the test data (Scripture book with footnotes) for rendering.
+ ///
+ protected override void CreateTestData()
+ {
+ SetupScenarioData("simple");
+ }
+
+ ///
+ /// Sets up each test.
+ ///
+ [SetUp]
+ public override void TestSetup()
+ {
+ m_environmentValidator = new RenderEnvironmentValidator();
+ base.TestSetup();
+ m_diagnostics = new RenderDiagnosticsToggle();
+ }
+
+ ///
+ /// Tears down each test.
+ ///
+ [TearDown]
+ public override void TestTearDown()
+ {
+ m_diagnostics?.Dispose();
+ m_diagnostics = null;
+ base.TestTearDown();
+ }
+
+ ///
+ /// Tests that the harness can render a simple view and capture a bitmap.
+ ///
+ [Test]
+ public void RenderHarness_CapturesSimpleView_ReturnsValidBitmap()
+ {
+ // Arrange
+ var scenario = new RenderScenario
+ {
+ Id = "simple-test",
+ Description = "Basic view for harness validation",
+ RootObjectHvo = m_hvoRoot,
+ RootFlid = m_flidContainingTexts,
+ FragmentId = m_frag
+ };
+
+ using (var harness = new RenderBenchmarkHarness(Cache, scenario, m_environmentValidator))
+ {
+ // Act
+ var coldTiming = harness.ExecuteColdRender(width: 400, height: 300);
+ var bitmap = harness.CaptureViewBitmap();
+
+ // Assert
+ Assert.That(coldTiming, Is.Not.Null, "Cold timing result should not be null");
+ Assert.That(coldTiming.DurationMs, Is.GreaterThanOrEqualTo(0),
+ "Cold render duration should not be negative.");
+ Assert.That(coldTiming.IsColdRender, Is.True, "Should be marked as cold render");
+
+ Assert.That(bitmap, Is.Not.Null, "Captured bitmap should not be null");
+ Assert.That(bitmap.Width, Is.EqualTo(400), "Bitmap width should match view width");
+ Assert.That(bitmap.Height, Is.GreaterThanOrEqualTo(300),
+ "Bitmap height should honor the requested view height and may grow to fit content.");
+ }
+ }
+
+ ///
+ /// Tests that warm renders complete in a reasonable time relative to cold renders.
+ /// With rich styled content, Reconstruct() can be close to or exceed cold render time,
+ /// so we use a generous multiplier. The real value is that both complete successfully.
+ ///
+ [Test]
+ public void RenderHarness_WarmRender_IsFasterThanColdRender()
+ {
+ // Arrange
+ var scenario = new RenderScenario
+ {
+ Id = "warm-vs-cold",
+ Description = "Compare warm vs cold render timing",
+ RootObjectHvo = m_hvoRoot,
+ RootFlid = m_flidContainingTexts,
+ FragmentId = m_frag
+ };
+
+ using (var harness = new RenderBenchmarkHarness(Cache, scenario, m_environmentValidator))
+ {
+ // Act
+ var coldTiming = harness.ExecuteColdRender();
+ var warmTiming = harness.ExecuteWarmRender();
+
+ // Assert
+ Assert.That(warmTiming, Is.Not.Null, "Warm timing result should not be null");
+ Assert.That(warmTiming.IsColdRender, Is.False, "Should be marked as warm render");
+
+ // With rich content (styles, chapter/verse formatting), Reconstruct()
+ // can be comparable to initial layout. Allow up to 5x cold time to
+ // accommodate style resolution overhead on warm renders.
+ Assert.That(warmTiming.DurationMs, Is.LessThan(coldTiming.DurationMs * 5),
+ $"Warm render ({warmTiming.DurationMs:F2}ms) should not be much slower than cold ({coldTiming.DurationMs:F2}ms)");
+ }
+ }
+
+ ///
+ /// Tests that the environment validator produces consistent hashes.
+ ///
+ [Test]
+ public void EnvironmentValidator_SameEnvironment_ProducesConsistentHash()
+ {
+ // Arrange
+ var validator1 = new RenderEnvironmentValidator();
+ var validator2 = new RenderEnvironmentValidator();
+
+ // Act
+ var hash1 = validator1.GetEnvironmentHash();
+ var hash2 = validator2.GetEnvironmentHash();
+
+ // Assert
+ Assert.That(hash1, Is.Not.Null.And.Not.Empty, "Hash should not be empty");
+ Assert.That(hash1, Is.EqualTo(hash2), "Same environment should produce same hash");
+ }
+
+ ///
+ /// Tests that diagnostics toggle enables trace output.
+ ///
+ [Test]
+ public void DiagnosticsToggle_Enable_WritesTraceEntries()
+ {
+ // Arrange
+ m_diagnostics.EnableDiagnostics();
+
+ // Act
+ m_diagnostics.WriteTraceEntry("TestStage", 123.45, "test context");
+ m_diagnostics.Flush();
+
+ var content = m_diagnostics.GetTraceLogContent();
+
+ // Assert
+ Assert.That(content, Does.Contain("[RENDER]"), "Trace log should contain render entry");
+ Assert.That(content, Does.Contain("TestStage"), "Trace log should contain stage name");
+ Assert.That(content, Does.Contain("123.45"), "Trace log should contain duration");
+
+ // Cleanup
+ m_diagnostics.ClearTraceLog();
+ }
+
+ ///
+ /// Tests that multiple toggle instances do not duplicate process-wide trace output.
+ ///
+ [Test]
+ public void DiagnosticsToggle_MultipleInstances_DoNotDuplicateTraceEntries()
+ {
+ m_diagnostics.DisableDiagnostics();
+
+ var tempDirectory = Path.Combine(Path.GetTempPath(), "RenderDiagnosticsToggleTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDirectory);
+ var flagsPath = Path.Combine(tempDirectory, "flags.json");
+ var traceLogPath = Path.Combine(tempDirectory, "trace.log");
+
+ File.WriteAllText(
+ flagsPath,
+ "{\"DiagnosticsEnabled\":false,\"TraceEnabled\":false,\"CaptureMode\":\"DrawToBitmap\"}");
+
+ try
+ {
+ using (var diagnostics1 = new RenderDiagnosticsToggle(flagsPath, traceLogPath))
+ using (var diagnostics2 = new RenderDiagnosticsToggle(flagsPath, traceLogPath))
+ {
+ diagnostics1.EnableDiagnostics();
+ diagnostics2.EnableDiagnostics();
+
+ diagnostics1.WriteTraceEntry("DuplicateStage", 42.0, "duplicate-test");
+ diagnostics1.Flush();
+ diagnostics2.Flush();
+
+ var content = diagnostics1.GetTraceLogContent();
+ var occurrenceCount = content.Split(new[] { "Stage=DuplicateStage" }, StringSplitOptions.None).Length - 1;
+
+ Assert.That(
+ occurrenceCount,
+ Is.EqualTo(1),
+ "Only one RenderBenchmark listener should receive each trace entry.");
+ }
+ }
+ finally
+ {
+ if (File.Exists(traceLogPath))
+ {
+ File.Delete(traceLogPath);
+ }
+
+ if (File.Exists(flagsPath))
+ {
+ File.Delete(flagsPath);
+ }
+
+ if (Directory.Exists(tempDirectory))
+ {
+ Directory.Delete(tempDirectory);
+ }
+ }
+ }
+
+ ///
+ /// Tests that restoring diagnostics preserves the initial enabled state across repeated restores.
+ ///
+ [Test]
+ public void DiagnosticsToggle_RestoreOriginalState_CanBeCalledRepeatedly()
+ {
+ // Arrange
+ var tempDirectory = Path.Combine(Path.GetTempPath(), "RenderDiagnosticsToggleTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDirectory);
+ var flagsPath = Path.Combine(tempDirectory, "flags.json");
+ var traceLogPath = Path.Combine(tempDirectory, "trace.log");
+
+ File.WriteAllText(
+ flagsPath,
+ "{\"DiagnosticsEnabled\":true,\"TraceEnabled\":true,\"CaptureMode\":\"DrawToBitmap\"}");
+
+ try
+ {
+ using (var diagnostics = new RenderDiagnosticsToggle(flagsPath, traceLogPath))
+ {
+ // Act
+ diagnostics.DisableDiagnostics();
+ diagnostics.RestoreOriginalState();
+ diagnostics.RestoreOriginalState();
+
+ // Assert
+ Assert.That(diagnostics.DiagnosticsEnabled, Is.True, "Restore should preserve the initial enabled state");
+ Assert.That(diagnostics.TraceEnabled, Is.True, "Trace output should remain enabled after repeated restore calls");
+ }
+ }
+ finally
+ {
+ if (File.Exists(traceLogPath))
+ {
+ File.Delete(traceLogPath);
+ }
+
+ if (File.Exists(flagsPath))
+ {
+ File.Delete(flagsPath);
+ }
+
+ if (Directory.Exists(tempDirectory))
+ {
+ Directory.Delete(tempDirectory);
+ }
+ }
+ }
+
+ [Test]
+ public void RenderSnapshotVerifier_MismatchMessage_IncludesConfigAndAnalysisFiles()
+ {
+ var tempDirectory = Path.Combine(Path.GetTempPath(), "RenderSnapshotVerifierTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDirectory);
+ const string snapshotName = "render-mismatch";
+ string verifiedPath = Path.Combine(tempDirectory, snapshotName + ".verified.png");
+
+ try
+ {
+ using (var verifiedBitmap = CreateSolidBitmap(2, 2, Color.White))
+ using (var actualBitmap = CreateSolidBitmap(2, 5, Color.White))
+ {
+ verifiedBitmap.Save(verifiedPath, ImageFormat.Png);
+
+ var result = RenderSnapshotVerifier.Verify(actualBitmap, tempDirectory, snapshotName, "mismatch-scenario");
+
+ Assert.That(result.Passed, Is.False, "The verifier should fail when the bitmap height drifts beyond tolerance.");
+ Assert.That(result.FailureMessage, Does.Contain("Saved baseline:"));
+ Assert.That(result.FailureMessage, Does.Contain("Current run: image=2x5"));
+ Assert.That(result.FailureMessage, Does.Contain("Diff composition:"));
+ Assert.That(File.Exists(Path.Combine(tempDirectory, snapshotName + ".received.json")), Is.True,
+ "Current run metadata should be written next to the received image.");
+ Assert.That(File.Exists(Path.Combine(tempDirectory, snapshotName + ".diff.json")), Is.True,
+ "Comparison analysis should be written when the snapshot differs.");
+ }
+ }
+ finally
+ {
+ if (Directory.Exists(tempDirectory))
+ Directory.Delete(tempDirectory, true);
+ }
+ }
+
+ [Test]
+ public void RenderSnapshotVerifier_UpdateBaseline_WritesVerifiedMetadata()
+ {
+ var originalUpdateValue = Environment.GetEnvironmentVariable("FW_UPDATE_RENDER_BASELINES");
+ var tempDirectory = Path.Combine(Path.GetTempPath(), "RenderSnapshotVerifierTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDirectory);
+ const string snapshotName = "render-refresh";
+
+ try
+ {
+ Environment.SetEnvironmentVariable("FW_UPDATE_RENDER_BASELINES", "1");
+
+ using (var actualBitmap = CreateSolidBitmap(3, 4, Color.White))
+ {
+ var result = RenderSnapshotVerifier.Verify(actualBitmap, tempDirectory, snapshotName, "refresh-scenario");
+
+ Assert.That(result.Passed, Is.True, "Refreshing a baseline should leave the verifier in a passing state.");
+ string verifiedMetadataPath = Path.Combine(tempDirectory, snapshotName + ".verified.json");
+ Assert.That(File.Exists(verifiedMetadataPath), Is.True,
+ "Refreshing a baseline should also persist the baseline metadata sidecar.");
+ string json = File.ReadAllText(verifiedMetadataPath);
+ Assert.That(json, Does.Contain("\"ImageWidth\": 3"));
+ Assert.That(json, Does.Contain("\"ImageHeight\": 4"));
+ Assert.That(json, Does.Contain("\"DpiAwareness\""));
+ }
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("FW_UPDATE_RENDER_BASELINES", originalUpdateValue);
+ if (Directory.Exists(tempDirectory))
+ Directory.Delete(tempDirectory, true);
+ }
+ }
+
+ private static Bitmap CreateSolidBitmap(int width, int height, Color color)
+ {
+ var bitmap = new Bitmap(width, height);
+ using (var graphics = Graphics.FromImage(bitmap))
+ {
+ graphics.Clear(color);
+ }
+ return bitmap;
+ }
+ }
+}
diff --git a/Src/Common/RootSite/RootSiteTests/RenderBaselineVerifier.cs b/Src/Common/RootSite/RootSiteTests/RenderBaselineVerifier.cs
new file mode 100644
index 0000000000..3adf25c125
--- /dev/null
+++ b/Src/Common/RootSite/RootSiteTests/RenderBaselineVerifier.cs
@@ -0,0 +1,19 @@
+using System.Drawing;
+using System.Runtime.CompilerServices;
+using SIL.FieldWorks.Common.RenderVerification;
+
+namespace SIL.FieldWorks.Common.RootSites
+{
+ internal static class RenderBaselineVerifier
+ {
+ internal static string GetSourceFileDirectory([CallerFilePath] string sourceFile = "")
+ {
+ return RenderSnapshotVerifier.GetSourceFileDirectory(sourceFile);
+ }
+
+ internal static RenderBaselineVerificationResult Verify(Bitmap actualBitmap, string directory, string name, string scenarioId)
+ {
+ return RenderSnapshotVerifier.Verify(actualBitmap, directory, name, scenarioId);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Src/Common/RootSite/RootSiteTests/RenderBenchmarkHarness.cs b/Src/Common/RootSite/RootSiteTests/RenderBenchmarkHarness.cs
new file mode 100644
index 0000000000..6be98cc7e5
--- /dev/null
+++ b/Src/Common/RootSite/RootSiteTests/RenderBenchmarkHarness.cs
@@ -0,0 +1,496 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.IO;
+using System.Windows.Forms;
+using SIL.FieldWorks.Common.ViewsInterfaces;
+using SIL.FieldWorks.Common.RenderVerification;
+using SIL.LCModel;
+
+namespace SIL.FieldWorks.Common.RootSites.RenderBenchmark
+{
+ ///
+ /// Harness for rendering lexical entries offscreen and capturing timing/bitmap output.
+ /// Uses DummyBasicView infrastructure with Views engine rendering for pixel-perfect validation.
+ ///
+ ///
+ /// Bitmap capture uses IVwDrawRootBuffered to render the RootBox directly to a GDI+ bitmap,
+ /// ensuring accurate capture of Views engine content including styled text, selections, and
+ /// complex multi-writing-system layouts. This approach bypasses WinForms DrawToBitmap which
+ /// doesn't work correctly for Views controls.
+ ///
+ public class RenderBenchmarkHarness : IDisposable
+ {
+ private const int CapturePaddingPx = 4;
+
+ private readonly LcmCache m_cache;
+ private readonly RenderScenario m_scenario;
+ private readonly RenderEnvironmentValidator m_environmentValidator;
+ private readonly List m_traceEvents = new List();
+ private DummyBasicView m_view;
+ private bool m_disposed;
+ private double m_traceTimelineMs;
+
+ // Cached GDI resources for offscreen layout (avoid per-call allocation).
+ private Bitmap m_layoutBmp;
+ private Graphics m_layoutGraphics;
+ private IntPtr m_layoutHdc;
+ private IVwGraphics m_layoutVwGraphics;
+
+ ///
+ /// Gets the last render timing result.
+ ///
+ public RenderTimingResult LastTiming { get; private set; }
+
+ ///
+ /// Gets the last captured bitmap (may be null if capture failed).
+ ///
+ public Bitmap LastCapture { get; private set; }
+
+ ///
+ /// Gets a snapshot of per-stage trace events captured for this harness instance.
+ ///
+ public IReadOnlyList TraceEvents => m_traceEvents.ToArray();
+
+ ///
+ /// Gets the environment hash for the current rendering context.
+ ///
+ public string EnvironmentHash => m_environmentValidator?.GetEnvironmentHash() ?? string.Empty;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The LCModel cache.
+ /// The render scenario to execute.
+ /// Optional environment validator for deterministic checks.
+ public RenderBenchmarkHarness(LcmCache cache, RenderScenario scenario, RenderEnvironmentValidator environmentValidator = null)
+ {
+ m_cache = cache ?? throw new ArgumentNullException(nameof(cache));
+ m_scenario = scenario ?? throw new ArgumentNullException(nameof(scenario));
+ m_environmentValidator = environmentValidator ?? new RenderEnvironmentValidator();
+ }
+
+ ///
+ /// Executes a cold render (first render after view creation).
+ ///
+ /// View width in pixels.
+ /// View height in pixels.
+ /// The timing result for the cold render.
+ public RenderTimingResult ExecuteColdRender(int width = 800, int height = 600)
+ {
+ ResetTraceEvents();
+ DisposeView();
+
+ var stopwatch = Stopwatch.StartNew();
+
+ m_view = MeasureStage(
+ "CreateView",
+ () => CreateView(width, height),
+ new Dictionary { { "phase", "cold" } });
+
+ MeasureStage(
+ "MakeRoot",
+ () => m_view.MakeRoot(m_scenario.RootObjectHvo, m_scenario.RootFlid, m_scenario.FragmentId),
+ new Dictionary { { "phase", "cold" } });
+
+ MeasureStage(
+ "PerformOffscreenLayout",
+ () => PerformOffscreenLayout(width, height),
+ new Dictionary { { "phase", "cold" } });
+
+ if (EnsureViewSizedToContent(width, height))
+ {
+ MeasureStage(
+ "PerformOffscreenLayout",
+ () => PerformOffscreenLayout(width, m_view.Height),
+ new Dictionary { { "phase", "cold-resized" } });
+ }
+
+ if (m_view.RootBox != null && (m_view.RootBox.Width <= 0 || m_view.RootBox.Height <= 0))
+ {
+ throw new InvalidOperationException($"[RenderBenchmarkHarness] RootBox dimensions are zero/negative after layout ({m_view.RootBox.Width}x{m_view.RootBox.Height}). View Size: {m_view.Width}x{m_view.Height}. Capture will be empty.");
+ }
+
+ stopwatch.Stop();
+
+ LastTiming = new RenderTimingResult
+ {
+ ScenarioId = m_scenario.Id,
+ IsColdRender = true,
+ DurationMs = stopwatch.Elapsed.TotalMilliseconds,
+ Timestamp = DateTime.UtcNow
+ };
+
+ return LastTiming;
+ }
+
+ ///
+ /// Executes a warm render (subsequent render with existing view/cache).
+ ///
+ /// The timing result for the warm render.
+ public RenderTimingResult ExecuteWarmRender()
+ {
+ if (m_view == null)
+ {
+ throw new InvalidOperationException("Must call ExecuteColdRender before ExecuteWarmRender.");
+ }
+
+ var stopwatch = Stopwatch.StartNew();
+
+ // Force a full relayout to simulate warm render
+ MeasureStage(
+ "Reconstruct",
+ () => m_view.RootBox?.Reconstruct(),
+ new Dictionary { { "phase", "warm" } });
+
+ MeasureStage(
+ "PerformOffscreenLayout",
+ () => PerformOffscreenLayout(m_view.Width, m_view.Height),
+ new Dictionary { { "phase", "warm" } });
+
+ if (EnsureViewSizedToContent(m_view.Width, m_view.Height))
+ {
+ MeasureStage(
+ "PerformOffscreenLayout",
+ () => PerformOffscreenLayout(m_view.Width, m_view.Height),
+ new Dictionary { { "phase", "warm-resized" } });
+ }
+
+ stopwatch.Stop();
+
+ LastTiming = new RenderTimingResult
+ {
+ ScenarioId = m_scenario.Id,
+ IsColdRender = false,
+ DurationMs = stopwatch.Elapsed.TotalMilliseconds,
+ Timestamp = DateTime.UtcNow
+ };
+
+ return LastTiming;
+ }
+
+ ///
+ /// Performs layout using an offscreen graphics context matching the target bitmap format.
+ /// This prevents dependency on the Control's window handle or screen DC.
+ ///
+ private void PerformOffscreenLayout(int width, int height)
+ {
+ if (m_view?.RootBox == null) return;
+
+ // Use the same width the site reports to Reconstruct, so the
+ // PATH-L1 layout guard can detect truly redundant calls.
+ int layoutWidth = m_view.GetAvailWidth(m_view.RootBox);
+
+ // PATH-L4: Cache the offscreen GDI resources across calls to
+ // eliminate ~27ms per-call Bitmap/Graphics/HDC allocation overhead.
+ // Layout itself takes <0.1ms when the PATH-L1 guard fires.
+ if (m_layoutBmp == null || m_layoutBmp.Width != width || m_layoutBmp.Height != height)
+ {
+ DisposeLayoutResources();
+ m_layoutBmp = new Bitmap(width, height, PixelFormat.Format32bppArgb);
+ m_layoutGraphics = Graphics.FromImage(m_layoutBmp);
+ m_layoutHdc = m_layoutGraphics.GetHdc();
+ m_layoutVwGraphics = VwGraphicsWin32Class.Create();
+ ((IVwGraphicsWin32)m_layoutVwGraphics).Initialize(m_layoutHdc);
+ }
+
+ m_view.RootBox.Layout(m_layoutVwGraphics, layoutWidth);
+ }
+
+ private bool EnsureViewSizedToContent(int width, int minimumHeight)
+ {
+ if (m_view?.RootBox == null)
+ return false;
+
+ int requiredHeight = Math.Max(minimumHeight, m_view.RootBox.Height + CapturePaddingPx);
+ if (requiredHeight <= 0 || requiredHeight == m_view.Height)
+ return false;
+
+ ResizeHostedView(width, requiredHeight);
+ return true;
+ }
+
+ private void ResizeHostedView(int width, int height)
+ {
+ if (m_view == null)
+ return;
+
+ var newSize = new Size(width, height);
+ m_view.Size = newSize;
+
+ if (m_view.Parent is Form form)
+ form.ClientSize = newSize;
+ }
+
+ ///
+ /// Captures the current view as a bitmap using the Views engine's rendering.
+ ///
+ ///
+ /// Uses IVwDrawRootBuffered to render the RootBox directly to a bitmap,
+ /// bypassing DrawToBitmap which doesn't work correctly for Views controls.
+ ///
+ /// The captured bitmap, or null if capture failed.
+ public Bitmap CaptureViewBitmap()
+ {
+ if (m_view == null)
+ {
+ throw new InvalidOperationException("No view available. Call ExecuteColdRender first.");
+ }
+
+ if (m_view.RootBox == null)
+ {
+ throw new InvalidOperationException("RootBox not initialized. MakeRoot may have failed.");
+ }
+
+ try
+ {
+ var width = m_view.Width;
+ var height = m_view.Height;
+
+ Bitmap bitmap = null;
+ MeasureStage(
+ "PrepareToDraw",
+ () => bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb),
+ new Dictionary { { "phase", "capture" } });
+
+ // Create bitmap and get its Graphics/HDC
+ using (var graphics = Graphics.FromImage(bitmap))
+ {
+ // Fill with white background first
+ graphics.Clear(Color.White);
+
+ MeasureStage(
+ "DrawTheRoot",
+ () =>
+ {
+ IntPtr hdc = graphics.GetHdc();
+ try
+ {
+ using (var vdrb = new SIL.FieldWorks.Views.VwDrawRootBuffered())
+ {
+ var clientRect = new Rect(0, 0, width, height);
+ const uint whiteColor = 0x00FFFFFF;
+ vdrb.DrawTheRoot(m_view.RootBox, hdc, clientRect, whiteColor, true, m_view);
+ }
+ }
+ finally
+ {
+ graphics.ReleaseHdc(hdc);
+ }
+ },
+ new Dictionary { { "phase", "capture" } });
+ }
+
+ LastCapture = bitmap;
+ return bitmap;
+ }
+ catch (Exception ex)
+ {
+ Trace.TraceWarning($"[RenderBenchmarkHarness] View capture failed: {ex.Message}");
+ return null;
+ }
+ }
+
+ private void ResetTraceEvents()
+ {
+ m_traceEvents.Clear();
+ m_traceTimelineMs = 0;
+ }
+
+ private void MeasureStage(string stage, Action action, Dictionary context = null)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ action();
+ stopwatch.Stop();
+ RecordStage(stage, stopwatch.Elapsed.TotalMilliseconds, context);
+ }
+
+ private T MeasureStage(string stage, Func func, Dictionary context = null)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ T result = func();
+ stopwatch.Stop();
+ RecordStage(stage, stopwatch.Elapsed.TotalMilliseconds, context);
+ return result;
+ }
+
+ private void RecordStage(string stage, double durationMs, Dictionary context)
+ {
+ m_traceEvents.Add(new TraceEvent
+ {
+ Stage = stage,
+ StartTimeMs = m_traceTimelineMs,
+ DurationMs = durationMs,
+ Context = context
+ });
+ m_traceTimelineMs += durationMs;
+ }
+
+ ///
+ /// Saves the last captured bitmap to the specified path.
+ ///
+ /// The file path to save the bitmap.
+ /// The image format (default: PNG).
+ public void SaveCapture(string outputPath, ImageFormat format = null)
+ {
+ if (LastCapture == null)
+ {
+ throw new InvalidOperationException("No capture available. Call CaptureViewBitmap first.");
+ }
+
+ var directory = Path.GetDirectoryName(outputPath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ LastCapture.Save(outputPath, format ?? ImageFormat.Png);
+ }
+
+ ///
+ /// Validates that the current environment matches the expected deterministic settings.
+ ///
+ /// The expected environment hash.
+ /// True if the environment matches; otherwise, false.
+ public bool ValidateEnvironment(string expectedHash)
+ {
+ return m_environmentValidator.Validate(expectedHash);
+ }
+
+ private DummyBasicView CreateView(int width, int height)
+ {
+ // Host in a Form to ensure valid layout context (handle, client rect, etc.)
+ var form = new Form
+ {
+ FormBorderStyle = FormBorderStyle.None,
+ ShowInTaskbar = false,
+ ClientSize = new Size(width, height)
+ };
+
+ DummyBasicView view;
+
+ switch (m_scenario.ViewType)
+ {
+ case RenderViewType.LexEntry:
+ view = CreateLexEntryView(width, height);
+ break;
+
+ default: // Scripture
+ view = CreateScriptureView(width, height);
+ break;
+ }
+
+ // Ensure styles are available (both StVc and LexEntryVc rely on stylesheet)
+ var ss = new SIL.LCModel.DomainServices.LcmStyleSheet();
+ ss.Init(m_cache, m_cache.LangProject.Hvo, SIL.LCModel.LangProjectTags.kflidStyles);
+ view.StyleSheet = ss;
+
+ form.Controls.Add(view);
+ form.CreateControl(); // Creates form handle and children handles
+
+ // Force handle creation if not yet created (critical for DoLayout)
+ if (!view.IsHandleCreated)
+ {
+ var h = view.Handle;
+ }
+ if (!view.IsHandleCreated)
+ throw new InvalidOperationException("View handle failed to create.");
+
+ return view;
+ }
+
+ private DummyBasicView CreateScriptureView(int width, int height)
+ {
+ var view = new GenericScriptureView(m_scenario.RootObjectHvo, m_scenario.RootFlid)
+ {
+ Cache = m_cache,
+ Visible = true,
+ Dock = DockStyle.None,
+ Location = Point.Empty,
+ Size = new Size(width, height)
+ };
+ view.RootFragmentId = m_scenario.FragmentId;
+ return view;
+ }
+
+ private DummyBasicView CreateLexEntryView(int width, int height)
+ {
+ var view = new GenericLexEntryView(m_scenario.RootObjectHvo, m_scenario.RootFlid)
+ {
+ Cache = m_cache,
+ Visible = true,
+ Dock = DockStyle.None,
+ Location = Point.Empty,
+ Size = new Size(width, height),
+ SimulateIfDataDoubleRender = m_scenario.SimulateIfDataDoubleRender
+ };
+ view.RootFragmentId = LexEntryVc.kFragEntry;
+ return view;
+ }
+
+ private void DisposeView()
+ {
+ DisposeLayoutResources();
+ if (m_view != null)
+ {
+ var form = m_view.Parent as Form;
+ m_view.Dispose();
+ m_view = null;
+ form?.Dispose();
+ }
+ }
+
+ private void DisposeLayoutResources()
+ {
+ if (m_layoutVwGraphics != null)
+ {
+ m_layoutVwGraphics.ReleaseDC();
+ m_layoutVwGraphics = null;
+ }
+ if (m_layoutHdc != IntPtr.Zero && m_layoutGraphics != null)
+ {
+ m_layoutGraphics.ReleaseHdc(m_layoutHdc);
+ m_layoutHdc = IntPtr.Zero;
+ }
+ m_layoutGraphics?.Dispose();
+ m_layoutGraphics = null;
+ m_layoutBmp?.Dispose();
+ m_layoutBmp = null;
+ }
+
+ ///
+ /// Releases all resources used by the harness.
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Releases the unmanaged resources and optionally releases the managed resources.
+ ///
+ /// True to release both managed and unmanaged resources.
+ protected virtual void Dispose(bool disposing)
+ {
+ if (m_disposed)
+ return;
+
+ if (disposing)
+ {
+ DisposeLayoutResources();
+ DisposeView();
+ LastCapture?.Dispose();
+ LastCapture = null;
+ }
+
+ m_disposed = true;
+ }
+ }
+}
diff --git a/Src/Common/RootSite/RootSiteTests/RenderBenchmarkTestsBase.cs b/Src/Common/RootSite/RootSiteTests/RenderBenchmarkTestsBase.cs
new file mode 100644
index 0000000000..5a6659ceff
--- /dev/null
+++ b/Src/Common/RootSite/RootSiteTests/RenderBenchmarkTestsBase.cs
@@ -0,0 +1,1024 @@
+using System;
+using System.Drawing;
+using System.IO;
+using System.Linq;
+using NUnit.Framework;
+using SIL.LCModel;
+using SIL.LCModel.Core.KernelInterfaces;
+using SIL.LCModel.Core.Scripture;
+using SIL.LCModel.Core.Text;
+using SIL.LCModel.Core.WritingSystems;
+using SIL.LCModel.Infrastructure;
+using SIL.FieldWorks.Common.RootSites.RootSiteTests;
+using SIL.FieldWorks.Common.RenderVerification;
+using SIL.LCModel.DomainServices;
+using SIL.LCModel.Utils;
+using SIL.WritingSystems;
+
+namespace SIL.FieldWorks.Common.RootSites.RenderBenchmark
+{
+ ///
+ /// Base class for benchmark tests, handling data generation for scenarios using Real Data.
+ /// Creates Scripture styles in the DB and produces rich test data with chapter/verse markers,
+ /// section headings, and diverse paragraph styles so StVc renders formatted output.
+ ///
+ public abstract class RenderBenchmarkTestsBase : RealDataTestsBase
+ {
+ protected const string DeterministicRenderFontFamily = "Segoe UI";
+ protected ILgWritingSystemFactory m_wsf;
+ protected int m_wsEng;
+ protected int m_wsAr; // Arabic (RTL)
+ protected int m_wsFr; // French (second analysis WS)
+ protected int m_hvoRoot;
+ protected int m_flidContainingTexts;
+ protected int m_frag = 100; // Default to Book View (100)
+
+ [SetUp]
+ public override void TestSetup()
+ {
+ base.TestSetup(); // Creates Cache and DB and calls InitializeProjectData/CreateTestData
+ m_flidContainingTexts = ScrBookTags.kflidFootnotes; // Default, can be overridden
+ }
+
+ protected override void InitializeProjectData()
+ {
+ m_wsf = Cache.WritingSystemFactory;
+ m_wsEng = m_wsf.GetWsFromStr("en");
+ if (m_wsEng == 0) throw new Exception("English WS not found");
+
+ // Create Arabic (RTL) writing system for bidirectional layout tests
+ CoreWritingSystemDefinition arabic;
+ Cache.ServiceLocator.WritingSystemManager.GetOrSet("ar", out arabic);
+ arabic.RightToLeftScript = true;
+ Cache.ServiceLocator.WritingSystems.VernacularWritingSystems.Add(arabic);
+ Cache.ServiceLocator.WritingSystems.CurrentVernacularWritingSystems.Add(arabic);
+ m_wsAr = arabic.Handle;
+
+ // Create French writing system for multi-WS tests
+ CoreWritingSystemDefinition french;
+ Cache.ServiceLocator.WritingSystemManager.GetOrSet("fr", out french);
+ Cache.ServiceLocator.WritingSystems.AnalysisWritingSystems.Add(french);
+ Cache.ServiceLocator.WritingSystems.CurrentAnalysisWritingSystems.Add(french);
+ m_wsFr = french.Handle;
+
+ NormalizeDeterministicWritingSystemFonts(arabic, french);
+
+ // Ensure Scripture exists in the real project
+ if (Cache.LangProject.TranslatedScriptureOA == null)
+ {
+ var scriptureFactory = Cache.ServiceLocator.GetInstance();
+ var script = scriptureFactory.Create();
+ script.Versification = ScrVers.English;
+ Cache.LangProject.TranslatedScriptureOA = script;
+ }
+
+ // Populate the DB with Scripture styles so the stylesheet can resolve them
+ CreateScriptureStyles();
+ }
+
+ private void NormalizeDeterministicWritingSystemFonts(params CoreWritingSystemDefinition[] additionalWritingSystems)
+ {
+ var writingSystems = new[]
+ {
+ Cache.ServiceLocator.WritingSystems.DefaultVernacularWritingSystem,
+ Cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem
+ }
+ .Concat(Cache.ServiceLocator.WritingSystems.CurrentVernacularWritingSystems)
+ .Concat(Cache.ServiceLocator.WritingSystems.CurrentAnalysisWritingSystems)
+ .Concat(additionalWritingSystems)
+ .Where(ws => ws != null)
+ .GroupBy(ws => ws.Handle)
+ .Select(group => group.First());
+
+ foreach (var writingSystem in writingSystems)
+ {
+ writingSystem.DefaultFont = new FontDefinition(DeterministicRenderFontFamily);
+ writingSystem.IsGraphiteEnabled = false;
+ }
+ }
+
+ #region Scripture Style Creation
+
+ ///
+ /// Creates the essential Scripture paragraph and character styles in the DB.
+ /// These mirror definitions from FlexStyles.xml and are required by StVc to
+ /// produce formatted output (bold headings, superscript verse numbers, etc.).
+ /// Without these, the Views engine falls back to plain black text.
+ ///
+ private void CreateScriptureStyles()
+ {
+ var styleFactory = Cache.ServiceLocator.GetInstance();
+ var styles = Cache.LangProject.StylesOC;
+
+ // Find or create the base Normal style (may already exist from template)
+ IStStyle normalStyle = FindStyle(ScrStyleNames.Normal);
+ if (normalStyle == null)
+ {
+ normalStyle = styleFactory.Create(styles, ScrStyleNames.Normal,
+ ContextValues.Internal, StructureValues.Undefined, FunctionValues.Prose,
+ false, 0, true);
+ var normalBldr = TsStringUtils.MakePropsBldr();
+ normalBldr.SetIntPropValues((int)FwTextPropType.ktptFontSize,
+ (int)FwTextPropVar.ktpvMilliPoint, 10000); // 10pt
+ normalBldr.SetStrPropValue((int)FwTextPropType.ktptFontFamily, DeterministicRenderFontFamily);
+ normalStyle.Rules = normalBldr.GetTextProps();
+ }
+
+ // "Paragraph" - the main Scripture prose style (first-line indent 12pt)
+ CreateParagraphStyle(styleFactory, styles, normalStyle);
+
+ // "Section Head" - bold, centered, 9pt
+ CreateSectionHeadStyle(styleFactory, styles, normalStyle);
+
+ // "Chapter Number" - large drop-cap character style
+ CreateChapterNumberStyle(styleFactory, styles);
+
+ // "Verse Number" - superscript character style
+ CreateVerseNumberStyle(styleFactory, styles);
+
+ // "Title Main" - large bold centered style
+ CreateTitleMainStyle(styleFactory, styles, normalStyle);
+ }
+
+ private void CreateParagraphStyle(IStStyleFactory factory, ILcmOwningCollection styles, IStStyle basedOn)
+ {
+ if (FindStyle(ScrStyleNames.NormalParagraph) != null) return;
+
+ var style = factory.Create(styles, ScrStyleNames.NormalParagraph,
+ ContextValues.Text, StructureValues.Body, FunctionValues.Prose,
+ false, 0, true);
+ style.BasedOnRA = basedOn;
+ style.NextRA = style; // next is self
+
+ var bldr = TsStringUtils.MakePropsBldr();
+ bldr.SetIntPropValues((int)FwTextPropType.ktptFirstIndent,
+ (int)FwTextPropVar.ktpvMilliPoint, 12000); // 12pt first line indent
+ style.Rules = bldr.GetTextProps();
+ }
+
+ private void CreateSectionHeadStyle(IStStyleFactory factory, ILcmOwningCollection styles, IStStyle basedOn)
+ {
+ if (FindStyle(ScrStyleNames.SectionHead) != null) return;
+
+ var style = factory.Create(styles, ScrStyleNames.SectionHead,
+ ContextValues.Text, StructureValues.Heading, FunctionValues.Prose,
+ false, 0, true);
+ style.BasedOnRA = basedOn;
+
+ var bldr = TsStringUtils.MakePropsBldr();
+ bldr.SetIntPropValues((int)FwTextPropType.ktptBold,
+ (int)FwTextPropVar.ktpvEnum, (int)FwTextToggleVal.kttvForceOn);
+ bldr.SetIntPropValues((int)FwTextPropType.ktptFontSize,
+ (int)FwTextPropVar.ktpvMilliPoint, 12000); // 12pt bold
+ bldr.SetIntPropValues((int)FwTextPropType.ktptAlign,
+ (int)FwTextPropVar.ktpvEnum, (int)FwTextAlign.ktalCenter);
+ bldr.SetIntPropValues((int)FwTextPropType.ktptSpaceBefore,
+ (int)FwTextPropVar.ktpvMilliPoint, 8000); // 8pt space before
+ bldr.SetIntPropValues((int)FwTextPropType.ktptSpaceAfter,
+ (int)FwTextPropVar.ktpvMilliPoint, 4000); // 4pt space after
+ bldr.SetIntPropValues((int)FwTextPropType.ktptForeColor,
+ (int)FwTextPropVar.ktpvDefault, (int)ColorUtil.ConvertColorToBGR(Color.FromArgb(0, 51, 102))); // dark blue
+ style.Rules = bldr.GetTextProps();
+ }
+
+ private void CreateChapterNumberStyle(IStStyleFactory factory, ILcmOwningCollection styles)
+ {
+ if (FindStyle(ScrStyleNames.ChapterNumber) != null) return;
+
+ var style = factory.Create(styles, ScrStyleNames.ChapterNumber,
+ ContextValues.Text, StructureValues.Body, FunctionValues.Chapter,
+ true, 0, true); // isCharStyle = true
+
+ var bldr = TsStringUtils.MakePropsBldr();
+ bldr.SetIntPropValues((int)FwTextPropType.ktptBold,
+ (int)FwTextPropVar.ktpvEnum, (int)FwTextToggleVal.kttvForceOn);
+ bldr.SetIntPropValues((int)FwTextPropType.ktptFontSize,
+ (int)FwTextPropVar.ktpvMilliPoint, 24000); // 24pt large chapter number
+ bldr.SetIntPropValues((int)FwTextPropType.ktptForeColor,
+ (int)FwTextPropVar.ktpvDefault, (int)ColorUtil.ConvertColorToBGR(Color.FromArgb(128, 0, 0))); // dark red
+ style.Rules = bldr.GetTextProps();
+ }
+
+ private void CreateVerseNumberStyle(IStStyleFactory factory, ILcmOwningCollection styles)
+ {
+ if (FindStyle(ScrStyleNames.VerseNumber) != null) return;
+
+ var style = factory.Create(styles, ScrStyleNames.VerseNumber,
+ ContextValues.Text, StructureValues.Body, FunctionValues.Verse,
+ true, 0, true); // isCharStyle = true
+
+ var bldr = TsStringUtils.MakePropsBldr();
+ bldr.SetIntPropValues((int)FwTextPropType.ktptSuperscript,
+ (int)FwTextPropVar.ktpvEnum, (int)FwSuperscriptVal.kssvSuper);
+ bldr.SetIntPropValues((int)FwTextPropType.ktptBold,
+ (int)FwTextPropVar.ktpvEnum, (int)FwTextToggleVal.kttvForceOn);
+ bldr.SetIntPropValues((int)FwTextPropType.ktptForeColor,
+ (int)FwTextPropVar.ktpvDefault, (int)ColorUtil.ConvertColorToBGR(Color.FromArgb(0, 102, 0))); // dark green
+ style.Rules = bldr.GetTextProps();
+ }
+
+ private void CreateTitleMainStyle(IStStyleFactory factory, ILcmOwningCollection styles, IStStyle basedOn)
+ {
+ if (FindStyle(ScrStyleNames.MainBookTitle) != null) return;
+
+ var style = factory.Create(styles, ScrStyleNames.MainBookTitle,
+ ContextValues.Title, StructureValues.Body, FunctionValues.Prose,
+ false, 0, true);
+ style.BasedOnRA = basedOn;
+
+ var bldr = TsStringUtils.MakePropsBldr();
+ bldr.SetIntPropValues((int)FwTextPropType.ktptBold,
+ (int)FwTextPropVar.ktpvEnum, (int)FwTextToggleVal.kttvForceOn);
+ bldr.SetIntPropValues((int)FwTextPropType.ktptFontSize,
+ (int)FwTextPropVar.ktpvMilliPoint, 20000); // 20pt
+ bldr.SetIntPropValues((int)FwTextPropType.ktptAlign,
+ (int)FwTextPropVar.ktpvEnum, (int)FwTextAlign.ktalCenter);
+ bldr.SetIntPropValues((int)FwTextPropType.ktptSpaceBefore,
+ (int)FwTextPropVar.ktpvMilliPoint, 36000); // 36pt space before
+ bldr.SetIntPropValues((int)FwTextPropType.ktptSpaceAfter,
+ (int)FwTextPropVar.ktpvMilliPoint, 12000); // 12pt space after
+ bldr.SetIntPropValues((int)FwTextPropType.ktptForeColor,
+ (int)FwTextPropVar.ktpvDefault, (int)ColorUtil.ConvertColorToBGR(Color.FromArgb(0, 0, 128))); // navy blue
+ style.Rules = bldr.GetTextProps();
+ }
+
+ private IStStyle FindStyle(string name)
+ {
+ foreach (var s in Cache.LangProject.StylesOC)
+ {
+ if (s.Name == name) return s;
+ }
+ return null;
+ }
+
+ #endregion
+
+ protected override void CreateTestData()
+ {
+ // Individual tests call SetupScenarioData
+ }
+
+ protected void SetupScenarioData(string scenarioId)
+ {
+ m_frag = 100;
+ m_flidContainingTexts = ScrBookTags.kflidFootnotes;
+
+ switch (scenarioId)
+ {
+ case "simple":
+ case "simple-test":
+ CreateSimpleScenario();
+ break;
+ case "medium":
+ CreateMediumScenario();
+ break;
+ case "complex":
+ CreateComplexScenario();
+ break;
+ case "deep-nested":
+ CreateDeepNestedScenario();
+ break;
+ case "custom-heavy":
+ CreateCustomHeavyScenario();
+ break;
+ case "many-paragraphs":
+ CreateManyParagraphsScenario();
+ break;
+ case "footnote-heavy":
+ CreateFootnoteHeavyScenario();
+ break;
+ case "mixed-styles":
+ CreateMixedStylesScenario();
+ break;
+ case "long-prose":
+ CreateLongProseScenario();
+ break;
+ case "multi-book":
+ CreateMultiBookScenario();
+ break;
+ case "rtl-script":
+ CreateRtlScriptScenario();
+ break;
+ case "multi-ws":
+ CreateMultiWsScenario();
+ break;
+ case "lex-shallow":
+ CreateLexEntryScenario(depth: 2, breadth: 3);
+ break;
+ case "lex-deep":
+ CreateLexEntryScenario(depth: 4, breadth: 2);
+ break;
+ case "lex-extreme":
+ CreateLexEntryScenario(depth: 6, breadth: 2);
+ break;
+ default:
+ CreateSimpleScenario();
+ break;
+ }
+ }
+
+ protected RenderScenario CreateConfiguredScenario(string scenarioId)
+ {
+ var scenarioConfig = RenderScenarioDataBuilder.LoadFromFile()
+ .FirstOrDefault(s => s.Id == scenarioId);
+
+ return new RenderScenario
+ {
+ Id = scenarioId,
+ Description = scenarioConfig?.Description ?? $"Render scenario for {scenarioId}",
+ Tags = scenarioConfig?.Tags,
+ RootObjectHvo = m_hvoRoot,
+ RootFlid = m_flidContainingTexts,
+ FragmentId = m_frag,
+ ViewType = scenarioConfig?.ViewType ?? RenderViewType.Scripture,
+ SimulateIfDataDoubleRender = scenarioConfig?.SimulateIfDataDoubleRender ?? false
+ };
+ }
+
+ internal RootSiteScenarioExecutionResult ExecuteScenarioAndCapture(
+ string scenarioId,
+ bool includeWarmRender,
+ RenderEnvironmentValidator environmentValidator = null,
+ int width = 800,
+ int height = 600)
+ {
+ RenderScenario scenario;
+ using (var uow = new UndoableUnitOfWorkHelper(Cache.ActionHandlerAccessor,
+ "Setup Scenario", "Undo Setup Scenario"))
+ {
+ SetupScenarioData(scenarioId);
+ scenario = CreateConfiguredScenario(scenarioId);
+ uow.RollBack = false;
+ }
+
+ using (var harness = new RenderBenchmarkHarness(Cache, scenario, environmentValidator))
+ {
+ var coldTiming = harness.ExecuteColdRender(width, height);
+
+ using (var bitmap = harness.CaptureViewBitmap())
+ {
+ if (bitmap == null)
+ throw new InvalidOperationException($"Failed to capture render bitmap for scenario '{scenarioId}'.");
+
+ string directory = global::SIL.FieldWorks.Common.RootSites.RenderBaselineVerifier.GetSourceFileDirectory();
+ string snapshotName = $"RenderVerifyTests.VerifyScenario_{scenarioId}";
+ var verification = global::SIL.FieldWorks.Common.RootSites.RenderBaselineVerifier.Verify(
+ bitmap,
+ directory,
+ snapshotName,
+ scenarioId);
+
+ var warmTiming = includeWarmRender ? harness.ExecuteWarmRender() : null;
+
+ return new RootSiteScenarioExecutionResult
+ {
+ Scenario = scenario,
+ ColdTiming = coldTiming,
+ WarmTiming = warmTiming,
+ Verification = verification,
+ CaptureWidth = bitmap.Width,
+ CaptureHeight = bitmap.Height,
+ TraceEvents = harness.TraceEvents.ToList()
+ };
+ }
+ }
+ }
+
+ protected static string[] GetConfiguredScenarioIds(params string[] fallbackScenarioIds)
+ {
+ try
+ {
+ var scenarios = RenderScenarioDataBuilder.LoadFromFile();
+ if (scenarios.Count > 0)
+ return scenarios.Select(s => s.Id).ToArray();
+ }
+ catch (Exception ex)
+ {
+ TestContext.Error.WriteLine($"Error discovering scenarios: {ex.Message}");
+ }
+
+ return fallbackScenarioIds;
+ }
+
+ private void CreateSimpleScenario()
+ {
+ var book = CreateBook(1); // GEN
+ m_hvoRoot = book.Hvo;
+ AddRichSections(book, 3, versesPerSection: 4, chapterStart: 1);
+ }
+
+ private void CreateMediumScenario()
+ {
+ var book = CreateBook(2); // EXO
+ m_hvoRoot = book.Hvo;
+ AddRichSections(book, 5, versesPerSection: 6, chapterStart: 1);
+ }
+
+ private void CreateComplexScenario()
+ {
+ var book = CreateBook(3); // LEV
+ m_hvoRoot = book.Hvo;
+ AddRichSections(book, 10, versesPerSection: 8, chapterStart: 1);
+ }
+
+ private void CreateDeepNestedScenario()
+ {
+ var book = CreateBook(4); // NUM
+ m_hvoRoot = book.Hvo;
+ AddRichSections(book, 3, versesPerSection: 12, chapterStart: 1);
+ }
+
+ private void CreateCustomHeavyScenario()
+ {
+ var book = CreateBook(5); // DEU
+ m_hvoRoot = book.Hvo;
+ AddRichSections(book, 5, versesPerSection: 8, chapterStart: 1);
+ }
+
+ ///
+ /// Stress: 50 sections, each with a single verse — forces massive paragraph layout overhead.
+ ///
+ private void CreateManyParagraphsScenario()
+ {
+ var book = CreateBook(6); // JOS
+ m_hvoRoot = book.Hvo;
+ AddRichSections(book, 50, versesPerSection: 1, chapterStart: 1);
+ }
+
+ ///
+ /// Stress: 8 sections each containing 20 verses plus footnotes on every other verse.
+ /// Forces footnote callers and footnote paragraph creation en masse.
+ ///
+ private void CreateFootnoteHeavyScenario()
+ {
+ var book = CreateBook(7); // JDG
+ m_hvoRoot = book.Hvo;
+ AddRichSectionsWithFootnotes(book, 8, versesPerSection: 20, chapterStart: 1);
+ }
+
+ ///
+ /// Stress: Every verse run uses a different character style combination.
+ /// Forces the style resolver to compute many distinct property sets.
+ ///
+ private void CreateMixedStylesScenario()
+ {
+ var book = CreateBook(8); // RUT
+ m_hvoRoot = book.Hvo;
+ AddMixedStyleSections(book, 6, versesPerSection: 15, chapterStart: 1);
+ }
+
+ ///
+ /// Stress: 4 sections, each with a single paragraph containing 80 verses — very long
+ /// unbroken paragraph that forces extensive line-breaking and layout computation.
+ ///
+ private void CreateLongProseScenario()
+ {
+ var book = CreateBook(9); // 1SA
+ m_hvoRoot = book.Hvo;
+ AddRichSections(book, 4, versesPerSection: 80, chapterStart: 1);
+ }
+
+ ///
+ /// Stress: Creates 3 separate books with sections each, then sets root to the
+ /// first book. Verifies the rendering engine handles large Scripture caches.
+ ///
+ private void CreateMultiBookScenario()
+ {
+ var book1 = CreateBook(10); // 2SA
+ AddRichSections(book1, 5, versesPerSection: 10, chapterStart: 1);
+
+ var book2 = CreateBook(11); // 1KI
+ AddRichSections(book2, 5, versesPerSection: 10, chapterStart: 1);
+
+ var book3 = CreateBook(12); // 2KI
+ AddRichSections(book3, 5, versesPerSection: 10, chapterStart: 1);
+
+ m_hvoRoot = book1.Hvo; // render the first; the others stress the backing store
+ }
+
+ ///
+ /// Stress: Creates sections where all prose text is in Arabic (RTL), exercising
+ /// bidirectional layout, Uniscribe/Graphite RTL shaping, and right-aligned paragraphs.
+ /// Section headings remain English to force bidi mixing within the view.
+ ///
+ private void CreateRtlScriptScenario()
+ {
+ var book = CreateBook(13); // 1CH
+ m_hvoRoot = book.Hvo;
+ AddRtlSections(book, 4, versesPerSection: 10, chapterStart: 1);
+ }
+
+ ///
+ /// Stress: Creates sections that alternate between English, Arabic, and French runs
+ /// within the same paragraph, forcing the rendering engine to handle font fallback,
+ /// writing-system switching, and mixed bidi text.
+ ///
+ private void CreateMultiWsScenario()
+ {
+ var book = CreateBook(14); // 2CH
+ m_hvoRoot = book.Hvo;
+ AddMultiWsSections(book, 5, versesPerSection: 8, chapterStart: 1);
+ }
+
+ #region Rich Data Factories
+
+ protected IScrBook CreateBook(int bookNum)
+ {
+ var bookFactory = Cache.ServiceLocator.GetInstance();
+ return bookFactory.Create(bookNum);
+ }
+
+ ///
+ /// Creates sections with formatted section headings (bold centered "Section Head" style),
+ /// chapter numbers (large bold red), verse numbers (superscript bold green), and body
+ /// text (indented "Paragraph" style). This produces richly styled output from StVc.
+ ///
+ protected void AddRichSections(IScrBook book, int sectionCount, int versesPerSection, int chapterStart)
+ {
+ var sectionFactory = Cache.ServiceLocator.GetInstance();
+ var stTextFactory = Cache.ServiceLocator.GetInstance();
+ int chapter = chapterStart;
+
+ // Sample prose fragments for realistic text variety
+ string[] proseFragments = new[]
+ {
+ "In the beginning God created the heavens and the earth. ",
+ "The earth was formless and empty, darkness was over the surface of the deep. ",
+ "And God said, Let there be light, and there was light. ",
+ "God saw that the light was good, and he separated the light from the darkness. ",
+ "God called the light day, and the darkness he called night. ",
+ "And there was evening, and there was morning, the first day. ",
+ "Then God said, Let there be a vault between the waters to separate water. ",
+ "So God made the vault and separated the water under the vault from the water above it. ",
+ "God called the vault sky. And there was evening, and there was morning, the second day. ",
+ "And God said, Let the water under the sky be gathered to one place, and let dry ground appear. ",
+ "God called the dry ground land, and the gathered waters he called seas. And God saw that it was good. ",
+ "Then God said, Let the land produce vegetation: seed bearing plants and trees on the land. ",
+ };
+
+ for (int s = 0; s < sectionCount; s++)
+ {
+ var section = sectionFactory.Create();
+ book.SectionsOS.Add(section);
+
+ // Create heading StText and add a heading paragraph
+ section.HeadingOA = stTextFactory.Create();
+ var headingBldr = new StTxtParaBldr(Cache)
+ {
+ ParaStyleName = ScrStyleNames.SectionHead
+ };
+ string headingText = $"Section {s + 1}: The Account of Day {s + 1}";
+ headingBldr.AppendRun(headingText, StyleUtils.CharStyleTextProps(null, m_wsEng));
+ headingBldr.CreateParagraph(section.HeadingOA);
+
+ // Create content StText and add body paragraphs with chapter/verse
+ section.ContentOA = stTextFactory.Create();
+
+ var paraBldr = new StTxtParaBldr(Cache)
+ {
+ ParaStyleName = ScrStyleNames.NormalParagraph
+ };
+
+ // First section gets a chapter number
+ if (s == 0 || s % 3 == 0)
+ {
+ paraBldr.AppendRun(chapter.ToString(),
+ StyleUtils.CharStyleTextProps(ScrStyleNames.ChapterNumber, m_wsEng));
+ chapter++;
+ }
+
+ // Add verses with prose text
+ for (int v = 1; v <= versesPerSection; v++)
+ {
+ // Verse number run
+ paraBldr.AppendRun(v.ToString() + "\u00A0",
+ StyleUtils.CharStyleTextProps(ScrStyleNames.VerseNumber, m_wsEng));
+
+ // Prose text run (cycle through fragments for variety)
+ string prose = proseFragments[(s * versesPerSection + v) % proseFragments.Length];
+ paraBldr.AppendRun(prose, StyleUtils.CharStyleTextProps(null, m_wsEng));
+ }
+
+ paraBldr.CreateParagraph(section.ContentOA);
+ }
+ }
+
+ ///
+ /// Creates sections with footnotes on every other verse, stressing the footnote
+ /// rendering path (caller markers, footnote paragraph boxes, and layout).
+ ///
+ protected void AddRichSectionsWithFootnotes(IScrBook book, int sectionCount,
+ int versesPerSection, int chapterStart)
+ {
+ var sectionFactory = Cache.ServiceLocator.GetInstance();
+ var stTextFactory = Cache.ServiceLocator.GetInstance();
+ var footnoteFactory = Cache.ServiceLocator.GetInstance();
+ int chapter = chapterStart;
+
+ string[] proseFragments = new[]
+ {
+ "The Lord is my shepherd, I lack nothing. He makes me lie down in green pastures. ",
+ "He leads me beside quiet waters, he refreshes my soul. He guides me along right paths. ",
+ "Even though I walk through the darkest valley, I will fear no evil, for you are with me. ",
+ "Your rod and your staff, they comfort me. You prepare a table before me. ",
+ "You anoint my head with oil; my cup overflows. Surely your goodness and love will follow me. ",
+ "And I will dwell in the house of the Lord forever. Hear my cry for mercy as I call to you. ",
+ };
+
+ string[] footnoteTexts = new[]
+ {
+ "Or righteousness; Hb. tsedeq",
+ "Some manuscripts add for his name's sake",
+ "Lit. in the valley of deep darkness",
+ "Gk. adds in the presence of my enemies",
+ };
+
+ for (int s = 0; s < sectionCount; s++)
+ {
+ var section = sectionFactory.Create();
+ book.SectionsOS.Add(section);
+
+ section.HeadingOA = stTextFactory.Create();
+ var headingBldr = new StTxtParaBldr(Cache) { ParaStyleName = ScrStyleNames.SectionHead };
+ headingBldr.AppendRun($"Psalm {s + 1}: A Song of Ascents",
+ StyleUtils.CharStyleTextProps(null, m_wsEng));
+ headingBldr.CreateParagraph(section.HeadingOA);
+
+ section.ContentOA = stTextFactory.Create();
+
+ var paraBldr = new StTxtParaBldr(Cache) { ParaStyleName = ScrStyleNames.NormalParagraph };
+
+ if (s == 0 || s % 3 == 0)
+ {
+ paraBldr.AppendRun(chapter.ToString(),
+ StyleUtils.CharStyleTextProps(ScrStyleNames.ChapterNumber, m_wsEng));
+ chapter++;
+ }
+
+ for (int v = 1; v <= versesPerSection; v++)
+ {
+ paraBldr.AppendRun(v.ToString() + "\u00A0",
+ StyleUtils.CharStyleTextProps(ScrStyleNames.VerseNumber, m_wsEng));
+
+ string prose = proseFragments[(s * versesPerSection + v) % proseFragments.Length];
+ paraBldr.AppendRun(prose, StyleUtils.CharStyleTextProps(null, m_wsEng));
+
+ // Add a footnote caller on every other verse
+ if (v % 2 == 0)
+ {
+ var footnote = footnoteFactory.Create();
+ book.FootnotesOS.Add(footnote);
+ var footParaBldr = new StTxtParaBldr(Cache)
+ {
+ ParaStyleName = ScrStyleNames.NormalParagraph
+ };
+ string fnText = footnoteTexts[(s + v) % footnoteTexts.Length];
+ footParaBldr.AppendRun(fnText, StyleUtils.CharStyleTextProps(null, m_wsEng));
+ footParaBldr.CreateParagraph(footnote);
+ }
+ }
+
+ paraBldr.CreateParagraph(section.ContentOA);
+ }
+ }
+
+ ///
+ /// Creates sections where each verse uses a different combination of character
+ /// formatting (bold, italic, font-size, foreground colour). This forces the style
+ /// resolver and text-properties builder to compute many distinct property sets,
+ /// stressing the rendering property cache.
+ ///
+ protected void AddMixedStyleSections(IScrBook book, int sectionCount,
+ int versesPerSection, int chapterStart)
+ {
+ var sectionFactory = Cache.ServiceLocator.GetInstance();
+ var stTextFactory = Cache.ServiceLocator.GetInstance();
+ int chapter = chapterStart;
+
+ string[] proseFragments = new[]
+ {
+ "Blessed is the one who does not walk in step with the wicked. ",
+ "But whose delight is in the law of the Lord, and who meditates on his law day and night. ",
+ "That person is like a tree planted by streams of water, which yields its fruit in season. ",
+ "Not so the wicked! They are like chaff that the wind blows away. ",
+ "Therefore the wicked will not stand in the judgment, nor sinners in the assembly. ",
+ "For the Lord watches over the way of the righteous, but the way of the wicked leads to destruction. ",
+ };
+
+ // Colour palette for rotating foreground colour
+ int[] colours = new[]
+ {
+ (int)ColorUtil.ConvertColorToBGR(Color.FromArgb(0, 0, 0)), // black
+ (int)ColorUtil.ConvertColorToBGR(Color.FromArgb(128, 0, 0)), // maroon
+ (int)ColorUtil.ConvertColorToBGR(Color.FromArgb(0, 0, 128)), // navy
+ (int)ColorUtil.ConvertColorToBGR(Color.FromArgb(0, 100, 0)), // dark green
+ (int)ColorUtil.ConvertColorToBGR(Color.FromArgb(128, 0, 128)), // purple
+ (int)ColorUtil.ConvertColorToBGR(Color.FromArgb(139, 69, 19)), // saddle brown
+ };
+
+ int[] fontSizes = new[] { 9000, 10000, 11000, 12000, 14000, 16000 }; // millipoints
+
+ for (int s = 0; s < sectionCount; s++)
+ {
+ var section = sectionFactory.Create();
+ book.SectionsOS.Add(section);
+
+ section.HeadingOA = stTextFactory.Create();
+ var headingBldr = new StTxtParaBldr(Cache) { ParaStyleName = ScrStyleNames.SectionHead };
+ headingBldr.AppendRun($"Varied Styles Section {s + 1}",
+ StyleUtils.CharStyleTextProps(null, m_wsEng));
+ headingBldr.CreateParagraph(section.HeadingOA);
+
+ section.ContentOA = stTextFactory.Create();
+
+ var paraBldr = new StTxtParaBldr(Cache) { ParaStyleName = ScrStyleNames.NormalParagraph };
+
+ if (s == 0 || s % 3 == 0)
+ {
+ paraBldr.AppendRun(chapter.ToString(),
+ StyleUtils.CharStyleTextProps(ScrStyleNames.ChapterNumber, m_wsEng));
+ chapter++;
+ }
+
+ for (int v = 1; v <= versesPerSection; v++)
+ {
+ // Verse number
+ paraBldr.AppendRun(v.ToString() + "\u00A0",
+ StyleUtils.CharStyleTextProps(ScrStyleNames.VerseNumber, m_wsEng));
+
+ // Build a custom text props for this verse
+ int idx = (s * versesPerSection + v);
+ var bldr = TsStringUtils.MakePropsBldr();
+ bldr.SetIntPropValues((int)FwTextPropType.ktptWs,
+ (int)FwTextPropVar.ktpvDefault, m_wsEng);
+ bldr.SetIntPropValues((int)FwTextPropType.ktptFontSize,
+ (int)FwTextPropVar.ktpvMilliPoint, fontSizes[idx % fontSizes.Length]);
+ bldr.SetIntPropValues((int)FwTextPropType.ktptForeColor,
+ (int)FwTextPropVar.ktpvDefault, colours[idx % colours.Length]);
+
+ // Alternate bold / italic
+ if (idx % 3 == 0)
+ {
+ bldr.SetIntPropValues((int)FwTextPropType.ktptBold,
+ (int)FwTextPropVar.ktpvEnum, (int)FwTextToggleVal.kttvForceOn);
+ }
+ if (idx % 4 == 0)
+ {
+ bldr.SetIntPropValues((int)FwTextPropType.ktptItalic,
+ (int)FwTextPropVar.ktpvEnum, (int)FwTextToggleVal.kttvForceOn);
+ }
+
+ string prose = proseFragments[idx % proseFragments.Length];
+ paraBldr.AppendRun(prose, bldr.GetTextProps());
+ }
+
+ paraBldr.CreateParagraph(section.ContentOA);
+ }
+ }
+
+ ///
+ /// Creates sections where all body text is Arabic (RTL). Section headings are
+ /// English to create bidi mixing from the view's perspective. Chapter and verse
+ /// numbers remain LTR (standard Scripture convention).
+ ///
+ protected void AddRtlSections(IScrBook book, int sectionCount,
+ int versesPerSection, int chapterStart)
+ {
+ var sectionFactory = Cache.ServiceLocator.GetInstance();
+ var stTextFactory = Cache.ServiceLocator.GetInstance();
+ int chapter = chapterStart;
+
+ // Arabic prose fragments (Bismillah-style phrases and common Quranic vocabulary)
+ string[] arabicProse = new[]
+ {
+ "\u0628\u0650\u0633\u0652\u0645\u0650 \u0627\u0644\u0644\u0651\u0647\u0650 \u0627\u0644\u0631\u0651\u064E\u062D\u0652\u0645\u0646\u0650 \u0627\u0644\u0631\u0651\u064E\u062D\u064A\u0645\u0650. ", // Bismillah
+ "\u0627\u0644\u0652\u062D\u064E\u0645\u0652\u062F\u064F \u0644\u0650\u0644\u0651\u0647\u0650 \u0631\u064E\u0628\u0651\u0650 \u0627\u0644\u0652\u0639\u064E\u0627\u0644\u064E\u0645\u064A\u0646\u064E. ", // Al-hamdu lillahi
+ "\u0645\u064E\u0627\u0644\u0650\u0643\u0650 \u064A\u064E\u0648\u0652\u0645\u0650 \u0627\u0644\u062F\u0651\u064A\u0646\u0650. ", // Maliki yawm al-din
+ "\u0625\u0650\u064A\u0651\u064E\u0627\u0643\u064E \u0646\u064E\u0639\u0652\u0628\u064F\u062F\u064F \u0648\u064E\u0625\u0650\u064A\u0651\u064E\u0627\u0643\u064E \u0646\u064E\u0633\u0652\u062A\u064E\u0639\u064A\u0646\u064F. ", // Iyyaka na'budu
+ "\u0627\u0647\u0652\u062F\u0650\u0646\u064E\u0627 \u0627\u0644\u0635\u0651\u0650\u0631\u064E\u0627\u0637\u064E \u0627\u0644\u0652\u0645\u064F\u0633\u0652\u062A\u064E\u0642\u064A\u0645\u064E. ", // Ihdina al-sirat
+ "\u0635\u0650\u0631\u064E\u0627\u0637\u064E \u0627\u0644\u0651\u064E\u0630\u064A\u0646\u064E \u0623\u064E\u0646\u0652\u0639\u064E\u0645\u0652\u062A\u064E \u0639\u064E\u0644\u064E\u064A\u0652\u0647\u0650\u0645\u0652. ", // Sirat alladhina
+ };
+
+ for (int s = 0; s < sectionCount; s++)
+ {
+ var section = sectionFactory.Create();
+ book.SectionsOS.Add(section);
+
+ // English heading (LTR in an otherwise RTL view)
+ section.HeadingOA = stTextFactory.Create();
+ var headingBldr = new StTxtParaBldr(Cache) { ParaStyleName = ScrStyleNames.SectionHead };
+ headingBldr.AppendRun($"Section {s + 1}: Arabic Scripture",
+ StyleUtils.CharStyleTextProps(null, m_wsEng));
+ headingBldr.CreateParagraph(section.HeadingOA);
+
+ section.ContentOA = stTextFactory.Create();
+ var paraBldr = new StTxtParaBldr(Cache) { ParaStyleName = ScrStyleNames.NormalParagraph };
+
+ if (s == 0 || s % 3 == 0)
+ {
+ paraBldr.AppendRun(chapter.ToString(),
+ StyleUtils.CharStyleTextProps(ScrStyleNames.ChapterNumber, m_wsEng));
+ chapter++;
+ }
+
+ for (int v = 1; v <= versesPerSection; v++)
+ {
+ paraBldr.AppendRun(v.ToString() + "\u00A0",
+ StyleUtils.CharStyleTextProps(ScrStyleNames.VerseNumber, m_wsEng));
+
+ // Arabic prose (RTL)
+ string prose = arabicProse[(s * versesPerSection + v) % arabicProse.Length];
+ paraBldr.AppendRun(prose, StyleUtils.CharStyleTextProps(null, m_wsAr));
+ }
+
+ paraBldr.CreateParagraph(section.ContentOA);
+ }
+ }
+
+ ///
+ /// Creates sections where each verse contains runs in three different writing systems
+ /// (English, Arabic, French) within the same paragraph. This forces the rendering
+ /// engine to handle font fallback, writing-system switching, mixed bidi text, and
+ /// line-breaking across WS boundaries.
+ ///
+ protected void AddMultiWsSections(IScrBook book, int sectionCount,
+ int versesPerSection, int chapterStart)
+ {
+ var sectionFactory = Cache.ServiceLocator.GetInstance();
+ var stTextFactory = Cache.ServiceLocator.GetInstance();
+ int chapter = chapterStart;
+
+ string[] englishProse = new[]
+ {
+ "In the beginning was the Word. ",
+ "The light shines in the darkness. ",
+ "Grace and truth came through Him. ",
+ };
+
+ string[] frenchProse = new[]
+ {
+ "Au commencement \u00E9tait la Parole. ",
+ "La lumi\u00E8re brille dans les t\u00E9n\u00E8bres. ",
+ "La gr\u00E2ce et la v\u00E9rit\u00E9 sont venues par Lui. ",
+ };
+
+ string[] arabicProse = new[]
+ {
+ "\u0641\u064A \u0627\u0644\u0628\u062F\u0621 \u0643\u0627\u0646 \u0627\u0644\u0643\u0644\u0645\u0629. ", // In the beginning was the Word
+ "\u0627\u0644\u0646\u0648\u0631 \u064A\u0636\u064A\u0621 \u0641\u064A \u0627\u0644\u0638\u0644\u0627\u0645. ", // The light shines in the darkness
+ "\u0627\u0644\u0646\u0639\u0645\u0629 \u0648\u0627\u0644\u062D\u0642 \u0628\u0650\u0647. ", // Grace and truth through Him
+ };
+
+ for (int s = 0; s < sectionCount; s++)
+ {
+ var section = sectionFactory.Create();
+ book.SectionsOS.Add(section);
+
+ section.HeadingOA = stTextFactory.Create();
+ var headingBldr = new StTxtParaBldr(Cache) { ParaStyleName = ScrStyleNames.SectionHead };
+ headingBldr.AppendRun($"Multi-WS Section {s + 1}",
+ StyleUtils.CharStyleTextProps(null, m_wsEng));
+ headingBldr.CreateParagraph(section.HeadingOA);
+
+ section.ContentOA = stTextFactory.Create();
+ var paraBldr = new StTxtParaBldr(Cache) { ParaStyleName = ScrStyleNames.NormalParagraph };
+
+ if (s == 0 || s % 3 == 0)
+ {
+ paraBldr.AppendRun(chapter.ToString(),
+ StyleUtils.CharStyleTextProps(ScrStyleNames.ChapterNumber, m_wsEng));
+ chapter++;
+ }
+
+ for (int v = 1; v <= versesPerSection; v++)
+ {
+ paraBldr.AppendRun(v.ToString() + "\u00A0",
+ StyleUtils.CharStyleTextProps(ScrStyleNames.VerseNumber, m_wsEng));
+
+ int idx = (s * versesPerSection + v);
+
+ // Rotate: English → Arabic → French within each verse
+ paraBldr.AppendRun(englishProse[idx % englishProse.Length],
+ StyleUtils.CharStyleTextProps(null, m_wsEng));
+ paraBldr.AppendRun(arabicProse[idx % arabicProse.Length],
+ StyleUtils.CharStyleTextProps(null, m_wsAr));
+ paraBldr.AppendRun(frenchProse[idx % frenchProse.Length],
+ StyleUtils.CharStyleTextProps(null, m_wsFr));
+ }
+
+ paraBldr.CreateParagraph(section.ContentOA);
+ }
+ }
+
+ #endregion
+
+ #region Lex Entry Scenario Data
+
+ ///
+ /// Creates a lexical entry scenario with nested senses at the specified depth and breadth.
+ /// This is the primary scenario for tracking the exponential rendering overhead in
+ /// XmlVc's visibility="ifdata" double-render pattern.
+ ///
+ /// Number of nesting levels (2 = senses with one level of subsenses).
+ /// Number of child senses per parent at each level.
+ ///
+ /// Total sense count = breadth + breadth^2 + ... + breadth^depth = breadth*(breadth^depth - 1)/(breadth - 1).
+ /// With the ifdata double-render, each level doubles the work, so rendering time
+ /// grows as O(breadth^depth * 2^depth) = O((2*breadth)^depth). Depth 6 with breadth 2
+ /// produces 126 senses and ~4096x the per-sense overhead of depth 1.
+ ///
+ private void CreateLexEntryScenario(int depth, int breadth)
+ {
+ // Ensure LexDb exists
+ if (Cache.LangProject.LexDbOA == null)
+ {
+ Cache.ServiceLocator.GetInstance().Create();
+ }
+
+ var entryFactory = Cache.ServiceLocator.GetInstance();
+ var senseFactory = Cache.ServiceLocator.GetInstance();
+ var morphFactory = Cache.ServiceLocator.GetInstance();
+
+ // Create the entry with a headword
+ var entry = entryFactory.Create();
+ var morph = morphFactory.Create();
+ entry.LexemeFormOA = morph;
+ morph.Form.set_String(m_wsEng, MakeRenderString("benchmark-entry", m_wsEng));
+
+ // Recursively create the sense tree
+ CreateNestedSenses(entry, senseFactory, depth, breadth, "", 1);
+
+ // Set root to entry HVO with LexEntry.Senses as the containing flid
+ m_hvoRoot = entry.Hvo;
+ m_flidContainingTexts = LexEntryTags.kflidSenses;
+ m_frag = LexEntryVc.kFragEntry;
+ }
+
+ ///
+ /// Recursively creates a tree of senses/subsenses.
+ ///
+ /// The owning entry or sense.
+ /// Factory for creating new senses.
+ /// Remaining nesting levels to create.
+ /// Number of children at each level.
+ /// Hierarchical number prefix (e.g., "1.2.").
+ /// Starting sense number at this level.
+ private void CreateNestedSenses(ICmObject owner, ILexSenseFactory senseFactory,
+ int remainingDepth, int breadth, string prefix, int startNumber)
+ {
+ if (remainingDepth <= 0)
+ return;
+
+ for (int i = 0; i < breadth; i++)
+ {
+ var sense = senseFactory.Create();
+ string senseNum = prefix + (startNumber + i);
+
+ // Add sense to entry or parent sense
+ var entry = owner as ILexEntry;
+ if (entry != null)
+ entry.SensesOS.Add(sense);
+ else
+ ((ILexSense)owner).SensesOS.Add(sense);
+
+ // Set gloss and definition
+ string gloss = $"gloss {senseNum}";
+ string definition = $"This is the definition for sense {senseNum}, which demonstrates " +
+ $"nested rendering at depth {remainingDepth} with {breadth}-way branching.";
+
+ sense.Gloss.set_String(m_wsEng, MakeRenderString(gloss, m_wsEng));
+ sense.Definition.set_String(m_wsEng, MakeRenderString(definition, m_wsEng));
+
+ // Recurse for subsenses
+ CreateNestedSenses(sense, senseFactory, remainingDepth - 1, breadth,
+ senseNum + ".", 1);
+ }
+ }
+
+ private static ITsString MakeRenderString(string value, int writingSystemHandle)
+ {
+ var propsBuilder = TsStringUtils.MakePropsBldr();
+ propsBuilder.SetIntPropValues((int)FwTextPropType.ktptWs,
+ (int)FwTextPropVar.ktpvDefault, writingSystemHandle);
+ propsBuilder.SetStrPropValue((int)FwTextPropType.ktptFontFamily,
+ DeterministicRenderFontFamily);
+
+ var stringBuilder = TsStringUtils.MakeStrBldr();
+ stringBuilder.Replace(0, 0, value, propsBuilder.GetTextProps());
+ return stringBuilder.GetString();
+ }
+
+ #endregion
+ }
+
+ internal sealed class RootSiteScenarioExecutionResult
+ {
+ public RenderScenario Scenario { get; set; }
+ public RenderTimingResult ColdTiming { get; set; }
+ public RenderTimingResult WarmTiming { get; set; }
+ internal global::SIL.FieldWorks.Common.RenderVerification.RenderBaselineVerificationResult Verification { get; set; }
+ public int CaptureWidth { get; set; }
+ public int CaptureHeight { get; set; }
+ public System.Collections.Generic.List TraceEvents { get; set; }
+ }
+}
diff --git a/Src/Common/RootSite/RootSiteTests/RenderTestAssemblySetup.cs b/Src/Common/RootSite/RootSiteTests/RenderTestAssemblySetup.cs
new file mode 100644
index 0000000000..575ec8ed09
--- /dev/null
+++ b/Src/Common/RootSite/RootSiteTests/RenderTestAssemblySetup.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Drawing.Text;
+using System.Linq;
+using System.Runtime.InteropServices;
+using NUnit.Framework;
+
+namespace SIL.FieldWorks.Common.RootSites
+{
+ [SetUpFixture]
+ public sealed class RenderTestAssemblySetup
+ {
+ private const int DpiAwarenessContextUnaware = -1;
+ private const string DeterministicRenderFontFamily = "Segoe UI";
+
+ [DllImport("User32.dll")]
+ private static extern bool SetProcessDpiAwarenessContext(int dpiFlag);
+
+ [OneTimeSetUp]
+ public void OneTimeSetup()
+ {
+ // Force grayscale antialiasing (ANTIALIASED_QUALITY=4) for deterministic
+ // rendering across dev machines and CI (Windows Server 2025).
+ // The native VwGraphics reads this env var when creating GDI fonts.
+ Environment.SetEnvironmentVariable("FW_FONT_QUALITY", "4");
+
+ try
+ {
+ SetProcessDpiAwarenessContext(DpiAwarenessContextUnaware);
+ }
+ catch (DllNotFoundException)
+ {
+ }
+ catch (EntryPointNotFoundException)
+ {
+ }
+
+ using (var installedFonts = new InstalledFontCollection())
+ {
+ bool hasDeterministicFont = installedFonts.Families.Any(
+ family => string.Equals(family.Name, DeterministicRenderFontFamily, StringComparison.OrdinalIgnoreCase));
+ TestContext.Progress.WriteLine(
+ $"[RENDER-SETUP] DPI unaware requested. Font '{DeterministicRenderFontFamily}' installed={hasDeterministicFont}");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Src/Common/RootSite/RootSiteTests/RenderTimingSuiteTests.cs b/Src/Common/RootSite/RootSiteTests/RenderTimingSuiteTests.cs
new file mode 100644
index 0000000000..ef07c20da5
--- /dev/null
+++ b/Src/Common/RootSite/RootSiteTests/RenderTimingSuiteTests.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using NUnit.Framework;
+using SIL.FieldWorks.Common.RootSites.RenderBenchmark;
+using SIL.FieldWorks.Common.RenderVerification;
+
+namespace SIL.FieldWorks.Common.RootSites
+{
+ ///
+ /// Main benchmark suite that executes all scenarios and generates a timing report.
+ /// Each scenario is timed and also checked against the committed pixel baselines
+ /// so performance and correctness are validated together.
+ ///
+ [TestFixture]
+ [Category("RenderBenchmark")]
+ [Category("Performance")]
+ public class RenderTimingSuiteTests : RenderBenchmarkTestsBase
+ {
+ private static List m_results;
+ private RenderBenchmarkReportWriter m_reportWriter;
+ private RenderEnvironmentValidator m_environmentValidator;
+
+ private static readonly string OutputDir = Path.Combine(
+ TestContext.CurrentContext.TestDirectory,
+ "..", "..", "Output", "RenderBenchmarks");
+
+ [OneTimeSetUp]
+ public void SuiteSetup()
+ {
+ m_results = new List();
+ m_reportWriter = new RenderBenchmarkReportWriter(OutputDir);
+ m_environmentValidator = new RenderEnvironmentValidator();
+
+ if (!Directory.Exists(OutputDir))
+ Directory.CreateDirectory(OutputDir);
+ }
+
+ [OneTimeTearDown]
+ public void SuiteTeardown()
+ {
+ var run = new BenchmarkRun
+ {
+ Results = m_results,
+ EnvironmentHash = m_environmentValidator?.GetEnvironmentHash() ?? "Unknown",
+ Configuration = "Debug",
+ MachineName = Environment.MachineName,
+ FeatureFlags = GetActivePerfFlags()
+ };
+
+ m_reportWriter?.WriteReport(run);
+ TestContext.WriteLine($"Benchmark report written to: {OutputDir}");
+ TestContext.WriteLine($"Summary: {Path.Combine(OutputDir, "summary.md")}");
+ }
+
+ [Test, TestCaseSource(nameof(GetScenarios))]
+ public void RunBenchmark(string scenarioId)
+ {
+ var execution = ExecuteScenarioAndCapture(
+ scenarioId,
+ includeWarmRender: true,
+ environmentValidator: m_environmentValidator);
+
+ TestContext.WriteLine($"Running Scenario: {scenarioId} - {execution.Scenario.Description}");
+
+ if (!execution.Verification.Passed)
+ TestContext.WriteLine($"[VERIFY] {execution.Verification.FailureMessage}");
+
+ m_results.Add(new BenchmarkResult
+ {
+ ScenarioId = execution.Scenario.Id,
+ ScenarioDescription = execution.Scenario.Description,
+ ColdRenderMs = execution.ColdTiming.DurationMs,
+ WarmRenderMs = execution.WarmTiming.DurationMs,
+ ColdPerformOffscreenLayoutMs = SumStageDuration(execution.TraceEvents, "PerformOffscreenLayout", "cold"),
+ WarmPerformOffscreenLayoutMs = SumStageDuration(execution.TraceEvents, "PerformOffscreenLayout", "warm"),
+ PixelPerfectPass = execution.Verification.Passed,
+ MismatchDetails = execution.Verification.FailureMessage,
+ SnapshotPath = execution.Verification.VerifiedPath,
+ TraceEvents = execution.TraceEvents
+ });
+
+ if (!execution.Verification.Passed)
+ Assert.Fail(execution.Verification.FailureMessage);
+ }
+
+ public static IEnumerable GetScenarios()
+ {
+ return GetConfiguredScenarioIds("simple", "medium", "complex", "deep-nested", "custom-heavy");
+ }
+
+ private static Dictionary GetActivePerfFlags()
+ {
+ return new Dictionary
+ {
+ { "FW_PERF_P125_PATH1", GetPerfFlagState("FW_PERF_P125_PATH1") },
+ { "FW_PERF_P125_PATH2", GetPerfFlagState("FW_PERF_P125_PATH2") },
+ { "FW_PERF_P125_PATH5", GetPerfFlagState("FW_PERF_P125_PATH5") }
+ };
+ }
+
+ private static string GetPerfFlagState(string variableName)
+ {
+ var value = Environment.GetEnvironmentVariable(variableName);
+ if (!string.IsNullOrEmpty(value))
+ return value;
+
+ if (string.Equals(variableName, "FW_PERF_P125_PATH5", StringComparison.OrdinalIgnoreCase))
+ return "not-implemented";
+
+ return "default-on";
+ }
+
+ private static double SumStageDuration(IEnumerable traceEvents, string stage, string phasePrefix)
+ {
+ if (traceEvents == null)
+ return 0;
+
+ double total = 0;
+ foreach (var traceEvent in traceEvents)
+ {
+ if (!string.Equals(traceEvent.Stage, stage, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ if (traceEvent.Context == null || !traceEvent.Context.TryGetValue("phase", out var phase))
+ continue;
+
+ if (phase.StartsWith(phasePrefix, StringComparison.OrdinalIgnoreCase))
+ total += traceEvent.DurationMs;
+ }
+
+ return total;
+ }
+ }
+}
diff --git a/Src/Common/RootSite/RootSiteTests/RenderTraceParserTests.cs b/Src/Common/RootSite/RootSiteTests/RenderTraceParserTests.cs
new file mode 100644
index 0000000000..f9fcbc7b05
--- /dev/null
+++ b/Src/Common/RootSite/RootSiteTests/RenderTraceParserTests.cs
@@ -0,0 +1,45 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using NUnit.Framework;
+using SIL.FieldWorks.Common.RenderVerification;
+
+namespace SIL.FieldWorks.Common.RootSites
+{
+ [TestFixture]
+ public class RenderTraceParserTests
+ {
+ [TestCase("Perform-Offscreen-Layout")]
+ [TestCase("Layout.v2")]
+ [TestCase("cold/Layout")]
+ public void ParseLine_StageNameContainsSeparators_ParsesStageName(string stageName)
+ {
+ var parser = new RenderTraceParser();
+ double cumulativeTime = 0;
+
+ var traceEvent = parser.ParseLine(
+ $"[RENDER] Stage={stageName} Duration=12.34ms Context=phase=cold",
+ ref cumulativeTime);
+
+ Assert.That(traceEvent, Is.Not.Null);
+ Assert.That(traceEvent.Stage, Is.EqualTo(stageName));
+ Assert.That(traceEvent.DurationMs, Is.EqualTo(12.34).Within(0.001));
+ }
+
+ [Test]
+ public void ParseLine_TimestampedStageNameContainsSeparators_ParsesStageName()
+ {
+ var parser = new RenderTraceParser();
+ double cumulativeTime = 0;
+
+ var traceEvent = parser.ParseLine(
+ "[2026-01-22T12:34:56.789] [RENDER] Stage=warm/Layout.v2 Duration=7.5ms Context=phase=warm",
+ ref cumulativeTime);
+
+ Assert.That(traceEvent, Is.Not.Null);
+ Assert.That(traceEvent.Stage, Is.EqualTo("warm/Layout.v2"));
+ Assert.That(traceEvent.DurationMs, Is.EqualTo(7.5).Within(0.001));
+ }
+ }
+}
\ No newline at end of file
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_complex.verified.png b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_complex.verified.png
new file mode 100644
index 0000000000..2f2314e311
Binary files /dev/null and b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_complex.verified.png differ
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_custom-heavy.verified.png b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_custom-heavy.verified.png
new file mode 100644
index 0000000000..bb10da5808
Binary files /dev/null and b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_custom-heavy.verified.png differ
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_deep-nested.verified.png b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_deep-nested.verified.png
new file mode 100644
index 0000000000..fdb8500c76
Binary files /dev/null and b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_deep-nested.verified.png differ
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_footnote-heavy.verified.png b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_footnote-heavy.verified.png
new file mode 100644
index 0000000000..7431a3e4f4
Binary files /dev/null and b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_footnote-heavy.verified.png differ
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_lex-deep.verified.png b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_lex-deep.verified.png
new file mode 100644
index 0000000000..f2c72a7bea
Binary files /dev/null and b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_lex-deep.verified.png differ
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_lex-extreme.verified.png b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_lex-extreme.verified.png
new file mode 100644
index 0000000000..6f4887f1fc
Binary files /dev/null and b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_lex-extreme.verified.png differ
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_lex-shallow.verified.png b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_lex-shallow.verified.png
new file mode 100644
index 0000000000..ebe0ae0724
Binary files /dev/null and b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_lex-shallow.verified.png differ
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_long-prose.verified.png b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_long-prose.verified.png
new file mode 100644
index 0000000000..608565ccac
Binary files /dev/null and b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_long-prose.verified.png differ
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_many-paragraphs.verified.png b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_many-paragraphs.verified.png
new file mode 100644
index 0000000000..08b500fe6b
Binary files /dev/null and b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_many-paragraphs.verified.png differ
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_medium.verified.png b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_medium.verified.png
new file mode 100644
index 0000000000..9d468523f5
Binary files /dev/null and b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_medium.verified.png differ
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_mixed-styles.verified.png b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_mixed-styles.verified.png
new file mode 100644
index 0000000000..7d5493acac
Binary files /dev/null and b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_mixed-styles.verified.png differ
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_multi-book.verified.png b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_multi-book.verified.png
new file mode 100644
index 0000000000..11beb770b5
Binary files /dev/null and b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_multi-book.verified.png differ
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_multi-ws.verified.png b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_multi-ws.verified.png
new file mode 100644
index 0000000000..32ce6badf6
Binary files /dev/null and b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_multi-ws.verified.png differ
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_rtl-script.verified.png b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_rtl-script.verified.png
new file mode 100644
index 0000000000..6946fa5462
Binary files /dev/null and b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_rtl-script.verified.png differ
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_simple.verified.png b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_simple.verified.png
new file mode 100644
index 0000000000..a81d9157a7
Binary files /dev/null and b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.VerifyScenario_simple.verified.png differ
diff --git a/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.cs b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.cs
new file mode 100644
index 0000000000..82b7c97080
--- /dev/null
+++ b/Src/Common/RootSite/RootSiteTests/RenderVerifyTests.cs
@@ -0,0 +1,68 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using SIL.FieldWorks.Common.RootSites.RenderBenchmark;
+
+namespace SIL.FieldWorks.Common.RootSites
+{
+ ///
+ /// Snapshot tests for render baseline validation.
+ ///
+ /// Each run saves a .received.png and compares it against the committed
+ /// .verified.png baseline by decoded pixel values, not by the PNG file bytes.
+ /// Small encoder-level differences are therefore ignored as long as the rendered
+ /// image differs by fewer than five pixels.
+ ///
+ /// Each scenario is set up inside its own UndoableUnitOfWork, matching the pattern
+ /// used by RenderTimingSuiteTests.
+ ///
+ [TestFixture]
+ [Category("RenderBenchmark")]
+ public class RenderVerifyTests : RenderBenchmarkTestsBase
+ {
+ ///
+ /// CreateTestData is a no-op; individual tests call SetupScenarioData within a UoW.
+ ///
+ protected override void CreateTestData()
+ {
+ // Scenario data is created per-test inside a UoW (see VerifyScenario).
+ }
+
+ ///
+ /// Verifies that a scenario renders consistently against its .verified.png baseline.
+ /// On first run, creates the .received.png for acceptance. On subsequent runs,
+ /// compares decoded pixels against the committed .verified.png baseline.
+ ///
+ [Test, TestCaseSource(nameof(GetVerifyScenarios))]
+ public async Task VerifyScenario(string scenarioId)
+ {
+ var execution = ExecuteScenarioAndCapture(scenarioId, includeWarmRender: false);
+ if (!execution.Verification.Passed)
+ Assert.Fail(execution.Verification.FailureMessage);
+
+ await Task.CompletedTask;
+ }
+
+ ///
+ /// Provides all scenario IDs from the JSON config for parameterized render baseline tests.
+ ///
+ public static IEnumerable GetVerifyScenarios()
+ {
+ return GetConfiguredScenarioIds(
+ "simple",
+ "medium",
+ "complex",
+ "deep-nested",
+ "custom-heavy",
+ "many-paragraphs",
+ "footnote-heavy",
+ "mixed-styles",
+ "long-prose",
+ "multi-book");
+ }
+ }
+}
diff --git a/Src/Common/RootSite/RootSiteTests/RootSiteTests.csproj b/Src/Common/RootSite/RootSiteTests/RootSiteTests.csproj
index 19a7e1654a..88e626aea8 100644
--- a/Src/Common/RootSite/RootSiteTests/RootSiteTests.csproj
+++ b/Src/Common/RootSite/RootSiteTests/RootSiteTests.csproj
@@ -1,11 +1,12 @@
-
+
RootSiteTests
SIL.FieldWorks.Common.RootSites
net48
Library
- true 168,169,219,414,649,1635,1702,1701
+ true
+ 168,169,219,414,649,1635,1702,1701
false
true
false
@@ -45,9 +46,11 @@
+
+
diff --git a/Src/Common/RootSite/RootSiteTests/TestData/RenderBenchmarkFlags.json b/Src/Common/RootSite/RootSiteTests/TestData/RenderBenchmarkFlags.json
new file mode 100644
index 0000000000..b4c72be426
--- /dev/null
+++ b/Src/Common/RootSite/RootSiteTests/TestData/RenderBenchmarkFlags.json
@@ -0,0 +1,5 @@
+{
+ "diagnosticsEnabled": false,
+ "traceEnabled": false,
+ "captureMode": "DrawToBitmap"
+}
diff --git a/Src/Common/RootSite/RootSiteTests/TestData/RenderBenchmarkScenarios.json b/Src/Common/RootSite/RootSiteTests/TestData/RenderBenchmarkScenarios.json
new file mode 100644
index 0000000000..782b92e682
--- /dev/null
+++ b/Src/Common/RootSite/RootSiteTests/TestData/RenderBenchmarkScenarios.json
@@ -0,0 +1,94 @@
+{
+ "scenarios": [
+ {
+ "id": "simple",
+ "description": "Minimal scripture book with one section and one footnote",
+ "tags": ["baseline", "minimal"],
+ "expectedSnapshotPath": "TestData/RenderSnapshots/simple.png"
+ },
+ {
+ "id": "medium",
+ "description": "Scripture book with 5 sections and multiple footnotes",
+ "tags": ["typical", "multi-section"],
+ "expectedSnapshotPath": "TestData/RenderSnapshots/medium.png"
+ },
+ {
+ "id": "complex",
+ "description": "Large scripture book with 20 sections, heavy footnotes",
+ "tags": ["stress", "multi-section", "heavy-footnotes"],
+ "expectedSnapshotPath": "TestData/RenderSnapshots/complex.png"
+ },
+ {
+ "id": "deep-nested",
+ "description": "Scripture book with nested levels (if supported) or many small paras",
+ "tags": ["nested", "layout-stress"],
+ "expectedSnapshotPath": "TestData/RenderSnapshots/deep-nested.png"
+ },
+ {
+ "id": "custom-heavy",
+ "description": "Scripture book with varied styles and character properties",
+ "tags": ["styles", "formatting"],
+ "expectedSnapshotPath": "TestData/RenderSnapshots/custom-heavy.png"
+ },
+ {
+ "id": "many-paragraphs",
+ "description": "50 sections with 1 verse each — massive paragraph layout overhead",
+ "tags": ["stress", "layout-stress", "paragraph-count"],
+ "expectedSnapshotPath": "TestData/RenderSnapshots/many-paragraphs.png"
+ },
+ {
+ "id": "footnote-heavy",
+ "description": "8 sections with 20 verses each, footnotes on every other verse",
+ "tags": ["stress", "footnotes", "heavy-footnotes"],
+ "expectedSnapshotPath": "TestData/RenderSnapshots/footnote-heavy.png"
+ },
+ {
+ "id": "mixed-styles",
+ "description": "6 sections with 15 verses, each verse has unique font-size/color/bold/italic",
+ "tags": ["stress", "styles", "property-cache"],
+ "expectedSnapshotPath": "TestData/RenderSnapshots/mixed-styles.png"
+ },
+ {
+ "id": "long-prose",
+ "description": "4 sections with 80 verses each — very long paragraphs for line-break stress",
+ "tags": ["stress", "layout-stress", "line-breaking"],
+ "expectedSnapshotPath": "TestData/RenderSnapshots/long-prose.png"
+ },
+ {
+ "id": "multi-book",
+ "description": "3 separate Scripture books with 5 sections each — large cache stress",
+ "tags": ["stress", "multi-book", "cache-stress"],
+ "expectedSnapshotPath": "TestData/RenderSnapshots/multi-book.png"
+ },
+ {
+ "id": "rtl-script",
+ "description": "4 sections with Arabic RTL text — bidirectional layout stress",
+ "tags": ["stress", "bidi", "rtl", "arabic"],
+ "expectedSnapshotPath": "TestData/RenderSnapshots/rtl-script.png"
+ },
+ {
+ "id": "multi-ws",
+ "description": "5 sections with English/Arabic/French runs per verse — WS switching stress",
+ "tags": ["stress", "multi-ws", "bidi", "font-fallback"],
+ "expectedSnapshotPath": "TestData/RenderSnapshots/multi-ws.png"
+ },
+ {
+ "id": "lex-shallow",
+ "description": "Lex entry with 3 senses, each having 3 subsenses (depth 2, breadth 3 = 12 senses)",
+ "tags": ["lex-entry", "nested-senses", "baseline"],
+ "viewType": "LexEntry"
+ },
+ {
+ "id": "lex-deep",
+ "description": "Lex entry with senses nested 4 levels deep, 2-wide (depth 4, breadth 2 = 30 senses)",
+ "tags": ["lex-entry", "nested-senses", "exponential-cost"],
+ "viewType": "LexEntry"
+ },
+ {
+ "id": "lex-extreme",
+ "description": "Lex entry with senses nested 6 levels deep, 2-wide (depth 6, breadth 2 = 126 senses)",
+ "tags": ["lex-entry", "nested-senses", "exponential-cost", "stress"],
+ "viewType": "LexEntry"
+ }
+ ]
+}
diff --git a/Src/Common/SimpleRootSite/RenderEngineFactory.cs b/Src/Common/SimpleRootSite/RenderEngineFactory.cs
index e473f0e09d..bf8a1c3c3a 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>>();
}
///
@@ -46,20 +46,26 @@ public IRenderEngine get_Renderer(ILgWritingSystem ws, IVwGraphics vg)
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(fontName, chrp, ws);
+ 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 +78,34 @@ 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)
+ private static string GetFontFeatures(string fontName, LgCharRenderProps chrp, ILgWritingSystem ws)
+ {
+ string charRenderFeatures = chrp.szFontVar == null
+ ? string.Empty
+ : FontFeatureSettings.Normalize(MarshalEx.UShortToString(chrp.szFontVar));
+ if (!string.IsNullOrEmpty(charRenderFeatures))
+ return charRenderFeatures;
+
+ if (fontName == ws.DefaultFontName)
+ return FontFeatureSettings.Normalize(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 +116,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 +135,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 +145,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/SimpleRootSite.cs b/Src/Common/SimpleRootSite/SimpleRootSite.cs
index e4a2599848..651be51567 100644
--- a/Src/Common/SimpleRootSite/SimpleRootSite.cs
+++ b/Src/Common/SimpleRootSite/SimpleRootSite.cs
@@ -39,6 +39,13 @@ namespace SIL.FieldWorks.Common.RootSites
public class SimpleRootSite : UserControl, IVwRootSite, IRootSite, IxCoreColleague,
IEditingCallbacks, IReceiveSequentialMessages, IMessageFilter
{
+ private enum RefreshPhase
+ {
+ Idle,
+ Refreshing,
+ QueuedForReplay
+ }
+
#region Events
///
/// This event notifies you that the right mouse button was clicked,
@@ -54,6 +61,47 @@ public class SimpleRootSite : UserControl, IVwRootSite, IRootSite, IxCoreColleag
///
public event EventHandler OnRefreshForScrollBarVisibility;
#endregion Events
+ private static readonly bool s_enableInteractionTrace = IsOptInPerfFlagEnabled("FW_PERF_INTERACTION_TRACE");
+ private static readonly int s_interactionTraceThresholdMs = GetPerfThresholdMs(
+ "FW_PERF_INTERACTION_TRACE_THRESHOLD_MS", 25);
+
+ private static bool IsOptInPerfFlagEnabled(string variableName)
+ {
+ var value = Environment.GetEnvironmentVariable(variableName);
+ if (string.IsNullOrEmpty(value))
+ return false;
+
+ return !string.Equals(value, "0", StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(value, "off", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static int GetPerfThresholdMs(string variableName, int defaultValue)
+ {
+ var value = Environment.GetEnvironmentVariable(variableName);
+ if (int.TryParse(value, out var thresholdMs) && thresholdMs >= 0)
+ return thresholdMs;
+
+ return defaultValue;
+ }
+
+ private static void TraceInteractionTiming(string stage, long elapsedMs, string details)
+ {
+ if (!s_enableInteractionTrace || elapsedMs < s_interactionTraceThresholdMs)
+ return;
+
+ Trace.WriteLine(
+ $"[FW_PERF_INTERACTION] [SimpleRootSite] Stage={stage} DurationMs={elapsedMs} {details}");
+ }
+
+ private static void TraceInteractionEvent(string stage, string details)
+ {
+ if (!s_enableInteractionTrace)
+ return;
+
+ Trace.WriteLine(
+ $"[FW_PERF_INTERACTION] [SimpleRootSite] Stage={stage} {details}");
+ }
#region WindowsLanguageProfileSink class
@@ -168,6 +216,15 @@ public void OnInputLanguageChanged(IKeyboardDefinition previousKeyboard, IKeyboa
/// as on Mono Setting AutoScrollPosition causes a redraw even when AllowPainting == false
///
private Point? cachedAutoScrollPosition = null;
+ private bool m_fWarmingScrollViewport;
+ private bool m_fDeferredScrollWarmupPending = true;
+ private bool m_fDeferredScrollWarmupQueued;
+ private int m_lastScrollWarmDirection;
+ private int m_scrollWarmRangeTop = int.MinValue;
+ private int m_scrollWarmRangeBottom = int.MinValue;
+ private int m_deferredScrollWarmupTargetTop = int.MinValue;
+ private int m_deferredScrollWarmupTargetBottom = int.MinValue;
+ private int m_deferredScrollWarmupGeneration;
/// Used to draw the rootbox
private IVwDrawRootBuffered m_vdrb;
@@ -195,6 +252,9 @@ public void OnInputLanguageChanged(IKeyboardDefinition previousKeyboard, IKeyboa
/// True if we are waiting to do a refresh on the view (will be done when the view
/// becomes visible); false otherwise
protected bool m_fRefreshPending = false;
+ private bool m_fForceNextRefreshDisplay;
+ private RefreshPhase m_refreshPhase;
+ private bool m_fRefreshReplayRequested;
/// True to show range selections when focus is lost; false otherwise
protected bool m_fShowRangeSelAfterLostFocus = false;
@@ -473,6 +533,8 @@ protected override void Dispose(bool disposing)
if (m_vdrb != null && Marshal.IsComObject(m_vdrb))
Marshal.ReleaseComObject(m_vdrb);
+ else if (m_vdrb is IDisposable disposableVdrb)
+ disposableVdrb.Dispose();
m_vdrb = null;
if (m_styleSheet != null && Marshal.IsComObject(m_styleSheet))
Marshal.ReleaseComObject(m_styleSheet);
@@ -952,6 +1014,56 @@ public virtual ISilDataAccess DataAccess
get { return (m_rootb == null) ? null : m_rootb.DataAccess; }
}
+ ///
+ /// Updates the root box data source without implying that the current box tree is stale.
+ /// Use this for initial wiring and other source swaps that should remain cheap.
+ ///
+ /// The data access to install on the current root box.
+ protected void SetRootBoxDataAccess(ISilDataAccess dataAccess)
+ {
+ CheckDisposed();
+ if (m_rootb == null)
+ return;
+
+ m_rootb.DataAccess = dataAccess;
+ }
+
+ ///
+ /// Updates the root box data source and marks the current display for an explicit refresh
+ /// when the new data source changes the meaning or visible set of the current view.
+ ///
+ /// The data access to install on the current root box.
+ protected void SetRootBoxDataAccessAndRefresh(ISilDataAccess dataAccess)
+ {
+ CheckDisposed();
+ if (m_rootb == null)
+ return;
+
+ m_rootb.DataAccess = dataAccess;
+ NotifyDataAccessSemanticsChanged();
+ }
+
+ ///
+ /// Signals that the current root box tree is semantically stale after an explicit
+ /// post-construction DataAccess swap. This keeps DataAccess assignment cheap while
+ /// routing the rebuild through the normal managed refresh pipeline.
+ ///
+ protected void NotifyDataAccessSemanticsChanged()
+ {
+ CheckDisposed();
+ if (m_rootb?.Site == null)
+ return;
+
+ m_fForceNextRefreshDisplay = true;
+ if (!Visible || FindForm() == null)
+ {
+ m_fRefreshPending = true;
+ return;
+ }
+
+ RefreshDisplay();
+ }
+
/// ------------------------------------------------------------------------------------
///
/// Helper used for processing editing requests.
@@ -998,7 +1110,15 @@ protected virtual EditingHelper CreateEditingHelper()
protected internal Point Dpi
{
get { return m_Dpi; }
- set { m_Dpi = value; }
+ set
+ {
+ if (m_Dpi == value)
+ return;
+
+ m_Dpi = value;
+ if (m_dxdLayoutWidth > 0)
+ m_dxdLayoutWidth = kForceLayout;
+ }
}
/// ------------------------------------------------------------------------------------
@@ -1264,11 +1384,28 @@ public virtual void ReleaseGraphics(IVwRootBox prootb, IVwGraphics pvg)
public void SelectionChanged(IVwRootBox rootb, IVwSelection vwselNew)
{
CheckDisposed();
+ Stopwatch stopwatch = null;
+ if (s_enableInteractionTrace)
+ stopwatch = Stopwatch.StartNew();
- Debug.Assert(rootb == EditingHelper.EditedRootBox);
- Debug.Assert(vwselNew == rootb.Selection);
+ try
+ {
+ Debug.Assert(rootb == EditingHelper.EditedRootBox);
+ Debug.Assert(vwselNew == rootb.Selection);
- EditingHelper.SelectionChanged();
+ EditingHelper.SelectionChanged();
+ }
+ finally
+ {
+ if (stopwatch != null)
+ {
+ stopwatch.Stop();
+ TraceInteractionTiming(
+ "SelectionChanged",
+ stopwatch.ElapsedMilliseconds,
+ $"Control={Name} RootBoxNull={rootb == null} SelectionValid={vwselNew != null && vwselNew.IsValid}");
+ }
+ }
}
/// -----------------------------------------------------------------------------------
@@ -1489,19 +1626,39 @@ public virtual bool ScrollSelectionIntoView(IVwSelection sel,
VwScrollSelOpts scrollOption)
{
CheckDisposed();
+ Stopwatch stopwatch = null;
+ if (s_enableInteractionTrace)
+ stopwatch = Stopwatch.StartNew();
+
+ bool result;
switch (scrollOption)
{
case VwScrollSelOpts.kssoDefault:
- return MakeSelectionVisible(sel, true);
+ result = MakeSelectionVisible(sel, true);
+ break;
case VwScrollSelOpts.kssoNearTop:
- return ScrollSelectionToLocation(sel, LineHeight);
+ result = ScrollSelectionToLocation(sel, LineHeight);
+ break;
case VwScrollSelOpts.kssoTop:
- return ScrollSelectionToLocation(sel, 1);
+ result = ScrollSelectionToLocation(sel, 1);
+ break;
case VwScrollSelOpts.kssoBoth:
- return MakeSelectionVisible(sel, true, true, true);
+ result = MakeSelectionVisible(sel, true, true, true);
+ break;
default:
throw new ArgumentException("Unsupported VwScrollSelOpts");
}
+
+ if (stopwatch != null)
+ {
+ stopwatch.Stop();
+ TraceInteractionTiming(
+ "ScrollSelectionIntoView",
+ stopwatch.ElapsedMilliseconds,
+ $"Control={Name} Option={scrollOption} DidScroll={result}");
+ }
+
+ return result;
}
/// -----------------------------------------------------------------------------------
@@ -1552,6 +1709,7 @@ public virtual Point ScrollPosition
set
{
CheckDisposed();
+ int previousViewportTop = -AutoScrollPosition.Y;
Point newPos = value;
if (this.AutoScroll)
{
@@ -1569,7 +1727,451 @@ public virtual Point ScrollPosition
AutoScrollPosition = newPos;
else
cachedAutoScrollPosition = newPos;
+
+ int scrollDelta = newPos.Y - previousViewportTop;
+ if (scrollDelta != 0)
+ m_lastScrollWarmDirection = Math.Sign(scrollDelta);
+
+ WarmScrollViewportIfNeeded();
+ }
+ }
+
+ // Tunable scroll warmup settings. Percent values are relative to the current viewport height.
+ protected virtual int ScrollWarmupImmediatePercentInScrollDirection => 100;
+ protected virtual int ScrollWarmupImmediatePercentAgainstScrollDirection => 25;
+ protected virtual int ScrollWarmupDeferredPercentInScrollDirection => 100;
+ protected virtual int ScrollWarmupDeferredPercentAgainstScrollDirection => 0;
+ protected virtual int ScrollWarmupSymmetricImmediatePercent => 50;
+ protected virtual int ScrollWarmupSymmetricDeferredPercent => 50;
+ protected virtual int ScrollWarmupDeferredChunkPercent => 50;
+ protected virtual int ScrollWarmupDeferredTimeBudgetMs => 50;
+ protected virtual int ScrollWarmupDeferredMaxChunksPerIdle => 2;
+ protected virtual IdleQueuePriority ScrollWarmupDeferredIdlePriority => IdleQueuePriority.Low;
+
+ protected virtual void WarmScrollViewportIfNeeded()
+ {
+ WarmScrollViewportIfNeeded(false, true);
+ }
+
+ private void WarmScrollViewportIfNeeded(bool preferSymmetric, bool restartDeferredWarmup)
+ {
+ if (m_fWarmingScrollViewport || m_fInPaint || m_fInLayout || m_rootb == null ||
+ m_dxdLayoutWidth <= 0 || !AllowPainting || ClientRectangle.Height <= 0)
+ {
+ TraceInteractionEvent(
+ "WarmScrollViewport.Skip",
+ $"Control={Name} InPaint={m_fInPaint} InLayout={m_fInLayout} RootBoxNull={m_rootb == null} AllowPainting={AllowPainting} ClientHeight={ClientRectangle.Height}");
+ return;
+ }
+
+ if (restartDeferredWarmup)
+ m_deferredScrollWarmupGeneration++;
+
+ int viewportTop = -ScrollPosition.Y;
+ int viewportBottom = viewportTop + ClientRectangle.Height;
+ GetScrollWarmupMargins(preferSymmetric,
+ out int immediateWarmMarginTop,
+ out int immediateWarmMarginBottom,
+ out int deferredWarmMarginTop,
+ out int deferredWarmMarginBottom);
+
+ int desiredImmediateTop = viewportTop - immediateWarmMarginTop;
+ int desiredImmediateBottom = viewportBottom + immediateWarmMarginBottom;
+ bool warmImmediateRange = !IsScrollWarmRangeCovered(desiredImmediateTop, desiredImmediateBottom);
+ TraceInteractionEvent(
+ "WarmScrollViewport.Plan",
+ $"Control={Name} PreferSymmetric={preferSymmetric} RestartDeferred={restartDeferredWarmup} Direction={m_lastScrollWarmDirection} ViewTop={viewportTop} ViewBottom={viewportBottom} ImmediateTop={desiredImmediateTop} ImmediateBottom={desiredImmediateBottom} DeferredTop={desiredImmediateTop - deferredWarmMarginTop} DeferredBottom={desiredImmediateBottom + deferredWarmMarginBottom} WarmImmediate={warmImmediateRange} ChunkPercent={ScrollWarmupDeferredChunkPercent} ChunkBudgetMs={ScrollWarmupDeferredTimeBudgetMs} MaxChunksPerIdle={ScrollWarmupDeferredMaxChunksPerIdle}");
+
+ if (warmImmediateRange)
+ {
+ Stopwatch immediateStopwatch = Stopwatch.StartNew();
+ using (new HoldGraphics(this))
+ {
+ m_fWarmingScrollViewport = true;
+ try
+ {
+ Rectangle rcSrcRoot;
+ Rectangle rcDstRoot;
+ GetCoordRects(out rcSrcRoot, out rcDstRoot);
+
+ if (!WarmScrollViewportRange(rcSrcRoot, rcDstRoot, immediateWarmMarginTop, immediateWarmMarginBottom,
+ Math.Max(1, ClientRectangle.Height)))
+ return;
+ }
+ finally
+ {
+ m_fWarmingScrollViewport = false;
+ }
+ }
+
+ viewportTop = -ScrollPosition.Y;
+ viewportBottom = viewportTop + ClientRectangle.Height;
+ desiredImmediateTop = viewportTop - immediateWarmMarginTop;
+ desiredImmediateBottom = viewportBottom + immediateWarmMarginBottom;
+ ExpandScrollWarmRange(desiredImmediateTop, desiredImmediateBottom);
+ immediateStopwatch.Stop();
+ TraceInteractionTiming(
+ "WarmScrollViewport.Immediate",
+ immediateStopwatch.ElapsedMilliseconds,
+ $"Control={Name} WarmTop={desiredImmediateTop} WarmBottom={desiredImmediateBottom}");
+ }
+
+ m_deferredScrollWarmupTargetTop = desiredImmediateTop - deferredWarmMarginTop;
+ m_deferredScrollWarmupTargetBottom = desiredImmediateBottom + deferredWarmMarginBottom;
+ m_fDeferredScrollWarmupPending = !IsScrollWarmRangeCovered(
+ m_deferredScrollWarmupTargetTop,
+ m_deferredScrollWarmupTargetBottom);
+
+ if (m_fDeferredScrollWarmupPending)
+ {
+ TraceInteractionEvent(
+ "WarmScrollViewport.DeferredQueued",
+ $"Control={Name} Generation={m_deferredScrollWarmupGeneration} TargetTop={m_deferredScrollWarmupTargetTop} TargetBottom={m_deferredScrollWarmupTargetBottom} CoveredTop={m_scrollWarmRangeTop} CoveredBottom={m_scrollWarmRangeBottom}");
+ QueueDeferredScrollWarmup();
+ }
+ else
+ {
+ TraceInteractionEvent(
+ "WarmScrollViewport.Complete",
+ $"Control={Name} Generation={m_deferredScrollWarmupGeneration} CoveredTop={m_scrollWarmRangeTop} CoveredBottom={m_scrollWarmRangeBottom}");
+ }
+ }
+
+ private void GetScrollWarmupMargins(bool preferSymmetric,
+ out int immediateWarmMarginTop,
+ out int immediateWarmMarginBottom,
+ out int deferredWarmMarginTop,
+ out int deferredWarmMarginBottom)
+ {
+ int symmetricImmediateMargin = GetScrollWarmupPixels(ScrollWarmupSymmetricImmediatePercent);
+ int symmetricDeferredMargin = GetScrollWarmupPixels(ScrollWarmupSymmetricDeferredPercent);
+ if (preferSymmetric || m_lastScrollWarmDirection == 0)
+ {
+ immediateWarmMarginTop = symmetricImmediateMargin;
+ immediateWarmMarginBottom = symmetricImmediateMargin;
+ deferredWarmMarginTop = symmetricDeferredMargin;
+ deferredWarmMarginBottom = symmetricDeferredMargin;
+ return;
+ }
+
+ int leadingImmediateMargin = GetScrollWarmupPixels(ScrollWarmupImmediatePercentInScrollDirection);
+ int trailingImmediateMargin = GetScrollWarmupPixels(ScrollWarmupImmediatePercentAgainstScrollDirection);
+ int leadingDeferredMargin = GetScrollWarmupPixels(ScrollWarmupDeferredPercentInScrollDirection);
+ int trailingDeferredMargin = GetScrollWarmupPixels(ScrollWarmupDeferredPercentAgainstScrollDirection);
+ if (m_lastScrollWarmDirection > 0)
+ {
+ immediateWarmMarginTop = trailingImmediateMargin;
+ immediateWarmMarginBottom = leadingImmediateMargin;
+ deferredWarmMarginTop = trailingDeferredMargin;
+ deferredWarmMarginBottom = leadingDeferredMargin;
+ }
+ else
+ {
+ immediateWarmMarginTop = leadingImmediateMargin;
+ immediateWarmMarginBottom = trailingImmediateMargin;
+ deferredWarmMarginTop = leadingDeferredMargin;
+ deferredWarmMarginBottom = trailingDeferredMargin;
+ }
+ }
+
+ private int GetScrollWarmupPixels(int viewportPercent)
+ {
+ if (viewportPercent <= 0 || ClientRectangle.Height <= 0)
+ return 0;
+
+ long pixels = (long)ClientRectangle.Height * viewportPercent / 100;
+ return Math.Max(1, (int)pixels);
+ }
+
+ private bool IsScrollWarmRangeCovered(int targetTop, int targetBottom)
+ {
+ return targetTop >= m_scrollWarmRangeTop && targetBottom <= m_scrollWarmRangeBottom;
+ }
+
+ private void ExpandScrollWarmRange(int warmTop, int warmBottom)
+ {
+ m_scrollWarmRangeTop = (m_scrollWarmRangeTop == int.MinValue)
+ ? warmTop
+ : Math.Min(m_scrollWarmRangeTop, warmTop);
+ m_scrollWarmRangeBottom = (m_scrollWarmRangeBottom == int.MinValue)
+ ? warmBottom
+ : Math.Max(m_scrollWarmRangeBottom, warmBottom);
+ }
+
+ private bool WarmScrollViewportRange(Rectangle rcSrcRoot, Rectangle rcDstRoot,
+ int warmMarginTop, int warmMarginBottom, int chunkSize)
+ {
+ if (PrepareToDrawForScrollWarmup(rcSrcRoot, rcDstRoot) == VwPrepDrawResult.kxpdrInvalidate)
+ return false;
+
+ chunkSize = Math.Max(1, chunkSize);
+ if (!WarmScrollViewportOffsets(rcSrcRoot, rcDstRoot, warmMarginTop, -1, chunkSize))
+ return false;
+ if (!WarmScrollViewportOffsets(rcSrcRoot, rcDstRoot, warmMarginBottom, 1, chunkSize))
+ return false;
+
+ return true;
+ }
+
+ private bool WarmScrollViewportOffsets(Rectangle rcSrcRoot, Rectangle rcDstRoot,
+ int warmDistance, int direction, int chunkSize)
+ {
+ if (warmDistance <= 0)
+ return true;
+
+ int lastOffset = 0;
+ for (int offset = chunkSize; offset <= warmDistance; offset += chunkSize)
+ {
+ lastOffset = offset;
+ if (PrepareToDrawForScrollWarmup(rcSrcRoot, OffsetRootRect(rcDstRoot, direction * offset)) == VwPrepDrawResult.kxpdrInvalidate)
+ return false;
+ }
+
+ if (lastOffset != warmDistance)
+ {
+ if (PrepareToDrawForScrollWarmup(rcSrcRoot, OffsetRootRect(rcDstRoot, direction * warmDistance)) == VwPrepDrawResult.kxpdrInvalidate)
+ return false;
+ }
+
+ return true;
+ }
+
+ private static Rectangle OffsetRootRect(Rectangle rect, int dy)
+ {
+ rect.Offset(0, dy);
+ return rect;
+ }
+
+ protected virtual VwPrepDrawResult PrepareToDrawForScrollWarmup(Rectangle rcSrcRoot, Rectangle rcDstRoot)
+ {
+ VwPrepDrawResult xpdr = VwPrepDrawResult.kxpdrAdjust;
+ while (xpdr == VwPrepDrawResult.kxpdrAdjust)
+ xpdr = PrepareToDraw(rcSrcRoot, rcDstRoot);
+
+ return xpdr;
+ }
+
+ private void QueueDeferredScrollWarmup()
+ {
+ if (IsDisposed)
+ return;
+ if (!m_fDeferredScrollWarmupPending)
+ return;
+ if (!IsHandleCreated || !Visible || m_rootb == null || !AllowPainting)
+ return;
+
+ if (m_mediator != null)
+ {
+ TraceInteractionEvent(
+ "WarmScrollViewport.IdleQueueAdd",
+ $"Control={Name} Generation={m_deferredScrollWarmupGeneration} Priority={ScrollWarmupDeferredIdlePriority} TargetTop={m_deferredScrollWarmupTargetTop} TargetBottom={m_deferredScrollWarmupTargetBottom}");
+ m_mediator.IdleQueue.Add(
+ ScrollWarmupDeferredIdlePriority,
+ ContinueDeferredScrollWarmupOnIdle,
+ m_deferredScrollWarmupGeneration,
+ true);
+ return;
+ }
+
+ if (m_fDeferredScrollWarmupQueued)
+ return;
+
+ m_fDeferredScrollWarmupQueued = true;
+ TraceInteractionEvent(
+ "WarmScrollViewport.BeginInvokeAdd",
+ $"Control={Name} Generation={m_deferredScrollWarmupGeneration} TargetTop={m_deferredScrollWarmupTargetTop} TargetBottom={m_deferredScrollWarmupTargetBottom}");
+ BeginInvoke((MethodInvoker)delegate
+ {
+ m_fDeferredScrollWarmupQueued = false;
+ if (IsDisposed || !m_fDeferredScrollWarmupPending)
+ return;
+ if (!IsHandleCreated || !Visible || m_rootb == null || !AllowPainting || m_fInLayout)
+ return;
+
+ if (!ContinueDeferredScrollWarmupOnIdle(m_deferredScrollWarmupGeneration))
+ QueueDeferredScrollWarmup();
+ });
+ }
+
+ private bool ContinueDeferredScrollWarmupOnIdle(object generationState)
+ {
+ if (IsDisposed || !m_fDeferredScrollWarmupPending)
+ return true;
+ if (!IsHandleCreated || !Visible || m_rootb == null || !AllowPainting || m_fInLayout)
+ {
+ TraceInteractionEvent(
+ "WarmScrollViewport.IdleDeferred",
+ $"Control={Name} Action=defer Visible={Visible} InLayout={m_fInLayout} RootBoxNull={m_rootb == null} AllowPainting={AllowPainting}");
+ return false;
+ }
+ if (m_deferredScrollWarmupTargetTop == int.MinValue || m_deferredScrollWarmupTargetBottom == int.MinValue)
+ {
+ TraceInteractionEvent(
+ "WarmScrollViewport.Bootstrap",
+ $"Control={Name} Generation={m_deferredScrollWarmupGeneration}");
+ WarmScrollViewportIfNeeded(true, true);
+ return true;
+ }
+
+ int generation = generationState is int intGeneration ? intGeneration : m_deferredScrollWarmupGeneration;
+ if (generation != m_deferredScrollWarmupGeneration)
+ {
+ TraceInteractionEvent(
+ "WarmScrollViewport.Cancelled",
+ $"Control={Name} Reason=stale-generation ScheduledGeneration={generation} CurrentGeneration={m_deferredScrollWarmupGeneration}");
+ return true;
+ }
+
+ if (IsScrollWarmRangeCovered(m_deferredScrollWarmupTargetTop, m_deferredScrollWarmupTargetBottom))
+ {
+ m_fDeferredScrollWarmupPending = false;
+ TraceInteractionEvent(
+ "WarmScrollViewport.Complete",
+ $"Control={Name} Generation={generation} CoveredTop={m_scrollWarmRangeTop} CoveredBottom={m_scrollWarmRangeBottom}");
+ return true;
+ }
+
+ int viewportTop = -ScrollPosition.Y;
+ int viewportBottom = viewportTop + ClientRectangle.Height;
+ int chunkPixels = GetScrollWarmupPixels(ScrollWarmupDeferredChunkPercent);
+ if (chunkPixels <= 0)
+ {
+ m_fDeferredScrollWarmupPending = false;
+ return true;
+ }
+
+ Stopwatch stopwatch = Stopwatch.StartNew();
+ int chunksProcessed = 0;
+ string stopReason = "covered";
+ using (new HoldGraphics(this))
+ {
+ m_fWarmingScrollViewport = true;
+ try
+ {
+ Rectangle rcSrcRoot;
+ Rectangle rcDstRoot;
+ GetCoordRects(out rcSrcRoot, out rcDstRoot);
+
+ while (!IsScrollWarmRangeCovered(m_deferredScrollWarmupTargetTop, m_deferredScrollWarmupTargetBottom))
+ {
+ if (chunksProcessed >= ScrollWarmupDeferredMaxChunksPerIdle)
+ {
+ stopReason = "chunk-budget";
+ break;
+ }
+ if (stopwatch.ElapsedMilliseconds >= ScrollWarmupDeferredTimeBudgetMs)
+ {
+ stopReason = "time-budget";
+ break;
+ }
+
+ bool warmBottomNext = m_lastScrollWarmDirection >= 0;
+ if (!WarmDeferredScrollViewportChunk(
+ rcSrcRoot,
+ rcDstRoot,
+ viewportTop,
+ viewportBottom,
+ chunkPixels,
+ warmBottomNext))
+ {
+ m_fDeferredScrollWarmupPending = false;
+ TraceInteractionEvent(
+ "WarmScrollViewport.Cancelled",
+ $"Control={Name} Reason=invalidate Generation={generation} CoveredTop={m_scrollWarmRangeTop} CoveredBottom={m_scrollWarmRangeBottom}");
+ return true;
+ }
+
+ chunksProcessed++;
+ }
+ }
+ finally
+ {
+ m_fWarmingScrollViewport = false;
+ }
+ }
+
+ m_fDeferredScrollWarmupPending = !IsScrollWarmRangeCovered(
+ m_deferredScrollWarmupTargetTop,
+ m_deferredScrollWarmupTargetBottom);
+ stopwatch.Stop();
+ TraceInteractionEvent(
+ "WarmScrollViewport.IdleChunk",
+ $"Control={Name} Generation={generation} DurationMs={stopwatch.ElapsedMilliseconds} ChunksProcessed={chunksProcessed} StopReason={(m_fDeferredScrollWarmupPending ? stopReason : "complete")} CoveredTop={m_scrollWarmRangeTop} CoveredBottom={m_scrollWarmRangeBottom} TargetTop={m_deferredScrollWarmupTargetTop} TargetBottom={m_deferredScrollWarmupTargetBottom}");
+ return !m_fDeferredScrollWarmupPending;
+ }
+
+ private bool WarmDeferredScrollViewportChunk(Rectangle rcSrcRoot, Rectangle rcDstRoot,
+ int viewportTop, int viewportBottom, int chunkPixels, bool warmBottomFirst)
+ {
+ if (warmBottomFirst)
+ {
+ if (!WarmDeferredScrollViewportChunkForDirection(
+ rcSrcRoot,
+ rcDstRoot,
+ viewportTop,
+ viewportBottom,
+ chunkPixels,
+ true))
+ {
+ return false;
+ }
+
+ return WarmDeferredScrollViewportChunkForDirection(
+ rcSrcRoot,
+ rcDstRoot,
+ viewportTop,
+ viewportBottom,
+ chunkPixels,
+ false);
+ }
+
+ if (!WarmDeferredScrollViewportChunkForDirection(
+ rcSrcRoot,
+ rcDstRoot,
+ viewportTop,
+ viewportBottom,
+ chunkPixels,
+ false))
+ {
+ return false;
+ }
+
+ return WarmDeferredScrollViewportChunkForDirection(
+ rcSrcRoot,
+ rcDstRoot,
+ viewportTop,
+ viewportBottom,
+ chunkPixels,
+ true);
+ }
+
+ private bool WarmDeferredScrollViewportChunkForDirection(Rectangle rcSrcRoot, Rectangle rcDstRoot,
+ int viewportTop, int viewportBottom, int chunkPixels, bool warmBottom)
+ {
+ if (warmBottom)
+ {
+ if (m_scrollWarmRangeBottom >= m_deferredScrollWarmupTargetBottom)
+ return true;
+
+ int nextBottom = Math.Min(m_deferredScrollWarmupTargetBottom, m_scrollWarmRangeBottom + chunkPixels);
+ int offset = nextBottom - viewportBottom;
+ if (PrepareToDrawForScrollWarmup(rcSrcRoot, OffsetRootRect(rcDstRoot, offset)) == VwPrepDrawResult.kxpdrInvalidate)
+ return false;
+
+ ExpandScrollWarmRange(m_scrollWarmRangeTop, nextBottom);
+ return true;
}
+
+ if (m_scrollWarmRangeTop <= m_deferredScrollWarmupTargetTop)
+ return true;
+
+ int nextTop = Math.Max(m_deferredScrollWarmupTargetTop, m_scrollWarmRangeTop - chunkPixels);
+ int topOffset = nextTop - viewportTop;
+ if (PrepareToDrawForScrollWarmup(rcSrcRoot, OffsetRootRect(rcDstRoot, topOffset)) == VwPrepDrawResult.kxpdrInvalidate)
+ return false;
+
+ ExpandScrollWarmRange(nextTop, m_scrollWarmRangeBottom);
+ return true;
}
/// -----------------------------------------------------------------------------------
@@ -1915,6 +2517,13 @@ public virtual bool RefreshDisplay()
if (m_rootb?.Site == null)
return false;
+ if (m_refreshPhase == RefreshPhase.Refreshing)
+ {
+ m_fRefreshReplayRequested = true;
+ m_fRefreshPending = true;
+ return false;
+ }
+
var decorator = m_rootb.DataAccess as DomainDataByFlidDecoratorBase;
decorator?.Refresh();
@@ -1926,24 +2535,81 @@ public virtual bool RefreshDisplay()
return false;
}
+ // PATH-L5: Skip the expensive selection save/restore and drawing
+ // suspension when the VwRootBox reports no pending changes.
+ // The root box's NeedsReconstruct flag is set by PropChanged,
+ // OnStylesheetChange, and other mutation paths. When false,
+ // Reconstruct() would be a no-op (PATH-R1), so we can avoid
+ // the managed overhead entirely.
+ if (!ShouldReconstructDisplay())
+ {
+ m_fRefreshPending = false;
+ return false;
+ }
+
// Rebuild the display... the drastic way.
SelectionRestorer restorer = CreateSelectionRestorer();
try
{
+ m_refreshPhase = RefreshPhase.Refreshing;
+ m_fRefreshReplayRequested = false;
+ Stopwatch reconstructStopwatch = null;
+ if (s_enableInteractionTrace)
+ reconstructStopwatch = Stopwatch.StartNew();
using (new SuspendDrawing(this))
{
m_rootb.Reconstruct();
m_fRefreshPending = false;
+ m_fForceNextRefreshDisplay = false;
+ }
+ if (reconstructStopwatch != null)
+ {
+ reconstructStopwatch.Stop();
+ TraceInteractionTiming(
+ "RefreshDisplay.Reconstruct",
+ reconstructStopwatch.ElapsedMilliseconds,
+ $"Control={Name} ReplayRequested={m_fRefreshReplayRequested}");
}
}
finally
{
+ m_refreshPhase = RefreshPhase.Idle;
restorer?.Dispose();
+ if (m_fRefreshReplayRequested)
+ QueueRefreshDisplay();
}
//Enhance: If all refreshable descendants are handled this should return true
return false;
}
+ private void QueueRefreshDisplay()
+ {
+ if (m_refreshPhase == RefreshPhase.QueuedForReplay || IsDisposed)
+ return;
+ if (!IsHandleCreated)
+ {
+ m_fRefreshPending = true;
+ return;
+ }
+
+ m_refreshPhase = RefreshPhase.QueuedForReplay;
+ BeginInvoke((MethodInvoker)delegate
+ {
+ m_refreshPhase = RefreshPhase.Idle;
+ if (IsDisposed)
+ return;
+ if (Visible && m_fRootboxMade && m_rootb != null)
+ RefreshDisplay();
+ else
+ m_fRefreshPending = true;
+ });
+ }
+
+ private bool ShouldReconstructDisplay()
+ {
+ return m_fForceNextRefreshDisplay || m_rootb.NeedsReconstruct;
+ }
+
/// ------------------------------------------------------------------------------------
///
/// Creates a new selection restorer.
@@ -2030,6 +2696,8 @@ public bool AllowPainting
Update();
Invalidate();
}
+
+ QueueDeferredScrollWarmup();
}
}
else
@@ -3166,6 +3834,8 @@ protected override void OnVisibleChanged(EventArgs e)
base.OnVisibleChanged(e);
if (Visible && m_fRootboxMade && m_rootb != null && m_fRefreshPending)
RefreshDisplay();
+ if (Visible)
+ QueueDeferredScrollWarmup();
}
///
@@ -3733,7 +4403,10 @@ protected override void OnLayout(LayoutEventArgs levent)
using (new HoldGraphics(this))
{
if (DoLayout())
+ {
Invalidate();
+ QueueDeferredScrollWarmup();
+ }
}
}
@@ -5449,6 +6122,13 @@ protected bool UpdateScrollRange(int dxdRange, int dxdPos, int dydRange, int dyd
/// -----------------------------------------------------------------------------------
protected virtual bool DoLayout()
{
+ m_fDeferredScrollWarmupPending = true;
+ m_deferredScrollWarmupGeneration++;
+ m_deferredScrollWarmupTargetTop = int.MinValue;
+ m_deferredScrollWarmupTargetBottom = int.MinValue;
+ m_scrollWarmRangeTop = int.MinValue;
+ m_scrollWarmRangeBottom = int.MinValue;
+
if (DesignMode && !AllowPaintingInDesigner)
return false;
diff --git a/Src/Common/SimpleRootSite/SimpleRootSiteTests/RenderEngineFactoryTests.cs b/Src/Common/SimpleRootSite/SimpleRootSiteTests/RenderEngineFactoryTests.cs
index 817f106190..6501fb0961 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
{
@@ -33,7 +34,11 @@ 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);
@@ -65,7 +70,11 @@ 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);
@@ -87,5 +96,141 @@ public void get_Renderer_Graphite()
}
}
}
+
+ [Test]
+ public void get_Renderer_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.get_Renderer(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 get_Renderer_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.get_Renderer(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 get_Renderer_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.get_Renderer(ws, gm.VwGraphics);
+
+ var equivalentChrp = CreateCharRenderProps(
+ ws.Handle,
+ "Arial",
+ "kern=0,smcp=1");
+ gm.VwGraphics.SetupGraphics(ref equivalentChrp);
+ IRenderEngine equivalent = reFactory.get_Renderer(ws, gm.VwGraphics);
+
+ var differentChrp = CreateCharRenderProps(
+ ws.Handle,
+ "Arial",
+ "smcp=0,kern=0");
+ gm.VwGraphics.SetupGraphics(ref differentChrp);
+ IRenderEngine different = reFactory.get_Renderer(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();
+ }
+ }
+ }
+
+ 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/Common/SimpleRootSite/SimpleRootSiteTests/SimpleRootSiteTests_DpiLayout.cs b/Src/Common/SimpleRootSite/SimpleRootSiteTests/SimpleRootSiteTests_DpiLayout.cs
new file mode 100644
index 0000000000..9463343bd2
--- /dev/null
+++ b/Src/Common/SimpleRootSite/SimpleRootSiteTests/SimpleRootSiteTests_DpiLayout.cs
@@ -0,0 +1,62 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System.Drawing;
+using NUnit.Framework;
+
+namespace SIL.FieldWorks.Common.RootSites.SimpleRootSiteTests
+{
+ internal class DpiLayoutDummyRootSite : SimpleRootSite
+ {
+ internal int LayoutWidth
+ {
+ get { return m_dxdLayoutWidth; }
+ set { m_dxdLayoutWidth = value; }
+ }
+
+ internal Point CurrentDpi
+ {
+ get { return Dpi; }
+ set { Dpi = value; }
+ }
+ }
+
+ [TestFixture]
+ public class DpiLayoutTests
+ {
+ [Test]
+ public void DpiSetter_DoesNotForceLayout_WhenDpiIsUnchanged()
+ {
+ var site = new DpiLayoutDummyRootSite();
+ try
+ {
+ site.LayoutWidth = 320;
+ site.CurrentDpi = new Point(96, 96);
+
+ Assert.That(site.LayoutWidth, Is.EqualTo(320));
+ }
+ finally
+ {
+ site.Dispose();
+ }
+ }
+
+ [Test]
+ public void DpiSetter_ForcesLayout_WhenDpiChangesAfterLayout()
+ {
+ var site = new DpiLayoutDummyRootSite();
+ try
+ {
+ site.LayoutWidth = 320;
+ site.CurrentDpi = new Point(144, 144);
+
+ Assert.That(site.LayoutWidth, Is.EqualTo(SimpleRootSite.kForceLayout));
+ }
+ finally
+ {
+ site.Dispose();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Src/Common/SimpleRootSite/SimpleRootSiteTests/SimpleRootSiteTests_RefreshDisplayNeedsReconstruct.cs b/Src/Common/SimpleRootSite/SimpleRootSiteTests/SimpleRootSiteTests_RefreshDisplayNeedsReconstruct.cs
new file mode 100644
index 0000000000..4c524ccdf8
--- /dev/null
+++ b/Src/Common/SimpleRootSite/SimpleRootSiteTests/SimpleRootSiteTests_RefreshDisplayNeedsReconstruct.cs
@@ -0,0 +1,152 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System.Windows.Forms;
+using Moq;
+using NUnit.Framework;
+using SIL.FieldWorks.Common.ViewsInterfaces;
+using SIL.LCModel.Core.KernelInterfaces;
+
+namespace SIL.FieldWorks.Common.RootSites.SimpleRootSiteTests
+{
+ internal class RefreshDisplayDummyRootSite : DummyRootSite
+ {
+ public bool RefreshPending => m_fRefreshPending;
+
+ public void SetRootBoxDataAccessForTest(ISilDataAccess dataAccess)
+ {
+ SetRootBoxDataAccess(dataAccess);
+ }
+
+ public void SetRootBoxDataAccessAndRefreshForTest(ISilDataAccess dataAccess)
+ {
+ SetRootBoxDataAccessAndRefresh(dataAccess);
+ }
+
+ public void NotifyDataAccessSemanticsChangedForTest()
+ {
+ NotifyDataAccessSemanticsChanged();
+ }
+
+ protected override SelectionRestorer CreateSelectionRestorer()
+ {
+ return null;
+ }
+ }
+
+ [TestFixture]
+ public class RefreshDisplayNeedsReconstructTests
+ {
+ private RefreshDisplayDummyRootSite m_site;
+ private Mock m_rootbMock;
+ private Form m_form;
+
+ [SetUp]
+ public void Setup()
+ {
+ m_site = new RefreshDisplayDummyRootSite();
+ m_rootbMock = new Mock(MockBehavior.Strict);
+ m_form = new Form();
+ m_form.Controls.Add(m_site);
+ m_site.Dock = DockStyle.Fill;
+ m_form.Show();
+ m_site.CreateControl();
+
+ m_rootbMock.SetupGet(rb => rb.Site).Returns(m_site);
+ m_rootbMock.SetupGet(rb => rb.DataAccess).Returns((ISilDataAccess)null);
+ m_rootbMock.Setup(rb => rb.LoseFocus()).Returns(true);
+ m_rootbMock.Setup(rb => rb.Activate(It.IsAny()));
+ m_rootbMock.Setup(rb => rb.Close());
+ m_site.RootBox = m_rootbMock.Object;
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ m_form?.Close();
+ m_form?.Dispose();
+ m_site?.Dispose();
+ }
+
+ [Test]
+ public void RefreshDisplay_SkipsReconstruct_WhenRootBoxDoesNotNeedReconstruct()
+ {
+ m_rootbMock.SetupGet(rb => rb.NeedsReconstruct).Returns(false);
+
+ Assert.That(m_site.RefreshDisplay(), Is.False);
+ Assert.That(m_site.RefreshPending, Is.False);
+ m_rootbMock.Verify(rb => rb.Reconstruct(), Times.Never);
+ }
+
+ [Test]
+ public void RefreshDisplay_Reconstructs_WhenRootBoxNeedsReconstruct()
+ {
+ m_rootbMock.SetupGet(rb => rb.NeedsReconstruct).Returns(true);
+ m_rootbMock.Setup(rb => rb.Reconstruct());
+
+ Assert.That(m_site.RefreshDisplay(), Is.False);
+ Assert.That(m_site.RefreshPending, Is.False);
+ m_rootbMock.Verify(rb => rb.Reconstruct(), Times.Once);
+ }
+
+ [Test]
+ public void NotifyDataAccessSemanticsChanged_Reconstructs_WhenRootBoxDoesNotNeedReconstruct()
+ {
+ m_rootbMock.SetupGet(rb => rb.NeedsReconstruct).Returns(false);
+ m_rootbMock.Setup(rb => rb.Reconstruct());
+
+ m_site.NotifyDataAccessSemanticsChangedForTest();
+
+ Assert.That(m_site.RefreshPending, Is.False);
+ m_rootbMock.Verify(rb => rb.Reconstruct(), Times.Once);
+ }
+
+ [Test]
+ public void NotifyDataAccessSemanticsChanged_DefersUntilVisible()
+ {
+ m_rootbMock.SetupGet(rb => rb.NeedsReconstruct).Returns(false);
+ m_rootbMock.Setup(rb => rb.Reconstruct());
+ m_site.Visible = false;
+
+ m_site.NotifyDataAccessSemanticsChangedForTest();
+
+ Assert.That(m_site.RefreshPending, Is.True);
+ m_rootbMock.Verify(rb => rb.Reconstruct(), Times.Never);
+
+ m_site.Visible = true;
+ Assert.That(m_site.RefreshDisplay(), Is.False);
+ Assert.That(m_site.RefreshPending, Is.False);
+ m_rootbMock.Verify(rb => rb.Reconstruct(), Times.Once);
+ }
+
+ [Test]
+ public void SetRootBoxDataAccess_DoesNotReconstruct_WhenSwapIsCheap()
+ {
+ var replacementDataAccess = Mock.Of();
+ m_rootbMock.SetupSet(rb => rb.DataAccess = replacementDataAccess);
+ m_rootbMock.SetupGet(rb => rb.NeedsReconstruct).Returns(false);
+
+ m_site.SetRootBoxDataAccessForTest(replacementDataAccess);
+
+ Assert.That(m_site.RefreshPending, Is.False);
+ m_rootbMock.VerifySet(rb => rb.DataAccess = replacementDataAccess, Times.Once);
+ m_rootbMock.Verify(rb => rb.Reconstruct(), Times.Never);
+ }
+
+ [Test]
+ public void SetRootBoxDataAccessAndRefresh_Reconstructs_WhenSwapChangesDisplaySemantics()
+ {
+ var replacementDataAccess = Mock.Of();
+ m_rootbMock.SetupSet(rb => rb.DataAccess = replacementDataAccess);
+ m_rootbMock.SetupGet(rb => rb.NeedsReconstruct).Returns(false);
+ m_rootbMock.Setup(rb => rb.Reconstruct());
+
+ m_site.SetRootBoxDataAccessAndRefreshForTest(replacementDataAccess);
+
+ Assert.That(m_site.RefreshPending, Is.False);
+ m_rootbMock.VerifySet(rb => rb.DataAccess = replacementDataAccess, Times.Once);
+ m_rootbMock.Verify(rb => rb.Reconstruct(), Times.Once);
+ }
+ }
+}
diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/DefaultFontsControl.cs b/Src/FwCoreDlgs/FwCoreDlgControls/DefaultFontsControl.cs
index 239bb66017..5c1c578884 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.UseGraphiteFeatures = m_ws.IsGraphiteEnabled;
+ m_defaultFontFeaturesButton.FontName = m_defaultFontComboBox.Text;
+ m_defaultFontFeaturesButton.SetupFontFeatures();
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.UseGraphiteFeatures = false;
+ m_defaultFontFeaturesButton.FontName = m_defaultFontComboBox.Text;
+ m_defaultFontFeaturesButton.SetupFontFeatures();
}
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,8 @@ 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.UseGraphiteFeatures = m_ws.IsGraphiteEnabled;
+ m_defaultFontFeaturesButton.SetupFontFeatures();
}
#endregion
diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/DefaultFontsControl.resx b/Src/FwCoreDlgs/FwCoreDlgControls/DefaultFontsControl.resx
index 663352e6ce..c75f535fb8 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
diff --git a/Src/FwCoreDlgs/FwCoreDlgControls/FontFeaturesButton.cs b/Src/FwCoreDlgs/FwCoreDlgControls/FontFeaturesButton.cs
index 15a3b31625..f758be46ea 100644
--- a/Src/FwCoreDlgs/FwCoreDlgControls/FontFeaturesButton.cs
+++ b/Src/FwCoreDlgs/FwCoreDlgControls/FontFeaturesButton.cs
@@ -3,9 +3,11 @@
// (http://www.gnu.org/licenses/lgpl-2.1.html)
using System;
+using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
+using System.Runtime.InteropServices;
using System.Windows.Forms;
using SIL.LCModel.Core.KernelInterfaces;
using SIL.FieldWorks.Common.FwUtils;
@@ -36,11 +38,13 @@ 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 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 = true;
#endregion
#region Constructor and dispose stuff
@@ -110,6 +114,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 +288,40 @@ public string FontFeatures
set
{
CheckDisposed();
- m_fontFeatures = value;
+ m_fontFeatures = FontFeatureSettings.Normalize(value);
+ }
+ }
+
+ ///
+ /// 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 +349,8 @@ public bool IsGraphiteFont
public void SetupFontFeatures()
{
CheckDisposed();
+ m_featureProvider = null;
+ m_hasFontFeatures = false;
if (string.IsNullOrEmpty(m_fontName))
{
@@ -317,49 +361,42 @@ 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)
+ var graphiteProvider = CreateGraphiteProvider(hdg);
+ m_isGraphiteFont = graphiteProvider != null;
+
+ if (m_useGraphiteFeatures && graphiteProvider != null && graphiteProvider.HasFeatures)
{
- Enabled = false;
+ m_featureProvider = graphiteProvider;
+ m_hasFontFeatures = true;
+ Enabled = true;
return;
}
- int cfid;
- m_featureEngine.GetFeatureIDs(0, null, out cfid);
- if (cfid == 0)
+
+ var openTypeProvider = OpenTypeFontFeatureProvider.Create(hdg.Hdc);
+ if (openTypeProvider != null && openTypeProvider.HasFeatures)
{
- Enabled = false;
+ m_featureProvider = openTypeProvider;
+ m_hasFontFeatures = true;
+ Enabled = true;
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 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 +682,13 @@ 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 = 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 +697,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 +722,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 +760,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 +831,224 @@ private void ItemClickHandler(Object sender, EventArgs e)
m_fontFeatures = GenerateFeatureString(m_ids, m_values);
OnFontFeatureSelected(new EventArgs());
}
+
+ private interface IFontFeatureProvider
+ {
+ 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 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_featureLabels = new Dictionary
+ {
+ { "aalt", "Access All Alternates" },
+ { "c2sc", "Small Capitals From Capitals" },
+ { "calt", "Contextual Alternates" },
+ { "case", "Case-Sensitive Forms" },
+ { "ccmp", "Glyph Composition/Decomposition" },
+ { "clig", "Contextual Ligatures" },
+ { "dlig", "Discretionary Ligatures" },
+ { "frac", "Fractions" },
+ { "kern", "Kerning" },
+ { "liga", "Standard Ligatures" },
+ { "lnum", "Lining Figures" },
+ { "onum", "Oldstyle Figures" },
+ { "pnum", "Proportional Figures" },
+ { "salt", "Stylistic Alternates" },
+ { "smcp", "Small Capitals" },
+ { "ss01", "Stylistic Set 1" },
+ { "ss02", "Stylistic Set 2" },
+ { "ss03", "Stylistic Set 3" },
+ { "ss04", "Stylistic Set 4" },
+ { "ss05", "Stylistic Set 5" },
+ { "tnum", "Tabular Figures" },
+ };
+
+ 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 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 label;
+ return s_featureLabels.TryGetValue(tag, out label) ? label : 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 ? "Off" : "On";
+ }
+ }
+
+ 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);
+ }
+
+ private static class OpenTypeFontFeatureReader
+ {
+ private const uint GdiError = 0xFFFFFFFF;
+ private static readonly uint[] s_layoutTables = { MakeTableTag("GSUB"), MakeTableTag("GPOS") };
+
+ [DllImport("gdi32.dll", SetLastError = true)]
+ private static extern uint GetFontData(IntPtr hdc, uint table, uint offset, byte[] buffer, uint length);
+
+ public static IReadOnlyList GetFeatureTags(IntPtr hdc)
+ {
+ var tags = new SortedSet(StringComparer.Ordinal);
+ foreach (var table in s_layoutTables)
+ {
+ var tableData = ReadTable(hdc, table);
+ if (tableData != null)
+ ReadFeatureList(tableData, tags);
+ }
+ return tags.ToArray();
+ }
+
+ 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))
+ tags.Add(tag);
+ }
+ }
+
+ 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);
+ }
+ }
}
}
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..236a38627c 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()
{
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..b8dfc2c86a 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,31 @@ 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"));
+ }
+ }
+
+ private static int FeatureId(string tag)
+ {
+ var reversedTagBytes = tag.Reverse().Select(Convert.ToByte).ToArray();
+ return BitConverter.ToInt32(reversedTagBytes, 0);
+ }
}
}
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/LexText/Discourse/DiscourseTests/InterlinRibbonTests.cs b/Src/LexText/Discourse/DiscourseTests/InterlinRibbonTests.cs
index f3bc486c6b..2458eb7e74 100644
--- a/Src/LexText/Discourse/DiscourseTests/InterlinRibbonTests.cs
+++ b/Src/LexText/Discourse/DiscourseTests/InterlinRibbonTests.cs
@@ -7,11 +7,13 @@
using System.Diagnostics;
using System.Drawing;
using System.Windows.Forms;
+using Moq;
using NUnit.Framework;
using SIL.FieldWorks.Common.FwUtils.Attributes;
using SIL.FieldWorks.Common.RootSites;
using SIL.FieldWorks.Common.ViewsInterfaces;
using SIL.LCModel;
+using SIL.LCModel.Core.KernelInterfaces;
using SIL.LCModel.DomainServices;
using SIL.LCModel.Infrastructure;
using XCore;
@@ -158,6 +160,37 @@ public void ClickExpansion()
m_ribbon.RootBox.MouseUp(1, 1, rcSrc, rcDst);
Assert.That(m_ribbon.SelectedOccurrences, Is.EqualTo(new[] { glosses[0] }));
}
+
+ [Test]
+ public void SetRoot_AssignsDecoratorBeforeChangingExistingRootObject()
+ {
+ EndSetupTask();
+ var orderingRibbon = new OrderingTestInterlinRibbon(Cache, m_stText.Hvo);
+ var rootbMock = new Mock(MockBehavior.Strict);
+ var installedDecorator = false;
+ rootbMock.Setup(rb => rb.Close());
+
+ rootbMock.SetupSet(rb => rb.DataAccess = It.IsAny())
+ .Callback(sda => installedDecorator = ReferenceEquals(sda, orderingRibbon.Decorator));
+ rootbMock.Setup(rb => rb.SetRootObject(
+ m_stText.Hvo,
+ It.IsAny(),
+ InterlinRibbon.kfragRibbonWordforms,
+ orderingRibbon.StyleSheet))
+ .Callback(() => Assert.That(installedDecorator, Is.True,
+ "InterlinRibbon should install its decorator before changing an existing root object"));
+
+ orderingRibbon.InstallRootBoxForTest(rootbMock.Object);
+ orderingRibbon.SetRoot(m_stText.Hvo);
+
+ rootbMock.VerifySet(rb => rb.DataAccess = It.IsAny(), Times.Once);
+ rootbMock.Verify(rb => rb.SetRootObject(
+ m_stText.Hvo,
+ It.IsAny(),
+ InterlinRibbon.kfragRibbonWordforms,
+ orderingRibbon.StyleSheet), Times.Once);
+ orderingRibbon.Dispose();
+ }
#endregion
}
@@ -221,6 +254,11 @@ internal void CallOnLoad(EventArgs eventArgs)
base.OnLoad(eventArgs);
}
+ internal void InstallRootBoxForTest(IVwRootBox rootBox)
+ {
+ m_rootb = rootBox;
+ }
+
protected override void Dispose(bool disposing)
{
Debug.WriteLineIf(!disposing, "****** Missing Dispose() call for " + GetType() + " ******");
@@ -228,4 +266,21 @@ protected override void Dispose(bool disposing)
base.Dispose(disposing);
}
}
+
+ internal class OrderingTestInterlinRibbon : TestInterlinRibbon
+ {
+ public OrderingTestInterlinRibbon(LcmCache cache, int hvoStText)
+ : base(cache, hvoStText)
+ {
+ }
+
+ protected override void SetRootInternal(int hvo)
+ {
+ EnsureVc();
+ }
+
+ public override void MakeInitialSelection()
+ {
+ }
+ }
}
diff --git a/Src/LexText/Discourse/InterlinRibbon.cs b/Src/LexText/Discourse/InterlinRibbon.cs
index 918706ba31..3b97711151 100644
--- a/Src/LexText/Discourse/InterlinRibbon.cs
+++ b/Src/LexText/Discourse/InterlinRibbon.cs
@@ -249,7 +249,7 @@ protected void SelectUpTo(int end1)
protected override void AddDecorator()
{
- m_rootb.DataAccess = Decorator;
+ SetRootBoxDataAccess(Decorator);
}
public override void SetRoot(int hvoStText)
@@ -261,8 +261,8 @@ public override void SetRoot(int hvoStText)
if (RootBox == null)
return;
SetRootInternal(hvoStText);
- ChangeOrMakeRoot(HvoRoot, Vc, kfragRibbonWordforms, StyleSheet);
AddDecorator();
+ ChangeOrMakeRoot(HvoRoot, Vc, kfragRibbonWordforms, StyleSheet);
MakeInitialSelection();
}
@@ -284,7 +284,7 @@ public override void MakeRoot()
Vc.LineChoices = LineChoices;
SetRootInternal(HvoRoot);
- m_rootb.DataAccess = Decorator;
+ SetRootBoxDataAccess(Decorator);
m_rootb.SetRootObject(HvoRoot, Vc, kfragRibbonWordforms, this.StyleSheet);
m_rootb.Activate(VwSelectionState.vssOutOfFocus); // Makes selection visible even before ever got focus.
diff --git a/Src/ManagedVwDrawRootBuffered/VwDrawRootBuffered.cs b/Src/ManagedVwDrawRootBuffered/VwDrawRootBuffered.cs
index 4abdc1bf23..f44e30e8b6 100644
--- a/Src/ManagedVwDrawRootBuffered/VwDrawRootBuffered.cs
+++ b/Src/ManagedVwDrawRootBuffered/VwDrawRootBuffered.cs
@@ -3,6 +3,7 @@
// (http://www.gnu.org/licenses/lgpl-2.1.html)
using System;
+using System.Diagnostics;
using System.Drawing;
using System.Runtime.InteropServices;
using SIL.FieldWorks.Common.ViewsInterfaces;
@@ -16,8 +17,11 @@ namespace SIL.FieldWorks.Views
///
[ComVisible(true)]
[Guid("97199458-10C7-49da-B3AE-EA922EA64859")]
- public class VwDrawRootBuffered : IVwDrawRootBuffered
+ public class VwDrawRootBuffered : IVwDrawRootBuffered, IDisposable
{
+ private GdiMemoryBuffer m_cachedBuffer;
+ private bool m_isDisposed;
+
private class GdiMemoryBuffer : IDisposable
{
public IntPtr HdcMem { get; private set; }
@@ -93,6 +97,36 @@ private struct RECT
private const int SRCCOPY = 0x00CC0020;
private const uint kclrTransparent = 0xC0000000;
+ private void ReplaceCachedBuffer(GdiMemoryBuffer newBuffer)
+ {
+ if (ReferenceEquals(m_cachedBuffer, newBuffer))
+ return;
+
+ m_cachedBuffer?.Dispose();
+ m_cachedBuffer = newBuffer;
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ~VwDrawRootBuffered()
+ {
+ Dispose(false);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (m_isDisposed)
+ return;
+
+ m_cachedBuffer?.Dispose();
+ m_cachedBuffer = null;
+ m_isDisposed = true;
+ }
+
///
/// See C++ documentation
///
@@ -117,7 +151,9 @@ public void DrawTheRoot(IVwRootBox prootb, IntPtr hdc, Rect rcpDraw, uint bkclr,
IVwGraphicsWin32 qvg = VwGraphicsWin32Class.Create();
Rectangle rcp = rcpDraw;
- using (var memoryBuffer = new GdiMemoryBuffer(hdc, rcp.Width, rcp.Height))
+ var memoryBuffer = new GdiMemoryBuffer(hdc, rcp.Width, rcp.Height);
+ bool keepBuffer = false;
+ try
{
IntPtr hdcMem = memoryBuffer.HdcMem;
try
@@ -184,9 +220,16 @@ public void DrawTheRoot(IVwRootBox prootb, IntPtr hdc, Rect rcpDraw, uint bkclr,
if (xpdr != VwPrepDrawResult.kxpdrInvalidate)
{
+ ReplaceCachedBuffer(memoryBuffer);
+ keepBuffer = true;
// We drew something...now blast it onto the screen.
BitBlt(hdc, rcp.Left, rcp.Top, rcp.Width, rcp.Height, hdcMem, 0, 0, SRCCOPY);
}
+ else if (m_cachedBuffer != null)
+ {
+ Trace.WriteLine("[FW_PERF_INTERACTION] [VwDrawRootBuffered] Stage=ReuseLastFrameOnInvalidate Path=Managed");
+ BitBlt(hdc, rcp.Left, rcp.Top, rcp.Width, rcp.Height, m_cachedBuffer.HdcMem, 0, 0, SRCCOPY);
+ }
}
catch (Exception)
{
@@ -200,12 +243,20 @@ public void DrawTheRoot(IVwRootBox prootb, IntPtr hdc, Rect rcpDraw, uint bkclr,
qvg.ReleaseDC();
}
}
+ finally
+ {
+ if (!keepBuffer)
+ memoryBuffer.Dispose();
+ }
}
public void ReDrawLastDraw(IntPtr hdc, Rect rcpDraw)
{
- // TODO-Linux: implement
- throw new NotImplementedException();
+ if (m_cachedBuffer == null)
+ throw new NotImplementedException();
+
+ Rectangle rcp = rcpDraw;
+ BitBlt(hdc, rcp.Left, rcp.Top, rcp.Width, rcp.Height, m_cachedBuffer.HdcMem, rcp.Left, rcp.Top, SRCCOPY);
}
///
diff --git a/Src/views/Test/RenderEngineTestBase.h b/Src/views/Test/RenderEngineTestBase.h
index 1b7a2d88be..16d24a5d0a 100644
--- a/Src/views/Test/RenderEngineTestBase.h
+++ b/Src/views/Test/RenderEngineTestBase.h
@@ -34,6 +34,7 @@ namespace TestViews
public:
TxtSrc(int n, ILgWritingSystemFactory * pwsf);
TxtSrc(const wchar_t *, ILgWritingSystemFactory * pwsf);
+ TxtSrc(const wchar_t *, ILgWritingSystemFactory * pwsf, const wchar_t * pszFontVar);
// IUnknown methods.
STDMETHOD(QueryInterface)(REFIID iid, void ** ppv);
@@ -107,10 +108,11 @@ namespace TestViews
protected:
long m_cref;
StrUni m_stu;
+ StrUni m_stuFontVar;
Vector m_vws;
private:
- void Init(const wchar_t* s, ILgWritingSystemFactory * pwsf);
+ void Init(const wchar_t* s, ILgWritingSystemFactory * pwsf, const wchar_t * pszFontVar = L"");
};
TxtSrc::TxtSrc(int n, ILgWritingSystemFactory * pwsf)
@@ -195,11 +197,17 @@ 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);
+ }
+
+ void TxtSrc::Init(const wchar_t* s, ILgWritingSystemFactory * pwsf, const wchar_t * pszFontVar)
{
AssertPtr(pwsf);
m_cref = 1;
m_stu.Assign(s);
+ m_stuFontVar.Assign(pszFontVar ? pszFontVar : L"");
int cws = 0;
pwsf->get_NumberOfWs(&cws);
m_vws.Resize(cws);
@@ -278,7 +286,7 @@ 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)
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/TestLayoutPage.h b/Src/views/Test/TestLayoutPage.h
index 6bcea16ea1..390588d0a8 100644
--- a/Src/views/Test/TestLayoutPage.h
+++ b/Src/views/Test/TestLayoutPage.h
@@ -425,6 +425,27 @@ namespace TestViews
unitpp::assert_true("should have found a box", pboxFound != NULL);
}
+ void testConstructAndLayoutUpdatesCachedDpiWhenWidthIsUnchanged()
+ {
+ CreateBoringStrings();
+ CreateTestStTexts(2);
+ SetupRootWithoutMargins();
+
+ const int kdxpLayoutWidth = 200;
+ CheckHr(m_qvg32->put_XUnitsPerInch(96));
+ CheckHr(m_qvg32->put_YUnitsPerInch(96));
+ m_qlay->ConstructAndLayout(m_qvg32, kdxpLayoutWidth);
+ unitpp::assert_eq("initial layout should cache X DPI", 96, m_qlay->DpiSrc().x);
+ unitpp::assert_eq("initial layout should cache Y DPI", 96, m_qlay->DpiSrc().y);
+
+ CheckHr(m_qvg32->put_XUnitsPerInch(144));
+ CheckHr(m_qvg32->put_YUnitsPerInch(144));
+ m_qlay->ConstructAndLayout(m_qvg32, kdxpLayoutWidth);
+
+ unitpp::assert_eq("same-width ConstructAndLayout should refresh X DPI", 144, m_qlay->DpiSrc().x);
+ unitpp::assert_eq("same-width ConstructAndLayout should refresh Y DPI", 144, m_qlay->DpiSrc().y);
+ }
+
void testFindPageBreakStuffNoMargins()
{
CreateBoringStrings();
diff --git a/Src/views/Test/TestUniscribeEngine.h b/Src/views/Test/TestUniscribeEngine.h
index bd5514f0e2..739c6a561e 100644
--- a/Src/views/Test/TestUniscribeEngine.h
+++ b/Src/views/Test/TestUniscribeEngine.h
@@ -36,6 +36,318 @@ 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;
+ HDC hdc = ::CreateCompatibleDC(::GetDC(::GetDesktopWindow()));
+ HBITMAP hbm = ::CreateCompatibleBitmap(hdc, dxMax, dxMax);
+ ::SelectObject(hdc, hbm);
+ ::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();
+ ::DeleteObject(hbm);
+ ::DeleteDC(hdc);
+#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)
+ {
+ ILgWritingSystemPtr qws;
+ CheckHr(g_qwsf->get_EngineOrNull(g_wsEng, &qws));
+ MockLgWritingSystem * pws = dynamic_cast(qws.Ptr());
+ unitpp::assert_true("English test writing system should be a mock writing system",
+ pws != NULL);
+ StrUni stuFont(pszFontName);
+ CheckHr(pws->put_DefaultFontName(stuFont.Bstr()));
+ }
+
+ RenderedFeatureText RenderTextWithFeatures(const wchar_t * pszText, const wchar_t * pszFontVar)
+ {
+ 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 = 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);
+ 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
+ }
+
virtual void Setup()
{
RenderEngineTestBase::Setup();
diff --git a/Src/views/Test/TestViewCaches.h b/Src/views/Test/TestViewCaches.h
new file mode 100644
index 0000000000..2b108c1d8d
--- /dev/null
+++ b/Src/views/Test/TestViewCaches.h
@@ -0,0 +1,223 @@
+/*--------------------------------------------------------------------*//*:Ignore this sentence.
+Copyright (c) 2026 SIL International
+This software is licensed under the LGPL, version 2.1 or later
+(http://www.gnu.org/licenses/lgpl-2.1.html)
+-------------------------------------------------------------------------------*//*:End Ignore*/
+#ifndef TESTVIEWCACHES_H_INCLUDED
+#define TESTVIEWCACHES_H_INCLUDED
+
+#pragma once
+
+#include "testViews.h"
+#include "ColorStateCache.h"
+#include "FontHandleCache.h"
+#include "LayoutCache.h"
+
+namespace TestViews
+{
+ class TestColorStateCache : public unitpp::suite
+ {
+ HDC m_hdc;
+ ColorStateCache m_cache;
+
+ void testApplyOnFirstUse()
+ {
+ bool fApplied = m_cache.ApplyIfNeeded(m_hdc, RGB(1, 2, 3), RGB(4, 5, 6), TRANSPARENT);
+ unitpp::assert_true("First color apply should update", fApplied);
+ }
+
+ void testNoApplyWhenUnchanged()
+ {
+ m_cache.ApplyIfNeeded(m_hdc, RGB(10, 20, 30), RGB(40, 50, 60), OPAQUE);
+ bool fApplied = m_cache.ApplyIfNeeded(m_hdc, RGB(10, 20, 30), RGB(40, 50, 60), OPAQUE);
+ unitpp::assert_true("Repeated identical color apply should be skipped", !fApplied);
+ }
+
+ void testApplyWhenChanged()
+ {
+ m_cache.ApplyIfNeeded(m_hdc, RGB(7, 8, 9), RGB(11, 12, 13), TRANSPARENT);
+ bool fApplied = m_cache.ApplyIfNeeded(m_hdc, RGB(17, 18, 19), RGB(11, 12, 13), TRANSPARENT);
+ unitpp::assert_true("Changed foreground color should update", fApplied);
+ }
+
+ void testInvalidateForcesApply()
+ {
+ m_cache.ApplyIfNeeded(m_hdc, RGB(1, 1, 1), RGB(2, 2, 2), OPAQUE);
+ m_cache.Invalidate();
+ bool fApplied = m_cache.ApplyIfNeeded(m_hdc, RGB(1, 1, 1), RGB(2, 2, 2), OPAQUE);
+ unitpp::assert_true("Invalidate should force next apply", fApplied);
+ }
+
+ public:
+ TestColorStateCache();
+ virtual void Setup()
+ {
+ m_hdc = GetTestDC();
+ m_cache.Invalidate();
+ }
+ virtual void Teardown()
+ {
+ if (m_hdc)
+ ReleaseTestDC(m_hdc);
+ m_hdc = NULL;
+ }
+ };
+
+ class TestFontHandleCache : public unitpp::suite
+ {
+ struct DeleteTracker
+ {
+ Set m_failing;
+ Vector m_deleted;
+ };
+
+ FontHandleCache m_cache;
+
+ static bool TryDeleteForTest(HFONT hfont, void * pContext)
+ {
+ DeleteTracker * pTracker = reinterpret_cast(pContext);
+ if (pTracker->m_failing.IsMember(hfont))
+ return false;
+ pTracker->m_deleted.Push(hfont);
+ return true;
+ }
+
+ LgCharRenderProps MakeProps(int n) const
+ {
+ LgCharRenderProps chrp;
+ memset(&chrp, 0, sizeof(chrp));
+ chrp.ttvBold = (n & 1) ? kttvForceOn : kttvOff;
+ chrp.ttvItalic = (n & 2) ? kttvForceOn : kttvOff;
+ chrp.dympHeight = 10000 + (n * 10);
+ swprintf_s(chrp.szFaceName, L"CacheFont_%d", n);
+ return chrp;
+ }
+
+ void FillToCacheMax(DeleteTracker & tracker)
+ {
+ for (int i = 0; i < FontHandleCache::kcFontCacheMax; ++i)
+ {
+ HFONT hfont = reinterpret_cast(static_cast(100 + i));
+ LgCharRenderProps chrp = MakeProps(i);
+ m_cache.AddFontToCache(hfont, &chrp, NULL, TryDeleteForTest, &tracker);
+ }
+ }
+
+ void testFindCachedFont()
+ {
+ DeleteTracker tracker;
+ HFONT hfont = reinterpret_cast(static_cast(200));
+ LgCharRenderProps chrp = MakeProps(1);
+ m_cache.AddFontToCache(hfont, &chrp, NULL, TryDeleteForTest, &tracker);
+ HFONT hfontFound = m_cache.FindCachedFont(&chrp);
+ unitpp::assert_eq("FindCachedFont should return added handle", hfont, hfontFound);
+ }
+
+ void testEvictionDeletesOldest()
+ {
+ DeleteTracker tracker;
+ FillToCacheMax(tracker);
+ HFONT hfontNewest = reinterpret_cast(static_cast(999));
+ LgCharRenderProps chrp = MakeProps(99);
+ m_cache.AddFontToCache(hfontNewest, &chrp, NULL, TryDeleteForTest, &tracker);
+
+ unitpp::assert_eq("Cache size should stay bounded", FontHandleCache::kcFontCacheMax,
+ m_cache.CacheCount());
+ unitpp::assert_true("Oldest entry should be deleted on eviction",
+ tracker.m_deleted.Size() >= 1 &&
+ tracker.m_deleted[tracker.m_deleted.Size() - 1] == reinterpret_cast(static_cast(100)));
+ }
+
+ void testFailedDeleteIsDeferredAndRetried()
+ {
+ DeleteTracker tracker;
+ HFONT hfontVictim = reinterpret_cast(static_cast(100));
+ tracker.m_failing.Insert(hfontVictim);
+ FillToCacheMax(tracker);
+
+ HFONT hfontNewest = reinterpret_cast(static_cast(1000));
+ LgCharRenderProps chrpNewest = MakeProps(100);
+ m_cache.AddFontToCache(hfontNewest, &chrpNewest, NULL, TryDeleteForTest, &tracker);
+
+ unitpp::assert_eq("Failed delete should queue one deferred font", 1,
+ m_cache.DeferredDeleteCount());
+ unitpp::assert_true("Victim should be in deferred queue",
+ m_cache.IsDeferredDeleteQueued(hfontVictim));
+
+ tracker.m_failing.Delete(hfontVictim);
+ m_cache.TryDeleteDeferredFonts(NULL, TryDeleteForTest, &tracker);
+ unitpp::assert_eq("Deferred queue should drain after successful retry", 0,
+ m_cache.DeferredDeleteCount());
+ }
+
+ void testDeferredDeleteSkipsActiveFont()
+ {
+ DeleteTracker tracker;
+ HFONT hfontVictim = reinterpret_cast(static_cast(100));
+ tracker.m_failing.Insert(hfontVictim);
+ FillToCacheMax(tracker);
+
+ HFONT hfontNewest = reinterpret_cast(static_cast(1001));
+ LgCharRenderProps chrpNewest = MakeProps(101);
+ m_cache.AddFontToCache(hfontNewest, &chrpNewest, NULL, TryDeleteForTest, &tracker);
+ tracker.m_failing.Delete(hfontVictim);
+
+ m_cache.TryDeleteDeferredFonts(hfontVictim, TryDeleteForTest, &tracker);
+ unitpp::assert_eq("Active deferred font should not be deleted", 1,
+ m_cache.DeferredDeleteCount());
+
+ m_cache.TryDeleteDeferredFonts(NULL, TryDeleteForTest, &tracker);
+ unitpp::assert_eq("Deferred queue should delete when no longer active", 0,
+ m_cache.DeferredDeleteCount());
+ }
+
+ public:
+ TestFontHandleCache();
+ virtual void Setup()
+ {
+ 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 c495982749..39044b567e 100644
--- a/Src/views/Test/TestViews.vcxproj
+++ b/Src/views/Test/TestViews.vcxproj
@@ -180,6 +180,8 @@
$(ViewsObjDir)VwSelection.obj;
$(ViewsObjDir)VwTableBox.obj;
$(ViewsObjDir)VwGraphics.obj;
+ $(ViewsObjDir)FontHandleCache.obj;
+ $(ViewsObjDir)ColorStateCache.obj;
$(ViewsObjDir)VwTxtSrc.obj;
$(ViewsObjDir)AfColorTable.obj;
$(ViewsObjDir)AfGfx.obj;
@@ -308,6 +310,8 @@
$(ViewsObjDir)VwSelection.obj;
$(ViewsObjDir)VwTableBox.obj;
$(ViewsObjDir)VwGraphics.obj;
+ $(ViewsObjDir)FontHandleCache.obj;
+ $(ViewsObjDir)ColorStateCache.obj;
$(ViewsObjDir)VwTxtSrc.obj;
$(ViewsObjDir)AfColorTable.obj;
$(ViewsObjDir)AfGfx.obj;
@@ -395,6 +399,7 @@
+
@@ -418,6 +423,9 @@
+
+
+
@@ -426,14 +434,26 @@
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/Src/views/Test/TestViews.vcxproj.filters b/Src/views/Test/TestViews.vcxproj.filters
index 91b1a14379..a0fda65a23 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
+
diff --git a/Src/views/Test/TestVwRootBox.h b/Src/views/Test/TestVwRootBox.h
index 71c16d46d5..c4ba1d66c8 100644
--- a/Src/views/Test/TestVwRootBox.h
+++ b/Src/views/Test/TestVwRootBox.h
@@ -1237,6 +1237,674 @@ namespace TestViews
qrootb->Close();
}
+ void testSetRootObjectReconstructsConstructedView()
+ {
+ class SwitchingRootVc : public DummyBaseVc
+ {
+ public:
+ STDMETHOD(Display)(IVwEnv * pvwenv, HVO hvo, int frag)
+ {
+ switch (frag)
+ {
+ case 1:
+ pvwenv->OpenDiv();
+ pvwenv->OpenParagraph();
+ pvwenv->AddStringProp(kflidStTxtPara_Contents, NULL);
+ pvwenv->CloseParagraph();
+ pvwenv->CloseDiv();
+ break;
+ case 2:
+ pvwenv->OpenDiv();
+ pvwenv->OpenParagraph();
+ pvwenv->AddStringProp(kflidStTxtPara_Contents, NULL);
+ pvwenv->CloseParagraph();
+ pvwenv->OpenParagraph();
+ pvwenv->AddStringProp(kflidStTxtPara_Contents, NULL);
+ pvwenv->CloseParagraph();
+ pvwenv->CloseDiv();
+ break;
+ default:
+ return E_INVALIDARG;
+ }
+ return S_OK;
+ }
+ };
+
+ ITsStrFactoryPtr qtsf;
+ qtsf.CreateInstance(CLSID_TsStrFactory);
+ IVwCacheDaPtr qcda;
+ qcda.CreateInstance(CLSID_VwCacheDa);
+ qcda->putref_TsStrFactory(qtsf);
+ ISilDataAccessPtr qsda;
+ CheckHr(qcda->QueryInterface(IID_ISilDataAccess, (void **)&qsda));
+ CheckHr(qsda->putref_WritingSystemFactory(g_qwsf));
+
+ IRenderEngineFactoryPtr qref;
+ qref.Attach(NewObj MockRenderEngineFactory);
+
+ ITsStringPtr qtss;
+ StrUni stuPara(L"Short paragraph text");
+ CheckHr(qtsf->MakeString(stuPara.Bstr(), g_wsEng, &qtss));
+ HVO hvoPara = 1;
+ CheckHr(qcda->CacheStringProp(hvoPara, kflidStTxtPara_Contents, qtss));
+
+ IVwRootBoxPtr qrootb;
+ VwRootBox::CreateCom(NULL, IID_IVwRootBox, (void **)&qrootb);
+ IVwGraphicsWin32Ptr qvg32;
+ HDC hdc = 0;
+ try
+ {
+ qvg32.CreateInstance(CLSID_VwGraphicsWin32);
+ hdc = GetTestDC();
+ CheckHr(qvg32->Initialize(hdc));
+
+ IVwViewConstructorPtr qvc;
+ qvc.Attach(NewObj SwitchingRootVc());
+ CheckHr(qrootb->putref_DataAccess(qsda));
+ CheckHr(qrootb->putref_RenderEngineFactory(qref));
+ CheckHr(qrootb->putref_TsStrFactory(qtsf));
+ CheckHr(qrootb->SetRootObject(hvoPara, qvc, 1, NULL));
+
+ DummyRootSitePtr qdrs;
+ qdrs.Attach(NewObj DummyRootSite());
+ Rect rcSrc(0, 0, 96, 96);
+ qdrs->SetRects(rcSrc, rcSrc);
+ qdrs->SetGraphics(qvg32);
+ CheckHr(qrootb->SetSite(qdrs));
+
+ CheckHr(qrootb->Layout(qvg32, 300));
+ int dySinglePara = 0;
+ CheckHr(qrootb->get_Height(&dySinglePara));
+ unitpp::assert_true("single-paragraph height should be positive", dySinglePara > 0);
+
+ ComBool fNeedsReconstruct = true;
+ CheckHr(qrootb->get_NeedsReconstruct(&fNeedsReconstruct));
+ unitpp::assert_false("layout should clear reconstruct flag", fNeedsReconstruct);
+
+ // This is the PATH-R1 regression: switching fragments on an already-constructed root
+ // must rebuild immediately even when the caller does not issue another explicit Layout().
+ CheckHr(qrootb->SetRootObject(hvoPara, qvc, 2, NULL));
+
+ int dyTwoParas = 0;
+ CheckHr(qrootb->get_Height(&dyTwoParas));
+ VwRootBox * prootb = dynamic_cast(qrootb.Ptr());
+ VwDivBox * pdivRoot = dynamic_cast(prootb);
+ unitpp::assert_true("root should be a div box", pdivRoot != NULL);
+ VwDivBox * pdivInner = dynamic_cast(pdivRoot->FirstBox());
+ unitpp::assert_true("root should contain an inner div after fragment change", pdivInner != NULL);
+ unitpp::assert_true("fragment change should produce a second paragraph",
+ pdivInner->FirstBox() != NULL && pdivInner->FirstBox()->Next() != NULL);
+ unitpp::assert_true("changing the root fragment should rebuild the view immediately",
+ dyTwoParas > dySinglePara);
+
+ CheckHr(qrootb->get_NeedsReconstruct(&fNeedsReconstruct));
+ unitpp::assert_false("successful reconstruct should clear reconstruct flag", fNeedsReconstruct);
+ }
+ catch(...)
+ {
+ if (qvg32)
+ qvg32->ReleaseDC();
+ if (hdc != 0)
+ ReleaseTestDC(hdc);
+ qrootb->Close();
+ throw;
+ }
+
+ qvg32->ReleaseDC();
+ ReleaseTestDC(hdc);
+ qrootb->Close();
+ }
+
+ void testPropChangedDirtiesConstructedView()
+ {
+ class SimpleParagraphVc : public DummyBaseVc
+ {
+ public:
+ STDMETHOD(Display)(IVwEnv * pvwenv, HVO hvo, int frag)
+ {
+ pvwenv->OpenDiv();
+ pvwenv->OpenParagraph();
+ pvwenv->AddStringProp(kflidStTxtPara_Contents, NULL);
+ pvwenv->CloseParagraph();
+ pvwenv->CloseDiv();
+ return S_OK;
+ }
+ };
+
+ ITsStrFactoryPtr qtsf;
+ qtsf.CreateInstance(CLSID_TsStrFactory);
+ IVwCacheDaPtr qcda;
+ qcda.CreateInstance(CLSID_VwCacheDa);
+ qcda->putref_TsStrFactory(qtsf);
+ ISilDataAccessPtr qsda;
+ CheckHr(qcda->QueryInterface(IID_ISilDataAccess, (void **)&qsda));
+ CheckHr(qsda->putref_WritingSystemFactory(g_qwsf));
+
+ ITsStringPtr qtss;
+ StrUni stuPara(L"Paragraph before PropChanged");
+ CheckHr(qtsf->MakeString(stuPara.Bstr(), g_wsEng, &qtss));
+ HVO hvoPara = 1;
+ CheckHr(qcda->CacheStringProp(hvoPara, kflidStTxtPara_Contents, qtss));
+
+ IRenderEngineFactoryPtr qref;
+ qref.Attach(NewObj MockRenderEngineFactory);
+
+ IVwRootBoxPtr qrootb;
+ VwRootBox::CreateCom(NULL, IID_IVwRootBox, (void **)&qrootb);
+ IVwGraphicsWin32Ptr qvg32;
+ HDC hdc = 0;
+ try
+ {
+ qvg32.CreateInstance(CLSID_VwGraphicsWin32);
+ hdc = GetTestDC();
+ CheckHr(qvg32->Initialize(hdc));
+
+ IVwViewConstructorPtr qvc;
+ qvc.Attach(NewObj SimpleParagraphVc());
+ CheckHr(qrootb->putref_DataAccess(qsda));
+ CheckHr(qrootb->putref_RenderEngineFactory(qref));
+ CheckHr(qrootb->putref_TsStrFactory(qtsf));
+ CheckHr(qrootb->SetRootObject(hvoPara, qvc, 1, NULL));
+
+ DummyRootSitePtr qdrs;
+ qdrs.Attach(NewObj DummyRootSite());
+ Rect rcSrc(0, 0, 96, 96);
+ qdrs->SetRects(rcSrc, rcSrc);
+ qdrs->SetGraphics(qvg32);
+ CheckHr(qrootb->SetSite(qdrs));
+
+ CheckHr(qrootb->Layout(qvg32, 300));
+ ComBool fNeedsReconstruct = true;
+ CheckHr(qrootb->get_NeedsReconstruct(&fNeedsReconstruct));
+ unitpp::assert_false("layout should clear reconstruct flag before PropChanged", fNeedsReconstruct);
+
+ StrUni stuUpdated(L"Paragraph after PropChanged");
+ CheckHr(qtsf->MakeString(stuUpdated.Bstr(), g_wsEng, &qtss));
+ CheckHr(qcda->CacheStringProp(hvoPara, kflidStTxtPara_Contents, qtss));
+ CheckHr(qrootb->PropChanged(hvoPara, kflidStTxtPara_Contents, 0, 0, 0));
+
+ CheckHr(qrootb->get_NeedsReconstruct(&fNeedsReconstruct));
+ unitpp::assert_true("PropChanged should dirty reconstruct state on a constructed view", fNeedsReconstruct);
+ }
+ catch(...)
+ {
+ if (qvg32)
+ qvg32->ReleaseDC();
+ if (hdc != 0)
+ ReleaseTestDC(hdc);
+ qrootb->Close();
+ throw;
+ }
+
+ qvg32->ReleaseDC();
+ ReleaseTestDC(hdc);
+ qrootb->Close();
+ }
+
+ void testPutrefOverlayRelayoutsWithoutDirtyingConstructedView()
+ {
+ class TaggedParagraphVc : public DummyBaseVc
+ {
+ public:
+ STDMETHOD(Display)(IVwEnv * pvwenv, HVO hvo, int frag)
+ {
+ pvwenv->OpenDiv();
+ pvwenv->OpenMappedTaggedPara();
+ pvwenv->AddStringProp(kflidStTxtPara_Contents, NULL);
+ pvwenv->CloseParagraph();
+ pvwenv->CloseDiv();
+ return S_OK;
+ }
+ };
+
+ class OverlayAwareRootSite : public DummyRootSite
+ {
+ public:
+ int m_cOverlayChanges;
+
+ OverlayAwareRootSite()
+ : m_cOverlayChanges(0)
+ {
+ }
+
+ STDMETHOD(OverlayChanged)(IVwRootBox * prootb, IVwOverlay * pvo)
+ {
+ ++m_cOverlayChanges;
+ return S_OK;
+ }
+ };
+
+ ITsStrFactoryPtr qtsf;
+ qtsf.CreateInstance(CLSID_TsStrFactory);
+ IVwCacheDaPtr qcda;
+ qcda.CreateInstance(CLSID_VwCacheDa);
+ qcda->putref_TsStrFactory(qtsf);
+ ISilDataAccessPtr qsda;
+ CheckHr(qcda->QueryInterface(IID_ISilDataAccess, (void **)&qsda));
+ CheckHr(qsda->putref_WritingSystemFactory(g_qwsf));
+
+ IRenderEngineFactoryPtr qref;
+ qref.Attach(NewObj MockRenderEngineFactory);
+
+ ITsStringPtr qtss;
+ StrUni stuPara(L"Tagged paragraph text");
+ CheckHr(qtsf->MakeString(stuPara.Bstr(), g_wsEng, &qtss));
+ HVO hvoPara = 1;
+ CheckHr(qcda->CacheStringProp(hvoPara, kflidStTxtPara_Contents, qtss));
+
+ IVwRootBoxPtr qrootb;
+ VwRootBox::CreateCom(NULL, IID_IVwRootBox, (void **)&qrootb);
+ IVwGraphicsWin32Ptr qvg32;
+ HDC hdc = 0;
+ try
+ {
+ qvg32.CreateInstance(CLSID_VwGraphicsWin32);
+ hdc = GetTestDC();
+ CheckHr(qvg32->Initialize(hdc));
+
+ IVwViewConstructorPtr qvc;
+ qvc.Attach(NewObj TaggedParagraphVc());
+ CheckHr(qrootb->putref_DataAccess(qsda));
+ CheckHr(qrootb->putref_RenderEngineFactory(qref));
+ CheckHr(qrootb->putref_TsStrFactory(qtsf));
+ CheckHr(qrootb->SetRootObject(hvoPara, qvc, 1, NULL));
+
+ OverlayAwareRootSite * pdrs = NewObj OverlayAwareRootSite();
+ DummyRootSitePtr qdrs;
+ qdrs.Attach(pdrs);
+ Rect rcSrc(0, 0, 96, 96);
+ qdrs->SetRects(rcSrc, rcSrc);
+ qdrs->SetGraphics(qvg32);
+ CheckHr(qrootb->SetSite(qdrs));
+
+ CheckHr(qrootb->Layout(qvg32, 300));
+ ComBool fNeedsReconstruct = true;
+ CheckHr(qrootb->get_NeedsReconstruct(&fNeedsReconstruct));
+ unitpp::assert_false("layout should clear reconstruct flag before overlay changes", fNeedsReconstruct);
+
+ IVwOverlayPtr qvo;
+ VwOverlay::CreateCom(NULL, IID_IVwOverlay, (void **)&qvo);
+
+ CheckHr(qrootb->putref_Overlay(qvo));
+
+ IVwOverlayPtr qvoRoundTrip;
+ CheckHr(qrootb->get_Overlay(&qvoRoundTrip));
+ unitpp::assert_eq("root box should retain the installed overlay", qvo.Ptr(), qvoRoundTrip.Ptr());
+ unitpp::assert_eq("site should be notified of overlay changes", 1, pdrs->m_cOverlayChanges);
+
+ CheckHr(qrootb->get_NeedsReconstruct(&fNeedsReconstruct));
+ unitpp::assert_false("putref_Overlay should remain on the relayout-only path", fNeedsReconstruct);
+ }
+ catch(...)
+ {
+ if (qvg32)
+ qvg32->ReleaseDC();
+ if (hdc != 0)
+ ReleaseTestDC(hdc);
+ qrootb->Close();
+ throw;
+ }
+
+ qvg32->ReleaseDC();
+ ReleaseTestDC(hdc);
+ qrootb->Close();
+ }
+
+ void testPutrefDataAccessDoesNotImplicitlyDirtyConstructedView()
+ {
+ class SimpleParagraphVc : public DummyBaseVc
+ {
+ public:
+ STDMETHOD(Display)(IVwEnv * pvwenv, HVO hvo, int frag)
+ {
+ pvwenv->OpenDiv();
+ pvwenv->OpenParagraph();
+ pvwenv->AddStringProp(kflidStTxtPara_Contents, NULL);
+ pvwenv->CloseParagraph();
+ pvwenv->CloseDiv();
+ return S_OK;
+ }
+ };
+
+ ITsStrFactoryPtr qtsf;
+ qtsf.CreateInstance(CLSID_TsStrFactory);
+ IVwCacheDaPtr qcda1;
+ qcda1.CreateInstance(CLSID_VwCacheDa);
+ qcda1->putref_TsStrFactory(qtsf);
+ ISilDataAccessPtr qsda1;
+ CheckHr(qcda1->QueryInterface(IID_ISilDataAccess, (void **)&qsda1));
+ CheckHr(qsda1->putref_WritingSystemFactory(g_qwsf));
+
+ IVwCacheDaPtr qcda2;
+ qcda2.CreateInstance(CLSID_VwCacheDa);
+ qcda2->putref_TsStrFactory(qtsf);
+ ISilDataAccessPtr qsda2;
+ CheckHr(qcda2->QueryInterface(IID_ISilDataAccess, (void **)&qsda2));
+ CheckHr(qsda2->putref_WritingSystemFactory(g_qwsf));
+
+ ITsStringPtr qtss;
+ StrUni stuPara(L"DataAccess baseline text");
+ CheckHr(qtsf->MakeString(stuPara.Bstr(), g_wsEng, &qtss));
+ HVO hvoPara = 1;
+ CheckHr(qcda1->CacheStringProp(hvoPara, kflidStTxtPara_Contents, qtss));
+ CheckHr(qcda2->CacheStringProp(hvoPara, kflidStTxtPara_Contents, qtss));
+
+ IRenderEngineFactoryPtr qref;
+ qref.Attach(NewObj MockRenderEngineFactory);
+
+ IVwRootBoxPtr qrootb;
+ VwRootBox::CreateCom(NULL, IID_IVwRootBox, (void **)&qrootb);
+ IVwGraphicsWin32Ptr qvg32;
+ HDC hdc = 0;
+ try
+ {
+ qvg32.CreateInstance(CLSID_VwGraphicsWin32);
+ hdc = GetTestDC();
+ CheckHr(qvg32->Initialize(hdc));
+
+ IVwViewConstructorPtr qvc;
+ qvc.Attach(NewObj SimpleParagraphVc());
+ CheckHr(qrootb->putref_DataAccess(qsda1));
+ CheckHr(qrootb->putref_RenderEngineFactory(qref));
+ CheckHr(qrootb->putref_TsStrFactory(qtsf));
+ CheckHr(qrootb->SetRootObject(hvoPara, qvc, 1, NULL));
+
+ DummyRootSitePtr qdrs;
+ qdrs.Attach(NewObj DummyRootSite());
+ Rect rcSrc(0, 0, 96, 96);
+ qdrs->SetRects(rcSrc, rcSrc);
+ qdrs->SetGraphics(qvg32);
+ CheckHr(qrootb->SetSite(qdrs));
+
+ CheckHr(qrootb->Layout(qvg32, 300));
+ ComBool fNeedsReconstruct = true;
+ CheckHr(qrootb->get_NeedsReconstruct(&fNeedsReconstruct));
+ unitpp::assert_false("layout should clear reconstruct flag before DataAccess swaps", fNeedsReconstruct);
+
+ CheckHr(qrootb->putref_DataAccess(qsda2));
+
+ CheckHr(qrootb->get_NeedsReconstruct(&fNeedsReconstruct));
+ unitpp::assert_false("putref_DataAccess should remain a cheap wiring operation", fNeedsReconstruct);
+ }
+ catch(...)
+ {
+ if (qvg32)
+ qvg32->ReleaseDC();
+ if (hdc != 0)
+ ReleaseTestDC(hdc);
+ qrootb->Close();
+ throw;
+ }
+
+ qvg32->ReleaseDC();
+ ReleaseTestDC(hdc);
+ qrootb->Close();
+ }
+
+ void testStylesheetChangeDirtiesConstructedView()
+ {
+ class SimpleParagraphVc : public DummyBaseVc
+ {
+ public:
+ STDMETHOD(Display)(IVwEnv * pvwenv, HVO hvo, int frag)
+ {
+ pvwenv->OpenDiv();
+ pvwenv->OpenParagraph();
+ pvwenv->AddStringProp(kflidStTxtPara_Contents, NULL);
+ pvwenv->CloseParagraph();
+ pvwenv->CloseDiv();
+ return S_OK;
+ }
+ };
+
+ ITsStrFactoryPtr qtsf;
+ qtsf.CreateInstance(CLSID_TsStrFactory);
+ IVwCacheDaPtr qcda;
+ qcda.CreateInstance(CLSID_VwCacheDa);
+ qcda->putref_TsStrFactory(qtsf);
+ ISilDataAccessPtr qsda;
+ CheckHr(qcda->QueryInterface(IID_ISilDataAccess, (void **)&qsda));
+ CheckHr(qsda->putref_WritingSystemFactory(g_qwsf));
+
+ ITsStringPtr qtss;
+ StrUni stuPara(L"Styled paragraph text");
+ CheckHr(qtsf->MakeString(stuPara.Bstr(), g_wsEng, &qtss));
+ HVO hvoPara = 1;
+ CheckHr(qcda->CacheStringProp(hvoPara, kflidStTxtPara_Contents, qtss));
+
+ IRenderEngineFactoryPtr qref;
+ qref.Attach(NewObj MockRenderEngineFactory);
+
+ IVwStylesheetPtr qss;
+ VwStylesheet::CreateCom(NULL, IID_IVwStylesheet, (void **)&qss);
+
+ IVwRootBoxPtr qrootb;
+ VwRootBox::CreateCom(NULL, IID_IVwRootBox, (void **)&qrootb);
+ IVwGraphicsWin32Ptr qvg32;
+ HDC hdc = 0;
+ try
+ {
+ qvg32.CreateInstance(CLSID_VwGraphicsWin32);
+ hdc = GetTestDC();
+ CheckHr(qvg32->Initialize(hdc));
+
+ IVwViewConstructorPtr qvc;
+ qvc.Attach(NewObj SimpleParagraphVc());
+ CheckHr(qrootb->putref_DataAccess(qsda));
+ CheckHr(qrootb->putref_RenderEngineFactory(qref));
+ CheckHr(qrootb->putref_TsStrFactory(qtsf));
+ CheckHr(qrootb->SetRootObject(hvoPara, qvc, 1, qss));
+
+ DummyRootSitePtr qdrs;
+ qdrs.Attach(NewObj DummyRootSite());
+ Rect rcSrc(0, 0, 96, 96);
+ qdrs->SetRects(rcSrc, rcSrc);
+ qdrs->SetGraphics(qvg32);
+ CheckHr(qrootb->SetSite(qdrs));
+
+ CheckHr(qrootb->Layout(qvg32, 300));
+ ComBool fNeedsReconstruct = true;
+ CheckHr(qrootb->get_NeedsReconstruct(&fNeedsReconstruct));
+ unitpp::assert_false("layout should clear reconstruct flag before stylesheet changes", fNeedsReconstruct);
+
+ CheckHr(qrootb->OnStylesheetChange());
+
+ CheckHr(qrootb->get_NeedsReconstruct(&fNeedsReconstruct));
+ unitpp::assert_true("stylesheet changes should dirty reconstruct state even after relayout", fNeedsReconstruct);
+ }
+ catch(...)
+ {
+ if (qvg32)
+ qvg32->ReleaseDC();
+ if (hdc != 0)
+ ReleaseTestDC(hdc);
+ qrootb->Close();
+ throw;
+ }
+
+ qvg32->ReleaseDC();
+ ReleaseTestDC(hdc);
+ qrootb->Close();
+ }
+
+ void testLayoutUpdatesCachedDpiWhenWidthIsUnchanged()
+ {
+ class SimpleParagraphVc : public DummyBaseVc
+ {
+ public:
+ STDMETHOD(Display)(IVwEnv * pvwenv, HVO hvo, int frag)
+ {
+ pvwenv->OpenDiv();
+ pvwenv->OpenParagraph();
+ pvwenv->AddStringProp(kflidStTxtPara_Contents, NULL);
+ pvwenv->CloseParagraph();
+ pvwenv->CloseDiv();
+ return S_OK;
+ }
+ };
+
+ ITsStrFactoryPtr qtsf;
+ qtsf.CreateInstance(CLSID_TsStrFactory);
+ IVwCacheDaPtr qcda;
+ qcda.CreateInstance(CLSID_VwCacheDa);
+ qcda->putref_TsStrFactory(qtsf);
+ ISilDataAccessPtr qsda;
+ CheckHr(qcda->QueryInterface(IID_ISilDataAccess, (void **)&qsda));
+ CheckHr(qsda->putref_WritingSystemFactory(g_qwsf));
+
+ ITsStringPtr qtss;
+ StrUni stuPara(L"Paragraph for DPI cache regression");
+ CheckHr(qtsf->MakeString(stuPara.Bstr(), g_wsEng, &qtss));
+ HVO hvoPara = 1;
+ CheckHr(qcda->CacheStringProp(hvoPara, kflidStTxtPara_Contents, qtss));
+
+ IRenderEngineFactoryPtr qref;
+ qref.Attach(NewObj MockRenderEngineFactory);
+
+ IVwRootBoxPtr qrootb;
+ VwRootBox::CreateCom(NULL, IID_IVwRootBox, (void **)&qrootb);
+ IVwGraphicsWin32Ptr qvg32;
+ HDC hdc = 0;
+ try
+ {
+ qvg32.CreateInstance(CLSID_VwGraphicsWin32);
+ hdc = GetTestDC();
+ CheckHr(qvg32->Initialize(hdc));
+
+ IVwViewConstructorPtr qvc;
+ qvc.Attach(NewObj SimpleParagraphVc());
+ CheckHr(qrootb->putref_DataAccess(qsda));
+ CheckHr(qrootb->putref_RenderEngineFactory(qref));
+ CheckHr(qrootb->putref_TsStrFactory(qtsf));
+ CheckHr(qrootb->SetRootObject(hvoPara, qvc, 1, NULL));
+
+ DummyRootSitePtr qdrs;
+ qdrs.Attach(NewObj DummyRootSite());
+ Rect rcSrc(0, 0, 96, 96);
+ qdrs->SetRects(rcSrc, rcSrc);
+ qdrs->SetGraphics(qvg32);
+ CheckHr(qrootb->SetSite(qdrs));
+
+ CheckHr(qvg32->put_XUnitsPerInch(96));
+ CheckHr(qvg32->put_YUnitsPerInch(96));
+ CheckHr(qrootb->Layout(qvg32, 300));
+
+ VwRootBox * prootb = dynamic_cast(qrootb.Ptr());
+ unitpp::assert_true("expected concrete VwRootBox", prootb != NULL);
+ unitpp::assert_eq("initial layout should cache the starting X DPI", 96, prootb->DpiSrc().x);
+ unitpp::assert_eq("initial layout should cache the starting Y DPI", 96, prootb->DpiSrc().y);
+
+ CheckHr(qvg32->put_XUnitsPerInch(144));
+ CheckHr(qvg32->put_YUnitsPerInch(144));
+ CheckHr(qrootb->Layout(qvg32, 300));
+
+ unitpp::assert_eq("same-width layout should still refresh cached X DPI", 144, prootb->DpiSrc().x);
+ unitpp::assert_eq("same-width layout should still refresh cached Y DPI", 144, prootb->DpiSrc().y);
+ }
+ catch(...)
+ {
+ if (qvg32)
+ qvg32->ReleaseDC();
+ if (hdc != 0)
+ ReleaseTestDC(hdc);
+ qrootb->Close();
+ throw;
+ }
+
+ qvg32->ReleaseDC();
+ ReleaseTestDC(hdc);
+ qrootb->Close();
+ }
+
+ void testLayoutWithChangedWidthRelayoutsWithoutDirtyingConstructedView()
+ {
+ class SimpleParagraphVc : public DummyBaseVc
+ {
+ public:
+ STDMETHOD(Display)(IVwEnv * pvwenv, HVO hvo, int frag)
+ {
+ pvwenv->OpenDiv();
+ pvwenv->OpenParagraph();
+ pvwenv->AddStringProp(kflidStTxtPara_Contents, NULL);
+ pvwenv->CloseParagraph();
+ pvwenv->CloseDiv();
+ return S_OK;
+ }
+ };
+
+ ITsStrFactoryPtr qtsf;
+ qtsf.CreateInstance(CLSID_TsStrFactory);
+ IVwCacheDaPtr qcda;
+ qcda.CreateInstance(CLSID_VwCacheDa);
+ qcda->putref_TsStrFactory(qtsf);
+ ISilDataAccessPtr qsda;
+ CheckHr(qcda->QueryInterface(IID_ISilDataAccess, (void **)&qsda));
+ CheckHr(qsda->putref_WritingSystemFactory(g_qwsf));
+
+ ITsStringPtr qtss;
+ StrUni stuPara(L"A paragraph with enough repeated words to wrap differently when the available width shrinks substantially in a relayout-only scenario.");
+ CheckHr(qtsf->MakeString(stuPara.Bstr(), g_wsEng, &qtss));
+ HVO hvoPara = 1;
+ CheckHr(qcda->CacheStringProp(hvoPara, kflidStTxtPara_Contents, qtss));
+
+ IRenderEngineFactoryPtr qref;
+ qref.Attach(NewObj MockRenderEngineFactory);
+
+ IVwRootBoxPtr qrootb;
+ VwRootBox::CreateCom(NULL, IID_IVwRootBox, (void **)&qrootb);
+ IVwGraphicsWin32Ptr qvg32;
+ HDC hdc = 0;
+ try
+ {
+ qvg32.CreateInstance(CLSID_VwGraphicsWin32);
+ hdc = GetTestDC();
+ CheckHr(qvg32->Initialize(hdc));
+
+ IVwViewConstructorPtr qvc;
+ qvc.Attach(NewObj SimpleParagraphVc());
+ CheckHr(qrootb->putref_DataAccess(qsda));
+ CheckHr(qrootb->putref_RenderEngineFactory(qref));
+ CheckHr(qrootb->putref_TsStrFactory(qtsf));
+ CheckHr(qrootb->SetRootObject(hvoPara, qvc, 1, NULL));
+
+ DummyRootSitePtr qdrs;
+ qdrs.Attach(NewObj DummyRootSite());
+ Rect rcSrc(0, 0, 96, 96);
+ qdrs->SetRects(rcSrc, rcSrc);
+ qdrs->SetGraphics(qvg32);
+ CheckHr(qrootb->SetSite(qdrs));
+
+ CheckHr(qrootb->Layout(qvg32, 300));
+ int dyWide = 0;
+ CheckHr(qrootb->get_Height(&dyWide));
+
+ ComBool fNeedsReconstruct = true;
+ CheckHr(qrootb->get_NeedsReconstruct(&fNeedsReconstruct));
+ unitpp::assert_false("layout should clear reconstruct flag before width-driven relayout", fNeedsReconstruct);
+
+ CheckHr(qrootb->Layout(qvg32, 120));
+ int dyNarrow = 0;
+ CheckHr(qrootb->get_Height(&dyNarrow));
+
+ unitpp::assert_true("narrower layout should relayout to a taller height", dyNarrow > dyWide);
+ CheckHr(qrootb->get_NeedsReconstruct(&fNeedsReconstruct));
+ unitpp::assert_false("width-driven relayout should stay on the relayout-only path", fNeedsReconstruct);
+ }
+ catch(...)
+ {
+ if (qvg32)
+ qvg32->ReleaseDC();
+ if (hdc != 0)
+ ReleaseTestDC(hdc);
+ qrootb->Close();
+ throw;
+ }
+
+ qvg32->ReleaseDC();
+ ReleaseTestDC(hdc);
+ qrootb->Close();
+ }
+
public:
TestVwRootBox();
diff --git a/Src/views/Views.idh b/Src/views/Views.idh
index b052958cc9..d4a07d57df 100644
--- a/Src/views/Views.idh
+++ b/Src/views/Views.idh
@@ -1712,6 +1712,14 @@ Last reviewed:
// Pass in the repository that will be used to get spell-checkers.
HRESULT SetSpellingRepository(
[in] IGetSpellChecker * pgsp);
+
+ // PATH-L5: Tells whether this root box needs a Reconstruct.
+ // When true, data or structural changes have occurred since the last
+ // Reconstruct completed. Managed callers (e.g. SimpleRootSite.RefreshDisplay)
+ // can check this to skip selection save/restore and drawing suspension
+ // overhead when no reconstruction is actually needed.
+ [propget] HRESULT NeedsReconstruct(
+ [out, retval] ComBool * pfNeeds);
}
#ifndef NO_COCLASSES
diff --git a/Src/views/Views.mak b/Src/views/Views.mak
index fd633d1513..556a8936e4 100644
--- a/Src/views/Views.mak
+++ b/Src/views/Views.mak
@@ -61,6 +61,8 @@ OBJ_VIEWS=\
$(INT_DIR)\autopch\VwSelection.obj\
$(INT_DIR)\autopch\VwTableBox.obj\
$(INT_DIR)\autopch\VwGraphics.obj\
+ $(INT_DIR)\autopch\FontHandleCache.obj\
+ $(INT_DIR)\autopch\ColorStateCache.obj\
$(INT_DIR)\autopch\VwTxtSrc.obj\
$(INT_DIR)\autopch\ModuleEntry.obj\
$(INT_DIR)\autopch\AfColorTable.obj\
diff --git a/Src/views/ViewsGlobals.cpp b/Src/views/ViewsGlobals.cpp
index e657887355..da41fddf93 100644
--- a/Src/views/ViewsGlobals.cpp
+++ b/Src/views/ViewsGlobals.cpp
@@ -12,6 +12,11 @@ Last reviewed:
-------------------------------------------------------------------------------*//*:End Ignore*/
#include "Main.h"
+#include "VwRenderTrace.h"
+
+#ifdef TRACING_RENDER
+FILE * g_fpRenderTrace = NULL;
+#endif
// Nothing should directly reference this.
static ViewsGlobals g_views;
diff --git a/Src/views/VwLayoutStream.cpp b/Src/views/VwLayoutStream.cpp
index fdb43dedb5..b26b24653a 100644
--- a/Src/views/VwLayoutStream.cpp
+++ b/Src/views/VwLayoutStream.cpp
@@ -29,11 +29,13 @@ DEFINE_THIS_FILE
VwLayoutStream::VwLayoutStream(VwPropertyStore * pzvps)
: VwRootBox(pzvps)
{
+ m_dxsLayoutWidth = -1;
}
// Protected default constructor used for CreateCom
VwLayoutStream::VwLayoutStream() : VwRootBox()
{
+ m_dxsLayoutWidth = -1;
}
@@ -42,15 +44,17 @@ VwLayoutStream::~VwLayoutStream()
}
/*----------------------------------------------------------------------------------------------
- Ensure that the view is constructed and laid out at our specified width (TODO: and DPI).
+ Ensure that the view is constructed and laid out at our specified width and source DPI.
----------------------------------------------------------------------------------------------*/
void VwLayoutStream::ConstructAndLayout(IVwGraphics * pvg, int dxsAvailWidth)
{
// Todo: Layout should delete all pages if the width changed.
if (!m_fConstructed)
Construct(pvg, dxsAvailWidth); // Does NOT lay out to this width.
- // Todo: also save and check the dpi of the VwGraphics. Layout if changed.
- if (m_dxsLayoutWidth != dxsAvailWidth)
+ int dpiX, dpiY;
+ CheckHr(pvg->get_XUnitsPerInch(&dpiX));
+ CheckHr(pvg->get_YUnitsPerInch(&dpiY));
+ if (m_dxsLayoutWidth != dxsAvailWidth || m_ptDpiSrc.x != dpiX || m_ptDpiSrc.y != dpiY)
Layout(pvg, dxsAvailWidth);
}
diff --git a/Src/views/VwPropertyStore.cpp b/Src/views/VwPropertyStore.cpp
index 20a047f005..40e74a9107 100644
--- a/Src/views/VwPropertyStore.cpp
+++ b/Src/views/VwPropertyStore.cpp
@@ -43,6 +43,25 @@ static int g_rgnFontSizes[] = {
// kvfsSmaller and kvfsLarger don't have absolute values
static int knDefaultFontSize = 10000; // 10 point default
+// Returns the lfQuality value to use for LOGFONT creation.
+// If the FW_FONT_QUALITY env var is set to a valid value (0-6), that value is used.
+// This allows tests to force ANTIALIASED_QUALITY (4) for deterministic rendering.
+static BYTE GetFontQualityOverride()
+{
+ static BYTE s_quality = []() -> BYTE {
+ wchar_t buf[16] = {};
+ DWORD len = ::GetEnvironmentVariableW(L"FW_FONT_QUALITY", buf, _countof(buf));
+ if (len > 0 && len < _countof(buf))
+ {
+ int val = _wtoi(buf);
+ if (val >= 0 && val <= 6)
+ return static_cast(val);
+ }
+ return DRAFT_QUALITY;
+ }();
+ return s_quality;
+}
+
// The order of these is signficant--it is the order the font properties are recorded in
// for each writing system, in the wsStyle string.
// A copy of this list is in VwPropertyStore.cpp -- the two lists must be kept in sync.
@@ -358,7 +377,7 @@ int VwPropertyStore::AdjustedLineHeight(VwPropertyStore * pzvpsLeaf, int * pdymp
lf.lfCharSet = DEFAULT_CHARSET; // let name determine it; WS should specify valid
lf.lfOutPrecision = OUT_TT_ONLY_PRECIS; // only work with TrueType fonts
lf.lfClipPrecision = CLIP_DEFAULT_PRECIS; // ??
- lf.lfQuality = DRAFT_QUALITY; // I (JohnT) don't think this matters for TrueType fonts.
+ lf.lfQuality = GetFontQualityOverride();
lf.lfPitchAndFamily = 0; // must be zero for EnumFontFamiliesEx
wcscpy_s(lf.lfFaceName, LF_FACESIZE, pchrp->szFaceName);
qzvpsWithWsAndFont->Unlock();
@@ -513,16 +532,28 @@ void VwPropertyStore::InitChrp()
ILgWritingSystemPtr qws;
if (!m_chrp.ws)
CheckHr(m_qwsf->get_UserWs(&m_chrp.ws)); // Get default writing system id.
- Assert(m_chrp.ws);
- CheckHr(m_qwsf->get_EngineOrNull(m_chrp.ws, &qws));
- AssertPtr(qws);
- ComBool fRtl;
- CheckHr(qws->get_RightToLeftScript(&fRtl));
- m_chrp.fWsRtl = (bool)fRtl;
- m_chrp.nDirDepth = (fRtl) ? 1 : 0;
+ // A ws of 0 can arrive during PropChanged-driven box rebuilds when
+ // a view constructor emits runs without an explicit writing system,
+ // or when get_UserWs fails to provide a valid ws.
+ // get_EngineOrNull may also return null for an unknown ws.
+ if (m_chrp.ws)
+ CheckHr(m_qwsf->get_EngineOrNull(m_chrp.ws, &qws));
+ AssertPtrN(qws); // null is recoverable — default to LTR below
+ ComBool fRtl = false; // default LTR for unknown/zero ws
+ if (qws)
+ {
+ CheckHr(qws->get_RightToLeftScript(&fRtl));
+ m_chrp.fWsRtl = (bool)fRtl;
+ m_chrp.nDirDepth = (fRtl) ? 1 : 0;
- // Interpret any magic font names.
- CheckHr(qws->InterpretChrp(&m_chrp));
+ // Interpret any magic font names.
+ CheckHr(qws->InterpretChrp(&m_chrp));
+ }
+ else
+ {
+ m_chrp.fWsRtl = false;
+ m_chrp.nDirDepth = 0;
+ }
// Other fields in m_chrp have the exact same meaning as the corresponding property,
// and are already used to store it.
@@ -1328,14 +1359,18 @@ STDMETHODIMP VwPropertyStore::put_IntProperty(int tpt, int xpv, int nValue)
if (m_chrp.ws != nValue)
{
m_chrp.ws = nValue;
- Assert(m_chrp.ws);
+ // A ws of 0 can arrive during PropChanged-driven box rebuilds when
+ // a view constructor emits runs without an explicit writing system.
+ // This is recoverable — we default to LTR below.
// Recompute m_chrp.fRtl and m_chrp.nDirDepth
EnsureWritingSystemFactory();
ILgWritingSystemPtr qws;
- CheckHr(m_qwsf->get_EngineOrNull(m_chrp.ws, &qws));
- AssertPtr(qws);
- ComBool fRtl;
- if (qws) // If by some chance we're trying to use an unknown WS, default to LTR.
+ if (m_chrp.ws)
+ CheckHr(m_qwsf->get_EngineOrNull(m_chrp.ws, &qws));
+ // An unknown or zero ws yields a null engine; default to LTR.
+ AssertPtrN(qws);
+ ComBool fRtl = false; // default LTR for unknown/zero ws
+ if (qws)
CheckHr(qws->get_RightToLeftScript(&fRtl));
m_chrp.fWsRtl = (bool)fRtl;
m_chrp.nDirDepth = (fRtl) ? 1 : 0;
diff --git a/Src/views/VwRenderTrace.h b/Src/views/VwRenderTrace.h
new file mode 100644
index 0000000000..71a554b044
--- /dev/null
+++ b/Src/views/VwRenderTrace.h
@@ -0,0 +1,169 @@
+/*--------------------------------------------------------------------*//*:Ignore this sentence.
+Copyright (c) 2026 SIL International
+This software is licensed under the LGPL, version 2.1 or later
+(http://www.gnu.org/licenses/lgpl-2.1.html)
+-------------------------------------------------------------------------------*//*:End Ignore*/
+#pragma once
+#ifndef VWRENDERTRACE_INCLUDED
+#define VWRENDERTRACE_INCLUDED
+
+#include
+#include
+
+/*----------------------------------------------------------------------------------------------
+ Render trace timing infrastructure for Views engine performance analysis.
+
+ Usage:
+ #ifdef TRACING_RENDER
+ VwRenderTraceTimer timer("Layout");
+ // ... do layout work ...
+ timer.Stop(); // or let destructor handle it
+ #endif
+
+ Output format (compatible with RenderTraceParser):
+ [RENDER] Stage=Layout Duration=123.45ms Context=VwRootBox
+
+ Enable by defining TRACING_RENDER before including this header.
+ The output goes to OutputDebugString and optionally to a file.
+
+ Hungarian: rtt (render trace timer)
+----------------------------------------------------------------------------------------------*/
+
+// Uncomment to enable render tracing globally for debug builds
+// #define TRACING_RENDER
+
+#ifdef TRACING_RENDER
+
+// File output for render trace (optional - set to NULL to disable file output)
+// Can be opened via VwRenderTrace::OpenTraceFile()
+extern FILE * g_fpRenderTrace;
+
+/*----------------------------------------------------------------------------------------------
+Class: VwRenderTrace
+Description: Static helper for render trace configuration and output.
+----------------------------------------------------------------------------------------------*/
+class VwRenderTrace
+{
+public:
+ // Open a trace file for persistent logging
+ static bool OpenTraceFile(const wchar_t * pszPath)
+ {
+ if (g_fpRenderTrace)
+ CloseTraceFile();
+
+ _wfopen_s(&g_fpRenderTrace, pszPath, L"a");
+ return g_fpRenderTrace != NULL;
+ }
+
+ // Close the trace file
+ static void CloseTraceFile()
+ {
+ if (g_fpRenderTrace)
+ {
+ fclose(g_fpRenderTrace);
+ g_fpRenderTrace = NULL;
+ }
+ }
+
+ // Write a trace message
+ static void Write(const char * pszFormat, ...)
+ {
+ char szBuffer[1024];
+ va_list args;
+ va_start(args, pszFormat);
+ vsprintf_s(szBuffer, sizeof(szBuffer), pszFormat, args);
+ va_end(args);
+
+ ::OutputDebugStringA(szBuffer);
+ if (g_fpRenderTrace)
+ {
+ fputs(szBuffer, g_fpRenderTrace);
+ fflush(g_fpRenderTrace);
+ }
+ }
+
+ // Check if tracing is enabled at runtime
+ static bool IsEnabled()
+ {
+ // Could check environment variable or registry here
+ return true;
+ }
+};
+
+/*----------------------------------------------------------------------------------------------
+Class: VwRenderTraceTimer
+Description: RAII timer for automatic stage duration measurement.
+Hungarian: rtt
+----------------------------------------------------------------------------------------------*/
+class VwRenderTraceTimer
+{
+public:
+ VwRenderTraceTimer(const char * pszStageName, const char * pszContext = NULL)
+ : m_pszStageName(pszStageName)
+ , m_pszContext(pszContext)
+ , m_fStopped(false)
+ {
+ if (VwRenderTrace::IsEnabled())
+ {
+ QueryPerformanceCounter(&m_liStart);
+ }
+ }
+
+ ~VwRenderTraceTimer()
+ {
+ if (!m_fStopped && VwRenderTrace::IsEnabled())
+ {
+ Stop();
+ }
+ }
+
+ void Stop()
+ {
+ if (m_fStopped || !VwRenderTrace::IsEnabled())
+ return;
+
+ m_fStopped = true;
+
+ LARGE_INTEGER liEnd, liFreq;
+ QueryPerformanceCounter(&liEnd);
+ QueryPerformanceFrequency(&liFreq);
+
+ double durationMs = static_cast(liEnd.QuadPart - m_liStart.QuadPart) * 1000.0
+ / static_cast(liFreq.QuadPart);
+
+ if (m_pszContext)
+ {
+ VwRenderTrace::Write("[RENDER] Stage=%s Duration=%.3fms Context=%s\r\n",
+ m_pszStageName, durationMs, m_pszContext);
+ }
+ else
+ {
+ VwRenderTrace::Write("[RENDER] Stage=%s Duration=%.3fms\r\n",
+ m_pszStageName, durationMs);
+ }
+ }
+
+private:
+ const char * m_pszStageName;
+ const char * m_pszContext;
+ LARGE_INTEGER m_liStart;
+ bool m_fStopped;
+};
+
+// Convenience macros for conditional tracing
+#define VWRENDERTRACE_CONCAT_INNER(a, b) a##b
+#define VWRENDERTRACE_CONCAT(a, b) VWRENDERTRACE_CONCAT_INNER(a, b)
+#define RENDER_TRACE_TIMER(name) VwRenderTraceTimer VWRENDERTRACE_CONCAT(__rtt_, __LINE__)(name)
+#define RENDER_TRACE_TIMER_CTX(name, ctx) VwRenderTraceTimer VWRENDERTRACE_CONCAT(__rtt_, __LINE__)(name, ctx)
+#define RENDER_TRACE_MSG(fmt, ...) VwRenderTrace::Write(fmt, ##__VA_ARGS__)
+
+#else // !TRACING_RENDER
+
+// No-op macros when tracing is disabled
+#define RENDER_TRACE_TIMER(name) ((void)0)
+#define RENDER_TRACE_TIMER_CTX(name, ctx) ((void)0)
+#define RENDER_TRACE_MSG(fmt, ...) ((void)0)
+
+#endif // TRACING_RENDER
+
+#endif // VWRENDERTRACE_INCLUDED
diff --git a/Src/views/VwRootBox.cpp b/Src/views/VwRootBox.cpp
index d3599d47ea..4c087c0d66 100644
--- a/Src/views/VwRootBox.cpp
+++ b/Src/views/VwRootBox.cpp
@@ -1,4 +1,4 @@
-/*--------------------------------------------------------------------*//*:Ignore this sentence.
+/*--------------------------------------------------------------------*//*:Ignore this sentence.
Copyright (c) 1999-2019 SIL International
This software is licensed under the LGPL, version 2.1 or later
(http://www.gnu.org/licenses/lgpl-2.1.html)
@@ -37,6 +37,21 @@ const CLSID CLSID_ViewInputManager = {0x830BAF1F, 0x6F84, 0x46EF, {0xB6, 0x3E, 0
//:> Forward declarations
//:>********************************************************************************************
+namespace
+{
+ void DeleteMemoryDcAndBitmap(HDC hdcMem)
+ {
+ if (!hdcMem)
+ return;
+
+ HBITMAP hbmp = (HBITMAP)::GetCurrentObject(hdcMem, OBJ_BITMAP);
+ BOOL fSuccess = AfGdi::DeleteObjectBitmap(hbmp);
+ Assert(fSuccess);
+ fSuccess = AfGdi::DeleteDC(hdcMem);
+ Assert(fSuccess);
+ }
+}
+
//:>********************************************************************************************
//:> Local Constants and static variables
//:>********************************************************************************************
@@ -66,6 +81,9 @@ void VwRootBox::Init()
m_fInDrag = false;
m_hrSegmentError = S_OK;
m_cMaxParasToScan = 4;
+ m_fNeedsLayout = true;
+ m_dxLastLayoutWidth = -1;
+ m_fNeedsReconstruct = true;
// Usually set in Layout method, but some tests don't do this...
// play safe also for any code called before Layout.
m_ptDpiSrc.x = 96;
@@ -193,6 +211,9 @@ STDMETHODIMP VwRootBox::PropChanged(HVO hvo, PropTag tag, int ivMin, int cvIns,
{
BEGIN_COM_METHOD;
+ // Any data change makes a subsequent Reconstruct() valid work.
+ m_fNeedsReconstruct = true;
+
int ivMinDisp;
if (m_qsda)
{
@@ -318,7 +339,10 @@ STDMETHODIMP VwRootBox::SetRootObjects(HVO * prghvo, IVwViewConstructor ** prgpv
m_chvoRoot = chvo;
m_qss = pss;
if (m_fConstructed)
+ {
+ m_fNeedsReconstruct = true; // root data changed — ensure PATH-R1 guard allows reconstruction
CheckHr(Reconstruct());
+ }
END_COM_METHOD(g_fact, IID_IVwRootBox);
}
@@ -397,7 +421,10 @@ STDMETHODIMP VwRootBox::putref_Overlay(IVwOverlay * pvo)
m_qvo = pvo;
m_qvrs->OverlayChanged(this, m_qvo);
if (m_fConstructed)
+ {
+ m_fNeedsLayout = true; // overlay changes may affect layout
LayoutFull();
+ }
END_COM_METHOD(g_fact, IID_IVwRootBox);
}
@@ -2488,10 +2515,27 @@ STDMETHODIMP VwRootBox::get_IsPropChangedInProgress(ComBool * pfInProgress)
BEGIN_COM_METHOD;
ChkComArgPtr(pfInProgress);
- *pfInProgress = m_fIsPropChangedInProgress;
+ *pfInProgress = m_fIsPropChangedInProgress ? true : false;
END_COM_METHOD(g_fact, IID_IVwRootBox);
}
+
+/*----------------------------------------------------------------------------------------------
+ PATH-L5: Reports whether this root box needs a full Reconstruct.
+ Returns true when data or structural changes (PropChanged, OnStylesheetChange, etc.)
+ have occurred since the last Reconstruct. Managed callers can use this to skip the
+ overhead of selection save/restore and drawing suspension when nothing has changed.
+----------------------------------------------------------------------------------------------*/
+STDMETHODIMP VwRootBox::get_NeedsReconstruct(ComBool * pfNeeds)
+{
+ BEGIN_COM_METHOD;
+ ChkComArgPtr(pfNeeds);
+
+ *pfNeeds = m_fNeedsReconstruct ? true : false;
+
+ END_COM_METHOD(g_fact, IID_IVwRootBox);
+}
+
/*----------------------------------------------------------------------------------------------
Discard all your notifiers. In case this happens during a sequence of PropChanged calls,
mark them all as deleted.
@@ -2576,6 +2620,7 @@ void VwRootBox::Reconstruct(bool fCheckForSync)
Invalidate(); // new
InvalidateRect(&vwrect); //old
+ m_fNeedsReconstruct = false; // reconstruction complete
}
/*----------------------------------------------------------------------------------------------
@@ -2601,9 +2646,15 @@ STDMETHODIMP VwRootBox::OnStylesheetChange()
if (!m_fConstructed || Style() == NULL)
return S_OK; // no Style() object exists to fix. (I think the second condition above is redundant, but play safe.)
// Redraw the boxes based on stylesheet changes.
+ // This is intentionally nontrivial: LayoutFull() is enough to keep the current display
+ // usable, but a stylesheet change can also invalidate construction-time state such as
+ // notifier/property-store assumptions. Leave m_fNeedsReconstruct set so later callers
+ // like SimpleRootSite.RefreshDisplay() can still observe that a full rebuild may be needed.
+ m_fNeedsReconstruct = true; // style changes warrant reconstruction
Style()->InitRootTextProps(m_vqvwvc.Size() == 0 ? NULL : m_vqvwvc[0]);
Style()->RecomputeEffects();
+ m_fNeedsLayout = true; // style changes invalidate layout
LayoutFull();
return S_OK;
@@ -2691,6 +2742,7 @@ STDMETHODIMP VwRootBox::SetTableColWidths(VwLength * prgvlen, int cvlen)
pbox = pbox->NextOrLazy();
}
}
+ m_fNeedsLayout = true;
LayoutFull();
END_COM_METHOD(g_fact, IID_IVwRootBox);
@@ -2838,12 +2890,23 @@ STDMETHODIMP VwRootBox::Layout(IVwGraphics * pvg, int dxAvailWidth)
int dpiX, dpiY;
CheckHr(pvg->get_XUnitsPerInch(&dpiX));
CheckHr(pvg->get_YUnitsPerInch(&dpiY));
+
+ // PATH-L1 guard: skip full layout when the box tree is already laid out at this width
+ // and source DPI, and no structural mutation has occurred since the last successful layout.
+ if (m_fConstructed && !m_fNeedsLayout && dxAvailWidth == m_dxLastLayoutWidth
+ && dpiX == m_ptDpiSrc.x && dpiY == m_ptDpiSrc.y)
+ return S_OK;
+
m_ptDpiSrc.x = dpiX;
m_ptDpiSrc.y = dpiY;
if (!m_fConstructed)
Construct(pvg, dxAvailWidth);
VwDivBox::DoLayout(pvg, dxAvailWidth, -1, true);
+
+ // Layout succeeded — cache the width and clear the dirty flag.
+ m_fNeedsLayout = false;
+ m_dxLastLayoutWidth = dxAvailWidth;
#ifdef ENABLE_TSF
if (m_qvim)
CheckHr(m_qvim->OnLayoutChange());
@@ -4140,6 +4203,16 @@ void VwRootBox::RelayoutRoot(IVwGraphics * pvg, FixupMap * pfixmap, int dxpAvail
int dyOld = FieldHeight();
int dxOld = Width();
RelayoutCore(pvg, dxAvailWidth, this, pfixmap, -1, NULL, pboxsetDeleted);
+
+ // Incremental relayout succeeded — update layout guard state.
+ int dpiX, dpiY;
+ CheckHr(pvg->get_XUnitsPerInch(&dpiX));
+ CheckHr(pvg->get_YUnitsPerInch(&dpiY));
+ m_ptDpiSrc.x = dpiX;
+ m_ptDpiSrc.y = dpiY;
+ m_fNeedsLayout = false;
+ m_dxLastLayoutWidth = dxAvailWidth;
+
if (dyOld != FieldHeight() || dxOld != Width() || dyOld2 != Height())
CheckHr(m_qvrs->RootBoxSizeChanged(this));
}
@@ -4189,6 +4262,11 @@ void VwRootBox::Construct(IVwGraphics * pvg, int dxAvailWidth)
// about everything being closed, etc...
qvwenv->Cleanup();
m_fConstructed = true;
+ m_fNeedsLayout = true; // newly-constructed boxes require layout
+ m_fNeedsReconstruct = false; // construction complete — no need to reconstruct
+ // until PropChanged, OnStylesheetChange, or another mutation sets the flag.
+ // Previously this was left true from Init, causing the first Reconstruct()
+ // call to redo all work even when no data had changed (PATH-R1 guard bug).
ResetSpellCheck(); // in case it somehow got called while we had no contents.
}
@@ -4885,40 +4963,22 @@ STDMETHODIMP VwDrawRootBuffered::DrawTheRoot(IVwRootBox * prootb, HDC hdc, RECT
IVwGraphicsWin32Ptr qvg32;
Rect rcp(rcpDraw);
CheckHr(qvg->QueryInterface(IID_IVwGraphicsWin32, (void **) &qvg32));
-
- // Clean up any previous cached bitmap and DC
- if (m_hdcMem)
- {
- HBITMAP hbmpCached = (HBITMAP)::GetCurrentObject(m_hdcMem, OBJ_BITMAP);
- if (hbmpCached)
- {
- BOOL fSuccessBitmap = AfGdi::DeleteObjectBitmap(hbmpCached);
- Assert(fSuccessBitmap);
- (void)fSuccessBitmap; // Suppress C4189 warning in Release builds
- }
- BOOL fSuccessDC = AfGdi::DeleteDC(m_hdcMem);
- Assert(fSuccessDC);
- (void)fSuccessDC; // Suppress C4189 warning in Release builds
- m_hdcMem = 0;
- }
-
- // Create a new memory DC and bitmap for double buffering
- m_hdcMem = AfGdi::CreateCompatibleDC(hdc);
- HBITMAP hbmp = AfGdi::CreateCompatibleBitmap(hdc, rcp.Width(), rcp.Height());
- Assert(hbmp);
- HBITMAP hbmpOld = AfGdi::SelectObjectBitmap(m_hdcMem, hbmp);
+ HDC hdcPrevious = m_hdcMem;
+ HDC hdcNew = AfGdi::CreateCompatibleDC(hdc);
+ HBITMAP hbmpNew = AfGdi::CreateCompatibleBitmap(hdc, rcp.Width(), rcp.Height());
+ Assert(hbmpNew);
+ HBITMAP hbmpOld = AfGdi::SelectObjectBitmap(hdcNew, hbmpNew);
Assert(hbmpOld && hbmpOld != HGDI_ERROR);
(void)hbmpOld; // Suppress C4189 warning in Release builds (variable only used in Assert)
- // We don't delete hbmpOld (the stock bitmap from the DC)
- // The new bitmap (hbmp) will stay selected in m_hdcMem for caching
+ bool fPromotedCache = false;
if (bkclr == kclrTransparent)
// if the background color is transparent, copy the current screen area in to the
// bitmap buffer as our background
- ::BitBlt(m_hdcMem, 0, 0, rcp.Width(), rcp.Height(), hdc, rcp.left, rcp.top, SRCCOPY);
+ ::BitBlt(hdcNew, 0, 0, rcp.Width(), rcp.Height(), hdc, rcp.left, rcp.top, SRCCOPY);
else
- AfGfx::FillSolidRect(m_hdcMem, Rect(0, 0, rcp.Width(), rcp.Height()), bkclr);
- CheckHr(qvg32->Initialize(m_hdcMem));
+ AfGfx::FillSolidRect(hdcNew, Rect(0, 0, rcp.Width(), rcp.Height()), bkclr);
+ CheckHr(qvg32->Initialize(hdcNew));
VwPrepDrawResult xpdr = kxpdrAdjust;
IVwGraphicsPtr qvgDummy; // Required for GetGraphics calls to get transform rects
@@ -4985,10 +5045,24 @@ STDMETHODIMP VwDrawRootBuffered::DrawTheRoot(IVwRootBox * prootb, HDC hdc, RECT
if (xpdr != kxpdrInvalidate)
{
+ DeleteMemoryDcAndBitmap(hdcPrevious);
+ m_hdcMem = hdcNew;
+ hdcNew = 0;
+ fPromotedCache = true;
// We drew something...now blast it onto the screen.
// The bitmap in m_hdcMem is kept around for potential ReDrawLastDraw calls.
::BitBlt(hdc, rcp.left, rcp.top, rcp.Width(), rcp.Height(), m_hdcMem, 0, 0, SRCCOPY);
}
+ else if (hdcPrevious)
+ {
+ ::BitBlt(hdc, rcp.left, rcp.top, rcp.Width(), rcp.Height(), hdcPrevious, 0, 0, SRCCOPY);
+ }
+
+ if (!fPromotedCache)
+ {
+ DeleteMemoryDcAndBitmap(hdcNew);
+ m_hdcMem = hdcPrevious;
+ }
END_COM_METHOD(g_factVDRB, IID_IVwRootBox);
}
diff --git a/Src/views/VwRootBox.h b/Src/views/VwRootBox.h
index 4c8c634863..2b01bd5d2b 100644
--- a/Src/views/VwRootBox.h
+++ b/Src/views/VwRootBox.h
@@ -198,6 +198,8 @@ class VwRootBox : public IVwRootBox, public IServiceProvider, public VwDivBox
STDMETHOD(SetSpellingRepository)(IGetSpellChecker * pgsp);
+ STDMETHOD(get_NeedsReconstruct)(ComBool * pfNeeds);
+
// IServiceProvider methods
STDMETHOD(QueryService)(REFGUID guidService, REFIID riid, void ** ppv);
@@ -398,6 +400,15 @@ class VwRootBox : public IVwRootBox, public IServiceProvider, public VwDivBox
bool m_fConstructed; // true when we have called Construct() successfully.
+ // PATH-L1 layout guard: skip redundant full-layout passes when width and source DPI
+ // haven't changed and no structural mutation has occurred since the last successful Layout().
+ bool m_fNeedsLayout; // true when internal state requires a full relayout
+ int m_dxLastLayoutWidth; // width used for last successful Layout(), -1 if none
+
+ // PATH-R1 reconstruct guard: skip redundant Reconstruct() calls when no data
+ // or structural change has occurred since the last successful construction.
+ bool m_fNeedsReconstruct; // true when data changed and full rebuild is warranted
+
ISilDataAccessPtr m_qsda; // data access object, for getting and setting properties
IVwOverlayPtr m_qvo; // controls overlay/tagging behavior for all text
@@ -433,7 +444,7 @@ class VwRootBox : public IVwRootBox, public IServiceProvider, public VwDivBox
// When it changes, we try to increase laziness.
int m_ydTopLastDraw;
- Point m_ptDpiSrc; // x and y resolutions of most recent Layout.
+ Point m_ptDpiSrc; // x and y resolutions of most recent successful Layout/Relayout.
StrUni m_stuAccessibleName;
diff --git a/Src/views/VwTextBoxes.cpp b/Src/views/VwTextBoxes.cpp
index c3c55766dd..5e46be52cf 100644
--- a/Src/views/VwTextBoxes.cpp
+++ b/Src/views/VwTextBoxes.cpp
@@ -19,12 +19,16 @@ Last reviewed: Not yet.
#include "Main.h"
#pragma hdrstop
// any other headers (not precompiled)
+#include "lib/LayoutCache.h"
+#include "VwRenderTrace.h"
using namespace std;
#undef THIS_FILE
DEFINE_THIS_FILE
+__declspec(thread) LayoutPassCache * g_pCurrentLayoutPassCache = NULL;
+
// #define _DEBUG_SHOW_BOX
/***********************************************************************************************
@@ -793,6 +797,10 @@ typedef enum
class ParaBuilder
{
public: // we can make anything public since the whole class is private to this file
+ ParaBuilder() : m_pPrevLayoutPassCache(NULL)
+ {
+ }
+
ParaBuilderState m_zpbs; // state of the builder, controls next step in MainLoop()
VwParagraphBox * m_pvpbox; // the thing we are laying out.
@@ -916,6 +924,8 @@ class ParaBuilder
int m_dyTagBelow; // extra height to insert below lines for closing tags
bool m_fSemiTagging; // displaying styles on text itself but not the tags
IVwOverlay * m_pxvo;
+ LayoutPassCache m_layoutPassCache;
+ LayoutPassCache * m_pPrevLayoutPassCache;
// Picture box we are trying to fit into line
VwPictureBox * m_pboxpic;
@@ -1187,6 +1197,8 @@ class ParaBuilder
}
Assert(!(fComplete && pboxStartLayout));
m_pvpbox = pvpbox;
+ m_layoutPassCache.Reset();
+ m_pPrevLayoutPassCache = SetCurrentLayoutPassCache(&m_layoutPassCache);
m_pboxOriginalFirst = m_pvpbox->FirstBox();
// Need to set the first box of the paragraph to null. This is needed since we can call
// EditableSubStringAt() while laying out the paragraph (possibly trying to get an
@@ -1591,6 +1603,22 @@ class ParaBuilder
------------------------------------------------------------------------------------------*/
virtual ~ParaBuilder()
{
+ LayoutPassCache * pLayoutPassCache = GetCurrentLayoutPassCache();
+ if (pLayoutPassCache)
+ {
+ RENDER_TRACE_MSG("[RENDER] Stage=LayoutPassCache AnalysisReq=%d AnalysisHit=%d AnalysisMiss=%d AnalysisEvict=%d AnalysisMissMs=%lu ShapeReq=%d ShapeHit=%d ShapeMiss=%d ShapeEvict=%d ShapeMissMs=%lu\r\n",
+ pLayoutPassCache->AnalysisCache().RequestCount(),
+ pLayoutPassCache->AnalysisCache().HitCount(),
+ pLayoutPassCache->AnalysisCache().MissCount(),
+ pLayoutPassCache->AnalysisCache().EvictionCount(),
+ pLayoutPassCache->AnalysisCache().ComputeMs(),
+ pLayoutPassCache->ShapeCache().RequestCount(),
+ pLayoutPassCache->ShapeCache().HitCount(),
+ pLayoutPassCache->ShapeCache().MissCount(),
+ pLayoutPassCache->ShapeCache().EvictionCount(),
+ pLayoutPassCache->ShapeCache().ComputeMs());
+ }
+ SetCurrentLayoutPassCache(m_pPrevLayoutPassCache);
Assert(m_vmpbox.Size() == 0);
// Delete any discarded string boxes that have not been reused
while (m_psboxReusable)
diff --git a/Src/views/lib/ColorStateCache.cpp b/Src/views/lib/ColorStateCache.cpp
new file mode 100644
index 0000000000..44767b719e
--- /dev/null
+++ b/Src/views/lib/ColorStateCache.cpp
@@ -0,0 +1,47 @@
+/*-----------------------------------------------------------------------*//*:Ignore in Surveyor
+Copyright (c) 2026 SIL International
+This software is licensed under the LGPL, version 2.1 or later
+(http://www.gnu.org/licenses/lgpl-2.1.html)
+-------------------------------------------------------------------------------*//*:End Ignore*/
+
+#include "main.h"
+#pragma hdrstop
+
+#include "ColorStateCache.h"
+
+ColorStateCache::ColorStateCache()
+{
+ Invalidate();
+}
+
+void ColorStateCache::Invalidate()
+{
+ m_clrForeCache = CLR_INVALID;
+ m_clrBackCache = CLR_INVALID;
+ m_nBkModeCache = -1;
+ m_fColorCacheValid = false;
+}
+
+bool ColorStateCache::ApplyIfNeeded(HDC hdc, COLORREF clrForeNeeded, COLORREF clrBackNeeded,
+ int nBkModeNeeded)
+{
+ if (!m_fColorCacheValid
+ || clrForeNeeded != m_clrForeCache
+ || clrBackNeeded != m_clrBackCache
+ || nBkModeNeeded != m_nBkModeCache)
+ {
+ SmartPalette spal(hdc);
+ bool fOK = (AfGfx::SetTextColor(hdc, clrForeNeeded) != CLR_INVALID);
+ fOK = fOK && (AfGfx::SetBkColor(hdc, clrBackNeeded) != CLR_INVALID);
+ fOK = fOK && ::SetBkMode(hdc, nBkModeNeeded);
+ (void)fOK;
+
+ m_clrForeCache = clrForeNeeded;
+ m_clrBackCache = clrBackNeeded;
+ m_nBkModeCache = nBkModeNeeded;
+ m_fColorCacheValid = true;
+ return true;
+ }
+
+ return false;
+}
diff --git a/Src/views/lib/ColorStateCache.h b/Src/views/lib/ColorStateCache.h
new file mode 100644
index 0000000000..cd8ef52130
--- /dev/null
+++ b/Src/views/lib/ColorStateCache.h
@@ -0,0 +1,25 @@
+/*--------------------------------------------------------------------*//*:Ignore this sentence.
+Copyright (c) 2026 SIL International
+This software is licensed under the LGPL, version 2.1 or later
+(http://www.gnu.org/licenses/lgpl-2.1.html)
+-------------------------------------------------------------------------------*//*:End Ignore*/
+#pragma once
+#ifndef COLORSTATECACHE_INCLUDED
+#define COLORSTATECACHE_INCLUDED
+
+class ColorStateCache
+{
+public:
+ ColorStateCache();
+
+ void Invalidate();
+ bool ApplyIfNeeded(HDC hdc, COLORREF clrForeNeeded, COLORREF clrBackNeeded, int nBkModeNeeded);
+
+private:
+ COLORREF m_clrForeCache;
+ COLORREF m_clrBackCache;
+ int m_nBkModeCache;
+ bool m_fColorCacheValid;
+};
+
+#endif // COLORSTATECACHE_INCLUDED
diff --git a/Src/views/lib/FontHandleCache.cpp b/Src/views/lib/FontHandleCache.cpp
new file mode 100644
index 0000000000..f69b6b6727
--- /dev/null
+++ b/Src/views/lib/FontHandleCache.cpp
@@ -0,0 +1,165 @@
+/*-----------------------------------------------------------------------*//*:Ignore in Surveyor
+Copyright (c) 2026 SIL International
+This software is licensed under the LGPL, version 2.1 or later
+(http://www.gnu.org/licenses/lgpl-2.1.html)
+-------------------------------------------------------------------------------*//*:End Ignore*/
+
+#include "main.h"
+#pragma hdrstop
+
+#include "FontHandleCache.h"
+
+#include
+template Vector; // VecHfont;
+
+FontHandleCache::FontHandleCache()
+{
+ m_cfceUsed = 0;
+ memset(m_rgfce, 0, sizeof(m_rgfce));
+}
+
+HFONT FontHandleCache::FindCachedFont(const LgCharRenderProps * pchrp) const
+{
+ const int cbFontOffset = (int)offsetof(LgCharRenderProps, ttvBold);
+ const int cbFontSize = isizeof(LgCharRenderProps) - cbFontOffset;
+
+ for (int i = 0; i < m_cfceUsed; i++)
+ {
+ if (m_rgfce[i].fUsed &&
+ memcmp(((byte *)pchrp) + cbFontOffset,
+ ((byte *)&m_rgfce[i].chrp) + cbFontOffset,
+ cbFontSize) == 0)
+ {
+ return m_rgfce[i].hfont;
+ }
+ }
+ return NULL;
+}
+
+void FontHandleCache::AddFontToCache(HFONT hfont, const LgCharRenderProps * pchrp,
+ HFONT hfontActive, TryDeleteFontProc pfnTryDelete, void * pDeleteContext)
+{
+ TryDeleteDeferredFonts(hfontActive, pfnTryDelete, pDeleteContext);
+
+ if (m_cfceUsed >= kcFontCacheMax)
+ {
+ int iEvict = 0;
+ HFONT hfontEvicted = m_rgfce[iEvict].hfont;
+ if (hfontEvicted == hfontActive)
+ {
+ Assert(false);
+ for (int i = 1; i < m_cfceUsed; ++i)
+ {
+ if (m_rgfce[i].hfont && m_rgfce[i].hfont != hfontActive)
+ {
+ iEvict = i;
+ hfontEvicted = m_rgfce[iEvict].hfont;
+ break;
+ }
+ }
+ }
+ if (hfontEvicted == hfontActive)
+ {
+ Assert(false);
+ return;
+ }
+ if (hfontEvicted)
+ {
+ bool fDeleted = pfnTryDelete ? pfnTryDelete(hfontEvicted, pDeleteContext) : true;
+ if (!fDeleted)
+ QueueFontForDeferredDelete(hfontEvicted);
+ }
+ if (iEvict < m_cfceUsed - 1)
+ {
+ memmove(&m_rgfce[iEvict], &m_rgfce[iEvict + 1],
+ (m_cfceUsed - iEvict - 1) * sizeof(FontCacheEntry));
+ }
+ m_cfceUsed--;
+ }
+
+ m_rgfce[m_cfceUsed].hfont = hfont;
+ m_rgfce[m_cfceUsed].chrp = *pchrp;
+ m_rgfce[m_cfceUsed].fUsed = true;
+ m_cfceUsed++;
+}
+
+void FontHandleCache::QueueFontForDeferredDelete(HFONT hfont)
+{
+ if (!hfont)
+ return;
+
+ for (int i = 0; i < m_vhfontDeferredDelete.Size(); ++i)
+ {
+ if (m_vhfontDeferredDelete[i] == hfont)
+ return;
+ }
+
+ m_vhfontDeferredDelete.Push(hfont);
+}
+
+void FontHandleCache::TryDeleteDeferredFonts(HFONT hfontActive, TryDeleteFontProc pfnTryDelete,
+ void * pDeleteContext)
+{
+ for (int i = m_vhfontDeferredDelete.Size() - 1; i >= 0; --i)
+ {
+ HFONT hfont = m_vhfontDeferredDelete[i];
+ if (!hfont)
+ {
+ m_vhfontDeferredDelete.Delete(i);
+ continue;
+ }
+ if (hfont == hfontActive)
+ continue;
+
+ bool fDeleted = pfnTryDelete ? pfnTryDelete(hfont, pDeleteContext) : true;
+ if (fDeleted)
+ m_vhfontDeferredDelete.Delete(i);
+ }
+}
+
+void FontHandleCache::Clear(HFONT hfontActive, TryDeleteFontProc pfnTryDelete, void * pDeleteContext)
+{
+ for (int i = 0; i < m_cfceUsed; i++)
+ {
+ if (m_rgfce[i].hfont)
+ {
+ bool fDeleted = pfnTryDelete ? pfnTryDelete(m_rgfce[i].hfont, pDeleteContext) : true;
+ if (!fDeleted)
+ QueueFontForDeferredDelete(m_rgfce[i].hfont);
+ m_rgfce[i].hfont = NULL;
+ }
+ m_rgfce[i].fUsed = false;
+ }
+ m_cfceUsed = 0;
+
+ TryDeleteDeferredFonts(hfontActive, pfnTryDelete, pDeleteContext);
+ for (int i = 0; i < m_vhfontDeferredDelete.Size(); ++i)
+ {
+ HFONT hfont = m_vhfontDeferredDelete[i];
+ if (!hfont || hfont == hfontActive)
+ continue;
+ if (pfnTryDelete)
+ pfnTryDelete(hfont, pDeleteContext);
+ }
+ m_vhfontDeferredDelete.Delete(0, m_vhfontDeferredDelete.Size());
+}
+
+int FontHandleCache::CacheCount() const
+{
+ return m_cfceUsed;
+}
+
+int FontHandleCache::DeferredDeleteCount() const
+{
+ return m_vhfontDeferredDelete.Size();
+}
+
+bool FontHandleCache::IsDeferredDeleteQueued(HFONT hfont) const
+{
+ for (int i = 0; i < m_vhfontDeferredDelete.Size(); ++i)
+ {
+ if (m_vhfontDeferredDelete[i] == hfont)
+ return true;
+ }
+ return false;
+}
diff --git a/Src/views/lib/FontHandleCache.h b/Src/views/lib/FontHandleCache.h
new file mode 100644
index 0000000000..5a775ab5c1
--- /dev/null
+++ b/Src/views/lib/FontHandleCache.h
@@ -0,0 +1,45 @@
+/*--------------------------------------------------------------------*//*:Ignore this sentence.
+Copyright (c) 2026 SIL International
+This software is licensed under the LGPL, version 2.1 or later
+(http://www.gnu.org/licenses/lgpl-2.1.html)
+-------------------------------------------------------------------------------*//*:End Ignore*/
+#pragma once
+#ifndef FONTHANDLECACHE_INCLUDED
+#define FONTHANDLECACHE_INCLUDED
+
+class FontHandleCache
+{
+public:
+ typedef bool (*TryDeleteFontProc)(HFONT hfont, void * pContext);
+ static const int kcFontCacheMax = 8;
+
+ FontHandleCache();
+
+ HFONT FindCachedFont(const LgCharRenderProps * pchrp) const;
+ void AddFontToCache(HFONT hfont, const LgCharRenderProps * pchrp,
+ HFONT hfontActive, TryDeleteFontProc pfnTryDelete, void * pDeleteContext);
+ void TryDeleteDeferredFonts(HFONT hfontActive, TryDeleteFontProc pfnTryDelete,
+ void * pDeleteContext);
+ void Clear(HFONT hfontActive, TryDeleteFontProc pfnTryDelete, void * pDeleteContext);
+
+ int CacheCount() const;
+ int DeferredDeleteCount() const;
+ bool IsDeferredDeleteQueued(HFONT hfont) const;
+
+private:
+ struct FontCacheEntry
+ {
+ HFONT hfont;
+ LgCharRenderProps chrp;
+ bool fUsed;
+ };
+
+ typedef Vector VecHfont;
+ FontCacheEntry m_rgfce[kcFontCacheMax];
+ int m_cfceUsed;
+ VecHfont m_vhfontDeferredDelete;
+
+ void QueueFontForDeferredDelete(HFONT hfont);
+};
+
+#endif // FONTHANDLECACHE_INCLUDED
diff --git a/Src/views/lib/LayoutCache.h b/Src/views/lib/LayoutCache.h
new file mode 100644
index 0000000000..5844a57c65
--- /dev/null
+++ b/Src/views/lib/LayoutCache.h
@@ -0,0 +1,480 @@
+#pragma once
+#ifndef LAYOUTCACHE_INCLUDED
+#define LAYOUTCACHE_INCLUDED
+
+class TextAnalysisEntry
+{
+public:
+ TextAnalysisEntry() :
+ m_pts(NULL),
+ m_ichMin(0),
+ m_cch(0),
+ m_ws(0),
+ m_fWsRtl(false),
+ m_fTextIsNfc(true),
+ m_cchNfc(0),
+ m_citem(0)
+ {
+ }
+
+ bool Covers(IVwTextSource * pts, int ichMin, int cch, int ws, bool fWsRtl) const
+ {
+ return m_pts == pts && m_ichMin == ichMin && m_cch >= cch && m_ws == ws && m_fWsRtl == fWsRtl;
+ }
+
+ int RequestedNfcLength(int cchRequested) const
+ {
+ if (cchRequested <= 0)
+ return 0;
+ if (m_fTextIsNfc)
+ return cchRequested;
+ if (m_vichOrigToNfc.Size() == 0)
+ return cchRequested;
+ if (cchRequested >= m_vichOrigToNfc.Size())
+ return m_vichOrigToNfc[m_vichOrigToNfc.Size() - 1];
+ return m_vichOrigToNfc[cchRequested];
+ }
+
+ int OffsetInNfc(int ich, int ichBase) const
+ {
+ Assert(ich >= ichBase);
+ if (m_fTextIsNfc)
+ return ich - ichBase;
+ int ichRelative = ich - ichBase;
+ if (ichRelative <= 0)
+ return 0;
+ if (m_vichOrigToNfc.Size() == 0)
+ return ichRelative;
+ if (ichRelative >= m_vichOrigToNfc.Size())
+ return m_vichOrigToNfc[m_vichOrigToNfc.Size() - 1];
+ return m_vichOrigToNfc[ichRelative];
+ }
+
+ int OffsetToOrig(int ich, int ichBase) const
+ {
+ if (m_fTextIsNfc)
+ return ich + ichBase;
+ if (ich <= 0)
+ return ichBase;
+ if (m_vichNfcToOrig.Size() == 0)
+ return ich + ichBase;
+ if (ich >= m_vichNfcToOrig.Size())
+ return m_cch + ichBase;
+ return m_vichNfcToOrig[ich] + ichBase;
+ }
+
+ void CopyScriptItemsTo(Vector & vscri, int & citem) const
+ {
+ citem = m_citem;
+ int cscri = m_vscri.Size();
+ if (vscri.Size() < cscri)
+ vscri.Resize(cscri);
+ if (cscri > 0)
+ ::memcpy(vscri.Begin(), const_cast &>(m_vscri).Begin(),
+ cscri * isizeof(SCRIPT_ITEM));
+ }
+
+public:
+ IVwTextSource * m_pts;
+ int m_ichMin;
+ int m_cch;
+ int m_ws;
+ bool m_fWsRtl;
+ bool m_fTextIsNfc;
+ int m_cchNfc;
+ int m_citem;
+ Vector m_vchNfc;
+ Vector m_vscri;
+ Vector m_vichOrigToNfc;
+ Vector m_vichNfcToOrig;
+};
+
+class ShapeRunEntry
+{
+public:
+ ShapeRunEntry() :
+ m_hfont(NULL),
+ m_cch(0),
+ m_cglyph(0),
+ m_dxdWidth(0),
+ m_fScriptPlaceFailed(false)
+ {
+ ::ZeroMemory(&m_sa, sizeof(m_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;
+ }
+
+ HFONT m_hfont;
+ SCRIPT_ANALYSIS m_sa;
+ int m_cch;
+ int m_cglyph;
+ int m_dxdWidth;
+ bool m_fScriptPlaceFailed;
+ Vector m_vch;
+ Vector m_vchFontVar;
+ Vector m_vglyph;
+ Vector m_vsva;
+ Vector m_vadvance;
+ Vector m_vcst;
+ Vector m_voff;
+ Vector m_vcluster;
+};
+
+class TextAnalysisCache
+{
+public:
+ TextAnalysisCache(int cEntriesMax = 16) :
+ m_cEntriesMax(cEntriesMax),
+ m_ientryReplace(0),
+ m_cHit(0),
+ m_cMiss(0),
+ m_cEvict(0),
+ m_msCompute(0)
+ {
+ }
+
+ void Reset()
+ {
+ m_ventry.Delete(0, m_ventry.Size());
+ m_ientryReplace = 0;
+ m_cHit = 0;
+ m_cMiss = 0;
+ m_cEvict = 0;
+ m_msCompute = 0;
+ }
+
+ TextAnalysisEntry * Find(IVwTextSource * pts, int ichMin, int cch, int ws, bool fWsRtl)
+ {
+ TextAnalysisEntry * pbest = NULL;
+ int cchBest = INT_MAX;
+ for (int ientry = 0; ientry < m_ventry.Size(); ++ientry)
+ {
+ TextAnalysisEntry & entry = m_ventry[ientry];
+ if (!entry.Covers(pts, ichMin, cch, ws, fWsRtl))
+ continue;
+ if (entry.m_cch < cchBest)
+ {
+ pbest = &entry;
+ cchBest = entry.m_cch;
+ }
+ }
+ if (pbest)
+ ++m_cHit;
+ else
+ ++m_cMiss;
+ return pbest;
+ }
+
+ TextAnalysisEntry * Store(IVwTextSource * pts, int ichMin, int cch, int ws, bool fWsRtl,
+ const OLECHAR * prgchNfc, int cchNfc, bool fTextIsNfc, const SCRIPT_ITEM * prgscri,
+ int citem, const Vector * pvichOrigToNfc, const Vector * pvichNfcToOrig)
+ {
+ TextAnalysisEntry * pentry = NULL;
+ for (int ientry = 0; ientry < m_ventry.Size(); ++ientry)
+ {
+ TextAnalysisEntry & entry = m_ventry[ientry];
+ if (entry.m_pts == pts && entry.m_ichMin == ichMin && entry.m_cch == cch &&
+ entry.m_ws == ws && entry.m_fWsRtl == fWsRtl)
+ {
+ pentry = &entry;
+ break;
+ }
+ }
+
+ if (!pentry)
+ {
+ if (m_ventry.Size() < m_cEntriesMax)
+ {
+ TextAnalysisEntry entry;
+ m_ventry.Push(entry);
+ pentry = &m_ventry[m_ventry.Size() - 1];
+ }
+ else
+ {
+ pentry = &m_ventry[m_ientryReplace];
+ m_ientryReplace = (m_ientryReplace + 1) % m_cEntriesMax;
+ ++m_cEvict;
+ }
+ }
+
+ pentry->m_pts = pts;
+ pentry->m_ichMin = ichMin;
+ pentry->m_cch = cch;
+ pentry->m_ws = ws;
+ pentry->m_fWsRtl = fWsRtl;
+ pentry->m_fTextIsNfc = fTextIsNfc;
+ pentry->m_cchNfc = cchNfc;
+ pentry->m_citem = citem;
+
+ pentry->m_vchNfc.Resize(cchNfc);
+ if (cchNfc > 0)
+ ::memcpy(pentry->m_vchNfc.Begin(), prgchNfc, cchNfc * isizeof(OLECHAR));
+
+ int cscri = citem + 1;
+ if (cscri < 2)
+ cscri = 2;
+ pentry->m_vscri.Resize(cscri);
+ if (cscri > 0)
+ ::memcpy(pentry->m_vscri.Begin(), prgscri, cscri * isizeof(SCRIPT_ITEM));
+
+ if (pvichOrigToNfc)
+ {
+ pentry->m_vichOrigToNfc.Resize(pvichOrigToNfc->Size());
+ for (int i = 0; i < pvichOrigToNfc->Size(); ++i)
+ pentry->m_vichOrigToNfc[i] = (*pvichOrigToNfc)[i];
+ }
+ else
+ pentry->m_vichOrigToNfc.Delete(0, pentry->m_vichOrigToNfc.Size());
+
+ if (pvichNfcToOrig)
+ {
+ pentry->m_vichNfcToOrig.Resize(pvichNfcToOrig->Size());
+ for (int i = 0; i < pvichNfcToOrig->Size(); ++i)
+ pentry->m_vichNfcToOrig[i] = (*pvichNfcToOrig)[i];
+ }
+ else
+ pentry->m_vichNfcToOrig.Delete(0, pentry->m_vichNfcToOrig.Size());
+
+ return pentry;
+ }
+
+ int HitCount() const { return m_cHit; }
+ int MissCount() const { return m_cMiss; }
+ int EvictionCount() const { return m_cEvict; }
+ int RequestCount() const { return m_cHit + m_cMiss; }
+ DWORD ComputeMs() const { return m_msCompute; }
+ void AddComputeMs(DWORD ms) { m_msCompute += ms; }
+
+private:
+ Vector m_ventry;
+ int m_cEntriesMax;
+ int m_ientryReplace;
+ int m_cHit;
+ int m_cMiss;
+ int m_cEvict;
+ DWORD m_msCompute;
+};
+
+class ShapeRunCache
+{
+public:
+ ShapeRunCache(int cEntriesMax = 32) :
+ m_cEntriesMax(cEntriesMax),
+ m_ientryReplace(0),
+ m_cHit(0),
+ m_cMiss(0),
+ m_cEvict(0),
+ m_msCompute(0)
+ {
+ }
+
+ void Reset()
+ {
+ m_ventry.Delete(0, m_ventry.Size());
+ m_ientryReplace = 0;
+ m_cHit = 0;
+ m_cMiss = 0;
+ m_cEvict = 0;
+ m_msCompute = 0;
+ }
+
+ 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, prgchFontVar))
+ {
+ ++m_cHit;
+ return &entry;
+ }
+ }
+ ++m_cMiss;
+ return NULL;
+ }
+
+ 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)
+ {
+ ShapeRunEntry * pentry = NULL;
+ for (int ientry = 0; ientry < m_ventry.Size(); ++ientry)
+ {
+ ShapeRunEntry & entry = m_ventry[ientry];
+ if (entry.Matches(prgch, cch, hfont, sa, prgchFontVar))
+ {
+ pentry = &entry;
+ break;
+ }
+ }
+
+ if (!pentry)
+ {
+ if (m_ventry.Size() < m_cEntriesMax)
+ {
+ ShapeRunEntry entry;
+ m_ventry.Push(entry);
+ pentry = &m_ventry[m_ventry.Size() - 1];
+ }
+ else
+ {
+ pentry = &m_ventry[m_ientryReplace];
+ m_ientryReplace = (m_ientryReplace + 1) % m_cEntriesMax;
+ ++m_cEvict;
+ }
+ }
+
+ pentry->m_hfont = hfont;
+ pentry->m_sa = sa;
+ pentry->m_cch = cch;
+ pentry->m_cglyph = cglyph;
+ pentry->m_dxdWidth = dxdWidth;
+ pentry->m_fScriptPlaceFailed = fScriptPlaceFailed;
+
+ pentry->m_vch.Resize(cch);
+ 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);
+ pentry->m_vcst.Resize(cglyph);
+ pentry->m_voff.Resize(cglyph);
+ if (cglyph > 0)
+ {
+ ::memcpy(pentry->m_vglyph.Begin(), prgGlyph, cglyph * isizeof(WORD));
+ ::memcpy(pentry->m_vsva.Begin(), prgsva, cglyph * isizeof(SCRIPT_VISATTR));
+ ::memcpy(pentry->m_vadvance.Begin(), prgAdvance, cglyph * isizeof(int));
+ ::memcpy(pentry->m_vcst.Begin(), prgcst, cglyph * isizeof(int));
+ ::memcpy(pentry->m_voff.Begin(), prgoff, cglyph * isizeof(GOFFSET));
+ }
+
+ pentry->m_vcluster.Resize(cch);
+ if (cch > 0)
+ ::memcpy(pentry->m_vcluster.Begin(), prgCluster, cch * isizeof(WORD));
+
+ return pentry;
+ }
+
+ int HitCount() const { return m_cHit; }
+ int MissCount() const { return m_cMiss; }
+ int EvictionCount() const { return m_cEvict; }
+ int RequestCount() const { return m_cHit + m_cMiss; }
+ DWORD ComputeMs() const { return m_msCompute; }
+ void AddComputeMs(DWORD ms) { m_msCompute += ms; }
+
+private:
+ Vector m_ventry;
+ int m_cEntriesMax;
+ int m_ientryReplace;
+ int m_cHit;
+ int m_cMiss;
+ int m_cEvict;
+ DWORD m_msCompute;
+};
+
+class LayoutPassCache
+{
+public:
+ LayoutPassCache() : m_analysisCache(16), m_shapeRunCache(32)
+ {
+ }
+
+ void Reset()
+ {
+ m_analysisCache.Reset();
+ m_shapeRunCache.Reset();
+ }
+
+ TextAnalysisCache & AnalysisCache()
+ {
+ return m_analysisCache;
+ }
+
+ ShapeRunCache & ShapeCache()
+ {
+ return m_shapeRunCache;
+ }
+
+private:
+ TextAnalysisCache m_analysisCache;
+ ShapeRunCache m_shapeRunCache;
+};
+
+extern __declspec(thread) LayoutPassCache * g_pCurrentLayoutPassCache;
+
+inline bool IsPerfFlagEnabled(const wchar_t * pszName)
+{
+ wchar_t rgchValue[16] = {0};
+ DWORD cchValue = ::GetEnvironmentVariableW(pszName, rgchValue, _countof(rgchValue));
+ if (cchValue == 0)
+ return true;
+ return _wcsicmp(rgchValue, L"0") != 0 && _wcsicmp(rgchValue, L"false") != 0 &&
+ _wcsicmp(rgchValue, L"off") != 0;
+}
+
+inline bool IsPath1ShapeCacheEnabled()
+{
+ static int s_nEnabled = -1;
+ if (s_nEnabled < 0)
+ s_nEnabled = IsPerfFlagEnabled(L"FW_PERF_P125_PATH1") ? 1 : 0;
+ return s_nEnabled == 1;
+}
+
+inline bool IsPath2AnalysisCacheEnabled()
+{
+ static int s_nEnabled = -1;
+ if (s_nEnabled < 0)
+ s_nEnabled = IsPerfFlagEnabled(L"FW_PERF_P125_PATH2") ? 1 : 0;
+ return s_nEnabled == 1;
+}
+
+inline LayoutPassCache * GetCurrentLayoutPassCache()
+{
+ return g_pCurrentLayoutPassCache;
+}
+
+inline LayoutPassCache * SetCurrentLayoutPassCache(LayoutPassCache * pLayoutPassCache)
+{
+ LayoutPassCache * pPrev = g_pCurrentLayoutPassCache;
+ g_pCurrentLayoutPassCache = pLayoutPassCache;
+ return pPrev;
+}
+
+#endif // LAYOUTCACHE_INCLUDED
\ No newline at end of file
diff --git a/Src/views/lib/UniscribeEngine.cpp b/Src/views/lib/UniscribeEngine.cpp
index d69d570f61..4ff897ec08 100644
--- a/Src/views/lib/UniscribeEngine.cpp
+++ b/Src/views/lib/UniscribeEngine.cpp
@@ -28,6 +28,7 @@ POSSIBLE OPTIMIZATION HINT:
#include "Main.h"
#pragma hdrstop
// any other headers (not precompiled)
+#include "LayoutCache.h"
#undef THIS_FILE
DEFINE_THIS_FILE
@@ -411,8 +412,11 @@ STDMETHODIMP UniscribeEngine::FindBreakPoint(
// end at a line break opportunity. In particular if a run contains sequences of PUA
// characters from plane 0 Uniscribe creates new items for these for reasons which are not
// clear.
+ // PATH-N1: Get NFC flag to avoid redundant OffsetInNfc/OffsetToOrig normalization below.
+ bool fTextIsNfc = false;
+ const TextAnalysisEntry * pAnalysis = NULL;
int cchNfc = UniscribeSegment::CallScriptItemize(rgchBuf, INIT_BUF_SIZE, vch, pts, ichMinSeg,
- ichLimText - ichMinSeg, &prgchBuf, citem, (bool)fParaRtoL);
+ ichLimText - ichMinSeg, &prgchBuf, citem, (bool)fParaRtoL, &fTextIsNfc, &pAnalysis);
Vector vichBreak;
ILgLineBreakerPtr qlb;
@@ -504,7 +508,7 @@ STDMETHODIMP UniscribeEngine::FindBreakPoint(
}
ichLim = min(ichLimNext, ichLimBT2);
// Optimize JohnT: if ichLim==ichBase+m_dichLim, can use cchNfc.
- ichLimNfc = UniscribeSegment::OffsetInNfc(ichLim, ichMinSeg, pts);
+ ichLimNfc = UniscribeSegment::OffsetInNfc(ichLim, ichMinSeg, pts, fTextIsNfc, pAnalysis);
if (ichLimNfc == ichMinNfc)
{
// This can happen if later characters in a composite have different properties than the first.
@@ -528,7 +532,7 @@ STDMETHODIMP UniscribeEngine::FindBreakPoint(
{
// Script item is smaller than run; shorten the amount we treat as a 'run'.
ichLimNfc = (pscri + 1)->iCharPos;
- ichLim = UniscribeSegment::OffsetToOrig(ichLimNfc, ichMinSeg, pts);
+ ichLim = UniscribeSegment::OffsetToOrig(ichLimNfc, ichMinSeg, pts, fTextIsNfc, pAnalysis);
}
// Set up the characters of the run, if any.
@@ -750,7 +754,7 @@ STDMETHODIMP UniscribeEngine::FindBreakPoint(
vdxRun.Pop();
ichLimBT2 = ichMin;
ichLim = *(vichRun.Top());
- ichLimNfc = UniscribeSegment::OffsetInNfc(ichLim, ichMinSeg, pts);
+ ichLimNfc = UniscribeSegment::OffsetInNfc(ichLim, ichMinSeg, pts, fTextIsNfc, pAnalysis);
vichRun.Pop();
cglyph = *(viglyphRun.Top());
viglyphRun.Pop();
@@ -855,7 +859,7 @@ STDMETHODIMP UniscribeEngine::FindBreakPoint(
vdxRun.Pop();
ichLimBT2 = ichMin;
ichLim = *(vichRun.Top());
- ichLimNfc = UniscribeSegment::OffsetInNfc(ichLim, ichMinSeg, pts);
+ ichLimNfc = UniscribeSegment::OffsetInNfc(ichLim, ichMinSeg, pts, fTextIsNfc, pAnalysis);
vichRun.Pop();
cglyph = *(viglyphRun.Top());
viglyphRun.Pop();
@@ -969,11 +973,11 @@ STDMETHODIMP UniscribeEngine::FindBreakPoint(
}
}
}
- ichLim = UniscribeSegment::OffsetToOrig(ichMinUri + ichRun, ichMinSeg, pts);
+ ichLim = UniscribeSegment::OffsetToOrig(ichMinUri + ichRun, ichMinSeg, pts, fTextIsNfc, pAnalysis);
break;
}
- int ichLineBreak = UniscribeSegment::OffsetToOrig(ichLineBreakNfc, ichMinSeg, pts);
+ int ichLineBreak = UniscribeSegment::OffsetToOrig(ichLineBreakNfc, ichMinSeg, pts, fTextIsNfc, pAnalysis);
if (ichLineBreak <= ichMin)
{
@@ -990,7 +994,7 @@ STDMETHODIMP UniscribeEngine::FindBreakPoint(
viglyphRun.Pop();
}
ichLim = ichMin; // Required to get correct values at start of loop.
- ichLimNfc = UniscribeSegment::OffsetInNfc(ichLim, ichMinSeg, pts);
+ ichLimNfc = UniscribeSegment::OffsetInNfc(ichLim, ichMinSeg, pts, fTextIsNfc, pAnalysis);
ichLimBT2 = ichLineBreak;
fRemovedWs = false;
fBacktracking = true;
@@ -998,12 +1002,12 @@ STDMETHODIMP UniscribeEngine::FindBreakPoint(
}
ichLim = ichLineBreak; // We limit the segment to not exceed the latest line break point.
Assert(ichLim <= ichLimBacktrack);
- ichLimNfc = UniscribeSegment::OffsetInNfc(ichLim, ichMinSeg, pts);
+ ichLimNfc = UniscribeSegment::OffsetInNfc(ichLim, ichMinSeg, pts, fTextIsNfc, pAnalysis);
fOkBreak = true; // Means we have a good line break.
// Store the glyph-specific information: stretch values.
int cchRunTotalTmp = uri.cch;
- uri.cch = UniscribeSegment::OffsetInNfc(ichLim, ichMinSeg, pts) - ichMinNfc;
+ uri.cch = UniscribeSegment::OffsetInNfc(ichLim, ichMinSeg, pts, fTextIsNfc, pAnalysis) - ichMinNfc;
UniscribeSegment::ShapePlaceRun(uri, true);
viglyphRun.Push(cglyph);
cglyph += uri.cglyph;
@@ -1029,7 +1033,7 @@ STDMETHODIMP UniscribeEngine::FindBreakPoint(
if (twsh == ktwshNoWs)
{
fRemovedWs = RemoveTrailingWhiteSpace(ichMinUri, &ichLimNfc, uri);
- ichLim = UniscribeSegment::OffsetToOrig(ichLimNfc, ichMinSeg, pts);
+ ichLim = UniscribeSegment::OffsetToOrig(ichLimNfc, ichMinSeg, pts, fTextIsNfc, pAnalysis);
// Usually the worst case is that ichLimNfc == ichMinUri, indicating that the whole run is
// white space. However, in at least one pathological case, we have observed uniscribe
// strip of more than one run of white space. Hence the <=.
@@ -1053,7 +1057,7 @@ STDMETHODIMP UniscribeEngine::FindBreakPoint(
dxSegWidth = *(vdxRun.Top());
vdxRun.Pop();
ichLim = *(vichRun.Top());
- ichLimNfc = UniscribeSegment::OffsetInNfc(ichLim, ichMinSeg, pts);
+ ichLimNfc = UniscribeSegment::OffsetInNfc(ichLim, ichMinSeg, pts, fTextIsNfc, pAnalysis);
vichRun.Pop();
cglyph = *(viglyphRun.Top());
viglyphRun.Pop();
@@ -1082,7 +1086,7 @@ STDMETHODIMP UniscribeEngine::FindBreakPoint(
Assert(irun == 1);
Assert(ichMinUri == 0);
RemoveNonWhiteSpace(ichMinUri, &ichLimNfc, uri);
- ichLim = UniscribeSegment::OffsetToOrig(ichLimNfc, ichMinSeg, pts);
+ ichLim = UniscribeSegment::OffsetToOrig(ichLimNfc, ichMinSeg, pts, fTextIsNfc, pAnalysis);
if (ichLim == ichMinSeg)
return S_OK; // failure to create a valid segment
fOkBreak = true;
diff --git a/Src/views/lib/UniscribeSegment.cpp b/Src/views/lib/UniscribeSegment.cpp
index ea45848da5..c540b40b07 100644
--- a/Src/views/lib/UniscribeSegment.cpp
+++ b/Src/views/lib/UniscribeSegment.cpp
@@ -16,6 +16,7 @@ Last reviewed: Not yet.
#include "Main.h"
#pragma hdrstop
// any other headers (not precompiled)
+#include "LayoutCache.h"
#undef THIS_FILE
DEFINE_THIS_FILE
@@ -27,6 +28,9 @@ DEFINE_THIS_FILE
//:>********************************************************************************************
//:> Forward declarations
//:>********************************************************************************************
+static void BuildNfcOffsetMaps(const StrUni & stuOrig, Vector & vichOrigToNfc,
+ Vector & vichNfcToOrig);
+static void ApplyShapeRunCacheEntry(ShapeRunEntry & entry, UniscribeRunInfo & uri);
//:>********************************************************************************************
//:> Local Constants and static variables
@@ -35,6 +39,192 @@ 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 *);
+
+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 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')
+ {
+ fHaveDigit = true;
+ value = value * 10 + (*pch - L'0');
+ ++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 void GetOpenTypeProcs(FwScriptShapeOpenTypeProc * ppfnShape,
+ FwScriptPlaceOpenTypeProc * ppfnPlace)
+{
+ static bool s_fTried = false;
+ static FwScriptShapeOpenTypeProc s_pfnShape = NULL;
+ static FwScriptPlaceOpenTypeProc s_pfnPlace = 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_fTried = true;
+ }
+ *ppfnShape = s_pfnShape;
+ *ppfnPlace = s_pfnPlace;
+}
+
+static bool ShapePlaceRunWithOpenType(UniscribeRunInfo & uri, int cglyphMax,
+ Vector & vfeatureRecords, ABC & abc)
+{
+ FwScriptShapeOpenTypeProc pfnShape;
+ FwScriptPlaceOpenTypeProc pfnPlace;
+ GetOpenTypeProcs(&pfnShape, &pfnPlace);
+ if (!pfnShape || !pfnPlace)
+ return false;
+
+ Vector vcharProps;
+ Vector vglyphProps;
+ Vector vrgich;
+ vcharProps.Resize(uri.cch);
+ vglyphProps.Resize(cglyphMax);
+ vrgich.Resize(1);
+ vrgich[0] = uri.cch;
+
+ FwTextRangeProperties rangeProperties;
+ rangeProperties.potfRecords = vfeatureRecords.Begin();
+ rangeProperties.cotfRecords = vfeatureRecords.Size();
+ FwTextRangeProperties * prangeProperties = &rangeProperties;
+
+ static const OLECHAR rgchLatn[] = { L'l', L'a', L't', L'n', 0 };
+ static const OLECHAR rgchDflt[] = { L'D', L'F', L'L', L'T', 0 };
+ FwOpenTypeTag rgtagScript[] = { MakeOpenTypeTag(rgchLatn), MakeOpenTypeTag(rgchDflt), 0 };
+ HRESULT hr = E_FAIL;
+ for (int itag = 0; itag < isizeof(rgtagScript) / isizeof(rgtagScript[0]); ++itag)
+ {
+ DISABLE_MULTISCRIBE
+ {
+ IgnoreHr(hr = pfnShape(uri.hdc, &uri.sc, uri.psa, rgtagScript[itag], 0, vrgich.Begin(),
+ &prangeProperties, 1, uri.prgch, uri.cch, cglyphMax, uri.prgCluster,
+ vcharProps.Begin(), uri.prgGlyph, vglyphProps.Begin(), &uri.cglyph));
+ }
+ if (FAILED(hr))
+ continue;
+
+ DISABLE_MULTISCRIBE
+ {
+ IgnoreHr(hr = pfnPlace(uri.hdc, &uri.sc, uri.psa, rgtagScript[itag], 0, vrgich.Begin(),
+ &prangeProperties, 1, uri.prgch, uri.prgCluster, vcharProps.Begin(), uri.cch,
+ uri.prgGlyph, vglyphProps.Begin(), uri.cglyph, uri.prgAdvance, uri.prgoff, &abc));
+ }
+ if (SUCCEEDED(hr))
+ break;
+ }
+ uri.fScriptPlaceFailed = FAILED(hr);
+ return SUCCEEDED(hr);
+}
+
// cache of SCRIPT_CACHE values accessed by LgCharRenderProps.
UniscribeSegment::FwScriptCache UniscribeSegment::g_fsc;
@@ -283,6 +473,21 @@ STDMETHODIMP UniscribeSegment::QueryInterface(REFIID riid, void **ppv)
void UniscribeSegment::ShapePlaceRun(UniscribeRunInfo& uri, bool fCreatingSeg)
{
HRESULT hr;
+ const OLECHAR * prgchFontVar = uri.pchrp ? uri.pchrp->szFontVar : NULL;
+ LayoutPassCache * pLayoutPassCache = 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, prgchFontVar);
+ if (pShapeEntry)
+ {
+ uri.sc = g_fsc.FindScriptCache(uri);
+ ApplyShapeRunCacheEntry(*pShapeEntry, uri);
+ return;
+ }
+ }
+ DWORD dwStartMs = (fCreatingSeg && pLayoutPassCache && uri.psa) ? ::GetTickCount() : 0;
// Enhance JohnT: (multithread) lock static buffers.
// Make sure buffers are big enough.
int cglyphMax = uri.CGlyphMax();
@@ -298,6 +503,8 @@ 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);
+ Vector vfeatureRecords;
+ bool fUseOpenTypeFeatures = TryParseFontFeatureRecords(prgchFontVar, vfeatureRecords);
#if !defined(_WIN32) && !defined(_M_X64)
// Associate VwGraphics with the cache as Linux uniscribe implementation needs it.
@@ -306,9 +513,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,
@@ -366,10 +581,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)
+ {
+ abc = abcOpenType;
+ hr = S_OK;
+ }
+ else
{
- IgnoreHr(hr = ::ScriptPlace(uri.hdc, &uri.sc, uri.prgGlyph, uri.cglyph, uri.prgsva,
- uri.psa, uri.prgAdvance, uri.prgoff, &abc));
+ 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))
@@ -418,6 +641,14 @@ void UniscribeSegment::ShapePlaceRun(UniscribeRunInfo& uri, bool fCreatingSeg)
}
}
+ if (fCreatingSeg && pLayoutPassCache && uri.psa)
+ {
+ 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);
@@ -1077,6 +1308,29 @@ int UniscribeSegment::OffsetInNfc(int ich, int ichBase, IVwTextSource * pts)
#endif
}
+// PATH-N1: NFC-aware overload. When fTextIsNfc is true (text already in NFC form),
+// skip the expensive Fetch + NormalizeStrUni and return the identity offset directly.
+// This eliminates redundant COM calls and NFC normalization when the text source
+// already provides NFC text (the common case in FieldWorks).
+int UniscribeSegment::OffsetInNfc(int ich, int ichBase, IVwTextSource * pts, bool fTextIsNfc)
+{
+ Assert(ich >= ichBase);
+ if (fTextIsNfc)
+ return ich - ichBase;
+ return OffsetInNfc(ich, ichBase, pts);
+}
+
+int UniscribeSegment::OffsetInNfc(int ich, int ichBase, IVwTextSource * pts, bool fTextIsNfc,
+ const TextAnalysisEntry * pAnalysis)
+{
+ Assert(ich >= ichBase);
+ if (fTextIsNfc)
+ return ich - ichBase;
+ if (pAnalysis)
+ return pAnalysis->OffsetInNfc(ich, ichBase);
+ return OffsetInNfc(ich, ichBase, pts, fTextIsNfc);
+}
+
// ich is an offset into the (NFC normalized) characters of this segment.
// convert it into a (typically NFD) position in the original paragraph.
// This is complicated because it isn't absolutely guaranteed that the original is
@@ -1115,6 +1369,76 @@ int UniscribeSegment::OffsetToOrig(int ich, int ichBase, IVwTextSource * pts)
#endif
}
+// PATH-N1: NFC-aware overload. When fTextIsNfc is true, original offsets equal NFC offsets,
+// so we can return directly without the expensive iterative Fetch + normalize loop.
+int UniscribeSegment::OffsetToOrig(int ich, int ichBase, IVwTextSource * pts, bool fTextIsNfc)
+{
+ if (fTextIsNfc)
+ return ich + ichBase;
+ return OffsetToOrig(ich, ichBase, pts);
+}
+
+int UniscribeSegment::OffsetToOrig(int ich, int ichBase, IVwTextSource * pts, bool fTextIsNfc,
+ const TextAnalysisEntry * pAnalysis)
+{
+ if (fTextIsNfc)
+ return ich + ichBase;
+ if (pAnalysis)
+ return pAnalysis->OffsetToOrig(ich, ichBase);
+ return OffsetToOrig(ich, ichBase, pts, fTextIsNfc);
+}
+
+static void BuildNfcOffsetMaps(const StrUni & stuOrig, Vector & vichOrigToNfc,
+ Vector & vichNfcToOrig)
+{
+ int cchOrig = stuOrig.Length();
+ vichOrigToNfc.Resize(cchOrig + 1);
+ vichOrigToNfc[0] = 0;
+ for (int ich = 1; ich <= cchOrig; ++ich)
+ {
+ StrUni stuPrefix(stuOrig.Chars(), ich);
+ StrUtil::NormalizeStrUni(stuPrefix, UNORM_NFC);
+ vichOrigToNfc[ich] = stuPrefix.Length();
+ }
+
+ int cchNfc = vichOrigToNfc[cchOrig];
+ vichNfcToOrig.Resize(cchNfc + 1);
+ int ichOrig = 0;
+ for (int ichNfc = 0; ichNfc <= cchNfc; ++ichNfc)
+ {
+ while (ichOrig + 1 <= cchOrig && vichOrigToNfc[ichOrig + 1] <= ichNfc)
+ ++ichOrig;
+ vichNfcToOrig[ichNfc] = ichOrig;
+ }
+}
+
+static void ApplyShapeRunCacheEntry(ShapeRunEntry & entry, UniscribeRunInfo & uri)
+{
+ if (uri.CGlyphMax() < entry.m_cglyph)
+ uri.UpdateGlyphSize(entry.m_cglyph);
+ if (uri.CClusterMax() < entry.m_cch)
+ uri.UpdateClusterSize(entry.m_cch);
+
+ uri.cglyph = entry.m_cglyph;
+ uri.dxdWidth = entry.m_dxdWidth;
+ uri.fScriptPlaceFailed = entry.m_fScriptPlaceFailed;
+ if (uri.psa)
+ *uri.psa = entry.m_sa;
+
+ if (entry.m_cglyph > 0)
+ {
+ ::memcpy(uri.prgGlyph, entry.m_vglyph.Begin(), entry.m_cglyph * isizeof(WORD));
+ ::memcpy(uri.prgsva, entry.m_vsva.Begin(), entry.m_cglyph * isizeof(SCRIPT_VISATTR));
+ ::memcpy(uri.prgAdvance, entry.m_vadvance.Begin(), entry.m_cglyph * isizeof(int));
+ ::memcpy(uri.prgcst, entry.m_vcst.Begin(), entry.m_cglyph * isizeof(int));
+ ::memcpy(uri.prgoff, entry.m_voff.Begin(), entry.m_cglyph * isizeof(GOFFSET));
+ }
+ if (entry.m_cch > 0)
+ ::memcpy(uri.prgCluster, entry.m_vcluster.Begin(), entry.m_cch * isizeof(WORD));
+ if (uri.prgJustAdv && entry.m_cglyph > 0)
+ ::memcpy(uri.prgJustAdv, uri.prgAdvance, entry.m_cglyph * isizeof(int));
+}
+
int OffsetInRun(UniscribeRunInfo & uri, int ichRun, bool fTrailing)
{
int dxdRunToIP;
@@ -2454,9 +2778,43 @@ void UniscribeSegment::AdjustEndForWidth(int ichBase, IVwGraphics * pvg)
----------------------------------------------------------------------------------------------*/
int UniscribeSegment::CallScriptItemize(OLECHAR * prgchDefBuf, int cchBuf,
Vector & vch, IVwTextSource * pts, int ichMin, int cch, OLECHAR ** pprgchBuf,
- int & citem, bool fParaRTL)
+ int & citem, bool fParaRTL, bool * pfTextIsNfc, const TextAnalysisEntry ** ppAnalysis)
{
* pprgchBuf = prgchDefBuf; // Use on-stack variable if big enough
+ if (ppAnalysis)
+ *ppAnalysis = NULL;
+
+ LayoutPassCache * pLayoutPassCache = IsPath2AnalysisCacheEnabled() ? GetCurrentLayoutPassCache() : NULL;
+ TextAnalysisEntry * pCachedAnalysis = NULL;
+ int cchOrig = cch;
+ int ws = 0;
+ bool fWsRtl = false;
+ DWORD dwStartMs = 0;
+ if (pLayoutPassCache && cch > 0)
+ {
+ int ichMin1, ichLim1;
+ LgCharRenderProps chrp;
+ CheckHr(pts->GetCharProps(ichMin, &chrp, &ichMin1, &ichLim1));
+ ws = chrp.ws;
+ fWsRtl = chrp.fWsRtl;
+ pCachedAnalysis = pLayoutPassCache->AnalysisCache().Find(pts, ichMin, cchOrig, ws, fWsRtl);
+ if (pCachedAnalysis)
+ {
+ if (pfTextIsNfc)
+ *pfTextIsNfc = pCachedAnalysis->m_fTextIsNfc;
+ *pprgchBuf = pCachedAnalysis->m_vchNfc.Size() > 0 ? pCachedAnalysis->m_vchNfc.Begin() : prgchDefBuf;
+ pCachedAnalysis->CopyScriptItemsTo(g_vscri, citem);
+ g_cscri = citem;
+ if (ppAnalysis)
+ *ppAnalysis = pCachedAnalysis;
+ return pCachedAnalysis->RequestedNfcLength(cchOrig);
+ }
+ }
+ if (pLayoutPassCache && !pCachedAnalysis)
+ dwStartMs = ::GetTickCount();
+
+ Vector vichOrigToNfc;
+ Vector vichNfcToOrig;
#ifdef UNISCRIBE_NFC
if (cch)
@@ -2466,9 +2824,18 @@ int UniscribeSegment::CallScriptItemize(OLECHAR * prgchDefBuf, int cchBuf,
OLECHAR * pch;
stu.SetSize(cch, &pch);
CheckHr(pts->Fetch(ichMin, ichMin + cch, pch));
+ StrUni stuOrig(stu);
StrUtil::NormalizeStrUni(stu, UNORM_NFC);
if (cch != stu.Length())
cch = stu.Length();
+ // PATH-N1: Only treat offsets as identity when normalization leaves the text
+ // byte-for-byte unchanged. Length equality alone is not sufficient because NFC
+ // can preserve length while still changing code-unit composition.
+ bool fComputedTextIsNfc = (stu == stuOrig);
+ if (pfTextIsNfc)
+ *pfTextIsNfc = fComputedTextIsNfc;
+ if (!fComputedTextIsNfc && pLayoutPassCache)
+ BuildNfcOffsetMaps(stuOrig, vichOrigToNfc, vichNfcToOrig);
if (cch > cchBuf)
{
cchBuf = cch;
@@ -2477,7 +2844,21 @@ int UniscribeSegment::CallScriptItemize(OLECHAR * prgchDefBuf, int cchBuf,
}
::memcpy(*pprgchBuf, stu.Chars(), isizeof(OLECHAR) * cch);
}
+ else
+ {
+ if (pfTextIsNfc)
+ *pfTextIsNfc = true; // Empty text is trivially NFC
+ if (pLayoutPassCache)
+ {
+ vichOrigToNfc.Resize(1);
+ vichOrigToNfc[0] = 0;
+ vichNfcToOrig.Resize(1);
+ vichNfcToOrig[0] = 0;
+ }
+ }
#else
+ if (pfTextIsNfc)
+ *pfTextIsNfc = true; // No NFC mode means offsets are always identity
if (cch > cchBuf)
{
cchBuf = cch;
@@ -2574,6 +2955,18 @@ typedef struct tag_SCRIPT_STATE {
g_vscri[0].iCharPos = 0;
g_vscri[1].iCharPos = 0;
}
+ g_cscri = citem;
+
+ if (pLayoutPassCache)
+ {
+ pLayoutPassCache->AnalysisCache().AddComputeMs(::GetTickCount() - dwStartMs);
+ TextAnalysisEntry * pStoredAnalysis = pLayoutPassCache->AnalysisCache().Store(pts, ichMin,
+ cchOrig, ws, fWsRtl, *pprgchBuf, cch, pfTextIsNfc ? *pfTextIsNfc : true,
+ g_vscri.Begin(), citem, vichOrigToNfc.Size() ? &vichOrigToNfc : NULL,
+ vichNfcToOrig.Size() ? &vichNfcToOrig : NULL);
+ if (ppAnalysis)
+ *ppAnalysis = pStoredAnalysis;
+ }
return cch;
}
#undef MAX_WS_GROUP
@@ -2624,8 +3017,11 @@ template int UniscribeSegment::DoAllRuns(int ichBase, IVwGraphics * pv
Vector vch; // Use as buffer if 1000 is not enough
int citem; // actual number of items obtained.
OLECHAR * prgchBuf; // Where text actually goes.
+ // PATH-N1: Get NFC flag from CallScriptItemize to skip redundant OffsetInNfc calls below.
+ bool fTextIsNfc = false;
+ const TextAnalysisEntry * pAnalysis = NULL;
int cchNfc = CallScriptItemize(rgchBuf, INIT_BUF_SIZE, vch, m_qts, ichBase, m_dichLim, &prgchBuf,
- citem, m_fParaRTL);
+ citem, m_fParaRTL, &fTextIsNfc, &pAnalysis);
// If dxdExpectedWidth is not 0, then the segment will try its best to stretch to the
// specified size.
@@ -2683,7 +3079,7 @@ template int UniscribeSegment::DoAllRuns(int ichBase, IVwGraphics * pv
if (ichLim - ichBase > m_dichLim)
ichLim = ichBase + m_dichLim;
- ichLimNfc = OffsetInNfc(ichLim, ichBase, m_qts);
+ ichLimNfc = OffsetInNfc(ichLim, ichBase, m_qts, fTextIsNfc, pAnalysis);
if (ichLimNfc == ichMinNfc && m_dichLim > 0)
{
// This can happen pathologically where later characters in a composition have different
diff --git a/Src/views/lib/UniscribeSegment.h b/Src/views/lib/UniscribeSegment.h
index ee0abcd76c..297fbe0bb5 100644
--- a/Src/views/lib/UniscribeSegment.h
+++ b/Src/views/lib/UniscribeSegment.h
@@ -29,6 +29,8 @@ typedef Vector OffsetVec; // Hungarian voff
typedef Vector ScrItemVec; // Hungarian vscri;
typedef Vector ScrLogAttrVec; // Hungarian vsla.
+class TextAnalysisEntry;
+
/*----------------------------------------------------------------------------------------------
Class: UniscribeRunInfo
Description: This is the block of information that is passed to all our functors.
@@ -231,7 +233,13 @@ class UniscribeSegment : public ILgSegment
}
static int OffsetInNfc(int ich, int ichBase, IVwTextSource * pts);
+ static int OffsetInNfc(int ich, int ichBase, IVwTextSource * pts, bool fTextIsNfc);
+ static int OffsetInNfc(int ich, int ichBase, IVwTextSource * pts, bool fTextIsNfc,
+ const TextAnalysisEntry * pAnalysis);
static int OffsetToOrig(int ich, int ichBase, IVwTextSource * pts);
+ static int OffsetToOrig(int ich, int ichBase, IVwTextSource * pts, bool fTextIsNfc);
+ static int OffsetToOrig(int ich, int ichBase, IVwTextSource * pts, bool fTextIsNfc,
+ const TextAnalysisEntry * pAnalysis);
protected:
// Static variables
@@ -298,7 +306,7 @@ class UniscribeSegment : public ILgSegment
static void ShapePlaceRun(UniscribeRunInfo& uri, bool fCreatingSeg = false);
static int CallScriptItemize(OLECHAR * prgchDefBuf, int cchBuf, Vector & vch,
IVwTextSource * pts, int ichMin, int cch, OLECHAR ** pprgchBuf, int & citem,
- bool fParaRTL);
+ bool fParaRTL, bool * pfTextIsNfc = NULL, const TextAnalysisEntry ** ppAnalysis = NULL);
int NumStretchableGlyphs();
int StretchGlyphs(UniscribeRunInfo & uri,
diff --git a/Src/views/lib/VwGraphics.cpp b/Src/views/lib/VwGraphics.cpp
index 9e30a945c2..ced86193f9 100644
--- a/Src/views/lib/VwGraphics.cpp
+++ b/Src/views/lib/VwGraphics.cpp
@@ -28,6 +28,25 @@ DEFINE_THIS_FILE
Local Constants and static variables
***********************************************************************************************/
+// Returns the lfQuality value to use for LOGFONT creation.
+// If the FW_FONT_QUALITY env var is set to a valid value (0-6), that value is used.
+// This allows tests to force ANTIALIASED_QUALITY (4) for deterministic rendering.
+static BYTE GetFontQualityOverride()
+{
+ static BYTE s_quality = []() -> BYTE {
+ wchar_t buf[16] = {};
+ DWORD len = ::GetEnvironmentVariableW(L"FW_FONT_QUALITY", buf, _countof(buf));
+ if (len > 0 && len < _countof(buf))
+ {
+ int val = _wtoi(buf);
+ if (val >= 0 && val <= 6)
+ return static_cast(val);
+ }
+ return DRAFT_QUALITY;
+ }();
+ return s_quality;
+}
+
/***********************************************************************************************
Two local classes, copied from AfGfx.h. Maybe we should move them to somewhere they
can be shared more easily?
@@ -76,11 +95,10 @@ VwGraphics::~VwGraphics()
m_hfontOld = NULL;
}
Assert(!m_hfont);
- if (m_hfont)
- {
- fSuccess = AfGdi::DeleteObjectFont(m_hfont);
- m_hfont = NULL;
- }
+ // PATH-C1: Font cache owns all created fonts. Don't double-delete m_hfont
+ // here — ClearFontCache handles it. Just null the pointer.
+ m_hfont = NULL;
+ ClearFontCache();
if (m_hdc)
{
@@ -105,6 +123,7 @@ void VwGraphics::Init()
Assert(m_hfont == NULL);
Assert(m_hfontOld == NULL);
// m_chrp should contain zeros too; don't bother checking.
+ m_colorStateCache.Invalidate();
// Initialize the clip rectangle to be as big as possible:
// TODO: decide if we want to do this.
@@ -124,6 +143,11 @@ static GenericFactory g_fact(
_T("Apartment"),
&VwGraphics::CreateCom);
+static bool TryDeleteCachedFont(HFONT hfont, void *)
+{
+ return AfGdi::DeleteObjectFont(hfont) != 0;
+}
+
void VwGraphics::CreateCom(IUnknown *punkCtl, REFIID riid, void ** ppv)
{
@@ -1084,10 +1108,13 @@ STDMETHODIMP VwGraphics::ReleaseDC()
// original one back into the DC to prevent GDI memory leaks and similar problems.
HFONT hfontPrev; // Fixed release build.
hfontPrev = AfGdi::SelectObjectFont(m_hdc, m_hfontOld, AfGdi::OLD);
- fSuccess = AfGdi::DeleteObjectFont(m_hfont);
+ // PATH-C1: Don't delete m_hfont here — ClearFontCache handles lifecycle.
m_hfont = 0;
m_hfontOld = 0;
}
+ // PATH-C1: Delete all cached fonts now that they're deselected from the DC.
+ ClearFontCache();
+ m_colorStateCache.Invalidate();
Assert(m_hfont == 0);
if (m_hfontOldMeasure)
{
@@ -1214,62 +1241,62 @@ STDMETHODIMP VwGraphics::SetupGraphics(LgCharRenderProps * pchrp)
memcpy(((byte *)&m_chrp) + cbFontOffset, ((byte *)pchrp) + cbFontOffset,
isizeof(m_chrp) - cbFontOffset);
- // Figure the actual font we need.
- LOGFONT lf;
- lf.lfItalic = pchrp->ttvItalic == kttvOff ? false : true;
- lf.lfWeight = pchrp->ttvBold == kttvOff ? 400 : 700;
- // The minus causes this to be the font height (roughly, from top of ascenders
- // to bottom of descenders). A positive number indicates we want a font with
- // this distance as the total line spacing, which makes them too small.
- // Note that we are also scaling the font size based on the resolution.
- lf.lfHeight = -MulDiv(pchrp->dympHeight, GetYInch(), kdzmpInch);
- lf.lfUnderline = false;
- lf.lfWidth = 0; // default width, based on height
- lf.lfEscapement = 0; // no rotation of text (is this how to do slanted?)
- lf.lfOrientation = 0; // no rotation of character baselines
-
- lf.lfStrikeOut = 0; // not strike-out
- lf.lfCharSet = DEFAULT_CHARSET; // let name determine it; WS should specify valid
- lf.lfOutPrecision = OUT_TT_ONLY_PRECIS; // only work with TrueType fonts
- lf.lfClipPrecision = CLIP_DEFAULT_PRECIS; // ??
- lf.lfQuality = DRAFT_QUALITY; // I (JohnT) don't think this matters for TrueType fonts.
- lf.lfPitchAndFamily = 0; // must be zero for EnumFontFamiliesEx
- #ifdef UNICODE
- // ENHANCE: test this path if ever needed.
- wcscpy_s(lf.lfFaceName, pchrp->szFaceName);
- #else // not unicode, LOGFONT has 8-bit chars
- WideCharToMultiByte(
- CP_ACP, 0, // dumb; we don't expect non-ascii chars
- pchrp->szFaceName, // string to convert
- -1, // null-terminated
- lf.lfFaceName, 32,
- NULL, NULL); // default handling of unconvertibles
- #endif // not unicode
- HFONT hfont;
- hfont = AfGdi::CreateFontIndirect(&lf);
- if (!hfont)
- ThrowHr(WarnHr(E_FAIL));
- SetFont(hfont);
+ HFONT hfontCached = FindCachedFont(pchrp);
+ if (hfontCached)
+ {
+ SetFont(hfontCached);
+ }
+ else
+ {
+ // Figure the actual font we need.
+ LOGFONT lf;
+ lf.lfItalic = pchrp->ttvItalic == kttvOff ? false : true;
+ lf.lfWeight = pchrp->ttvBold == kttvOff ? 400 : 700;
+ // The minus causes this to be the font height (roughly, from top of ascenders
+ // to bottom of descenders). A positive number indicates we want a font with
+ // this distance as the total line spacing, which makes them too small.
+ // Note that we are also scaling the font size based on the resolution.
+ lf.lfHeight = -MulDiv(pchrp->dympHeight, GetYInch(), kdzmpInch);
+ lf.lfUnderline = false;
+ lf.lfWidth = 0; // default width, based on height
+ lf.lfEscapement = 0; // no rotation of text (is this how to do slanted?)
+ lf.lfOrientation = 0; // no rotation of character baselines
+
+ lf.lfStrikeOut = 0; // not strike-out
+ lf.lfCharSet = DEFAULT_CHARSET; // let name determine it; WS should specify valid
+ lf.lfOutPrecision = OUT_TT_ONLY_PRECIS; // only work with TrueType fonts
+ lf.lfClipPrecision = CLIP_DEFAULT_PRECIS; // ??
+ lf.lfQuality = GetFontQualityOverride();
+ lf.lfPitchAndFamily = 0; // must be zero for EnumFontFamiliesEx
+ #ifdef UNICODE
+ // ENHANCE: test this path if ever needed.
+ wcscpy_s(lf.lfFaceName, pchrp->szFaceName);
+ #else // not unicode, LOGFONT has 8-bit chars
+ WideCharToMultiByte(
+ CP_ACP, 0, // dumb; we don't expect non-ascii chars
+ pchrp->szFaceName, // string to convert
+ -1, // null-terminated
+ lf.lfFaceName, 32,
+ NULL, NULL); // default handling of unconvertibles
+ #endif // not unicode
+ HFONT hfont;
+ hfont = AfGdi::CreateFontIndirect(&lf);
+ if (!hfont)
+ ThrowHr(WarnHr(E_FAIL));
+ SetFont(hfont);
+ AddFontToCache(hfont, pchrp);
+ }
}
+ m_rgbForeColor = pchrp->clrFore;
+ m_rgbBackColor = pchrp->clrBack;
- // Always set the colors.
- // OPTIMIZE JohnT: would it be useful to remember what the hdc is set to?
+ // PATH-C2: Reuse HDC color state when the requested colors and background mode
+ // already match the previous SetupGraphics call.
{
- SmartPalette spal(m_hdc);
-
- bool fOK = (AfGfx::SetTextColor(m_hdc, pchrp->clrFore) != CLR_INVALID);
- if (pchrp->clrBack == kclrTransparent)
- {
- // I can't find it documented anywhere, but it seems to be necessary to set
- // the background color to black to make TRANSPARENT mode work--at least on my
- // computer.
- fOK = fOK && (::SetBkColor(m_hdc, RGB(0,0,0)) != CLR_INVALID);
- fOK = fOK && ::SetBkMode(m_hdc, TRANSPARENT);
- } else {
- fOK = fOK && (AfGfx::SetBkColor(m_hdc, pchrp->clrBack)!= CLR_INVALID);
- fOK = fOK && ::SetBkMode(m_hdc, OPAQUE);
- }
+ COLORREF clrBackNeeded = (pchrp->clrBack == kclrTransparent) ? RGB(0,0,0) : pchrp->clrBack;
+ int nBkModeNeeded = (pchrp->clrBack == kclrTransparent) ? TRANSPARENT : OPAQUE;
+ m_colorStateCache.ApplyIfNeeded(m_hdc, pchrp->clrFore, clrBackNeeded, nBkModeNeeded);
}
#if 0
// DarrellX reports that this was causing some weird failures on his machine.
@@ -1612,7 +1639,6 @@ int VwGraphics::GetYInch()
----------------------------------------------------------------------------------------------*/
void VwGraphics::SetFont(HFONT hfont)
{
- BOOL fSuccess;
if (hfont == m_hfont)
return;
// Select the new font into the device context
@@ -1630,21 +1656,16 @@ void VwGraphics::SetFont(HFONT hfont)
if (!hfontPrev)
ThrowHr(WarnHr(E_FAIL));
- if (m_hfontOld)
- {
- // We have previously created a font and now need to delete it.
- // NB this must be done after it is selected out of the DC, or we get a hard-to-find
- // GDI memory leak that causes weird drawing failures on W-98.
- Assert(m_hfont);
- fSuccess = AfGdi::DeleteObjectFont(m_hfont);
- }
- else
+ if (!m_hfontOld)
{
// This is the first font selection we have made into this level; save the old one
// to eventually select back into the DC before we RestoreDC.
m_hfontOld = hfontPrev;
}
+ // PATH-C1: Don't delete the previous font here — the font cache manages
+ // all created HFONT lifecycles.
m_hfont = hfont;
+ m_fontHandleCache.TryDeleteDeferredFonts(m_hfont, TryDeleteCachedFont, NULL);
}
/*----------------------------------------------------------------------------------------------
@@ -1866,4 +1887,19 @@ int VwGraphics::GetFontHeightFromFontTable()
}
#include
-template Vector; // VecHfont;
+template Vector; // VecHRgn;
+
+HFONT VwGraphics::FindCachedFont(const LgCharRenderProps * pchrp)
+{
+ return m_fontHandleCache.FindCachedFont(pchrp);
+}
+
+void VwGraphics::AddFontToCache(HFONT hfont, const LgCharRenderProps * pchrp)
+{
+ m_fontHandleCache.AddFontToCache(hfont, pchrp, m_hfont, TryDeleteCachedFont, NULL);
+}
+
+void VwGraphics::ClearFontCache()
+{
+ m_fontHandleCache.Clear(m_hfont, TryDeleteCachedFont, NULL);
+}
diff --git a/Src/views/lib/VwGraphics.h b/Src/views/lib/VwGraphics.h
index 583cd67b15..3329a4aa81 100644
--- a/Src/views/lib/VwGraphics.h
+++ b/Src/views/lib/VwGraphics.h
@@ -23,6 +23,9 @@ Last reviewed: Not yet.
#ifndef VWGRAPHICS_INCLUDED
#define VWGRAPHICS_INCLUDED
+#include "ColorStateCache.h"
+#include "FontHandleCache.h"
+
#if !defined(_WIN32) && !defined(_M_X64)
#include "VwGraphicsCairo.h"
#else
@@ -162,11 +165,17 @@ class VwGraphics : public IVwGraphicsWin32
HFONT m_hfontOldMeasure;
HFONT m_hfont; // current font selected into DC, if any
LgCharRenderProps m_chrp;
+ FontHandleCache m_fontHandleCache;
+ ColorStateCache m_colorStateCache;
// Vertical and horizontal resolution. Zero indicates not yet initialized.
int m_xInch;
int m_yInch;
+ HFONT FindCachedFont(const LgCharRenderProps * pchrp);
+ void AddFontToCache(HFONT hfont, const LgCharRenderProps * pchrp);
+ void ClearFontCache();
+
void Init();
int IntFromFixed(FIXED f)
diff --git a/Src/views/views.vcxproj b/Src/views/views.vcxproj
index 91fc36f294..bc15e8bf27 100644
--- a/Src/views/views.vcxproj
+++ b/Src/views/views.vcxproj
@@ -111,6 +111,8 @@
+
+
@@ -154,6 +156,8 @@
+
+
diff --git a/Src/views/views.vcxproj.filters b/Src/views/views.vcxproj.filters
index e2d597b9b2..24cac1b3b5 100644
--- a/Src/views/views.vcxproj.filters
+++ b/Src/views/views.vcxproj.filters
@@ -73,6 +73,12 @@
lib
+
+ lib
+
+
+ lib
+
lib
@@ -153,6 +159,12 @@
lib
+
+ lib
+
+
+ lib
+
lib
diff --git a/Src/xWorks/CssGenerator.cs b/Src/xWorks/CssGenerator.cs
index a04819121f..37ff4d7f57 100644
--- a/Src/xWorks/CssGenerator.cs
+++ b/Src/xWorks/CssGenerator.cs
@@ -1542,6 +1542,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 +1556,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 +1580,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,10 +1653,14 @@ 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))
- return;
+ {
+ fontValue = fallbackFontValue;
+ if (string.IsNullOrEmpty(fontValue))
+ return;
+ }
var fontProp = new Property("font-feature-settings");
fontProp.Term = ConvertToCssFeatures(fontValue);
declaration.Add(fontProp);
diff --git a/Src/xWorks/WordStylesGenerator.cs b/Src/xWorks/WordStylesGenerator.cs
index 616ad7ee84..16b4c82077 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
{
@@ -630,7 +633,11 @@ 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))
+ {
+ AddOpenTypeFontFeatureProperties(charDefaults, fontFeatures);
+ }
return charDefaults;
}
@@ -763,9 +770,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 67b638d9fa..7f5056f739 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..4b90d9a533 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
@@ -3066,6 +3067,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()
{
diff --git a/Src/xWorks/xWorksTests/LcmWordGeneratorTests.cs b/Src/xWorks/xWorksTests/LcmWordGeneratorTests.cs
index 6af984d64e..e368f87831 100644
--- a/Src/xWorks/xWorksTests/LcmWordGeneratorTests.cs
+++ b/Src/xWorks/xWorksTests/LcmWordGeneratorTests.cs
@@ -8,6 +8,8 @@
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;
@@ -19,6 +21,7 @@
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 +62,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 +214,67 @@ 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);
+ }
+
+ 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 W14.OnOffValues GetOnOffValue(bool value)
+ {
+ return value ? W14.OnOffValues.True : W14.OnOffValues.False;
+ }
+
[Test]
public void GenerateWordDocForEntry_OneSenseWithGlossGeneratesCorrectResult()
diff --git a/Src/xWorks/xWorksTests/XhtmlDocViewTests.cs b/Src/xWorks/xWorksTests/XhtmlDocViewTests.cs
index fd80e1b6d5..cb309c4df9 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,15 @@ public override void FixtureInit()
private const string ConfigurationTemplateWithAllPublications = "" +
"";
+ [Test]
+ public void XhtmlDocView_ImplementsRefreshableRoot()
+ {
+ using (var docView = new TestXhtmlDocView())
+ {
+ 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..ef0a4175e1
--- /dev/null
+++ b/openspec/changes/add-opentype-font-features/design.md
@@ -0,0 +1,113 @@
+## 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.
+- 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.
+- 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.
+
+## 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
+
+**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.
+
+**Rationale:** The control should depend on “has configurable font features,” not “is Graphite.” This preserves current UI reuse in writing-system defaults, styles, and font dialogs.
+
+**Alternatives considered:** Add OpenType conditions directly to `DefaultFontsControl`. Rejected because it would 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.
+
+## 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. |
+
+## Migration Plan
+
+1. Wait until `001-render-speedup` is merged into the target branch.
+2. Add provider abstractions, parser/normalizer tests, and UI tests without changing rendering behavior.
+3. Add OpenType feature discovery for the UI and preserve Graphite provider behavior.
+4. Add native OpenType shaping/placing support and native tests.
+5. Add render snapshot scenarios using the merged render baseline infrastructure.
+6. Add test-only HarfBuzzSharp + SkiaSharp comparison tests in FieldWorks test projects.
+7. Update help/localized UI text.
+8. Add Word DOCX export mapping for the documented WordprocessingML subset 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.
+
+## Open Questions
+
+1. Which redistributable fonts should be committed as deterministic test assets: Charis SIL 5.000, Abyssinica SIL, Lorna Evans, or a smaller purpose-built test font?
+2. Should OpenType feature UI list only detected font features or also expose common tags not advertised by all fonts?
+3. Should the production OpenType path use Uniscribe OpenType APIs only, or is a DirectWrite spike required before implementation?
+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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## 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..bddec76776
--- /dev/null
+++ b/openspec/changes/add-opentype-font-features/proposal.md
@@ -0,0 +1,45 @@
+## 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.
+
+## 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.
+- 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 UI/component tests for font-feature controls and high-level visual rendering tests proving feature settings change output.
+- 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.
+- 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.
+
+## 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.
+- **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.
\ No newline at end of file
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..4460f17c42
--- /dev/null
+++ b/openspec/changes/add-opentype-font-features/research.md
@@ -0,0 +1,133 @@
+## 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 already emits `font-feature-settings` and is covered by `GenerateCssForConfiguration_CharStyleFontFeaturesWorks`. Notebook export preserves writing-system `DefaultFontFeatures`. `WordStylesGenerator` did not show a feature-string mapping and should be tracked separately if Word export parity is required.
+- 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.
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..41735e35c8
--- /dev/null
+++ b/openspec/changes/add-opentype-font-features/specs/architecture/testing/test-strategy/spec.md
@@ -0,0 +1,31 @@
+## 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
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..6cf2dcf3d5
--- /dev/null
+++ b/openspec/changes/add-opentype-font-features/specs/architecture/ui-framework/views-rendering/spec.md
@@ -0,0 +1,30 @@
+## 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
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..e7962931f2
--- /dev/null
+++ b/openspec/changes/add-opentype-font-features/specs/architecture/ui-framework/winforms-patterns/spec.md
@@ -0,0 +1,23 @@
+## 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
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..3dba99d4b9
--- /dev/null
+++ b/openspec/changes/add-opentype-font-features/specs/font-feature-settings/spec.md
@@ -0,0 +1,117 @@
+## 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: 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/tasks.md b/openspec/changes/add-opentype-font-features/tasks.md
new file mode 100644
index 0000000000..7c17a0f1ce
--- /dev/null
+++ b/openspec/changes/add-opentype-font-features/tasks.md
@@ -0,0 +1,90 @@
+## 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]
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..e10e93c5ea
--- /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.
\ No newline at end of file
diff --git a/openspec/changes/render-speedup-benchmark/.openspec.yaml b/openspec/changes/render-speedup-benchmark/.openspec.yaml
new file mode 100644
index 0000000000..d0ec88b291
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-02-20
diff --git a/openspec/changes/render-speedup-benchmark/PERFORM_OFFSCREENLAYOUT_PATHS_1_2_5_PLAN.md b/openspec/changes/render-speedup-benchmark/PERFORM_OFFSCREENLAYOUT_PATHS_1_2_5_PLAN.md
new file mode 100644
index 0000000000..e7de0ba491
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/PERFORM_OFFSCREENLAYOUT_PATHS_1_2_5_PLAN.md
@@ -0,0 +1,747 @@
+# PerformOffscreenLayout Optimization Plan (Paths 1+2+5)
+
+## Purpose
+
+Define the implementation plan for three native text-layout optimization paths targeting `PerformOffscreenLayout`:
+
+- Path 1: ShapePlaceRun result caching (revised from FindBreakPoint memoization)
+- Path 2: Text analysis cache (`ScriptItemize`, NFC buffers, and non-NFC offset maps)
+- Path 5: Common-case fast path in line breaking
+
+This plan includes:
+
+- concrete code changes,
+- phased task list,
+- expected savings grounded in the current render run,
+- edge-case test coverage (unit + render/timing),
+- explicit pre-implementation decisions that still need to be made.
+
+## Current Evidence (2026-03-11)
+
+Source artifacts:
+
+- `Output/RenderBenchmarks/summary.md`
+- `Output/RenderBenchmarks/results.json`
+- `Output/RenderBenchmarks/summary.cache-on-final.md`
+- `Output/RenderBenchmarks/results.cache-on-final.json`
+- `Output/RenderBenchmarks/summary.cache-off.md`
+- `Output/RenderBenchmarks/results.cache-off.json`
+
+Latest cache-enabled timing-suite run `aeead2c451e2426e92a8b945790d5e80` (`2026-03-11 15:53:56Z`):
+
+- Flags: `FW_PERF_P125_PATH1=1`, `FW_PERF_P125_PATH2=1`, `FW_PERF_P125_PATH5=not-implemented`
+- Avg cold render: 110.90 ms
+- Avg warm render: 43.72 ms
+- Avg cold `PerformOffscreenLayout`: 90.54 ms
+- Avg warm `PerformOffscreenLayout`: 1.81 ms
+- `PerformOffscreenLayout` remains the dominant traced stage (55.6% of traced stage time)
+
+Matched cache-disabled control run `e2167ab0e60f4dd0a944601e266ca3aa` (`2026-03-11 15:52:23Z`):
+
+- Flags: `FW_PERF_P125_PATH1=0`, `FW_PERF_P125_PATH2=0`, `FW_PERF_P125_PATH5=not-implemented`
+- Avg cold render: 156.44 ms
+- Avg warm render: 77.04 ms
+- Avg cold `PerformOffscreenLayout`: 136.80 ms
+- Avg warm `PerformOffscreenLayout`: 2.27 ms
+
+Observed cache-on improvements versus control:
+
+- Avg cold render: 156.44 → 110.90 ms (`-45.54 ms`, about `-29.1%`)
+- Avg cold `PerformOffscreenLayout`: 136.80 → 90.54 ms (`-46.26 ms`, about `-33.8%`)
+- Avg warm render: 77.04 → 43.72 ms (`-33.32 ms`, about `-43.2%`)
+
+Representative heavy-scenario cold `PerformOffscreenLayout` wins from the matched control pair:
+
+- `footnote-heavy`: 459.00 → 139.65 ms
+- `many-paragraphs`: 194.56 → 63.61 ms
+- `complex`: 191.27 → 68.40 ms
+- `deep-nested`: 97.05 → 30.76 ms
+- `medium`: 89.49 → 28.11 ms
+- `custom-heavy`: 84.70 → 35.16 ms
+
+Some scenarios were noisy or slightly worse in the single-run control pair (`lex-deep`, `lex-extreme`, `multi-book`, `multi-ws`). Treat the current pair as strong directional evidence, not the final tuned benchmark set.
+
+Historical pre-implementation benchmark reference `1b193fb08a1b477f9419b59b0ec8d78f` (`2026-03-10 19:33:16Z`):
+
+- `long-prose`: 149.90/152.92 ms (98.0%)
+- `mixed-styles`: 109.37/115.23 ms (94.9%)
+- `rtl-script`: 67.28/70.09 ms (96.0%)
+- `lex-extreme`: 82.12/86.39 ms (95.1%)
+
+## Branch Context: Optimizations Already Landed
+
+This plan was originally drafted against an older benchmark snapshot. On the current
+`001-render-speedup` branch, several related optimizations have already changed the baseline:
+
+- `PATH-L1` width-invariant layout guard is complete and validated.
+- `PATH-L4` harness GDI resource caching is complete.
+- `PATH-R1` reconstruct guard is complete.
+- `PATH-C1` HFONT cache and `PATH-C2` color-state cache are complete.
+- `PATH-N1` NFC-aware fast path is complete in the Uniscribe pipeline.
+
+Implications for this plan:
+
+1. Warm-path protection is already excellent and must not regress.
+2. Cold-path numbers must now be evaluated against the current 49.22 ms baseline, not the older 64.98 ms run.
+3. Path 2 is no longer "cache NFC work from scratch"; `PATH-N1` already removes redundant normalization work for the common `fTextIsNfc == true` case.
+4. The remaining native opportunity is still real because `PerformOffscreenLayout` now dominates an even larger share of cold render time.
+
+## Validated Implementation Status
+
+Implemented and validated on this branch:
+
+- `LayoutPassCache` is owned by `ParaBuilder` and exposed through a thread-local pointer.
+- Path 2 is implemented: `CallScriptItemize` analysis reuse, cached NFC text, cached non-NFC offset maps, and `DoAllRuns` reuse of cached analysis.
+- Path 1 first slice is implemented: `ShapePlaceRun` result reuse keyed by NFC text content + font + `SCRIPT_ANALYSIS`, with deep-copied shaping buffers.
+- Shape-cache hits are allowed for all `ShapePlaceRun` callers, while new cache stores remain restricted to `fCreatingSeg` calls.
+- Layout-pass telemetry is implemented and emitted from `ParaBuilder` teardown: analysis/shape request counts, hit/miss counts, evictions, and miss compute time.
+- Runtime kill switches are implemented for the validated paths:
+ - `FW_PERF_P125_PATH1`
+ - `FW_PERF_P125_PATH2`
+- Render benchmark artifacts now record active P125 flag states and per-scenario cold/warm `PerformOffscreenLayout` totals.
+
+Explicitly attempted and rejected:
+
+- Broad reuse of cached `ScriptBreak` / `ScriptGetLogicalWidths` results from the shape cache was implemented experimentally and then fully reverted after it caused large pixel-output regressions across 12 render scenarios.
+
+Still pending:
+
+- Path 5 fast path is not implemented.
+- A native runtime flag for Path 5 is not yet needed because there is no Path 5 code to gate.
+- There are no new native unit tests yet for the new cache layers; current confidence comes from existing native tests plus render/timing/baseline validation.
+
+## Design Goals and Constraints
+
+Goals:
+
+1. Reduce cold `PerformOffscreenLayout` by at least 20% in heavy scenarios.
+2. Preserve exact line-break and pixel output behavior.
+3. Keep warm-path gains intact (no regressions to `PATH-L1`, `PATH-L4`, or `PATH-R1`).
+
+Constraints:
+
+1. Text pipeline correctness (bidi, surrogates, trailing whitespace, fallback) is non-negotiable.
+2. Optimizations must be safely disableable (feature flags or compile-time guard).
+3. Cache invalidation must be conservative; stale cache is worse than a miss.
+4. Existing `PATH-N1` NFC fast path should remain the common-case fast path for already-normalized text.
+
+## Scope
+
+In scope:
+
+- `Src/views/lib/UniscribeEngine.cpp`
+- `Src/views/lib/UniscribeSegment.cpp`
+- `Src/views/lib/UniscribeSegment.h`
+- `Src/views/lib/UniscribeEngine.h` (if new helper declarations are required)
+- `Src/views/Test/*` for native unit coverage
+- `Src/Common/RootSite/RootSiteTests/*` for render/timing assertions
+
+Out of scope:
+
+- DataTree architectural virtualization changes
+- Graphite engine redesign
+- DirectWrite migration
+
+## Architectural Analysis: Why Full FindBreakPoint Memoization Was Discarded
+
+Deep-dive analysis (traced `ParaBuilder::MainLoop` → `AddWsRun` → `GetSegment` → `FindBreakPoint`
+and the `BackTrack` re-entry path) revealed that memoizing the complete `FindBreakPoint` result
+has a near-zero hit rate:
+
+- **Pattern A — Sequential segments (most common):** `AddWsRun` calls `GetSegment` with advancing
+ `ichMinSeg` positions. Each call starts where the previous one ended. Non-overlapping calls mean
+ no repeated `(ichMinSeg, dxMaxWidth)` tuple.
+- **Pattern B — Backtracking:** `BackTrack()` re-calls `GetSegment` with the same `ichMinSeg` but
+ different `ichLimBacktrack` and potentially different width. No exact-parameter match.
+- **Pattern C — Bidi ws toggle:** `AddWsRun` may retry with `ktwshOnlyWs` after `ktwshNoWs` fails.
+ Same text, different `twsh`.
+- **Pattern D — Re-layout:** Same paragraph at different `dxAvailWidth` — all parameters differ.
+ (Already handled by `PATH-L1` guard when width is unchanged.)
+
+The expensive operations inside `FindBreakPoint` — `CallScriptItemize`, `ShapePlaceRun`,
+`ScriptBreak`, `ScriptGetLogicalWidths` — depend only on text content + font/HDC + writing system.
+They do not depend on `dxMaxWidth`, `ichLimBacktrack`, `lbPref`, `twsh`, or any layout-policy
+parameters. The policy parameters only control how the main loop uses those results to pick a
+break point.
+
+This means the valuable cache target is the sub-result level (analysis products and shaped runs),
+not the full `FindBreakPoint` output.
+
+## Invalidation Contract
+
+`IVwTextSource` has no version stamp or change counter (confirmed by inspecting `VwTxtSrc.h`).
+This is intentional: the architecture guarantees text immutability during a layout pass.
+
+- `PropChanged` → `VwNotifier::PropChanged` → regenerates boxes → `DoLayout`/`Relayout` is a
+ sequential pipeline. Text source content cannot change while `ParaBuilder::MainLoop` is running.
+- The `IVwTextSource *` pointer identity is stable throughout the entire `ParaBuilder` lifetime.
+- `UniscribeEngine` instances are shared (cached per writing system + font + bold/italic in
+ `RenderEngineFactory`), so cache must be scoped to the layout pass, not the engine.
+
+**Invalidation strategy:** Clear all caches at layout-pass boundary. No version stamps needed.
+Within a pass, `IVwTextSource *` pointer + character range is a sufficient identity key.
+
+## Cache Ownership
+
+Because `UniscribeEngine` is shared across paragraphs and layout passes, caches cannot live on
+the engine instance. Two viable ownership models:
+
+1. **Stack-scoped context object** passed through `FindBreakPoint` (requires signature change or
+ thread-local) — cleanest lifecycle, automatic cleanup.
+2. **Layout-pass context on `VwParagraphBox` or `ParaBuilder`** — passed via `IVwTextSource`
+ extension or side-channel. Cleared when `ParaBuilder` is destroyed.
+
+Preferred direction: thread-local or RAII-managed layout-pass context, because it keeps cache
+lifetime explicit without changing the COM-facing `IRenderEngine::FindBreakPoint` signature.
+
+Research refinement: `ParaBuilder` already owns the full layout-pass lifetime and has a
+destructor in `VwTextBoxes.cpp`, so the cleanest implementation is a `ParaBuilder`-owned cache
+with a thread-local scope helper only if needed to reach `FindBreakPoint` without a COM signature
+change.
+
+## Path 1: ShapePlaceRun Result Caching (Revised)
+
+### Intent
+
+Cache per-run glyph shaping results (`ScriptShape` + `ScriptPlace` outputs) within a layout pass,
+so that when backtracking re-encounters a run that was already shaped, it reuses the
+widths/glyphs without calling the expensive Win32 shaping APIs again.
+
+### Why this replaces full FBP memoization
+
+`ShapePlaceRun` is the single most expensive call inside the `FindBreakPoint` main loop
+(each invocation calls `ScriptShape` + `ScriptPlace`, both of which hit the font rasterizer).
+During backtracking (Pattern B), the loop re-shapes runs it already shaped in the previous
+attempt. Caching the shaped results eliminates this redundant work.
+
+### Cache key
+
+```
+ShapeRunKey = {
+ const OLECHAR * prgch, // NFC text slice (content, not pointer — memcmp)
+ int cch, // length of the NFC text slice
+ HFONT hfont, // font handle (from VwGraphics font cache)
+ SCRIPT_ANALYSIS sa // Uniscribe script analysis (from ScriptItemize)
+}
+```
+
+### Cache value
+
+```
+ShapeRunValue = {
+ WORD * prgGlyphs, // glyph indices
+ int cglyph, // glyph count
+ int * prgAdvance, // advance widths
+ GOFFSET * prgGoffset, // glyph offsets
+ WORD * prgCluster, // cluster mapping
+ SCRIPT_VISATTR * prgsva, // visual attributes
+ int dxdWidth, // total run width
+ int * prgcst, // stretch types
+ bool fScriptPlaceFailed // whether ScriptPlace failed
+}
+```
+
+### Implementation approach
+
+1. Introduce a `ShapeRunCache` class with a small fixed-capacity map (initially about 32 entries).
+ Key comparison is `memcmp` on the NFC text content + `HFONT` + `SCRIPT_ANALYSIS` bytes.
+2. At the start of `FindBreakPoint`, accept or create the cache context.
+3. Before `ShapePlaceRun(uri, true)`, probe the cache. On hit, copy cached values into `uri`.
+ On miss, call `ShapePlaceRun` as before, then store the result.
+4. Cache lifetime: created by `ParaBuilder` at layout-pass start, destroyed when `ParaBuilder`
+ goes out of scope. Passed to `FindBreakPoint` via thread-local or added parameter.
+5. Sub-task: deep-copy the owned `UniscribeRunInfo` buffers. Current code shows
+ `UniscribeRunInfo` owns its glyph/advance/cluster/offset storage via `realloc`, so the cache
+ should copy those owned buffers directly rather than trying to alias any transient `g_v*` state.
+
+### Scenarios that benefit
+
+- **Backtracking** (Pattern B): runs shaped in the first attempt are reused when the loop retries
+ with `ichLimBacktrack` reduced.
+- **Bidi ws toggle** (Pattern C): the same text is shaped twice with different `twsh` — the
+ shaping result is identical.
+- **Multi-line paragraphs**: runs near the end of line N that did not fit may be re-shaped at
+ the start of line N+1 with the same text content.
+
+### Expected savings
+
+- `PerformOffscreenLayout`: about 5-12% reduction (concentrated in backtrack-heavy scenarios)
+- Best gains in `long-prose`, `lex-extreme` (many lines, frequent backtracking)
+- Modest gains in simple scenarios (little backtracking)
+
+## Path 2: Text Analysis Cache (`ScriptItemize` + NFC buffers + non-NFC maps)
+
+### Intent
+
+Compute and reuse the expensive text-analysis products (`ScriptItemize`, NFC text buffers,
+and non-NFC offset maps) once per paragraph text source, instead of recomputing them on every
+`FindBreakPoint` call.
+
+This is still the main analysis-layer optimization, but it is now narrower than the original
+idea because `PATH-N1` already removes redundant normalization work when the source text is
+already NFC. The main remaining target is repeated `CallScriptItemize` work plus the
+non-NFC offset-map case.
+
+### Call-chain analysis: where the redundancy is
+
+During a single `ParaBuilder::MainLoop` pass for one paragraph:
+
+1. `AddWsRun` calls `GetSegment` → `FindBreakPoint` once per line segment.
+2. Each `FindBreakPoint` call re-runs `CallScriptItemize` on the same text range
+ (`ichMinSeg..ichLimText`). For a 10-line paragraph, this means 10+ calls to
+ `ScriptItemize` on overlapping or identical text windows.
+3. `BackTrack` re-calls `FindBreakPoint` on the same text with different limits,
+ triggering another full `CallScriptItemize`.
+4. `UniscribeSegment::DoAllRuns` also calls `CallScriptItemize` again when the
+ segment is drawn, so the same paragraph can be itemized once in `FindBreakPoint`
+ and again in the segment path.
+
+The text content does not change between any of these calls. The `IVwTextSource *` is the
+same object with the same content throughout the entire `ParaBuilder` lifetime.
+
+### Cache key
+
+```
+AnalysisKey = {
+ IVwTextSource * pts, // pointer identity — stable within layout pass
+ int ichMinSeg, // start of text region in source
+ int ichLimText, // end of text region in source
+ int ws, // writing system (from chrp.ws)
+ ComBool fParaRtoL // paragraph direction (affects ScriptItemize)
+}
+```
+
+Note: `HFONT` is not part of this key — `ScriptItemize` and normalization are font-independent.
+Font only matters for shaping (Path 1).
+
+Research refinement: match the key to the actual `CallScriptItemize` inputs in the current code.
+`UniscribeSegment::CallScriptItemize(...)` derives bidi state from `GetCharProps(ichMin)`
+(`chrp.fWsRtl`) and does not currently consume `fParaRtoL` when building `SCRIPT_STATE`.
+That means the cache key should be based on text source identity/span plus the effective initial
+bidi state used by `CallScriptItemize`, not on `HFONT` and not necessarily on paragraph RTL alone.
+
+### Cache value
+
+```
+AnalysisValue = {
+ OLECHAR * prgchNfc, // NFC-normalized text buffer (owned copy)
+ int cchNfc, // NFC text length
+ bool fTextIsNfc, // whether source text was already NFC
+ SCRIPT_ITEM * prgscri, // ScriptItemize results (owned copy)
+ int citem, // script item count
+ // Offset maps (only populated when fTextIsNfc == false):
+ int * prgichOrigToNfc, // original→NFC offset map
+ int * prgichNfcToOrig, // NFC→original offset map
+}
+```
+
+### Implementation approach
+
+1. **Introduce `TextAnalysisCache` class** with a small map (initially about 8-16 entries,
+ keyed by `AnalysisKey`).
+ - Owns allocated buffers; entries freed on cache destruction.
+ - Supports `Lookup(key) → AnalysisValue*` and `Store(key, value)`.
+
+2. **Refactor the `CallScriptItemize` call site in `FindBreakPoint`** (currently around
+ `UniscribeEngine.cpp` line 414):
+ - Before calling `CallScriptItemize`, probe the cache.
+ - On hit: copy `prgchNfc`, `cchNfc`, `fTextIsNfc`, `prgscri`, `citem` from cache
+ into local variables. Skip the `CallScriptItemize` call entirely.
+ - On miss: call `CallScriptItemize` as today, then store results in cache.
+
+3. **Refactor offset conversion calls** (`OffsetInNfc`, `OffsetToOrig`):
+ - When the analysis cache entry exists with `fTextIsNfc == true`, these are identity
+ (already optimized by `PATH-N1`). No extra conversion cache is needed.
+ - When `fTextIsNfc == false`, use the cached offset maps instead of re-normalizing.
+ Currently `OffsetInNfc` calls `StrUtil::NormalizeStrUni` on each call.
+
+4. **Wire `DoAllRuns` to consume cached analysis** when the same text source
+ is still active. `DoAllRuns` in `UniscribeSegment.cpp` still calls
+ `CallScriptItemize`; if the cache is available via thread-local or parameter,
+ it can skip re-itemization there as well.
+
+5. **Cache lifetime:** Same as Path 1 — created by `ParaBuilder`, destroyed when
+ `ParaBuilder` goes out of scope. Same context object can hold both caches.
+
+### Concrete code changes
+
+| File | Change |
+|------|--------|
+| New: `Src/views/lib/LayoutCache.h` | `TextAnalysisCache` and `ShapeRunCache` class definitions, key/value structs |
+| `Src/views/lib/UniscribeEngine.cpp` | `FindBreakPoint`: probe analysis cache before `CallScriptItemize`, store on miss |
+| `Src/views/lib/UniscribeSegment.cpp` | `CallScriptItemize`: extract into cacheable form; `DoAllRuns`: probe analysis cache before itemizing |
+| `Src/views/lib/UniscribeSegment.h` | Updated `CallScriptItemize` signature or overload accepting cache |
+| `Src/views/VwTextBoxes.cpp` | `ParaBuilder`: create/own `LayoutPassCache` context and expose it to layout code |
+
+### Expected savings
+
+- `PerformOffscreenLayout`: about 8-15% reduction if repeated itemization remains a top residual cost after `PATH-N1`
+- Total cold average: about 4-7% reduction from the current 49.22 ms baseline
+- Strongest in multi-line paragraphs, redraws that revisit the same paragraph text, and render paths that hit both `FindBreakPoint` and `DoAllRuns`
+- Savings from NFC normalization itself should be treated as mostly already captured by `PATH-N1`
+
+## Path 5: Common-Case Fast Path in FindBreakPoint
+
+### Intent
+
+Short-circuit common LTR/no-special handling path with fewer branches and less fallback machinery.
+
+### Main implementation changes
+
+1. Add explicit fast-path gate for requests meeting all conditions:
+ - LTR simple direction depth,
+ - no complex trailing-whitespace mode,
+ - no hard breaks in candidate window,
+ - no script/layout failure flags.
+2. In fast path:
+ - use precomputed analysis from Path 2,
+ - perform streamlined width accumulation and break lookup,
+ - avoid generic backtracking scaffolding unless needed.
+3. Immediate fallback to existing generic logic when any guard fails.
+
+### Expected savings
+
+- `PerformOffscreenLayout`: about 4-10% reduction if loop/control-flow overhead remains visible after Paths 1 and 2
+- Total cold average: likely secondary to Paths 1 and 2; only justify if re-profiling shows the generic loop itself still dominates
+
+## Decisions Required Before Further Coding
+
+## Research-Backed Recommendation
+
+Code review plus Microsoft Uniscribe guidance suggest the following execution order and guardrails:
+
+1. **Implement `LayoutPassCache` at `ParaBuilder` scope first.** Uniscribe `SCRIPT_CACHE` is
+ already per-font/style and should stay that way; the new cache should capture higher-level
+ analysis/shaping results for one layout pass only, not live on the shared `UniscribeEngine`.
+2. **Do Path 2 before Path 1.** The current branch duplicates `CallScriptItemize(...)` in both
+ `UniscribeEngine::FindBreakPoint(...)` and `UniscribeSegment::DoAllRuns(...)`, and repeats
+ `OffsetInNfc`/`OffsetToOrig` work on non-NFC text. That duplication happens on every segment,
+ not just on backtracking-heavy paragraphs.
+3. **Do Path 1 second, after Path 2 telemetry is in place.** `ShapePlaceRun(...)` reuse is still
+ valuable, but its biggest wins are tied to backtracking and rerun scenarios. It should be keyed
+ by NFC text content + font/style identity + `SCRIPT_ANALYSIS`, with deep-copied buffers.
+4. **Defer Path 5 until re-profiling after Paths 1/2.** Microsoft guidance for `ScriptBreak`
+ explicitly assumes whole-item processing, so a manual fast path must preserve item boundaries and
+ fall back immediately on any mixed-script, bidi, whitespace-mode, or fallback-font complexity.
+5. **Add telemetry before tuning capacities.** Reuse the existing render-trace infrastructure or
+ equivalent counters to record cache hits, misses, evictions, `CallScriptItemize` timings,
+ `ShapePlaceRun` timings, and backtrack frequency before locking in capacities or defaults.
+
+1. **Cache threading model**
+ - Preferred choice: `ParaBuilder` owns the cache object; a narrow thread-local scope helper can
+ expose it to `FindBreakPoint` without changing the COM-facing `IRenderEngine::FindBreakPoint`
+ signature.
+ - Reason: matches the real layout-pass lifetime and avoids putting mutable state on the shared
+ render engine.
+ - Must still verify no cross-thread reuse of the same layout context is possible.
+
+2. **Initial cache capacities**
+ - The draft `~16` analysis entries and `~32` shaped-run entries are placeholders, not validated values.
+ - First implementation should instrument hit/miss/eviction counts and tune after benchmark runs.
+
+3. **Feature-flag rollout**
+ - The three paths should remain independently disableable.
+ - Default enablement should be decided after correctness coverage and first benchmark pass, not assumed up front.
+
+4. **Path ordering after current profiling**
+ - `PATH-N1` changed the cost distribution inside the Uniscribe path.
+ - Re-profile the current branch before committing to Path 2 as the first substantive optimization after infrastructure work.
+
+Current status of these decisions:
+
+- Cache ownership/lifetime: settled in favor of `ParaBuilder` + thread-local exposure.
+- Initial capacities: implemented as `16` analysis entries and `32` shaped-run entries, with telemetry in place for later tuning.
+- Feature-flag rollout: implemented paths default on and are independently disableable via environment variables.
+- Path ordering: resolved in practice as infrastructure → Path 2 → Path 1 slice → re-profile → Path 5 last.
+
+## Execution Order
+
+Based on the current branch state, the revised execution order is:
+
+1. **Refresh the micro-profile against the current branch** — confirm the residual split between
+ `CallScriptItemize`, non-NFC offset work, and `ShapePlaceRun` after `PATH-N1`.
+2. **P125-1 infrastructure first** — `LayoutPassCache` lifetime and cache plumbing are required
+ regardless of whether Path 1 or Path 2 lands first.
+3. **Default to Path 2 first unless the refreshed profile clearly disproves it**:
+ - repeated itemization and non-NFC offset conversion are currently duplicated in both
+ `FindBreakPoint` and `DoAllRuns`;
+ - Path 1 should move ahead only if the refreshed profile shows backtracking reshaping dominating
+ more than expected.
+4. **Re-profile after Paths 1 and/or 2** to measure actual gains and identify remaining hot spots.
+5. **Path 5 last** — only if re-profiling still shows meaningful generic loop overhead after the
+ expensive API calls are being reused.
+
+Current state:
+
+- Steps 1-4 are complete enough for the current slice.
+- The next substantive implementation target is Path 5, guided by the refreshed benchmark/reporting data.
+
+## Combined Savings Model (Paths 1+2+5)
+
+Combined reductions are not additive. Against the current baseline, a realistic expectation is:
+
+- `PerformOffscreenLayout`: 15-25% reduction on heavy scenarios
+- Total cold average: about 3.4-6.8 ms saved on the current 49.22 ms average cold baseline
+
+Using current average cold baseline (49.22 ms):
+
+- projected cold range: about 42.4-45.8 ms
+- projected average savings: about 3.4-6.8 ms
+- heavy-case savings (`long-prose`/`mixed-styles`/`lex-extreme`) can be materially larger.
+
+Note: Previous estimates were optimistic because they assumed full `FindBreakPoint` memoization hits.
+The revised model reflects sub-result caching, which eliminates API call overhead but
+still requires the loop logic to run. It also assumes `PATH-L1`, `PATH-L4`, `PATH-R1`, and `PATH-N1`
+remain intact.
+
+## Implementation Task List
+
+### P125-0: Refresh evidence and settle design choices
+
+- [x] Reconfirm the current `PerformOffscreenLayout` sub-cost split on this branch before choosing Path 1 vs. Path 2 ordering.
+- [x] Decide whether the cache context is thread-local RAII or an explicit parameter path.
+- [x] Decide initial cache capacities and required hit/miss/eviction telemetry.
+- [x] Decide initial feature-flag defaults and rollout strategy.
+
+### P125-1: LayoutPassCache infrastructure
+
+- [x] Create `Src/views/lib/LayoutCache.h` with:
+ - `AnalysisKey` and `AnalysisValue` structs.
+ - `ShapeRunKey` and `ShapeRunValue` structs.
+ - `TextAnalysisCache` class (fixed-capacity map, initial target about 16 entries, key comparison via `memcmp`).
+ - `ShapeRunCache` class (fixed-capacity map, initial target about 32 entries).
+ - `LayoutPassCache` wrapper owning both caches.
+- [x] Add `LayoutPassCache` creation in `ParaBuilder::Initialize` (`VwTextBoxes.cpp`).
+- [x] Thread the cache to `GetSegment` → `FindBreakPoint` via either:
+ - (a) thread-local pointer set/cleared by `ParaBuilder`, or
+ - (b) added parameter to `IRenderEngine::FindBreakPoint` (COM signature change — heavier).
+ - Current recommendation: prefer (a) to avoid COM interface change.
+- [x] Ensure cache is destroyed when `ParaBuilder` goes out of scope (RAII or destructor).
+
+### P125-2: Path 2 — Text analysis cache
+
+- [x] Refactor `CallScriptItemize` call in `FindBreakPoint` (currently around line 414 of `UniscribeEngine.cpp`):
+ - Build `AnalysisKey` from `{pts, ichMinSeg, ichLimText, chrpThis.ws, fParaRtoL}`.
+ - Probe `TextAnalysisCache`. On hit, use cached `prgchNfc`, `cchNfc`, `fTextIsNfc`,
+ `prgscri`, `citem`. Skip `CallScriptItemize`.
+ - On miss, call `CallScriptItemize` as today, then store results.
+- [x] Refactor `OffsetInNfc`/`OffsetToOrig` calls in the main loop to use cached offset maps
+ when `fTextIsNfc == false` (when `true`, these are already identity via `PATH-N1`).
+- [x] Add overload or optional parameter to `DoAllRuns` in `UniscribeSegment.cpp` to accept
+ cached analysis and skip its own `CallScriptItemize` call.
+- [x] Add trace counter: analysis cache hits vs. misses per layout pass.
+
+### P125-3: Path 1 — ShapePlaceRun cache
+
+- [x] Before `ShapePlaceRun(uri, true)` in `FindBreakPoint` main loop (currently around line 547):
+ - Build `ShapeRunKey` from `{uri.prgch, uri.cch, hfont (from VwGraphics), uri.psa}`.
+ - Probe `ShapeRunCache`. On hit, copy cached glyph/advance/cluster/width data into `uri`.
+ - On miss, call `ShapePlaceRun` as today, then deep-copy results into cache.
+- [x] Deep-copy `UniscribeRunInfo` owned glyph/advance/cluster/offset buffers into the cache;
+ do not rely on transient vector state.
+- [x] Add trace counter: shaping cache hits vs. misses per layout pass.
+- [x] Validate that `BackTrack()` re-entry into `FindBreakPoint` now hits the shaping cache
+ for previously-shaped runs.
+
+Implementation note: the validated slice caches shaping outputs only. A broader attempt to cache logical widths and `ScriptBreak` results was reverted after render regressions.
+
+### P125-4: Path 5 fast path
+
+- [ ] Add fast-path gate function with explicit guard conditions.
+- [ ] Implement streamlined break computation using analysis cache.
+- [ ] Guarantee exact fallback semantics to generic path.
+
+### P125-5: Feature flag and diagnostics
+
+- [x] Add runtime flags:
+ - `FW_PERF_P125_PATH1`
+ - `FW_PERF_P125_PATH2`
+- [ ] Add runtime flag:
+ - `FW_PERF_P125_PATH5`
+- [x] Emit counters for analysis/shape hit/miss/evict/compute-time telemetry into trace logs.
+- [ ] Emit Path 5 fallback-reason counters once Path 5 exists.
+
+### P125-6: Verification and benchmark reporting
+
+- [x] Run targeted native tests for line-break correctness.
+- [x] Run render timing suite and compare against baseline JSON / matched control runs.
+- [x] Record before/after in `Output/RenderBenchmarks` and summarize deltas by scenario against
+ run `1b193fb08a1b477f9419b59b0ec8d78f` unless a newer validated baseline replaces it.
+
+Latest validation snapshot:
+
+- `msbuild Src\views\Test\TestViews.vcxproj /p:Configuration=Debug /p:Platform=x64 /p:LinkIncremental=false /v:minimal /nologo` passed.
+- `.\test.ps1 -Native -TestProject TestViews -NoBuild` passed.
+- `msbuild Src\Common\RootSite\RootSiteTests\RootSiteTests.csproj /p:Configuration=Debug /p:Platform=x64 /v:minimal /nologo` passed.
+- `.\Build\Agent\Run-AllRenders.ps1 -Scope All -NoBuild -Verbosity minimal` passed.
+- `.\test.ps1 -NoBuild -TestProject "Src/Common/RootSite/RootSiteTests" -TestFilter "FullyQualifiedName~RenderTimingSuiteTests"` passed with Path 1/2 enabled and disabled.
+
+## Test Plan
+
+## A. Native Unit Tests (Correctness)
+
+Primary location: `Src/views/Test/RenderEngineTestBase.h` and related native render tests.
+
+### A1. Path 1 shaping cache correctness tests
+
+- [ ] Shaped run with cache enabled produces identical glyphs/widths as without cache.
+- [ ] Different `HFONT` for same text produces different cache entry (no false hit).
+- [ ] Different `SCRIPT_ANALYSIS` for same text produces different cache entry.
+- [ ] Backtracking scenario: re-shaped runs after `BackTrack()` match original shaping.
+- [ ] Cache entry ownership: cached copies remain valid after the source `UniscribeRunInfo`
+ instance and any transient vectors have been reused or destroyed.
+
+### A2. Path 2 analysis cache correctness tests
+
+- [ ] Surrogate pair boundaries never split across cached NFC offset maps.
+- [ ] Combining marks where NFC length differs from source preserve correct offset mapping.
+- [ ] Hard-break characters (`\n`, `\r`, tab, object replacement char) preserve break semantics.
+- [ ] RTL input with mirrored punctuation yields identical `SCRIPT_ITEM` array with cache on/off.
+- [ ] Same text source with different `ws` gets separate cache entries.
+- [ ] Same text source with different `ichMinSeg` gets separate cache entries.
+- [ ] Cache hit returns identical `cchNfc`, `fTextIsNfc`, `citem` as fresh computation.
+
+### A3. Path 5 fast-path equivalence tests
+
+- [ ] LTR simple run enters fast path and equals generic output.
+- [ ] Whitespace handling modes (`ktwshNoWs`, `ktwshOnlyWs`, others) force fallback when required.
+- [ ] ScriptPlace failure or special shaping conditions force fallback and preserve output.
+- [ ] Mixed writing systems in one candidate span trigger fallback safely.
+
+### A4. Stress and stability tests
+
+- [ ] Thousands of repeated break calls with cache enabled do not leak memory.
+- [ ] LRU or fixed-cap eviction behaves deterministically under pressure.
+- [ ] Null or empty segment cases remain unchanged.
+
+## B. Render Tests (Pixel + Behavior)
+
+Primary location: `Src/Common/RootSite/RootSiteTests/RenderTimingSuiteTests.cs` and baseline tests.
+
+- [x] Existing pixel-perfect scenarios still pass unchanged.
+- [ ] Add scenario variants emphasizing:
+ - deep paragraphs with many break opportunities,
+ - mixed styles in one paragraph,
+ - multi-writing-system lines,
+ - RTL-heavy text blocks,
+ - combining-mark intensive text.
+- [x] Validate no pixel variance compared to baseline snapshots.
+
+## C. Timing and Regression Tests
+
+- [ ] Add per-stage regression assertion for `PerformOffscreenLayout` (improves or no worse within tolerance).
+- [x] Add per-scenario delta reporting for heavy scenarios:
+ - `long-prose`, `mixed-styles`, `rtl-script`, `lex-extreme`.
+- [ ] Add hit-rate telemetry assertions (non-zero hit rate in scenarios designed for reuse).
+
+## D. Flag Matrix Tests
+
+Run matrix:
+
+1. all paths off (control)
+2. Path 2 only
+3. Path 1 only
+4. Path 5 only
+5. Path 1+2
+6. Path 1+2+5
+
+For each matrix entry:
+
+- [ ] native correctness tests pass,
+- [x] render snapshots pass for the `all paths off` and `Path 1+2` runs performed so far,
+- [x] timing suite emits valid artifacts for the `all paths off` and `Path 1+2` runs performed so far,
+- [ ] no crash or regression on warm path.
+
+## Edge Cases Checklist (Must Explicitly Pass)
+
+- [ ] surrogate pair at break boundary
+- [ ] combining sequence contraction or expansion under NFC
+- [ ] trailing whitespace segment with backtracking
+- [ ] upstream or downstream directional runs in same paragraph
+- [ ] hard line break and object replacement character handling
+- [ ] mixed ws renderer switching boundaries
+- [ ] empty-segment fallback and zero-width fit
+
+## Acceptance Criteria
+
+1. Correctness:
+ - zero pixel regressions in existing render baseline suite,
+ - all new native edge-case tests green.
+2. Performance:
+ - `PerformOffscreenLayout` improvement in at least 3 of 4 heavy scenarios,
+ - average `PerformOffscreenLayout` reduction >= 20% in heavy-scenario subset.
+3. Safety:
+ - feature flags allow disabling each path independently,
+ - no memory growth trend in stress loop.
+
+## Managed-Layer Scroll Rendering Fix (DataTree Separator Lines)
+
+### Problem
+
+DataTree is a scrollable `UserControl` that hosts child `Slice` controls (each a separate HWND).
+Thin horizontal separator lines are painted in the parent background between slices. During
+scrolling, Windows bitblts the entire client area (including parent-painted separator pixels)
+to the new scroll position and only repaints the newly-exposed strip. This caused separator
+lines to accumulate at wrong positions — visible as persistent gray line artifacts during
+fast scrolling.
+
+The original code used `ScrollWindowEx` (P/Invoke) to manually bitblt separator content during
+scroll. This was both unnecessary (WinForms handles scrolling natively) and the root cause of
+additional jitter because manually blitting parent-painted content races with the framework's
+own scroll handling.
+
+### Approaches Tried
+
+1. **ScrollWindowEx removal**: Removed all `ScrollWindowEx` interop (`DllImport`, `RECT` struct,
+ `SW_INVALIDATE` constant, `m_inScrollWindowEx` flag, `ScrollParentPaintedContent()` method).
+ This eliminated the manual bitblt jitter but did not solve the stale line artifacts from
+ Windows' own scroll bitblt.
+
+2. **SeparatorOverlayControl**: A dedicated transparent child control drawn on top of all slices
+ to paint separator lines independently. Failed: (a) caused `NullReferenceException` because
+ `Controls.Add` triggered layout before `Slices` was initialized; (b) after fixing the crash,
+ the opaque sibling covered slice content — WinForms HWND-per-control architecture made this
+ fundamentally unworkable without `WS_CLIPSIBLINGS` management. Fully reverted.
+
+3. **OnScroll override**: Added `Invalidate(false)` in `OnScroll`. Partially worked but
+ `OnScroll` does not fire for mouse wheel input.
+
+4. **WndProc scroll message interception (final solution)**: Override `WndProc` to catch
+ `WM_VSCROLL`, `WM_HSCROLL`, `WM_MOUSEWHEEL`, and `WM_MOUSEHWHEEL`. After `base.WndProc`,
+ call `Invalidate(false); Update();`. This works because:
+ - `Invalidate(false)` invalidates only the parent background (separator gap areas), not
+ child HWNDs
+ - `Update()` forces synchronous `WM_PAINT` processing before the next scroll message can
+ bitblt stale pixels
+ - Covers all scroll input vectors (scrollbar drag, mouse wheel, horizontal wheel)
+
+### Additional Managed Optimizations
+
+- **Double buffering**: `SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true)`
+ eliminates flicker from the synchronous repaint.
+
+- **Deferred slice prewarm**: After scroll, queues idle-time work to pre-materialize lazy slices
+ in the scroll direction ahead of the viewport. Uses `IdleQueue` integration when available,
+ with configurable chunk size and time budget to avoid jank.
+
+- **VwDrawRootBuffered cached frame reuse**: When `PrepareToDrawRoot` returns `kxpdrInvalidate`
+ (content not yet ready), the previous successfully-drawn frame is re-blitted instead of
+ showing a blank or partially-drawn frame. Implemented in both managed (`VwDrawRootBuffered.cs`)
+ and native (`VwRootBox.cpp`) paths so the fallback is always available during invalidate-only passes.
+
+### Key Insight
+
+WinForms hosts each child control in a separate native HWND. Unlike a browser (single composited
+texture), parent-painted content between child HWNDs is just pixels in the parent DC — Windows
+has no knowledge that those pixels represent separator lines. When scrolling bitblts the client
+area, those pixels move with everything else. The fix is to repaint them synchronously after
+every scroll event, which has negligible cost because `Invalidate(false)` skips child
+invalidation and separator line drawing is a handful of `DrawLine` calls.
+
+## Deliverables
+
+1. Code changes for paths 1, 2, and 5.
+2. New or updated native tests for edge-case correctness.
+3. New or updated render and timing tests plus scenario coverage.
+4. Before/after benchmark artifacts and summary note linked from this change.
+5. DataTree scroll rendering fix with separator line artifact elimination.
diff --git a/openspec/changes/render-speedup-benchmark/RENDER_OPTIMIZATIONS.md b/openspec/changes/render-speedup-benchmark/RENDER_OPTIMIZATIONS.md
new file mode 100644
index 0000000000..b2c4a36594
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/RENDER_OPTIMIZATIONS.md
@@ -0,0 +1,148 @@
+# Render Optimizations (Consolidated)
+
+This file is the single source of truth for render/layout optimization work done on branch `001-render-speedup`.
+
+## Scope and Evidence
+
+- Branch commit history since `main`:
+ - `d6741a0bd` perf: cold render optimizations (HFONT cache, color caching, NFC bypass, reconstruct guard fix)
+ - `b7f52166b` perf(render): benchmark and snapshot stabilization
+ - `d0066f324` perf(render): DataTree layout speedups and benchmark coverage
+ - `c4678e824` squashed foundational change set
+- Latest benchmark outputs used here:
+ - `Output/RenderBenchmarks/summary.md`
+ - `Output/RenderBenchmarks/results.json`
+ - `Output/RenderBenchmarks/datatree-timings.json`
+ - `Output/Debug/TestResults/vstest.console.log`
+
+## Main Paths and Bottlenecks (Lexical Edit View)
+
+There are two related pipelines.
+
+1. Views-engine benchmark pipeline (`RenderTimingSuite`)
+- `CreateView` -> `MakeRoot` -> `PerformOffscreenLayout` -> capture (`PrepareToDraw` + `DrawTheRoot`)
+- Current stage breakdown (`summary.md`, latest run):
+ - `PerformOffscreenLayout`: 67.9% (dominant)
+ - `DrawTheRoot`: 17.2%
+ - `CreateView`: 7.2%
+ - `MakeRoot`: 7.1%
+ - `PrepareToDraw`/`Reconstruct`: negligible
+
+2. Lexical DataTree edit-view pipeline (`DataTreeTiming`)
+- `PopulateSlices` (WinForms + XML-driven slice construction)
+- layout convergence/visibility operations
+- composite capture (`DrawToBitmap` + Views overlay)
+- Current timing examples (`datatree-timings.json`, latest run):
+ - `timing-shallow`: total 985.3 ms
+ - `timing-deep`: total 385.8 ms
+ - `timing-extreme`: total 465.1 ms
+ - `paint-extreme`: total 479.4 ms
+ - detailed paint line in test log: capture about 4798 ms for full 1007x6072 surface
+
+Cold-start interpretation:
+- For Views pipeline, cold time is still mostly layout (`PerformOffscreenLayout`).
+- For DataTree lexical edit view, total cold-like startup cost is often dominated by slice population + full-surface paint/capture work.
+
+## What Was Done and Why It Helped
+
+## A. Warm-path structural wins (major)
+
+1. Layout/reconstruct guarding (`VwRootBox` + `SimpleRootSite`)
+- Added and wired guard state (`m_fNeedsLayout`, `m_fNeedsReconstruct`, width checks, `NeedsReconstruct` COM surface).
+- Effect: redundant warm reconstruct/layout cycles are bypassed when content/width is unchanged.
+- Result trend: warm render collapsed from baseline triple-digit ms to near-zero in benchmark runs.
+
+2. Harness-side redundant work removal (`RenderBenchmarkHarness`)
+- Cached offscreen GDI resources and removed repeated setup overhead in warm path.
+- Effect: reduced benchmark harness overhead and made warm numbers stable.
+
+## B. Cold-path wins (targeted)
+
+Implemented in `d6741a0bd`:
+
+1. PATH-C1 HFONT cache in `VwGraphics`
+- New 8-entry LRU-like cache keyed by font-relevant `LgCharRenderProps` region.
+- Avoids repetitive `CreateFontIndirect`/delete churn in mixed writing-system text.
+- Measured contribution: modest but real cold-start improvement.
+
+2. PATH-C2 color state cache in `VwGraphics`
+- Skips redundant `SetTextColor`/`SetBkColor`/`SetBkMode` calls.
+- Measured contribution: small cold-start improvement.
+
+3. PATH-N1 NFC bypass in Uniscribe path
+- Added NFC-awareness flag flow and fast identity-offset path when text is already NFC.
+- Reduced repeated normalization/fetch overhead in line-break and run handling.
+- Measured contribution: largest cold improvement among the late native optimizations.
+
+## C. DataTree/WinForms hot-path wins
+
+Implemented mainly in `d0066f324` and `b7f52166b`:
+
+- Construction batching and tab-index optimization (`ConstructingSlices` guards)
+- Layout churn reduction (`SetWidthForDataTreeLayout` early-exit, size-change guards)
+- Paint-path reductions (clip culling, cached XML attribute reads, high-water visibility tracking)
+- Binary search for first visible slice in paint path
+- Added optimization regression tests (`DataTreeOpt_*`) and expanded timing coverage
+
+These changes reduced repeated O(N) and O(N^2)-like behavior in common DataTree layout/paint loops.
+
+## What Was Considered and Discarded (or Deferred)
+
+1. Deferred layout inside `Reconstruct` (PATH-L2)
+- Considered removing internal layout call.
+- Rejected because `Reconstruct` callers rely on immediate post-reconstruct dimensions.
+
+2. Paragraph-level cache for reconstruct flow (PATH-L3)
+- Considered caching `VwParagraphBox` layout across reconstruct.
+- Deferred for this path because reconstruct rebuilds boxes, so previous paragraph objects are not reused.
+
+3. `ShowSubControls` visibility guard (DataTree)
+- Assessed as too little benefit for added complexity in current benchmarks.
+
+4. Partial-paint-only gains that do not move full-capture benchmarks
+- Clip culling helps real partial paints, but full-surface benchmark captures show little/no gain from that specific mechanism.
+
+5. Some data-tree optimizations had architecture benefit but limited measurable impact in current scenarios
+- Example: lazy expansion suspension path is valid but timing scenarios do not always trigger it.
+
+## What Could Still Be Done (Cold Start Only)
+
+The following estimates are for cold behavior, not warm steady-state. Risk and savings are relative and based on current stage shares (`summary.md`) and DataTree measurements.
+
+1. Reduce `PerformOffscreenLayout` cost further (highest ROI)
+- Ideas: cache itemization/shaping artifacts at segment level, reduce repeat line-break work, smarter reuse of per-run analysis.
+- Risk: Medium-High (native text pipeline correctness is sensitive).
+- Potential cold savings: 10-25% total cold (because this stage is about 68% of cold benchmark time).
+
+2. Lower DataTree `PopulateSlices` startup overhead
+- Ideas: defer creation for non-visible optional slices, precompiled layout metadata, stricter construction batching.
+- Risk: Medium (high interaction surface with existing slice lifecycle).
+- Potential cold savings: 15-35% on lexical edit startup scenarios, especially deep/extreme entries.
+
+3. Reduce full-surface capture/paint work for very tall views
+- Ideas: avoid work on non-visible regions for cold startup render path when possible; keep viewport-first rendering and defer below-fold composition.
+- Risk: Medium.
+- Potential cold savings: 10-20% in extreme lexical entries (bigger in test-capture workflows than normal UI first paint).
+
+4. Startup/JIT warming for first scenario spikes
+- Ideas: pre-touch key code paths and objects during app startup phase.
+- Risk: Low-Medium (trade startup work for smoother first action).
+- Potential cold savings: 5-15% on first-open latency only.
+
+5. Medium-term architectural path: reduce WinForms control explosion in DataTree
+- Ideas: stronger virtualization and view-model separation in edit view pipeline.
+- Risk: High.
+- Potential cold savings: 25-50%+ on heavy lexical entries, but not a short patch-cycle change.
+
+## Current Status Snapshot
+
+- Latest run (`2026-03-09`) in `summary.md`:
+ - Avg cold render: 64.98 ms
+ - Avg warm render: 0.01 ms
+ - Top contributor remains `PerformOffscreenLayout`.
+- DataTree timing remains the largest practical lexical edit-view cost center for deep/extreme structures.
+
+## Notes
+
+- This document intentionally consolidates and replaces prior speedup/timing markdown artifacts for this branch.
+- Keep future updates here instead of creating new speedup summary files.
\ No newline at end of file
diff --git a/openspec/changes/render-speedup-benchmark/VERIFY_WINFORMS.md b/openspec/changes/render-speedup-benchmark/VERIFY_WINFORMS.md
new file mode 100644
index 0000000000..3e17a9ff2d
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/VERIFY_WINFORMS.md
@@ -0,0 +1,500 @@
+# FieldWorks Verify.WinForms Integration Plan
+
+## Executive Summary
+
+**Feasibility: High. Core infrastructure is proven.**
+
+FieldWorks uses a hybrid architecture where the C++ rendering engine (`Views.dll`) paints onto a managed .NET `UserControl` (`SimpleRootSite`) via `OnPaint` and GDI HDCs. We have built a working test harness that captures pixel-perfect bitmaps from this pipeline using `IVwDrawRootBuffered`, bypassing the unreliable `DrawToBitmap` path entirely. The harness renders richly styled Scripture content (bold section headings, colored chapter/verse numbers, indented paragraphs) through the production `StVc` view constructor against a real file-backed `LcmCache`.
+
+The next step is replacing our hand-rolled baseline comparison infrastructure with [Verify](https://github.com/VerifyTests/Verify), which provides diff tooling, automatic `.verified.png` management, and seamless NUnit integration — all compatible with .NET Framework 4.8.
+
+---
+
+## Current State (Phases 1-2 Complete)
+
+### What's Working
+
+| Component | File | Status |
+|-----------|------|--------|
+| Real-data test base | `RootSiteTests/RealDataTestsBase.cs` | **Done** — Spins up temporary `.fwdata` projects via `FwNewLangProjectModel` |
+| Scripture style creation | `RootSiteTests/RenderBenchmarkTestsBase.cs` | **Done** — Creates `Paragraph`, `Section Head`, `Chapter Number`, `Verse Number`, `Title Main` styles with full formatting rules (bold, colors, sizes, alignment, superscript) |
+| Rich test data | `RootSiteTests/RenderBenchmarkTestsBase.cs` | **Done** — `AddRichSections()` generates section headings, chapter numbers, verse numbers, and varied prose text |
+| View constructor | `RootSiteTests/GenericScriptureView.cs` | **Done** — `GenericScriptureVc` extends `StVc`, handles Book→Sections→Heading+Content hierarchy |
+| Bitmap capture | `RootSiteTests/RenderBenchmarkHarness.cs` | **Done** — Uses `VwDrawRootBuffered.DrawTheRoot()` to render RootBox directly to GDI+ bitmap |
+| Offscreen layout | `RootSiteTests/RenderBenchmarkHarness.cs` | **Done** — `VwGraphicsWin32` drives layout without a visible window |
+| Baseline comparison | `RootSiteTests/RenderBitmapComparer.cs` | **Done** — Pixel-diff with diff-image generation |
+| Environment validation | `RootSiteTests/RenderEnvironmentValidator.cs` | **Done** — DPI/theme/font hashing for deterministic checks |
+| Content density check | `RootSiteTests/RenderBaselineTests.cs` | **Done** — `ValidateBitmapContent()` ensures >0.4% non-white pixels (currently hitting 6.3%) |
+| All tests passing | 43/43 Render tests | **Done** — 4 baseline infra + 15 Verify snapshots + 15 timing suite + 6 DataTree Verify + 3 DataTree timing |
+
+### Key Metrics
+- **Content density**: 6.30% non-white pixels (up from 0.46% with plain text)
+- **Cold render**: ~147ms (includes view creation, MakeRoot, layout)
+- **Warm render**: ~107ms (Reconstruct + relayout)
+- **Test suite time**: ~58s for RootSiteTests (34 tests), ~7s for DataTree (9 tests)
+
+### Problems Solved
+
+1. **Mock vs Real data** — `ScrInMemoryLcmTestBase` lacked the deep metadata `StVc` needs. Solved by creating real XML-backed projects via `FwNewLangProjectModel`.
+
+2. **Missing styles = plain black text** — New projects from the template have no Scripture styles. The Views engine fell back to unformatted defaults. Solved by manually creating styles with `IStStyleFactory.Create()` and setting `Rules` via `ITsPropsBldr`.
+
+3. **`DrawToBitmap` failures** — WinForms `DrawToBitmap` returns black for Views controls because painting goes through native `HDC` interop. Solved by using `VwDrawRootBuffered` to render the `IVwRootBox` directly to an offscreen bitmap.
+
+4. **Handle creation crash** — `SimpleRootSite.OnHandleCreated` called `MakeRoot()` with wrong fragment before test data was ready. Solved by setting `m_fMakeRootWhenHandleIsCreated = false` and calling `MakeRoot()` explicitly with the correct parameters.
+
+---
+
+## 10-Point Comparison & Analysis
+
+### 1. Rendering Architecture Compatibility
+- **Verify.WinForms** uses `Control.DrawToBitmap` internally, which **does not work** for FieldWorks Views controls.
+- **Our approach**: Use `IVwDrawRootBuffered` to render the `IVwRootBox` directly to a `System.Drawing.Bitmap`, then pass the raw bitmap to Verify for snapshot management.
+- **We don't need `Verify.WinForms`** — only `Verify.NUnit` for bitmap stream verification.
+
+### 2. Handling Complex Scripts & Text Rendering
+- Snapshot testing catches subtle kerning, ligature, and line-breaking regressions that are impossible to assert programmatically.
+- Our harness already renders through the full production pipeline (Graphite, Uniscribe, ICU) via `StVc`.
+
+### 3. Test Harness Complexity (Solved)
+- `RealDataTestsBase` creates temporary `.fwdata` projects with full schema.
+- `RenderBenchmarkTestsBase` populates Scripture styles and rich data.
+- `RenderBenchmarkHarness` handles view lifecycle and bitmap capture.
+
+### 4. GDI Handle Management
+- `PerformOffscreenLayout()` and `CaptureViewBitmap()` carefully acquire and release HDCs in try/finally blocks.
+- View disposal cascades through Form → Control cleanup.
+
+### 5. Cross-Platform / OS Rendering Differences
+- Font rendering varies with ClearType settings, DPI, and Windows version.
+- `RenderEnvironmentValidator` hashes these settings so tests can detect environment drift.
+- For CI, consider `Verify.Phash` (perceptual hashing) if pixel-exact comparison is too strict.
+
+### 6. Tooling Integration
+- **NUnit** is already the project's test framework.
+- **Verify.NUnit** provides `[VerifyAttribute]` and `Verifier.Verify()` for net48.
+- Packages target `net48` explicitly.
+
+### 7. Performance
+- ~260ms cold, ~180ms warm per render. Acceptable for integration tests.
+- Data setup adds ~3s per test (project creation + DB population).
+
+### 8. Failure Diagnostics
+- Verify automatically opens a diff tool on mismatch.
+- `.received.png` vs `.verified.png` side-by-side comparison.
+- Our `RenderBitmapComparer` can still generate diff images as supplementary evidence.
+
+### 9. Custom Control Support
+- Not needed. We bypass `Control.DrawToBitmap` entirely and verify raw bitmaps.
+
+### 10. Fallback Strategy
+- If Verify integration has issues, our existing `RenderBitmapComparer` + `simple.png` baseline approach is fully functional.
+- Migration is incremental — we can keep both systems running in parallel.
+
+---
+
+## Implementation Plan
+
+### Phase 1: Stabilization ✅ COMPLETE
+1. ~~Revert hacks~~ — `StVc.cs` is clean; `GenericScriptureView.cs` uses proper `MakeRoot` override.
+2. ~~Clean slate~~ — All 8 tests pass with rich formatted rendering.
+
+### Phase 2: Test Harness "Real Data" ✅ COMPLETE
+1. ~~Create `RealDataTestsBase`~~ — `Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs`
+2. ~~Refactor Benchmark~~ — `RenderBenchmarkTestsBase` inherits from `RealDataTestsBase`
+3. ~~Scripture styles~~ — `CreateScriptureStyles()` populates `Paragraph`, `Section Head`, `Chapter Number`, `Verse Number`, `Title Main`
+4. ~~Rich data~~ — `AddRichSections()` creates headings, chapter/verse markers, varied prose
+
+### Phase 3: Verify Integration ✅ COMPLETE
+
+#### 3.1 Install NuGet Package
+Add to `RootSiteTests.csproj`:
+```xml
+
+```
+
+**Not needed**: `Verify.WinForms` — we capture bitmaps directly via `IVwDrawRootBuffered`.
+
+#### 3.2 Configure Verify Settings
+
+Create `RootSiteTests/VerifySetup.cs` using NUnit's `[SetUpFixture]` (required because FieldWorks uses C# 8.0 / `LangVersion=8.0` globally, so `[ModuleInitializer]` from C# 9 is unavailable):
+
+```csharp
+[SetUpFixture]
+public class VerifySetup
+{
+ [OneTimeSetUp]
+ public void Setup()
+ {
+ // Use Verify's default PNG comparison for bitmap streams
+ VerifierSettings.ScrubEmptyLines();
+
+ // Don't auto-open diff tool in CI
+ if (Environment.GetEnvironmentVariable("CI") != null)
+ DiffRunner.Disabled = true;
+ }
+}
+```
+
+#### 3.3 Register Custom Bitmap Converter
+
+Register a typed converter so `Verify(bitmap)` works directly:
+
+```csharp
+VerifierSettings.RegisterFileConverter(
+ conversion: (bitmap, context) =>
+ {
+ var stream = new MemoryStream();
+ bitmap.Save(stream, ImageFormat.Png);
+ stream.Position = 0;
+ return new ConversionResult(null, "png", stream);
+ });
+```
+
+#### 3.4 Create Verify-Based Test
+
+Add `RenderVerifyTests.cs` alongside existing `RenderBaselineTests.cs`:
+
+```csharp
+[TestFixture]
+[Category("RenderBenchmark")]
+public class RenderVerifyTests : RenderBenchmarkTestsBase
+{
+ protected override void CreateTestData() => SetupScenarioData("simple");
+
+ [Test]
+ public Task SimpleScenario_MatchesVerifiedSnapshot()
+ {
+ var scenario = new RenderScenario
+ {
+ Id = "simple",
+ RootObjectHvo = m_hvoRoot,
+ RootFlid = m_flidContainingTexts,
+ FragmentId = m_frag
+ };
+
+ using var harness = new RenderBenchmarkHarness(Cache, scenario);
+ harness.ExecuteColdRender();
+ var bitmap = harness.CaptureViewBitmap();
+
+ return Verifier.Verify(bitmap);
+ // First run: creates RenderVerifyTests.SimpleScenario_MatchesVerifiedSnapshot.verified.png
+ // Subsequent runs: compares against verified file
+ }
+}
+```
+
+#### 3.5 Verified Files Location
+
+Verify stores `.verified.png` files next to the test source. Structure:
+```
+RootSiteTests/
+├── RenderVerifyTests.cs
+├── RenderVerifyTests.SimpleScenario_MatchesVerifiedSnapshot.verified.png
+├── RenderVerifyTests.MediumScenario_MatchesVerifiedSnapshot.verified.png
+└── ...
+```
+
+These files are committed to source control and serve as the approved baselines.
+
+#### 3.6 Git Configuration
+
+Add to `.gitattributes`:
+```
+*.verified.png binary
+*.received.png binary
+```
+
+Add to `.gitignore`:
+```
+*.received.png
+```
+
+### Phase 4: Pilot Test ✅ COMPLETE
+
+1. ~~**Run**~~ `SimpleScenario_MatchesVerifiedSnapshot` — first run created `.received.png`.
+2. ~~**Accept**~~ — Copied `.received.png` to `.verified.png`.
+3. ~~**Commit**~~ the `.verified.png` file.
+4. ~~**Re-run**~~ — test passes, comparing against the committed baseline.
+
+### Phase 5: Expand Coverage ✅ COMPLETE
+
+| Scenario | Data | What It Tests |
+|----------|------|---------------|
+| `simple` | 3 sections, 4 verses each | Basic formatting pipeline |
+| `medium` | 5 sections, 6 verses each | Style resolution at scale |
+| `complex` | 10 sections, 8 verses each | Layout performance and wrapping |
+| `deep-nested` | 3 sections, 12 verses each | Dense content in single paragraphs |
+| `custom-heavy` | 5 sections, 8 verses each | Mixed style properties |
+| `many-paragraphs` | 50 sections, 1 verse each | Paragraph layout overhead |
+| `footnote-heavy` | 8 sections, 20 verses + footnotes | Footnote rendering path |
+| `mixed-styles` | 6 sections, unique formatting per verse | Style resolver stress |
+| `long-prose` | 4 sections, 80 verses each | Line-breaking computation |
+| `multi-book` | 3 books, 5 sections each | Large cache stress |
+| `rtl-script` | Arabic/Hebrew text | Bidirectional layout |
+| `multi-ws` | Mixed writing systems | Font fallback and WS switching |
+
+### Phase 6: Deprecate Hand-Rolled Comparison ✅ COMPLETE
+
+Completed:
+1. ~~Remove `RenderBitmapComparer.cs` and hand-rolled diff logic.~~ — Deleted.
+2. ~~Remove `TestData/RenderSnapshots/` directory (replaced by `.verified.png` files).~~ — Cleared.
+3. Keep `RenderBenchmarkHarness` and `RenderEnvironmentValidator` — they're the capture infrastructure.
+4. `RenderBaselineTests` trimmed to 4 infrastructure tests (harness, warm/cold, environment, diagnostics).
+5. `RenderTimingSuiteTests` uses content-density sanity check instead of pixel comparison.
+
+### Phase 7: Lexical Entry Benchmarks ✅ COMPLETE
+
+**Motivation**: The primary speed-up target is deeply nested lexical entry rendering. In production, `XmlVc.ProcessPartRef` with `visibility="ifdata"` causes every part to render its entire subtree **twice** — once via `TestCollectorEnv` to check if data exists, then again into the real `IVwEnv`. With recursive `LexSense → Senses → LexSense` layouts and ~15 ifdata parts per sense, this creates O(N·2^d) work. The benchmarks track rendering time at varying nesting depths to quantify speedup from optimizations.
+
+Completed:
+1. Created `GenericLexEntryView.cs` with custom `LexEntryVc : VwBaseVc` — exercises the same recursive nested-field pattern as `XmlVc` with configurable `SimulateIfDataDoubleRender` flag for modeling the ifdata overhead.
+2. Extended `RenderBenchmarkHarness` with `RenderViewType` enum (`Scripture`, `LexEntry`) and split view creation into `CreateScriptureView()` / `CreateLexEntryView()`.
+3. Added 3 lex entry scenarios to `RenderBenchmarkTestsBase` — `lex-shallow` (depth 2, breadth 3 = 12 senses), `lex-deep` (depth 4, breadth 2 = 30 senses), `lex-extreme` (depth 6, breadth 2 = 126 senses).
+4. Added `CreateLexEntryScenario()` and `CreateNestedSenses()` — creates realistic lex entry data with headword, glosses, definitions, and recursively nested subsenses using `ILexEntryFactory`/`ILexSenseFactory`/`IMoStemAllomorphFactory`.
+5. Extended `RenderBenchmarkScenarios.json` with 3 new lex scenarios (with `viewType: "LexEntry"`).
+6. Updated both `RenderTimingSuiteTests` and `RenderVerifyTests` to propagate `ViewType` and `SimulateIfDataDoubleRender` from config.
+7. Accepted 3 new `.verified.png` baselines for lex entry scenarios.
+
+**Lex Entry Rendering Metrics**:
+| Scenario | Senses | Depth | Non-white density |
+|----------|--------|-------|-------------------|
+| `lex-shallow` | 12 | 2 | 5.83% |
+| `lex-deep` | 30 | 4 | 6.78% |
+| `lex-extreme` | 126 | 6 | 6.94% |
+
+### Phase 8: Shared Render Verification Library _(DONE)_
+
+**Vision**: Extract the render capture/comparison engine into a shared class library (`RenderVerification`) that any test project can consume. This library does not contain tests itself — it provides the infrastructure to create views, render them to bitmaps, and compare against established baselines.
+
+**Location**: `Src/Common/RenderVerification/RenderVerification.csproj` (class library, not test project)
+
+**What was built**:
+- `RenderVerification.csproj` — SDK-style, net48, references DetailControls + FwUtils
+- `DataTreeRenderHarness` — Creates DataTree with Mediator/PropertyTable, loads layout inventories, calls ShowObject(), captures composite bitmaps, tracks timing
+- `CompositeViewCapture` — Multi-pass capture: DrawToBitmap for WinForms chrome + VwDrawRootBuffered overlay per ViewSlice
+- `DataTreeTimingInfo` — Model class for timing data
+- Added to `FieldWorks.sln`
+
+**What moved from RootSiteTests → RenderVerification** (infrastructure/model classes):
+- `RenderEnvironmentValidator` — DPI/theme/font determinism checks
+- `RenderDiagnosticsToggle` — Trace switch management
+- `RenderBenchmarkResults` — Timing result aggregation
+- `RenderBenchmarkComparer` — Benchmark timing comparison
+- `RenderBenchmarkReportWriter` — Timing report generation
+- `RenderScenarioDataBuilder` — JSON scenario loading
+- `RenderTraceParser` — Trace output parsing
+- `RenderModels.cs` (new) — Extracted `RenderScenario`, `RenderTimingResult`, `RenderViewType` model classes
+
+**What stays in RootSiteTests** (depends on test-only `DummyBasicView`):
+- `RenderBenchmarkHarness` — Core bitmap capture via `VwDrawRootBuffered` (creates `GenericScriptureView`/`GenericLexEntryView` which extend `DummyBasicView`)
+- `GenericScriptureView` + `GenericScriptureVc` — Scripture rendering path (extends `DummyBasicView`)
+- `GenericLexEntryView` + `LexEntryVc` — Views-only lex entry rendering path (extends `DummyBasicView`)
+- `RenderBenchmarkTestsBase`, `RealDataTestsBase` — Test base classes
+- All test classes (`RenderVerifyTests`, `RenderTimingSuiteTests`, `RenderBaselineTests`)
+- `VerifySetup.cs` — Verify configuration
+- `DummyBasicView`, `DummyBasicViewVc` — Shared test view control used broadly
+
+**RootSiteTests.csproj** now references `RenderVerification.csproj` as a `ProjectReference` to access the extracted infrastructure classes.
+
+**What's new in RenderVerification**:
+- `DataTreeRenderHarness` — Creates a `DataTree` with production layout inventories, populates it via `ShowObject()`, captures full-view bitmaps including all WinForms chrome
+- `CompositeViewCapture` — Multi-pass bitmap capture:
+ 1. `DrawToBitmap` on the `DataTree` control to capture WinForms chrome (labels, grey backgrounds, splitters, expand/collapse icons, section headers)
+ 2. Iterate `ViewSlice` children, render each `RootBox` via `VwDrawRootBuffered` into the correct region
+ 3. Composite the Views-rendered text over the WinForms chrome bitmap
+- `RenderViewType.DataTree` enum value — New pipeline for full DataTree/Slice rendering
+- Layout inventory helpers — Load production `.fwlayout` and `*Parts.xml` from `DistFiles/Language Explorer/Configuration/Parts/`
+
+**Dependencies** (RenderVerification.csproj references):
+- `DetailControls.csproj` (brings DataTree, Slice, SliceFactory, ViewSlice, SummarySlice, etc.)
+- `RootSite.csproj` / `SimpleRootSite.csproj` (base view rendering)
+- `ViewsInterfaces.csproj` (IVwRootBox, IVwDrawRootBuffered)
+- `xCore.csproj` + `xCoreInterfaces.csproj` (Mediator, PropertyTable — required by DataTree)
+- `XMLViews.csproj` (XmlVc, LayoutCache, Inventory — required by DataTree)
+- `FwUtils.csproj` (FwDirectoryFinder for locating DistFiles)
+- Verify (31.11.0) + Newtonsoft.Json (13.0.4) NuGet packages
+- SIL.LCModel packages
+
+**Consumer pattern** (how test projects use it):
+```csharp
+// In any test project:
+using SIL.FieldWorks.Common.RenderVerification;
+
+// Views-only capture (existing):
+using (var harness = new RenderBenchmarkHarness(cache, scenario))
+{
+ harness.ExecuteColdRender();
+ var bitmap = harness.CaptureViewBitmap();
+}
+
+// Full DataTree capture (new):
+using (var harness = new DataTreeRenderHarness(cache, entry, "Normal"))
+{
+ harness.PopulateSlices();
+ var bitmap = harness.CaptureCompositeBitmap(); // WinForms chrome + Views content
+ // bitmap includes grey labels, icons, expand/collapse, AND rendered text
+}
+```
+
+Steps:
+1. Create `Src/Common/RenderVerification/RenderVerification.csproj` with dependencies
+2. Move existing harness classes from `RootSiteTests` → `RenderVerification`
+3. Update `RootSiteTests.csproj` to reference `RenderVerification` instead of containing the infrastructure directly
+4. Implement `DataTreeRenderHarness` with composite bitmap capture
+5. Implement `CompositeViewCapture` (DrawToBitmap + VwDrawRootBuffered overlay)
+6. Add layout inventory loading from `DistFiles/` production XML
+7. Verify all existing tests still pass after extraction
+
+### Phase 9: DataTree Full-View Verification Tests _(DONE)_
+
+**Motivation**: The full lexical entry edit view includes WinForms UI chrome critical to verify: grey field labels, writing system indicators, expand/collapse tree icons, section headers, separator lines, indentation of nested senses.
+
+**Location**: `DetailControlsTests/DataTreeRenderTests.cs` — 6 Verify snapshot tests + 3 timing benchmarks
+
+**What was built**:
+- `DataTreeRenderTests.cs` with 9 tests:
+ - 6 Verify snapshot tests: simple (3 senses), deep (4-level nesting), extreme (6-level nesting), collapsed (minimal 1-sense), expanded (full enriched 4-sense), multiws (French + English)
+ - 3 timing benchmarks: shallow/deep/extreme with parameterized depth/breadth
+- Data factories: `CreateSimpleEntry()`, `CreateDeepEntry()`, `CreateExtremeEntry()`, `CreateCollapsedEntry()`, `CreateExpandedEntry()`, `CreateMultiWsEntry()`, `CreateNestedSenses()` (recursive)
+- Content density validation, InnerVerifier integration (2-arg constructor + VerifyStream)
+- Uses test layout inventories (`OptSensesEty` layout) from DetailControlsTests
+- 6 `.verified.png` baselines accepted
+- All 9/9 tests passing
+
+**Test scenarios**:
+| Scenario ID | Description | What it exercises |
+|-------------|-------------|-------------------|
+| `datatree-lex-simple` | Lex entry with 3 senses, default layout | Basic DataTree population + slice chrome |
+| `datatree-lex-deep` | 4-level nested senses, all expanded | Recursive slice indentation + tree lines |
+| `datatree-lex-extreme` | 6-level nesting, 126 senses | Scrollable DataTree stress + slice count |
+| `datatree-lex-collapsed` | Minimal 1-sense entry, no optional fields | Fewest slices, baseline layout |
+| `datatree-lex-expanded` | Full 4-sense entry with all enrichment fields | Maximum slice count, all chrome visible (pronounciations, notes, bibliography, etc.) |
+| `datatree-lex-multiws` | Entry with English + French writing systems | Multi-WS MultiStringSlice layout, taller slice heights |
+
+**DataTree Rendering Metrics**:
+| Scenario | Slice Count | Populate Time | Density | Height |
+|----------|-------------|---------------|---------|--------|
+| `collapsed` | 5 | ~359ms | 1.32% | 400px |
+| `simple` | 14 | ~59ms | 2.32% | 768px |
+| `deep` | 14 | ~71ms | 1.62% | 1100px |
+| `extreme` | 14 | ~35ms | 1.62% | 1100px |
+| `expanded` | 19 | ~76ms | 2.25% | 1100px |
+| `multiws` | 12 | ~49ms | 1.98% | 768px |
+
+**What's captured per scenario**:
+- Full composite bitmap (WinForms chrome + Views text content)
+- `.verified.png` baseline via Verify for pixel-perfect regression
+- Timing: DataTree population (CreateSlices), layout, composite capture
+- Slice count and type distribution (how many MultiStringSlice, ViewSlice, SummarySlice, etc.)
+
+**What the bitmaps should show** (matching the FLEx screenshot):
+- Blue/grey header bar with "Entry" label _(if contained in DataTree)_
+- Summary line with formatted headword, grammar, sense numbers
+- "Lexeme Form" / "Citation Form" grey labels with WS indicators
+- Sense summary lines ("Sense 1 — to do - v") with expand/collapse buttons
+- Indented subsense sections
+- "Variants" / "Allomorphs" / "Grammatical Info. Details" / "Publication Settings" headers
+- Grey backgrounds, separator lines, tree indentation lines
+
+Steps:
+1. Add `Verify` (31.11.0) NuGet package to `DetailControlsTests.csproj`
+2. Create `DataTreeRenderTests.cs` — Verify snapshot tests for each DataTree scenario
+3. Create `DataTreeTimingTests.cs` — Timing benchmarks measuring DataTree population + rendering
+4. Create shared data factory for lex entries with controlled structure (headword, senses, glosses, variants, allomorphs)
+5. Load production layout inventories from `DistFiles/` for realistic slice generation
+6. Accept initial `.verified.png` baselines
+7. Validate all scenarios produce non-trivial bitmaps (content density check)
+
+---
+
+## Architecture Diagram
+
+```
+┌─────────────────────────────────────────────────────────────────────────────┐
+│ Test Projects (consumers) │
+│ │
+│ RootSiteTests/ DetailControlsTests/ │
+│ RenderVerifyTests DataTreeRenderTests (Phase 9) │
+│ RenderTimingSuiteTests DataTreeTimingTests (Phase 9) │
+│ RenderBaselineTests DataTreeTests (existing) │
+│ ↓ ↓ │
+│ RenderBenchmarkTestsBase (shared data factories) │
+│ (styles + data) ↓ │
+│ ↓ ↓ │
+│ RealDataTestsBase ↓ │
+│ (FwNewLangProjectModel) ↓ │
+└──────────────┬─────────────────────────────────┼────────────────────────────┘
+ │ │
+┌──────────────▼─────────────────────────────────▼────────────────────────────┐
+│ RenderVerification (shared library) (Phase 8) │
+│ │
+│ Views-Only Path: DataTree Path: │
+│ RenderBenchmarkHarness DataTreeRenderHarness │
+│ ├── CreateScriptureView() ├── DataTree + Inventory.Load() │
+│ ├── CreateLexEntryView() ├── ShowObject() → CreateSlices() │
+│ └── CaptureViewBitmap() └── CaptureCompositeBitmap() │
+│ └ VwDrawRootBuffered ├ DrawToBitmap (WinForms chrome) │
+│ └ VwDrawRootBuffered per ViewSlice │
+│ │
+│ Shared Infrastructure: │
+│ RenderEnvironmentValidator CompositeViewCapture │
+│ RenderDiagnosticsToggle RenderScenario + RenderViewType │
+│ RenderBenchmarkReportWriter Verify InnerVerifier wrapper │
+└──────────────┬─────────────────────────────────┬────────────────────────────┘
+ │ │
+┌──────────────▼─────────────────────────────────▼────────────────────────────┐
+│ Views Engine (C++ / COM) DataTree / Slice (WinForms) │
+│ │
+│ IVwRootBox → IVwRootSite DataTree : UserControl │
+│ StVc, LexEntryVc, XmlVc ├── Slice (SplitContainer) │
+│ IVwStylesheet (LcmStyleSheet) │ ├── SliceTreeNode (grey labels) │
+│ IVwDrawRootBuffered (HDC→Bitmap) │ └── Control (editor panel) │
+│ ├── ViewSlice (Views RootSite) │
+│ ├── SummarySlice (sense summaries) │
+│ └── MultiStringSlice (multi-WS) │
+└─────────────────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Risk Register
+
+| # | Risk | Likelihood | Impact | Mitigation |
+|---|------|-----------|--------|------------|
+| 1 | Verify package breaks on net48 in future | Low | High | Pin version; Verify has explicit net48 support |
+| 2 | Pixel differences across machines | Medium | Medium | Use `RenderEnvironmentValidator` hash; consider `Verify.Phash` for fuzzy matching |
+| 3 | Test data changes break baselines | Medium | Low | Re-accept `.verified.png`; clear separation between data setup and rendering |
+| 4 | GDI handle leaks in long test runs | Low | High | Proven try/finally pattern in harness; monitor in CI |
+| 5 | Charis SIL font not installed on CI | Medium | High | Add font dependency to CI setup or use system default with known metrics |
+| 6 | DataTree composite capture misalignment | Medium | Medium | ViewSlice regions must precisely match DrawToBitmap coordinates; validate with edge-detection in tests |
+| 7 | Production layout XML changes break DataTree snapshots | Medium | Low | Re-accept `.verified.png`; layout inventories loaded from `DistFiles/` track production state |
+| 8 | DataTree initialization requires Mediator/PropertyTable | Low | Medium | Create minimal stubs for test context; existing `DataTreeTests` proves this pattern works |
+| 9 | Shared RenderVerification library increases coupling | Low | Medium | Library is infrastructure only (no tests); consumers decide which capabilities to use |
+
+---
+
+## Decision Log
+
+| Date | Decision | Rationale |
+|------|----------|-----------|
+| 2026-02-03 | Use `RealDataTestsBase` over mock data | `ScrInMemoryLcmTestBase` lacks metadata for StVc |
+| 2026-02-03 | Use `IVwDrawRootBuffered` not `DrawToBitmap` | WinForms DrawToBitmap returns black for Views controls |
+| 2026-02-05 | Override `MakeRoot` in GenericScriptureView | Prevent auto-MakeRoot on handle creation with wrong fragment |
+| 2026-02-05 | Create Scripture styles manually via `IStStyleFactory` | Avoids xWorks assembly dependency; FlexStyles.xml not loaded in test projects |
+| 2026-02-05 | Use `Verify.NUnit` only, skip `Verify.WinForms` | We capture bitmaps ourselves; Verify.WinForms uses DrawToBitmap internally |
+| 2026-02-05 | Use `[SetUpFixture]` not `[ModuleInitializer]` | FieldWorks uses C# 8.0 (LangVersion=8.0); ModuleInitializer requires C# 9 |
+| 2026-02-05 | Use base `Verify` package, not `Verify.NUnit` | All Verify.NUnit versions require NUnit ≥ 4.x; FieldWorks pins NUnit 3.13.3. Use `InnerVerifier` from base `Verify` package directly |
+| 2026-02-05 | Wrap `SetupScenarioData()` in UoW in timing suite | `RenderTimingSuiteTests.RunBenchmark()` was calling `SetupScenarioData()` outside `UndoableUnitOfWorkHelper`, causing `InvalidOperationException` — all 5 scenario test cases now pass |
+| 2026-02-05 | Add 5 new stress scenarios | many-paragraphs (50 sections), footnote-heavy (footnotes on every other verse), mixed-styles (unique formatting per verse), long-prose (80 verses per paragraph), multi-book (3 books) |
+| 2026-02-05 | Add RTL and multi-WS scenarios | Arabic (RTL) and trilingual (English+Arabic+French) data factories for bidirectional and multilingual rendering coverage |
+| 2026-02-05 | Expand Verify to all 12 scenarios | Parameterized `VerifyScenario(scenarioId)` with UoW-wrapped data setup; 12 `.verified.png` baselines accepted |
+| 2026-02-05 | Delete RenderBitmapComparer | Hand-rolled pixel diff replaced by Verify snapshots; timing suite uses content-density sanity check instead |
+| 2026-02-05 | Clear TestData/RenderSnapshots | Old baseline PNGs deleted; Verify `.verified.png` files live alongside test class |
+| 2026-02-06 | Custom LexEntryVc over XmlVc | RootSiteTests can't reference XMLViews (massive dependency chain). Custom `LexEntryVc : VwBaseVc` exercises the same recursive nested-field Views pattern with `SimulateIfDataDoubleRender` flag for modeling ifdata overhead |
+| 2026-02-06 | Add lex-shallow/deep/extreme scenarios | Three nesting depths (2/4/6 levels) to quantify O(N·2^d) rendering overhead from ifdata double-render; primary target for speedup measurement |
+| 2026-02-06 | Plan shared RenderVerification library | Current harness only captures Views engine text, not WinForms chrome (grey labels, icons, section headers). Full DataTree/Slice rendering requires DetailControls dependency chain. Extract harness into shared library reusable across test projects — long-term goal: verification coverage for significant parts of the view architecture |
+| 2026-02-06 | Composite bitmap capture for DataTree | `DrawToBitmap` works for WinForms controls (labels, splitters, backgrounds) but not for Views engine content in ViewSlice. Solution: multi-pass capture (DrawToBitmap for chrome + VwDrawRootBuffered per ViewSlice region) |
+| 2026-02-12 | Keep view classes in RootSiteTests | `GenericScriptureView`, `GenericLexEntryView`, and `RenderBenchmarkHarness` depend on `DummyBasicView` (test-only base class). Moving would require extracting DummyBasicView too, which is used broadly. Instead, move only infrastructure/model classes to RenderVerification; RootSiteTests references RenderVerification via ProjectReference. |
+| 2026-02-12 | Extract RenderModels.cs as new file | `RenderTimingResult`, `RenderViewType`, `RenderScenario` extracted from `RenderBenchmarkHarness.cs` into `RenderVerification/RenderModels.cs` to reduce coupling |
+| 2026-02-12 | Three new DataTree scenario types | Collapsed (minimal 1-sense), Expanded (enriched 4-sense with pronunciations+notes), MultiWs (French+English writing systems) — covers the full range of DataTree rendering complexity |
diff --git a/openspec/changes/render-speedup-benchmark/contracts/render-benchmark.openapi.yaml b/openspec/changes/render-speedup-benchmark/contracts/render-benchmark.openapi.yaml
new file mode 100644
index 0000000000..7b3989e635
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/contracts/render-benchmark.openapi.yaml
@@ -0,0 +1,219 @@
+openapi: 3.0.3
+info:
+ title: Render Benchmark Harness API
+ version: 1.0.0
+ description: Local-only API for running render benchmarks and retrieving results.
+servers:
+ - url: http://localhost
+paths:
+ /scenarios:
+ get:
+ summary: List benchmark scenarios
+ responses:
+ '200':
+ description: Scenario list
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/RenderScenario'
+ /runs:
+ post:
+ summary: Start a benchmark run
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/BenchmarkRunRequest'
+ responses:
+ '202':
+ description: Run accepted
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/BenchmarkRunReceipt'
+ get:
+ summary: List benchmark runs
+ responses:
+ '200':
+ description: Run list
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/BenchmarkRunSummary'
+ /runs/{runId}:
+ get:
+ summary: Get a benchmark run
+ parameters:
+ - in: path
+ name: runId
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Benchmark run details
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/BenchmarkRun'
+ /runs/{runId}/trace:
+ get:
+ summary: Download trace diagnostics for a run
+ parameters:
+ - in: path
+ name: runId
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Trace file contents
+ content:
+ text/plain:
+ schema:
+ type: string
+ /validate:
+ post:
+ summary: Validate pixel-perfect rendering for a scenario
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PixelPerfectRequest'
+ responses:
+ '200':
+ description: Validation result
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PixelPerfectResult'
+components:
+ schemas:
+ RenderScenario:
+ type: object
+ required: [id, description, expectedSnapshotId]
+ properties:
+ id:
+ type: string
+ description:
+ type: string
+ entrySource:
+ type: string
+ expectedSnapshotId:
+ type: string
+ tags:
+ type: array
+ items:
+ type: string
+ BenchmarkRunRequest:
+ type: object
+ required: [scenarioIds]
+ properties:
+ scenarioIds:
+ type: array
+ items:
+ type: string
+ includeTrace:
+ type: boolean
+ default: false
+ includeSnapshots:
+ type: boolean
+ default: true
+ BenchmarkRunReceipt:
+ type: object
+ required: [runId, status]
+ properties:
+ runId:
+ type: string
+ status:
+ type: string
+ enum: [pending, running]
+ BenchmarkRunSummary:
+ type: object
+ required: [runId, runAt, status]
+ properties:
+ runId:
+ type: string
+ runAt:
+ type: string
+ format: date-time
+ status:
+ type: string
+ enum: [pending, running, completed, failed]
+ BenchmarkRun:
+ type: object
+ required: [runId, runAt, configuration, environmentId, results]
+ properties:
+ runId:
+ type: string
+ runAt:
+ type: string
+ format: date-time
+ configuration:
+ type: string
+ environmentId:
+ type: string
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/BenchmarkResult'
+ summary:
+ $ref: '#/components/schemas/AnalysisSummary'
+ BenchmarkResult:
+ type: object
+ required: [scenarioId, coldRenderMs, warmRenderMs, pixelPerfectPass]
+ properties:
+ scenarioId:
+ type: string
+ coldRenderMs:
+ type: number
+ warmRenderMs:
+ type: number
+ variancePercent:
+ type: number
+ pixelPerfectPass:
+ type: boolean
+ PixelPerfectRequest:
+ type: object
+ required: [scenarioId, snapshotId]
+ properties:
+ scenarioId:
+ type: string
+ snapshotId:
+ type: string
+ PixelPerfectResult:
+ type: object
+ required: [scenarioId, snapshotId, pass]
+ properties:
+ scenarioId:
+ type: string
+ snapshotId:
+ type: string
+ pass:
+ type: boolean
+ AnalysisSummary:
+ type: object
+ required: [topContributors, recommendations]
+ properties:
+ topContributors:
+ type: array
+ items:
+ $ref: '#/components/schemas/Contributor'
+ recommendations:
+ type: array
+ items:
+ type: string
+ Contributor:
+ type: object
+ required: [stage, sharePercent]
+ properties:
+ stage:
+ type: string
+ sharePercent:
+ type: number
diff --git a/openspec/changes/render-speedup-benchmark/data-model.md b/openspec/changes/render-speedup-benchmark/data-model.md
new file mode 100644
index 0000000000..712d51dcb7
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/data-model.md
@@ -0,0 +1,28 @@
+## Data Model (Migrated from Speckit 001)
+
+### Render Scenario
+- id, description, entrySource, expectedSnapshotId, tags
+- id is unique; expectedSnapshotId must resolve
+
+### Render Snapshot
+- id, scenarioId, imagePath, environmentHash, createdAt
+- environmentHash must match current deterministic environment for validation
+
+### Benchmark Run
+- id, runAt, configuration, environmentId, results, summary
+- must contain all five required scenarios for full suite
+
+### Benchmark Result
+- scenarioId, coldRenderMs, warmRenderMs, variancePercent, pixelPerfectPass
+- pixelPerfectPass must be true for suite pass
+
+### Trace Event
+- runId, stage, startTime, durationMs, context
+- stage must be from approved render-stage list
+
+### Analysis Summary
+- runId, topContributors, recommendations
+- recommendations count >= 3
+
+### Contributor
+- stage, sharePercent
diff --git a/openspec/changes/render-speedup-benchmark/design.md b/openspec/changes/render-speedup-benchmark/design.md
new file mode 100644
index 0000000000..4952dac238
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/design.md
@@ -0,0 +1,120 @@
+## Context
+
+This change migrates the complete Speckit `001-render-speedup` workstream into OpenSpec and continues delivery from current code state. The migrated scope includes:
+
+- RootSite baseline and timing suite infrastructure.
+- File-based render trace diagnostics and parser/report integration.
+- DataTree render harness extensions and timing-fidelity fixes.
+
+## Goals / Non-Goals
+
+**Goals**
+- Preserve the three Speckit user stories in OpenSpec:
+ - Pixel-perfect baseline (P1)
+ - Five-scenario timing suite (P2)
+ - Render-stage trace diagnostics (P3)
+- Keep deterministic environment safeguards for pixel-perfect validation.
+- Enforce benchmark-fidelity invariants so deeper scenarios represent larger work.
+- Drive optimization with measurable evidence and regression safety gates.
+
+**Non-Goals**
+- Full architectural rewrite of DataTree/Views.
+- Behavior-changing UI redesign.
+- External telemetry or service dependencies.
+
+## Decisions
+
+### 1) Full Speckit carry-over with status preservation
+
+- Carry over FR/SC and task IDs semantically (including completed and pending status) into OpenSpec tasks.
+- Keep Speckit files as source references during transition; OpenSpec becomes authoritative moving forward.
+
+### 2) Separate confidence channels by purpose
+
+- Snapshot channel verifies visual correctness and deterministic output.
+- Timing channel emphasizes repeatability and workload scaling.
+- Trace channel attributes time to native rendering stages.
+
+### 3) Benchmark-fidelity guardrails are mandatory
+
+- Timing scenarios must show monotonic workload growth indicators.
+- Timing tests fail fast when expected complexity is not exercised.
+
+### 4) Optimization loop is evidence-first
+
+- Baseline -> change -> remeasure on same scenarios.
+- Require non-regressing snapshots and timing evidence for acceptance.
+
+## Risks / Trade-offs
+
+- Snapshot and timing channels may use different internals for stability; risk mitigated by documenting each channel's intent.
+- Native trace instrumentation carries low-level regression risk; mitigate with scoped trace switches and targeted tests.
+- Timing variance across machines complicates absolute thresholds; use trend comparisons and workload invariants.
+
+## Open Questions
+
+1. Should benchmark comparison output (`comparison.md`) become a required artifact for optimization PRs?
+2. Should pending native trace tasks be completed in this change or tracked as a follow-on OpenSpec change if blocked by native build constraints?
+
+## Research Decisions (carried over from Speckit)
+
+These decisions were made during the original Phase 0 research and remain in effect.
+
+### 5) Pixel-perfect validation via deterministic environment control
+- **Decision**: Enforce fixed fonts, DPI, and theme with zero tolerance image comparison.
+- **Rationale**: Guarantees strict pixel identity and avoids false positives from tolerances.
+- **Alternatives rejected**: Image diff with tolerance (weakens correctness guarantees); layout tree snapshot (lower fidelity, misses paint issues).
+
+### 6) Cold + warm render metrics
+- **Decision**: Report cold-start and warm-cache render timings separately.
+- **Rationale**: Cold captures first-load cost; warm captures steady-state performance.
+- **Alternatives rejected**: Single "best of 3" (hides cold-start regressions); median of 5 (blurs warm vs cold).
+
+### 7) Fixed five timing scenarios
+- **Decision**: Use five scenarios: simple, medium, complex, deep-nested, custom-field-heavy.
+- **Rationale**: Balanced coverage for common and worst-case entries without excessive runtime.
+- **Alternatives rejected**: Minimum three (insufficient coverage); ten (longer runtime, higher maintenance).
+
+### 8) File-based trace logging
+- **Decision**: Append-only file output with timestamps and durations.
+- **Rationale**: Low overhead, aligns with existing FieldWorks tracing practices.
+- **Alternatives rejected**: ETW/EventSource (more setup); UI panel trace (adds measurement overhead).
+
+### 9) Harness approach: offscreen render capture
+- **Decision**: Use managed WinForms offscreen rendering (Option 2) with `DummyBasicView`/RootSite layout and `Control.DrawToBitmap`-style capture.
+- **Rationale**: Fastest integration path with existing managed test scaffolding and deterministic capture.
+- **Alternatives rejected**: Native Views offscreen render (higher fidelity but more complex); on-screen capture (window manager variability); layout-only verification (insufficient for visual correctness).
+- **Pivot note**: T009a/T009b track potential pivot to native capture and production StVc if `DrawToBitmap` limitations prove blocking.
+
+### 10) Initial optimization candidates
+- **Decision**: Start with layout/DisplayCommand caching, custom-field collapse, and field-level virtualization/lazy creation.
+- **Rationale**: Targets high-impact bottlenecks in XML layout interpretation and control creation.
+- **Alternatives rejected**: Full views engine replacement (too large for initial phase).
+- **Reference**: See `research/FORMS_SPEEDUP_PLAN.md` for the 10 optimization techniques and `research/implementation-paths.md` for strategic paths A/B/C.
+
+## Quickstart
+
+Detailed build, run, snapshot generation, diagnostics, and output artifact documentation is in [quickstart.md](quickstart.md) (carried over from Speckit).
+
+## Key Architecture Findings
+
+The `research/` folder contains the full analysis. Key points for context:
+
+- **Worst-case complexity**: O(M × N × D × F × S) where M=senses, N=subsenses, D=layout depth, F=custom fields, S=source XML size.
+- **No field-level virtualization**: `VwLazyBox` virtualizes at vector property level only; within each expanded item, all fields render immediately.
+- **Custom field explosion**: Each custom field triggers XML subtree cloning via `PartGenerator.Generate` — O(F × S × A).
+- **COM boundary**: Every `IVwEnv` call crosses managed/native boundary synchronously.
+- **Layout recomputation**: No incremental/dirty-region optimization at the managed layer; property changes trigger full subtree re-layout.
+- **Known bottlenecks**: `VwSelection.cpp` comments explicitly note performance issues; `VwRootBox` has 1/10 second timeout for `MakeSimpleSel`.
+- **Original problem statement**: DataTree needs redesign; current architecture intermixes view model (XML layouts), data model (liblcm), and view (Slices with XML nodes + HVO/flid). See `research/JIRA-21951.md`.
+
+## Constitution Check (carried over from Speckit)
+
+| Category | Status | Notes |
+|----------|--------|-------|
+| Data Integrity / Backward Compatibility | PASS | No schema/data changes in scope |
+| Test & Review Discipline | PASS | Harness, timing suite, and trace validation included |
+| Internationalization / Script Correctness | PASS | Pixel-perfect validation must include complex scripts |
+| Stability & Performance | PASS | Feature flags / staged rollout for optimizations; baseline first |
+| Licensing | PASS | No new external dependencies or services |
+| Documentation Fidelity | PASS | Plan includes updating docs when code changes |
diff --git a/openspec/changes/render-speedup-benchmark/dpi-fix-implementation.md b/openspec/changes/render-speedup-benchmark/dpi-fix-implementation.md
new file mode 100644
index 0000000000..3df563b146
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/dpi-fix-implementation.md
@@ -0,0 +1,41 @@
+# DPI Fix Implementation
+
+Date: 2026-03-10
+
+## Goal
+
+Make the render-speedup fast paths safe when monitor DPI changes without a width change.
+
+## Findings Being Fixed
+
+- `VwRootBox::Layout()` PATH-L1 compared width only and returned before refreshing `m_ptDpiSrc`.
+- `SimpleRootSite` only forced layout when available width changed, so many hosts would never call native `Layout()` on a DPI-only change.
+- `VwLayoutStream::ConstructAndLayout()` already had a TODO noting that it should also check DPI.
+
+## Implementation Plan
+
+- [x] Track DPI as part of the native root-box layout cache key.
+- [x] Refresh native cached DPI after incremental relayout.
+- [x] Force managed layout when `SimpleRootSite.Dpi` changes.
+- [x] Make `VwLayoutStream::ConstructAndLayout()` relayout on DPI changes.
+- [x] Add native regression coverage for same-width DPI changes.
+
+## Files
+
+- `Src/views/VwRootBox.cpp`
+- `Src/views/VwRootBox.h`
+- `Src/views/VwLayoutStream.cpp`
+- `Src/Common/SimpleRootSite/SimpleRootSite.cs`
+- `Src/views/Test/TestVwRootBox.h`
+
+## Validation
+
+- [x] Run native `TestViews`.
+- [x] Run a managed build for touched managed code.
+- [x] Run a focused managed test pass if available (`SimpleRootSiteTests`).
+
+## Notes
+
+- This change treats DPI as layout state, not reconstruct state.
+- `NeedsReconstruct` / PATH-L5 remain data- and structure-driven; DPI changes only force relayout.
+- `RootSiteTests` still has unrelated pre-existing failures in this worktree, so validation used the narrower `SimpleRootSiteTests` pass for the managed change.
\ No newline at end of file
diff --git a/openspec/changes/render-speedup-benchmark/final_cleanup.md b/openspec/changes/render-speedup-benchmark/final_cleanup.md
new file mode 100644
index 0000000000..18c7882673
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/final_cleanup.md
@@ -0,0 +1,172 @@
+# Final Cleanup Review
+
+This file captures the final review items for the render-speedup branch before the PR is considered clean. The goal is to keep the branch focused on Views rendering speed and render/image assessment, while fixing rebase cruft and tightening explanations where the current diff is ambiguous.
+
+## Required Code Fixes
+
+### `VwRootBox::SetTableColWidths` must force layout dirty
+
+**Files**
+- `Src/views/VwRootBox.cpp`
+
+**Issue**
+`SetTableColWidths()` changes table geometry and then calls `LayoutFull()`, but the new width/DPI layout guard can skip layout if `m_fNeedsLayout` is false and the available width has not changed.
+
+**Best resolution**
+Set `m_fNeedsLayout = true` before calling `LayoutFull()` from `SetTableColWidths()`. Add a focused native regression test if practical; otherwise call out the targeted rationale in the code review notes. This is render-speedup work, not unrelated cleanup, because the new layout guard introduced the stale-layout risk.
+
+### Managed redraw cache must copy from the matching clipped source region
+
+**Files**
+- `Src/ManagedVwDrawRootBuffered/VwDrawRootBuffered.cs`
+- Native comparison point: `Src/views/VwRootBox.cpp`
+
+**Issue**
+`ReDrawLastDraw()` copies from cached source `(0, 0)` for every repaint clip. The native buffered redraw path copies from `(rcp.left, rcp.top)` so a partial repaint gets the matching region from the cached bitmap.
+
+**Best resolution**
+Align the managed cached redraw path with the native coordinate semantics. For disabled-view reuse, copy the requested clip from the same coordinates in the cached full-client image, or explicitly change/cache the managed buffer contract so it is impossible to reuse a full-client buffer with a clipped repaint. The safest fix is to mirror native source offsets and add a focused clipped-repaint test or diagnostic proof.
+
+## Scope And Rebase-Cruft Fixes
+
+### DeleteRecord routing should keep the pub/sub direction
+
+**Files**
+- `Src/FdoUi/FdoUiCore.cs`
+- `Src/Common/Controls/XMLViews/XmlBrowseViewBase.cs`
+- `Src/Common/Controls/XMLViews/XmlBrowseRDEView.cs`
+
+**Issue**
+The current diff moves DeleteRecord dispatch back toward obsolete `Mediator.SendMessage()` command handling and removes `XmlBrowseRDEView`'s pub/sub subscription. That is not a render-speedup feature. It appears to be rebase cruft from older branch history.
+
+**Best resolution**
+Do not revert the broader pub/sub migration direction. Fix the cruft by preserving the newer pub/sub path: deletion initiated by `DeleteUnderlyingObject()` should publish the DeleteRecord event, and the RDE browse view should handle that event through the pub/sub mechanism expected by the current architecture. Reconcile the files carefully rather than blindly reverting chunks, because there may be adjacent compile/interface changes worth keeping. Add or update a focused regression test proving RDE DeleteRecord still reaches the correct handler through pub/sub.
+
+### VS Code task changes should be render-only
+
+**Files**
+- `.vscode/tasks.json`
+
+**Issue**
+The render baseline task belongs in this PR, but the same diff also changes general task descriptions, removes the worktree colorizer `runOn` behavior, and adds a generic `Build Tests` task. Those are workflow cleanup, not render-speedup or render-assessment work.
+
+**Best resolution**
+Keep only `Test: RenderBaselineTests` in this branch. Move the general task cleanup to the non-render cleanup PR, or drop it if it is no longer needed. The render task should remain documented as the quick targeted validation entry point.
+
+### Unused `Verify` package version should be removed or made real
+
+**Files**
+- `Directory.Packages.props`
+
+**Issue**
+The branch adds a central `Verify` package version, but the implementation uses the custom `RenderSnapshotVerifier` and there is no matching `PackageReference Include="Verify"` in the changed projects.
+
+**Best resolution**
+Remove the unused central package version. Only keep it if the branch actually adopts the Verify package through an explicit project reference. For the current custom verifier approach, the central version pin is misleading and should not be in this PR.
+
+### `Views_RenderTiming` switch should be wired or removed
+
+**Files**
+- `Src/Common/FieldWorks/FieldWorks.Diagnostics.dev.config`
+- `Src/views/VwRenderTrace.h`
+
+**Issue**
+The dev diagnostics config adds `Views_RenderTiming`, but the native trace helper is compile-time gated by `TRACING_RENDER` and no runtime consumer of this switch was found. As written, the switch looks like it enables tracing but does not.
+
+**Best resolution**
+Either wire the switch into a real runtime trace path, or remove the config entry and mark the runtime trace-switch work as deferred. If native tracing remains compile-time only, the docs and task list should not claim that the config switch enables it.
+
+## Documentation And Explanation Fixes
+
+### OpenSpec task paths should match the final project layout
+
+**Files**
+- `openspec/changes/render-speedup-benchmark/tasks.md`
+- `openspec/changes/render-speedup-benchmark/quickstart.md`
+
+**Issue**
+Some migrated task entries still point helper classes at `Src/Common/RootSite/RootSiteTests`, but the final implementation places shared infrastructure under `Src/Common/RenderVerification` and `Src/Common/RenderTestInfrastructure`. The quickstart also still uses the old `.csproj` `-TestProject` form and stale snapshot locations.
+
+**Best resolution**
+Update the OpenSpec docs to the actual implementation layout:
+- Shared snapshot/benchmark helpers live under `RenderVerification` and `RenderTestInfrastructure`.
+- RootSite test entry points remain under `Src/Common/RootSite/RootSiteTests`.
+- DataTree render tests and baselines live beside `DetailControlsTests`.
+- Targeted command should use the working form: `./test.ps1 -TestProject "RootSiteTests" -TestFilter "FullyQualifiedName~RenderBaselineTests"`.
+- Approved snapshots are committed as `*.verified.png` beside the relevant test source, not under the old `TestData/RenderSnapshots` path.
+
+### DataTree render validation should describe its actual coverage
+
+**Files**
+- `Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.cs`
+- `Src/Common/RenderVerification/DataTreeRenderHarness.cs`
+- PR description / OpenSpec notes
+
+**Issue**
+Some comments describe the DataTree snapshots as full production layout coverage, but the harness strips known-problematic production parts and can continue after `ShowObject()` throws an `ApplicationException`. The tests are still valuable, but their coverage is not literally the full lexeme edit view.
+
+**Best resolution**
+Adjust comments and PR/OpenSpec wording to say these are production-like render baselines with documented exclusions. Keep the tests, but explain that they validate the DataTree/Slice render pipeline and selected production-like layouts, not every production part in a fully initialized FLEx shell.
+
+### Render project boundary should be explicit
+
+**Files**
+- `Src/Common/RenderVerification/RenderVerification.csproj`
+- `Src/Common/RenderTestInfrastructure/RenderTestInfrastructure.csproj`
+
+**Issue**
+Most shared verifier/reporting files physically live in `RenderVerification`, while `RenderTestInfrastructure` links many of them. That may be intentional to avoid dependencies, but it is not obvious from the file layout.
+
+**Best resolution**
+Either move the linked shared files under the project that owns them, or document the assembly boundary. A good explanation would be: `RenderTestInfrastructure` owns lightweight benchmark/snapshot helpers that tests can reference broadly, while `RenderVerification` owns DataTree/composite capture pieces that need heavier DetailControls dependencies.
+
+### Artifact policy should be crisp
+
+**Files**
+- `.gitignore`
+- `.gitattributes`
+- `Src/Common/RenderVerification/RenderSnapshotVerifier.cs`
+- `Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTimingBaselineCatalog.cs`
+
+**Issue**
+The intended artifact contract is spread across code and repo metadata. Ignoring `*.received.png` and `*.diff.png` is correct for transient snapshot output, but marking ignored `*.received.png` as binary is confusing. `DataTreeTimingBaselines.json` is ignored and missing baselines skip timing threshold checks, so timing thresholds are advisory/local unless the baseline file is committed.
+
+**Best resolution**
+Document the contract in the quickstart and keep metadata consistent:
+- Commit `*.verified.png` and any intentional `*.verified.json` metadata.
+- Ignore transient `*.received.*` and `*.diff.*` artifacts.
+- Remove unnecessary binary metadata for ignored received images unless there is a specific staging workflow.
+- Decide whether `DataTreeTimingBaselines.json` is local advisory data or a committed guard. If local-only, say so and do not present it as a CI-enforced threshold.
+
+### Generic macro hygiene changes should be explained, not moved
+
+**Files**
+- `Src/Generic/GenSmartPtr.h`
+- `Src/Generic/UtilCom.h`
+- `Src/Generic/UtilTime.h`
+- `Src/views/VwRenderTrace.h`
+
+**Issue**
+The Generic utility edits look non-render-specific at first glance.
+
+**Best resolution**
+Keep them with this PR, but explain the supporting purpose: they fix two-step token-paste macro hygiene so `__LINE__` expands correctly, matching the new render trace timer macro pattern and avoiding duplicate local names when lock/timing macros are used more than once in a scope.
+
+## Suggested Cleanup Order
+
+1. Fix the two concrete render bugs: `SetTableColWidths()` dirty layout and managed cached redraw clip coordinates.
+2. Fix DeleteRecord rebase cruft by preserving the pub/sub migration direction and adding a focused regression test.
+3. Trim unrelated `.vscode/tasks.json` changes from this PR.
+4. Remove unused `Verify` package pin unless the package is actually referenced.
+5. Remove or wire `Views_RenderTiming`.
+6. Update `tasks.md`, `quickstart.md`, and PR wording to match the final implementation and artifact policy.
+7. Add a short explanation for Generic macro hygiene and the render project boundary.
+
+## Expected End State
+
+After cleanup, the branch should read as one coherent change set:
+- Views render path avoids redundant reconstruct/layout and caches hot native render resources.
+- Managed render buffering remains visually correct for partial repaints.
+- Render verification and benchmark infrastructure are documented with current paths and commands.
+- Rebase cruft is fixed in favor of the current pub/sub architecture.
+- Non-render workflow cleanup stays in the companion cleanup PR.
\ No newline at end of file
diff --git a/openspec/changes/render-speedup-benchmark/migration-map.md b/openspec/changes/render-speedup-benchmark/migration-map.md
new file mode 100644
index 0000000000..096353ec94
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/migration-map.md
@@ -0,0 +1,48 @@
+## Speckit to OpenSpec Migration Map
+
+This change migrates `specs/001-render-speedup/*` into OpenSpec.
+
+### Source artifacts
+
+| Speckit Source | OpenSpec Destination | Migration Type |
+|---|---|---|
+| `spec.md` | `specs/render-speedup-benchmark/spec.md` | Restructured into ADDED requirements with scenarios |
+| `tasks.md` | `tasks.md` | Carried over with completion status (T001–T026 + OS-* + OPT-*) |
+| `plan.md` | `design.md` | Strategy/constitution merged into design decisions |
+| `research.md` | `design.md` (Research Decisions section) | 6 decisions preserved as numbered decisions 5–10 |
+| `data-model.md` | `data-model.md` | Carried over with all 7 entities |
+| `contracts/render-benchmark.openapi.yaml` | `contracts/render-benchmark.openapi.yaml` | Copied verbatim |
+| `quickstart.md` | `quickstart.md` | Copied verbatim |
+| `checklists/requirements.md` | `design.md` (Constitution Check section) | Quality checklist folded into design notes |
+| `FAST_FORM_PLAN.md` | `research/FAST_FORM_PLAN.md` | Copied verbatim — architecture analysis, algorithms, impl paths |
+| `FORMS_SPEEDUP_PLAN.md` | `research/FORMS_SPEEDUP_PLAN.md` | Copied verbatim — 10 optimization techniques with code examples |
+| `JIRA_FORMS_SPEEDUP_PLAN.md` | `research/JIRA_FORMS_SPEEDUP_PLAN.md` | Copied verbatim — JIRA story defs, sprint plan, feature flags |
+| `views-architecture-research.md` | `research/views-architecture-research.md` | Copied verbatim — native C++ Views deep dive |
+| `JIRA-21951.md` | `research/JIRA-21951.md` | Copied verbatim — original problem statement |
+| _(new)_ | `research/implementation-paths.md` | Synthesized from FAST_FORM_PLAN Sections 2–4 |
+| _(new)_ | `timing-artifact-schema.md` | New artifact for benchmark output schemas |
+| `Slow 2026-01-14 1140.fwbackup` | _(not migrated)_ | Binary test data; stays in Speckit folder or is archived separately |
+
+### Data model entities carried over
+
+- Render Scenario
+- Render Snapshot
+- Benchmark Run
+- Benchmark Result
+- Trace Event
+- Analysis Summary
+- Contributor
+
+### Task status carry-over
+
+Speckit task completion state is preserved in `tasks.md` (T001–T026 and pivots), with OpenSpec continuation tasks (`OS-*`) for work started post-migration and optimization tasks (`OPT-*`) for the 10 techniques from FORMS_SPEEDUP_PLAN.
+
+### Key content that was restructured (not copied verbatim)
+
+- **Edge cases** from Speckit `spec.md` → added to OpenSpec `spec.md` Edge Cases section
+- **Assumptions** from Speckit `spec.md` → added to OpenSpec `spec.md` Assumptions section
+- **Success criteria** (SC-001 through SC-005) → added to OpenSpec `spec.md` Success Criteria section
+- **Performance targets** → converted from absolute ms to relative improvement % in OpenSpec `spec.md`
+- **Research decisions** (6 decisions from `research.md`) → numbered decisions 5–10 in `design.md`
+- **Constitution check** (from `plan.md`) → table in `design.md`
+- **Optimization techniques** (10 from FORMS_SPEEDUP_PLAN) → OPT-1 through OPT-11 tasks in `tasks.md`
diff --git a/openspec/changes/render-speedup-benchmark/proposal.md b/openspec/changes/render-speedup-benchmark/proposal.md
new file mode 100644
index 0000000000..4ac6d06fe9
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/proposal.md
@@ -0,0 +1,36 @@
+## Why
+
+We are migrating the full Speckit `specs/001-render-speedup` scope into OpenSpec and continuing implementation from there. This scope combines three linked goals: deterministic pixel-perfect render validation, repeatable timing baselines across fixed scenarios, and trace diagnostics for render-stage attribution. Recent DataTree benchmark improvements surfaced a fidelity gap (deep scenarios not always exercising true deep workload), so migration must preserve existing completed work and explicitly track remaining items.
+
+## What Changes
+
+- Carry over all Speckit user stories, FR/SC requirements, and tasks into OpenSpec with completion status preserved.
+- Keep the benchmark harness and five-scenario timing suite as first-class capabilities.
+- Preserve trace-diagnostics requirements for native Views (`VwRootBox`, `VwEnv`, lazy expansion paths).
+- Add DataTree benchmark-fidelity guardrails so scenario complexity growth is enforced.
+- Continue optimization work using measured before/after evidence and snapshot safety gates.
+
+## Non-goals
+
+- Rewriting the DataTree or Views architecture in this change.
+- Replacing WinForms UI framework.
+- Introducing external services or non-repo dependencies.
+
+## Capabilities
+
+### New Capabilities
+
+- `render-speedup-benchmark`: Pixel-perfect render baseline + five-scenario timing suite + benchmark artifacts.
+- `render-trace-diagnostics`: File-based render-stage diagnostics, parsing, and top-contributor summaries.
+- `datatree-benchmark-fidelity`: DataTree timing path with monotonic workload guardrails.
+
+### Modified Capabilities
+
+- `architecture/ui-framework/views-rendering`: Add benchmark and tracing guidance for managed/native render analysis.
+
+## Impact
+
+- Managed test/harness code under `Src/Common/RootSite/RootSiteTests/`, `Src/Common/RenderVerification/`, and `Src/Common/Controls/DetailControls/DetailControlsTests/`.
+- Native trace instrumentation surfaces under `Src/views/`.
+- Benchmark artifacts under `Output/RenderBenchmarks/`.
+- Migration references retained to Speckit source under `specs/001-render-speedup/` for auditability.
diff --git a/openspec/changes/render-speedup-benchmark/quickstart.md b/openspec/changes/render-speedup-benchmark/quickstart.md
new file mode 100644
index 0000000000..acef0d9a4f
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/quickstart.md
@@ -0,0 +1,83 @@
+# Quickstart: Render Performance Baselines
+
+## Prerequisites
+
+- Windows x64 with deterministic settings (fonts, DPI, theme).
+- Debug build output available locally.
+- No external services required.
+
+## Build
+
+```powershell
+.\build.ps1
+```
+
+## Run the RootSite snapshot suite
+
+```powershell
+.\test.ps1 -TestProject "RootSiteTests" -TestFilter "FullyQualifiedName~RenderBaselineTests"
+```
+
+This validates the committed RootSite snapshot baselines and the shared snapshot verifier.
+
+## Run the timing suite
+
+```powershell
+.\test.ps1 -TestProject "RootSiteTests" -TestFilter "FullyQualifiedName~RenderTimingSuiteTests"
+```
+
+Results are written to:
+
+- `Output/RenderBenchmarks/results.json` for machine-readable timing data.
+- `Output/RenderBenchmarks/summary.md` for the human-readable summary.
+
+## Snapshot files and regeneration
+
+Committed baselines live next to the tests that own them:
+
+- `Src/Common/RootSite/RootSiteTests/*.verified.png`
+- `Src/Common/Controls/DetailControls/DetailControlsTests/*.verified.png`
+- `Src/Common/Controls/DetailControls/DetailControlsTests/*.verified.json` for scenarios that store extra metadata.
+
+Transient verification outputs are ignored:
+
+- `*.received.png`
+- `*.diff.png`
+
+To regenerate a committed baseline, delete the matching `*.verified.png` file and rerun the owning test suite.
+
+## Timing baselines
+
+`Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTimingBaselines.json` is local advisory data. When it is missing, timing threshold checks are skipped instead of failing CI.
+
+## Trace diagnostics
+
+Use `RenderDiagnosticsToggle` from the shared render infrastructure when a test run needs managed trace capture:
+
+```csharp
+using (var diagnostics = new RenderDiagnosticsToggle())
+{
+ diagnostics.EnableDiagnostics();
+ // Run benchmark or snapshot capture.
+ diagnostics.Flush();
+ var traceContent = diagnostics.GetTraceLogContent();
+}
+```
+
+`Src/Common/FieldWorks/FieldWorks.Diagnostics.dev.config` keeps the core xWorks trace switches enabled for Debug runs. Native `VwRenderTrace.h` timing output is still compile-time-gated by `TRACING_RENDER`; there is no always-on runtime `Views_RenderTiming` switch in the final configuration.
+
+## Project layout
+
+- `Src/Common/RenderTestInfrastructure/RenderTestInfrastructure.csproj` exposes the lightweight benchmark, snapshot, and trace helpers that test projects can reference broadly.
+- The source for those reusable helpers lives under `Src/Common/RenderVerification/`.
+- `Src/Common/RenderVerification/RenderVerification.csproj` adds the heavier DataTree and composite-capture pieces that depend on `DetailControls`.
+- `Src/Common/RootSite/RootSiteTests/RenderBenchmarkHarness.cs` stays in `RootSiteTests` because it still depends on RootSite test-only view scaffolding.
+
+## Environment requirements
+
+For pixel-perfect validation to pass, the runtime environment must match the baseline:
+
+- DPI typically 96x96 (100% scaling).
+- Theme must match the captured baseline.
+- Font smoothing state must match the baseline.
+- Text scale factor should remain 100%.
diff --git a/openspec/changes/render-speedup-benchmark/refresh-dirty-flag-audit.md b/openspec/changes/render-speedup-benchmark/refresh-dirty-flag-audit.md
new file mode 100644
index 0000000000..6455bd4274
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/refresh-dirty-flag-audit.md
@@ -0,0 +1,386 @@
+# Refresh Dirty-Flag Audit
+
+Date: 2026-03-10
+
+## Purpose
+
+This document audits the parts of FieldWorks that can require a visual refresh after the PATH-L5 / PATH-R1 optimization work.
+
+The core question is now:
+
+> When something changes that makes the current box tree stale, what code path marks the root box dirty so a later `RefreshDisplay()` or `Reconstruct()` actually rebuilds it?
+
+The recent `SetRootObjects()` fix in [Src/views/VwRootBox.cpp](../../../../Src/views/VwRootBox.cpp) shows the pattern clearly: the optimization is valid, but every mutation path that invalidates the current render tree must set the dirty state.
+
+## Executive Summary
+
+- The native source of truth for whether a box tree needs rebuilding is `VwRootBox::m_fNeedsReconstruct` in [Src/views/VwRootBox.h](../../../../Src/views/VwRootBox.h).
+- The managed fast path is [SimpleRootSite.RefreshDisplay()](../../../../Src/Common/SimpleRootSite/SimpleRootSite.cs), which now skips managed overhead when `m_rootb.NeedsReconstruct == false`.
+- The native fast path is `VwRootBox::Reconstruct()`, which now returns early when `m_fConstructed && !m_fNeedsReconstruct`.
+- The architecture is correct only if every semantic mutation that can stale the view either:
+ - sets `m_fNeedsReconstruct = true` directly in native code, or
+ - causes a `PropChanged(...)` notification that sets it indirectly.
+- Confirmed missing path that was already fixed: `SetRootObjects(...)` on an already-constructed root box.
+- Confirmed managed fix: `DictionaryPublicationDecorator.Refresh()` now emits a conservative invalidation when its filtered state changes.
+- Plausible native risk still to close: `putref_DataAccess(...)` does not dirty reconstruct state if used post-construction.
+- Confirmed current contract: `putref_Overlay(...)` is intentionally relayout-only, with native regression coverage to lock that behavior in.
+- Confirmed native stylesheet contract with regression coverage: `OnStylesheetChange()` dirties reconstruct state and also triggers immediate relayout.
+- Separate fact: many callers invoke `m_rootb.Reconstruct()` directly. Those sites bypass `SimpleRootSite.RefreshDisplay()` and therefore are not protected by the managed guard, but they still depend on native reconstruct semantics.
+
+## Current Architecture
+
+### Native dirty state
+
+`VwRootBox` owns the native dirty flag:
+
+- `m_fNeedsReconstruct` in [Src/views/VwRootBox.h](../../../../Src/views/VwRootBox.h)
+- exposed through `IVwRootBox.NeedsReconstruct` in [Src/Common/ViewsInterfaces/Views.cs](../../../../Src/Common/ViewsInterfaces/Views.cs)
+
+Confirmed native dirtying paths in [Src/views/VwRootBox.cpp](../../../../Src/views/VwRootBox.cpp):
+
+- `Init()` initializes `m_fNeedsReconstruct = true`
+- `PropChanged(HVO hvo, PropTag tag, int ivMin, int cvIns, int cvDel)` sets `m_fNeedsReconstruct = true`
+- `OnStylesheetChange()` sets `m_fNeedsReconstruct = true`
+- `SetRootObjects(...)` now sets `m_fNeedsReconstruct = true` before `Reconstruct()` when already constructed
+
+Confirmed native clearing paths:
+
+- `Construct(...)` clears `m_fNeedsReconstruct = false`
+- `Reconstruct(...)` clears `m_fNeedsReconstruct = false`
+
+### Managed refresh gate
+
+[SimpleRootSite.RefreshDisplay()](../../../../Src/Common/SimpleRootSite/SimpleRootSite.cs) does this:
+
+1. If a decorator is installed as `m_rootb.DataAccess`, call `decorator.Refresh()`.
+2. If the control is not visible, defer refresh.
+3. If `!m_rootb.NeedsReconstruct`, return without selection save/restore or drawing suspension.
+4. Otherwise call `m_rootb.Reconstruct()`.
+
+This means the system has two layered assumptions:
+
+- managed assumption: `NeedsReconstruct` accurately tells `SimpleRootSite` whether refresh work is needed
+- native assumption: `m_fNeedsReconstruct` accurately tells `VwRootBox::Reconstruct()` whether rebuild work is needed
+
+If either layer misses a mutation path, stale display becomes possible.
+
+## Affected Refresh Hosts
+
+These classes route through `SimpleRootSite.RefreshDisplay()` or are structurally downstream of it.
+
+### Core hosts
+
+| Class / family | File | Refresh model | Notes |
+|---|---|---|---|
+| `SimpleRootSite` | [Src/Common/SimpleRootSite/SimpleRootSite.cs](../../../../Src/Common/SimpleRootSite/SimpleRootSite.cs) | Base managed refresh gate | Central PATH-L5 check |
+| `RootSite` | [Src/Common/RootSite/RootSite.cs](../../../../Src/Common/RootSite/RootSite.cs) | Inherits `SimpleRootSite.RefreshDisplay()` | Most document-style views flow through here |
+| `FwRootSite` family | multiple under `Src/` | Inherits `RootSite` behavior | Affected unless they directly call `m_rootb.Reconstruct()` |
+| `XmlSeqView` / `XmlBrowseViewBase` / `XmlBrowseView` | [Src/Common/Controls/XMLViews/](../../../../Src/Common/Controls/XMLViews/) | Mostly go through base refresh, with pre/post work | Common consumers of decorators |
+| Direct `SimpleRootSite` subclasses such as `InnerFwTextBox`, `InnerFwListBox`, `InternalFwMultiParaTextBox`, `InnerLabeledMultiStringControl`, `RelatedWordsView`, `BulletsPreview`, `SampleView` | `Src/Common/Controls/Widgets/`, `Src/FdoUi/Dialogs/`, `Src/FwCoreDlgs/` | Mixed | Some rely on `RefreshDisplay()`, others call `m_rootb.Reconstruct()` directly |
+
+### Composite refresh callers
+
+These are important because they cause refreshes but are not the dirty-state authority:
+
+| Class | File | Role |
+|---|---|---|
+| `RootSiteGroup` | [Src/Common/RootSite/RootSite.cs](../../../../Src/Common/RootSite/RootSite.cs) | Delegates refresh to `ScrollingController.RefreshDisplay()` |
+| `FwXWindow` | [Src/xWorks/FwXWindow.cs](../../../../Src/xWorks/FwXWindow.cs) | Walks refreshable children |
+| `BrowseViewer` | [Src/Common/Controls/XMLViews/BrowseViewer.cs](../../../../Src/Common/Controls/XMLViews/BrowseViewer.cs) | Higher-level refresh wrapper |
+| `LabeledMultiStringView` | [Src/Common/Controls/Widgets/LabeledMultiStringView.cs](../../../../Src/Common/Controls/Widgets/LabeledMultiStringView.cs) | Wraps inner root site refresh |
+| `DataTree` | [Src/Common/Controls/DetailControls/DataTree.cs](../../../../Src/Common/Controls/DetailControls/DataTree.cs) | Refreshes slices by rebuilding control list, not by root-box dirty flag |
+
+## Dirtying Matrix: Native Reconstruct-State Mutations
+
+These are the highest-priority paths because they define whether the native box tree is stale.
+
+| Item | Parameters / transform | Should make `NeedsReconstruct` dirty? | Current behavior | Status |
+|---|---|---:|---|---|
+| `VwRootBox::PropChanged(HVO hvo, PropTag tag, int ivMin, int cvIns, int cvDel)` | Any semantic data change surfaced through SDA notification | Yes | Sets `m_fNeedsReconstruct = true` | Confirmed correct |
+| `VwRootBox::OnStylesheetChange()` | Stylesheet effects change rendered output and layout | Yes | Sets `m_fNeedsReconstruct = true` and `m_fNeedsLayout = true`, then immediately relayouts | Confirmed correct and covered by native regression test |
+| `VwRootBox::SetRootObjects(HVO* prghvo, IVwViewConstructor** prgpvwvc, int* prgfrag, IVwStylesheet* pss, int chvo)` | Root HVO, VC, fragment, stylesheet, or root count changes | Yes | Now sets `m_fNeedsReconstruct = true` before `Reconstruct()` when already constructed | Confirmed fixed |
+| `VwRootBox::SetRootObject(HVO hvo, IVwViewConstructor* pvvc, int frag, IVwStylesheet* pss)` | Single-root wrapper over `SetRootObjects(...)` | Yes | Delegates to `SetRootObjects(...)` | Confirmed correct after fix |
+| `VwRootBox::putref_Overlay(IVwOverlay* pvo)` | Overlay affects appearance and can affect layout | No, under the current design it is relayout-only | Sets `m_fNeedsLayout = true` and calls `LayoutFull()`, but does not set `m_fNeedsReconstruct` | Confirmed intentional and covered by native regression test |
+| `VwRootBox::putref_DataAccess(ISilDataAccess* psda)` | Replacing the underlying data access object after construction | No by itself; this remains the cheap wiring primitive | Adds/removes notifications, does not dirty reconstruct state | Confirmed intentional and covered by native regression test |
+| `VwRootBox::Construct(...)` | Initial construction of box tree | No, this is the rebuild itself | Clears `m_fNeedsReconstruct = false` | Correct |
+| `VwRootBox::Reconstruct(...)` | Full rebuild of current root | No, this is the rebuild itself | Clears `m_fNeedsReconstruct = false` at end | Correct |
+
+### Native paths that bypass the dirty flag intentionally
+
+Not every path that bypasses `m_fNeedsReconstruct` is a bug. The native sweep found several intentional bypass categories:
+
+| Path family | Example | Why it bypasses the flag |
+|---|---|---|
+| Immediate reconstruct after explicit root change | `SetRootObjects(...)` | Sets the flag and immediately reconstructs |
+| Immediate relayout after appearance change | `OnStylesheetChange()`, `putref_Overlay(...)` | Treats the change as relayout work instead of deferred rebuild work |
+| Cheap source wiring without semantic invalidation | `putref_DataAccess(...)` | Keeps setup-time and non-visual SDA swaps cheap; host layers must opt into an explicit semantic refresh signal when the current tree is stale |
+
+### Explicit SDA swap contract
+
+The current native/managed split treats SDA changes as a two-step contract:
+
+1. `putref_DataAccess(...)` changes notification wiring and the source used for future reads.
+2. `SimpleRootSite.NotifyDataAccessSemanticsChanged()` is the explicit opt-in signal for the rarer case where an already-constructed root site's current tree is semantically stale and must be rebuilt later.
+
+This keeps initialization-time `DataAccess` setup cheap, avoids forcing incidental swaps to behave like live-view refreshes, and reuses the existing managed refresh pipeline instead of changing the `IVwRootBox` COM surface.
+
+The known `XmlSeqView` print-source swap should stay on the cheap path. That code temporarily changes `RootBox.DataAccess` so `SimpleRootSite.Print()` picks up an alternate SDA when creating a separate `PrintRootSite`; it is not asking the live on-screen root box to rebuild.
+
+The first concrete product caller adjusted under this contract is `InterlinRibbon.SetRoot(...)`. That path previously rebuilt the existing root and then swapped `DataAccess` to the ribbon decorator afterward. It now installs the decorator before calling `ChangeOrMakeRoot(...)`, so the rebuild runs against the right source without needing a post-construct semantic refresh.
+| Lazy-box incremental updates | lazy expansion and notifier relayout paths | Mutates only the affected part of the tree |
+| Synchronizer-driven updates | synchronized lazy expansion / contraction paths | Coordinator logic updates participating roots directly |
+
+The real bug pattern is narrower: a path that changes semantics, does not dirty reconstruct state, and also does not immediately repair the tree.
+
+## Dirtying Matrix: Decorators and Data-Transform Layers
+
+The managed refresh path explicitly calls `decorator.Refresh()` before checking `NeedsReconstruct`. That makes decorator behavior part of the architecture.
+
+### Confirmed decorator behavior in this repo
+
+| Decorator | File | Transform it performs | Should dirty reconstruct state when transform changes visible output? | Current dirty path | Status |
+|---|---|---|---:|---|---|
+| `ConcDecorator` | [Src/xWorks/ConcDecorator.cs](../../../../Src/xWorks/ConcDecorator.cs) | Rebuilds cached occurrence/value lists | Yes | Overrides `Refresh()`, clears caches, then calls `base.Refresh()`; also sends property-change style notifications elsewhere | Looks aligned |
+| `DictionaryPublicationDecorator` | [Src/xWorks/DictionaryPublicationDecorator.cs](../../../../Src/xWorks/DictionaryPublicationDecorator.cs) | Rebuilds publication exclusion sets, filtered field sets, homograph info | Yes | `Refresh()` now compares pre/post filtered state and sends a broad lexical-list invalidation when that state changes | Fixed and validated with targeted test |
+| `XMLViewsDataCache` | [Src/Common/Controls/XMLViews/XMLViewsDataCache.cs](../../../../Src/Common/Controls/XMLViews/XMLViewsDataCache.cs) | View-local cached data for XML views | Yes if cache contents affect visible rows/strings | Does not override `Refresh()`; repo-local setters appear to use explicit property-change notifications | Low risk based on repo-local usage |
+| `ObjectListPublisher` | [Src/Common/Controls/XMLViews/ObjectListPublisher.cs](../../../../Src/Common/Controls/XMLViews/ObjectListPublisher.cs) | Owner/list projection | Yes | No `Refresh()` override; public mutation API issues `SendPropChanged(...)` | Low risk |
+| `FilterSdaDecorator` | [Src/Common/Controls/XMLViews/FilterSdaDecorator.cs](../../../../Src/Common/Controls/XMLViews/FilterSdaDecorator.cs) | Filtered sequence projection | Yes | No `Refresh()` override found; appears static after setup | Low risk in current usage |
+| `InterestingTextsDecorator` | [Src/xWorks/InterestingTextsDecorator.cs](../../../../Src/xWorks/InterestingTextsDecorator.cs) | Filtered text list projection | Yes | Relies on external interesting-text-list notifications rather than local `Refresh()` override | Medium risk |
+| `ComplexConcPatternSda` | [Src/LexText/Interlinear/ComplexConcPatternSda.cs](../../../../Src/LexText/Interlinear/ComplexConcPatternSda.cs) | Pattern-based concordance transform | Yes | Inherits base behavior | Unverified in this repo |
+| `InterlinRibbonDecorator` | [Src/LexText/Discourse/InterlinRibbonDecorator.cs](../../../../Src/LexText/Discourse/InterlinRibbonDecorator.cs) | Ribbon/occurrence transform | Yes | Inherits base behavior | Unverified in this repo |
+| `ShowSpaceDecorator` | [Src/LexText/Interlinear/ShowSpaceDecorator.cs](../../../../Src/LexText/Interlinear/ShowSpaceDecorator.cs) | Alternate display formatting | Yes | Inherits base behavior | Unverified in this repo |
+| `ReversalEntryDataAccess` | [Src/LexText/Lexicon/ReversalIndexEntrySlice.cs](../../../../Src/LexText/Lexicon/ReversalIndexEntrySlice.cs) | Reversal entry projection/caching | Yes | Special-case `PropChanged()` behavior but no repo-local `Refresh()` override | Low-medium risk |
+| `GhostDaDecorator` | [Src/Common/Controls/DetailControls/GhostStringSlice.cs](../../../../Src/Common/Controls/DetailControls/GhostStringSlice.cs) | Ghost-string data projection | Yes | Inherits base behavior | Unverified in this repo |
+| `SdaDecorator` in reference views | [Src/Common/Controls/DetailControls/PossibilityVectorReferenceView.cs](../../../../Src/Common/Controls/DetailControls/PossibilityVectorReferenceView.cs), [Src/Common/Controls/DetailControls/PossibilityAtomicReferenceView.cs](../../../../Src/Common/Controls/DetailControls/PossibilityAtomicReferenceView.cs), [Src/Common/Controls/DetailControls/PhoneEnvReferenceView.cs](../../../../Src/Common/Controls/DetailControls/PhoneEnvReferenceView.cs) | Specialized reference-view projection | Yes | Inherits base behavior | Unverified in this repo |
+
+### Confirmed decorator outlier
+
+`DictionaryPublicationDecorator` is the important current outlier because:
+
+- `Refresh()` rebuilds `m_excludedItems`, `m_fieldsToFilter`, and homograph info.
+- Those caches directly affect what rows and fields are visible.
+- `Refresh()` does not obviously emit a native-dirtying signal.
+- `PropChanged(...)` in that class does re-broadcast notifications, but that only helps if refresh-causing operations actually flow through `PropChanged(...)`.
+
+This is the strongest remaining argument that the current architecture still relies on convention rather than one enforced rule.
+
+### Decorator risk ranking from the comprehensive sweep
+
+The risk is not evenly distributed across all decorators.
+
+- High risk before fix: `DictionaryPublicationDecorator`
+- Medium risk: `InterestingTextsDecorator`, `ConcDecorator`, and downstream `RespellingSda` behavior through `ConcDecorator`
+- Low-medium risk: `ReversalEntryDataAccess`
+- Low risk: `ObjectListPublisher`, `XMLViewsDataCache`, `FilterSdaDecorator`, and most reference-view / ghost decorators
+
+The concentration of risk in dynamic filtering/projection decorators suggests the current notification model is mostly sound, but brittle at the most stateful transforms.
+
+## Sites That Bypass the Managed Guard
+
+Many classes call `m_rootb.Reconstruct()` directly instead of relying on `RefreshDisplay()`. Examples include files under:
+
+- [Src/Common/Controls/Widgets/FwTextBox.cs](../../../../Src/Common/Controls/Widgets/FwTextBox.cs)
+- [Src/Common/Controls/Widgets/FwListBox.cs](../../../../Src/Common/Controls/Widgets/FwListBox.cs)
+- [Src/Common/Controls/Widgets/FwMultiParaTextBox.cs](../../../../Src/Common/Controls/Widgets/FwMultiParaTextBox.cs)
+- [Src/Common/Controls/XMLViews/XmlSeqView.cs](../../../../Src/Common/Controls/XMLViews/XmlSeqView.cs)
+- [Src/LexText/Interlinear/SandboxBase.cs](../../../../Src/LexText/Interlinear/SandboxBase.cs)
+- [Src/LexText/Morphology/InflAffixTemplateControl.cs](../../../../Src/LexText/Morphology/InflAffixTemplateControl.cs)
+
+This matters because:
+
+- they do not depend on the managed PATH-L5 check
+- they still depend on native `Reconstruct()` semantics
+- they can hide missing dirty paths if the caller simply reconstructs unconditionally
+
+This is not necessarily wrong. It just means the architecture is currently mixed:
+
+- some sites use dirty-flag-driven refresh
+- some sites opt into explicit full rebuilds
+
+### Direct `m_rootb.Reconstruct()` caller categories
+
+The managed sweep grouped direct reconstruct callers into four buckets:
+
+| Category | Typical files | Why they rebuild directly |
+|---|---|---|
+| Fragment / VC / root semantics change | [Src/Common/Controls/XMLViews/XmlSeqView.cs](../../../../Src/Common/Controls/XMLViews/XmlSeqView.cs), [Src/LexText/ParserUI/TryAWordRootSite.cs](../../../../Src/LexText/ParserUI/TryAWordRootSite.cs) | View composition changed in a way the caller treats as an immediate rebuild event |
+| UI-property / writing-system changes | [Src/Common/Controls/Widgets/FwTextBox.cs](../../../../Src/Common/Controls/Widgets/FwTextBox.cs), [Src/Common/Controls/Widgets/FwMultiParaTextBox.cs](../../../../Src/Common/Controls/Widgets/FwMultiParaTextBox.cs), [Src/Common/Controls/Widgets/FwListBox.cs](../../../../Src/Common/Controls/Widgets/FwListBox.cs) | Rendering assumptions changed outside normal model `PropChanged` flow |
+| Dialog-launcher / collection edits | files under `Src/LexText/Lexicon/` | UI action commits data that the view chooses to rebuild immediately |
+| Workaround paths for incomplete dependency tracking | [Src/LexText/Morphology/InflAffixTemplateControl.cs](../../../../Src/LexText/Morphology/InflAffixTemplateControl.cs) | Explicit workaround because the XML `` element does not fully track dependencies |
+
+This categorization suggests that direct `Reconstruct()` calls are serving two roles:
+
+- legitimate immediate rebuild policy chosen by the caller
+- escape hatch for dependency-tracking weaknesses higher in the stack
+
+That second role is where future audit effort is likely to pay off.
+
+## What Correctly Needs to Dirty the Flag
+
+The right rule is not “everything that changes should set the flag.” The right rule is narrower:
+
+> Any operation that can make the existing root-box tree semantically wrong for the next paint must dirty reconstruct state, unless that operation immediately performs its own full rebuild.
+
+That includes:
+
+- changing root object identity
+- changing fragment selection
+- changing view constructor selection
+- changing stylesheet in a way that requires rebuilding boxes, not just relayout
+- changing overlay if overlay semantics alter generated boxes and not just layout metrics
+- changing decorator-side filtering, projection, suppression, homograph labeling, or any other transform that changes visible content
+- any deferred or batch-updated data access swap performed after initial construction
+
+That does not necessarily include:
+
+- pure width/layout changes already handled by `m_fNeedsLayout`
+- explicit callers that immediately invoke `m_rootb.Reconstruct()` as their chosen mechanism
+
+## Test and Evidence Coverage
+
+The subagent sweep also catalogued the current tests and the remaining blind spots.
+
+### Existing coverage
+
+Representative existing coverage includes:
+
+- [Src/Common/RootSite/RootSiteTests/RenderBaselineTests.cs](../../../../Src/Common/RootSite/RootSiteTests/RenderBaselineTests.cs)
+- [Src/Common/RootSite/RootSiteTests/RenderTimingSuiteTests.cs](../../../../Src/Common/RootSite/RootSiteTests/RenderTimingSuiteTests.cs)
+- [Src/Common/RootSite/RootSiteTests/MoreRootSiteTests.cs](../../../../Src/Common/RootSite/RootSiteTests/MoreRootSiteTests.cs)
+- [Src/xWorks/xWorksTests/DictionaryPublicationDecoratorTests.cs](../../../../Src/xWorks/xWorksTests/DictionaryPublicationDecoratorTests.cs)
+- [Src/xWorks/xWorksTests/InterestingTextsTests.cs](../../../../Src/xWorks/xWorksTests/InterestingTextsTests.cs)
+- [Src/Common/Controls/XMLViews/XMLViewsTests/XmlBrowseViewBaseTests.cs](../../../../Src/Common/Controls/XMLViews/XMLViewsTests/XmlBrowseViewBaseTests.cs)
+
+These tests give evidence that the optimization works and that several `PropChanged`-driven refresh paths behave correctly.
+
+### Coverage gaps
+
+The most important remaining gaps are:
+
+| Gap | Why it matters |
+|---|---|
+| Direct `Reconstruct()` caller scenarios (`XmlView`, `XmlSeqView`, `TryAWordRootSite`, bulk-edit flows) | These bypass `RefreshDisplay()` by design and therefore need focused caller-level tests rather than more root-box dirty-flag changes |
+| Decorator refresh under PATH-L5 for dynamic list/projector decorators | Validates that `Refresh()`-driven visible changes are not lost when `NeedsReconstruct` stays false |
+| XML `` and similar dependency-tracking workaround paths | Confirms explicit rebuild workarounds remain necessary and intentional |
+| Width-change boundary behavior around `m_fNeedsLayout` / `m_dxLastLayoutWidth` | Confirms the split between relayout and reconstruct stays coherent |
+
+### Suggested test targets
+
+If this audit turns into follow-up implementation work, the highest-value new tests are:
+
+1. a focused managed test for direct rebuild callers such as `XmlView.ResetTables()` / `XmlSeqView.ResetTables()`
+2. a decorator test for `DictionaryPublicationDecorator.Refresh()` under the managed fast path
+3. a focused managed test for the XML `` workaround path that currently rebuilds explicitly
+4. a width-boundary test that documents relayout-only behavior versus reconstruct behavior
+
+## Execution Checklist
+
+### Findings
+
+- [x] Confirm `SetRootObjects(...)` needed to dirty reconstruct state for already-constructed root boxes.
+- [x] Confirm `DictionaryPublicationDecorator.Refresh()` was the highest-risk managed refresh path.
+- [x] Identify `putref_DataAccess(...)` as a plausible native dirty-flag gap.
+- [x] Resolve whether `putref_Overlay(...)` is intentionally relayout-only.
+- [x] Decide whether any other decorator besides `DictionaryPublicationDecorator` needs immediate code changes.
+- [x] Classify remaining live `DataAccess` swaps after construction.
+
+Findings summary:
+
+- No additional live post-construction `DataAccess` swap needing code changes was found beyond the already-fixed `InterlinRibbon.SetRoot(...)` ordering case.
+- `XmlSeqView` print-time `DataAccess` swaps remain intentionally cheap because they are print/setup flows, not live-view semantic refreshes.
+- The remaining medium-risk decorator is `InterestingTextsDecorator`, but its update model already flows through explicit `SendPropChanged(...)` on list changes rather than a hidden post-construction SDA swap.
+- Most remaining direct `m_rootb.Reconstruct()` callers are explicit caller-policy rebuilds or workaround paths for incomplete dependency tracking, not evidence that `putref_DataAccess(...)` should dirty the root box globally.
+
+### Existing Coverage Verified
+
+- [x] Native regression test added for repeated `SetRootObject()` on a constructed view.
+- [x] Existing render baseline/timing tests cover the performance intent of PATH-L5 / PATH-R1.
+- [x] Existing dictionary decorator tests cover filtering logic and `PropChanged(...)` routing.
+- [x] Existing `InterestingTextsTests` cover the list/decorator event path at the domain level.
+- [x] Existing `SimpleRootSiteTests` cover cheap SDA swaps, explicit semantic refresh, and hidden-site deferral.
+- [x] Existing `InterlinRibbonTests` cover decorator-install ordering before rebuilding an existing root.
+
+### New Tests Planned
+
+- [x] Add native test that `putref_DataAccess(...)` remains a cheap wiring operation after construction.
+- [x] Add managed test coverage for explicit semantic refresh after a post-construction SDA swap.
+- [x] Add targeted test for overlay mutation behavior after construction.
+- [x] Add targeted test for stylesheet-change behavior after construction.
+- [x] Add focused caller-level test for `XmlView.ResetTables()` / `XmlSeqView.ResetTables()` explicit rebuild behavior.
+- [x] Add focused caller-level test for the XML `` rebuild workaround path.
+- [x] Close the decorator-visible PATH-L5 gap with the existing `DictionaryPublicationDecoratorTests.Refresh_NotifiesRegisteredRoots_WhenVisibleFilteringChanges` coverage plus the focused `SimpleRootSite` PATH-L5 tests.
+- [x] Add width-boundary test that distinguishes relayout-only invalidation from reconstruct invalidation.
+
+### Edge Cases Planned
+
+- [x] Existing root box rebuilt after VC/layout-spec table reset.
+- [x] Decorator-visible filter/list change while `NeedsReconstruct == false`.
+- [x] Hidden-site semantic refresh deferred until visible.
+- [x] XML `` dependency change that still requires an explicit rebuild workaround.
+- [x] Width change that should relayout without forcing reconstruct.
+
+### Code Changes Completed
+
+- [x] Keep `putref_DataAccess(...)` as a cheap wiring operation and move semantic post-construction swaps to the managed explicit-refresh contract.
+- [x] Make `DictionaryPublicationDecorator.Refresh()` participate in the normal refresh/invalidation contract.
+- [x] Add explicit managed SDA-swap helpers in `SimpleRootSite` for cheap versus semantic swaps.
+- [x] Reorder `InterlinRibbon.SetRoot(...)` so the decorator is installed before rebuilding an existing root.
+
+### Code Changes Rejected By Audit
+
+- [x] Do not dirty reconstruct state automatically in `putref_DataAccess(...)`.
+- [x] Do not add another COM/native dirtying API for SDA semantics.
+- [x] Do not weaken the PATH-L5 / PATH-R1 guards globally for decorated views.
+
+### Validation
+
+- [x] Get a clean native `TestViews` validation run.
+- [x] Run targeted managed tests for `DictionaryPublicationDecoratorTests` after managed test/code changes.
+- [x] Run targeted managed tests for `SimpleRootSiteTests` covering cheap SDA swaps and explicit semantic refreshes.
+- [x] Run targeted managed tests for `InterlinRibbon` ordering on existing root boxes.
+- [x] Run the focused `XMLViewsTests` assertions for explicit rebuild callers and the `ShowFailingItems` workaround path.
+
+### Validation Notes
+
+- `SimpleRootSiteTests` now provide deterministic coverage for:
+ - cheap `SetRootBoxDataAccess(...)` swaps staying on the no-reconstruct fast path,
+ - explicit `SetRootBoxDataAccessAndRefresh(...)` swaps forcing one managed rebuild,
+ - deferred semantic refresh when the site is hidden.
+- `InterlinRibbonTests.SetRoot_AssignsDecoratorBeforeChangingExistingRootObject` provides deterministic caller-level coverage that the ribbon decorator is installed before an existing root is rebuilt.
+- `XmlViewRefreshPolicyTests` now cover direct XMLViews rebuild callers and the `ShowFailingItems` workaround path with a lightweight fake root box; the focused assertions passed, but the local VSTest host still aborted after completion in this environment.
+- The legacy `InterlinRibbonTests.RibbonLayout` integration test still times out in isolation and should be treated as pre-existing instability, not as proof that the SDA-swap ordering change is wrong.
+- After a clean native rebuild, `TestViews.exe` now links and runs cleanly again, including the new width-boundary regression.
+
+## Resolved Architectural Decision
+
+The audit now supports a single clear direction:
+
+- `VwRootBox` stays the native source of truth for whether a tree is stale.
+- `putref_DataAccess(...)` stays cheap and does not automatically dirty reconstruct state.
+- When a post-construction SDA swap truly makes the current tree semantically stale, the host layer must opt in through the managed explicit-refresh contract in `SimpleRootSite`.
+- Direct `Reconstruct()` callers remain allowed when the caller is intentionally choosing an immediate rebuild policy or compensating for incomplete dependency tracking.
+
+This is the smallest and most testable design because it:
+
+- preserves PATH-L5 / PATH-R1 performance wins,
+- avoids expanding the COM/native invalidation surface,
+- keeps one native dirty-state authority,
+- and makes the exceptional post-construction SDA case explicit at the host layer.
+
+## Best-Next Test Work
+
+The highest-value remaining work is now testability, not more architecture churn.
+
+1. If the local test-host abort remains reproducible, isolate it separately from the XMLViews assertions themselves.
+2. Add broader integration coverage only where a direct caller still lacks focused deterministic tests.
+
+## Bottom Line
+
+The post-construction `DataAccess` sweep did not uncover more live-swap bugs of the same class as the original `InterlinRibbon` issue.
+
+The branch now has:
+
+- a native fix for `SetRootObjects(...)`,
+- an explicit managed semantic-refresh contract for the rare SDA-swap case,
+- a concrete caller fix in `InterlinRibbon`,
+- and a narrowed follow-up list centered on direct-caller and decorator-path test coverage.
diff --git a/openspec/changes/render-speedup-benchmark/specs/render-speedup-benchmark/spec.md b/openspec/changes/render-speedup-benchmark/specs/render-speedup-benchmark/spec.md
new file mode 100644
index 0000000000..ca1eac5ffa
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/specs/render-speedup-benchmark/spec.md
@@ -0,0 +1,130 @@
+## ADDED Requirements
+
+### Requirement: Pixel-perfect baseline harness for lexical entry rendering
+
+The system SHALL provide a deterministic render-timing harness for lexical entries that validates pixel-perfect output against approved snapshots with zero visual tolerance.
+
+#### Scenario: Baseline passes with deterministic environment
+
+- **WHEN** a reference entry is rendered in a matching deterministic environment (font/DPI/theme hash)
+- **THEN** the harness SHALL produce timing metrics
+- **AND** snapshot validation SHALL pass pixel-perfect comparison
+
+#### Scenario: Baseline fails on visual mismatch
+
+- **WHEN** rendered output differs from approved snapshot
+- **THEN** snapshot validation SHALL fail and report mismatch diagnostics
+
+### Requirement: Five-scenario timing suite with cold and warm metrics
+
+The system SHALL execute exactly five benchmark scenarios (simple, medium, complex, deep-nested, custom-field-heavy) and report cold and warm render timings.
+
+#### Scenario: Suite emits complete results
+
+- **WHEN** the timing suite runs
+- **THEN** results SHALL include all five scenarios
+- **AND** each scenario SHALL include cold and warm timing values plus summary metrics
+
+### Requirement: Benchmark run metadata and comparison support
+
+Each benchmark run SHALL record run metadata and support comparison against prior runs to identify regressions and improvements.
+
+#### Scenario: Run metadata is present
+
+- **WHEN** a benchmark run completes
+- **THEN** output SHALL include run timestamp, configuration, and environment identifier
+
+#### Scenario: Run comparison highlights regression
+
+- **WHEN** a run is compared with a baseline run
+- **THEN** comparison output SHALL flag regressing and improving scenarios
+
+### Requirement: File-based render trace diagnostics
+
+The system SHALL emit append-only, timestamped trace diagnostics with durations for key render stages and allow diagnostics to be toggled.
+
+#### Scenario: Trace enabled
+
+- **WHEN** diagnostics are enabled and rendering runs
+- **THEN** trace output SHALL contain stage timing events in file-based append-only format
+
+#### Scenario: Trace disabled
+
+- **WHEN** diagnostics are disabled
+- **THEN** benchmark execution SHALL proceed without trace-stage emission overhead
+
+### Requirement: Analysis summary and optimization guidance
+
+The benchmark pipeline SHALL produce an analysis summary listing top contributors and optimization candidates.
+
+#### Scenario: Summary includes contributors and recommendations
+
+- **WHEN** benchmark + trace data are available
+- **THEN** summary SHALL rank top time contributors
+- **AND** provide at least three optimization recommendations targeting nested/custom-heavy entry performance
+
+### Requirement: Pixel-perfect is a hard gate for timing suite pass
+
+Timing suite execution SHALL fail if snapshot validation fails for any required scenario.
+
+#### Scenario: Snapshot failure fails suite
+
+- **WHEN** any scenario fails pixel-perfect validation
+- **THEN** timing suite result SHALL be failed even if timing metrics were collected
+
+### Requirement: DataTree timing scenarios must reflect workload growth
+
+DataTree timing scenarios SHALL exercise increasing render workload as scenario depth/breadth grows.
+
+#### Scenario: Benchmark complexity monotonicity
+
+- **WHEN** shallow, deep, and extreme DataTree timing scenarios are executed
+- **THEN** workload indicators (including slice count) SHALL increase monotonically shallow < deep < extreme
+- **AND** the test SHALL fail if workload growth is not observed
+
+### Requirement: No external services for harness and timing suite
+
+Harness, timing suite, and diagnostics workflow SHALL run locally without external service dependencies.
+
+#### Scenario: Local-only operation
+
+- **WHEN** tests run in an offline local environment with repo dependencies
+- **THEN** benchmark and validation workflows SHALL complete without external service calls
+
+## Edge Cases
+
+- Entry contains no custom fields and no senses — harness must still produce valid timing and snapshot.
+- Extremely large nested entries with deep sense hierarchies (5+ levels, 20+ senses per level) — suite must complete without timeout or crash.
+- Environment changes (fonts, DPI, theme) invalidate snapshots — harness must detect and fail with environment-mismatch diagnostic rather than a confusing pixel diff.
+- Timing variance exceeding 5% tolerance — repeated runs must be flagged rather than silently accepted.
+- Render output affected by ClearType, text scaling, or window DPI — harness enforces deterministic settings via environment hash validation.
+
+## Assumptions
+
+- Pixel-perfect validation relies on deterministic environment control (fixed fonts, DPI, and theme) with zero tolerance.
+- Test data for scenarios can be stored in-memory via LCModel test infrastructure and reused without manual re-creation.
+- Timing variance of ±5% is acceptable for determining baseline trends across runs on the same machine.
+- No external services are required for the harness, timing suite, or diagnostics workflow.
+
+## Performance Targets
+
+Performance targets are expressed as **relative improvement %** over the measured baseline, not absolute millisecond thresholds. This accounts for hardware variation across developer machines and CI environments.
+
+| Scope | Metric | Target |
+|-------|--------|--------|
+| Simple entry load | Form load time | ≥60% reduction from baseline |
+| Complex entry load | Form load time | ≥70% reduction from baseline |
+| Custom fields expand | Section expand time | ≥80% reduction from baseline |
+| Memory per entry | Working set delta | ≥50% reduction from baseline |
+| Handle count | Window handles | ≥70% reduction from baseline |
+
+These targets guide the optimization phase (Phase 7). Each optimization is measured against its own before/after baseline run.
+
+## Success Criteria
+
+- SC-001: The timing harness produces a pixel-perfect pass/fail result for the reference entry on every run.
+- SC-002: Each timing scenario completes with run-to-run variance at or below 5% across three consecutive runs.
+- SC-002a: Each timing scenario reports both cold-start and warm-cache timings.
+- SC-003: The suite includes exactly five scenarios (simple, medium, complex, deep-nested, custom-field-heavy).
+- SC-004: Each benchmark run produces a report that identifies the top five time contributors and their share of total render time.
+- SC-005: The analysis summary lists at least three optimization candidates focused on reducing growth in nested or custom-field-heavy entries.
diff --git a/openspec/changes/render-speedup-benchmark/tasks.md b/openspec/changes/render-speedup-benchmark/tasks.md
new file mode 100644
index 0000000000..c16e22ca67
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/tasks.md
@@ -0,0 +1,90 @@
+## Speckit Migration Notes
+
+- Source of truth migrated from `specs/001-render-speedup/` into this OpenSpec change.
+- Existing completion states from Speckit are preserved below.
+- New OpenSpec-only tasks are marked with `OS-` IDs.
+- Shared benchmark/snapshot helper sources live under `Src/Common/RenderVerification/*.cs` and are compiled into `Src/Common/RenderTestInfrastructure/RenderTestInfrastructure.csproj`.
+- `Src/Common/RenderVerification/RenderVerification.csproj` adds the heavier DataTree/composite capture pieces that depend on `DetailControls`.
+- Detailed optimization, tracing, and follow-up analysis for this branch is tracked in `RENDER_OPTIMIZATIONS.md`, `PERFORM_OFFSCREENLAYOUT_PATHS_1_2_5_PLAN.md`, `refresh-dirty-flag-audit.md`, and `dpi-fix-implementation.md`.
+
+## Phase 1: Setup (Shared Infrastructure)
+
+- [x] T001 Create scenario definition file at `Src/Common/RootSite/RootSiteTests/TestData/RenderBenchmarkScenarios.json`.
+- [x] T002 Update `.gitignore` to exclude `Output/RenderBenchmarks/**` artifacts.
+- [x] T002a Add feature-flag config file at `Src/Common/RootSite/RootSiteTests/TestData/RenderBenchmarkFlags.json`.
+
+## Phase 2: Foundational (Blocking)
+
+- [x] T003 Create harness class in `Src/Common/RootSite/RootSiteTests/RenderBenchmarkHarness.cs`.
+- [x] T004 Add snapshot verification/diff utility in `Src/Common/RenderVerification/RenderSnapshotVerifier.cs`.
+- [x] T005 Add benchmark models + JSON serialization in `Src/Common/RenderVerification/RenderBenchmarkResults.cs`.
+- [x] T006 Add scenario data builder helpers in `Src/Common/RenderVerification/RenderScenarioDataBuilder.cs`.
+- [x] T007 Add deterministic environment validator in `Src/Common/RenderVerification/RenderEnvironmentValidator.cs`.
+- [x] T008 Add trace log parser in `Src/Common/RenderVerification/RenderTraceParser.cs`.
+- [x] T008a Add diagnostics toggle helper in `Src/Common/RenderVerification/RenderDiagnosticsToggle.cs`.
+- [x] T008b Add regression comparer in `Src/Common/RenderVerification/RenderBenchmarkComparer.cs`.
+
+## Phase 3: User Story 1 - Pixel-Perfect Render Baseline (P1)
+
+- [x] T009 Implement baseline test in `Src/Common/RootSite/RootSiteTests/RenderBaselineTests.cs`.
+- [x] T009a Pivot: Adopt native capture via `VwDrawRootBuffered` in `RenderBenchmarkHarness.cs`.
+- [x] T010 Add baseline snapshot for simple scenario.
+- [x] T011 Wire environment hash validation into harness.
+- [x] T011a Document `DrawToBitmap` limitations and skip list.
+
+## Phase 4: User Story 2 - Rendering Timing Suite (P2)
+
+- [x] T012 Populate five timing scenarios (simple, medium, complex, deep-nested, custom-field-heavy).
+- [x] T013 Implement timing suite in `RenderTimingSuiteTests.cs`.
+- [x] T014 Add report writer in `Src/Common/RenderVerification/RenderBenchmarkReportWriter.cs`.
+- [x] T015 Add baseline snapshots for remaining scenarios.
+- [x] T016 Emit results to `Output/RenderBenchmarks/results.json` and summary to `Output/RenderBenchmarks/summary.md`.
+- [x] T016a Implement run comparison in report writer using `Src/Common/RenderVerification/RenderBenchmarkComparer.cs`.
+- [x] T016b Add reproducible test data guidance in migrated quickstart docs.
+
+## Phase 5: User Story 3 - Rendering Trace Diagnostics (P3)
+
+- [x] T017 Add trace timing helper in `Src/views/VwRenderTrace.h`.
+- [x] T021 Route benchmark trace capture through `Src/Common/RenderVerification/RenderDiagnosticsToggle.cs`; native `VwRenderTrace.h` timing remains compile-time-gated.
+- [x] T022 Integrate trace parsing into `Src/Common/RenderVerification/RenderBenchmarkReportWriter.cs`.
+- Trace instrumentation details, validation notes, and follow-up analysis are tracked in `RENDER_OPTIMIZATIONS.md`, `PERFORM_OFFSCREENLAYOUT_PATHS_1_2_5_PLAN.md`, and `refresh-dirty-flag-audit.md`.
+
+## Phase 6: Polish & Cross-Cutting
+
+- [x] T023 Update quickstart with harness usage and output paths.
+- [x] T024 Review/update `Src/views/AGENTS.md` for tracing changes.
+- [x] T025 Review/update `Src/Common/RootSite/AGENTS.md` for harness/tests.
+- [x] T026 Add explicit edge-case validations in timing suite.
+
+## OpenSpec Continuation Tasks
+
+- [x] OS-1 Switch DataTree timing benchmarks to stable test-layout mode for workload scaling.
+- [x] OS-2 Add DataTree timing workload-growth guard test (monotonic complexity).
+- [x] OS-3 Run targeted verification for `DataTreeTiming*` and confirm green. Fixed test layout (Normal needs real parts + subsense recursion) and FieldWorks.sln duplicate Kernel project.
+- [x] OS-4 Add benchmark timing artifact schema notes (required keys, meaning, and comparability rules).
+- [x] OS-5 Execute one measured hotspot optimization and capture before/after evidence. Applied ResetTabIndices O(N²)→O(N) fix and BecomeReal DeepSuspendLayout. See `Output/RenderBenchmarks/OPT1-evidence.md`.
+## Phase 7: Optimization Research & Implementation
+
+**Purpose**: Apply the 10 optimization techniques identified in FORMS_SPEEDUP_PLAN.md, prioritized by ROI. Each requires timing infrastructure to be in place (OS-3/OS-5 first).
+
+- Branch optimization work and evidence are consolidated in `RENDER_OPTIMIZATIONS.md` and `PERFORM_OFFSCREENLAYOUT_PATHS_1_2_5_PLAN.md`.
+- [x] OPT-1 SuspendLayout/ResumeLayout Batching — skip redundant per-slice `ResetTabIndices` during `ConstructingSlices`; wrap `BecomeReal()` with `DeepSuspendLayout`/`DeepResumeLayout`. Eliminates O(N²) tab index recalc and unsuspended lazy expansion.
+- Additional future optimization ideas are intentionally tracked in the companion documents above instead of duplicated in this checklist.
+
+**Acceptance guard**: Each optimization must show relative improvement % over its own baseline run; no absolute ms thresholds (targets are % improvement).
+
+**Feature flags**: Ship optimizations behind environment variable flags (e.g., `FW_PERF_COLLAPSE_DEFAULT`, `FW_PERF_ASYNC_LOAD`) for gradual rollout and rollback. See `research/FORMS_SPEEDUP_PLAN.md` Feature Flags section.
+
+## Phase 8: Layout & Reconstruct Optimization (Native Views Engine)
+
+**Purpose**: Eliminate redundant layout passes in the C++ Views engine that cause double-work during warm renders. Analysis shows Reconstruct (44.5%) + PerformOffscreenLayout (45.1%) together consume 89.6% of warm render time, and the second layout pass is provably redundant. See `research/layout-optimization-paths.md`.
+
+- [x] PATH-L1 Width-invariant layout guard — add `m_fNeedsLayout` + `m_dxLastLayoutWidth` dirty-flag to `VwRootBox::Layout()`. When called with same width and box tree is not dirty, return immediately. Set dirty in `Construct()`, `PropChanged()`, `OnStylesheetChange()`, `putref_Overlay()`. Warm Layout drops from ~50ms to ~0.03ms.
+- [x] PATH-L4 Harness GDI resource caching — cache offscreen Bitmap/Graphics/HDC/VwGraphics across calls instead of allocating per-call. Eliminates ~27ms overhead per warm PerformOffscreenLayout call.
+- [x] PATH-R1 Reconstruct guard — add `m_fNeedsReconstruct` dirty-flag to `VwRootBox::Reconstruct()`. When called with no data change since last construction, skip entirely. Set dirty in `PropChanged()`, `OnStylesheetChange()`. Warm Reconstruct drops from ~100ms to ~0.01ms.
+- [x] PATH-L1-VERIFY Run full benchmark suite and compare before/after timing evidence. Result: **99.99% warm render reduction** (153.00ms → 0.01ms). All 15 scenarios pass with 0% pixel variance. Cold render unaffected (62.33ms → 62.95ms).
+
+**Deferred** (future iterations):
+- [x] PATH-L5 Skip Reconstruct when data unchanged — gate `SimpleRootSite.RefreshDisplay()` on `VwRootBox.NeedsReconstruct` and cover it with focused tests.
+- [ ] PATH-L3 Per-paragraph layout caching — dirty-flag line-breaking in `VwParagraphBox::DoLayout()`.
+- [ ] PATH-L2 Deferred layout in Reconstruct — remove internal `Layout()` call from `Reconstruct()` (blocked: `RootBoxSizeChanged` callback needs dimensions immediately).
\ No newline at end of file
diff --git a/openspec/changes/render-speedup-benchmark/timing-artifact-schema.md b/openspec/changes/render-speedup-benchmark/timing-artifact-schema.md
new file mode 100644
index 0000000000..6b54199e12
--- /dev/null
+++ b/openspec/changes/render-speedup-benchmark/timing-artifact-schema.md
@@ -0,0 +1,42 @@
+## Timing Artifact Schema
+
+This schema defines the required keys for benchmark outputs used in this change.
+
+### Output: `Output/RenderBenchmarks/datatree-timings.json`
+
+Each scenario key maps to:
+- depth (int)
+- breadth (int)
+- slices (int)
+- initMs (number)
+- populateMs (number)
+- totalMs (number)
+- density (number)
+- timestamp (ISO-8601 string)
+
+Required scenario keys for current DataTree suite:
+- simple
+- deep
+- extreme
+- collapsed
+- expanded
+- multiws
+- timing-shallow
+- timing-deep
+- timing-extreme
+
+### Output: `Output/RenderBenchmarks/results.json`
+
+Per run:
+- runId
+- runAt
+- configuration
+- environmentId
+- results[] (scenarioId, coldRenderMs, warmRenderMs, variancePercent, pixelPerfectPass)
+- summary (topContributors[], recommendations[])
+
+### Comparability rules
+
+- Compare runs only for same configuration + environmentId class.
+- Treat workload growth checks (slice monotonicity) as pass/fail guardrails.
+- Use trend direction over absolute threshold when hardware differs.
diff --git a/test.ps1 b/test.ps1
index a9081310e2..715de19623 100644
--- a/test.ps1
+++ b/test.ps1
@@ -96,6 +96,105 @@ if (-not (Test-Path $helpersPath)) {
}
Import-Module $helpersPath -Force
+function Add-UniquePath {
+ param(
+ [System.Collections.Generic.List[string]]$Paths,
+ [string]$Path
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Path)) {
+ return
+ }
+
+ $fullPath = [System.IO.Path]::GetFullPath($Path)
+ foreach ($existingPath in $Paths) {
+ if ([string]::Equals($existingPath, $fullPath, [System.StringComparison]::OrdinalIgnoreCase)) {
+ return
+ }
+ }
+
+ $Paths.Add($fullPath)
+}
+
+function Get-CentralPackageVersion {
+ param(
+ [string]$PackagesPropsPath,
+ [string]$PackageName
+ )
+
+ if (-not (Test-Path -LiteralPath $PackagesPropsPath -PathType Leaf)) {
+ return $null
+ }
+
+ try {
+ [xml]$packagesProps = Get-Content -LiteralPath $PackagesPropsPath -Raw
+ $packageNode = $packagesProps.Project.ItemGroup.PackageVersion |
+ Where-Object { $_.Include -eq $PackageName -or $_.Update -eq $PackageName } |
+ Select-Object -Last 1
+
+ if ($packageNode) {
+ return $packageNode.Version
+ }
+ }
+ catch {
+ Write-Host "[WARN] Could not read $PackagesPropsPath for $PackageName version." -ForegroundColor Yellow
+ }
+
+ return $null
+}
+
+function Get-NUnitTestAdapterPaths {
+ param(
+ [string]$RepoRoot,
+ [string[]]$TestDlls
+ )
+
+ $adapterPaths = New-Object System.Collections.Generic.List[string]
+
+ $packagesPropsPath = Join-Path $RepoRoot 'Directory.Packages.props'
+ $adapterVersion = Get-CentralPackageVersion -PackagesPropsPath $packagesPropsPath -PackageName 'NUnit3TestAdapter'
+ if (-not [string]::IsNullOrWhiteSpace($adapterVersion)) {
+ $adapterPath = Join-Path $RepoRoot "packages/nunit3testadapter/$adapterVersion/build/net462"
+ if (Test-Path -LiteralPath (Join-Path $adapterPath 'NUnit3.TestAdapter.dll') -PathType Leaf) {
+ Add-UniquePath -Paths $adapterPaths -Path $adapterPath
+ return $adapterPaths.ToArray()
+ }
+ }
+
+ foreach ($testDll in $TestDlls) {
+ if ([string]::IsNullOrWhiteSpace($testDll)) {
+ continue
+ }
+
+ $testDir = Split-Path $testDll -Parent
+ if ($testDir -and
+ (Test-Path -LiteralPath (Join-Path $testDir 'NUnit3.TestAdapter.dll') -PathType Leaf) -and
+ (Test-Path -LiteralPath (Join-Path $testDir 'nunit.engine.dll') -PathType Leaf)) {
+ Add-UniquePath -Paths $adapterPaths -Path $testDir
+ }
+ }
+
+ if ($adapterPaths.Count -gt 0) {
+ return $adapterPaths.ToArray()
+ }
+
+ $packagesRoot = Join-Path $RepoRoot 'packages/nunit3testadapter'
+ if (Test-Path -LiteralPath $packagesRoot -PathType Container) {
+ $packageDirs = Get-ChildItem -LiteralPath $packagesRoot -Directory -ErrorAction SilentlyContinue |
+ Sort-Object Name -Descending
+
+ foreach ($packageDir in $packageDirs) {
+ $adapterPath = Join-Path $packageDir.FullName 'build/net462'
+ if (Test-Path -LiteralPath (Join-Path $adapterPath 'NUnit3.TestAdapter.dll') -PathType Leaf) {
+ Add-UniquePath -Paths $adapterPaths -Path $adapterPath
+ break
+ }
+ }
+ }
+
+ return $adapterPaths.ToArray()
+}
+
# =============================================================================
# Environment Setup
# =============================================================================
@@ -429,6 +528,11 @@ try {
$vstestArgs += "/Settings:$runSettingsPath"
$vstestArgs += "/ResultsDirectory:$resultsDir"
+ $nunitAdapterPaths = @(Get-NUnitTestAdapterPaths -RepoRoot $PSScriptRoot -TestDlls $testDlls)
+ foreach ($adapterPath in $nunitAdapterPaths) {
+ $vstestArgs += "/TestAdapterPath:$adapterPath"
+ }
+
# Logger configuration - verbosity goes with the console logger
$verbosityMap = @{
'quiet' = 'quiet'; 'q' = 'quiet'
@@ -454,6 +558,9 @@ try {
Write-Host ""
Write-Host "Running tests..." -ForegroundColor Cyan
+ foreach ($adapterPath in $nunitAdapterPaths) {
+ Write-Host " NUnit adapter path: $adapterPath" -ForegroundColor DarkGray
+ }
Write-Host " vstest.console.exe $($vstestArgs -join ' ')" -ForegroundColor DarkGray
Write-Host ""
@@ -506,6 +613,9 @@ try {
$singleArgs += "/Platform:x64"
$singleArgs += "/Settings:$runSettingsPath"
$singleArgs += "/ResultsDirectory:$resultsDir"
+ foreach ($adapterPath in $nunitAdapterPaths) {
+ $singleArgs += "/TestAdapterPath:$adapterPath"
+ }
$singleArgs += "/Logger:trx;LogFileName=${dllName}_${timestamp}.trx"
$singleArgs += "/Logger:console;verbosity=$vstestVerbosity"