Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

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

## [0.4.3] - 2025-12-28

### Fixed
Expand Down
42 changes: 42 additions & 0 deletions src/formats/apng.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -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);
Expand All @@ -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 +198 to +204
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 +176 to +205
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.

// Second pass: decode frames
let currentFrameControl: {
width: number;
Expand All @@ -191,6 +228,7 @@ export class APNGFormat extends PNGBase implements ImageFormat {
currentFrameControl.height,
bitDepth,
colorType,
palette,
);

frames.push({
Expand Down Expand Up @@ -263,6 +301,7 @@ export class APNGFormat extends PNGBase implements ImageFormat {
currentFrameControl.height,
bitDepth,
colorType,
palette,
);

frames.push({
Expand All @@ -284,6 +323,7 @@ export class APNGFormat extends PNGBase implements ImageFormat {
height,
bitDepth,
colorType,
palette,
);

frames.push({
Expand Down Expand Up @@ -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);
Expand All @@ -463,6 +504,7 @@ export class APNGFormat extends PNGBase implements ImageFormat {
height,
bitDepth,
colorType,
palette,
);

return rgba;
Expand Down
38 changes: 38 additions & 0 deletions src/formats/png.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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") {
Expand Down Expand Up @@ -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
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.
}

// Unfilter and convert to RGBA
const rgba = this.unfilterAndConvert(
decompressed,
width,
height,
bitDepth,
colorType,
palette,
);

return {
Expand Down
43 changes: 41 additions & 2 deletions src/formats/png_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.

let dataPos = 0;
const scanlines: Uint8Array[] = [];

Expand All @@ -97,7 +109,7 @@ export abstract class PNGBase {
scanline,
y > 0 ? scanlines[y - 1] : null,
filterType,
bytesPerPixel,
filterBpp,
);

scanlines.push(scanline);
Expand All @@ -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}`);
}
Expand Down
Loading
Loading