Exploring the PNG Image File Format with a PNG Viewer in JavaScript

The JavaScript program below, when run, prompts the user to upload a PNG file and displays that image. To see the code in action, copy it into an .html file and open that file in a web browser that runs JavaScript.

The code makes use of the “pako” library, by Andrey Tupitsin and Vitaly Puzrin, to uncompress the pixel data using the DEFLATE algorithm. For more information about pako, visit “https://github.com/nodeca/pako“.

Obviously there are easier and better ways to display a PNG in JavaScript, or for that matter within a web browser. This code is intended as a straightforward illustration of the process of decoding a PNG, with an eye toward eventually implementing a PNG viewer in some other language, though obviously another decompression library will needed in that case.

pngviewer


<html>
<body>

	<div><label>File to Load:</label></div>
	<div><input type="file" onchange="inputFile_Changed(this);"></input></div>
	<div><label>Output:</label></div>
	<div><div id="divOutput"></div></div>

<script type="text/javascript" src="https://rawgit.com/nodeca/pako/master/dist/pako.js"></script>

<script type="text/javascript">

// ui events

function inputFile_Changed(inputFile)
{
	var fileSpecified = inputFile.files[0];
	if (fileSpecified != null)
	{
		FileHelper.loadFileAsBytes
		(
			fileSpecified, inputFile_Changed_Loaded
		);
	}
}

function inputFile_Changed_Loaded(fileAsBytes)
{
	var fileAsPNG = PNG.fromBytes(fileAsBytes);
	var fileAsCanvas = fileAsPNG.toCanvas();
	var divOutput = document.getElementById("divOutput");
	divOutput.appendChild(fileAsCanvas);
}

// extensions

function ArrayExtensions()
{
	// extension class
}
{
	Array.prototype.addLookupArrays = function(keyName)
	{
		for (var i = 0; i < this.length; i++)
		{
			var element = this[i];
			var key = element[keyName];
			var arrayForKey = this[key];
			if (arrayForKey == null)
			{
				arrayForKey = [];
				this[key] = arrayForKey;
			}
			arrayForKey.push(element);
		}
		return this;
	}
}

// classes

function ByteStream(bytes)
{
	this.bytes = bytes;
	this.byteOffset = 0;
}
{
	// constants

	ByteStream.BitsPerByte = 8;

	// methods

	ByteStream.prototype.hasMoreBytes = function()
	{
		var returnValue = (this.byteOffset < this.bytes.length);
		return returnValue;
	}

	ByteStream.prototype.readByte = function()
	{
		var returnValue = this.bytes[this.byteOffset];
		this.byteOffset++;
		return returnValue;
	}

	ByteStream.prototype.readBytes = function(numberOfBytes)
	{
		var returnBytes = [];

		for (var i = 0; i < numberOfBytes; i++)
		{
			var byte = this.readByte();
			returnBytes.push(byte);
		}

		return returnBytes;
	}

	ByteStream.prototype.readInteger32 = function()
	{
		var returnValue = 0;

		var numberOfBytes = 4;

		for (var i = 0; i < numberOfBytes; i++)
		{
			var byte = this.readByte();
			var iReversed = numberOfBytes - i - 1;
			var valueOfByteInPlace = 
				byte << (iReversed * ByteStream.BitsPerByte);
			returnValue += valueOfByteInPlace;
		}

		return returnValue;
	}

	ByteStream.prototype.readString = function(numberOfBytes)
	{
		var returnString = "";

		for (var i = 0; i < numberOfBytes; i++)
		{
			var byte = this.readByte();
			var byteAsChar = String.fromCharCode(byte);
			returnString += byteAsChar;
		}

		return returnString;
	}
}

function Color(componentsRGBA)
{
	this.componentsRGBA = componentsRGBA;
}
{
	Color.prototype.clear = function()
	{
		for (var i = 0; i < this.componentsRGBA.length; i++)
		{
			this.componentsRGBA[i] = 0;
		}
	}

	Color.prototype.overwriteWith = function(other)
	{
		for (var i = 0; i < this.componentsRGBA.length; i++)
		{
			this.componentsRGBA[i] = other.componentsRGBA[i];
		}
	}
}

function FileHelper()
{
	// static class
}
{
	FileHelper.binaryStringToBytes = function(binaryStringToConvert)
	{
		var returnBytes = [];

		for (var i = 0; i < binaryStringToConvert.length; i++)
		{
			var byte = binaryStringToConvert.charCodeAt(i);
			returnBytes.push(byte);
		}

		return returnBytes;
	}

	FileHelper.loadFileAsBytes = function(fileToLoad, callback)
	{
		var fileReader = new FileReader();
		fileReader.onload = function(event)
		{
			var fileAsBinaryString = fileReader.result;
			var fileAsBytes = FileHelper.binaryStringToBytes
			(
				fileAsBinaryString
			);
			callback(fileAsBytes);
		}
		fileReader.readAsBinaryString(fileToLoad);
	}
}

