diff --git a/README.md b/README.md index de0af31..310eb04 100644 --- a/README.md +++ b/README.md @@ -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)` diff --git a/src/ctf-parser.test.ts b/src/ctf-parser.test.ts index c218db3..b3fe593 100644 --- a/src/ctf-parser.test.ts +++ b/src/ctf-parser.test.ts @@ -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(); }); // ----------------------------------------------------------------------- diff --git a/src/ctf-parser.ts b/src/ctf-parser.ts index 8cbbbb4..a456cb0 100644 --- a/src/ctf-parser.ts +++ b/src/ctf-parser.ts @@ -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; } // --------------------------------------------------------------------------- @@ -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) { @@ -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(); @@ -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; } @@ -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 }; } diff --git a/src/errors.ts b/src/errors.ts index 531f076..99420ae 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -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', diff --git a/src/index.ts b/src/index.ts index acf7252..49641d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/mtx-decompress.ts b/src/mtx-decompress.ts index 1bea7b7..4b23dda 100644 --- a/src/mtx-decompress.ts +++ b/src/mtx-decompress.ts @@ -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; @@ -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); diff --git a/src/sfnt-builder.test.ts b/src/sfnt-builder.test.ts index 19ce782..02827db 100644 --- a/src/sfnt-builder.test.ts +++ b/src/sfnt-builder.test.ts @@ -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'; /** @@ -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. */ @@ -36,7 +46,7 @@ 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); @@ -44,7 +54,7 @@ describe('dumpContainer', () => { 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); @@ -52,7 +62,7 @@ describe('dumpContainer', () => { 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 @@ -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)), ], @@ -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 @@ -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); @@ -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); }); @@ -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); }); // ----------------------------------------------------------------------- @@ -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); + } + }); }); diff --git a/src/sfnt-builder.ts b/src/sfnt-builder.ts index 5a5ab6e..8245837 100644 --- a/src/sfnt-builder.ts +++ b/src/sfnt-builder.ts @@ -4,6 +4,7 @@ */ import type { SFNTContainer, SFNTTable } from './ctf-parser'; +import { EotError, EotErrorCode } from './errors'; import { Stream } from './stream'; // --------------------------------------------------------------------------- @@ -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; diff --git a/src/stream.test.ts b/src/stream.test.ts index e0c5bbb..f66c57e 100644 --- a/src/stream.test.ts +++ b/src/stream.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; +import { EotError, EotErrorCode } from './errors'; import { Stream } from './stream'; describe('stream', () => { @@ -177,15 +178,25 @@ describe('stream', () => { // Seek operations // ----------------------------------------------------------------------- describe('seeking', () => { - it('seekAbsolute sets position and resets bitPos', () => { + it('seekAbsolute sets position when byte-aligned', () => { const s = new Stream(new Uint8Array(10), 10); s.pos = 5; - s.bitPos = 3; s.seekAbsolute(2); expect(s.pos).toBe(2); expect(s.bitPos).toBe(0); }); + it('seekAbsolute throws OFF_BYTE_BOUNDARY when mid-byte', () => { + const s = new Stream(new Uint8Array(10), 10); + s.bitPos = 3; + expect(() => s.seekAbsolute(2)).toThrow(EotError); + try { + s.seekAbsolute(2); + } catch (e) { + expect((e as EotError).code).toBe(EotErrorCode.OffByteBoundary); + } + }); + it('seekAbsolute throws when seeking past end', () => { const s = new Stream(new Uint8Array(5), 5); expect(() => s.seekAbsolute(6)).toThrow('seek past end'); @@ -204,16 +215,29 @@ describe('stream', () => { expect(() => s.seekRelative(-3)).toThrow('negative seek'); }); - it('seekAbsoluteThroughReserve grows buffer when needed', () => { + it('seekAbsoluteThroughReserve extends size into reserved space', () => { const s = new Stream(null, 0); + s.reserve(100); s.seekAbsoluteThroughReserve(100); expect(s.pos).toBe(100); expect(s.size).toBe(100); expect(s.reserved).toBeGreaterThanOrEqual(100); }); - it('seekRelativeThroughReserve grows from current position', () => { + it('seekAbsoluteThroughReserve throws SEEK_PAST_EOS past reserved end', () => { const s = new Stream(null, 0); + s.reserve(50); + expect(() => s.seekAbsoluteThroughReserve(100)).toThrow(EotError); + try { + s.seekAbsoluteThroughReserve(100); + } catch (e) { + expect((e as EotError).code).toBe(EotErrorCode.SeekPastEos); + } + }); + + it('seekRelativeThroughReserve advances within reserved space', () => { + const s = new Stream(null, 0); + s.reserve(60); s.seekAbsoluteThroughReserve(10); s.seekRelativeThroughReserve(50); expect(s.pos).toBe(60); @@ -261,18 +285,58 @@ describe('stream', () => { s.readNBits(8); expect(() => s.readNBits(1)).toThrow('not enough data for bit read'); }); + + it('reading whole bytes worth of bits leaves the stream byte-aligned', () => { + // enc.xBits + enc.yBits is always a multiple of 8, so byte accessors + // remain usable after a pair of bit reads. + const s = new Stream(new Uint8Array([0xab, 0xcd, 0x12]), 3); + s.readNBits(4); + s.readNBits(12); // total 16 bits -> back on a byte boundary + expect(s.bitPos).toBe(0); + expect(s.readU8()).toBe(0x12); + }); + }); + + // ----------------------------------------------------------------------- + // Byte-boundary enforcement (item 1) + // ----------------------------------------------------------------------- + describe('byte-boundary enforcement', () => { + it('throws OFF_BYTE_BOUNDARY on a byte read while mid-byte', () => { + const s = new Stream(new Uint8Array([0xff, 0x00]), 2); + s.readNBits(3); // now mid-byte (bitPos = 3) + expect(() => s.readU8()).toThrow(EotError); + try { + s.readU8(); + } catch (e) { + expect((e as EotError).code).toBe(EotErrorCode.OffByteBoundary); + } + }); + + it('throws OFF_BYTE_BOUNDARY on a byte write while mid-byte', () => { + const s = new Stream(new Uint8Array([0xff, 0x00]), 2); + s.reserve(4); + s.readNBits(3); + expect(() => s.writeU8(0x42)).toThrow(EotError); + try { + s.writeU8(0x42); + } catch (e) { + expect((e as EotError).code).toBe(EotErrorCode.OffByteBoundary); + } + }); }); // ----------------------------------------------------------------------- // copyTo // ----------------------------------------------------------------------- describe('copyTo', () => { - it('copies bytes from one stream to another', () => { + it('copies bytes into reserved destination space', () => { const src = new Stream(new Uint8Array([10, 20, 30, 40]), 4); const dest = new Stream(null, 0); + dest.reserve(3); src.copyTo(dest, 3); expect(src.pos).toBe(3); expect(dest.pos).toBe(3); + expect(dest.size).toBe(3); expect(dest.buf[0]).toBe(10); expect(dest.buf[1]).toBe(20); expect(dest.buf[2]).toBe(30); @@ -281,8 +345,20 @@ describe('stream', () => { it('throws when source does not have enough data', () => { const src = new Stream(new Uint8Array([1, 2]), 2); const dest = new Stream(null, 0); + dest.reserve(5); expect(() => src.copyTo(dest, 5)).toThrow('not enough data for copy'); }); + + it('throws OUT_OF_RESERVED_SPACE when destination lacks capacity', () => { + const src = new Stream(new Uint8Array([1, 2, 3, 4]), 4); + const dest = new Stream(null, 0); // 0 reserved + expect(() => src.copyTo(dest, 3)).toThrow(EotError); + try { + src.copyTo(dest, 3); + } catch (e) { + expect((e as EotError).code).toBe(EotErrorCode.OutOfReservedSpace); + } + }); }); // ----------------------------------------------------------------------- diff --git a/src/stream.ts b/src/stream.ts index 839feeb..55878b7 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -2,6 +2,8 @@ * Binary stream reader/writer for big-endian data. * Ported from libeot (MPL 2.0) util/stream.c */ +import { EotError, EotErrorCode } from './errors'; + export class Stream { buf: Uint8Array; size: number; // how much data has been written or is valid @@ -41,7 +43,27 @@ export class Stream { this.reserved = n; } + /** + * Reject byte-level access while the stream sits mid-byte (`bitPos != 0`). + * + * libeot returns `EOT_OFF_BYTE_BOUNDARY` for any byte read/write/seek issued + * before a partial byte has been consumed. The bit-level reader + * ({@link readNBits}) is the sole legitimate mid-byte accessor and bypasses + * this guard by touching `buf`/`pos` directly. On valid input the only + * `readNBits` caller consumes whole bytes per point, so the stream is always + * byte-aligned when a byte accessor runs and this guard never fires. + */ + private ensureByteAligned(): void { + if (this.bitPos !== 0) { + throw new EotError( + EotErrorCode.OffByteBoundary, + `Stream: byte-level access at a non-byte boundary (bitPos=${this.bitPos}, pos=${this.pos})`, + ); + } + } + private ensureWrite(n: number): void { + this.ensureByteAligned(); const needed = this.pos + n; if (needed > this.reserved) { this.reserve(Math.max(needed, this.reserved * 2 || 256)); @@ -52,6 +74,7 @@ export class Stream { } private ensureRead(n: number): void { + this.ensureByteAligned(); if (this.pos + n > this.size) { throw new Error( `Stream: not enough data (need ${n} bytes at pos ${this.pos}, size ${this.size})`, @@ -60,15 +83,19 @@ export class Stream { } // --- Seek --- + // A seek requires the stream to be byte-aligned and never clears `bitPos` + // itself (mirroring libeot, which refuses to seek mid-byte rather than + // silently re-aligning). The alignment guard leaves `bitPos` at 0. seekAbsolute(pos: number): void { + this.ensureByteAligned(); if (pos > this.size) { throw new Error(`Stream: seek past end (${pos} > ${this.size})`); } this.pos = pos; - this.bitPos = 0; } seekRelative(offset: number): void { + this.ensureByteAligned(); const newPos = this.pos + offset; if (newPos < 0) { throw new Error('Stream: negative seek'); @@ -77,18 +104,24 @@ export class Stream { throw new Error('Stream: seek past end'); } this.pos = newPos; - this.bitPos = 0; } + // Seek into already-reserved-but-unwritten space, extending `size` up to the + // seek target. libeot returns `EOT_SEEK_PAST_EOS` when the target exceeds the + // reserved capacity; we mirror that rather than growing the buffer, so an + // over-reach surfaces as a failure instead of a silent realloc. seekAbsoluteThroughReserve(pos: number): void { + this.ensureByteAligned(); if (pos > this.reserved) { - this.reserve(pos); + throw new EotError( + EotErrorCode.SeekPastEos, + `Stream: seek to ${pos} past reserved end (${this.reserved})`, + ); } if (pos > this.size) { this.size = pos; } this.pos = pos; - this.bitPos = 0; } seekRelativeThroughReserve(offset: number): void { @@ -216,15 +249,33 @@ export class Stream { } // --- Copy --- - /** Copy `length` bytes from this stream to `dest`. */ + /** + * Copy `length` bytes from this stream to `dest`. + * + * Both streams must be byte-aligned. The destination must already have the + * capacity reserved: libeot returns `EOT_OUT_OF_RESERVED_SPACE` when a copy + * would overrun the reserved buffer, so we throw rather than auto-growing — + * an under-reservation is a bug we want surfaced, not silently patched. + */ copyTo(dest: Stream, length: number): void { + this.ensureByteAligned(); + dest.ensureByteAligned(); if (this.pos + length > this.size) { throw new Error('Stream: not enough data for copy'); } - dest.ensureWrite(length); + const needed = dest.pos + length; + if (needed > dest.reserved) { + throw new EotError( + EotErrorCode.OutOfReservedSpace, + `Stream: copy of ${length} bytes exceeds reserved capacity (need ${needed}, reserved ${dest.reserved})`, + ); + } dest.buf.set(this.buf.subarray(this.pos, this.pos + length), dest.pos); this.pos += length; dest.pos += length; + if (dest.pos > dest.size) { + dest.size = dest.pos; + } } /** Read rest of data as 4-byte-aligned U32 values. Returns 0 on incomplete read. */