A GIF Image Viewer in JavaScript

The JavaScript code below, when run, will present a file upload button, which will allow you to select a .GIF file, upload it, and display it on the web page.  To see it in action, copy it into an .html file and open that file in a web browser that runs JavaScript. Or, for an online version, visit http://thiscouldbebetter.neocities.org/gifviewer.html.

You’d never actually need a JavaScript GIF viewer, of course, because any web browser that implements JavaScript already knows how to load and display GIFs just fine.  However, if you, like me, are interested in knowing more about the structure of a GIF file, and how it works at the bits-and-bytes level, this post is for you.

My original intent was this code was to use it as a basis for an online animated GIF editor.  Because, you know, there aren’t nearly enough animated GIFs on the internet.   But at the moment, it can only load static images, and there are some additional bugs besides.  One big one is that it doesn’t seem to understand how to handle black pixels, at all.  For example, if you drew a rainbow on a white background in Microsoft Paint, drew a single black line across it halfway down, and saved it as a GIF, and opened it with this code, nothing below that line would look even remotely right.  Something to do with my decompression algorithm, I guess.  If anyone out there can figure out how to fix it, I’d appreciate the help.

<html>
<body>

<script type="text/javascript">

// main

function GIFTest()
{}
{
	GIFTest.prototype.main = function()
	{
		var inputFileToLoad = document.createElement("input");
		inputFileToLoad.type = "file";
		var callback = this.fileLoadComplete.bind(this);
		inputFileToLoad.onchange = function(event)
		{
			var srcElement = event.target;
			var fileToLoad = srcElement.files[0];
			FileHelper.loadFileAsBinaryString
			(
				fileToLoad, callback
			);
		}	

		document.body.appendChild(inputFileToLoad);
	}

	// events

	GIFTest.prototype.fileLoadComplete = function(textFromFile)
	{
		var imageFileGIF = ImageFileGIF.readFromFileContents
		(
			textFromFile
		);

		var canvasSizeInPixels = imageFileGIF.blocks[0].imageSizeInPixels; // hack

		var canvas = document.createElement("canvas");
		canvas.width = canvasSizeInPixels.x;
		canvas.height = canvasSizeInPixels.y;

		document.body.appendChild(canvas);

		graphics = canvas.getContext("2d");
		
		graphics.strokeStyle = "Gray";
		graphics.strokeRect(0, 0, canvasSizeInPixels.x, canvasSizeInPixels.y);

		imageFileGIF.drawToGraphics(graphics);
	}
}

// classes

function BitStream(bytes)
{
	this.bytes = bytes;
	this.byteIndexCurrent = 0;
	this.bitOffsetWithinByte = 0;
}
{
	// constants

	BitStream.NaturalLogarithmOf2 = Math.log(2);

	// methods

	BitStream.prototype.hasMoreBits = function()
	{
		// todo
		return (this.byteIndexCurrent < this.bytes.length);
	}

	BitStream.prototype.readBit = function()
	{
		var byteCurrent = this.bytes[this.byteIndexCurrent];
		var bitOffsetReversed = 8 - this.bitOffsetWithinByte - 1;
		var returnValue = (byteCurrent >> bitOffsetReversed) & 1;

		this.bitOffsetWithinByte++;
		if (this.bitOffsetWithinByte >= 8)
		{
			this.bitOffsetWithinByte = 0;
			this.byteIndexCurrent++;
		}

		return returnValue;
	}

	BitStream.prototype.readBitLittleEndian = function()
	{
		var byteCurrent = this.bytes[this.byteIndexCurrent];
		var returnValue = (byteCurrent >> this.bitOffsetWithinByte) & 1;

		this.bitOffsetWithinByte++;
		if (this.bitOffsetWithinByte >= 8)
		{
			this.bitOffsetWithinByte = 0;
			this.byteIndexCurrent++;
		}

		return returnValue;
	}

	BitStream.prototype.readBits = function(numberOfBitsToRead)
	{
		var returnValues = [];

		for (var i = 0; i < numberOfBitsToRead; i++)
		{
			var bit = this.readBit();
			returnValues.push(bit);
		}

		return returnValues;
	}

	BitStream.prototype.readBitsLittleEndian = function(numberOfBitsToRead)
	{
		var returnValues = [];

		for (var i = 0; i < numberOfBitsToRead; i++)
		{
			var bit = this.readBitLittleEndian();
			returnValues.push(bit);
		}

		return returnValues;
	}

	BitStream.prototype.readInteger = function(numberOfBitsToRead)
	{
		var returnValue = 0;

		for (var i = 0; i < numberOfBitsToRead; i++)
		{
			var bit = this.readBit();
			returnValue = (returnValue << 1) | bit; // big-endian
		}

		return returnValue;
	}

	BitStream.prototype.readIntegerLittleEndian = function(numberOfBitsToRead)
	{
		var returnValue = 0;

		for (var i = 0; i < numberOfBitsToRead; i++)
		{
			var bit = this.readBitLittleEndian();
			returnValue = returnValue | (bit << i);
		}

		return returnValue;
	}
}

