diff --git a/src/glyphset.mjs b/src/glyphset.mjs index 4aaa6871..c0525c66 100644 --- a/src/glyphset.mjs +++ b/src/glyphset.mjs @@ -134,10 +134,29 @@ function ttfGlyphLoader(font, index, parseGlyph, data, position, buildPath) { const glyph = new Glyph({index: index, font: font}); glyph.path = function() { - parseGlyph(glyph, data, position); - const path = buildPath(font.glyphs, glyph); - path.unitsPerEm = font.unitsPerEm; - return path; + const pathLoadStack = font._glyphPathLoadStack || (font._glyphPathLoadStack = []); + if (glyph._isLoadingPath) { + const cycleStart = pathLoadStack.indexOf(index); + const cycle = cycleStart === -1 ? + [index, index] : + pathLoadStack.slice(cycleStart).concat(index); + throw new Error(`Circular component reference detected: ${cycle.join(' -> ')}`); + } + + glyph._isLoadingPath = true; + pathLoadStack.push(index); + try { + parseGlyph(glyph, data, position); + const path = buildPath(font.glyphs, glyph); + path.unitsPerEm = font.unitsPerEm; + return path; + } finally { + pathLoadStack.pop(); + glyph._isLoadingPath = false; + if (pathLoadStack.length === 0) { + delete font._glyphPathLoadStack; + } + } }; defineDependentProperty(glyph, 'numberOfContours', '_numberOfContours'); diff --git a/test/fonts/CompositeCycle.ttf b/test/fonts/CompositeCycle.ttf new file mode 100644 index 00000000..c2520c84 Binary files /dev/null and b/test/fonts/CompositeCycle.ttf differ diff --git a/test/fonts/generate-composite-cycle-font.mjs b/test/fonts/generate-composite-cycle-font.mjs new file mode 100644 index 00000000..2e5c9b68 --- /dev/null +++ b/test/fonts/generate-composite-cycle-font.mjs @@ -0,0 +1,159 @@ +import { writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import sfnt from '../../src/tables/sfnt.mjs'; +import table from '../../src/table.mjs'; +import cmap from '../../src/tables/cmap.mjs'; +import head from '../../src/tables/head.mjs'; +import hhea from '../../src/tables/hhea.mjs'; +import maxp from '../../src/tables/maxp.mjs'; +import name from '../../src/tables/name.mjs'; +import post from '../../src/tables/post.mjs'; + +const TRUE_TYPE_SIGNATURE = String.fromCharCode(0, 1, 0, 0); +const COMPOSITE_FLAGS = { + ARG_1_AND_2_ARE_WORDS: 0x0001, + ARGS_ARE_XY_VALUES: 0x0002, + MORE_COMPONENTS: 0x0020 +}; + +function makeByteTable(tableName, bytes) { + return new table.Table(tableName, bytes.map((value, index) => ({ + name: `byte_${index}`, + type: 'BYTE', + value + }))); +} + +function makeUShortTable(tableName, values) { + return new table.Table(tableName, values.map((value, index) => ({ + name: `ushort_${index}`, + type: 'USHORT', + value + }))); +} + +function makeHmtxTable(advanceWidths, leftSideBearings) { + const fields = []; + for (let i = 0; i < advanceWidths.length; i += 1) { + fields.push({name: `advanceWidth_${i}`, type: 'USHORT', value: advanceWidths[i]}); + fields.push({name: `leftSideBearing_${i}`, type: 'SHORT', value: leftSideBearings[i]}); + } + return new table.Table('hmtx', fields); +} + +function createNames() { + const unicode = { + copyright: {en: ' '}, + fontFamily: {en: 'Composite Cycle Test'}, + fontSubfamily: {en: 'Regular'}, + uniqueID: {en: 'Composite Cycle Test Regular'}, + fullName: {en: 'Composite Cycle Test Regular'}, + version: {en: 'Version 1.0'}, + postScriptName: {en: 'CompositeCycleTest-Regular'}, + trademark: {en: ' '} + }; + + return { + unicode, + windows: unicode, + macintosh: unicode + }; +} + +function createGlyphMetadata() { + const glyphs = [ + {name: '.notdef', unicodes: []}, + {name: 'cycleA', unicode: 65, unicodes: [65]}, + {name: 'cycleB', unicode: 66, unicodes: [66]} + ]; + + return { + length: glyphs.length, + get(index) { + return glyphs[index]; + } + }; +} + +function createGlyfBytes() { + return [ + // glyph 0: empty .notdef + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00, + + // glyph 1: composite referencing glyph 2 + 0xFF, 0xFF, + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x23, + 0x00, 0x02, + 0x00, 0x00, + 0x00, 0x00, + + // glyph 2: composite referencing glyph 1 + 0xFF, 0xFF, + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x03, + 0x00, 0x01, + 0x00, 0x00, + 0x00, 0x00 + ]; +} + +export function makeCompositeCycleFontBuffer() { + const glyphs = createGlyphMetadata(); + const tables = [ + cmap.make(glyphs), + head.make({ + unitsPerEm: 1000, + indexToLocFormat: 0, + lowestRecPPEM: 3 + }), + hhea.make({ + ascender: 800, + descender: -200, + advanceWidthMax: 500, + numberOfHMetrics: glyphs.length + }), + maxp.make(glyphs.length), + makeByteTable('glyf', createGlyfBytes()), + makeUShortTable('loca', [0, 5, 14, 23]), + makeHmtxTable([500, 500, 500], [0, 0, 0]), + name.make(createNames(), []), + post.make({tables: {}}) + ]; + + const sfntTable = sfnt.make(tables); + sfntTable.version = TRUE_TYPE_SIGNATURE; + + const bytes = sfntTable.encode(); + const checkSum = sfnt.computeCheckSum(bytes); + for (let i = 0; i < sfntTable.fields.length; i += 1) { + if (sfntTable.fields[i].name === 'head table') { + sfntTable.fields[i].value.checkSumAdjustment = 0xB1B0AFBA - checkSum; + break; + } + } + + return new Uint8Array(sfntTable.encode()).buffer; +} + +const outputPath = fileURLToPath(new URL('./CompositeCycle.ttf', import.meta.url)); +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const buffer = new Uint8Array(makeCompositeCycleFontBuffer()); + writeFileSync(outputPath, buffer); + const aComponentFlags = COMPOSITE_FLAGS.ARG_1_AND_2_ARE_WORDS | + COMPOSITE_FLAGS.ARGS_ARE_XY_VALUES | + COMPOSITE_FLAGS.MORE_COMPONENTS; + const bComponentFlags = COMPOSITE_FLAGS.ARG_1_AND_2_ARE_WORDS | + COMPOSITE_FLAGS.ARGS_ARE_XY_VALUES; + console.log(`Wrote ${outputPath} (${buffer.byteLength} bytes, flags ${aComponentFlags}/${bComponentFlags}).`); +} diff --git a/test/glyph.spec.mjs b/test/glyph.spec.mjs index 9793f053..a218cc70 100644 --- a/test/glyph.spec.mjs +++ b/test/glyph.spec.mjs @@ -173,6 +173,26 @@ describe('glyph.mjs', function() { }); }); + describe('composite glyph cycle detection', function() { + function loadCompositeCycleFont(opt) { + return loadSync('./test/fonts/CompositeCycle.ttf', opt); + } + + it('throws a controlled error for circular composite references', function() { + const font = loadCompositeCycleFont(); + assert.throws(function() { + font.glyphs.get(1).getPath(); + }, /Circular component reference/); + }); + + it('throws a controlled error for circular composite references in low memory mode', function() { + const font = loadCompositeCycleFont({lowMemory: true}); + assert.throws(function() { + font.glyphs.get(1).getPath(); + }, /Circular component reference/); + }); + }); + describe('color glyph drawing/rendering', function() { it('draws and renders layers correctly', function() { let contextLogs = [];