Skip to content

Commit 18925d5

Browse files
Merge pull request #541 from SixLabors/js/interpreter-pool-reset
Fix TrueTypeInterpreter pool state pollution
2 parents 0d7e223 + ae7e3aa commit 18925d5

3 files changed

Lines changed: 102 additions & 6 deletions

File tree

src/SixLabors.Fonts/Tables/TrueType/Hinting/TrueTypeInterpreter.cs

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,15 @@ public void InitializeFunctionDefs(byte[] instructions)
162162
/// <param name="cvProgram">The raw prep program bytecode, or <see langword="null"/> if absent.</param>
163163
public void SetControlValueTable(short[]? cvt, float scale, float ppem, byte[]? cvProgram)
164164
{
165-
if (this.scale == scale || cvt == null)
165+
if (this.scale == scale)
166166
{
167167
return;
168168
}
169-
else
169+
170+
// A missing CVT table must not skip the prep program: fonts may carry a prep
171+
// program without control values, and prep still establishes the graphics state,
172+
// storage, and twilight points that glyph programs build on.
173+
if (cvt != null)
170174
{
171175
if (this.controlValueTable.Length == 0 && cvt.Length > 0)
172176
{
@@ -175,16 +179,52 @@ public void SetControlValueTable(short[]? cvt, float scale, float ppem, byte[]?
175179

176180
for (int i = 0; i < cvt.Length; i++)
177181
{
178-
this.controlValueTable[i] = cvt[i] * scale;
182+
// Match FreeType's tt_size_run_prep CVT scaling, which produces 26.6
183+
// fixed-point pixel values (FT_MulFix rounds to the nearest 1/64).
184+
// Scaling in unquantized float lets control values land on the other
185+
// side of a rounding boundary, flipping prep's round-to-grid decisions
186+
// for some sizes (Arial's x-height at 13 ppem rounds to 8px instead of
187+
// FreeType's 7px). FreeType notes the operation is "very sensitive to
188+
// rounding".
189+
this.controlValueTable[i] = MathF.Round(cvt[i] * scale * 64F) / 64F;
179190
}
180191
}
192+
else
193+
{
194+
this.controlValueTable = [];
195+
}
181196

182197
this.scale = scale;
183198
this.ppem = (int)Math.Round(ppem);
184-
this.zp0 = this.zp1 = this.zp2 = this.points;
185199
this.state.Reset();
186200
this.stack.Clear();
187201

202+
// Restore the interpreter to the same state a freshly created interpreter would be in
203+
// immediately before running the prep program. A pooled interpreter may have been used
204+
// to hint glyphs at a previous size, leaving behind storage writes, twilight points,
205+
// rounding state and zone pointers. The prep program reads and builds on this state, so
206+
// without restoring it the prep result — and therefore the hinted outline — depends on
207+
// the interpreter's history. That made hinting non-deterministic when a font family was
208+
// rendered concurrently from a shared interpreter pool (see issue #484).
209+
// FreeType does the same in tt_size_run_prep (ttobjs.c): it zeroes the twilight zone
210+
// and the storage area before every prep execution, deliberately discarding any
211+
// storage writes made by the font program (fpgm).
212+
this.ResetTwilightZone();
213+
Array.Clear(this.storage, 0, this.storage.Length);
214+
this.prepStorage = null;
215+
this.inGlyphProgram = false;
216+
this.callStackSize = 0;
217+
this.fdotp = 0;
218+
this.roundThreshold = 0;
219+
this.roundPhase = 0;
220+
this.roundPeriod = 0;
221+
this.iupXCalled = false;
222+
this.iupYCalled = false;
223+
this.isComposite = false;
224+
this.contours = [];
225+
this.points = default;
226+
this.zp0 = this.zp1 = this.zp2 = this.points;
227+
188228
if (cvProgram != null)
189229
{
190230
// Initialize safety counters for the prep program (no glyph points yet).
Lines changed: 2 additions & 2 deletions
Loading

tests/SixLabors.Fonts.Tests/HintingTests.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// Copyright (c) Six Labors.
22
// Licensed under the Six Labors Split License.
33

4+
using System.Numerics;
45
using System.Text;
6+
using SixLabors.Fonts.Rendering;
57
using SixLabors.Fonts.Unicode;
68

79
namespace SixLabors.Fonts.Tests;
@@ -70,4 +72,58 @@ public void Test_Hinting_Robustness(string path, string name)
7072
options,
7173
properties: name);
7274
}
75+
76+
// The TrueType bytecode interpreter is pooled and reused across renders for the same
77+
// font. When a pooled interpreter is reused for a different pixel size it re-runs the
78+
// font's prep (CVT) program, which must execute from the same pristine state as a freshly
79+
// created interpreter. If transient interpreter state (twilight zone, storage, rounding
80+
// state, zone pointers, ...) is not reset first, the prep result — and therefore the
81+
// hinted glyph outline — depends on which sizes were rendered previously on that
82+
// interpreter. Because interpreters are shared through a pool, that made hinting output
83+
// non-deterministic when a single font family was rendered concurrently from multiple
84+
// threads
85+
[Fact]
86+
public void Hinting_OutputIsIndependentOfPreviouslyRenderedSizes()
87+
{
88+
const string text = "The quick brown fox 12345";
89+
const float dpi = 150F;
90+
const float targetSize = 7F;
91+
const float otherSize = 12F;
92+
93+
static List<Vector2> RenderControlPoints(string text, float size, float dpi, float? warmUpSize)
94+
{
95+
FontCollection collection = new();
96+
FontFamily family = collection.Add(TestFonts.Arial);
97+
98+
if (warmUpSize is { } w)
99+
{
100+
RenderTo(family, text, w, dpi, new GlyphRenderer());
101+
}
102+
103+
GlyphRenderer renderer = new();
104+
RenderTo(family, text, size, dpi, renderer);
105+
return renderer.ControlPoints;
106+
}
107+
108+
static void RenderTo(FontFamily family, string text, float size, float dpi, GlyphRenderer renderer)
109+
{
110+
Font font = family.CreateFont(size);
111+
TextOptions options = new(font)
112+
{
113+
Dpi = dpi,
114+
HintingMode = HintingMode.Standard,
115+
};
116+
117+
TextRenderer.RenderTextTo(renderer, text, options);
118+
}
119+
120+
// Render the target size on a font whose interpreter has processed nothing else.
121+
List<Vector2> reference = RenderControlPoints(text, targetSize, dpi, warmUpSize: null);
122+
123+
// Render the same target size, but on a font whose pooled interpreter has already
124+
// processed a different size. With a correct per-size reset this is byte-for-byte equal.
125+
List<Vector2> afterOtherSize = RenderControlPoints(text, targetSize, dpi, warmUpSize: otherSize);
126+
127+
Assert.Equal(reference, afterOtherSize);
128+
}
73129
}

0 commit comments

Comments
 (0)