function ByteStream(bytesAsBinaryString)
{
	this.bytes = bytesAsBinaryString;
	this.byteIndexCurrent = 0;
}
{
	ByteStream.prototype.hasMoreBytes = function()
	{
		return (this.byteIndexCurrent < this.bytes.length);
	}

	ByteStream.prototype.readByte = function()
	{
		var returnValue = this.bytes.charCodeAt(this.byteIndexCurrent);
		this.byteIndexCurrent++;
		return returnValue;
	}

	ByteStream.prototype.readBytes = function(numberOfBytesToRead)
	{	
		var returnValues = [];

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

		return returnValues;
	}

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

		for (var i = 0; i < numberOfBytesToRead; i++)
		{
			var byte = this.readByte();
			returnValue |= byte << (8 * i); // little-endian
		}

		return returnValue;		
	}

	ByteStream.prototype.readString = function(lengthOfString)
	{	
		var returnValue = "";

		var bytes = this.readBytes(lengthOfString);

		for (var i = 0; i < bytes.length; i++)
		{
			var byte = bytes[i];
			returnValue += String.fromCharCode(byte);	
		}

		return returnValue;
	}
}

function Color(componentsRGB)
{
	this.componentsRGB = componentsRGB;
}

function CompressorLZW()
{
	// do nothing
}
{
	// instance methods

	CompressorLZW.prototype.decompressBytes = function(bytesToDecode, symbolWidthInBitsInitial)
	{
		var stringDecompressed = "";

		// Adapted from pseudocode found at the URL:
		// http://oldwww.rasip.fer.hr/research/compress/algorithms/fund/lz/lzw.html

		var bitStream = new BitStream(bytesToDecode);

		var dictionary = this.initializeDictionary(symbolWidthInBitsInitial);
		var symbolForClear = dictionary.length - 2;
		var symbolForBitStreamEnd = symbolForClear + 1;
		var symbolForBitWidthIncrease = null; // symbolForClear + 2;
		symbolWidthInBitsCurrent = symbolWidthInBitsInitial + 1;

		var symbolToDecode;
		var symbolDecoded;
		var pattern = "";
		var character = "";
		var patternPlusCharacter = "";
	
		while (bitStream.hasMoreBits() == true)
		{
			var symbolNext = bitStream.readIntegerLittleEndian
			(
				symbolWidthInBitsCurrent
			);

			pattern = dictionary[symbolToDecode];
			if (pattern == null)
			{
				pattern = "";
			}

			if (symbolNext == symbolForClear)
			{
				var dictionary = this.initializeDictionary(symbolWidthInBitsInitial);
				symbolWidthInBitsCurrent = symbolWidthInBitsInitial + 1;	
			}
			else if (symbolNext == symbolForBitWidthIncrease)
			{
				symbolWidthInBitsCurrent++;
			}
			else if (symbolNext == symbolForBitStreamEnd)
			{
				break;
			}
			else
			{
				symbolToDecode = symbolNext;
				symbolDecoded = dictionary[symbolToDecode];
	
				if (symbolDecoded == null)
				{
					character = pattern[0];
					patternPlusCharacter = pattern + character;
					stringDecompressed += patternPlusCharacter;
				}
				else
				{
					stringDecompressed += symbolDecoded;
					character = symbolDecoded[0];
					patternPlusCharacter = pattern + character;
				}
			
				if (dictionary[patternPlusCharacter] == null)
				{
					var dictionaryIndex = dictionary.length;

					dictionary[patternPlusCharacter] = dictionaryIndex;
					dictionary[dictionaryIndex] = patternPlusCharacter;

					if (dictionaryIndex >= Math.pow(2, symbolWidthInBitsCurrent) - 1)
					{ 	
						symbolWidthInBitsCurrent++;
					}
				}
			}
		}

		return stringDecompressed;
	}

	CompressorLZW.prototype.initializeDictionary = function(symbolWidthInBitsInitial)
	{
		var dictionary = [];

		var numberOfSymbolsInitial = Math.pow(2, symbolWidthInBitsInitial);
		var numberOfControlSymbols = 2; // 3;

		var numberOfSymbolsTotal = numberOfSymbolsInitial + numberOfControlSymbols;

		for (var i = 0; i < numberOfSymbolsTotal; i++)
		{
			var charCode = String.fromCharCode(i);
			dictionary[charCode] = i;
			dictionary[i] = charCode;
		}

		return dictionary;
	}
}

