Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Decompress an MTX-compressed font into a TrueType binary.
| `fontData` | `Uint8Array` | Raw font bytes (MTX-compressed, optionally encrypted) |
| `options.encrypted` | `boolean` (default: `false`) | If `true`, XOR-decrypt with key `0x50` before decompression |
| `options.compressed` | `boolean` (default: `true`) | If `false`, skip decompression and return the (possibly decrypted) data as-is |
| `options.onWarn` | `(message: string) => void` | Optional hook called for each non-fatal diagnostic (e.g. a dropped `hdmx`/`VDMX` table); the font is still produced |
| **Returns** | `Uint8Array` | A valid TrueType (.ttf) font binary |

### `decompressEotFont(fontData, compressed, encrypted)`
Expand Down
17 changes: 16 additions & 1 deletion src/ctf-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,19 +137,34 @@ describe('parseCTF', () => {
{ 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)]);
const warnings: string[] = [];
const container = parseCTF([s0, new Stream(null, 0), new Stream(null, 0)], {
onWarn: (m) => warnings.push(m),
});

// 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]));

// The drop is surfaced structurally and via the onWarn hook.
expect(container.droppedTables).toEqual(['hdmx']);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('hdmx');
});

it('skips VDMX tables', () => {
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();
expect(container.droppedTables).toEqual(['VDMX']);
});

it('omits droppedTables when nothing is dropped', () => {
const s0 = buildMinimalCTFStream0([{ tag: 'name', data: new Uint8Array([0xaa, 0xbb]) }]);
const container = parseCTF([s0, new Stream(null, 0), new Stream(null, 0)]);
expect(container.droppedTables).toBeUndefined();
});

