Sanitize glyf composite cycles, OS/2 length and maxp version mismatches

Prune the back-edge components from cyclic composite glyphs in
sanitizeGlyphLocations (leaving non-cyclic siblings intact), reject OS/2
tables whose length is too short for the declared version so a clean
table gets regenerated, and upgrade a version 0.5 maxp table to 1.0 for
TrueType fonts to silence OTS' "wrong maxp version for glyph data".

It fixes #21298.
This commit is contained in:
Calixte Denizet 2026-05-19 21:57:24 +02:00
parent f500cffd2e
commit d6a2b91243
5 changed files with 297 additions and 13 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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 };

141
test/font/font_glyf_spec.js Normal file
View File

@ -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 =
/<TTGlyph[^>]*name="\.notdef"[^>]*\/>|<TTGlyph[^>]*name="\.notdef"[^>]*>([\s\S]*?)<\/TTGlyph>/.exec(
output
);
expect(notdef).not.toBeNull();
expect(notdef[1] || "").not.toMatch(
/<component\b[^>]*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(
/<OS_2>\s*(<!--[\s\S]*?-->\s*)?<version value="3"\/>/.test(output)
).toEqual(true);
expect(/<sCapHeight\b/.test(output)).toEqual(true);
expect(/<usMaxContext\b/.test(output)).toEqual(true);
});
});
});

View File

@ -46,6 +46,7 @@ async function initializePDFJS(callback) {
await Promise.all(
[
"pdfjs-test/font/font_core_spec.js",
"pdfjs-test/font/font_glyf_spec.js",
"pdfjs-test/font/font_os2_spec.js",
"pdfjs-test/font/font_post_spec.js",
"pdfjs-test/font/font_fpgm_spec.js",