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.
36 comments:
At the end of the embedded data the text shows many "null" (or something like that)
http://img257.imageshack.us/img257/254/00bm0.jpg
Forgot to say: using Firefox 3 beta 5, on XP SP2.
Ah, yea. Fixed. Thanks.
Nice work. Looks other packers can still beat this, e.g. for jQuery
Well 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.
Is this a job worth doing?.. Ofcource it still may be useable by browsers that doesn't support GZip compression yet (which one doesn't?)...
Meanwhile, 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.
Did you try on the packed or unpacked code of 3D TOMB II ?
Anyhow 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 :)
I 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)
Here 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 :)
Forgot 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
and for good measure, a link to optipng: http://optipng.sourceforge.net/
this is smart thinking! I really like it, its ingenious!
try using
imagetruecolortopalette ($im ,false , 256);
just before you output the image.
Saves opening photoshop :)
hmm, actually that messes up the data - nevermind.
OK, I think I got it sorted.
use 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!
PNGout seems to give me the best results on optimizing the PNG file size.
Here's the PHP refactored
$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?"
Too 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 ) );
Jacob: 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).
Just 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)
15664...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?
traunic: Any compressed ( not just minified ) JavaScript. See 3D TOMB II or Super Mario in 14k of JS for instance.
Such 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 ;)
One question ... Why?
As a trackback from translation of this article:
this 'compressing' method can be used for hiding exploits and malicious JS from user.
amazing work, that's really awesome stuff.
Want 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.
@Jamie: Pixel data access with Canvas is limited by a same-origin policy much like XHR, so that wouldn't work. Nice idea, though. =)
With 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.
If 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.
Assuming no characters > 127 are used, you can squeeze out another kilobyte or so by doing something like this:
Outside 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:
It 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.
Post a Comment