diff --git a/src/core/font_renderer.js b/src/core/font_renderer.js index e2bd0f911..0c0fcdd8e 100644 --- a/src/core/font_renderer.js +++ b/src/core/font_renderer.js @@ -163,6 +163,9 @@ function lookupCmap(ranges, unicode) { } function compileGlyf(code, cmds, font, visitedGlyphs = new Set()) { + if (!code?.length) { + return; + } if (visitedGlyphs.has(code)) { warn("compileGlyf: skipping recursive composite glyph reference."); return; diff --git a/src/core/fonts.js b/src/core/fonts.js index 4f5932d31..71c5f3eac 100644 --- a/src/core/fonts.js +++ b/src/core/fonts.js @@ -55,13 +55,13 @@ import { getSupplementalGlyphMapForArialBlack, getSupplementalGlyphMapForCalibri, } from "./standard_fonts.js"; +import { GlyfTable, pruneCompositeGlyphCycles } from "./glyf.js"; import { IdentityToUnicodeMap, ToUnicodeMap } from "./to_unicode_map.js"; import { CFFFont } from "./cff_font.js"; import { compileFontInfo } from "./obj_bin_transform_core.js"; import { DataBuilder } from "./data_builder.js"; import { FontRendererFactory } from "./font_renderer.js"; import { getFontBasicMetrics } from "./metrics.js"; -import { GlyfTable } from "./glyf.js"; import { OpenTypeFileBuilder } from "./opentype_file_builder.js"; import { Stream } from "./stream.js"; import { Type1Font } from "./type1_font.js"; @@ -720,6 +720,11 @@ function createCmapTable(glyphs, toUnicodeExtraMap, numGlyphs) { function validateOS2Table(os2, file) { file.pos = (file.start || 0) + os2.offset; const version = file.getUint16(); + // https://learn.microsoft.com/en-us/typography/opentype/spec/os2 + const minLength = [78, 86, 96, 96, 96, 100][version]; + if (minLength === undefined || os2.length < minLength) { + return false; + } // TODO verify all OS/2 tables fields, but currently we validate only those // that give us issues file.skip(60); // skipping type, misc sizes, panose, unicode ranges @@ -2195,18 +2200,25 @@ class Font { last.endOffset = oldGlyfDataLength; } + const droppedGlyphs = pruneCompositeGlyphCycles( + oldGlyfData, + locaEntries, + numGlyphs + ); const missingGlyphs = Object.create(null); let writeOffset = 0; itemEncode(locaData, 0, writeOffset); for (i = 0, j = itemSize; i < numGlyphs; i++, j += itemSize) { - const glyphProfile = sanitizeGlyph( - oldGlyfData, - locaEntries[i].offset, - locaEntries[i].endOffset, - newGlyfData, - writeOffset, - hintsValid - ); + const glyphProfile = droppedGlyphs.has(i) + ? { length: 0, sizeOfInstructions: 0 } + : sanitizeGlyph( + oldGlyfData, + locaEntries[i].offset, + locaEntries[i].endOffset, + newGlyfData, + writeOffset, + hintsValid + ); const newLength = glyphProfile.length; if (newLength === 0) { missingGlyphs[i] = true; @@ -2837,6 +2849,19 @@ class Font { maxFunctionDefs = font.getUint16(); font.pos += 4; maxSizeOfInstructions = font.getUint16(); + } else if (isTrueType && version === 0x00005000) { + const newMaxp = new Uint8Array(32); + writeUint32(newMaxp, 0, 0x00010000); + newMaxp[4] = (numGlyphs >> 8) & 0xff; + newMaxp[5] = numGlyphs & 0xff; + newMaxp.fill(0xff, 6, 14); + newMaxp[15] = 2; + newMaxp[28] = 0xff; + newMaxp[29] = 0xff; + newMaxp[31] = 0x10; + tables.maxp.data = newMaxp; + tables.maxp.length = 32; + version = 0x00010000; } tables.maxp.data[4] = numGlyphsOut >> 8; diff --git a/src/core/glyf.js b/src/core/glyf.js index 06bde6482..29a3e6895 100644 --- a/src/core/glyf.js +++ b/src/core/glyf.js @@ -34,6 +34,8 @@ const WE_HAVE_INSTRUCTIONS = 1 << 8; // const SCALED_COMPONENT_OFFSET = 1 << 11; // const UNSCALED_COMPONENT_OFFSET = 1 << 12; +const GLYPH_HEADER_SIZE = 10; + /** * GlyfTable object represents a glyf table containing glyph information: * - glyph header (xMin, yMin, xMax, yMax); @@ -218,7 +220,7 @@ class GlyphHeader { static parse(pos, glyf) { return [ - 10, + GLYPH_HEADER_SIZE, new GlyphHeader({ numberOfContours: glyf.getInt16(pos), xMin: glyf.getInt16(pos + 2), @@ -230,7 +232,7 @@ class GlyphHeader { } getSize() { - return 10; + return GLYPH_HEADER_SIZE; } write(pos, buf) { @@ -240,7 +242,7 @@ class GlyphHeader { buf.setInt16(pos + 6, this.xMax); buf.setInt16(pos + 8, this.yMax); - return 10; + return GLYPH_HEADER_SIZE; } scale(x, factor) { @@ -696,4 +698,116 @@ class CompositeGlyph { scale(x, factor) {} } -export { GlyfTable }; +function pruneCompositeGlyphCycles(glyfTable, locaEntries, numGlyphs) { + const glyf = new DataView( + glyfTable.buffer, + glyfTable.byteOffset, + glyfTable.byteLength + ); + const components = new Array(numGlyphs); + for (let i = 0; i < numGlyphs; i++) { + const offset = locaEntries[i].offset; + const endOffset = Math.min(locaEntries[i].endOffset, glyf.byteLength); + if (endOffset - offset <= GLYPH_HEADER_SIZE || glyf.getInt16(offset) >= 0) { + continue; + } + const comps = []; + let p = offset + GLYPH_HEADER_SIZE; + while (p + 4 <= endOffset) { + const flags = glyf.getUint16(p); + const gid = glyf.getUint16(p + 2); + let size = 4 + (flags & ARG_1_AND_2_ARE_WORDS ? 4 : 2); + if (flags & WE_HAVE_A_SCALE) { + size += 2; + } else if (flags & WE_HAVE_AN_X_AND_Y_SCALE) { + size += 4; + } else if (flags & WE_HAVE_A_TWO_BY_TWO) { + size += 8; + } + comps.push({ gid, offset: p, size, flags }); + p += size; + if (!(flags & MORE_COMPONENTS)) { + break; + } + } + if (comps.length) { + components[i] = comps; + } + } + + const WHITE = 0, + GRAY = 1, + BLACK = 2; + const state = new Uint8Array(numGlyphs); + const backEdges = new Map(); + for (let start = 0; start < numGlyphs; start++) { + if (state[start] !== WHITE || !components[start]) { + continue; + } + const stack = [{ node: start, idx: 0 }]; + state[start] = GRAY; + while (stack.length > 0) { + const top = stack.at(-1); + const comps = components[top.node]; + if (!comps || top.idx >= comps.length) { + state[top.node] = BLACK; + stack.pop(); + continue; + } + const compIdx = top.idx++; + const next = comps[compIdx].gid; + if (next >= numGlyphs || state[next] === BLACK) { + continue; + } + if (state[next] === WHITE) { + state[next] = GRAY; + stack.push({ node: next, idx: 0 }); + continue; + } + + let removeSet = backEdges.get(top.node); + if (!removeSet) { + removeSet = new Set(); + backEdges.set(top.node, removeSet); + } + removeSet.add(compIdx); + } + } + + const droppedGlyphs = new Set(); + for (const [gIdx, removeSet] of backEdges) { + const comps = components[gIdx]; + const remaining = []; + for (let ci = 0; ci < comps.length; ci++) { + if (!removeSet.has(ci)) { + remaining.push(comps[ci]); + } + } + if (remaining.length === 0) { + droppedGlyphs.add(gIdx); + continue; + } + const start = locaEntries[gIdx].offset; + const endOffset = Math.min(locaEntries[gIdx].endOffset, glyf.byteLength); + let writePos = start + GLYPH_HEADER_SIZE; + for (let ci = 0; ci < remaining.length; ci++) { + const c = remaining[ci]; + const isLast = ci === remaining.length - 1; + let newFlags = c.flags & ~WE_HAVE_INSTRUCTIONS; + newFlags = isLast + ? newFlags & ~MORE_COMPONENTS + : newFlags | MORE_COMPONENTS; + if (writePos !== c.offset) { + glyfTable.copyWithin(writePos, c.offset, c.offset + c.size); + } + glyf.setUint16(writePos, newFlags); + writePos += c.size; + } + if (writePos < endOffset) { + glyfTable.fill(0, writePos, endOffset); + } + } + return droppedGlyphs; +} + +export { GlyfTable, pruneCompositeGlyphCycles }; diff --git a/test/font/font_glyf_spec.js b/test/font/font_glyf_spec.js new file mode 100644 index 000000000..decaedccd --- /dev/null +++ b/test/font/font_glyf_spec.js @@ -0,0 +1,141 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ttx, verifyTtxOutput } from "./fontutils.js"; +import { Font } from "../../src/core/fonts.js"; +import { Stream } from "../../src/core/stream.js"; +import { ToUnicodeMap } from "../../src/core/to_unicode_map.js"; + +// Minimal TrueType font: 4 glyphs (.notdef, space, A, B), OS/2 v1 / 86 bytes, +// no hinting tables. +const baseFont = Uint8Array.fromBase64( + "AAEAAAAKAIAAAwAgT1MvMkTeRDYAAAEoAAAAVmNtYXAAdQBcAAABjAAAADxnbHlmmNLJuAAAAdQAAABKaGVhZC3Q8mwAAACsAAAANmhoZWEFFgH2AAAA5AAAACRobXR4AlgAAAAAAYAAAAAKbG9jYQAyACYAAAHIAAAACm1heHAABgAGAAABCAAAACBuYW1lAJlcyAAAAiAAAAA8cG9zdAAuACQAAAJcAAAAKgABAAAAAQAAfM/c718PPPUAAQPoAAAAAOYyVzYAAAAA5jJXNgAAAAACWAMgAAAAAwACAAAAAAAAAAEAAAMg/zgAAAJYAAAAZAH0AAEAAAAAAAAAAAAAAAAAAAABAAEAAAAEAAQAAQAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAQJYAZAABQAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAPz8/PwAAACAAQgMg/zgAAAMgAMgAAAAAAAAAAAAAAlgAAAAAAAAAAAAAAAAAAgAAAAMAAAAUAAMAAQAAABQABAAoAAAABgAEAAEAAgAgAEL//wAAACAAQf///+H/wQABAAAAAAAAAAAADQANABkAJQAAAAEAZAAAAlgDIAADAAAzIREhZAH0/gwDIAAAAQAAAAAB9AK8AAMAADEhESEB9P4MArwAAQAAAAAB9AK8AAMAADEhESEB9P4MArwAAAAAAAQANgABAAAAAAABAAEAAAABAAAAAAACAAEAAQADAAEECQABAAIAAgADAAEECQACAAIABFRSAFQAUgACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAADACQAJQAA" +); + +function clone(buf) { + return new Uint8Array(buf); +} + +function readUint16(buf, pos) { + return (buf[pos] << 8) | buf[pos + 1]; +} + +function readUint32(buf, pos) { + return ( + buf[pos] * 0x1000000 + + ((buf[pos + 1] << 16) | (buf[pos + 2] << 8) | buf[pos + 3]) + ); +} + +function getTables(buf) { + const tables = Object.create(null); + const numTables = readUint16(buf, 4); + for (let i = 0; i < numTables; i++) { + const off = 12 + i * 16; + const tag = String.fromCharCode( + buf[off], + buf[off + 1], + buf[off + 2], + buf[off + 3] + ); + tables[tag] = { + offset: readUint32(buf, off + 8), + length: readUint32(buf, off + 12), + }; + } + return tables; +} + +function makeProperties(toUnicode) { + return { + loadedName: "font", + type: "TrueType", + differences: [], + defaultEncoding: [], + toUnicode, + xHeight: 0, + capHeight: 0, + italicAngle: 0, + firstChar: 0, + lastChar: 255, + }; +} + +describe("font_glyf", function () { + describe("Cyclic composite glyph 0", function () { + it("removes a self-referencing composite glyph 0 (issue 21298)", async function () { + const buggy = clone(baseFont); + const tables = getTables(buggy); + const headOff = tables.head.offset; + const indexToLocFormat = readUint16(buggy, headOff + 50); + const locaOff = tables.loca.offset; + const glyf0 = + indexToLocFormat === 0 + ? readUint16(buggy, locaOff) * 2 + : readUint32(buggy, locaOff); + const glyf0End = + indexToLocFormat === 0 + ? readUint16(buggy, locaOff + 2) * 2 + : readUint32(buggy, locaOff + 4); + const pos = tables.glyf.offset + glyf0; + buggy.fill(0, pos, tables.glyf.offset + glyf0End); + buggy[pos] = 0xff; + buggy[pos + 1] = 0xff; + buggy[pos + 11] = 0x02; + + const font = new Font( + "font", + new Stream(buggy), + makeProperties(new ToUnicodeMap([])), + {} + ); + const output = await ttx(font.data); + verifyTtxOutput(output); + const notdef = + /]*name="\.notdef"[^>]*\/>|]*name="\.notdef"[^>]*>([\s\S]*?)<\/TTGlyph>/.exec( + output + ); + expect(notdef).not.toBeNull(); + expect(notdef[1] || "").not.toMatch( + /]*glyphName="\.notdef"/ + ); + }); + }); + + describe("OS/2 table length validation", function () { + it("rewrites the OS/2 table when its length doesn't match the declared version", async function () { + const buggy = clone(baseFont); + const tables = getTables(buggy); + const os2 = tables["OS/2"].offset; + buggy[os2 + 62] = 0x00; + buggy[os2 + 63] = 0x40; + buggy[os2 + 1] = 0x03; + + const font = new Font( + "font", + new Stream(buggy), + makeProperties(new ToUnicodeMap([])), + {} + ); + const output = await ttx(font.data); + verifyTtxOutput(output); + expect( + /\s*(\s*)?/.test(output) + ).toEqual(true); + expect(/