Skip to content

Prevent stack overflow from circular composite glyph references#826

Merged
fdb merged 2 commits intomasterfrom
claude/fix-glyph-circular-reference-4ADE7
Apr 3, 2026
Merged

Prevent stack overflow from circular composite glyph references#826
fdb merged 2 commits intomasterfrom
claude/fix-glyph-circular-reference-4ADE7

Conversation

@fdb
Copy link
Copy Markdown
Contributor

@fdb fdb commented Apr 3, 2026

Description

This change adds protection against stack overflow errors when loading TrueType fonts that contain circular references in composite glyphs. The fix tracks which glyphs are currently being resolved and skips any circular references encountered during path building.

Changes Made:

  1. src/tables/glyf.mjs: Added a module-scoped _resolving Set to track glyph indices currently being processed. When building composite glyph paths, the code now checks if a component glyph is already being resolved and skips it if a circular reference is detected. The tracking is properly cleaned up using try/finally to ensure the Set is maintained correctly across recursive calls.

  2. test/generate-circular-ref-font.mjs: Added a utility script that generates a minimal test font (circular-composite.ttf) containing two composite glyphs that reference each other circularly. This font is used to verify the fix works correctly.

  3. test/glyph.spec.mjs: Added a test case that loads the circular composite font and calls getPath() on both glyphs to verify they return valid Path objects without causing a stack overflow.

Motivation and Context

Fonts with circular composite glyph references (where glyph A references glyph B and glyph B references glyph A) would previously cause infinite recursion and stack overflow errors when calling getPath(). While such fonts are malformed, the library should handle them gracefully rather than crashing.

How Has This Been Tested?

  • Added a new test case circular composite glyph references that loads a specially crafted font with circular references and verifies that getPath() returns valid Path objects without throwing errors
  • The test font is generated by the new generate-circular-ref-font.mjs script
  • All existing tests continue to pass with the circular reference detection in place

Types of changes

  • Bug fix (non-breaking change which fixes an issue)

Checklist:

  • I have added tests to cover my changes
  • The fix handles the edge case gracefully without breaking existing functionality

https://claude.ai/code/session_014E56osqBcVfPwdgHHtrMBJ

Add cycle detection to buildPath() in glyf.mjs using a module-scoped
Set that tracks glyph indices currently being resolved. When a circular
reference is detected (e.g. glyph A → B → A), the component is skipped
instead of recursing infinitely. This prevents denial-of-service via
crafted TrueType fonts with circular composite glyph references.

Includes a POC font generator and regression test with a minimal TTF
containing two mutually-referencing composite glyphs.

https://claude.ai/code/session_014E56osqBcVfPwdgHHtrMBJ
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request hardens TrueType composite glyph path building against infinite recursion/stack overflows caused by circular component references, and adds a regression fixture + test to ensure getPath() remains safe on malformed fonts.

Changes:

  • Add circular-reference detection during composite glyph path resolution.
  • Add a generator script and a minimal TTF fixture containing circular composite references.
  • Add a regression test that loads the fixture and calls getPath() on the involved glyphs.

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 3 comments.

