mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-05-31 07:11:00 +02:00
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:
parent
f500cffd2e
commit
d6a2b91243
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
122
src/core/glyf.js
122
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 };
|
||||
|
||||
141
test/font/font_glyf_spec.js
Normal file
141
test/font/font_glyf_spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user