function Coords(x, y)
{
	this.x = x;
	this.y = y;
}

function FileHelper()
{
	// static class
}
{
	FileHelper.destroyClickedElement = function(event)
	{
		event.target.parentElement.removeChild(event.target);
	}

	FileHelper.loadFileAsBinaryString = function(fileToLoad, callback)
	{
		var fileReader = new FileReader();

		fileReader.onloadend = function(fileLoadedEvent)
		{
			if (fileLoadedEvent.target.readyState == FileReader.DONE)
			{
				var bytesFromFile = fileLoadedEvent.target.result;
			}

			callback(bytesFromFile);
		}
		fileReader.readAsBinaryString(fileToLoad);
	}

	FileHelper.saveTextAsFile = function(textToWrite, fileNameToSaveAs)
	{
		var textFileAsBlob = new Blob([textToWrite], {type:'text/plain'});

		var downloadLink = document.createElement("a");
		downloadLink.download = fileNameToSaveAs;
		downloadLink.innerHTML = "Download File";
		if (window.webkitURL != null)
		{
			// Chrome allows the link to be clicked
			// without actually adding it to the DOM.
			downloadLink.href = window.webkitURL.createObjectURL(textFileAsBlob);
		}
		else
		{
			// Firefox requires the link to be added to the DOM
			// before it can be clicked.
			downloadLink.href = window.URL.createObjectURL(textFileAsBlob);
			downloadLink.onclick = FileHelper.destroyClickedElement;
			downloadLink.style.display = "none";
			document.body.appendChild(downloadLink);
		}
	
		downloadLink.click();
	}
}

