Parsing PNGs with Node, Part 2
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!