Skip to content

feat: implement indexed PNG (color type 3) decoding support#98

Open
nnmrts wants to merge 4 commits intocross-org:mainfrom
nnmrts:feat-indexed-png
Open

feat: implement indexed PNG (color type 3) decoding support#98
nnmrts wants to merge 4 commits intocross-org:mainfrom
nnmrts:feat-indexed-png

Conversation

@nnmrts
Copy link
Copy Markdown

@nnmrts nnmrts commented Mar 10, 2026

Added

  • Indexed PNG (color type 3) decoding support:
    • PLTE palette chunk parsing for up to 256 RGB colors
    • tRNS chunk support for per-entry palette transparency
    • Sub-byte bit depths (1, 2, 4 bits per pixel) in addition to 8-bit indexed color

Changes

  • src/formats/png_base.tsunfilterAndConvert() now accepts an optional palette parameter; handles packed sub-byte scanlines and maps pixel indices to RGBA
  • src/formats/png.ts — parses PLTE/tRNS chunks and builds the RGBA palette before decoding
  • src/formats/apng.ts — same PLTE/tRNS handling for all APNG frame types
  • test/formats/png.test.ts — 7 new test cases covering 8-bit, 4-bit, 1-bit indexed color, transparency, and error conditions
  • CHANGELOG.md — updated [Unreleased] section

All 556 tests pass (deno task precommit).

Copilot AI and others added 4 commits March 10, 2026 01:08
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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + tRNS chunks in PNGFormat/APNGFormat and 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.

Comment on lines +198 to +204
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;
}
Copy link

Copilot AI Mar 21, 2026

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.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.

Copilot uses AI. Check for mistakes.
Comment on lines +87 to +95
// 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;
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +136 to +142
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;
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +176 to +205
// 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;
}
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 uses AI. Check for mistakes.
@nnmrts
Copy link
Copy Markdown
Author

nnmrts commented Mar 21, 2026

@copilot open a new pull request to apply changes based on the comments in this thread

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants