@@ -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).
0 commit comments