From 6f0431456c355e33edd9833d697d21c34c83c964 Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Mon, 6 Apr 2026 17:41:24 +0200 Subject: [PATCH] Reduce allocations when compiling CFF fonts Currently the `CFFCompiler.prototype.compile` implementation seem a bit inefficient, since the data is stored in a plain Array that needs to grow (a lot) during compilation. Additionally, adding a lot of entries isn't very efficient either and requires special handling of the "too many elements" case. Some of the "helper" methods that use TypedArrays internally currently need to convert their return data to plain Arrays, via the `compileTypedArray` method, which adds even more intermediate allocations. Note also that the `OpenTypeFileBuilder` has a special-case for writing plain Array data, which is only needed because of how the CFF compilation is implemented. To improve this situation the `CFFCompiler.prototype.compile` method is re-factored to store its data in a TypedArray, whose initial size is estimated from the "raw" file size. This removes the need for most intermediate allocations, and it also handles adding of "many elements" more efficiently. --- src/core/cff_parser.js | 92 ++++++++++++++++++++--------- src/core/opentype_file_builder.js | 5 -- src/core/type1_font.js | 5 +- test/unit/cff_parser_spec.js | 96 +++++++++++++++++-------------- 4 files changed, 121 insertions(+), 77 deletions(-) diff --git a/src/core/cff_parser.js b/src/core/cff_parser.js index 1a0bd4634..c12839e31 100644 --- a/src/core/cff_parser.js +++ b/src/core/cff_parser.js @@ -228,7 +228,7 @@ class CFFParser { parse() { const properties = this.properties; - const cff = new CFF(); + const cff = new CFF(this.bytes.length); this.cff = cff; // The first five sections must be in order, all the others are reached @@ -1017,6 +1017,10 @@ class CFF { charStringCount = 0; + constructor(rawFileLength = 0) { + this.rawFileLength = rawFileLength; + } + duplicateFirstGlyph() { // Browsers will not display a glyph at position 0. Typically glyph 0 is // notdef, but a number of fonts put a valid glyph there so it must be @@ -1372,6 +1376,57 @@ class CFFOffsetTracker { } } +class CompilerOutput { + #buf; + + #bufLength = 1024; + + #pos = 0; + + constructor(minLength) { + // Note: Usually the compiled size is smaller than the initial data, + // however in some cases it may increase slightly. + this.#initBuf(minLength); + } + + #initBuf(minLength) { + // Compute the first power of two that is as big as the `minLength`. + while (this.#bufLength < minLength) { + this.#bufLength *= 2; + } + const newBuf = new Uint8Array(this.#bufLength); + + if (this.#buf) { + newBuf.set(this.#buf, 0); + } + this.#buf = newBuf; + } + + get data() { + return this.#buf.subarray(0, this.#pos); + } + + get finalData() { + const data = this.#buf.slice(0, this.#pos); + this.#buf = null; + return data; + } + + get length() { + return this.#pos; + } + + add(data) { + const newPos = this.#pos + data.length; + if (newPos > this.#bufLength) { + // It should be very rare that the buffer needs to grow. + this.#initBuf(newPos); + } + this.#buf.set(data, this.#pos); + this.#pos = newPos; + } +} + // Takes a CFF and converts it to the binary representation. class CFFCompiler { constructor(cff) { @@ -1380,21 +1435,7 @@ class CFFCompiler { compile() { const cff = this.cff; - const output = { - data: [], - length: 0, - add(data) { - try { - // It's possible to exceed the call stack maximum size when trying - // to push too much elements. - // In case of failure, we fallback to the `concat` method. - this.data.push(...data); - } catch { - this.data = this.data.concat(data); - } - this.length = this.data.length; - }, - }; + const output = new CompilerOutput(cff.rawFileLength); // Compile the five entries that must be in order. const header = this.compileHeader(cff.header); @@ -1499,7 +1540,7 @@ class CFFCompiler { // the sanitizer will bail out. Add a dummy byte to avoid that. output.add([0]); - return output.data; + return output.finalData; } encodeNumber(value) { @@ -1781,7 +1822,7 @@ class CFFCompiler { } else { const length = 1 + numGlyphsLessNotDef * 2; out = new Uint8Array(length); - out[0] = 0; // format 0 + // format 0, skip redundant `out[0] = 0;` assignment. let charsetIndex = 0; const numCharsets = charset.charset.length; let warned = false; @@ -1802,11 +1843,11 @@ class CFFCompiler { out[i + 1] = sid & 0xff; } } - return this.compileTypedArray(out); + return out; } compileEncoding(encoding) { - return this.compileTypedArray(encoding.raw); + return encoding.raw; } compileFDSelect(fdSelect) { @@ -1847,11 +1888,7 @@ class CFFCompiler { out = new Uint8Array(ranges); break; } - return this.compileTypedArray(out); - } - - compileTypedArray(data) { - return Array.from(data); + return out; } compileIndex(index, trackers = []) { @@ -1915,9 +1952,8 @@ class CFFCompiler { for (i = 0; i < count; i++) { // Notify the tracker where the object will be offset in the data. - if (trackers[i]) { - trackers[i].offset(data.length); - } + trackers[i]?.offset(data.length); + data.push(...objects[i]); } return data; diff --git a/src/core/opentype_file_builder.js b/src/core/opentype_file_builder.js index e02a0ee04..f51be45c0 100644 --- a/src/core/opentype_file_builder.js +++ b/src/core/opentype_file_builder.js @@ -35,11 +35,6 @@ function writeData(dest, offset, data) { for (let i = 0, ii = data.length; i < ii; i++) { dest[offset++] = data.charCodeAt(i) & 0xff; } - } else { - // treating everything else as array - for (const num of data) { - dest[offset++] = num & 0xff; - } } } diff --git a/src/core/type1_font.js b/src/core/type1_font.js index 94f81056c..919157e6e 100644 --- a/src/core/type1_font.js +++ b/src/core/type1_font.js @@ -153,6 +153,8 @@ function getEexecBlock(stream, suggestedLength) { * Type1Font is also a CIDFontType0. */ class Type1Font { + #rawFileLength; + constructor(name, file, properties) { // Some bad generators embed pfb file as is, we have to strip 6-byte header. // Also, length1 and length2 might be off by 6 bytes as well. @@ -200,6 +202,7 @@ class Type1Font { for (const key in data.properties) { properties[key] = data.properties[key]; } + this.#rawFileLength = headerBlock.length + eexecBlock.length; const charstrings = data.charstrings; const type2Charstrings = this.getType2Charstrings(charstrings); @@ -323,7 +326,7 @@ class Type1Font { } wrap(name, glyphs, charstrings, subrs, properties) { - const cff = new CFF(); + const cff = new CFF(this.#rawFileLength); cff.header = new CFFHeader(1, 0, 4, 4); cff.names = [name]; diff --git a/test/unit/cff_parser_spec.js b/test/unit/cff_parser_spec.js index 82b9ba4cd..c58fd6551 100644 --- a/test/unit/cff_parser_spec.js +++ b/test/unit/cff_parser_spec.js @@ -430,47 +430,53 @@ describe("CFFCompiler", function () { const fdSelect = new CFFFDSelect(0, [3, 2, 1]); const c = new CFFCompiler(); const out = c.compileFDSelect(fdSelect); - expect(out).toEqual([ - 0, // format - 3, // gid: 0 fd 3 - 2, // gid: 1 fd 3 - 1, // gid: 2 fd 3 - ]); + expect(out).toEqual( + new Uint8Array([ + 0, // format + 3, // gid: 0 fd 3 + 2, // gid: 1 fd 3 + 1, // gid: 2 fd 3 + ]) + ); }); it("compiles fdselect format 3", function () { const fdSelect = new CFFFDSelect(3, [0, 0, 1, 1]); const c = new CFFCompiler(); const out = c.compileFDSelect(fdSelect); - expect(out).toEqual([ - 3, // format - 0, // nRanges (high) - 2, // nRanges (low) - 0, // range struct 0 - first (high) - 0, // range struct 0 - first (low) - 0, // range struct 0 - fd - 0, // range struct 0 - first (high) - 2, // range struct 0 - first (low) - 1, // range struct 0 - fd - 0, // sentinel (high) - 4, // sentinel (low) - ]); + expect(out).toEqual( + new Uint8Array([ + 3, // format + 0, // nRanges (high) + 2, // nRanges (low) + 0, // range struct 0 - first (high) + 0, // range struct 0 - first (low) + 0, // range struct 0 - fd + 0, // range struct 0 - first (high) + 2, // range struct 0 - first (low) + 1, // range struct 0 - fd + 0, // sentinel (high) + 4, // sentinel (low) + ]) + ); }); it("compiles fdselect format 3, single range", function () { const fdSelect = new CFFFDSelect(3, [0, 0]); const c = new CFFCompiler(); const out = c.compileFDSelect(fdSelect); - expect(out).toEqual([ - 3, // format - 0, // nRanges (high) - 1, // nRanges (low) - 0, // range struct 0 - first (high) - 0, // range struct 0 - first (low) - 0, // range struct 0 - fd - 0, // sentinel (high) - 2, // sentinel (low) - ]); + expect(out).toEqual( + new Uint8Array([ + 3, // format + 0, // nRanges (high) + 1, // nRanges (low) + 0, // range struct 0 - first (high) + 0, // range struct 0 - first (low) + 0, // range struct 0 - fd + 0, // sentinel (high) + 2, // sentinel (low) + ]) + ); }); it("compiles charset of CID font", function () { @@ -479,13 +485,15 @@ describe("CFFCompiler", function () { const numGlyphs = 7; const out = c.compileCharset(charset, numGlyphs, new CFFStrings(), true); // All CID charsets get turned into a simple format 2. - expect(out).toEqual([ - 2, // format - 0, // cid (high) - 1, // cid (low) - 0, // nLeft (high) - numGlyphs - 2, // nLeft (low) - ]); + expect(out).toEqual( + new Uint8Array([ + 2, // format + 0, // cid (high) + 1, // cid (low) + 0, // nLeft (high) + numGlyphs - 2, // nLeft (low) + ]) + ); }); it("compiles charset of non CID font", function () { @@ -494,13 +502,15 @@ describe("CFFCompiler", function () { const numGlyphs = 3; const out = c.compileCharset(charset, numGlyphs, new CFFStrings(), false); // All non-CID fonts use a format 0 charset. - expect(out).toEqual([ - 0, // format - 0, // sid of 'space' (high) - 1, // sid of 'space' (low) - 0, // sid of 'exclam' (high) - 2, // sid of 'exclam' (low) - ]); + expect(out).toEqual( + new Uint8Array([ + 0, // format + 0, // sid of 'space' (high) + 1, // sid of 'space' (low) + 0, // sid of 'exclam' (high) + 2, // sid of 'exclam' (low) + ]) + ); }); // TODO a lot more compiler tests