function PNG(signature, chunks)
{
	this.signature = signature;
	this.chunks = chunks;

	this.chunks.addLookupArrays("typeCode");
}
{
	PNG.fromBytes = function(pngAsBytes)
	{
		var byteStream = new ByteStream(pngAsBytes);

		var signature = byteStream.readBytes(8);

		var chunks = [];
		
		while (byteStream.hasMoreBytes() == true)
		{
			var chunkPayloadSizeInBytes = byteStream.readInteger32();
			var chunkTypeCode = byteStream.readString(4);
			var chunkPayloadBytes = byteStream.readBytes(chunkPayloadSizeInBytes);
			var chunkChecksum = byteStream.readInteger32();

			var chunk = new PNG_Chunk
			(
				chunkTypeCode,
				chunkPayloadBytes,
				chunkChecksum
			);

			chunks.push(chunk);
		}

		var returnValue = new PNG(signature, chunks);

		return returnValue;
	}

	// dom

	PNG.prototype.toCanvas = function()
	{
		var headerChunk = this.chunks["IHDR"][0];
		var headerReader = new ByteStream
		(
			headerChunk.payloadBytes
		);

		var imageWidth = headerReader.readInteger32();
		var imageHeight = headerReader.readInteger32();
		
		var bitDepth = headerReader.readByte();

		var colorType = headerReader.readByte(); 

		var bytesPerPixel;
		if (colorType == 0) // grayscale without alpha
		{
			throw "Not yet implemented.";
		}
		else if (colorType == 2) // color without alpha
		{
			bytesPerPixel = 3;
		}
		else if (colorType == 4) // grayscale with alpha
		{
			throw "Not yet implemented.";
		}
		else if (colorType == 6) // color with alpha
		{
			bytesPerPixel = 4;
		}	

		var compressionMethod = headerReader.readByte();

		var filterMethod = headerReader.readByte();

		var interlaceMode = headerReader.readByte();
		
		var dataChunks = this.chunks["IDAT"]; // todo

		var pixelsCompressed = [];

		for (var i = 0; i < dataChunks.length; i++)
		{
			var dataChunk = dataChunks[i];

			pixelsCompressed = pixelsCompressed.concat
			(
				dataChunk.payloadBytes
			);
		}

		var pixelsDecompressed = pako.inflate
		(
			pixelsCompressed
		);

		// An alternative to the pako library is available at
		// https://rawgit.com/imaya/zlib.js/master/bin/zlib.min.js

		// var pixelsDecompressed = new Zlib.Inflate(pixelsCompressed).decompress();

		var canvas = document.createElement("canvas");
		canvas.width = imageWidth;
		canvas.height = imageHeight; 

		var pixelsDefilteredSoFar = [];

		var pixelReader = new ByteStream(pixelsDecompressed);

		var pixelRGBAZeroes = [0, 0, 0, 0];

		for (var y = 0; y < imageHeight; y++)
		{
			var filterTypeCode = pixelReader.readByte();

			for (var x = 0; x < imageWidth; x++)
			{
				var pixelRGBAFiltered = pixelReader.readBytes(bytesPerPixel);
				
				var pixelRGBADefiltered = [];

				for (var c = 0; c < pixelRGBAFiltered.length; c++)
				{
					var pixelComponentFiltered = pixelRGBAFiltered[c];

					var pixelComponentDefiltered;

					if (filterTypeCode == 0) // no filter
					{
						pixelComponentDefiltered = pixelComponentFiltered;
					}
					else if (filterTypeCode == 1) // "sub"
					{
						// Difference of this pixel's component
						// and the corresponding component 
						// of the pixel to the left.

						var pixelLeft;
						if (x == 0)
						{
							pixelLeft = pixelRGBAZeroes;
						}
						else
						{
							var pixelLeftIndex = 
								pixelsDefilteredSoFar.length - 1;
							pixelLeft = pixelsDefilteredSoFar[pixelLeftIndex];
						}
						var pixelLeftComponent = pixelLeft[c];
						pixelComponentDefiltered = 
							(pixelComponentFiltered + pixelLeftComponent) % 256;
					}
					else if (filterTypeCode == 2) // "up"
					{
						// Difference of this pixel's component
						// and the corresponding component 
						// of the pixel above.

						var pixelAboveIndex = 
							pixelsDefilteredSoFar.length - imageWidth;
						var pixelAbove = pixelsDefilteredSoFar[pixelAboveIndex];
						var pixelAboveComponent = pixelAbove[c];
						pixelComponentDefiltered = 
							(pixelComponentFiltered + pixelAboveComponent) % 256;
					}
					else if (filterTypeCode == 3) // "average"
					{
						// Average of left and above.

						var pixelLeft;
						var pixelAbove;

						if (x == 0)
						{
							pixelLeft = pixelRGBAZeroes;
						}
						else
						{
							var pixelLeftIndex = 
								pixelsDefilteredSoFar.length - 1;

							pixelLeft = pixelsDefilteredSoFar[pixelLeftIndex];
						}

						var pixelAboveIndex = 
							pixelsDefilteredSoFar.length - imageWidth;
						pixelAbove = pixelsDefilteredSoFar[pixelAboveIndex];

						var pixelLeftComponent = pixelLeft[c];
						var pixelAboveComponent = pixelAbove[c];


						pixelComponentDefiltered = 
							pixelComponentFiltered 
							+ Math.floor
							(
								(pixelLeftComponent + pixelAboveComponent)
								/2
							);

						pixelComponentDefiltered = pixelComponentDefiltered % 256;
					}
					else if (filterTypeCode == 4) // "Paeth"
					{
						// Uses left, above, and above left. 
						var pixelLeft;
						var pixelAbove;
						var pixelAboveLeft;

						if (x == 0)
						{
							pixelLeft = pixelRGBAZeroes;
							pixelAboveLeft = pixelRGBAZeroes;
						}
						else
						{
							var pixelLeftIndex = 
								pixelsDefilteredSoFar.length - 1;

							pixelLeft = pixelsDefilteredSoFar[pixelLeftIndex];

							if (y == 0)
							{
								pixelAboveLeft = pixelRGBAZeroes;
							}
							else
							{

								var pixelAboveLeftIndex = 
									pixelsDefilteredSoFar.length - 1 - imageWidth;

								pixelAboveLeft = pixelsDefilteredSoFar[pixelAboveLeftIndex];
							}
						}

						if (y == 0)
						{
							pixelAbove = pixelRGBAZeroes;
						}
						else
						{
							var pixelAboveIndex = 
								pixelsDefilteredSoFar.length - imageWidth;
							pixelAbove = pixelsDefilteredSoFar[pixelAboveIndex];
						}

						var pixelLeftComponent = pixelLeft[c];
						var pixelAboveLeftComponent = pixelAboveLeft[c];
						var pixelAboveComponent = pixelAbove[c];

						var paethValue = this.paethPredictor
						(
							pixelLeftComponent, 
							pixelAboveComponent,
							pixelAboveLeftComponent 
						);

						pixelComponentDefiltered = 
							(pixelComponentFiltered + paethValue) % 256;
					}
					else
					{
						throw "Unknown filter type."
					}

					pixelRGBADefiltered[c] = pixelComponentDefiltered;
				}

				pixelsDefilteredSoFar.push(pixelRGBADefiltered);
			}
		}

		var graphics = canvas.getContext("2d");

		for (var y = 0; y < imageHeight; y++)
		{
			for (var x = 0; x < imageWidth; x++)
			{
				var pixelIndex = y * imageWidth + x;
				var pixelRGBA = pixelsDefilteredSoFar[pixelIndex];

				var pixelColorAsString = 
					"(" + pixelRGBA[0] + "," 
					+ pixelRGBA[1] + "," 
					+ pixelRGBA[2]; 

				if (pixelRGBA.length == 3)
				{
					pixelColorAsString = 
						"rgb" + pixelColorAsString;
				}
				else
				{
					pixelColorAsString = 
						"rgba" 
						+ pixelColorAsString 
						+ "," + (pixelRGBA[3] / 255);
				}

				pixelColorAsString += ")";

				graphics.fillStyle = pixelColorAsString;
				graphics.fillRect(x, y, 1, 1);
			}
		}
	
		return canvas;
	}

	// helper methods

	PNG.prototype.paethPredictor = function(left, above, aboveLeft)
	{
		// Adapted from pseudocode found at the URL
		// https://www.w3.org/TR/PNG-Filters.html

		var estimate = left + above - aboveLeft;

		var differenceFromLeft = Math.abs(estimate - left);
		var differenceFromAbove = Math.abs(estimate - above);
		var differenceFromAboveLeft = Math.abs(estimate - aboveLeft);

		var returnValue;

		if 
		(
			differenceFromLeft <= differenceFromAbove
			&& differenceFromLeft <= differenceFromAboveLeft
		)
		{ 
			returnValue = left;
		}
		else if (differenceFromAbove <= differenceFromAboveLeft)
		{
			returnValue = above;
		}
		else
		{ 
			returnValue = aboveLeft;
		}

		return returnValue;
   	}
}

function PNG_Chunk(typeCode, payloadBytes, checksum)
{
	this.typeCode = typeCode;
	this.payloadBytes = payloadBytes;
	this.checksum = checksum;
}

</script>
</body>
</html>

Advertisements
This entry was posted in Uncategorized and tagged , , , , , . Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s