Compression using Canvas and PNG-embedded data
I got this idea to stuff Javascript in a PNG image and then read it out using the getImageData() method on the canvas element. Unfortunately, for now, that means only Firefox, Opera Beta and the recent WebKit nightlies work. And before anyone else points out how gzip is superior, this is in no way meant as a realistic alternative. Earlier today I posted about a compressed 8 KB version of the Super Mario script using this technique. Here are some more details about what is going on.
Now, the image above may look like noise to you but it is actually the 124 kilobyte Prototype library embedded in a 30 kilobyte 8 bit PNG image file. In a lossless image format, you could in theory store any kind of data in the pixel values, for instance Javascript code as we're doing here. And since many image formats offer some form of compression we can take advantage of that to shrink our code to a smaller size, provided that we are able to extract that data again.
The first step was to find the best image format for the job, that means the one that gives the best compression while still being lossless. Here on the intertubes, we don't get a lot of image format choices and since JPEG is lossy, we're down to GIF and PNG.
For PNG we have two options, 24 bit and 8 bit. Using 24 bit RGB colors, we can store 3 bytes of data per pixel while 8 bit indexed colors only gives us 1 byte per pixel.
A quick test in Photoshop tells us that a 300x100 8 bit image with monochromatic noise compresses down to just 5 KB while a 100x100 24 bit image with similar noise applied to each of the R, G and A channels compresses down to about 20 KB. A regular 8 bit GIF comes in a bit heavier than the 8 bit PNG, so we go with the PNG option. (Photoshop noise doesn't alter every pixel, but code isn't random either, so I figure it equals out.)
Now we need to convert our Javascript file into color data and stuff it in a PNG file. For this purpose, I crafted this quick and dirty PHP script, which reads the Javascript file, creates a PNG image file and simply lets each pixel have a value 0-255 corresponding to the ascii value of the character in the script.
I ran into a problem here, since the image is created as a truecolor image and we need it to be 8 bit indexed and PHP won't make an exact conversion. I guess there are ways to create a palletted image from scratch in PHP/GD, but I haven't looked into that yet. The solution for now is to simply run the generated image through something like Photoshop and convert it to 8 bit there.
So now we have the Javascript all nice and packed up in a compressed PNG file and now we need to get it out again in the client. Using the canvas element, we simply paint the picture using drawImage() and then read all the pixel data using getImageData(). This data is given to us as a large array of values, where each pixels takes up 4 elements (RGBA), so we just take every 4 value and tack them all together into an eval()-ready string. And we're done.
The reading function can look something like this: pngdata.js
A few test results:
prototype-1.6.0.2.js
123 KB Javascript compressed to 30 KB PNG (24%)
jquery-1.2.3.min.js
53 KB Javascript compressed to 17 KB PNG (32%)
excanvas.js
24 KB Javascript compressed to 8 KB PNG (33%)
excanvas-compressed.js
10 KB Javascript compressed to 5 KB PNG (50%)
dijit.js
46 KB Javascript compressed to 16 KB PNG (35%)
Pretty decent results and even for packed (or otherwise minified) scripts we can shave off another 50% using this method. The PNG's can even be further compressed using various optimizing tools. Check the comments for more details and test results (thanks FreakCERS!).
Click here to play with the test images yourself.
There is of course a bit of overhead, since we need some code to read the data and execute it, but it can be cooked down to 300 bytes or so. Some files also don't compress well, ie. files that are already very compressed. I tested it on Mathieu 'p01' Henri's excellent (and heavily compressed) 3D Tomb 2, and since the file is already so small and compressed, whatever was saved by the PNG compression was lost in the overhead.
And of course, for larger scripts you will also feel a significant load time as first the image is being painted to the canvas and then the pixels are read. Reading and parsing the 69 KB PNG compressed from the 255 KB dijit-all.js Javascript takes about 5-6 seconds (in FF2, Safari and others are faster) which generally isn't acceptable. The 16 KB PNG from the 46 KB dijit.js Javascript takes only 500-1000 ms, so for smaller files this problem isn't so big.
Anyway, since the support for the getImageData method on the canvas element isn't widely supported yet, I guess this remains a curiosity for now and just another way to use/misuse the canvas. So, this is meant only as thing of interest and is not something you should use in most any real life applications, where something like gzip will outperform this.
Again, click here to see the "decompression" in action. And here to see the Mario game using this technique.
Now, the image above may look like noise to you but it is actually the 124 kilobyte Prototype library embedded in a 30 kilobyte 8 bit PNG image file. In a lossless image format, you could in theory store any kind of data in the pixel values, for instance Javascript code as we're doing here. And since many image formats offer some form of compression we can take advantage of that to shrink our code to a smaller size, provided that we are able to extract that data again.
The first step was to find the best image format for the job, that means the one that gives the best compression while still being lossless. Here on the intertubes, we don't get a lot of image format choices and since JPEG is lossy, we're down to GIF and PNG.
For PNG we have two options, 24 bit and 8 bit. Using 24 bit RGB colors, we can store 3 bytes of data per pixel while 8 bit indexed colors only gives us 1 byte per pixel.
A quick test in Photoshop tells us that a 300x100 8 bit image with monochromatic noise compresses down to just 5 KB while a 100x100 24 bit image with similar noise applied to each of the R, G and A channels compresses down to about 20 KB. A regular 8 bit GIF comes in a bit heavier than the 8 bit PNG, so we go with the PNG option. (Photoshop noise doesn't alter every pixel, but code isn't random either, so I figure it equals out.)
Now we need to convert our Javascript file into color data and stuff it in a PNG file. For this purpose, I crafted this quick and dirty PHP script, which reads the Javascript file, creates a PNG image file and simply lets each pixel have a value 0-255 corresponding to the ascii value of the character in the script.
I ran into a problem here, since the image is created as a truecolor image and we need it to be 8 bit indexed and PHP won't make an exact conversion. I guess there are ways to create a palletted image from scratch in PHP/GD, but I haven't looked into that yet. The solution for now is to simply run the generated image through something like Photoshop and convert it to 8 bit there.
So now we have the Javascript all nice and packed up in a compressed PNG file and now we need to get it out again in the client. Using the canvas element, we simply paint the picture using drawImage() and then read all the pixel data using getImageData(). This data is given to us as a large array of values, where each pixels takes up 4 elements (RGBA), so we just take every 4 value and tack them all together into an eval()-ready string. And we're done.
The reading function can look something like this: pngdata.js
A few test results:
prototype-1.6.0.2.js
123 KB Javascript compressed to 30 KB PNG (24%)
jquery-1.2.3.min.js
53 KB Javascript compressed to 17 KB PNG (32%)
excanvas.js
24 KB Javascript compressed to 8 KB PNG (33%)
excanvas-compressed.js
10 KB Javascript compressed to 5 KB PNG (50%)
dijit.js
46 KB Javascript compressed to 16 KB PNG (35%)
Pretty decent results and even for packed (or otherwise minified) scripts we can shave off another 50% using this method. The PNG's can even be further compressed using various optimizing tools. Check the comments for more details and test results (thanks FreakCERS!).
Click here to play with the test images yourself.
There is of course a bit of overhead, since we need some code to read the data and execute it, but it can be cooked down to 300 bytes or so. Some files also don't compress well, ie. files that are already very compressed. I tested it on Mathieu 'p01' Henri's excellent (and heavily compressed) 3D Tomb 2, and since the file is already so small and compressed, whatever was saved by the PNG compression was lost in the overhead.
And of course, for larger scripts you will also feel a significant load time as first the image is being painted to the canvas and then the pixels are read. Reading and parsing the 69 KB PNG compressed from the 255 KB dijit-all.js Javascript takes about 5-6 seconds (in FF2, Safari and others are faster) which generally isn't acceptable. The 16 KB PNG from the 46 KB dijit.js Javascript takes only 500-1000 ms, so for smaller files this problem isn't so big.
Anyway, since the support for the getImageData method on the canvas element isn't widely supported yet, I guess this remains a curiosity for now and just another way to use/misuse the canvas. So, this is meant only as thing of interest and is not something you should use in most any real life applications, where something like gzip will outperform this.
Again, click here to see the "decompression" in action. And here to see the Mario game using this technique.
At the end of the embedded data the text shows many "null" (or something like that)
May 4, 2008 at 1:06 PM Anonymoushttp://img257.imageshack.us/img257/254/00bm0.jpg
Forgot to say: using Firefox 3 beta 5, on XP SP2.
May 4, 2008 at 1:07 PM Jacob SeidelinAh, yea. Fixed. Thanks.
May 4, 2008 at 2:11 PM Martin HassmanNice work. Looks other packers can still beat this, e.g. for jQuery
May 4, 2008 at 3:41 PM Jacob SeidelinWell it's hard to compare since that post seems to be using an older version of jquery, but I figure using gzip will give about the same compression as this.
May 4, 2008 at 10:33 PM AnonymousIs this a job worth doing?.. Ofcource it still may be useable by browsers that doesn't support GZip compression yet (which one doesn't?)...
May 5, 2008 at 1:24 AM Jacob SeidelinMeanwhile, GZip compressing of given JS files gave me these results:
prototype-1.6.0.2.js
126,127 bytes -> 27,897 bytes GZip (22% vs. 24% PNG)
jquery-1.2.3.min.js
54,075 bytes -> 15,356 bytes GZip (28% vs. 32% PNG)
excanvas.js
23,822 bytes -> 6,770 bytes GZip (28% vs. 33% PNG)
excanvas-compressed.js
9,464 bytes -> 3,591 bytes GZip (38% vs. 50% PNG)
dijit.js
46,691 bytes -> 14,222 bytes GZip (30% vs. 35% PNG)
And, it needs no additional JS for "uncompressing", just a slight change in link to JS file. And even that can be avoided by writing rewrite rule (mod_rewrite).
So, I think, PNG compressing is just a fun theoretical stuff that has no practical use... But still this is a bit... extraordinari thing that was interesting to read and know of, thanks :)
Regards,
No, you are entirely correct. I can't think of many reasons to use this over gzip. As I said in the post, it is merely another canvas curiosity.
May 5, 2008 at 1:47 AM AnonymousDid you try on the packed or unpacked code of 3D TOMB II ?
May 5, 2008 at 2:33 AM Jacob SeidelinAnyhow I doubt the PNG compression would gain much as I tweaked my code to be very packer friendly to the type of packer ( LZSS ) I used.
Have you tried GIF vs PNG8 vs PNG24 ? and using PNGCrush and GIFOpt of course.
A crazy idea for huuge script would be to use a JPEG for coarse packing + a PNG for the error correction ;)
@p01: Using it on the packed code did nothing at all. I think it even added a few bytes. Unpacking and then PNG compressing gave a few hundred bytes, but with the reading overhead, any advantage was lost. So yea, your compression is very efficient :)
May 5, 2008 at 2:52 AM The ProfessorI actually wasn't aware of pngcrush (or gifopt), I just went with what Photoshop produces. Maybe I'll see if that makes any difference. I did try both GIF, PNG8 and PNG24, though, and PNG8 seemed to give the better result.
Using optipng, I was actually able to compress the images further (though not much)
May 5, 2008 at 4:34 AM Jacob SeidelinHere are a list of my results:
15515...dijit.js_optimized.png
15664...dijit.js.png
3920....excanvas-compressed.js_optimized.png
4180....excanvas-compressed.js.png
7443....excanvas.js_optimized.png
7623....excanvas.js.png
16577...jquery-1.2.3.min.js_optimized.png
16721...jquery-1.2.3.min.js.png
30000...prototype-1.6.0.2.js_optimized.png
30115...prototype-1.6.0.2.js.png
so there is actually a further 6.22% of the compressed png's to be shaved off in one case...
@FreakCERS: Nice. Thanks for that. I guess every byte counts :)
May 5, 2008 at 5:18 AM The ProfessorForgot to add that optipng will also automatically reduce the pallette, so you could probably bypass the use of photoshop entirely, and still end up with 8bit grayscale images
May 5, 2008 at 5:28 AM Virtual Goodsand for good measure, a link to optipng: http://optipng.sourceforge.net/
this is smart thinking! I really like it, its ingenious!
May 5, 2008 at 7:08 AM Anonymoustry using
May 5, 2008 at 8:08 AM Anonymousimagetruecolortopalette ($im ,false , 256);
just before you output the image.
Saves opening photoshop :)
hmm, actually that messes up the data - nevermind.
May 5, 2008 at 8:22 AM AnonymousOK, I think I got it sorted.
May 5, 2008 at 9:10 AM Jacob Seidelinuse this php & you'll get a 8-bit image with correct data
...
$iFileSize = filesize($filename);
$iWidth = ceil(sqrt($iFileSize / 1));
$iHeight = $iWidth;
//$im = imagecreatetruecolor($iWidth, $iHeight);
$im = imagecreate($iWidth, $iHeight);
$fs = fopen($filename, "r");
$data = fread($fs, $iFileSize);
fclose($fs);
$i = 0;
$colors = array();
for ($y=0;$y<$iHeight;$y++) {
for ($x=0;$x<$iWidth;$x++) {
$ord = ord($data[$i]);
if(!$colors[$ord]){
$colors[$ord] = imagecolorallocate($im,$ord,$ord,$ord);
}
$color = $colors[$ord];
imagesetpixel($im, $x, $y,$color);
$i++;
}
}
...
@david wilhelm: Yea, the imagetruecolortopalette function doesn't do an exact conversion. Maybe I'll try your method. Thanks!
May 5, 2008 at 9:43 AM EllisPNGout seems to give me the best results on optimizing the PNG file size.
May 5, 2008 at 11:36 AM EllisHere's the PHP refactored
May 5, 2008 at 11:42 AM Alek Traunic$filename = 'prototype-1.6.0.2.packed.js';
if(file_exists($filename))
{
$iFileSize = filesize($filename);
$iWidth = ceil(sqrt($iFileSize / 1));
$iHeight = $iWidth;
$im = imagecreate($iWidth, $iHeight);
$fs = fopen($filename, 'r');
$data = fread($fs, $iFileSize);
fclose($fs);
$i = 0;
$colors = array();
for($y=0;$y<$iHeight;++$y)
{
for($x=0;$x<$iWidth;++$x)
{
$ord = ord($data[$i]);
if(!$colors[$ord])
{
$colors[$ord] = imagecolorallocate($im,$ord,$ord,$ord);
}
$color = $colors[$ord];
imagesetpixel($im, $x, $y, imagecolorallocate($im, $color));
++$i;
}
}
header('Content-Type: image/png');
imagepng($im);
imagedestroy($im);
}
just curious, since your ascii range is 33-126 why not get 4 characters per pixel using the full RGBA? i may be missing something obvious but my first reaction was "why no color?"
May 5, 2008 at 12:52 PM AnonymousToo bad ImageData.data is not assimilited to an Array. It would greatly ease the generation of the code. For a 32bits PNG image, it could become as simple as: eval( String.fromCharCode.apply( 0, context.getImageData( 0,0,width,height ).data ) );
May 5, 2008 at 3:49 PM SpellcoderJacob: Btw you can gain 30-40 bytes on m.js
traunic: Bare in mind that some scripts use wider a range of characters.
OptiPNG, PNGOut(/PNGOutWin) or AdvanceCOMP might also help improve the compression and can throw away chunks of the PNG that aren't needed (like meta info on what program created it, color profiles etc).
May 6, 2008 at 2:02 AM The ProfessorJust for kicks, I've tried out AdvanceCOMP and pngout too (wrote a simple script to try different settings - so I think this should be as effecient as pngout can make it (but I am new to it, so I can't promise)
May 6, 2008 at 7:00 AM Alek Traunic15664...dijit.js.png
15008...dijit.js_advpng.png
15515...dijit.js_optipng.png
14987...dijit.js_pngout.png
4180....excanvas-compressed.js.png
4073....excanvas-compressed.js_advpng.png
3920....excanvas-compressed.js_optipng.png
4050....excanvas-compressed.js_pngout.png
7623....excanvas.js.png
7381....excanvas.js_advpng.png
7443....excanvas.js_optipng.png
7334....excanvas.js_pngout.png
16721...jquery-1.2.3.min.js.png
16191...jquery-1.2.3.min.js_advpng.png
16577...jquery-1.2.3.min.js_optipng.png
16133...jquery-1.2.3.min.js_pngout.png
30115...prototype-1.6.0.2.js.png
28944...prototype-1.6.0.2.js_advpng.png
30000...prototype-1.6.0.2.js_optipng.png
28839...prototype-1.6.0.2.js_pngout.png
it seems pngout wins most of the time, but not all...
@Mathieu 'p01' Henri: looking at www.asciitable.com I can only think that strings of text could possibly fall outside the 33-126. The vast majority of javascript will be covered and the exception strings could easily exists outside the image. Do you have any specific examples?
May 6, 2008 at 9:22 AM Anonymoustraunic: Any compressed ( not just minified ) JavaScript. See 3D TOMB II or Super Mario in 14k of JS for instance.
May 6, 2008 at 10:56 AM Alek TraunicSuch technique is becoming more and more common ( with things like Dean Edward's packer, or YUI compressor, ... ) especially among the people crazy enough to encode their JS into a PNG ;)
@m_p01_h: actually www.nihilogic.dk/labs/mario/mario.js gives an even better example in the aSpriteData values. so there is your example of a legit (although currently edge case) argument against the 33-126. Unfortunately, if this means supporting the entire possibilities of UTF-8 (or 16 if you like) then the practicality of the image solution having more than one char per pixel gets a bit blown. i would still be interested to see how much a 33-126 script gets compressed when using 4 chars per pixel. (it might look nice too ;)
May 6, 2008 at 1:32 PM AnonymousOne question ... Why?
June 9, 2008 at 5:11 AM AnonymousAs a trackback from translation of this article:
June 20, 2008 at 7:57 AM Anonymousthis 'compressing' method can be used for hiding exploits and malicious JS from user.
amazing work, that's really awesome stuff.
July 21, 2008 at 2:29 AM AnonymousWant to have a go at this myself, at the moment I'm relying on Google AJAX Libraries API to speed things up; which is also returning a good speed boost.
Keep it going, good work!
Ingenious. I'm thinking crazy cross-domain Ajax via PNGs. Nice.
November 10, 2008 at 8:29 AM Jacob Seidelin@Jamie: Pixel data access with Canvas is limited by a same-origin policy much like XHR, so that wouldn't work. Nice idea, though. =)
November 10, 2008 at 10:44 AM AnonymousWith modern broadband speeds surely the speed to decode versus the speed to load makes this pointless? In the days of 56k modems probably worth it but not now.
January 27, 2009 at 10:49 AM AnonymousIf your JS scripts are that large... maybe you're doing too much in JS? ;-)
I have managed most things with CSS, including tabbed menus and uber tooltips. I rarely use JS as I think a site can usually be designed without it, or at least with hardly any JS.
Even so, this is an amazing idea which proves you are a programming wizard :-)
Thanks for this fresh information; I was looking for a convenient place for compressing heavy video file. Besides these, I have found another place to compress and decompress all sorts of files. That is www.krunchit.net where you can zip or unzip ten files online altogether.
February 11, 2009 at 3:39 AM AnonymousAssuming no characters > 127 are used, you can squeeze out another kilobyte or so by doing something like this:
August 3, 2009 at 3:53 AM AnonymousOutside the loop...
$i = 1;
$last = ord($data[0]);
Inside the loop...
$ord = ord($data[$i]) + 127;
$offset = $last - $ord;
$last = $ord;
imagesetpixel($im, $x, $y, imagecolorallocate($im, $offset, $offset, $offset));
Appending my last comment:
August 3, 2009 at 4:05 AM AnonymousIt appears a minified PNG of Mootools using my method + PNGOUT is actually smaller than a minified GZIP. I wasn't expecting that! :)
Aaaand you can get rid of my last two comments. My math was wrong when trying to get the offset of each pixel - whoops.
August 3, 2009 at 4:52 AM Alex LeI wrote up a Ruby version for the string2png (using RMagick) here:
August 21, 2010 at 1:57 PM Jacob Seidelinhttp://alexle.net/archives/306
Cheers!
Alex
@Alex: Nice! I just noticed now that my PHP script can't be accessed anymore. Guess I should fix that.
August 21, 2010 at 3:21 PM calIt was awfully simple and crude anyway.
Also interesting that this technique found it's way into the 10K apart contest. Not using it for my own entry, though.
Played with some different encoding variants (bit depth, filters, bit packing) and it seems like PNG-8 using straight-up ASCII is probably the best for most files: http://www.iamcal.com/png-store/
August 23, 2010 at 12:23 AM AnonymousPNG is based on ZIP-compression. but all mayor browsers (the ones that are mentioned above plus many more) directly support GZIP-compression, which of course is also true for external JS-files, you can e.g. directly serve them from e.g. prototype.js.gz file, or alternatively activate runtime GZIP-compression on the server.
August 23, 2010 at 4:51 AM Bradand since ZIP and GZIP-compression (for single files) are more or less identical, apart from a few different header-bytes, I don't see the purpose of using ZIP via PNG. can anybody think of a real-word advantage? or is this just meant as a gimmick?
This may be a really dumb question... but what's the best way to call the loadPNGData() function on the html page to get the decoded javascript onto the page? :|
August 24, 2010 at 7:39 AM Adam NessFor folks who are wondering why compress at all, think of your monthly hosting bill. If you can cut the number of bits flowing to your upstream provider by 75%, that can make your hosting bill a heck of a lot cheaper.
August 25, 2010 at 6:29 PM Web Resources.euIt is an interesting hack, though as a lot of other folks pointed out, runtime or static gzip will save you the same amount (Both are compressing the bytes using the DEFLATE algorithm, and gzip has a smaller header than PNG)
Jast perfect ! :) xaxaxaxa :D
September 4, 2010 at 7:00 AM AnonymousThis technique is realy very helpfull but not for now !
We have to wait for full support of the canvas element !
Thanks !
you might archieve much better results by using all 8 bits per pixel. Currently you use only one bit (black or white) pixel, however, PNG will store the other 7 pixels anyway (and however, this format is not very well suited for black/white compression anyways). You could either try to use all pixels (creating coloury pictures) or create a GIF file (which would affect in even better compression, as I believe.
September 6, 2010 at 12:04 AM micksam7I stumbled upon this method earlier and played with my own implimentation of it.
September 9, 2010 at 6:15 AM Victor DossYou can encode it into all 3 RGB channels in a 24-bit png and use a program like pngout to compress it. I actually saw about a 5% improvement in my experiments; not a lot, but an improvement never the less. Attempting to encode info into the alpha channel inflated the size, though.
Anyway to hack this to get cross domain xhr working ? All we need is one server that takes an url and dumps the content pointed by URL in PNG. Any content in the world is now available in ajaxy way. Any takers ? Client side mashup just got interesting...
September 23, 2010 at 11:28 AM AnonymousCan the encoding done in C# ? need help!!
February 17, 2011 at 1:21 AM sagartu.net solution of it... Enjoy!!!
February 22, 2011 at 1:15 AM AnonymousAuthor : Sagar T U
usage : Give the byte array and destination file name...
public void createPNG(byte[] data, string fname)
{
// 8bpp
double w = Math.Floor(Math.Sqrt(data.LongLength));
double h = Math.Ceiling(data.LongLength/w);
int i;
Bitmap bmp = new Bitmap((int)w,(int)h);
Color[] clr = new Color[256];
for (i = 0; i < 256; i++)
{
clr[i] = Color.FromArgb(i, 0, 0);
}
i = 0;
for (int y = 0; y < h; y++)
{
for (int x = 0; x < w; x++)
{
if(i<data.LongLength)
bmp.SetPixel(x, y, clr[data[i]]);
i++;
}
}
bmp.Save(fname, ImageFormat.Png);
}
Compression using Canvas and PNG-embedded data article presents a method that allows packing the 124 kilobyte Prototype library embedded in a 30 kilobyte 8 bit PNG image file. The data inside PNG image is and read out with JavaScript using the getImageData() method on the canvas element.
March 9, 2012 at 10:51 PM AnonymousHave you considered embedding the script in an actual image you need for the document to reduce overhead even more?
July 24, 2012 at 6:08 PM Dr. ClueThis is not the first time I've seen images used in this fashion, but it is the first time I've noticed it in a web scripting context.
September 14, 2013 at 9:12 PMWhile some may consider it but a curiosity, there could be a number of practical uses for it including access control,widget packaging,active QR codes,as well as some interesting possibilities with HTML5 level custom protocol handlers and the like.
As to the compression itself, a little tinkering along with packing in other resources to save on request counts should yield
some fairly positive advantages overall that some may not be accounting for.
Even before the Internet went public we used images for conveying more than graphics. They've even been used for confidential communications by spooks and thugs and as multipart keys.
Thanks for the writeup. It came to my attention as a quasi random result while surfing up some compression information, making for an interesting diversion from the day's coding tasks