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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 43 additions & 15 deletions src/formats/png_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand All @@ -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}`);
}
}
}
}
Expand Down
296 changes: 296 additions & 0 deletions test/formats/png.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading