An Intro to PNG Decoder

An Intro to PNG Decoder

·

4 min read

Ever thinking about decoding PNG file manually? Let's do it in this article.

First, let's go to pngsuite to download the test png image. Since this is only an introduction, I will emit a lot of details. Let's find the target test image PngSuite-2017jul19/f00n2c08.png, we will decode this image step by step.

Specifically, here are the steps we will do:

  • load image as arraybuffer
  • decode image header
  • decode image data
  • draw image data into canvas

All the code should be in one js file, which will be loaded in one html file.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <script src="./node_modules/pako/dist/pako.js"></script>
    <script src="./index.js"></script>
</body>

</html>

OK. Let write our first function: loading image.

async function fetchPng(path) {
    const file = await fetch(path);
    const buffer = await file.arrayBuffer();
    return new Uint8Array(buffer);
}

After the image is loaded, we should have this Uint8Array. We need to check the first 8 bytes to see if it is a valid png.

function checkMagic(bytes) {
    const magic = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
    if (!bytes.slice(0, 8).every((v, i) => v === magic[i])) {
        throw new Error("magic number check fail");
    }
}

After the magic number, PNG file consists of a bunch of chunks. Each chunk have the same format but may belong to different type. Let's implement a function to split bytes into chunks.

function splitChunk(bytes, start) {
    let offset = start;

    const dataLength = new DataView(bytes.slice(offset, offset + 4).buffer).getUint32();
    offset += 4;

    const type = new TextDecoder().decode(bytes.slice(offset, offset + 4));
    offset += 4;

    const data = bytes.slice(offset, offset + dataLength);
    offset += dataLength;

    const crc = bytes.slice(offset, offset + 4);
    offset + 4;

    // data length field(4) + type(4) + crc(4) + data length
    const length = dataLength + 4 + 4 + 4;

    // this is the info 
    return { dataLength, type, data, crc, start, length };
}

PNG file may have a lot of types. We only care about 2 of them here.

The first is IHDR. It is the image head chunk, containing all the basic information about the image.

// image header chunk, it contains information about how it was encoded, its size ...
// https://www.w3.org/TR/png/#11IHDR
function parseIHDR(bytes) {

    let offset = 0;

    const width = new DataView(bytes.slice(offset, offset + 4).buffer).getUint32();
    offset += 4;

    const height = new DataView(bytes.slice(offset, offset + 4).buffer).getUint32();
    offset += 4;

    const bitDepth = bytes[offset];
    offset += 1;

    const colourType = bytes[offset];
    offset += 1;

    const compressionMethod = bytes[offset];
    offset += 1;

    const interlaceMethod = bytes[offset];

    return { width, height, bitDepth, colourType, compressionMethod, interlaceMethod };
}

The next important one is IDAT, which contains all the image data. It is compressed by the deflate algorithm. We use pako to decompress it.

The decompressed data is a one-dimensional array, containing rows of data. We could use the width value from previous header chunk to recognize it into rows. The first value in each row is used to indicate the filter type. In this article, the test image's filter type is 0, which means no conversion, so we just ignore it. Then we need to convert this data into a ImageData object, so we could draw it into canvas later.

function parseIDAT(bytes, width, height) {
    /**
     * format
     * [
     *  filter, r, g, b, r, g, b, ...,
     *  filter, r, g, b, r, g, b, ...,
     *  filter, r, g, b, r, g, b, ...,
     *  ...
     * ]
     */
    const imageRaw = pako.inflate(bytes);

    // colorType as 2, consists of rgb values
    const stride = width * 3 + 1;

    /**
     * target format
     * [
     *  r, g, b, a, r, g, b, a, ...
     *  r, g, b, a, r, g, b, a, ...
     *  r, g, b, a, r, g, b, a, ...
     *  ...
     * ]
     */
    const imageArr = new Uint8ClampedArray(width * height * 4);

    let i = 0; // index for imageRaw
    let j = 0; // index for imageArr
    let count = 0; // count for rgb
    while (i < imageRaw.length) {
        if (i % stride === 0) { i++; };
        if (count++ < 3) {
            imageArr[j++] = imageRaw[i++];
        } else {
            imageArr[j++] = 255;
            count = 0;
        }
    }

    return new ImageData(imageArr, width, height);
}

After this function, we should have enough information. The last step is to draw it into canvas.

function draw(pixels, width, height) {
    const canvas = document.createElement("canvas");
    canvas.width = width;
    canvas.height = height;
    const ctx = canvas.getContext("2d");
    ctx.putImageData(pixels, 0, 0);
    document.body.appendChild(canvas);
}

And lastly, let's put all functions together.


(async () => {
    const bytes = await fetchPng("./PngSuite-2017jul19/f00n2c08.png");
    checkMagic(bytes);

    let width, height;
    let pixels;

    let offset = 8;
    while (offset < bytes.length) {
        const chunk = splitChunk(bytes, offset);

        switch (chunk.type) {
            case "IHDR":
                const ihdr = parseIHDR(chunk.data);
                width = ihdr.width;
                height = ihdr.height;
                break;
            case "IDAT":
                pixels = parseIDAT(chunk.data, width, height);
                break;
        }

        offset += chunk.length;
    }

    draw(pixels, width, height);

})();

After this, you should see the target png file on page. You may feel it is so easy. Actually I emit a lot of complicated stuff like decompression, crc, filtering, etc. But for an introduction, this is enough. Check the spec and feel free to dig deeper!