From 7b6d1228c0cadf5492c5cde13d7b9df525b15da1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 01:08:47 +0000 Subject: [PATCH 1/3] Initial plan From f587084d5e34bec736a92787285288bbea7bc9fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 01:18:55 +0000 Subject: [PATCH 2/3] feat: implement indexed PNG (color type 3) decoding support Co-authored-by: nnmrts <20396367+nnmrts@users.noreply.github.com> --- CHANGELOG.md | 7 ++ src/formats/apng.ts | 24 ++++ src/formats/png.ts | 20 ++++ src/formats/png_base.ts | 37 +++++- test/formats/png.test.ts | 253 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 339 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a32daeb..67f38bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Indexed PNG (color type 3) decoding support: + - PLTE palette chunk parsing for up to 256 RGB colors + - tRNS chunk support for per-entry palette transparency + - Sub-byte bit depths (1, 2, 4 bits per pixel) in addition to 8-bit indexed color + ## [0.4.3] - 2025-12-28 ### Fixed diff --git a/src/formats/apng.ts b/src/formats/apng.ts index 9ad2d4d..63016bf 100644 --- a/src/formats/apng.ts +++ b/src/formats/apng.ts @@ -114,6 +114,8 @@ export class APNGFormat extends PNGBase implements ImageFormat { let colorType = 0; const metadata: ImageMetadata = {}; const frames: ImageFrame[] = []; + let plte: Uint8Array | undefined; + let trns: Uint8Array | undefined; // First pass: parse structure and extract metadata const chunkList: Array<{ @@ -144,6 +146,10 @@ export class APNGFormat extends PNGBase implements ImageFormat { height = this.readUint32(chunkData, 4); bitDepth = chunkData[8]; colorType = chunkData[9]; + } else if (type === "PLTE") { + plte = chunkData; + } else if (type === "tRNS") { + trns = chunkData; } else if (type === "acTL") { // Animation control chunk - we'll use frame count later if needed // const numFrames = this.readUint32(chunkData, 0); @@ -167,6 +173,19 @@ export class APNGFormat extends PNGBase implements ImageFormat { validateImageDimensions(width, height); + // Build RGBA palette for indexed color (color type 3) + let palette: Uint8Array | undefined; + if (colorType === 3 && plte) { + const numColors = plte.length / 3; + palette = new Uint8Array(numColors * 4); + for (let i = 0; i < numColors; i++) { + palette[i * 4] = plte[i * 3]; + palette[i * 4 + 1] = plte[i * 3 + 1]; + palette[i * 4 + 2] = plte[i * 3 + 2]; + palette[i * 4 + 3] = trns && i < trns.length ? trns[i] : 255; + } + } + // Second pass: decode frames let currentFrameControl: { width: number; @@ -191,6 +210,7 @@ export class APNGFormat extends PNGBase implements ImageFormat { currentFrameControl.height, bitDepth, colorType, + palette, ); frames.push({ @@ -263,6 +283,7 @@ export class APNGFormat extends PNGBase implements ImageFormat { currentFrameControl.height, bitDepth, colorType, + palette, ); frames.push({ @@ -284,6 +305,7 @@ export class APNGFormat extends PNGBase implements ImageFormat { height, bitDepth, colorType, + palette, ); frames.push({ @@ -449,6 +471,7 @@ export class APNGFormat extends PNGBase implements ImageFormat { height: number, bitDepth: number, colorType: number, + palette?: Uint8Array, ): Promise { // Concatenate chunks const idatData = this.concatenateArrays(chunks); @@ -463,6 +486,7 @@ export class APNGFormat extends PNGBase implements ImageFormat { height, bitDepth, colorType, + palette, ); return rgba; diff --git a/src/formats/png.ts b/src/formats/png.ts index 2aac572..af9508f 100644 --- a/src/formats/png.ts +++ b/src/formats/png.ts @@ -52,6 +52,8 @@ export class PNGFormat extends PNGBase implements ImageFormat { let colorType = 0; const chunks: { type: string; data: Uint8Array }[] = []; const metadata: ImageMetadata = {}; + let plte: Uint8Array | undefined; + let trns: Uint8Array | undefined; // Parse chunks while (pos < data.length) { @@ -73,6 +75,10 @@ export class PNGFormat extends PNGBase implements ImageFormat { height = this.readUint32(chunkData, 4); bitDepth = chunkData[8]; colorType = chunkData[9]; + } else if (type === "PLTE") { + plte = chunkData; + } else if (type === "tRNS") { + trns = chunkData; } else if (type === "IDAT") { chunks.push({ type, data: chunkData }); } else if (type === "pHYs") { @@ -105,6 +111,19 @@ export class PNGFormat extends PNGBase implements ImageFormat { // Decompress data const decompressed = await this.inflate(idatData); + // Build RGBA palette for indexed color (color type 3) + let palette: Uint8Array | undefined; + if (colorType === 3 && plte) { + const numColors = plte.length / 3; + palette = new Uint8Array(numColors * 4); + for (let i = 0; i < numColors; i++) { + palette[i * 4] = plte[i * 3]; + palette[i * 4 + 1] = plte[i * 3 + 1]; + palette[i * 4 + 2] = plte[i * 3 + 2]; + palette[i * 4 + 3] = trns && i < trns.length ? trns[i] : 255; + } + } + // Unfilter and convert to RGBA const rgba = this.unfilterAndConvert( decompressed, @@ -112,6 +131,7 @@ export class PNGFormat extends PNGBase implements ImageFormat { height, bitDepth, colorType, + palette, ); return { diff --git a/src/formats/png_base.ts b/src/formats/png_base.ts index 623c162..3afda58 100644 --- a/src/formats/png_base.ts +++ b/src/formats/png_base.ts @@ -71,6 +71,7 @@ export abstract class PNGBase { /** * Unfilter PNG scanlines and convert to RGBA + * @param palette RGBA palette for indexed color (color type 3): flat array [R,G,B,A, R,G,B,A, ...] */ protected unfilterAndConvert( data: Uint8Array, @@ -78,10 +79,19 @@ export abstract class PNGBase { height: number, bitDepth: number, colorType: number, + palette?: Uint8Array, ): Uint8Array { const rgba = new Uint8Array(width * height * 4); const bytesPerPixel = this.getBytesPerPixel(colorType, bitDepth); - const scanlineLength = width * bytesPerPixel; + + // For indexed color with sub-byte bit depths, scanline bytes != width * bytesPerPixel + const scanlineLength = colorType === 3 && bitDepth < 8 + ? Math.ceil(width * bitDepth / 8) + : width * bytesPerPixel; + + // Filter prediction uses bytesPerPixel=1 for sub-byte bit depths + const filterBpp = bitDepth < 8 ? 1 : bytesPerPixel; + let dataPos = 0; const scanlines: Uint8Array[] = []; @@ -97,7 +107,7 @@ export abstract class PNGBase { scanline, y > 0 ? scanlines[y - 1] : null, filterType, - bytesPerPixel, + filterBpp, ); scanlines.push(scanline); @@ -121,6 +131,29 @@ export abstract class PNGBase { rgba[outIdx + 1] = gray; rgba[outIdx + 2] = gray; rgba[outIdx + 3] = 255; + } else if (colorType === 3) { // Indexed (palette) + let index: number; + if (bitDepth === 8) { + index = scanline[x]; + } else { + // Unpack sub-byte pixel index from packed scanline byte + const byteIdx = Math.floor((x * bitDepth) / 8); + const bitShift = 8 - bitDepth - ((x * bitDepth) % 8); + const mask = (1 << bitDepth) - 1; + index = (scanline[byteIdx] >> bitShift) & mask; + } + if (palette) { + rgba[outIdx] = palette[index * 4]; + rgba[outIdx + 1] = palette[index * 4 + 1]; + rgba[outIdx + 2] = palette[index * 4 + 2]; + rgba[outIdx + 3] = palette[index * 4 + 3]; + } else { + // No palette: treat index as grayscale value + rgba[outIdx] = index; + rgba[outIdx + 1] = index; + rgba[outIdx + 2] = index; + rgba[outIdx + 3] = 255; + } } else { throw new Error(`Unsupported PNG color type: ${colorType}`); } diff --git a/test/formats/png.test.ts b/test/formats/png.test.ts index 7ada31a..0132a08 100644 --- a/test/formats/png.test.ts +++ b/test/formats/png.test.ts @@ -1,6 +1,117 @@ import { assertEquals, assertRejects } from "@std/assert"; import { test } from "@cross/test"; import { PNGFormat } from "../../src/formats/png.ts"; +import { PNGBase } from "../../src/formats/png_base.ts"; + +/** + * Test helper that exposes PNGBase protected methods needed to build indexed PNG fixtures + */ +class PNGTestHelper extends PNGBase { + buildChunk(type: string, data: Uint8Array): Uint8Array { + return this.createChunk(type, data); + } + concatArrays(arrays: Uint8Array[]): Uint8Array { + return this.concatenateArrays(arrays); + } + compress(data: Uint8Array): Promise { + return this.deflate(data); + } + writeU32(data: Uint8Array, offset: number, value: number): void { + this.writeUint32(data, offset, value); + } + // Required abstract stubs (not used in tests) + canDecode(): boolean { + return false; + } + decode(): Promise { + return Promise.reject(new Error("Not implemented in test helper")); + } + encode(): Promise { + return Promise.reject(new Error("Not implemented in test helper")); + } +} + +/** + * Build a minimal indexed PNG binary for testing. + * @param width Image width + * @param height Image height + * @param bitDepth Bit depth (1, 2, 4, or 8) + * @param palette RGB palette entries [[R,G,B], ...] + * @param alphas Optional alpha values per palette entry + * @param indices Pixel indices in row-major order + */ +async function buildIndexedPNG( + width: number, + height: number, + bitDepth: number, + palette: [number, number, number][], + alphas: number[] | null, + indices: number[], +): Promise { + const helper = new PNGTestHelper(); + + // IHDR + const ihdr = new Uint8Array(13); + helper.writeU32(ihdr, 0, width); + helper.writeU32(ihdr, 4, height); + ihdr[8] = bitDepth; + ihdr[9] = 3; // indexed + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + + // PLTE + const plte = new Uint8Array(palette.length * 3); + for (let i = 0; i < palette.length; i++) { + plte[i * 3] = palette[i][0]; + plte[i * 3 + 1] = palette[i][1]; + plte[i * 3 + 2] = palette[i][2]; + } + + // Raw scanlines (filter byte 0 = None, then packed pixel indices) + if (bitDepth !== 1 && bitDepth !== 2 && bitDepth !== 4 && bitDepth !== 8) { + throw new Error(`Invalid bit depth for indexed PNG: ${bitDepth}`); + } + const scanlineBytes = bitDepth === 8 ? width : Math.ceil(width * bitDepth / 8); + const rawData = new Uint8Array(height * (1 + scanlineBytes)); + let pos = 0; + const pixelsPerByte = 8 / bitDepth; + for (let y = 0; y < height; y++) { + rawData[pos++] = 0; // filter type: None + if (bitDepth === 8) { + for (let x = 0; x < width; x++) { + rawData[pos++] = indices[y * width + x]; + } + } else { + for (let b = 0; b < scanlineBytes; b++) { + let byte = 0; + for (let p = 0; p < pixelsPerByte; p++) { + const x = b * pixelsPerByte + p; + if (x < width) { + const idx = indices[y * width + x] & ((1 << bitDepth) - 1); + byte |= idx << (8 - bitDepth * (p + 1)); + } + } + rawData[pos++] = byte; + } + } + } + + const compressed = await helper.compress(rawData); + + const chunks: Uint8Array[] = [ + new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), + helper.buildChunk("IHDR", ihdr), + helper.buildChunk("PLTE", plte), + ]; + if (alphas) { + chunks.push(helper.buildChunk("tRNS", new Uint8Array(alphas))); + } + chunks.push(helper.buildChunk("IDAT", compressed)); + chunks.push(helper.buildChunk("IEND", new Uint8Array(0))); + + return helper.concatArrays(chunks); +} test("PNG: canDecode - valid PNG signature", () => { const validPNG = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0]); @@ -247,3 +358,145 @@ test("PNG: metadata - no metadata when not provided", async () => { assertEquals(decoded.metadata, undefined); }); + +test("PNG: indexed color (type 3) - 8-bit depth, no transparency", async () => { + const format = new PNGFormat(); + + // 2x2 image with 4 distinct palette colors + const palette: [number, number, number][] = [ + [255, 0, 0], // index 0: red + [0, 255, 0], // index 1: green + [0, 0, 255], // index 2: blue + [255, 255, 0], // index 3: yellow + ]; + const indices = [0, 1, 2, 3]; // row-major: [red, green, blue, yellow] + + const pngData = await buildIndexedPNG(2, 2, 8, palette, null, indices); + const decoded = await format.decode(pngData); + + assertEquals(decoded.width, 2); + assertEquals(decoded.height, 2); + assertEquals(decoded.data.length, 16); // 2x2 * 4 bytes RGBA + + // Pixel 0: red + assertEquals(decoded.data[0], 255); + assertEquals(decoded.data[1], 0); + assertEquals(decoded.data[2], 0); + assertEquals(decoded.data[3], 255); + + // Pixel 1: green + assertEquals(decoded.data[4], 0); + assertEquals(decoded.data[5], 255); + assertEquals(decoded.data[6], 0); + assertEquals(decoded.data[7], 255); + + // Pixel 2: blue + assertEquals(decoded.data[8], 0); + assertEquals(decoded.data[9], 0); + assertEquals(decoded.data[10], 255); + assertEquals(decoded.data[11], 255); + + // Pixel 3: yellow + assertEquals(decoded.data[12], 255); + assertEquals(decoded.data[13], 255); + assertEquals(decoded.data[14], 0); + assertEquals(decoded.data[15], 255); +}); + +test("PNG: indexed color (type 3) - 8-bit depth, with tRNS transparency", async () => { + const format = new PNGFormat(); + + const palette: [number, number, number][] = [ + [255, 0, 0], // index 0: red, fully opaque + [0, 0, 255], // index 1: blue, semi-transparent + ]; + const alphas = [255, 128]; // index 0 opaque, index 1 semi-transparent + const indices = [0, 1]; // 1x2: red, blue + + const pngData = await buildIndexedPNG(1, 2, 8, palette, alphas, indices); + const decoded = await format.decode(pngData); + + assertEquals(decoded.width, 1); + assertEquals(decoded.height, 2); + + // Pixel 0: red, alpha=255 + assertEquals(decoded.data[0], 255); + assertEquals(decoded.data[1], 0); + assertEquals(decoded.data[2], 0); + assertEquals(decoded.data[3], 255); + + // Pixel 1: blue, alpha=128 + assertEquals(decoded.data[4], 0); + assertEquals(decoded.data[5], 0); + assertEquals(decoded.data[6], 255); + assertEquals(decoded.data[7], 128); +}); + +test("PNG: indexed color (type 3) - 4-bit depth", async () => { + const format = new PNGFormat(); + + // 4x1 image with 4 colors (2 pixels per byte at 4-bit depth) + const palette: [number, number, number][] = [ + [255, 0, 0], // index 0: red + [0, 255, 0], // index 1: green + [0, 0, 255], // index 2: blue + [128, 128, 0], // index 3: olive + ]; + const indices = [0, 1, 2, 3]; + + const pngData = await buildIndexedPNG(4, 1, 4, palette, null, indices); + const decoded = await format.decode(pngData); + + assertEquals(decoded.width, 4); + assertEquals(decoded.height, 1); + + // Pixel 0: red + assertEquals(decoded.data[0], 255); + assertEquals(decoded.data[1], 0); + assertEquals(decoded.data[2], 0); + assertEquals(decoded.data[3], 255); + + // Pixel 1: green + assertEquals(decoded.data[4], 0); + assertEquals(decoded.data[5], 255); + assertEquals(decoded.data[6], 0); + assertEquals(decoded.data[7], 255); + + // Pixel 2: blue + assertEquals(decoded.data[8], 0); + assertEquals(decoded.data[9], 0); + assertEquals(decoded.data[10], 255); + assertEquals(decoded.data[11], 255); + + // Pixel 3: olive + assertEquals(decoded.data[12], 128); + assertEquals(decoded.data[13], 128); + assertEquals(decoded.data[14], 0); + assertEquals(decoded.data[15], 255); +}); + +test("PNG: indexed color (type 3) - 1-bit depth", async () => { + const format = new PNGFormat(); + + // 8x1 image alternating between two colors at 1-bit depth + const palette: [number, number, number][] = [ + [0, 0, 0], // index 0: black + [255, 255, 255], // index 1: white + ]; + const indices = [0, 1, 0, 1, 0, 1, 0, 1]; + + const pngData = await buildIndexedPNG(8, 1, 1, palette, null, indices); + const decoded = await format.decode(pngData); + + assertEquals(decoded.width, 8); + assertEquals(decoded.height, 1); + + // Check alternating black/white pattern + for (let i = 0; i < 8; i++) { + const expected = i % 2 === 0 ? 0 : 255; + assertEquals(decoded.data[i * 4], expected); // R + assertEquals(decoded.data[i * 4 + 1], expected); // G + assertEquals(decoded.data[i * 4 + 2], expected); // B + assertEquals(decoded.data[i * 4 + 3], 255); // A + } +}); From 9ab72f3d0eae54927c59cfb759d5528daa529e30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 01:38:10 +0000 Subject: [PATCH 3/3] fix: add validation and error handling for indexed PNG per review feedback Co-authored-by: nnmrts <20396367+nnmrts@users.noreply.github.com> --- src/formats/apng.ts | 20 ++++++++- src/formats/png.ts | 20 ++++++++- src/formats/png_base.ts | 32 ++++++++------ test/formats/png.test.ts | 93 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 15 deletions(-) diff --git a/src/formats/apng.ts b/src/formats/apng.ts index 63016bf..2d89892 100644 --- a/src/formats/apng.ts +++ b/src/formats/apng.ts @@ -175,8 +175,26 @@ export class APNGFormat extends PNGBase implements ImageFormat { // Build RGBA palette for indexed color (color type 3) let palette: Uint8Array | undefined; - if (colorType === 3 && plte) { + if (colorType === 3) { + if (!plte) { + throw new Error("PNG color type 3 (indexed) requires a PLTE chunk"); + } + if (plte.length % 3 !== 0) { + throw new Error( + `PNG PLTE chunk length must be a multiple of 3 (got ${plte.length})`, + ); + } const numColors = plte.length / 3; + if (numColors > 256) { + throw new Error( + `PNG PLTE chunk must not exceed 256 entries (got ${numColors})`, + ); + } + if (bitDepth !== 1 && bitDepth !== 2 && bitDepth !== 4 && bitDepth !== 8) { + throw new Error( + `PNG color type 3 (indexed) requires bit depth 1, 2, 4, or 8 (got ${bitDepth})`, + ); + } palette = new Uint8Array(numColors * 4); for (let i = 0; i < numColors; i++) { palette[i * 4] = plte[i * 3]; diff --git a/src/formats/png.ts b/src/formats/png.ts index af9508f..b262480 100644 --- a/src/formats/png.ts +++ b/src/formats/png.ts @@ -113,8 +113,26 @@ export class PNGFormat extends PNGBase implements ImageFormat { // Build RGBA palette for indexed color (color type 3) let palette: Uint8Array | undefined; - if (colorType === 3 && plte) { + if (colorType === 3) { + if (!plte) { + throw new Error("PNG color type 3 (indexed) requires a PLTE chunk"); + } + if (plte.length % 3 !== 0) { + throw new Error( + `PNG PLTE chunk length must be a multiple of 3 (got ${plte.length})`, + ); + } const numColors = plte.length / 3; + if (numColors > 256) { + throw new Error( + `PNG PLTE chunk must not exceed 256 entries (got ${numColors})`, + ); + } + if (bitDepth !== 1 && bitDepth !== 2 && bitDepth !== 4 && bitDepth !== 8) { + throw new Error( + `PNG color type 3 (indexed) requires bit depth 1, 2, 4, or 8 (got ${bitDepth})`, + ); + } palette = new Uint8Array(numColors * 4); for (let i = 0; i < numColors; i++) { palette[i * 4] = plte[i * 3]; diff --git a/src/formats/png_base.ts b/src/formats/png_base.ts index 3afda58..8b1b968 100644 --- a/src/formats/png_base.ts +++ b/src/formats/png_base.ts @@ -84,8 +84,10 @@ export abstract class PNGBase { const rgba = new Uint8Array(width * height * 4); const bytesPerPixel = this.getBytesPerPixel(colorType, bitDepth); - // For indexed color with sub-byte bit depths, scanline bytes != width * bytesPerPixel - const scanlineLength = colorType === 3 && bitDepth < 8 + // For packed (sub-byte) formats (grayscale color type 0 and indexed color type 3 with + // bitDepth < 8), the scanline width in bytes is ceil(width * bitDepth / 8), not + // width * bytesPerPixel. + const scanlineLength = bitDepth < 8 && (colorType === 0 || colorType === 3) ? Math.ceil(width * bitDepth / 8) : width * bytesPerPixel; @@ -132,6 +134,11 @@ export abstract class PNGBase { rgba[outIdx + 2] = gray; rgba[outIdx + 3] = 255; } else if (colorType === 3) { // Indexed (palette) + if (!palette) { + throw new Error( + "PNG color type 3 (indexed) requires a PLTE chunk", + ); + } let index: number; if (bitDepth === 8) { index = scanline[x]; @@ -142,18 +149,17 @@ export abstract class PNGBase { const mask = (1 << bitDepth) - 1; index = (scanline[byteIdx] >> bitShift) & mask; } - if (palette) { - rgba[outIdx] = palette[index * 4]; - rgba[outIdx + 1] = palette[index * 4 + 1]; - rgba[outIdx + 2] = palette[index * 4 + 2]; - rgba[outIdx + 3] = palette[index * 4 + 3]; - } else { - // No palette: treat index as grayscale value - rgba[outIdx] = index; - rgba[outIdx + 1] = index; - rgba[outIdx + 2] = index; - rgba[outIdx + 3] = 255; + if (index >= palette.length / 4) { + throw new Error( + `PNG indexed color: palette index ${index} out of range (palette has ${ + palette.length / 4 + } entries)`, + ); } + rgba[outIdx] = palette[index * 4]; + rgba[outIdx + 1] = palette[index * 4 + 1]; + rgba[outIdx + 2] = palette[index * 4 + 2]; + rgba[outIdx + 3] = palette[index * 4 + 3]; } else { throw new Error(`Unsupported PNG color type: ${colorType}`); } diff --git a/test/formats/png.test.ts b/test/formats/png.test.ts index 0132a08..b6cbf8a 100644 --- a/test/formats/png.test.ts +++ b/test/formats/png.test.ts @@ -500,3 +500,96 @@ test("PNG: indexed color (type 3) - 1-bit depth", async () => { assertEquals(decoded.data[i * 4 + 3], 255); // A } }); + +test("PNG: indexed color (type 3) - missing PLTE chunk throws", async () => { + const format = new PNGFormat(); + const helper = new PNGTestHelper(); + + // Build an indexed PNG without a PLTE chunk + const ihdr = new Uint8Array(13); + helper.writeU32(ihdr, 0, 2); + helper.writeU32(ihdr, 4, 1); + ihdr[8] = 8; // bitDepth + ihdr[9] = 3; // color type: indexed + + // scanline: filter=0, then 2 indices + const rawData = new Uint8Array([0, 0, 1]); + const compressed = await helper.compress(rawData); + + const chunks: Uint8Array[] = [ + new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), + helper.buildChunk("IHDR", ihdr), + // PLTE intentionally omitted + helper.buildChunk("IDAT", compressed), + helper.buildChunk("IEND", new Uint8Array(0)), + ]; + + await assertRejects( + async () => await format.decode(helper.concatArrays(chunks)), + Error, + "PLTE chunk", + ); +}); + +test("PNG: indexed color (type 3) - PLTE length not divisible by 3 throws", async () => { + const format = new PNGFormat(); + const helper = new PNGTestHelper(); + + const ihdr = new Uint8Array(13); + helper.writeU32(ihdr, 0, 1); + helper.writeU32(ihdr, 4, 1); + ihdr[8] = 8; + ihdr[9] = 3; + + // Invalid PLTE: 5 bytes (not a multiple of 3) + const badPlte = new Uint8Array([255, 0, 0, 0, 255]); + + const rawData = new Uint8Array([0, 0]); + const compressed = await helper.compress(rawData); + + const chunks: Uint8Array[] = [ + new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), + helper.buildChunk("IHDR", ihdr), + helper.buildChunk("PLTE", badPlte), + helper.buildChunk("IDAT", compressed), + helper.buildChunk("IEND", new Uint8Array(0)), + ]; + + await assertRejects( + async () => await format.decode(helper.concatArrays(chunks)), + Error, + "multiple of 3", + ); +}); + +test("PNG: indexed color (type 3) - out-of-range palette index throws", async () => { + const format = new PNGFormat(); + const helper = new PNGTestHelper(); + + const ihdr = new Uint8Array(13); + helper.writeU32(ihdr, 0, 1); + helper.writeU32(ihdr, 4, 1); + ihdr[8] = 8; + ihdr[9] = 3; + + // Palette with only 2 colors (indices 0 and 1) + const plte = new Uint8Array([255, 0, 0, 0, 255, 0]); + + // Pixel references index 5 which is out of range + const rawData = new Uint8Array([0, 5]); + const compressed = await helper.compress(rawData); + + const chunks: Uint8Array[] = [ + new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), + helper.buildChunk("IHDR", ihdr), + helper.buildChunk("PLTE", plte), + helper.buildChunk("IDAT", compressed), + helper.buildChunk("IEND", new Uint8Array(0)), + ]; + + await assertRejects( + async () => await format.decode(helper.concatArrays(chunks)), + Error, + "out of range", + ); +});