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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions src/SixLabors.Fonts/Tables/TrueType/Hinting/TrueTypeInterpreter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ internal partial class TrueTypeInterpreter
// (see WS instruction) so that prep state is preserved across glyphs.
private int[] storage;
private int[]? prepStorage;

// Snapshot of the storage area immediately after the font program (fpgm) runs. A freshly
// created interpreter executes fpgm once, which may seed storage locations that the prep
// (CVT) program later reads. When a pooled interpreter is reused for a new pixel size the
// prep program is re-run, so storage must be restored to this post-fpgm baseline rather
// than being left in its previous (glyph-modified) state.
private int[] fpgmStorage;
private bool inGlyphProgram;

private IReadOnlyList<ushort> contours;
Expand Down Expand Up @@ -131,6 +138,7 @@ public TrueTypeInterpreter(int maxStack, int maxStorage, int maxFunctions, int m
this.twilight = new Zone(maxTwilightPoints, isTwilight: true);
this.controlValueTable = [];
this.baseControlValueTable = [];
this.fpgmStorage = [];
this.contours = [];
}

Expand All @@ -148,7 +156,13 @@ public void SetNormalizedAxisCoordinates(float[]? coordinates)
/// </summary>
/// <param name="instructions">The raw font program bytecode.</param>
public void InitializeFunctionDefs(byte[] instructions)
=> this.Execute(new StackInstructionStream(instructions, 0), false, true);
{
this.Execute(new StackInstructionStream(instructions, 0), false, true);

// Capture the post-fpgm storage so it can be restored whenever the prep program is
// re-run for a new size on a reused (pooled) interpreter.
this.fpgmStorage = (int[])this.storage.Clone();
}

/// <summary>
/// Scales the Control Value Table and executes the prep (CVT) program.
Expand Down Expand Up @@ -181,10 +195,40 @@ public void SetControlValueTable(short[]? cvt, float scale, float ppem, byte[]?

this.scale = scale;
this.ppem = (int)Math.Round(ppem);
this.zp0 = this.zp1 = this.zp2 = this.points;
this.state.Reset();
this.stack.Clear();

// Restore the interpreter to the same state a freshly created interpreter would be in
// immediately before running the prep program. A pooled interpreter may have been used
// to hint glyphs at a previous size, leaving behind storage writes, twilight points,
// rounding state and zone pointers. The prep program reads and builds on this state, so
// without restoring it the prep result — and therefore the hinted outline — depends on
// the interpreter's history. That made hinting non-deterministic when a font family was
// rendered concurrently from a shared interpreter pool (see issue #484).
this.ResetTwilightZone();
if (this.storage.Length == this.fpgmStorage.Length)
{
Array.Copy(this.fpgmStorage, this.storage, this.fpgmStorage.Length);
}
else
{
this.storage = (int[])this.fpgmStorage.Clone();
}

this.prepStorage = null;
this.inGlyphProgram = false;
this.callStackSize = 0;
this.fdotp = 0;
this.roundThreshold = 0;
this.roundPhase = 0;
this.roundPeriod = 0;
this.iupXCalled = false;
this.iupYCalled = false;
this.isComposite = false;
this.contours = [];
this.points = default;
this.zp0 = this.zp1 = this.zp2 = this.points;

if (cvProgram != null)
{
// Initialize safety counters for the prep program (no glyph points yet).
Expand Down
56 changes: 56 additions & 0 deletions tests/SixLabors.Fonts.Tests/HintingTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.

using System.Numerics;
using System.Text;
using SixLabors.Fonts.Rendering;
using SixLabors.Fonts.Unicode;

namespace SixLabors.Fonts.Tests;
Expand Down Expand Up @@ -70,4 +72,58 @@ public void Test_Hinting_Robustness(string path, string name)
options,
properties: name);
}

// The TrueType bytecode interpreter is pooled and reused across renders for the same
// font. When a pooled interpreter is reused for a different pixel size it re-runs the
// font's prep (CVT) program, which must execute from the same pristine state as a freshly
// created interpreter. If transient interpreter state (twilight zone, storage, rounding
// state, zone pointers, ...) is not reset first, the prep result — and therefore the
// hinted glyph outline — depends on which sizes were rendered previously on that
// interpreter. Because interpreters are shared through a pool, that made hinting output
// non-deterministic when a single font family was rendered concurrently from multiple
// threads
[Fact]
public void Hinting_OutputIsIndependentOfPreviouslyRenderedSizes()
{
const string text = "The quick brown fox 12345";
const float dpi = 150F;
const float targetSize = 7F;
const float otherSize = 12F;

static List<Vector2> RenderControlPoints(string text, float size, float dpi, float? warmUpSize)
{
FontCollection collection = new();
FontFamily family = collection.Add(TestFonts.Arial);

if (warmUpSize is { } w)
{
RenderTo(family, text, w, dpi, new GlyphRenderer());
}

GlyphRenderer renderer = new();
RenderTo(family, text, size, dpi, renderer);
return renderer.ControlPoints;
}

static void RenderTo(FontFamily family, string text, float size, float dpi, GlyphRenderer renderer)
{
Font font = family.CreateFont(size);
TextOptions options = new(font)
{
Dpi = dpi,
HintingMode = HintingMode.Standard,
};

TextRenderer.RenderTextTo(renderer, text, options);
}

// Render the target size on a font whose interpreter has processed nothing else.
List<Vector2> reference = RenderControlPoints(text, targetSize, dpi, warmUpSize: null);

// Render the same target size, but on a font whose pooled interpreter has already
// processed a different size. With a correct per-size reset this is byte-for-byte equal.
List<Vector2> afterOtherSize = RenderControlPoints(text, targetSize, dpi, warmUpSize: otherSize);

Assert.Equal(reference, afterOtherSize);
}
}
Loading