@portable/png
Decodes PNG files to 8-bit RGBA pixels and encodes RGBA pixels as PNG, with zlib from the platform's CompressionStream and DecompressionStream. Runs in browsers, Cloudflare Workers, Deno, Bun and Node 24+.
Replaces pngjs.
npm install @portable/pngimport * as Png from '@portable/png';
const response = await fetch('https://example.com/photo.png');
const { width, height, data } = await Png.decode(
await response.bytes(),
);
for (let i = 0; i < data.length; i += 4) {
data[i] = 255 - data[i]!;
} // invert red
const bytes = await Png.encode({ width, height, data });decode reads every PNG color type (grayscale, RGB, palette, each with or without alpha), bit depths 1, 2, 4, 8 and 16, Adam7 interlacing, and tRNS transparency. The result is always 8-bit RGBA: 16-bit samples are rounded to 8 bits, and lower depths are scaled up, so a 1-bit white is 255. Gamma is reported but not applied.
Sync or async
decode and encode return promises, because CompressionStream and DecompressionStream are the only zlib that every runtime has, and they are async. decodeSync and encodeSync do the same work synchronously with a zlib function you pass:
import * as Png from '@portable/png';
import { unzlibSync, zlibSync } from 'fflate';
const image = Png.decodeSync(bytes, { inflate: unzlibSync });
const png = Png.encodeSync(image, {
deflate: (raw) => zlibSync(raw, { level: 9 }),
});In Node, inflateSync and deflateSync from node:zlib work too. Both functions use the zlib format (RFC 1950), not raw deflate.
API
decode(bytes: Uint8Array, options?): Promise<Decoded>
decodeSync(bytes: Uint8Array, options): Decoded
| Option | Default | Description |
|---|---|---|
inflate | DecompressionStream('deflate') | Decompresses zlib data. Required for decodeSync. decode also accepts an async function. |
checkCrc | true | Verify each chunk's CRC. The zlib checksum is always verified by the inflate function. |
maxPixels | Infinity | The largest number of pixels (width * height) to decode. A larger image throws MaxPixelsError as soon as IHDR is read, before anything is decompressed or allocated. Set it when you decode untrusted files. |
signal | none | decode only. Aborting rejects with signal.reason and stops the default decompression. |
Decoded is a plain object:
| Field | Description |
|---|---|
width, height | Pixels. |
data | Uint8Array of 8-bit RGBA, four bytes per pixel, row by row from the top left. |
depth | The file's bit depth: 1, 2, 4, 8 or 16. |
colorType | The file's color type: 0 grayscale, 2 RGB, 3 palette, 4 grayscale with alpha, 6 RGBA. |
interlace | Whether the file is Adam7 interlaced. |
alpha | Whether the file has an alpha channel or a tRNS chunk. |
gamma | The gAMA value, such as 0.45455, or undefined. |
Bytes after IEND are ignored. Unknown ancillary chunks are skipped.
encode(image: RgbaImage, options?): Promise<Uint8Array>
encodeSync(image: RgbaImage, options): Uint8Array
image is { width, height, data, gamma? } with 8-bit RGBA data (Uint8Array or Uint8ClampedArray), so a Decoded result or a canvas ImageData can be passed as is. When gamma is set, it is written to a gAMA chunk.
| Option | Default | Description |
|---|---|---|
deflate | CompressionStream('deflate') | Compresses to zlib format. Required for encodeSync. Pass one to choose a compression level. |
colorType | 6 | 0 grayscale, 2 RGB, 4 grayscale with alpha or 6 RGBA. Gray is the BT.709 luma of each pixel. |
background | { red: 255, green: 255, blue: 255 } | Color that semi-transparent pixels are blended onto for color types without alpha. |
filter | [0, 1, 2, 3, 4] | A filter for every row (0 none, 1 sub, 2 up, 3 average, 4 Paeth), or a list to choose from per row. With a list, each row gets the filter whose output has the smallest sum of absolute values, with each byte read as signed, as libpng does. |
signal | none | encode only. Aborting rejects with signal.reason and stops the default compression. |
The encoder writes 8-bit, non-interlaced files with one IDAT chunk.
Errors
| Error | Thrown when |
|---|---|
TruncatedError | The file ends before IEND or inside a chunk, or holds less image data than its header describes. |
InvalidInputError | The input is malformed: a bad signature, a CRC mismatch, an invalid header, a bad filter type, a palette index out of range, or zlib data that fails to decompress (the cause is in error.cause). encode throws it for data of the wrong length or a width or height that isn't a positive integer, and a RangeError for an invalid filter. |
UnsupportedError | The file has an unknown critical chunk, or a compression, filter or interlace method PNG doesn't define. encode throws it for colorType 3. |
MaxPixelsError | The image has more pixels than maxPixels, which the error stores as maxPixels. |
Their names are Png.TruncatedError, Png.InvalidInputError, Png.UnsupportedError and Png.MaxPixelsError. A bad maxPixels, such as a negative number, throws a RangeError.
Types
| Type | Description |
|---|---|
Png.ColorType | 0 | 2 | 3 | 4 | 6: grayscale, RGB, palette, grayscale with alpha and RGBA. |
Png.BitDepth | 1 | 2 | 4 | 8 | 16, bits per sample, or per palette index for color type 3. |
Png.FilterType | 0 | 1 | 2 | 3 | 4: none, sub, up, average and Paeth. |
Why use this over pngjs
pngjs is built on Node's zlib and streams. This package takes zlib from CompressionStream and DecompressionStream, so it runs in browsers and Workers with no dependencies outside @portable/*.
- A header that claims more pixels than the data can hold throws
TruncatedErrorbefore anything is allocated. pngjs sizes its buffer from the header alone, and a 100000×100000 header with 19 bytes of image data made it try to allocate 40 GB and throw aRangeError. - A valid file can still be small and decode to a huge image: a 1-bit 100000×100000 image compresses to about 1.2 MB and decodes to 40 GB of RGBA.
maxPixelsrejects it from the header, as sharp'slimitInputPixelsdoes. pngjs has no limit. - Decoding a 1024×1024 image with
decodeSyncandnode:zlibwas about 1.3× faster than pngjs. The defaultdecodewas about 1.2× slower than pngjs in Node, because Node'sDecompressionStreamsends zlib work to the thread pool one 16 KiB chunk at a time. - Encoding with
CompressionStreamwas about 1.2× faster than pngjs at the same zlib settings, andencodeSyncwith pngjs's defaults (level 9,Z_RLE) was about 1.3× faster than pngjs. - A file with no
IDATis rejected withInvalidInputError. pngjs's stream parser calls back with neither an error nor an image. - Bytes after
IENDare ignored.PNG.sync.readthrows on them, including on a file that pngjs's own async test parses. - A pixel matching the
tRNScolor gets alpha 0 and keeps its color. pngjs makes it black. encodecomputes gray as BT.709 luma. pngjs averages the channels.- The default encoder compresses at zlib level 6 with the default strategy, which is what
CompressionStreamdoes. pngjs uses level 9 withZ_RLE, which is faster and makes files about 17% larger on photographs. pngjs'sdeflateLevelhas no equivalent, so pass adeflatefunction to choose a level. - pngjs sets
gammato0when there is nogAMAchunk, anddecodereturnsgamma: undefined. - Pixels are always 8-bit RGBA, so pngjs's
skipRescale,bitDepth: 16,inputColorTypeandinputHasAlphaoptions have no equivalent. Neither do pngjs'sbitbltandadjustGamma.
Performance
| @portable/png | pngjs | |
|---|---|---|
| Decode a 1024×1024 PNG | 21.5 | 26.7 |
Decode a 1024×1024 PNG with decodeSync and node:zlib | 35.9 | 26.7 |
| Encode a 1024×1024 PNG at zlib level 6 | 6.84 | 5.94 |
Encode a 1024×1024 PNG at level 9 with Z_RLE, through node:zlib | 13.1 | 9.84 |
Operations per second from pnpm bench, higher is better, in Node 24.22.0, on Intel(R) Xeon(R) Processor @ 2.10GHz (4 cores). A dash means the library has no equivalent.
| @portable/png | pngjs | |
|---|---|---|
| Minified and gzipped | 4.9 kB | 9.1 kB |
| Runtime dependencies | 2 | 0 |
| Node built-ins used | none | assert, buffer, stream, util, zlib |
| TypeScript types | included | @types/pngjs |
| Version compared | – | 7.0.0 |
Sizes cover the imports in the usage example, or the equivalent imports of each library, bundled for the browser by scripts/bundle-size.ts. Runtime dependencies count every package installed with the published package. A library that uses Node built-ins needs polyfills in a browser and the nodejs_compat flag in Cloudflare Workers.