Skip to content
LogoLogo

@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/png
import * 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

OptionDefaultDescription
inflateDecompressionStream('deflate')Decompresses zlib data. Required for decodeSync. decode also accepts an async function.
checkCrctrueVerify each chunk's CRC. The zlib checksum is always verified by the inflate function.
maxPixelsInfinityThe 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.
signalnonedecode only. Aborting rejects with signal.reason and stops the default decompression.

Decoded is a plain object:

FieldDescription
width, heightPixels.
dataUint8Array of 8-bit RGBA, four bytes per pixel, row by row from the top left.
depthThe file's bit depth: 1, 2, 4, 8 or 16.
colorTypeThe file's color type: 0 grayscale, 2 RGB, 3 palette, 4 grayscale with alpha, 6 RGBA.
interlaceWhether the file is Adam7 interlaced.
alphaWhether the file has an alpha channel or a tRNS chunk.
gammaThe 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.

OptionDefaultDescription
deflateCompressionStream('deflate')Compresses to zlib format. Required for encodeSync. Pass one to choose a compression level.
colorType60 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.
signalnoneencode only. Aborting rejects with signal.reason and stops the default compression.

The encoder writes 8-bit, non-interlaced files with one IDAT chunk.

Errors

ErrorThrown when
TruncatedErrorThe file ends before IEND or inside a chunk, or holds less image data than its header describes.
InvalidInputErrorThe 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.
UnsupportedErrorThe file has an unknown critical chunk, or a compression, filter or interlace method PNG doesn't define. encode throws it for colorType 3.
MaxPixelsErrorThe 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

TypeDescription
Png.ColorType0 | 2 | 3 | 4 | 6: grayscale, RGB, palette, grayscale with alpha and RGBA.
Png.BitDepth1 | 2 | 4 | 8 | 16, bits per sample, or per palette index for color type 3.
Png.FilterType0 | 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 TruncatedError before 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 a RangeError.
  • 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. maxPixels rejects it from the header, as sharp's limitInputPixels does. pngjs has no limit.
  • Decoding a 1024×1024 image with decodeSync and node:zlib was about 1.3× faster than pngjs. The default decode was about 1.2× slower than pngjs in Node, because Node's DecompressionStream sends zlib work to the thread pool one 16 KiB chunk at a time.
  • Encoding with CompressionStream was about 1.2× faster than pngjs at the same zlib settings, and encodeSync with pngjs's defaults (level 9, Z_RLE) was about 1.3× faster than pngjs.
  • A file with no IDAT is rejected with InvalidInputError. pngjs's stream parser calls back with neither an error nor an image.
  • Bytes after IEND are ignored. PNG.sync.read throws on them, including on a file that pngjs's own async test parses.
  • A pixel matching the tRNS color gets alpha 0 and keeps its color. pngjs makes it black.
  • encode computes gray as BT.709 luma. pngjs averages the channels.
  • The default encoder compresses at zlib level 6 with the default strategy, which is what CompressionStream does. pngjs uses level 9 with Z_RLE, which is faster and makes files about 17% larger on photographs. pngjs's deflateLevel has no equivalent, so pass a deflate function to choose a level.
  • pngjs sets gamma to 0 when there is no gAMA chunk, and decode returns gamma: undefined.
  • Pixels are always 8-bit RGBA, so pngjs's skipRescale, bitDepth: 16, inputColorType and inputHasAlpha options have no equivalent. Neither do pngjs's bitblt and adjustGamma.

Performance

@portable/pngpngjs
Decode a 1024×1024 PNG21.526.7
Decode a 1024×1024 PNG with decodeSync and node:zlib35.926.7
Encode a 1024×1024 PNG at zlib level 66.845.94
Encode a 1024×1024 PNG at level 9 with Z_RLE, through node:zlib13.19.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/pngpngjs
Minified and gzipped4.9 kB9.1 kB
Runtime dependencies20
Node built-ins usednoneassert, buffer, stream, util, zlib
TypeScript typesincluded@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.