From 23078912f6f2a412eaad4324ef70ec214d62838d Mon Sep 17 00:00:00 2001 From: Christopher van Rooyen Date: Sat, 18 Jul 2026 06:40:06 +1000 Subject: [PATCH 1/3] fix: restore libeot parity in decoder core (CVT, caps, BitIO, validity checks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port-parity fixes found by diffing against libeot (MPL 2.0) and the W3C MTX spec: - ctf-parser: unpackCVT read the leading U16 as a byte count and halved every hinted font's Control Value Table. The MTX spec (§5.2 "USHORT numEntries") and libeot's unpackCVT both treat it as an entry count. Fixed; the existing test was self-referential (built the fixture under the same wrong assumption) and now asserts the correct 3-entry / 6-byte result. - lzcomp: the 4 MiB MAX_OUT_LEN cap rejected legal fonts. out_len is a 24-bit header field, so 2^24-1 is the real bound; raised to match, since the field width already bounds allocation. RLE growth cap raised accordingly. - bitio: clamp `size` to the buffer length so an over-large size can't read past the end and silently yield zero bits; don't mutate bitCount before the end-of-data throw so a caught error leaves the reader consistent. - ctf-parser: restore libeot's structural checks that had been dropped — missing maxp/head/hmtx and a head table < 12 bytes now raise errors instead of decoding a silently-empty or malformed font; corrupt 0xFB/0xFC hop codes raise instead of injecting a literal 251 into the instruction stream. - errors: add a machine-discriminable EotError/EotErrorCode mirroring libeot's EOTError enum, exported from the package entry point. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bitio.test.ts | 21 +++++ src/bitio.ts | 15 +++- src/ctf-parser.test.ts | 193 ++++++++++++++++++++++++++-------------- src/ctf-parser.ts | 76 +++++++++++----- src/errors.ts | 49 ++++++++++ src/index.ts | 1 + src/integration.test.ts | 25 ++++-- src/lzcomp.ts | 19 +++- 8 files changed, 292 insertions(+), 107 deletions(-) create mode 100644 src/errors.ts diff --git a/src/bitio.test.ts b/src/bitio.test.ts index 1223d2b..8e80bcf 100644 --- a/src/bitio.test.ts +++ b/src/bitio.test.ts @@ -56,6 +56,27 @@ describe('bitIO', () => { // 17th bit should throw expect(() => bio.inputBit()).toThrow('end of data'); }); + + it('throws rather than yielding zero bits when size exceeds the buffer', () => { + // size claims 4 bytes but only 1 is present. The extra length must be + // clamped so reads past the real end throw instead of silently + // returning 0 bits (which would corrupt a decode). + const bio = new BitIO(new Uint8Array([0xff]), 0, 4); + for (let i = 0; i < 8; i++) { + expect(bio.inputBit()).toBeTruthy(); + } + expect(() => bio.inputBit()).toThrow('end of data'); + }); + + it('stays consistent after a caught end-of-data error', () => { + const bio = new BitIO(new Uint8Array([0xff])); + for (let i = 0; i < 8; i++) { + bio.inputBit(); + } + expect(() => bio.inputBit()).toThrow('end of data'); + // A retry must throw again, not return a stale/garbage bit. + expect(() => bio.inputBit()).toThrow('end of data'); + }); }); // ----------------------------------------------------------------------- diff --git a/src/bitio.ts b/src/bitio.ts index 1210a1f..7a97c1c 100644 --- a/src/bitio.ts +++ b/src/bitio.ts @@ -18,12 +18,15 @@ export class BitIO { /** * @param data Source byte buffer. * @param offset Starting byte offset into `data`. - * @param size Number of bytes available from `offset`. + * @param size Absolute end index into `data` (exclusive) — reading stops + * once `index` reaches it. Defaults to `data.length`. Clamped + * to `data.length` so an over-large value cannot read past the + * end of the buffer and silently yield zero bits. */ constructor(data: Uint8Array, offset: number = 0, size?: number) { this.data = data; this.index = offset; - this.size = size ?? data.length; + this.size = Math.min(size ?? data.length, data.length); } /** @@ -37,13 +40,17 @@ export class BitIO { * shifted out of the original byte value). */ inputBit(): boolean { - if (this.bitCount-- === 0) { + if (this.bitCount === 0) { + // Reload before consuming any bit, and throw *before* mutating any + // state so a caught end-of-data error leaves the reader consistent + // (a retry throws again rather than returning a stale bit). if (this.index >= this.size) { throw new Error('BitIO: end of data'); } this.bitBuffer = this.data[this.index++]; - this.bitCount = 7; + this.bitCount = 8; } + this.bitCount--; this.bitBuffer <<= 1; return (this.bitBuffer & 0x100) !== 0; } diff --git a/src/ctf-parser.test.ts b/src/ctf-parser.test.ts index 2bb8137..c218db3 100644 --- a/src/ctf-parser.test.ts +++ b/src/ctf-parser.test.ts @@ -1,15 +1,62 @@ import { describe, it, expect } from 'vitest'; import { parseCTF } from './ctf-parser'; +import { EotError, EotErrorCode } from './errors'; import { Stream } from './stream'; +/** Minimal 54-byte `head` table (indexToLocFormat lives at offset 50). */ +function minimalHead(): Uint8Array { + return new Uint8Array(54); +} + +/** Minimal 32-byte `maxp` v1.0 table with numGlyphs = 0. */ +function minimalMaxp(): Uint8Array { + const s = new Stream(null, 0); + s.reserve(32); + s.writeU32(0x00010000); // version 1.0 + for (let i = 0; i < 14; i++) { + s.writeU16(0); // numGlyphs + the 13 v1.0 limit fields + } + return s.toUint8Array(); +} + +/** + * Ensure the structural tables parseCTF requires (head, maxp, hmtx) are + * present, injecting minimal versions for any the caller did not supply. + */ +function withRequiredTables( + tables: { tag: string; data: Uint8Array }[], +): { tag: string; data: Uint8Array }[] { + const have = new Set(tables.map((t) => t.tag)); + const result = [...tables]; + if (!have.has('head')) { + result.push({ tag: 'head', data: minimalHead() }); + } + if (!have.has('maxp')) { + result.push({ tag: 'maxp', data: minimalMaxp() }); + } + if (!have.has('hmtx')) { + result.push({ tag: 'hmtx', data: new Uint8Array([0, 0, 0, 0]) }); + } + return result; +} + /** * Build a minimal CTF stream[0] that contains an SFNT header and - * table directory, plus the raw table data. + * table directory, plus the raw table data. The structural tables required + * by parseCTF (head, maxp, hmtx) are injected automatically when absent. * * This is a helper for constructing test inputs for parseCTF. */ -function buildMinimalCTFStream0(tables: { tag: string; data: Uint8Array }[]): Stream { +function buildMinimalCTFStream0(inputTables: { tag: string; data: Uint8Array }[]): Stream { + return buildRawCTFStream0(withRequiredTables(inputTables)); +} + +/** + * Build a CTF stream[0] from exactly the given tables, with no injection of + * required structural tables. Use this to exercise the missing-table guards. + */ +function buildRawCTFStream0(tables: { tag: string; data: Uint8Array }[]): Stream { const s = new Stream(null, 0); // --- SFNT offset table (12 bytes) --- @@ -59,10 +106,10 @@ describe('parseCTF', () => { const container = parseCTF([s0, s1, s2]); - expect(container.tables).toHaveLength(1); - expect(container.tables[0].tag).toBe('name'); - expect(container.tables[0].bufSize).toBe(4); - expect(container.tables[0].buf).toStrictEqual(tableData); + const name = container.tables.find((t) => t.tag === 'name')!; + expect(name).toBeDefined(); + expect(name.bufSize).toBe(4); + expect(name.buf).toStrictEqual(tableData); }); it('parses multiple tables', () => { @@ -77,73 +124,32 @@ describe('parseCTF', () => { const container = parseCTF([s0, s1, s2]); - expect(container.tables).toHaveLength(3); - expect(container.tables[0].tag).toBe('name'); - expect(container.tables[1].tag).toBe('post'); - expect(container.tables[2].tag).toBe('OS/2'); + expect(container.tables.find((t) => t.tag === 'name')).toBeDefined(); + expect(container.tables.find((t) => t.tag === 'post')).toBeDefined(); + expect(container.tables.find((t) => t.tag === 'OS/2')).toBeDefined(); }); // ----------------------------------------------------------------------- // hdmx and VDMX table skipping // ----------------------------------------------------------------------- it('skips hdmx tables', () => { - const s = new Stream(null, 0); - s.writeU32(0x00010000); // scalarType - s.writeU16(2); // numTables - s.writeU16(0); - s.writeU16(0); - s.writeU16(0); - - // Table 1: hdmx (should be skipped) - for (const c of 'hdmx') { - s.writeU8(c.charCodeAt(0)); - } - s.writeU32(0); // checksum - s.writeU32(100); // offset - s.writeU32(50); // size - - // Table 2: name - const nameOffset = 12 + 2 * 16; - const nameData = new Uint8Array([0xaa, 0xbb]); - for (const c of 'name') { - s.writeU8(c.charCodeAt(0)); - } - s.writeU32(0); - s.writeU32(nameOffset); - s.writeU32(nameData.length); - - // Write name data - s.seekAbsoluteThroughReserve(nameOffset); - for (let i = 0; i < nameData.length; i++) { - s.writeU8(nameData[i]); - } - - s.seekAbsolute(0); - const container = parseCTF([s, new Stream(null, 0), new Stream(null, 0)]); + const s0 = buildMinimalCTFStream0([ + { tag: 'hdmx', data: new Uint8Array(50) }, + { tag: 'name', data: new Uint8Array([0xaa, 0xbb]) }, + ]); + const container = parseCTF([s0, new Stream(null, 0), new Stream(null, 0)]); - // Only the name table should be present (hdmx skipped) - expect(container.tables).toHaveLength(1); - expect(container.tables[0].tag).toBe('name'); + // hdmx is dropped; name survives. + expect(container.tables.find((t) => t.tag === 'hdmx')).toBeUndefined(); + const name = container.tables.find((t) => t.tag === 'name')!; + expect(name).toBeDefined(); + expect(name.buf).toStrictEqual(new Uint8Array([0xaa, 0xbb])); }); it('skips VDMX tables', () => { - const s = new Stream(null, 0); - s.writeU32(0x00010000); - s.writeU16(1); // numTables: just VDMX - s.writeU16(0); - s.writeU16(0); - s.writeU16(0); - - for (const c of 'VDMX') { - s.writeU8(c.charCodeAt(0)); - } - s.writeU32(0); - s.writeU32(100); - s.writeU32(50); - - s.seekAbsolute(0); - const container = parseCTF([s, new Stream(null, 0), new Stream(null, 0)]); - expect(container.tables).toHaveLength(0); + const s0 = buildMinimalCTFStream0([{ tag: 'VDMX', data: new Uint8Array(50) }]); + const container = parseCTF([s0, new Stream(null, 0), new Stream(null, 0)]); + expect(container.tables.find((t) => t.tag === 'VDMX')).toBeUndefined(); }); // ----------------------------------------------------------------------- @@ -186,7 +192,7 @@ describe('parseCTF', () => { // ----------------------------------------------------------------------- // Empty container // ----------------------------------------------------------------------- - it('handles a container with zero tables', () => { + it('rejects a container with zero tables (no required tables present)', () => { const s = new Stream(null, 0); s.writeU32(0x00010000); s.writeU16(0); // 0 tables @@ -195,8 +201,11 @@ describe('parseCTF', () => { s.writeU16(0); s.seekAbsolute(0); - const container = parseCTF([s, new Stream(null, 0), new Stream(null, 0)]); - expect(container.tables).toHaveLength(0); + // A font with no tables is missing maxp/head/hmtx — parseCTF must reject + // it rather than return an empty container. + expect(() => parseCTF([s, new Stream(null, 0), new Stream(null, 0)])).toThrow( + /missing a maxp table/, + ); }); // ----------------------------------------------------------------------- @@ -211,10 +220,60 @@ describe('parseCTF', () => { const s0 = buildMinimalCTFStream0([{ tag: 'cmap', data }]); const container = parseCTF([s0, new Stream(null, 0), new Stream(null, 0)]); - const cmap = container.tables[0]; + const cmap = container.tables.find((t) => t.tag === 'cmap')!; + expect(cmap).toBeDefined(); expect(cmap.bufSize).toBe(256); for (let i = 0; i < 256; i++) { expect(cmap.buf[i]).toBe(i); } }); + + // ----------------------------------------------------------------------- + // Structural validity guards (ported from libeot parseCTF.c) + // ----------------------------------------------------------------------- + const empty = () => new Stream(null, 0); + + it('throws EotError NoMaxpTable when maxp is absent', () => { + // head + hmtx present, maxp deliberately omitted (raw builder — no injection). + const s0 = buildRawCTFStream0([ + { tag: 'head', data: minimalHead() }, + { tag: 'hmtx', data: new Uint8Array([0, 0, 0, 0]) }, + ]); + try { + parseCTF([s0, empty(), empty()]); + expect.fail('expected parseCTF to throw for missing maxp'); + } catch (e) { + expect(e).toBeInstanceOf(EotError); + expect((e as EotError).code).toBe(EotErrorCode.NoMaxpTable); + } + }); + + it('throws EotError NoHmtxTable when hmtx is absent', () => { + const s0 = buildRawCTFStream0([ + { tag: 'head', data: minimalHead() }, + { tag: 'maxp', data: minimalMaxp() }, + ]); + try { + parseCTF([s0, empty(), empty()]); + expect.fail('expected parseCTF to throw for missing hmtx'); + } catch (e) { + expect(e).toBeInstanceOf(EotError); + expect((e as EotError).code).toBe(EotErrorCode.NoHmtxTable); + } + }); + + it('throws EotError MalformedHeadTable for a head table shorter than 12 bytes', () => { + const s0 = buildRawCTFStream0([ + { tag: 'head', data: new Uint8Array(8) }, + { tag: 'maxp', data: minimalMaxp() }, + { tag: 'hmtx', data: new Uint8Array([0, 0, 0, 0]) }, + ]); + try { + parseCTF([s0, empty(), empty()]); + expect.fail('expected parseCTF to throw for a short head table'); + } catch (e) { + expect(e).toBeInstanceOf(EotError); + expect((e as EotError).code).toBe(EotErrorCode.MalformedHeadTable); + } + }); }); diff --git a/src/ctf-parser.ts b/src/ctf-parser.ts index 22b6f4d..8cbbbb4 100644 --- a/src/ctf-parser.ts +++ b/src/ctf-parser.ts @@ -7,6 +7,7 @@ * @see http://www.w3.org/Submission/MTX/ */ +import { EotError, EotErrorCode } from './errors'; import { Stream } from './stream'; import { TRIPLET_ENCODINGS } from './triplet-encodings'; @@ -157,12 +158,13 @@ function read255Short(s: Stream): number { function unpackCVT(table: SFNTTable, sIn: Stream): void { sIn.seekAbsolute(table.offset); - // First U16 is the table length in bytes (each entry = 2 bytes) - const tableLength = sIn.readU16(); - const numEntries = tableLength >>> 1; // each entry is 2 bytes + // First U16 is the number of CVT entries (each decodes to one 2-byte S16), + // per the MTX spec §5.2 ("USHORT numEntries — Number of cvt entries") and + // libeot's unpackCVT, which reserves numEntries * sizeof(int16_t) bytes. + const numEntries = sIn.readU16(); const out = new Stream(null, 0); - out.reserve(tableLength); + out.reserve(numEntries * 2); let lastValue = 0; @@ -271,9 +273,18 @@ function decodePushInstructions(sIn: Stream, sOut: Stream, pushCount: number): v // Peek at next byte to check for hop codes const code = sIn.peekU8(); - if (code === 0xfb && remaining >= 3 && data.length >= 2) { + if (code === 0xfb) { // hop3: A B 0xFB C → A B A C A - // A is data[dataIndex - 2] (the value 2 back in decoded output) + // A is data[dataIndex - 2] (the value 2 back in decoded output). + // 0xFB is unambiguously a hop marker here; if the surrounding data + // can't satisfy the expansion the stream is corrupt (libeot returns + // EOT_CORRUPT_HOPCODE_DATA rather than treating 0xFB as a literal). + if (remaining < 3 || data.length < 2) { + throw new EotError( + EotErrorCode.CorruptHopcodeData, + `corrupt hop3 (0xFB) push data: remaining=${remaining}, decoded=${data.length}`, + ); + } sIn.readU8(); // consume the 0xFB const prev = data[data.length - 2]; put(prev); @@ -281,8 +292,14 @@ function decodePushInstructions(sIn: Stream, sOut: Stream, pushCount: number): v put(val); put(prev); remaining -= 3; - } else if (code === 0xfc && remaining >= 5 && data.length >= 2) { + } else if (code === 0xfc) { // hop4: A B 0xFC C D → A B A C A D A + if (remaining < 5 || data.length < 2) { + throw new EotError( + EotErrorCode.CorruptHopcodeData, + `corrupt hop4 (0xFC) push data: remaining=${remaining}, decoded=${data.length}`, + ); + } sIn.readU8(); // consume the 0xFC const prev = data[data.length - 2]; put(prev); @@ -867,7 +884,7 @@ export function parseCTF(streams: Stream[]): SFNTContainer { let locaIdx = -1; let maxpIdx = -1; let headIdx = -1; - let _hmtxIdx = -1; + let hmtxIdx = -1; let _cvtIdx = -1; for (let i = 0; i < numTables; i++) { @@ -906,7 +923,7 @@ export function parseCTF(streams: Stream[]): SFNTContainer { } else if (tag === 'head') { headIdx = idx; } else if (tag === 'hmtx') { - _hmtxIdx = idx; + hmtxIdx = idx; } else if (tag === 'cvt ') { _cvtIdx = idx; } @@ -935,8 +952,17 @@ export function parseCTF(streams: Stream[]): SFNTContainer { } table.buf = buf; - // For the `head` table, zero out bytes 8–11 (checksumAdjustment) + // For the `head` table, zero out bytes 8–11 (checksumAdjustment). + // Guard the length first: JS silently ignores out-of-range typed-array + // writes, so without this a truncated head would be accepted silently + // (libeot returns EOT_MALFORMED_HEAD_TABLE for bufSize < 12). if (table.tag === 'head') { + if (table.bufSize < 12) { + throw new EotError( + EotErrorCode.MalformedHeadTable, + `head table too small: ${table.bufSize} bytes (need at least 12)`, + ); + } table.buf[8] = 0; table.buf[9] = 0; table.buf[10] = 0; @@ -944,23 +970,25 @@ export function parseCTF(streams: Stream[]): SFNTContainer { } } - // --- Parse head and maxp for glyph decoding parameters ----------------- - let headData: HeadData = { indexToLocFormat: 0 }; - if (headIdx >= 0) { - headData = parseHead(tables[headIdx]); + // --- Require the structural tables ------------------------------------- + // libeot (parseCTF.c:782-790) rejects a font missing maxp/head/hmtx rather + // than proceeding with fabricated defaults. Substituting zero-defaults here + // would decode a structurally valid but empty/garbage font instead of + // surfacing the corruption. + if (maxpIdx < 0) { + throw new EotError(EotErrorCode.NoMaxpTable, 'CTF font is missing a maxp table'); } - - let maxpData: MaxpData = { - numGlyphs: 0, - maxPoints: 0, - maxContours: 0, - maxSizeOfInstructions: 0, - maxComponentElements: 0, - }; - if (maxpIdx >= 0) { - maxpData = parseMaxp(tables[maxpIdx]); + if (headIdx < 0) { + throw new EotError(EotErrorCode.NoHeadTable, 'CTF font is missing a head table'); + } + if (hmtxIdx < 0) { + throw new EotError(EotErrorCode.NoHmtxTable, 'CTF font is missing an hmtx table'); } + // --- Parse head and maxp for glyph decoding parameters ----------------- + const headData: HeadData = parseHead(tables[headIdx]); + const maxpData: MaxpData = parseMaxp(tables[maxpIdx]); + // --- Decode glyf and build loca ---------------------------------------- if (glyfIdx >= 0) { // Add a loca table if one was not present in the directory diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..531f076 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,49 @@ +/** + * Structured error type for the MTX/EOT decoder. + * + * Mirrors libeot's `enum EOTError` (MPL 2.0 — inc/libeot/EOTError.h) so that + * failures are machine-discriminable via a stable `code` rather than only a + * human-readable message. Codes at or above {@link EOT_WARN} denote recoverable + * warnings ("the font is usable, but…") as opposed to fatal errors. + */ + +/** Threshold at or above which a code is a non-fatal warning. */ +export const EOT_WARN = 1000; + +/** Discriminable error/warning codes, mirroring libeot's `enum EOTError`. */ +export enum EotErrorCode { + InsufficientBytes = 'INSUFFICIENT_BYTES', + HeaderTooBig = 'HEADER_TOO_BIG', + BogusStringSize = 'BOGUS_STRING_SIZE', + CorruptFile = 'CORRUPT_FILE', + LogicError = 'LOGIC_ERROR', + NoMaxpTable = 'NO_MAXP_TABLE', + NoHeadTable = 'NO_HEAD_TABLE', + NoHmtxTable = 'NO_HMTX_TABLE', + CorruptHopcodeData = 'CORRUPT_HOPCODE_DATA', + MalformedHeadTable = 'MALFORMED_HEAD_TABLE', + MtxError = 'MTX_ERROR', + /** Recoverable: the coded version was wrong but a retry succeeded. */ + WarnBadVersion = 'WARN_BAD_VERSION', +} + +/** Numeric tier per code — warnings sort at/above {@link EOT_WARN}. */ +const WARNING_CODES = new Set([EotErrorCode.WarnBadVersion]); + +/** An error (or warning) raised while decoding MTX/EOT font data. */ +export class EotError extends Error { + readonly code: EotErrorCode; + + constructor(code: EotErrorCode, message: string) { + super(message); + this.name = 'EotError'; + this.code = code; + // Preserve the prototype chain when targeting ES5-ish transpiles. + Object.setPrototypeOf(this, EotError.prototype); + } + + /** True when this represents a recoverable warning rather than a fatal error. */ + get isWarning(): boolean { + return WARNING_CODES.has(this.code); + } +} diff --git a/src/index.ts b/src/index.ts index c81b77d..a387d59 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,3 +12,4 @@ export { decompressMtx, decompressEotFont, unpackMtx } from './mtx-decompress'; export type { SFNTContainer, SFNTTable } from './ctf-parser'; +export { EotError, EotErrorCode, EOT_WARN } from './errors'; diff --git a/src/integration.test.ts b/src/integration.test.ts index dc73c65..c48ba37 100644 --- a/src/integration.test.ts +++ b/src/integration.test.ts @@ -203,8 +203,15 @@ interface CTFStreamOptions { * table data + glyph data at the glyf offset). */ function buildCTFStream0(opts: CTFStreamOptions): Uint8Array { + // parseCTF requires head, maxp, and hmtx to be present (it errors otherwise). + // hmtx is loaded raw and only presence-checked, so a minimal record suffices; + // inject one when a caller didn't supply it so fixtures stay concise. + const inputTables = opts.tables.some((t) => t.tag === 'hmtx') + ? opts.tables + : [...opts.tables, { tag: 'hmtx', data: new Uint8Array([0, 0, 0, 0]) }]; + // We need head, maxp, glyf + any extra tables - const allTags = opts.tables.map((t) => t.tag).concat(['glyf']); + const allTags = inputTables.map((t) => t.tag).concat(['glyf']); const numTables = allTags.length; const headerSize = 12; @@ -220,7 +227,7 @@ function buildCTFStream0(opts: CTFStreamOptions): Uint8Array { data: Uint8Array | null; }> = []; - for (const t of opts.tables) { + for (const t of inputTables) { tableLayouts.push({ tag: t.tag, offset, size: t.data.length, data: t.data }); offset += t.data.length; } @@ -524,8 +531,8 @@ describe('cTF integration — simple glyph (triangle)', () => { it('output font has correct number of tables', () => { const { ttfOutput } = runCTFPipeline(stream0); const numTables = (ttfOutput[4] << 8) | ttfOutput[5]; - // head, maxp, glyf, loca = 4 tables - expect(numTables).toBe(4); + // head, maxp, hmtx (injected by the fixture builder), glyf, loca = 5 + expect(numTables).toBe(5); }); it('head.checksumAdjustment is patched in the output', () => { @@ -675,7 +682,7 @@ describe('cTF integration — multiple glyphs', () => { it('produces valid TrueType with correct table count', () => { const { ttfOutput } = runCTFPipeline(stream0); const numTables = (ttfOutput[4] << 8) | ttfOutput[5]; - expect(numTables).toBe(4); // head, maxp, glyf, loca + expect(numTables).toBe(5); // head, maxp, hmtx, glyf, loca }); }); @@ -871,9 +878,9 @@ describe('cTF integration — CVT table delta decoding', () => { const cvtEncoded = new Stream(null, 0); cvtEncoded.reserve(64); - // CVT format: U16 table length (in bytes), then delta-encoded entries - // 3 entries × 2 bytes = 6 bytes - cvtEncoded.writeU16(6); + // CVT format: U16 numEntries (count, not bytes), then delta-encoded + // entries. Each entry decodes to one big-endian S16. + cvtEncoded.writeU16(3); // Entry 0: literal 100 → lastValue = 100 cvtEncoded.writeU8(100); @@ -904,6 +911,8 @@ describe('cTF integration — CVT table delta decoding', () => { const { container } = runCTFPipeline(stream0); const cvt = container.tables.find((t) => t.tag === 'cvt ')!; expect(cvt).toBeDefined(); + // 3 entries × 2 bytes each — a regression guard against the historical + // bug where numEntries was misread as a byte count, halving the table. expect(cvt.bufSize).toBe(6); // Read the decoded CVT values (big-endian S16) diff --git a/src/lzcomp.ts b/src/lzcomp.ts index 3b14e58..76966ef 100644 --- a/src/lzcomp.ts +++ b/src/lzcomp.ts @@ -37,11 +37,22 @@ const LEN_MIN = 2; /** Minimum match distance returned by decodeDistance. */ const DIST_MIN = 1; -/** Hard cap on the declared LZCOMP output length to bound allocations. */ -const MAX_OUT_LEN = 4 * 1024 * 1024; // 4 MiB +/** + * Hard cap on the declared LZCOMP output length. The `out_len` header field is + * 24 bits wide (see `readValue(24)` below), so 2^24 - 1 is the largest value an + * encoder can legally emit; anything above that is corrupt input, not a large + * font. Capping lower would reject legitimate fonts (e.g. large CJK glyph + * streams that exceed a few MiB uncompressed). + */ +const MAX_OUT_LEN = (1 << 24) - 1; // 16 MiB - 1 (24-bit field maximum) -/** Hard cap on RLE-expanded output buffer growth. */ -const MAX_OUT = 16 * 1024 * 1024; // 16 MiB +/** + * Hard cap on RLE-expanded output growth. RLE can legally expand up to 255:1 + * per escape triple, so the expanded stream can exceed the declared `out_len`. + * Bound it a small multiple above the 24-bit maximum to stop a hostile stream + * from growing the buffer without limit while still admitting real fonts. + */ +const MAX_OUT = 64 * 1024 * 1024; // 64 MiB // --------------------------------------------------------------------------- // RLE state constants From 74f8d077fc0eb73a2c3be4c04d2fb3ca9f53bcdb Mon Sep 17 00:00:00 2001 From: Christopher van Rooyen Date: Sat, 18 Jul 2026 06:45:27 +1000 Subject: [PATCH 2/3] feat: port EOT container parsing (eot.ts) + wire demo to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports libeot's EOT.c (~310 lines), the one significant module the original port skipped — the library previously began one step after libeot's entry point, forcing callers to guess where the font data starts and whether it is compressed/encrypted. New public API (exported from index.ts): - parseEotMetadata(bytes): full little-endian EOT header parse, including the 0x504C magic check, UTF-16LE name strings, version 2 root string and version 3 EUDC trailer, and the version-retry loop that copes with files whose declared version disagrees with their layout (sets metadata.badVersion instead of failing, mirroring EOT_WARN_BAD_VERSION). - eotToTtf(bytes): parse -> slice font data -> decompressEotFont; the drop-in equivalent of libeot's EOT2ttf_* entry points. - canLegallyEdit(metadata): fsType embedding-permission check. The demo now calls parseEotMetadata instead of its own tail-guessing heuristic (which never validated the magic number), and reports the font name/version. The header bounds check uses `>` where libeot's EOT_ENSURE_SCANNER macro used `>=`; the latter is an off-by-one that rejects the exact-fit case, and libeot's own string/array helpers already use `>`. Co-Authored-By: Claude Opus 4.8 (1M context) --- demo/main.ts | 95 ++--------- src/eot.test.ts | 241 +++++++++++++++++++++++++++ src/eot.ts | 435 ++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 9 + 4 files changed, 701 insertions(+), 79 deletions(-) create mode 100644 src/eot.test.ts create mode 100644 src/eot.ts diff --git a/demo/main.ts b/demo/main.ts index 219bd2e..96c9435 100644 --- a/demo/main.ts +++ b/demo/main.ts @@ -11,77 +11,11 @@ * demo always tracks the real, current public API. */ -import { decompressMtx } from '../src/index'; +import { decompressMtx, parseEotMetadata, type EotMetadata } from '../src/index'; -// --------------------------------------------------------------------------- -// EOT container parsing (demo-side helper) -// --------------------------------------------------------------------------- -// -// IMPORTANT: the `mtx-decompressor` library operates on the *MTX font blob* -// that lives inside an EOT container — it does not parse the EOT header -// itself. To make this demo accept a real `.eot` file, we parse the minimal -// EOT header here (in the demo, not the library) to locate the embedded font -// bytes and read the compression/encryption flags. -// -// EOT layout (little-endian, see the W3C EOT submission / Microsoft spec): -// offset 0 : EOTSize U32 total file size in bytes -// offset 4 : FontDataSize U32 size of the embedded font data block -// offset 8 : Version U32 -// offset 12: Flags U32 (TTEMBED_* bit flags) -// ... variable-length metadata fields (family name, etc.) -// end : FontData FontDataSize bytes at (EOTSize - FontDataSize) - -/** Flag bit: the embedded font data is MTX-compressed. */ -const TTEMBED_TTCOMPRESSED = 0x00000004; -/** Flag bit: the embedded font data is XOR-obfuscated (key 0x50). */ -const TTEMBED_XORENCRYPTDATA = 0x10000000; - -interface EotFont { - /** Raw MTX font blob extracted from the tail of the EOT file. */ - fontData: Uint8Array; - /** Whether the blob is MTX-compressed (per EOT flags). */ - compressed: boolean; - /** Whether the blob is XOR-encrypted (per EOT flags). */ - encrypted: boolean; -} - -/** - * Parse an EOT container and return the embedded font blob plus its - * compression/encryption flags. Throws with a readable message on malformed - * input. - */ -function parseEot(buffer: ArrayBuffer): EotFont { - if (buffer.byteLength < 16) { - throw new Error('File too small to be a valid EOT container (need at least 16 bytes).'); - } - - const view = new DataView(buffer); - const eotSize = view.getUint32(0, true); - const fontDataSize = view.getUint32(4, true); - const flags = view.getUint32(12, true); - - if (fontDataSize === 0 || fontDataSize > buffer.byteLength) { - throw new Error( - `EOT FontDataSize (${fontDataSize}) is out of range for a ${buffer.byteLength}-byte file. This may not be an EOT file.`, - ); - } - - // The font data sits at the tail of the file. Prefer EOTSize when it is - // consistent with the actual byte length; otherwise fall back to the real - // length so we still locate the trailing blob. - const total = eotSize === buffer.byteLength ? eotSize : buffer.byteLength; - const start = total - fontDataSize; - if (start < 16) { - throw new Error('EOT font-data offset overlaps the header — file appears malformed.'); - } - - const fontData = new Uint8Array(buffer, start, fontDataSize); - return { - fontData, - compressed: (flags & TTEMBED_TTCOMPRESSED) !== 0, - encrypted: (flags & TTEMBED_XORENCRYPTDATA) !== 0, - }; -} +// The library now parses the EOT container itself (see `parseEotMetadata`), +// including the version-retry logic real-world files need — so the demo no +// longer carries its own tail-guessing heuristic. // --------------------------------------------------------------------------- // sfnt / version sniffing for the output font @@ -205,23 +139,24 @@ async function handleFile(file: File): Promise { return; } - // 1. Locate the embedded font blob inside the EOT container. - let eot: EotFont; + // 1. Parse the EOT container header to locate the font blob and its flags. + let meta: EotMetadata; try { - eot = parseEot(buffer); + meta = parseEotMetadata(new Uint8Array(buffer)); } catch (err) { setStatus((err as Error).message, 'error'); return; } + const fontData = new Uint8Array(buffer, meta.fontDataOffset, meta.fontDataSize); // 2. Decompress with the library, timing the call. let ttf: Uint8Array; let elapsedMs: number; try { const t0 = performance.now(); - ttf = decompressMtx(eot.fontData, { - compressed: eot.compressed, - encrypted: eot.encrypted, + ttf = decompressMtx(fontData, { + compressed: meta.compressed, + encrypted: meta.encrypted, }); elapsedMs = performance.now() - t0; } catch (err) { @@ -230,13 +165,15 @@ async function handleFile(file: File): Promise { } // 3. Report metrics. - const ratio = ttf.length > 0 ? eot.fontData.length / ttf.length : 0; + const ratio = ttf.length > 0 ? fontData.length / ttf.length : 0; + const fontLabel = meta.fullName || meta.familyName || 'n/a'; showMetrics([ ['Source file', `${file.name} (${formatBytes(buffer.byteLength)})`], - ['MTX blob (input)', formatBytes(eot.fontData.length)], + ['Font name', `${fontLabel} (EOT v${meta.version}${meta.badVersion ? ', corrected' : ''})`], + ['MTX blob (input)', formatBytes(fontData.length)], ['TrueType (output)', formatBytes(ttf.length)], ['Compression ratio', `${(ratio * 100).toFixed(1)}% of output`], - ['EOT flags', `compressed=${eot.compressed}, encrypted=${eot.encrypted}`], + ['EOT flags', `compressed=${meta.compressed}, encrypted=${meta.encrypted}`], ['sfnt version', describeSfntVersion(ttf) ?? 'n/a'], ['Glyph count', readNumGlyphs(ttf)?.toString() ?? 'n/a'], ['Decompress time', `${elapsedMs.toFixed(2)} ms`], diff --git a/src/eot.test.ts b/src/eot.test.ts new file mode 100644 index 0000000..5f3c9ff --- /dev/null +++ b/src/eot.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect } from 'vitest'; + +import { + parseEotMetadata, + eotToTtf, + canLegallyEdit, + TTEMBED_TTCOMPRESSED, + TTEMBED_XORENCRYPTDATA, + type EotVersion, +} from './eot'; +import { EotError, EotErrorCode } from './errors'; + +// --------------------------------------------------------------------------- +// EOT fixture builder — writes a little-endian EOT header + trailing font data. +// --------------------------------------------------------------------------- + +interface EotFixture { + version?: EotVersion; + flags?: number; + permissions?: number; + familyName?: string; + styleName?: string; + versionName?: string; + fullName?: string; + rootString?: string; + fontData?: Uint8Array; + /** Override the version magic in the file (to test bad-version handling). */ + versionMagicOverride?: number; +} + +const VERSION_MAGIC: Record = { + 1: 0x00010000, + 2: 0x00020001, + 3: 0x00020002, +}; + +/** Grow-able little-endian writer. */ +class LE { + private bytes: number[] = []; + u8(v: number): void { + this.bytes.push(v & 0xff); + } + u16(v: number): void { + this.u8(v); + this.u8(v >>> 8); + } + u32(v: number): void { + this.u16(v & 0xffff); + this.u16(v >>> 16); + } + raw(a: Uint8Array | number[]): void { + for (const b of a) this.u8(b); + } + /** Length-prefixed (U16LE byte count) UTF-16LE string. */ + str(s: string): void { + this.u16(s.length * 2); + for (let i = 0; i < s.length; i++) this.u16(s.charCodeAt(i)); + } + get length(): number { + return this.bytes.length; + } + toUint8Array(): Uint8Array { + return new Uint8Array(this.bytes); + } +} + +function buildEot(fix: EotFixture = {}): Uint8Array { + const version = fix.version ?? 1; + const fontData = fix.fontData ?? new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + const flags = fix.flags ?? 0; + const permissions = fix.permissions ?? 0; + + // Build the header body (everything after the fixed 12-byte prologue). + const body = new LE(); + body.u32(flags); + body.raw(new Uint8Array(10)); // PANOSE + body.u8(0); // charset + body.u8(0); // italic + body.u32(400); // weight + body.u16(permissions); + body.u16(0x504c); // magic "LP" + for (let i = 0; i < 4; i++) body.u32(0); // unicodeRange + for (let i = 0; i < 2; i++) body.u32(0); // codePageRange + body.u32(0); // checkSumAdjustment + body.raw(new Uint8Array(16)); // Reserved1..4 + body.u16(0); // Padding1 + body.str(fix.familyName ?? 'Fam'); + body.u16(0); // Padding2 + body.str(fix.styleName ?? 'Reg'); + body.u16(0); // Padding3 + body.str(fix.versionName ?? 'v1'); + body.u16(0); // Padding4 + body.str(fix.fullName ?? 'Fam Reg'); + if (version > 1) { + body.u16(0); // Padding5 + body.str(fix.rootString ?? ''); + if (version === 3) { + body.u32(0); // root string checksum + body.u32(0); // EUDC code page + body.u16(0); // Padding6 + body.u16(0); // signature size (0) + body.u32(0); // EUDC flags + body.u32(0); // EUDC font data size (0) + } + } + + const bodyBytes = body.toUint8Array(); + const fontDataSize = fontData.length; + const totalSize = 12 + bodyBytes.length + fontDataSize; + + const out = new LE(); + out.u32(totalSize); + out.u32(fontDataSize); + out.u32(fix.versionMagicOverride ?? VERSION_MAGIC[version]); + out.raw(bodyBytes); + out.raw(fontData); + return out.toUint8Array(); +} + +describe('parseEotMetadata', () => { + it('parses a version 1 header and locates the font data', () => { + const fontData = new Uint8Array([1, 2, 3, 4, 5]); + const eot = buildEot({ version: 1, fontData, familyName: 'Helvetica' }); + const meta = parseEotMetadata(eot); + + expect(meta.version).toBe(1); + expect(meta.familyName).toBe('Helvetica'); + expect(meta.fontDataSize).toBe(5); + expect(meta.badVersion).toBe(false); + // The located font data must match what we appended. + expect(eot.subarray(meta.fontDataOffset, meta.fontDataOffset + meta.fontDataSize)).toStrictEqual( + fontData, + ); + }); + + it('decodes the compressed/encrypted flags', () => { + const eot = buildEot({ flags: TTEMBED_TTCOMPRESSED | TTEMBED_XORENCRYPTDATA }); + const meta = parseEotMetadata(eot); + expect(meta.compressed).toBe(true); + expect(meta.encrypted).toBe(true); + }); + + it('treats absent compression/encryption flags as false', () => { + const meta = parseEotMetadata(buildEot({ flags: 0 })); + expect(meta.compressed).toBe(false); + expect(meta.encrypted).toBe(false); + }); + + it('parses version 2 with a root string', () => { + const eot = buildEot({ version: 2, rootString: 'ROOT', familyName: 'Arial' }); + const meta = parseEotMetadata(eot); + expect(meta.version).toBe(2); + expect(meta.rootString).toBe('ROOT'); + expect(meta.familyName).toBe('Arial'); + }); + + it('parses version 3 including the EUDC trailer', () => { + const fontData = new Uint8Array([9, 8, 7]); + const eot = buildEot({ version: 3, fontData, fullName: 'Times New Roman' }); + const meta = parseEotMetadata(eot); + expect(meta.version).toBe(3); + expect(meta.fullName).toBe('Times New Roman'); + expect(eot.subarray(meta.fontDataOffset, meta.fontDataOffset + meta.fontDataSize)).toStrictEqual( + fontData, + ); + }); + + it('flags a bad version and still parses when the magic disagrees with the layout', () => { + // A version-2 layout mislabeled with the version-1 magic. The retry loop + // should bump the version up and succeed with badVersion set. + const eot = buildEot({ version: 2, rootString: 'X', versionMagicOverride: VERSION_MAGIC[1] }); + const meta = parseEotMetadata(eot); + expect(meta.badVersion).toBe(true); + expect(meta.rootString).toBe('X'); + }); + + it('throws CorruptFile on a bad magic number', () => { + const eot = buildEot(); + // Corrupt the 0x504C magic (little-endian at body offset: prologue 12 + + // flags 4 + panose 10 + charset 1 + italic 1 + weight 4 + permissions 2 = 34). + eot[34] = 0x00; + eot[35] = 0x00; + try { + parseEotMetadata(eot); + expect.fail('expected a CorruptFile error'); + } catch (e) { + expect(e).toBeInstanceOf(EotError); + expect((e as EotError).code).toBe(EotErrorCode.CorruptFile); + } + }); + + it('throws CorruptFile on an unknown version magic', () => { + const eot = buildEot({ versionMagicOverride: 0xdeadbeef }); + try { + parseEotMetadata(eot); + expect.fail('expected a CorruptFile error'); + } catch (e) { + expect((e as EotError).code).toBe(EotErrorCode.CorruptFile); + } + }); + + it('throws InsufficientBytes on a truncated file', () => { + try { + parseEotMetadata(new Uint8Array(4)); + expect.fail('expected an InsufficientBytes error'); + } catch (e) { + expect((e as EotError).code).toBe(EotErrorCode.InsufficientBytes); + } + }); +}); + +describe('eotToTtf', () => { + it('returns the raw font data when neither compressed nor encrypted', () => { + const fontData = new Uint8Array([0x11, 0x22, 0x33, 0x44]); + const eot = buildEot({ flags: 0, fontData }); + expect(eotToTtf(eot)).toStrictEqual(fontData); + }); + + it('XOR-decrypts uncompressed data with key 0x50', () => { + const plain = new Uint8Array([0x00, 0x50, 0xff, 0xa5]); + const encrypted = plain.map((b) => b ^ 0x50); + const eot = buildEot({ flags: TTEMBED_XORENCRYPTDATA, fontData: encrypted }); + expect(eotToTtf(eot)).toStrictEqual(plain); + }); +}); + +describe('canLegallyEdit', () => { + const base = parseEotMetadata(buildEot()); + + it('allows editing when permissions are 0 (installable)', () => { + expect(canLegallyEdit({ ...base, permissions: 0 })).toBe(true); + }); + + it('allows editing when the editable-embedding bit is set', () => { + expect(canLegallyEdit({ ...base, permissions: 0x0008 })).toBe(true); + }); + + it('forbids editing for restricted-license fonts', () => { + expect(canLegallyEdit({ ...base, permissions: 0x0002 })).toBe(false); + }); +}); diff --git a/src/eot.ts b/src/eot.ts new file mode 100644 index 0000000..04c9cf1 --- /dev/null +++ b/src/eot.ts @@ -0,0 +1,435 @@ +/** + * EOT (Embedded OpenType) container parsing. + * + * Ported from libeot (MPL 2.0) — src/EOT.c. Parses the little-endian EOT + * header that wraps MTX-compressed font data, derives where the font data + * begins, and exposes the compression/encryption flags so callers no longer + * have to guess them. `eotToTtf` chains this into {@link decompressEotFont} to + * turn a raw `.eot` file straight into a TrueType binary. + * + * Note: every field in the EOT header is LITTLE-endian, unlike the big-endian + * CTF/SFNT streams handled elsewhere in this library. + * + * @see http://www.w3.org/Submission/EOT/ + */ + +import { decompressEotFont } from './mtx-decompress'; +import { EotError, EotErrorCode } from './errors'; + +// --------------------------------------------------------------------------- +// EOT header flags (flags.h) +// --------------------------------------------------------------------------- + +/** The font is a subset of the original. */ +export const TTEMBED_SUBSET = 0x00000001; +/** The font data is MTX-compressed. */ +export const TTEMBED_TTCOMPRESSED = 0x00000004; +/** The font data is XOR-obfuscated with key 0x50. */ +export const TTEMBED_XORENCRYPTDATA = 0x10000000; + +/** Magic number appended after the code-page range fields (`"LP"` little-endian). */ +const EOT_MAGIC = 0x504c; + +/** fsType editing-permission mask (see {@link canLegallyEdit}). */ +const EDITING_MASK = 0x0008; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** EOT header version. */ +export type EotVersion = 1 | 2 | 3; + +/** Parsed EOT container metadata. */ +export interface EotMetadata { + /** EOT header version (1, 2, or 3). */ + version: EotVersion; + /** Raw `Flags` field (see the `TTEMBED_*` constants). */ + flags: number; + /** 10-byte PANOSE classification. */ + panose: Uint8Array; + /** `Charset` byte. */ + charset: number; + /** Whether the font is italic. */ + italic: boolean; + /** Weight (100–900). */ + weight: number; + /** `fsType` embedding permissions (see {@link canLegallyEdit}). */ + permissions: number; + /** The four `UnicodeRange` bitmask words. */ + unicodeRange: [number, number, number, number]; + /** The two `CodePageRange` bitmask words. */ + codePageRange: [number, number]; + /** `head.checkSumAdjustment` copied from the original font. */ + checkSumAdjustment: number; + /** Font family name (decoded from UTF-16LE). */ + familyName: string; + /** Subfamily / style name. */ + styleName: string; + /** Version name string. */ + versionName: string; + /** Full font name. */ + fullName: string; + /** Root string (version 2+), else an empty string. */ + rootString: string; + /** Declared total size of the EOT file, in bytes. */ + totalSize: number; + /** Size of the embedded font data, in bytes. */ + fontDataSize: number; + /** Absolute offset at which the font data begins. */ + fontDataOffset: number; + /** True when the font data is MTX-compressed (`flags & TTEMBED_TTCOMPRESSED`). */ + compressed: boolean; + /** True when the font data is XOR-encrypted (`flags & TTEMBED_XORENCRYPTDATA`). */ + encrypted: boolean; + /** + * True when the version magic in the file disagreed with the version that + * actually parsed cleanly. The font is still usable (libeot returns + * `EOT_WARN_BAD_VERSION` in this case), but the header was inconsistent. + */ + badVersion: boolean; +} + +// --------------------------------------------------------------------------- +// Little-endian primitive reads (bounds-checked) +// --------------------------------------------------------------------------- + +function readU16LE(bytes: Uint8Array, at: number): number { + return bytes[at] | (bytes[at + 1] << 8); +} + +function readU32LE(bytes: Uint8Array, at: number): number { + return ( + (bytes[at] | + (bytes[at + 1] << 8) | + (bytes[at + 2] << 16) | + (bytes[at + 3] << 24)) >>> + 0 + ); +} + +/** Decode `count` UTF-16LE code units starting at `at` into a JS string. */ +function decodeUtf16LE(bytes: Uint8Array, at: number, byteLength: number): string { + let out = ''; + for (let i = 0; i < byteLength; i += 2) { + out += String.fromCharCode(readU16LE(bytes, at + i)); + } + return out; +} + +// --------------------------------------------------------------------------- +// Scanner — tracks an absolute cursor and enforces the body bound +// --------------------------------------------------------------------------- + +/** + * A forward cursor over the header body. `limit` is the absolute index one past + * the last readable byte of the header (i.e. where the font data begins). The + * bound uses `>` (reading N bytes needs `pos + N <= limit`); libeot's + * `EOT_ENSURE_SCANNER` macro used `>=`, which is an off-by-one that spuriously + * rejects the exact-fit case — the string/array helpers there already use the + * correct `>`, so this matches libeot's intent. + */ +class Scanner { + pos: number; + constructor( + private readonly bytes: Uint8Array, + start: number, + private readonly limit: number, + ) { + this.pos = start; + } + + private ensure(n: number): void { + if (this.pos + n > this.limit) { + throw new EotError( + EotErrorCode.InsufficientBytes, + `EOT header truncated: need ${n} more byte(s) at offset ${this.pos}`, + ); + } + } + + u16(): number { + this.ensure(2); + const v = readU16LE(this.bytes, this.pos); + this.pos += 2; + return v; + } + + u32(): number { + this.ensure(4); + const v = readU32LE(this.bytes, this.pos); + this.pos += 4; + return v; + } + + u8(): number { + this.ensure(1); + return this.bytes[this.pos++]; + } + + take(n: number): Uint8Array { + this.ensure(n); + const slice = this.bytes.subarray(this.pos, this.pos + n); + this.pos += n; + return slice; + } + + skip(n: number): void { + this.ensure(n); + this.pos += n; + } + + /** Read a length-prefixed (U16LE byte count) UTF-16LE string. */ + string(): string { + this.ensure(2); + const size = readU16LE(this.bytes, this.pos); + this.pos += 2; + if (size % 2 !== 0) { + throw new EotError( + EotErrorCode.BogusStringSize, + `EOT string size ${size} is not a multiple of 2 (UTF-16)`, + ); + } + if (size === 0) { + return ''; + } + this.ensure(size); + const s = decodeUtf16LE(this.bytes, this.pos, size); + this.pos += size; + return s; + } + + /** Read a length-prefixed (U32LE byte count) raw byte array. */ + byteArray(): Uint8Array { + this.ensure(4); + const size = readU32LE(this.bytes, this.pos); + this.pos += 4; + if (size === 0) { + return new Uint8Array(0); + } + return this.take(size); + } +} + +// --------------------------------------------------------------------------- +// Header body parsing (per version) +// --------------------------------------------------------------------------- + +/** Sentinel thrown internally to drive the version-retry loop. */ +class HeaderTooBig extends Error {} + +/** + * Parse the version-specific portion of the header. Mirrors + * `EOTfillMetadataSpecifyingVersion`. Throws {@link HeaderTooBig} when the parse + * consumed less than the declared header (a signal to try a higher version), + * {@link EotError} with `InsufficientBytes` when it ran past the end (try lower), + * and `CorruptFile` / `BogusStringSize` as terminal failures. + */ +function parseBody( + bytes: Uint8Array, + version: EotVersion, + totalSize: number, + fontDataSize: number, +): Omit { + const HEADER_START = 12; + // The header body is bounded by where the font data must begin. + const limit = bytes.length - fontDataSize; + const sc = new Scanner(bytes, HEADER_START, limit); + + const flags = sc.u32(); + const panose = sc.take(10).slice(); + const charset = sc.u8(); + const italic = sc.u8() !== 0; + const weight = sc.u32(); + const permissions = sc.u16(); + + if (sc.u16() !== EOT_MAGIC) { + throw new EotError(EotErrorCode.CorruptFile, 'EOT magic number (0x504C) mismatch'); + } + + const unicodeRange: [number, number, number, number] = [sc.u32(), sc.u32(), sc.u32(), sc.u32()]; + const codePageRange: [number, number] = [sc.u32(), sc.u32()]; + + const checkSumAdjustment = sc.u32(); + // Skip Reserved1..4 (16 bytes) + Padding1 (2 bytes) after checkSumAdjustment. + sc.skip(18); + + const familyName = sc.string(); + sc.skip(2); // Padding2 + const styleName = sc.string(); + sc.skip(2); // Padding3 + const versionName = sc.string(); + sc.skip(2); // Padding4 + const fullName = sc.string(); + + let rootString = ''; + if (version > 1) { + sc.skip(2); // Padding5 + rootString = sc.string(); + + if (version === 3) { + sc.u32(); // root string checksum (discarded) + sc.u32(); // EUDC code page + sc.skip(2); // Padding6 + const signatureSize = sc.u16(); + sc.skip(signatureSize); // signature (reserved) + sc.u32(); // EUDC flags + sc.byteArray(); // EUDC font data (unused here) + } + } + + const fontDataOffset = sc.pos; + const expectedHeaderSize = totalSize - fontDataSize; + if (fontDataOffset < expectedHeaderSize) { + // We consumed less than the declared header — the real version is likely + // higher. Signal the retry loop. + throw new HeaderTooBig(); + } + + return { + version, + flags, + panose, + charset, + italic, + weight, + permissions, + unicodeRange, + codePageRange, + checkSumAdjustment, + familyName, + styleName, + versionName, + fullName, + rootString, + totalSize, + fontDataSize, + fontDataOffset, + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +const VERSION_MAGIC: Record = { + 0x00010000: 1, + 0x00020001: 2, + 0x00020002: 3, +}; + +/** + * Parse the metadata of an EOT container. + * + * Reproduces libeot's `EOTfillMetadata`, including the version-retry loop that + * copes with EOT files whose declared version disagrees with their actual + * layout. On a corrected version {@link EotMetadata.badVersion} is set rather + * than throwing (libeot returns the recoverable `EOT_WARN_BAD_VERSION`). + * + * @param bytes Raw `.eot` file bytes. + * @throws {EotError} on a corrupt or truncated container. + */ +export function parseEotMetadata(bytes: Uint8Array): EotMetadata { + if (bytes.length < 8) { + throw new EotError(EotErrorCode.InsufficientBytes, 'EOT file too small (need at least 8 bytes)'); + } + + const totalSize = readU32LE(bytes, 0); + const fontDataSize = readU32LE(bytes, 4); + // EOTgetMetadataLength = totalSize - fontDataSize; the file must be at least + // that long to contain the full header. + const metadataLength = totalSize - fontDataSize; + if (bytes.length < metadataLength) { + throw new EotError( + EotErrorCode.InsufficientBytes, + `EOT file shorter than its declared metadata length (${metadataLength})`, + ); + } + + if (bytes.length < 12) { + throw new EotError(EotErrorCode.InsufficientBytes, 'EOT file too small for a version field'); + } + const versionMagic = readU32LE(bytes, 8); + const codedVersion = VERSION_MAGIC[versionMagic]; + if (codedVersion === undefined) { + throw new EotError( + EotErrorCode.CorruptFile, + `unrecognized EOT version magic 0x${versionMagic.toString(16)}`, + ); + } + + // The font data must fit after the fixed 12-byte prologue. + if (12 + fontDataSize > bytes.length) { + throw new EotError(EotErrorCode.CorruptFile, 'EOT font data extends past end of file'); + } + + let tryVersion: EotVersion = codedVersion; + let bumpedUp = false; + let knockedDown = false; + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const body = parseBody(bytes, tryVersion, totalSize, fontDataSize); + const flags = body.flags; + return { + ...body, + compressed: (flags & TTEMBED_TTCOMPRESSED) !== 0, + encrypted: (flags & TTEMBED_XORENCRYPTDATA) !== 0, + badVersion: tryVersion !== codedVersion, + }; + } catch (err) { + if (err instanceof HeaderTooBig) { + // Under-read: try a higher version. The latches prevent oscillation. + if (knockedDown || tryVersion === 3) { + throw new EotError(EotErrorCode.CorruptFile, 'EOT header inconsistent across all versions'); + } + knockedDown = false; + bumpedUp = true; + tryVersion = (tryVersion + 1) as EotVersion; + continue; + } + if (err instanceof EotError && err.code === EotErrorCode.InsufficientBytes) { + // Over-read: try a lower version. + if (bumpedUp || tryVersion === 1) { + throw new EotError(EotErrorCode.CorruptFile, 'EOT header inconsistent across all versions'); + } + knockedDown = true; + bumpedUp = false; + tryVersion = (tryVersion - 1) as EotVersion; + continue; + } + // CorruptFile / BogusStringSize and anything else are terminal. + throw err; + } + } +} + +/** + * Decode a raw EOT container straight into a TrueType (.ttf) font binary. + * + * Parses the header, locates and slices the embedded font data, and runs it + * through {@link decompressEotFont} using the container's own + * compressed/encrypted flags. This is the drop-in equivalent of libeot's + * `EOT2ttf_*` entry points. + * + * @param bytes Raw `.eot` file bytes. + * @returns The reconstructed TrueType font. + * @throws {EotError} on a corrupt container or during decompression. + */ +export function eotToTtf(bytes: Uint8Array): Uint8Array { + const meta = parseEotMetadata(bytes); + const fontData = bytes.subarray(meta.fontDataOffset, meta.fontDataOffset + meta.fontDataSize); + return decompressEotFont(fontData, meta.compressed, meta.encrypted); +} + +/** + * Whether the font's embedding permissions allow editing. + * + * Mirrors libeot's `EOTcanLegallyEdit`. The upstream author asks that callers + * reflect before circumventing this: installable-permission fonts (`fsType` + * 0) and editable-embedding fonts may be edited; others may not. + */ +export function canLegallyEdit(metadata: EotMetadata): boolean { + return metadata.permissions === 0 || (metadata.permissions & EDITING_MASK) !== 0; +} diff --git a/src/index.ts b/src/index.ts index a387d59..acf7252 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,3 +13,12 @@ export { decompressMtx, decompressEotFont, unpackMtx } from './mtx-decompress'; export type { SFNTContainer, SFNTTable } from './ctf-parser'; export { EotError, EotErrorCode, EOT_WARN } from './errors'; +export { + parseEotMetadata, + eotToTtf, + canLegallyEdit, + TTEMBED_SUBSET, + TTEMBED_TTCOMPRESSED, + TTEMBED_XORENCRYPTDATA, +} from './eot'; +export type { EotMetadata, EotVersion } from './eot'; From 194786f9f9720870bedeb091f71919148badc913 Mon Sep 17 00:00:00 2001 From: Christopher van Rooyen Date: Sat, 18 Jul 2026 06:47:09 +1000 Subject: [PATCH 3/3] docs: document EOT API; chore(release): 1.5.0 README now leads with eotToTtf (whole .eot -> .ttf) and documents parseEotMetadata / canLegallyEdit / EotError. Bump to 1.5.0 for the new public API surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 58 ++++++++++++++++++++++++++++++++++++++++++++-------- package.json | 2 +- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1fa8eff..de0af31 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A zero-dependency TypeScript library that decompresses **MicroType Express (MTX)** compressed font data found inside **EOT** (Embedded OpenType) containers, producing standard **TrueType (.ttf)** font binaries. -MTX is a font compression format developed by Monotype, used inside EOT containers commonly found in older web pages and embedded in Microsoft Office documents. This library extracts the compressed data and reconstructs a standard `.ttf` file usable with standard font APIs (e.g. the `FontFace` API). It has no dependencies and works in both the browser and Node.js. +MTX is a font compression format developed by Monotype, used inside EOT containers commonly found in older web pages and embedded in Microsoft Office documents. This library parses the EOT container, extracts the compressed data, and reconstructs a standard `.ttf` file usable with standard font APIs (e.g. the `FontFace` API). It has no dependencies and works in both the browser and Node.js. **[▶️ Live demo](https://christophervr.github.io/mtx-decompressor/)** · **[📦 npm](https://www.npmjs.com/package/mtx-decompressor)** @@ -26,23 +26,63 @@ npm install mtx-decompressor ## Quick start +The simplest path takes a whole `.eot` file and hands back a `.ttf`: + ```typescript -import { decompressMtx, decompressEotFont } from 'mtx-decompressor'; +import { eotToTtf } from 'mtx-decompressor'; -// Decompress MTX-compressed font data -const fontData: Uint8Array = /* extracted from EOT container */; -const ttfBytes = decompressMtx(fontData, { encrypted: false, compressed: true }); +const eotBytes: Uint8Array = /* the raw bytes of a .eot file */; +const ttfBytes = eotToTtf(eotBytes); // => Uint8Array containing a valid TrueType font +``` + +`eotToTtf` parses the EOT header, locates the embedded font data, and applies +the container's own compression/encryption flags for you. + +If you already have the MTX blob (or want the metadata), the lower-level API is +still available: + +```typescript +import { parseEotMetadata, decompressMtx, decompressEotFont } from 'mtx-decompressor'; + +// Inspect the container and slice out the font data yourself +const meta = parseEotMetadata(eotBytes); +const fontData = eotBytes.subarray(meta.fontDataOffset, meta.fontDataOffset + meta.fontDataSize); +const ttf = decompressMtx(fontData, { compressed: meta.compressed, encrypted: meta.encrypted }); -// Convenience wrapper with explicit boolean parameters -const ttf = decompressEotFont(fontData, /* compressed */ true, /* encrypted */ false); +// Or, with an already-extracted blob and explicit flags: +const ttf2 = decompressEotFont(fontData, /* compressed */ true, /* encrypted */ false); // XOR-obfuscated data const decrypted = decompressMtx(encryptedData, { encrypted: true, compressed: true }); ``` +Errors are thrown as `EotError` with a machine-readable `code` (see +`EotErrorCode`) so corrupt vs. truncated vs. unsupported inputs can be told +apart. + ## API +### `eotToTtf(eotBytes)` + +Parse a raw EOT container and return the reconstructed TrueType binary. Handles +header parsing, font-data extraction, and the container's compression/encryption +flags. Throws `EotError` on a corrupt or truncated container. + +### `parseEotMetadata(eotBytes)` + +Parse just the EOT header. Returns an `EotMetadata` object: `version`, `flags`, +`compressed`, `encrypted`, `familyName` / `styleName` / `versionName` / +`fullName`, `fontDataOffset`, `fontDataSize`, `permissions`, and more. Includes +libeot's version-retry logic for files whose declared version disagrees with +their layout (`metadata.badVersion` is set rather than throwing). Throws +`EotError` on corrupt input. + +### `canLegallyEdit(metadata)` + +Given an `EotMetadata`, returns whether the font's `fsType` embedding +permissions allow editing. + ### `decompressMtx(fontData, options?)` Decompress an MTX-compressed font into a TrueType binary. @@ -62,11 +102,11 @@ Convenience wrapper around `decompressMtx` taking explicit boolean parameters; r Low-level: unpack an MTX blob into three LZCOMP-decompressed streams. Returns `{ streams: Uint8Array[], sizes: number[] }`. -The exported `SFNTContainer` and `SFNTTable` types describe the reconstructed font tables. +The exported `SFNTContainer` and `SFNTTable` types describe the reconstructed font tables. `EotError` / `EotErrorCode` provide machine-discriminable error handling. ## How it works -The pipeline: optional XOR decryption → MTX header parsing (splits into three LZCOMP blocks) → LZCOMP decompression (sliding-window LZ with adaptive Huffman coding) → CTF parsing (reconstructs TrueType tables from the three Compact TrueType Font streams) → SFNT assembly (table directory, alignment, checksums). +The pipeline: EOT container parsing (little-endian header → font-data offset + flags) → optional XOR decryption → MTX header parsing (splits into three LZCOMP blocks) → LZCOMP decompression (sliding-window LZ with adaptive Huffman coding) → CTF parsing (reconstructs TrueType tables from the three Compact TrueType Font streams) → SFNT assembly (table directory, alignment, checksums). ## Provenance diff --git a/package.json b/package.json index d16f46b..fc3fe02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mtx-decompressor", - "version": "1.4.2", + "version": "1.5.0", "description": "MicroType Express (MTX) font decompressor — extracts TTF/OTF from compressed EOT containers.", "homepage": "https://github.com/ChristopherVR/mtx-decompressor", "bugs": {