File Description
src/tables/glyf.mjs Tracks glyph indices during composite resolution to skip circular references and prevent recursion overflow.
test/glyph.spec.mjs Adds a regression test ensuring circular composites don’t crash getPath().
test/generate-circular-ref-font.mjs Adds a utility to generate a minimal circular-composite TTF fixture for testing.
test/fonts/circular-composite.ttf Commits the generated test fixture font used by the new test.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 269 to 308
if (glyph.isComposite) {
for (let j = 0; j < glyph.components.length; j += 1) {
const component = glyph.components[j];
const componentGlyph = glyphs.get(component.glyphIndex);
// Force the ttfGlyphLoader to parse the glyph.
componentGlyph.getPath();
if (componentGlyph.points) {
let transformedPoints;
if (component.matchedPoints === undefined) {
// component positioned by offset
transformedPoints = transformPoints(componentGlyph.points, component);
} else {
// component positioned by matched points
if ((component.matchedPoints[0] > glyph.points.length - 1) ||
(component.matchedPoints[1] > componentGlyph.points.length - 1)) {
throw Error('Matched points out of range in ' + glyph.name);
_resolving.add(glyph.index);
try {
for (let j = 0; j < glyph.components.length; j += 1) {
const component = glyph.components[j];
if (_resolving.has(component.glyphIndex)) {
continue; // skip circular reference
}
const componentGlyph = glyphs.get(component.glyphIndex);
// Force the ttfGlyphLoader to parse the glyph.
componentGlyph.getPath();
if (componentGlyph.points) {
let transformedPoints;
if (component.matchedPoints === undefined) {
// component positioned by offset
transformedPoints = transformPoints(componentGlyph.points, component);
} else {
// component positioned by matched points
if ((component.matchedPoints[0] > glyph.points.length - 1) ||
(component.matchedPoints[1] > componentGlyph.points.length - 1)) {
throw Error('Matched points out of range in ' + glyph.name);
}
const firstPt = glyph.points[component.matchedPoints[0]];
let secondPt = componentGlyph.points[component.matchedPoints[1]];
const transform = {
xScale: component.xScale, scale01: component.scale01,
scale10: component.scale10, yScale: component.yScale,
dx: 0, dy: 0
};
secondPt = transformPoints([secondPt], transform)[0];
transform.dx = firstPt.x - secondPt.x;
transform.dy = firstPt.y - secondPt.y;
transformedPoints = transformPoints(componentGlyph.points, transform);
}
const firstPt = glyph.points[component.matchedPoints[0]];
let secondPt = componentGlyph.points[component.matchedPoints[1]];
const transform = {
xScale: component.xScale, scale01: component.scale01,
scale10: component.scale10, yScale: component.yScale,
dx: 0, dy: 0
};
secondPt = transformPoints([secondPt], transform)[0];
transform.dx = firstPt.x - secondPt.x;
transform.dy = firstPt.y - secondPt.y;
transformedPoints = transformPoints(componentGlyph.points, transform);
glyph.points = glyph.points.concat(transformedPoints);
}
glyph.points = glyph.points.concat(transformedPoints);
}
} finally {
_resolving.delete(glyph.index);
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_resolving is module-scoped and keyed only by glyph index. If two different fonts (or separate GlyphSet instances) resolve glyphs with the same index interleaved in the same process, this can cause false circular-reference detection and silently drop components. Also, using a plain Set can be unsafe under re-entrant calls: adding an already-present index and then unconditionally deleting it in finally can clear the marker while an outer frame is still resolving. Consider scoping the tracking to the font/glyphset (e.g., store a Set/Map on glyphs/glyph.font, or use a WeakMap<GlyphSet, Map<number, count>>) and only remove the marker if this frame added it (or decrement a refcount).

Copilot uses AI. Check for mistakes.
Comment on lines +157 to +163
...i16(0), ...i16(1), // idDelta[]: 1-0x41=-0x40... let's use glyph offset instead
...u16(0), ...u16(0), // idRangeOffset[]: 0, 0
];
// Fix idDelta: to map 0x41 → glyph 1, delta = 1 - 0x41 = -0x40 = 0xFFC0
subtable[20] = 0xFF; subtable[21] = 0xC0;
// sentinel delta
subtable[22] = 0x00; subtable[23] = 0x01;
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cmap generator looks incorrect here: subtable is a byte array, and indices 20–23 correspond to startCode[], not idDelta[]. As written, this overwrites the startCode for the segment and the font will not map U+0041 ('A') to glyph 1 as described. The idDelta bytes should be patched at the correct offsets (immediately after the startCode[] array) and the inline comment about “let's use glyph offset instead” should be updated to match the chosen approach. After fixing, regenerate the committed test/fonts/circular-composite.ttf so the fixture matches the script/output description.

Suggested change
...i16(0), ...i16(1), // idDelta[]: 1-0x41=-0x40... let's use glyph offset instead
...u16(0), ...u16(0), // idRangeOffset[]: 0, 0
];
// Fix idDelta: to map 0x41 → glyph 1, delta = 1 - 0x41 = -0x40 = 0xFFC0
subtable[20] = 0xFF; subtable[21] = 0xC0;
// sentinel delta
subtable[22] = 0x00; subtable[23] = 0x01;
...i16(0), ...i16(1), // idDelta[]: patched below for 'A' and sentinel
...u16(0), ...u16(0), // idRangeOffset[]: 0, 0
];
// Fix idDelta: to map 0x41 → glyph 1, delta = 1 - 0x41 = -0x40 = 0xFFC0
subtable[24] = 0xFF; subtable[25] = 0xC0;
// Sentinel delta keeps 0xFFFF mapping to glyph 0.
subtable[26] = 0x00; subtable[27] = 0x01;

Copilot uses AI. Check for mistakes.
Comment on lines +176 to +186
describe('circular composite glyph references', function() {
it('does not crash on fonts with circular composite references', function() {
const font = loadSync('./test/fonts/circular-composite.ttf');
const glyph1 = font.glyphs.get(1);
const glyph2 = font.glyphs.get(2);
// Should return paths without stack overflow
const path1 = glyph1.getPath();
const path2 = glyph2.getPath();
assert.ok(path1 instanceof Path);
assert.ok(path2 instanceof Path);
});
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test only exercises glyphs.get(1/2) and doesn't validate the cmap mapping that generate-circular-ref-font.mjs claims (U+0041 → glyph 1). Once the test font’s cmap is corrected, it would be more robust to fetch glyph 1 via font.charToGlyph('A') (and optionally assert its index is 1) before calling getPath(), so the fixture covers both cmap parsing and the circular-composite recursion guard.

Copilot uses AI. Check for mistakes.
… offsets

- Use a WeakMap<GlyphSet, Set> instead of a module-scoped Set to avoid
  theoretical cross-font collisions on shared glyph indices
- Fix cmap byte offsets in POC generator (indices 24-27 for idDelta,
  not 20-23 which are startCode)
- Test now uses charToGlyph('A') to also verify cmap mapping

https://claude.ai/code/session_014E56osqBcVfPwdgHHtrMBJ
@fdb fdb merged commit f95c049 into master Apr 3, 2026
1 check passed
@fdb fdb deleted the claude/fix-glyph-circular-reference-4ADE7 branch April 3, 2026 17:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants