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
58 changes: 49 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<samp>**[▶️ Live demo](https://christophervr.github.io/mtx-decompressor/)** · **[📦 npm](https://www.npmjs.com/package/mtx-decompressor)**</samp>

Expand All @@ -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.
Expand All @@ -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

Expand Down
95 changes: 16 additions & 79 deletions demo/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -205,23 +139,24 @@ async function handleFile(file: File): Promise<void> {
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) {
Expand All @@ -230,13 +165,15 @@ async function handleFile(file: File): Promise<void> {
}

// 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`],
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
21 changes: 21 additions & 0 deletions src/bitio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

// -----------------------------------------------------------------------
Expand Down
15 changes: 11 additions & 4 deletions src/bitio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand All @@ -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;
}
Expand Down
Loading