feat: implement indexed PNG (color type 3) decoding support#1
feat: implement indexed PNG (color type 3) decoding support#1nnmrts merged 3 commits intofeat-indexed-pngfrom
Conversation
Co-authored-by: nnmrts <20396367+nnmrts@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds decode support for PNG/APNG indexed color (color type 3) by parsing palette chunks and expanding packed palette indices into RGBA output, including sub-byte bit depths and per-entry transparency.
Changes:
- Extend
unfilterAndConvertto handle indexed scanlines (1/2/4/8-bit) and apply palette lookup into RGBA. - Parse
PLTE/tRNSin PNG/APNG decoders and thread a combined RGBA palette into the scanline conversion step. - Add new unit tests covering indexed PNG variants and document the feature in the changelog.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
src/formats/png_base.ts |
Adds indexed-palette conversion logic and adjusts scanline sizing/filter bpp for sub-byte indexed data. |
src/formats/png.ts |
Parses PLTE/tRNS, builds RGBA palette, passes it into unfilter/convert for PNG decode. |
src/formats/apng.ts |
Mirrors palette parsing/building and threads palette into APNG frame decode. |
test/formats/png.test.ts |
Adds fixture builder + tests for indexed PNG (8-bit, tRNS, 4-bit, 1-bit). |
CHANGELOG.md |
Notes indexed PNG decoding support additions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
src/formats/png.ts
Outdated
| 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]; |
There was a problem hiding this comment.
PLTE/tRNS handling needs basic validation for correctness and robustness: (1) for color type 3, PLTE is mandatory—if missing, decoding should fail with a clear error; (2) PLTE length must be a multiple of 3 and no more than 256 entries (768 bytes); and (3) the indexed bitDepth should be restricted to 1/2/4/8. Without these checks, malformed inputs can produce silent corruption or large/invalid palette allocations.
src/formats/apng.ts
Outdated
| 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]; |
There was a problem hiding this comment.
Same as PNG: when building the palette, validate PLTE (multiple of 3, <= 256 entries) and enforce that indexed images (color type 3) must have a PLTE chunk and a valid indexed bitDepth (1/2/4/8). This avoids silent corruption and potential memory issues from oversized/malformed palettes.
| 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); |
There was a problem hiding this comment.
The new indexed-PNG tests cover positive cases, but there are no negative tests for malformed indexed images (e.g., color type 3 with missing PLTE, PLTE length not divisible by 3, or pixel indices that exceed the palette size). Adding a couple of assertRejects cases would help lock in spec-compliant error handling for the new code paths.
src/formats/png_base.ts
Outdated
| // 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; | ||
|
|
There was a problem hiding this comment.
scanlineLength is only corrected for indexed (color type 3) sub-byte images. Valid grayscale PNGs (color type 0) can also have bit depths 1/2/4, but with the current calculation (width * bytesPerPixel) the decoder will read too many bytes per row and misalign subsequent scanlines. Consider computing scanline bytes generically as ceil(width * bitsPerPixel / 8) for any packed format (at least color types 0 and 3), or explicitly rejecting unsupported bit-depth/color-type combinations before unfiltering.
src/formats/png_base.ts
Outdated
| } else { | ||
| // No palette: treat index as grayscale value | ||
| rgba[outIdx] = index; | ||
| rgba[outIdx + 1] = index; | ||
| rgba[outIdx + 2] = index; | ||
| rgba[outIdx + 3] = 255; | ||
| } |
There was a problem hiding this comment.
For indexed PNGs (color type 3), the palette is required by the PNG spec. The current behavior silently falls back to treating the index as a grayscale value when palette is missing, which will produce incorrect output for otherwise-valid inputs and can hide malformed files. It would be better to throw a clear error when colorType === 3 and palette is undefined (ideally earlier during chunk parsing).
src/formats/png_base.ts
Outdated
| 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]; |
There was a problem hiding this comment.
Palette lookup does not validate that the decoded index is within the palette range. If the image references an out-of-range index, reads like palette[index * 4] will yield undefined and end up as 0, producing incorrect pixels instead of reporting a malformed PNG. Add a bounds check (e.g., index < palette.length / 4) and throw a descriptive error when violated.
|
@copilot apply changes based on the comments in this thread |
…dback Co-authored-by: nnmrts <20396367+nnmrts@users.noreply.github.com>
Applied all review changes in commit
|
png_base.ts: updateunfilterAndConvertto support indexed color (PLTE palette lookup, sub-byte bit depths 1/2/4/8)png.ts: parse PLTE and tRNS chunks, build RGBA palette, pass to decoderapng.ts: parse PLTE and tRNS chunks, pass palette through frame decoderpng.tsandapng.ts: mandatory for type 3, length multiple of 3, ≤256 entries, valid bitDepth (1/2/4/8)png_base.tswith clear error when palette is missing for color type 3png_base.tswith descriptive error✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.