diff --git a/CHANGELOG.md b/CHANGELOG.md index e91c3db..54cfd64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- PNG decoder: 16-bit per-channel images (bitDepth=16) now decode correctly; the pixel stride was + using a fixed 8-bit offset (`x*4`, `x*3`, `x`) causing pixel-offset corruption in 16-bit RGBA, + RGB, and grayscale images +- PNG decoder: colorType 4 (grayscale+alpha) images are now supported instead of throwing an + "Unsupported PNG color type: 4" error +- PNG decoder: sub-byte grayscale formats (bitDepth 1, 2, 4) now compute the correct scanline byte + length (`ceil(width * bitsPerPixel / 8)`) and correctly unpack pixel values from packed bytes; + previously the scanline was over-read and raw byte values were used directly as gray values - Pixel-offset corruption in runtime decoders: Fixed buffer offset handling when decoding images using runtime APIs (ImageDecoder/OffscreenCanvas) in JPEG, WebP, GIF, TIFF, HEIC, and AVIF formats. Previously, creating `new Uint8Array(imageData.data.buffer)` incorrectly assumed pixel diff --git a/src/formats/png_base.ts b/src/formats/png_base.ts index 623c162..99fcbe7 100644 --- a/src/formats/png_base.ts +++ b/src/formats/png_base.ts @@ -80,8 +80,13 @@ export abstract class PNGBase { colorType: number, ): Uint8Array { const rgba = new Uint8Array(width * height * 4); - const bytesPerPixel = this.getBytesPerPixel(colorType, bitDepth); - const scanlineLength = width * bytesPerPixel; + const bitsPerPixel = this.getBitsPerPixel(colorType, bitDepth); + // bytesPerPixel for PNG filter predictor: floor(bpp/8), min 1 (sub-byte formats use 1) + const bytesPerPixel = Math.max(1, Math.floor(bitsPerPixel / 8)); + // Correct scanline byte count: ceil(width * bitsPerPixel / 8) handles sub-byte formats + const scanlineLength = Math.ceil(width * bitsPerPixel / 8); + // Number of bytes per channel (1 for 8-bit, 2 for 16-bit) + const channelSize = bitDepth >= 8 ? bitDepth >> 3 : 1; let dataPos = 0; const scanlines: Uint8Array[] = []; @@ -105,24 +110,47 @@ export abstract class PNGBase { // Convert to RGBA for (let x = 0; x < width; x++) { const outIdx = (y * width + x) * 4; - if (colorType === 6) { // RGBA - rgba[outIdx] = scanline[x * 4]; - rgba[outIdx + 1] = scanline[x * 4 + 1]; - rgba[outIdx + 2] = scanline[x * 4 + 2]; - rgba[outIdx + 3] = scanline[x * 4 + 3]; - } else if (colorType === 2) { // RGB - rgba[outIdx] = scanline[x * 3]; - rgba[outIdx + 1] = scanline[x * 3 + 1]; - rgba[outIdx + 2] = scanline[x * 3 + 2]; - rgba[outIdx + 3] = 255; - } else if (colorType === 0) { // Grayscale - const gray = scanline[x]; + if (bitDepth < 8 && (colorType === 0 || colorType === 3)) { + // Sub-byte grayscale or indexed-color: multiple pixels packed per byte + const pixelsPerByte = 8 / bitDepth; + const byteIndex = Math.floor(x / pixelsPerByte); + const bitShift = bitDepth * (pixelsPerByte - 1 - (x % pixelsPerByte)); + const mask = (1 << bitDepth) - 1; + const gray = Math.round( + ((scanline[byteIndex] >> bitShift) & mask) * (255 / mask), + ); rgba[outIdx] = gray; rgba[outIdx + 1] = gray; rgba[outIdx + 2] = gray; rgba[outIdx + 3] = 255; } else { - throw new Error(`Unsupported PNG color type: ${colorType}`); + // Use bytesPerPixel as stride; channelSize offsets within each pixel + const pixelOffset = x * bytesPerPixel; + if (colorType === 6) { // RGBA + rgba[outIdx] = scanline[pixelOffset]; + rgba[outIdx + 1] = scanline[pixelOffset + channelSize]; + rgba[outIdx + 2] = scanline[pixelOffset + channelSize * 2]; + rgba[outIdx + 3] = scanline[pixelOffset + channelSize * 3]; + } else if (colorType === 4) { // Grayscale + Alpha + const gray = scanline[pixelOffset]; + rgba[outIdx] = gray; + rgba[outIdx + 1] = gray; + rgba[outIdx + 2] = gray; + rgba[outIdx + 3] = scanline[pixelOffset + channelSize]; + } else if (colorType === 2) { // RGB + rgba[outIdx] = scanline[pixelOffset]; + rgba[outIdx + 1] = scanline[pixelOffset + channelSize]; + rgba[outIdx + 2] = scanline[pixelOffset + channelSize * 2]; + rgba[outIdx + 3] = 255; + } else if (colorType === 0) { // Grayscale + const gray = scanline[pixelOffset]; + rgba[outIdx] = gray; + rgba[outIdx + 1] = gray; + rgba[outIdx + 2] = gray; + 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..0d79867 100644 --- a/test/formats/png.test.ts +++ b/test/formats/png.test.ts @@ -247,3 +247,299 @@ test("PNG: metadata - no metadata when not provided", async () => { assertEquals(decoded.metadata, undefined); }); + +// Test PNG colorType 4 (grayscale+alpha, 8-bit) decoding. +// This was previously unsupported and threw an error. +// Binary: 2x1 PNG, colorType=4 (grayscale+alpha), bitDepth=8 +// Pixels: (gray=128, alpha=200), (gray=64, alpha=255) +const GA8_PNG = new Uint8Array([ + 137, + 80, + 78, + 71, + 13, + 10, + 26, + 10, // PNG signature + 0, + 0, + 0, + 13, + 73, + 72, + 68, + 82, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 1, + 8, + 4, + 0, + 0, + 0, + 94, + 43, + 183, + 1, // IHDR: 2x1, bitDepth=8, colorType=4 + 0, + 0, + 0, + 13, + 73, + 68, + 65, + 84, + 120, + 156, + 99, + 104, + 56, + 225, + 240, + 31, + 0, + 5, + 220, + 2, + 136, + 238, + 166, + 2, + 103, // IDAT + 0, + 0, + 0, + 0, + 73, + 69, + 78, + 68, + 174, + 66, + 96, + 130, // IEND +]); + +test("PNG: decode colorType 4 (grayscale+alpha 8-bit)", async () => { + const format = new PNGFormat(); + const decoded = await format.decode(GA8_PNG); + + assertEquals(decoded.width, 2); + assertEquals(decoded.height, 1); + // Pixel 1: gray=128, alpha=200 -> RGBA=(128,128,128,200) + assertEquals(decoded.data[0], 128); + assertEquals(decoded.data[1], 128); + assertEquals(decoded.data[2], 128); + assertEquals(decoded.data[3], 200); + // Pixel 2: gray=64, alpha=255 -> RGBA=(64,64,64,255) + assertEquals(decoded.data[4], 64); + assertEquals(decoded.data[5], 64); + assertEquals(decoded.data[6], 64); + assertEquals(decoded.data[7], 255); +}); + +// Test PNG 16-bit RGBA (colorType=6, bitDepth=16) decoding. +// Previously the pixel stride was wrong (x*4 instead of x*8), causing corruption. +// Binary: 2x1 PNG, colorType=6, bitDepth=16 +// Pixel 1 raw: R=0xFF00, G=0x0000, B=0x7F00, A=0xFF00 -> high bytes: R=255,G=0,B=127,A=255 +// Pixel 2 raw: R=0x8000, G=0x4000, B=0x2000, A=0xFF00 -> high bytes: R=128,G=64,B=32,A=255 +const RGBA16_PNG = new Uint8Array([ + 137, + 80, + 78, + 71, + 13, + 10, + 26, + 10, // PNG signature + 0, + 0, + 0, + 13, + 73, + 72, + 68, + 82, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 1, + 16, + 6, + 0, + 0, + 0, + 164, + 178, + 163, + 201, // IHDR: 2x1, bitDepth=16, colorType=6 + 0, + 0, + 0, + 24, + 73, + 68, + 65, + 84, + 120, + 156, + 99, + 248, + 207, + 192, + 192, + 80, + 207, + 240, + 159, + 161, + 129, + 193, + 129, + 65, + 1, + 72, + 3, + 0, + 39, + 233, + 4, + 93, + 204, + 55, + 1, + 237, // IDAT + 0, + 0, + 0, + 0, + 73, + 69, + 78, + 68, + 174, + 66, + 96, + 130, // IEND +]); + +test("PNG: decode 16-bit RGBA (colorType=6, bitDepth=16)", async () => { + const format = new PNGFormat(); + const decoded = await format.decode(RGBA16_PNG); + + assertEquals(decoded.width, 2); + assertEquals(decoded.height, 1); + // Pixel 1: high bytes R=255, G=0, B=127, A=255 + assertEquals(decoded.data[0], 255); + assertEquals(decoded.data[1], 0); + assertEquals(decoded.data[2], 127); + assertEquals(decoded.data[3], 255); + // Pixel 2: high bytes R=128, G=64, B=32, A=255 + assertEquals(decoded.data[4], 128); + assertEquals(decoded.data[5], 64); + assertEquals(decoded.data[6], 32); + assertEquals(decoded.data[7], 255); +}); + +// Test PNG 4-bit grayscale (colorType=0, bitDepth=4) decoding. +// Previously scanlineLength was computed as width*1=4 bytes instead of ceil(4*4/8)=2 bytes, +// causing read-past-end and wrong pixel values. +// Binary: 4x1 PNG, colorType=0, bitDepth=4 +// Packed pixels: 0,5,10,15 -> scaled to 8-bit: 0,85,170,255 +const GRAY4_PNG = new Uint8Array([ + 137, + 80, + 78, + 71, + 13, + 10, + 26, + 10, // PNG signature + 0, + 0, + 0, + 13, + 73, + 72, + 68, + 82, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 1, + 4, + 0, + 0, + 0, + 0, + 25, + 167, + 189, + 16, // IHDR: 4x1, bitDepth=4, colorType=0 + 0, + 0, + 0, + 11, + 73, + 68, + 65, + 84, + 120, + 156, + 99, + 96, + 93, + 15, + 0, + 0, + 188, + 0, + 181, + 130, + 65, + 130, + 156, // IDAT + 0, + 0, + 0, + 0, + 73, + 69, + 78, + 68, + 174, + 66, + 96, + 130, // IEND +]); + +test("PNG: decode 4-bit grayscale (colorType=0, bitDepth=4)", async () => { + const format = new PNGFormat(); + const decoded = await format.decode(GRAY4_PNG); + + assertEquals(decoded.width, 4); + assertEquals(decoded.height, 1); + // Pixel 0: gray=0 -> RGBA=(0,0,0,255) + assertEquals(decoded.data[0], 0); + assertEquals(decoded.data[3], 255); + // Pixel 1: gray=85 -> RGBA=(85,85,85,255) + assertEquals(decoded.data[4], 85); + assertEquals(decoded.data[7], 255); + // Pixel 2: gray=170 -> RGBA=(170,170,170,255) + assertEquals(decoded.data[8], 170); + // Pixel 3: gray=255 -> RGBA=(255,255,255,255) + assertEquals(decoded.data[12], 255); +});