We left off the last post having successfully parsed the IHDR chunk, revealing metadata about the image.

Here’s the image I’m working against:

And here’s the final output of this post.

The IHDR chunk fully parsed provides this information:

{
  "width": 150,
  "height": 200,
  "bitDepth": 8,
  "colorType": 0,
  "compression": 0,
  "filter": 0,
  "interlace": 0
}

Now that we have the image metadata, let’s parse the rest of the image!

We’ll start by adding to our loop:

function parsePng(data) {
  let width;
  let height;
  let bitDepth;
  let colorType;
  let compression;
  let filter;
  let interlace;

  // this will hold the pixel bytes as we pull them from the image
+ let idatBuffers = []

  // first handle for the signature
  let i = 8;
+ let futureBufLength = 0

  // loop over the data
  while (i < data.length) {
    // find the length of the current chunk
    let chunkLength = data.readUInt32BE(i);

    // find the type of the current chunk
    let chunkName = data.toString("ascii", i + 4, i + 8);

+   let chunkData = data.subarray(i + 8, i + 8 + chunkLength)

    // access IHDR
    if (chunkName == "IHDR") {
      // create an offset to set the position at the beginning of the data section
      let ihdr = i + 8;

      // parse the header data
      width = data.readUInt32BE(ihdr);
      height = data.readUInt32BE(ihdr + 4);
      bitDepth = data[ihdr + 8];
      colorType = data[ihdr + 9];
      compression = data[ihdr + 10];
      filter = data[ihdr + 11];
      interlace = data[ihdr + 12];
    }

    // get each chunk of pixels
+   if (chunkName == 'IDAT') {
+     idatBuffers.push(chunkData)
+     futureBufLength += chunkLength
+   }

    // update the offset
    i = i + chunkLength + 12;
  }

  // return the header data
  return {
    width,
    height,
    bitDepth,
    colorType,
    compression,
    filter,
    interlace,
  };
}

In the last post, we talked about how there are no spaces between chunks, so updating the offset will bring us to the beginning of the next chunk. For us, the next chunk we care about is the IDAT chunk.

There can be multiple image data (IDAT) chunks, and these chunks take on the structure of any other chunk. Their first four bytes will contain the length of the data section. The next four will indicate the chunk type. After the type, we’re finally accessing the actual data, phew!

Within our IDAT if-block, it’s pretty straightforward. We use chunkData, which is a Buffer array containing only the data, and we push that into idatBuffers which will be our reference to our pixels.

Now that we have our pixels in an array, we’re ready for the next step.

const zlib = require('zlib')

function parsePng(data) {
  let width
  let height
  let bitDepth
  let colorType
  let compression
  let filter
  let interlace

  // this will hold the pixel bytes as we pull them from the image
  let idatBuffers = []

  // loop over the data
  // first handle for the signature
  let i = 8
  let futureBufLength = 0

  while (i < data.length) {
    let chunkLength = data.readUInt32BE(i)
    let chunkName = data.toString('ascii', i + 4, i + 8)
    let chunkData = data.subarray(i + 8, i + 8 + chunkLength)

    // get and set the signature
    if (chunkName == 'IHDR') {
      let ihdr = i + 8
      width = data.readUInt32BE(ihdr)
      height = data.readUInt32BE(ihdr + 4)
      bitDepth = data[ihdr + 8]
      colorType = data[ihdr + 9]
      compression = data[ihdr + 10]
      filter = data[ihdr + 11]
      interlace = data[ihdr + 12]
    }

    // get each chunk of pixels
    if (chunkName == 'IDAT') {
      idatBuffers.push(chunkData)
      futureBufLength += chunkLength
    }

    // signifies the end of the file
    // since we are all the way through the file, return the signature and pixels
+   if (chunkName == 'IEND') {
+     const pixel = Buffer.concat(idatBuffers, futureBufLength)

+     try {
+       let decompressed = zlib.inflateSync(pixel)

+       return {
+         signature: {
+           width,
+           height,
+           bitDepth,
+           colorType,
+           compression,
+           filter,
+           interlace,
+         },
+         pixelBytes: decompressed,
+       }
+     } catch (err) {
+       console.error('Failed to decompress:', err)
+       return
+     }
    }

    i = i + chunkLength + 12
  }
}

The last chunk of a valid PNG will always be the IEND chunk. Its data section will be empty, so once we find this chunk, we know we’re at the end of our file stream.

A few things happen here:

First, we create a new Buffer array with all of our pixel bytes (note that even though the idatBuffer array was a nested array, the pixel array will be flat).

That’s not the important part though - the most important part of this new set of code is here: zlib.inflateSync(pixel). PNGs compress their pixel data using zlib compression. You can find more information about this here in the spec.

We’re using the Node zlib library to “inflate” or decompress the pixel data. I plan to manually implement the algorithm in the future, but for now, we’ll just use the library.

Now that we have the image metadata (signature) and the raw pixels – we’re ready to get started! Check out the next blog post where we finally start decoding these pixels!