sdxc

[ Content & formats ]

@sdxc/png

PNG encoder and decoder over Web Streams, with no WebAssembly

npm add @sdxc/png
pnpm add @sdxc/png
yarn add @sdxc/png
bun add @sdxc/png

PNG encoder and decoder over Web Streams, with no WebAssembly.

Installation

npm add @sdxc/png

Decode failures are values from @sdxc/result, which installs alongside. Compression goes through CompressionStream and DecompressionStream, so it runs in Workers, Bun, Node and the browser.

Usage

Encode pixels

import { encode } from "@sdxc/png";

let data = new Uint8ClampedArray(width * height * 4); // straight RGBA, top row first
let bytes = await encode({ width, height, data });

return new Response(bytes, { headers: { "content-type": "image/png" } });

An image with no transparent pixel is written as RGB, a quarter smaller before compression. Pass { alpha: "keep" } to always write RGBA.

Decode a file

import { decode } from "@sdxc/png";
import { isFailure } from "@sdxc/result";

let image = await decode(bytes);
if (isFailure(image)) return new Response(image.error.message, { status: 422 });
image.data.data; // Uint8ClampedArray, RGBA, 8 bits per channel

Every color type and bit depth, palettes with transparency, 16-bit samples and Adam7 interlacing read back as the same 8-bit RGBA layout.

API

encode(image: PNG.Image, options?: PNG.EncodeOptions): Promise<Uint8Array>

The complete PNG file. Each scanline takes the filter that compresses best by libpng's heuristic, at zlib's default level. Rejects with a RangeError when data is not width * height * 4 bytes, a dimension is not a positive integer, or a text entry is not valid tEXt.

  • options.alpha: "auto" (default) drops the alpha channel when every pixel is opaque; "keep" writes it anyway

  • options.text: tEXt entries, such as { Software: "My App" }; keywords are 1–79 Latin-1 characters

decode(bytes: Uint8Array): Promise<Result<PNG.Image, PNGError>>

The image as 8-bit straight RGBA. 16-bit samples round to 8 bits; ancillary chunks such as gAMA, iCCP and tEXt are read past.

PNGError

An Error with a code:

CodeWhen
signatureThe bytes do not start with the PNG signature
crcA chunk's CRC-32 disagrees with its contents
headerIHDR, PLTE or chunk order breaks the specification
unsupportedAn unknown critical chunk, filter or method, or an image over 2²⁸ pixels
truncatedThe file or its pixel data ends early
inflateThe compressed pixel data is corrupt

Types

  • PNG.Image: { width: number; height: number; data: Uint8ClampedArray }, the layout of the web's ImageData

  • PNG.EncodeOptions: { alpha?: "auto" | "keep"; text?: Record<string, string> }

  • PNGError.Code: "signature" | "crc" | "header" | "unsupported" | "truncated" | "inflate"

Pattern: Round-Trip Assertions in Tests

Decoding what a renderer encoded lets a test assert on pixels rather than on file bytes.

import { decode, encode } from "@sdxc/png";
import { unwrap } from "@sdxc/result";
import { expect, test } from "vitest";

test("the first pixel is red", async () => {
	let data = new Uint8ClampedArray(4 * 4 * 4).fill(255);
	data.set([255, 0, 0, 255], 0);

	let image = unwrap(await decode(await encode({ width: 4, height: 4, data })));
	expect([...image.data.subarray(0, 4)]).toEqual([255, 0, 0, 255]);
});

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.