diff --git a/src/SixLabors.Fonts/Tables/TrueType/Hinting/TrueTypeInterpreter.cs b/src/SixLabors.Fonts/Tables/TrueType/Hinting/TrueTypeInterpreter.cs index c80ff330..587356b8 100644 --- a/src/SixLabors.Fonts/Tables/TrueType/Hinting/TrueTypeInterpreter.cs +++ b/src/SixLabors.Fonts/Tables/TrueType/Hinting/TrueTypeInterpreter.cs @@ -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 contours; @@ -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 = []; } @@ -148,7 +156,13 @@ public void SetNormalizedAxisCoordinates(float[]? coordinates) /// /// The raw font program bytecode. 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(); + } /// /// Scales the Control Value Table and executes the prep (CVT) program. @@ -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). diff --git a/tests/SixLabors.Fonts.Tests/HintingTests.cs b/tests/SixLabors.Fonts.Tests/HintingTests.cs index a9af18ea..f4d4570c 100644 --- a/tests/SixLabors.Fonts.Tests/HintingTests.cs +++ b/tests/SixLabors.Fonts.Tests/HintingTests.cs @@ -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; @@ -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 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 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 afterOtherSize = RenderControlPoints(text, targetSize, dpi, warmUpSize: otherSize); + + Assert.Equal(reference, afterOtherSize); + } }