-
Notifications
You must be signed in to change notification settings - Fork 1
feat: implement indexed PNG (color type 3) decoding support #98
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7b6d122
f587084
9ab72f3
071bcc6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,37 @@ 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) { | ||
| 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]; | ||
| 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; | ||
| } | ||
| } | ||
|
Comment on lines
+176
to
+205
|
||
|
|
||
| // Second pass: decode frames | ||
| let currentFrameControl: { | ||
| width: number; | ||
|
|
@@ -191,6 +228,7 @@ export class APNGFormat extends PNGBase implements ImageFormat { | |
| currentFrameControl.height, | ||
| bitDepth, | ||
| colorType, | ||
| palette, | ||
| ); | ||
|
|
||
| frames.push({ | ||
|
|
@@ -263,6 +301,7 @@ export class APNGFormat extends PNGBase implements ImageFormat { | |
| currentFrameControl.height, | ||
| bitDepth, | ||
| colorType, | ||
| palette, | ||
| ); | ||
|
|
||
| frames.push({ | ||
|
|
@@ -284,6 +323,7 @@ export class APNGFormat extends PNGBase implements ImageFormat { | |
| height, | ||
| bitDepth, | ||
| colorType, | ||
| palette, | ||
| ); | ||
|
|
||
| frames.push({ | ||
|
|
@@ -449,6 +489,7 @@ export class APNGFormat extends PNGBase implements ImageFormat { | |
| height: number, | ||
| bitDepth: number, | ||
| colorType: number, | ||
| palette?: Uint8Array, | ||
| ): Promise<Uint8Array> { | ||
| // Concatenate chunks | ||
| const idatData = this.concatenateArrays(chunks); | ||
|
|
@@ -463,6 +504,7 @@ export class APNGFormat extends PNGBase implements ImageFormat { | |
| height, | ||
| bitDepth, | ||
| colorType, | ||
| palette, | ||
| ); | ||
|
|
||
| return rgba; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,13 +111,45 @@ 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) { | ||
| 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]; | ||
| 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; | ||
| } | ||
|
Comment on lines
+136
to
+142
|
||
| } | ||
|
|
||
| // Unfilter and convert to RGBA | ||
| const rgba = this.unfilterAndConvert( | ||
| decompressed, | ||
| width, | ||
| height, | ||
| bitDepth, | ||
| colorType, | ||
| palette, | ||
| ); | ||
|
|
||
| return { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -71,17 +71,29 @@ 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, | ||
| width: number, | ||
| 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 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; | ||
|
|
||
| // Filter prediction uses bytesPerPixel=1 for sub-byte bit depths | ||
| const filterBpp = bitDepth < 8 ? 1 : bytesPerPixel; | ||
|
Comment on lines
+87
to
+95
|
||
|
|
||
| let dataPos = 0; | ||
| const scanlines: Uint8Array[] = []; | ||
|
|
||
|
|
@@ -97,7 +109,7 @@ export abstract class PNGBase { | |
| scanline, | ||
| y > 0 ? scanlines[y - 1] : null, | ||
| filterType, | ||
| bytesPerPixel, | ||
| filterBpp, | ||
| ); | ||
|
|
||
| scanlines.push(scanline); | ||
|
|
@@ -121,6 +133,33 @@ export abstract class PNGBase { | |
| rgba[outIdx + 1] = gray; | ||
| 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]; | ||
| } 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 (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}`); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as PNG decoder: consider validating that
tRNS.lengthdoes not exceed the number of palette entries (numColors). For indexed images, a longer tRNS chunk is invalid and should throw rather than being silently truncated/ignored.