feat: implement indexed PNG (color type 3) decoding support#98
feat: implement indexed PNG (color type 3) decoding support#98nnmrts wants to merge 4 commits intocross-org:mainfrom
Conversation
Co-authored-by: nnmrts <20396367+nnmrts@users.noreply.github.com>
…dback Co-authored-by: nnmrts <20396367+nnmrts@users.noreply.github.com>
feat: implement indexed PNG (color type 3) decoding support
There was a problem hiding this comment.
Pull request overview
Adds decoding support for indexed-color PNG/APNG images (PNG color type 3) by parsing palette/transparency chunks and mapping packed pixel indices to RGBA during scanline conversion.
Changes:
- Parse
PLTE+tRNSchunks inPNGFormat/APNGFormatand build an RGBA palette for color type 3. - Extend
PNGBase.unfilterAndConvert()to accept an optional palette and decode indexed scanlines, including sub-byte (1/2/4 bpp) packed indices. - Add targeted tests for indexed decoding paths and related error conditions; update changelog.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| test/formats/png.test.ts | Adds fixtures + tests for indexed PNG decoding (8/4/1 bpp, tRNS, and failure cases). |
| src/formats/png_base.ts | Adds palette-aware indexed conversion and packed-scanline handling in the unfilter/convert step. |
| src/formats/png.ts | Parses PLTE/tRNS and constructs RGBA palette before decoding. |
| src/formats/apng.ts | Mirrors PLTE/tRNS palette handling for APNG frame decoding and threads palette through decode helpers. |
| CHANGELOG.md | Documents indexed PNG decoding support under Unreleased. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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; | ||
| } |
There was a problem hiding this comment.
Same as PNG decoder: consider validating that tRNS.length does 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.
| // 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; |
There was a problem hiding this comment.
scanlineLength is adjusted for packed grayscale (colorType 0, bitDepth < 8), but the grayscale conversion logic below still reads scanline[x] per pixel. For packed scanlines this will read past scanlineLength (out-of-bounds -> undefined coerces to 0) and produces incorrect output. Either remove grayscale from the packed-scanline special-case or implement proper unpacking + scaling for bit depths 1/2/4 (expand to 0–255).
| 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; | ||
| } |
There was a problem hiding this comment.
When building the indexed-color palette from PLTE/tRNS, there’s no validation that tRNS.length is <= the number of palette entries. For indexed PNGs, a longer tRNS chunk is invalid and should be rejected (rather than silently ignoring extra alpha bytes) to keep decoding behavior consistent with the other strict PLTE validations.
| // 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
The PLTE/tRNS-to-RGBA palette construction logic is duplicated here and in src/formats/png.ts. Consider extracting this into a shared helper on PNGBase (or a small utility) so future spec/validation tweaks (e.g., tRNS length checks) don’t have to be applied in two places.
|
@copilot open a new pull request to apply changes based on the comments in this thread |
Added
Changes
src/formats/png_base.ts—unfilterAndConvert()now accepts an optionalpaletteparameter; handles packed sub-byte scanlines and maps pixel indices to RGBAsrc/formats/png.ts— parses PLTE/tRNS chunks and builds the RGBA palette before decodingsrc/formats/apng.ts— same PLTE/tRNS handling for all APNG frame typestest/formats/png.test.ts— 7 new test cases covering 8-bit, 4-bit, 1-bit indexed color, transparency, and error conditionsCHANGELOG.md— updated[Unreleased]sectionAll 556 tests pass (
deno task precommit).