// -----------------------------------------------------------------------
Expand Down
38 changes: 32 additions & 6 deletions src/ctf-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ export interface SFNTTable {
/** Collection of SFNT tables that constitute a font. */
export interface SFNTContainer {
tables: SFNTTable[];
/**
* Tags of tables the parser dropped rather than reconstructing (currently
* `hdmx` / `VDMX`, which MTX does not round-trip). Present for diagnostics;
* mirrors the warning libeot logs when it skips these tables.
*/
droppedTables?: string[];
}

/** Optional hooks for {@link parseCTF}. */
export interface ParseCTFOptions {
/**
* Invoked once per non-fatal diagnostic (e.g. a dropped hdmx/VDMX table).
* Lets callers surface warnings without the library writing to `console`.
*/
onWarn?: (message: string) => void;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -477,9 +492,15 @@ function decodeSimpleGlyph(
xDeltas[i] = dx;
yDeltas[i] = dy;

// Accumulate for bbox calculation
cumulativeX += dx;
cumulativeY += dy;
// Accumulate for bbox calculation using the int16-truncated deltas
// (`xDeltas`/`yDeltas` are Int16Array). This matches libeot, which
// narrows each delta to int16_t before folding it into the running
// bbox — and is the value the glyph actually renders with, since the
// written coordinates come from the same truncated deltas. Differs from
// the raw delta only for the 16-bit triplet encodings with a delta that
// overflows int16, which no valid encoder emits.
cumulativeX += xDeltas[i];
cumulativeY += yDeltas[i];

if (calcBBox) {
if (cumulativeX < minX) {
Expand Down Expand Up @@ -866,8 +887,9 @@ function parseMaxp(table: SFNTTable): MaxpData {
* [2] = hinting code data
* @returns An `SFNTContainer` holding all reconstructed SFNT tables.
*/
export function parseCTF(streams: Stream[]): SFNTContainer {
export function parseCTF(streams: Stream[], options?: ParseCTFOptions): SFNTContainer {
const s0 = streams[0];
const droppedTables: string[] = [];

// --- Read SFNT offset (header) table -----------------------------------
const _scalarType = s0.readU32();
Expand All @@ -891,9 +913,13 @@ export function parseCTF(streams: Stream[]): SFNTContainer {
// Read 4-byte ASCII tag
const tag = s0.readChar() + s0.readChar() + s0.readChar() + s0.readChar();

// Skip "hdmx" and "VDMX" tables entirely (12 bytes: checksum + offset + size)
// Skip "hdmx" and "VDMX" tables entirely (12 bytes: checksum + offset + size).
// MTX does not round-trip these; libeot logs a warning when it drops them,
// so we record the tag and notify any caller-supplied `onWarn` hook.
if (tag === 'hdmx' || tag === 'VDMX') {
s0.seekRelative(12);
droppedTables.push(tag);
options?.onWarn?.(`Ignoring ${tag} table — MTX does not preserve it`);
continue;
}

Expand Down Expand Up @@ -1007,5 +1033,5 @@ export function parseCTF(streams: Stream[]): SFNTContainer {
populateGlyfAndLoca(tables[glyfIdx], tables[locaIdx], headData, maxpData, streams);
}

return { tables };
return droppedTables.length > 0 ? { tables, droppedTables } : { tables };
}
6 changes: 6 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export enum EotErrorCode {
NoHmtxTable = 'NO_HMTX_TABLE',
CorruptHopcodeData = 'CORRUPT_HOPCODE_DATA',
MalformedHeadTable = 'MALFORMED_HEAD_TABLE',
/** A byte-level read/write/seek was attempted while mid-byte (`bitPos != 0`). */
OffByteBoundary = 'OFF_BYTE_BOUNDARY',
/** A write or copy would exceed the stream's reserved capacity. */
OutOfReservedSpace = 'OUT_OF_RESERVED_SPACE',
/** A seek would move past the stream's reserved end. */
SeekPastEos = 'SEEK_PAST_EOS',
MtxError = 'MTX_ERROR',
/** Recoverable: the coded version was wrong but a retry succeeded. */
WarnBadVersion = 'WARN_BAD_VERSION',
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
*/

export { decompressMtx, decompressEotFont, unpackMtx } from './mtx-decompress';
export type { SFNTContainer, SFNTTable } from './ctf-parser';
export { parseCTF } from './ctf-parser';
export type { SFNTContainer, SFNTTable, ParseCTFOptions } from './ctf-parser';
export { EotError, EotErrorCode, EOT_WARN } from './errors';
export {
parseEotMetadata,
Expand Down
7 changes: 5 additions & 2 deletions src/mtx-decompress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,14 @@ export function unpackMtx(
* @param options.encrypted If `true`, XOR-decrypt with {@link ENCRYPTION_KEY}.
* @param options.compressed If `false`, skip decompression and return the
* (possibly decrypted) data as-is.
* @param options.onWarn Optional hook invoked with a message for each
* non-fatal diagnostic (e.g. a dropped hdmx/VDMX
* table). The font is still produced.
* @returns A `Uint8Array` containing a valid TrueType (.ttf) font.
*/
export function decompressMtx(
fontData: Uint8Array,
options?: { encrypted?: boolean; compressed?: boolean },
options?: { encrypted?: boolean; compressed?: boolean; onWarn?: (message: string) => void },
): Uint8Array {
const encrypted = options?.encrypted ?? false;
const compressed = options?.compressed ?? true;
Expand Down Expand Up @@ -136,7 +139,7 @@ export function decompressMtx(
const streamObjects = streams.map((buf) => new Stream(buf, buf.length));

// --- Parse CTF structure -----------------------------------------------
const container = parseCTF(streamObjects);
const container = parseCTF(streamObjects, { onWarn: options?.onWarn });

// --- Assemble final TrueType font -------------------------------------
return dumpContainer(container);
Expand Down
67 changes: 45 additions & 22 deletions src/sfnt-builder.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, expectTypeOf } from 'vitest';

import type { SFNTContainer, SFNTTable } from './ctf-parser';
import { EotError, EotErrorCode } from './errors';
import { dumpContainer } from './sfnt-builder';

/**
Expand All @@ -16,6 +17,15 @@ function makeTable(tag: string, data: Uint8Array): SFNTTable {
};
}

/**
* A `head` table of `size` bytes (default 54). dumpContainer now requires a
* head table (it patches checksumAdjustment at offset 8), so structural tests
* include one. Keep `size` >= 12 so the 4-byte patch stays within the table.
*/
function makeHead(size = 54): SFNTTable {
return makeTable('head', new Uint8Array(size));
}

/**
* Read a big-endian U16 from a Uint8Array at the given offset.
*/
Expand All @@ -36,23 +46,23 @@ describe('dumpContainer', () => {
// -----------------------------------------------------------------------
it('writes the TrueType scalar type (0x00010000)', () => {
const ctr: SFNTContainer = {
tables: [makeTable('name', new Uint8Array([0x01, 0x02, 0x03, 0x04]))],
tables: [makeHead()],
};
const result = dumpContainer(ctr);
expect(readU32(result, 0)).toBe(0x00010000);
});

it('writes correct numTables', () => {
const ctr: SFNTContainer = {
tables: [makeTable('name', new Uint8Array(4)), makeTable('cmap', new Uint8Array(8))],
tables: [makeHead(), makeTable('cmap', new Uint8Array(8))],
};
const result = dumpContainer(ctr);
expect(readU16(result, 4)).toBe(2);
});

it('computes correct searchRange, entrySelector, rangeShift for 1 table', () => {
const ctr: SFNTContainer = {
tables: [makeTable('name', new Uint8Array(4))],
tables: [makeHead()],
};
const result = dumpContainer(ctr);
// 1 table: maxPow2(1)=1, searchRange=1*16=16, entrySelector=0, rangeShift=1*16-16=0
Expand All @@ -64,7 +74,7 @@ describe('dumpContainer', () => {
it('computes correct searchRange for 3 tables', () => {
const ctr: SFNTContainer = {
tables: [
makeTable('name', new Uint8Array(4)),
makeHead(),
makeTable('cmap', new Uint8Array(4)),
makeTable('post', new Uint8Array(4)),
],
Expand All @@ -83,18 +93,17 @@ describe('dumpContainer', () => {
// -----------------------------------------------------------------------
it('writes table tags in the directory', () => {
const ctr: SFNTContainer = {
tables: [makeTable('name', new Uint8Array(4))],
tables: [makeHead()],
};
const result = dumpContainer(ctr);
// Table directory starts at offset 12
const tag = String.fromCharCode(result[12], result[13], result[14], result[15]);
expect(tag).toBe('name');
expect(tag).toBe('head');
});

it('writes correct table size in directory', () => {
const data = new Uint8Array(42);
const ctr: SFNTContainer = {
tables: [makeTable('test', data)],
tables: [makeHead(42)],
};
const result = dumpContainer(ctr);
// Directory entry: tag(4) + checksum(4) + offset(4) + size(4) starting at byte 12
Expand All @@ -108,18 +117,20 @@ describe('dumpContainer', () => {
it('embeds table data in the output', () => {
const data = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
const ctr: SFNTContainer = {
tables: [makeTable('test', data)],
// 'test' is first, so its directory entry / data stay at the front;
// head is required by dumpContainer and lives after it.
tables: [makeTable('test', data), makeHead()],
};
const result = dumpContainer(ctr);
// Table data starts after header (12) + directory (16)
const dataOffset = readU32(result, 12 + 8); // offset field in directory
// Table data starts after header (12) + directory (2*16)
const dataOffset = readU32(result, 12 + 8); // offset field of the first directory entry
expect(readU32(result, dataOffset)).toBe(0xdeadbeef);
});

it('computes correct checksum for a simple 4-byte table', () => {
const data = new Uint8Array([0x00, 0x00, 0x00, 0x01]);
const table = makeTable('test', data);
const ctr: SFNTContainer = { tables: [table] };
const ctr: SFNTContainer = { tables: [table, makeHead()] };
dumpContainer(ctr);
// After dump, table.checksum should be 0x00000001
expect(table.checksum).toBe(1);
Expand All @@ -131,7 +142,7 @@ describe('dumpContainer', () => {
// Checksum = 0x01000000 + 0x02000000 = 0x03000000
const data = new Uint8Array([0x01, 0x00, 0x00, 0x00, 0x02]);
const table = makeTable('test', data);
const ctr: SFNTContainer = { tables: [table] };
const ctr: SFNTContainer = { tables: [table, makeHead()] };
dumpContainer(ctr);
expect(table.checksum).toBe(0x03000000);
});
Expand Down Expand Up @@ -162,25 +173,22 @@ describe('dumpContainer', () => {
// Output size
// -----------------------------------------------------------------------
it('output size matches expected: header + directory + padded tables', () => {
const data1 = new Uint8Array(4);
const data2 = new Uint8Array(8);
const ctr: SFNTContainer = {
tables: [makeTable('tst1', data1), makeTable('tst2', data2)],
tables: [makeHead(12), makeTable('tst2', new Uint8Array(8))],
};
const result = dumpContainer(ctr);
// Expected: 12 (header) + 2*16 (directory) + 4 (table1 padded to 4) + 8 (table2 padded to 4)
expect(result).toHaveLength(12 + 32 + 4 + 8);
// Expected: 12 (header) + 2*16 (directory) + 12 (head padded to 12) + 8 (tst2 padded to 8)
expect(result).toHaveLength(12 + 32 + 12 + 8);
});

it('pads tables to 4-byte boundaries', () => {
// 5-byte table -> pads to 8 bytes in output
const data = new Uint8Array(5);
const ctr: SFNTContainer = {
tables: [makeTable('test', data)],
tables: [makeTable('test', new Uint8Array(5)), makeHead()],
};
const result = dumpContainer(ctr);
// 12 + 16 + 8 = 36
expect(result).toHaveLength(36);
// 12 (header) + 2*16 (directory) + 8 (test 5->8 padded) + 56 (head 54->56 padded) = 108
expect(result).toHaveLength(12 + 32 + 8 + 56);
});

// -----------------------------------------------------------------------
Expand All @@ -204,4 +212,19 @@ describe('dumpContainer', () => {
expect(tag1).toBe('head');
expect(tag2).toBe('name');
});

// -----------------------------------------------------------------------
// Missing head table
// -----------------------------------------------------------------------
it('throws EotError NoHeadTable when the container has no head table', () => {
const ctr: SFNTContainer = {
tables: [makeTable('name', new Uint8Array(4)), makeTable('cmap', new Uint8Array(8))],
};
expect(() => dumpContainer(ctr)).toThrow(EotError);
try {
dumpContainer(ctr);
} catch (e) {
expect((e as EotError).code).toBe(EotErrorCode.NoHeadTable);
}
});
});
23 changes: 16 additions & 7 deletions src/sfnt-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import type { SFNTContainer, SFNTTable } from './ctf-parser';
import { EotError, EotErrorCode } from './errors';
import { Stream } from './stream';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -199,15 +200,23 @@ export function dumpContainer(ctr: SFNTContainer): Uint8Array {
totalChecksum = (totalChecksum + beginningChecksum) >>> 0;

// --- 7. Patch head.checksumAdjustment (offset 8 within the head table) -
// A valid SFNT always carries a `head` table (parseCTF enforces this), and
// checksumAdjustment cannot be computed without it. Fail hard rather than
// emitting a font whose whole-file checksum is silently left unpatched.
if (!headTable) {
throw new EotError(
EotErrorCode.NoHeadTable,
'cannot assemble SFNT: container is missing a head table',
);
}

const finalChecksum = (0xb1b0afba - totalChecksum) >>> 0;

if (headTable) {
const adjOffset = headTable.offset + 8;
buf[adjOffset] = (finalChecksum >>> 24) & 0xff;
buf[adjOffset + 1] = (finalChecksum >>> 16) & 0xff;
buf[adjOffset + 2] = (finalChecksum >>> 8) & 0xff;
buf[adjOffset + 3] = finalChecksum & 0xff;
}
const adjOffset = headTable.offset + 8;
buf[adjOffset] = (finalChecksum >>> 24) & 0xff;
buf[adjOffset + 1] = (finalChecksum >>> 16) & 0xff;
buf[adjOffset + 2] = (finalChecksum >>> 8) & 0xff;
buf[adjOffset + 3] = finalChecksum & 0xff;

// Restore position to end
out.pos = afterTables;
Expand Down
Loading