Skip to content

Commit 2a6df82

Browse files
Merge pull request #547 from SixLabors/js/fix-537
Guard recursive glyph and subroutine calls
2 parents 2d4ebab + f385640 commit 2a6df82

7 files changed

Lines changed: 88 additions & 9 deletions

File tree

src/SixLabors.Fonts/Tables/Cff/CffEvaluationEngine.cs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ namespace SixLabors.Fonts.Tables.Cff;
1919
/// </remarks>
2020
internal ref struct CffEvaluationEngine
2121
{
22+
// Appendix B of both Type 2 and CFF2 CharString specifications limits nested local and global
23+
// subroutine calls to 10, which also provides a fixed stack bound for malformed cyclic programs.
24+
private const int MaxSubroutineNesting = 10;
25+
2226
private static readonly Random Random = new();
2327
private float? width;
2428
private int nStems;
@@ -108,7 +112,7 @@ public Bounds GetBounds()
108112
this.transforming = new(finder, Vector2.Zero, new Vector2(1, -1), Vector2.Zero, Matrix3x2.Identity);
109113

110114
// Boolean IGlyphRenderer.BeginGlyph(..) is handled by the caller.
111-
this.Parse(this.charStrings);
115+
this.Parse(this.charStrings, 0);
112116

113117
// Some CFF end without closing the latest contour.
114118
if (this.transforming.IsOpen)
@@ -134,7 +138,7 @@ public void RenderTo(IGlyphRenderer renderer, Vector2 origin, Vector2 scale, Vec
134138
this.transforming = new(renderer, origin, scale, offset, transform);
135139

136140
// Boolean IGlyphRenderer.BeginGlyph(..) is handled by the caller.
137-
this.Parse(this.charStrings);
141+
this.Parse(this.charStrings, 0);
138142

139143
// Some CFF end without closing the latest contour.
140144
if (this.transforming.IsOpen)
@@ -147,7 +151,8 @@ public void RenderTo(IGlyphRenderer renderer, Vector2 origin, Vector2 scale, Vec
147151
/// Parses and interprets a Type 2 charstring byte buffer, executing operators and accumulating operands.
148152
/// </summary>
149153
/// <param name="buffer">The charstring byte data to parse.</param>
150-
private void Parse(ReadOnlySpan<byte> buffer)
154+
/// <param name="subroutineDepth">The number of active local and global subroutine calls.</param>
155+
private void Parse(ReadOnlySpan<byte> buffer, int subroutineDepth)
151156
{
152157
SimpleBinaryReader reader = new(buffer);
153158
bool endCharEncountered = false;
@@ -239,9 +244,11 @@ private void Parse(ReadOnlySpan<byte> buffer)
239244
index = (int)this.stack.Pop() + this.localBias;
240245
subr = this.localSubrBuffers[index];
241246

242-
if (subr.Length > 0)
247+
// The over-limit call contributes no outline, matching how cyclic TrueType components
248+
// degrade to empty while allowing the enclosing charstring to continue normally.
249+
if (subr.Length > 0 && subroutineDepth < MaxSubroutineNesting)
243250
{
244-
this.Parse(subr);
251+
this.Parse(subr, subroutineDepth + 1);
245252
}
246253

247254
break;
@@ -452,9 +459,11 @@ private void Parse(ReadOnlySpan<byte> buffer)
452459
index = (int)this.stack.Pop() + this.globalBias;
453460
subr = this.globalSubrBuffers[index];
454461

455-
if (subr.Length > 0)
462+
// Local and global subroutines share the same nesting stack and therefore the same
463+
// format limit and empty-outline fallback behavior.
464+
if (subr.Length > 0 && subroutineDepth < MaxSubroutineNesting)
456465
{
457-
this.Parse(subr);
466+
this.Parse(subr, subroutineDepth + 1);
458467
}
459468

460469
break;

src/SixLabors.Fonts/Tables/TrueType/Glyphs/CompositeGlyphLoader.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ namespace SixLabors.Fonts.Tables.TrueType.Glyphs;
1212
/// </summary>
1313
internal sealed class CompositeGlyphLoader : GlyphLoader
1414
{
15+
// The TrueType reference specification defines 16 as the maximum legal maxComponentDepth. Enforcing the
16+
// format limit bounds malformed cyclic graphs without allocating path-tracking state on composite loads.
17+
private const int MaxCompositeDepth = 16;
18+
1519
private readonly Bounds bounds;
1620
private readonly Composite[] composites;
1721
private readonly ReadOnlyMemory<byte> instructions;
@@ -31,14 +35,29 @@ public CompositeGlyphLoader(IEnumerable<Composite> composites, Bounds bounds, Re
3135

3236
/// <inheritdoc/>
3337
public override GlyphVector CreateGlyph(GlyphTable table)
38+
=> this.CreateGlyph(table, 0);
39+
40+
/// <summary>
41+
/// Creates a glyph vector while enforcing the TrueType composite nesting limit.
42+
/// </summary>
43+
/// <param name="table">The glyph table used to resolve component glyphs.</param>
44+
/// <param name="compositeDepth">The number of composite glyphs above this glyph.</param>
45+
/// <returns>The resolved glyph vector, or an empty vector when the component graph exceeds the format limit.</returns>
46+
public GlyphVector CreateGlyph(GlyphTable table, int compositeDepth)
3447
{
48+
if (compositeDepth >= MaxCompositeDepth)
49+
{
50+
return GlyphVector.Empty(this.bounds);
51+
}
52+
3553
List<ControlPoint> controlPoints = [];
3654
List<ushort> endPoints = [];
3755
CompositeComponent[] components = new CompositeComponent[this.composites.Length];
56+
3857
for (int i = 0; i < this.composites.Length; i++)
3958
{
4059
Composite composite = this.composites[i];
41-
GlyphVector clone = GlyphVector.DeepClone(table.GetGlyph(composite.GlyphIndex));
60+
GlyphVector clone = GlyphVector.DeepClone(table.GetGlyph(composite.GlyphIndex, compositeDepth + 1));
4261
GlyphVector.TransformInPlace(ref clone, composite.Transformation);
4362
ushort endPointOffset = (ushort)controlPoints.Count;
4463

src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphTable.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,27 @@ public GlyphTable(GlyphLoader[] glyphLoaders)
4242
/// <returns>The <see cref="GlyphVector"/>, or an empty vector if the index is out of range.</returns>
4343
// TODO: Make this non-virtual
4444
internal virtual GlyphVector GetGlyph(int index)
45+
=> this.GetGlyph(index, 0);
46+
47+
/// <summary>
48+
/// Gets the <see cref="GlyphVector"/> for the glyph at the specified index while tracking composite nesting.
49+
/// </summary>
50+
/// <param name="index">The zero-based glyph index.</param>
51+
/// <param name="compositeDepth">The number of composite glyphs above the requested glyph.</param>
52+
/// <returns>The <see cref="GlyphVector"/>, or an empty vector if the index is out of range.</returns>
53+
internal GlyphVector GetGlyph(int index, int compositeDepth)
4554
{
4655
if (index < 0 || index >= this.loaders.Length)
4756
{
4857
return GlyphVector.Empty();
4958
}
5059

51-
return this.glyphCache.GetOrAdd(index, i => this.loaders[i].CreateGlyph(this));
60+
return this.glyphCache.GetOrAdd(
61+
index,
62+
static (i, state) => state.Table.loaders[i] is CompositeGlyphLoader composite
63+
? composite.CreateGlyph(state.Table, state.CompositeDepth)
64+
: state.Table.loaders[i].CreateGlyph(state.Table),
65+
(Table: this, CompositeDepth: compositeDepth));
5266
}
5367

5468
/// <summary>

tests/Fonts/Issues/Issue537.ttf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:b9e733bd5644379e8bec5bf7dd90a001e6b8487b2d1f27a1ceb6564c578cb6cb
3+
size 10832

tests/Fonts/Issues/Issue537Cff.otf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:63eaf9c9184ca1cedc97291f288ca633d55c3aba61d1c4913724b9274b47f6f1
3+
size 1016
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) Six Labors.
2+
// Licensed under the Six Labors Split License.
3+
4+
namespace SixLabors.Fonts.Tests.Issues;
5+
6+
public class Issues_537
7+
{
8+
[Fact]
9+
public void ShouldMeasureFontWithSelfReferentialCompositeGlyph()
10+
{
11+
Font font = TestFonts.GetFont(TestFonts.Issues.Issue537, 16);
12+
13+
FontRectangle bounds = TextMeasurer.MeasureRenderableBounds("ABCabc123!@#", new TextOptions(font));
14+
15+
Assert.NotEqual(FontRectangle.Empty, bounds);
16+
}
17+
18+
[Fact]
19+
public void ShouldMeasureCffFontWithSelfReferentialSubroutine()
20+
{
21+
Font font = TestFonts.GetFont(TestFonts.Issues.Issue537Cff, 16);
22+
23+
FontRectangle bounds = TextMeasurer.MeasureRenderableBounds("A", new TextOptions(font));
24+
25+
Assert.NotEqual(FontRectangle.Empty, bounds);
26+
}
27+
}

tests/SixLabors.Fonts.Tests/TestFonts.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,10 @@ public static class Issues
416416
public static string Issue514 => GetFullPath("Issues/Issue514.ttf");
417417

418418
public static string Issue534 => GetFullPath("Issues/Issue534.ttf");
419+
420+
public static string Issue537 => GetFullPath("Issues/Issue537.ttf");
421+
422+
public static string Issue537Cff => GetFullPath("Issues/Issue537Cff.otf");
419423
}
420424

421425
/// <summary>

0 commit comments

Comments
 (0)