Decoding a PNG Image in JavaScript

If you're interested in how to decode a PNG without using a library, I've outlined the steps I took to decode a simple PNG with explanations and code samples. The methods below are not made for efficiency and it is recommended to use an existing library for projects.

Loading the File

In order to work on a PNG file the raw data will need to be retrieved. This is done using an XMLHttpRequest with the responseType set to 'arraybuffer'.

const req = new XMLHttpRequest();

req.responseType = 'arraybuffer';  
req.addEventListener('load', (e) => {  
  const arrayBuffer = e.target.response;

  if (!arrayBuffer) {
    throw new Error('No response');
  }

  // this the the byte array to decode
  const byteArray = new Uint8Array(arrayBuffer);
});
req.addEventListener('error', () => {  
  throw new Error('An error has occurred on request');
});

req.open('GET', 'foo.png', true);  
req.send();  

Checking the Signature

A PNG file must start with the following bytes: 137, 80, 78, 71, 13, 10, 26, 10. It is also recommended to check the signature of chunks (explained in the next section) that are constant when possible. This allows us to check the IHDR chunk as it must be the first chunk in a PNG file and the length is always 13.

Given this information, we can check that the PNG file starts with the following bytes: 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82.

const signature = [137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82];

for (let i = 0; i < signature.length; i++) {  
  if (byteArray[i] !== signature[i]) {
    return false;
  }
}

Reading the Chunk Meta Data

Before diving into how the chunks are read, understanding what a chunk is and the structure of one is required.

A PNG file has multiple chunks starting after the first 8 bytes (these are reserved for the PNG file signature). All chunks are structured in this order:

  1. length (4 bytes) - this includes only the length of the data portion of the chunk with a maximum value of 2^31
  2. type (4 bytes) - this is the type of chunk (must be treated as binary values)
  3. data (X bytes) - data in the chunk, may be 0
  4. CRC (4 bytes) - CRC calculated on the preceding bytes in the chunk, excludes length

The IHDR, IDAT, and IEND chunks are required. It is also possible to have multiple chunks of the same type.

Armed with the knowledge of chunks, recall that the check in the previous section there were some bytes appended to the PNG signature. The checked bytes can be seen as the PNG Signature + IHDR Chunk Length + IHDR Chunk Type.

The code below reads all the chunks meta data along with the position of where the data starts to make chunks easier to work with.

// bytesToUint32 converts the given bytes from the "start" to "count" to an unsigned 32 bit integer.
function bytesToUint32(byteArray, start, count) { ... }

const META_SIZE = 4;  
const chunks = [];  
let i = PNG_SIGNATURE.length; // skip the PNG signature  
while (i < byteArray.byteLength) {  
  const dataLength = bytesToUint32(byteArray, i, META_SIZE);

  i += META_SIZE;
  const signature = bytesToString(byteArray, i, META_SIZE);
  const type = chunkSignatureType[signature];

  i += META_SIZE;
  const dataStart = i;

  i += dataLength;
  const crc = bytesToUint32(byteArray, i, META_SIZE);

  i += META_SIZE;

  const meta = {
    type,
    signature,
    crc,
    data: {
      start: dataStart,
      length: dataLength,
    }
  };

  if (type) {
    chunks.push(meta);
  }

  // IEND must be the last chunk
  if (type === 'IEND') {
    break;
  }
}

Parsing IHDR

The IHDR chunk has the following form:

  1. Width: 4 bytes
  2. Height: 4 bytes
  3. Bit depth: 1 byte
  4. Color type: 1 byte
  5. Compression method: 1 byte
  6. Filter method: 1 byte
  7. Interlace method: 1 byte

From the start of the data of the chunk, the values can be retrieved by reading the bytes and offsetting by that amount for the next value.

Parsing IDAT

IDAT chunk or chunks contain the image data. It uses an LZ77 derivative and must be decompressed before use. If there are multiple IDAT chunks the data must be concatenated before decompression.

// I used pako for decompressing, 
const compressed = byteArray.slice(start, start + length);  
const decompressed = pako.inflate(compressed);  

Once decompressed, the data can be read though additional work will need to be done. An extra byte is at the start of each scanline (a row of pixels) which tells us what filter was used.

In the case of the sub filter, the following is used:

// filteredBytes represents bytes that have already been unfiltered.
// byteOffset represents the byte to start from in the unfilteredBytes array.
// bytesPerPixel is used to know how many bytes to subtract to get to the previous sample.
function setSubLine(unfilteredBytes, byteOffset, byteArray, start, length, bytesPerPixel) {  
  for (let i = 0; i < length; i++) {
    const offset = byteOffset + i - bytesPerPixel;
    const current = byteArray[start + i];
    const previous = offset >= byteOffset ? unfilteredBytes[offset] : 0;

    unfilteredBytes.push((current + previous) & 0xFF);
  }
}

The unfilteredBytes array now contains the pixel information starting from the upper-left of the image.

More info on filters, documentation can be found here

Summary

Reading a simple PNG in JavaScript is not too bad once the structure is known. I would recommend using a better way to read bytes and converting to them as it seemed to be the part that gave me the most trouble in keeping the code clean.

The finished simple PNG loading code for this post can be found at https://github.com/MWGitHub/basic-loaders/tree/master/src/png

Additional Reading

The PNG specification which can be found here is a great resource on working with the file format.

Unrelated but also interesting is how PNG versioning is done which can be found here