function ImageFileGIF
(
	filePath, 
	gifType, 
	logicalScreenSizeInPixels,
	backgroundColorIndex,
	aspectRatioType,
	globalColorTable,
	blocks
)
{
	this.filePath = filePath;
	this.gifType = gifType;
	this.logicalScreenSizeInPixels = logicalScreenSizeInPixels;
	this.backgroundColorIndex = backgroundColorIndex;
	this.aspectRatioType = aspectRatioType;
	this.globalColorTable = globalColorTable;
	this.blocks = blocks;	
}
{
	// static methods

	ImageFileGIF.readFromFileContents = function(bytesFromFile)
	{
		var byteStream = new ByteStream(bytesFromFile);

		var gifType = byteStream.readString(6);

		var logicalScreenSizeInPixels = new Coords
		(
			byteStream.readInteger(2),
			byteStream.readInteger(2)
		);

		var globalColorTableType = byteStream.readByte();
		var backgroundColorIndex = byteStream.readByte();
		var aspectRatioType = byteStream.readByte();

		var bitStreamGCT = new BitStream([globalColorTableType]);
		var hasGlobalColorTable = (bitStreamGCT.readInteger(1) == 1);

		var numberOfBitsPerColorComponent = bitStreamGCT.readInteger(3) + 1;
		var areColorsSorted = (bitStreamGCT.readInteger(1) == 1);
		var numberOfColors = Math.pow(2, bitStreamGCT.readInteger(3) + 1);

		var componentsPerColor = 3;
		var bytesPerColor = 
			componentsPerColor 
			* numberOfBitsPerColorComponent
			/ 8;

		var colors;

		if (hasGlobalColorTable == true)
		{
			colors = ImageFileGIF.readFromFileContents_Colors
			(
				byteStream,
				numberOfColors,
				bytesPerColor
			);
		}
		else
		{
			colors = null;
		}

		var globalColorTable = new ImageFileGIF_GlobalColorTable
		(
			bytesPerColor,
			areColorsSorted,
			colors
		);

		var compressorLZW = new CompressorLZW();

		var blocks = [];
		
		while (true)
		{
			var blockType = byteStream.readByte();
			if (blockType == 0x2C) // "," - image
			{	
				var cornerNWPosInPixels = new Coords
				(
					byteStream.readInteger(2),
					byteStream.readInteger(2)
				);

				var imageSizeInPixels = new Coords
				(
					byteStream.readInteger(2),
					byteStream.readInteger(2)
				);

				var packedFields = byteStream.readByte();

				var bitStream = new BitStream([packedFields]);

				var hasLocalColorTable = (bitStream.readBit() == 1);
				var isInterlaced = (bitStream.readBit() == 1);
				var isSorted = (bitStream.readBit() == 1);
				var reserved = bitStream.readBits(2);

				var localColorTable;

				if (hasLocalColorTable == true)
				{
					numberOfColors = Math.pow(2, bitStream.readInteger(3) + 1);

					var colors = ImageFileGIF.readFromFileContents_Colors
					(
						byteStream, 
						numberOfColors, 
						bytesPerColor
					);

					localColorTable = new ImageFileGIF_LocalColorTable
					(
						bytesPerColor,
						numberOfColors, 
						colors
					);
				}
				else
				{
					localColorTable = null;
				}

				var symbolSizeInBits = byteStream.readByte();
				var symbols = [];

				var symbolClear = Math.pow(2, symbolSizeInBits);
				var symbolEnd = symbolClear + 1;

				var bytesFromSubBlocksAllConcatenated = [];
				
				while (true)
				{
					var numberOfBytesInSubBlock = byteStream.readByte();
					if (numberOfBytesInSubBlock == 0)
					{
						break;
					}
					else
					{
						var bytesFromSubBlock = byteStream.readBytes
						(
							numberOfBytesInSubBlock
						);
						
						bytesFromSubBlocksAllConcatenated = bytesFromSubBlocksAllConcatenated.concat
						(
							bytesFromSubBlock
						);
					}
	
				} // end while - subblocks

				var pixelsDecompressedAsString = compressorLZW.decompressBytes
				(
					bytesFromSubBlocksAllConcatenated, 
					symbolSizeInBits
				);

				var pixelsDecompressedAsBytes = new ByteStream
				(
					pixelsDecompressedAsString
				).readBytes
				(
					pixelsDecompressedAsString.length
				);

				var imageBlock = new ImageFileGIF_BlockImage
				(
					cornerNWPosInPixels,
					imageSizeInPixels,
					localColorTable,
					symbolSizeInBits,
					symbols,
					pixelsDecompressedAsBytes
				);

				blocks.push(imageBlock);
			}
			else if (blockType == 0x21) // "!" - extension
			{
				var extensionType = byteStream.readByte();

				if (extensionType == 0xF9) // graphic control
				{
					var numberOfDataBytes = byteStream.readByte();
					var isThereATransparentBackgroundColor = (byteStream.readByte() == 1);
					var delayForAnimation = byteStream.readInteger(2);
					var indexForColorTransparent = byteStream.readByte();

					var endOfBlockFlag = byteStream.readByte();
				}
				else
				{
					throw "Unrecognized extension block type!";
					return;
				}
			}
			else if (blockType == 0x3B) // ";" - end of file
			{				
				break;
			}
			else
			{
				throw "The file does not match the expected format!";
				return;
			}
			
		} // end while - blocks

		var returnValue = new ImageFileGIF
		(
			"[from file]",
			gifType,
			logicalScreenSizeInPixels,
			backgroundColorIndex,
			aspectRatioType,
			globalColorTable,
			blocks
		);

		return returnValue;
	
	} // end method readFromFileAtPath()

	ImageFileGIF.readFromFileContents_Colors = function(byteStream, numberOfColors, bytesPerColor)
	{
		var colors = [];

		for (var i = 0; i < numberOfColors; i++)
		{
			var bytesForColor = byteStream.readBytes(bytesPerColor);
			var color = new Color(bytesForColor);
			colors.push(color);
		}

		return colors;
	}	

	// instance methods

	ImageFileGIF.prototype.drawToGraphics = function(graphics)
	{
		var imageBlock = this.blocks[0]; // hack

		var colorPalette = this.globalColorTable.colors;
		if (colorPalette == null)
		{
			colorPalette = imageBlock.localColorTable.colors;
		}

		var cornerNWPosInPixels = imageBlock.cornerNWPosInPixels;
		var imageSizeInPixels = imageBlock.imageSizeInPixels;
		var colorIndicesForPixels = imageBlock.pixelsAsBytes;

		for (var y = 0; y < imageSizeInPixels.y; y++)
		{
			for (var x = 0; x < imageSizeInPixels.x; x++)
			{
				var pixelIndex = y * imageSizeInPixels.x + x;
				var colorIndexForPixel = colorIndicesForPixels[pixelIndex];
				var colorComponentsRGB = colorPalette[colorIndexForPixel].componentsRGB;
				var colorAsStringRGB = 
					"rgb(" 
					+ colorComponentsRGB[0] + ","
					+ colorComponentsRGB[1] + ","	
					+ colorComponentsRGB[2]
					+ ")"; 
				graphics.fillStyle = colorAsStringRGB;
				graphics.fillRect
				(
					cornerNWPosInPixels.x + x, 
					cornerNWPosInPixels.y + y, 
					1, 1
				);
			}
		}
	}
}

function ImageFileGIF_BlockImage
(
	cornerNWPosInPixels,
	imageSizeInPixels,
	localColorTable,
	symbolSizeInBits, 
	symbols,
	pixelsAsBytes
)
{
	this.cornerNWPosInPixels = cornerNWPosInPixels;
	this.imageSizeInPixels = imageSizeInPixels;
	this.localColorTable = localColorTable;
	this.symbolSizeInBits = symbolSizeInBits;
	this.symbols = symbols;
	this.pixelsAsBytes = pixelsAsBytes;
}


function ImageFileGIF_GlobalColorTable(bytesPerColor, areColorsSorted, colors)
{
	this.bytesPerColor = bytesPerColor;
	this.areColorsSorted = areColorsSorted;
	this.colors = colors;
}

function ImageFileGIF_LocalColorTable(bytesPerColor, areColorsSorted, colors)
{
	this.bytesPerColor = bytesPerColor;
	this.areColorsSorted = areColorsSorted;
	this.colors = colors;
}

// run

new GIFTest().main();

</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