Having covered JPEG-XR images recently, one thing has struck me as a little odd: there aren’t really any good cross-platform viewers available to look at them. Yes, it’s a bit of an obscure format, but shouldn’t there be something available? Well, I decided to make a simple Flash app to load a JPEG-XR image from a URL or a browse button and display it. Along the way I added support for PNG, JPEG, GIF, AVM1 SWF, and AVM2 SWF. Today’s article has the source code and the viewer itself. Added support for panning the image

Here is the source code for the viewer. A large portion of the code deals with error handling. Even so there seem to be quite a few security-related issues, especially with cross-site image loading via URL and many on Google Chrome.

package
{
	import flash.geom.Point;
	import flash.system.LoaderContext;
	import flash.display.AVM1Movie;
	import flash.display.MovieClip;
	import flash.events.UncaughtErrorEvent;
	import flash.events.ProgressEvent;
	import flash.geom.Rectangle;
	import flash.utils.ByteArray;
	import flash.errors.IOError;
	import flash.events.SecurityErrorEvent;
	import flash.events.AsyncErrorEvent;
	import flash.display.LoaderInfo;
	import flash.net.URLRequest;
	import flash.display.Loader;
	import flash.errors.MemoryError;
	import flash.errors.IllegalOperationError;
	import flash.net.FileFilter;
	import flash.events.IOErrorEvent;
	import flash.events.Event;
	import flash.net.FileReference;
	import flash.display.DisplayObject;
	import flash.events.FocusEvent;
	import flash.text.TextFieldType;
	import flash.events.MouseEvent;
	import flash.text.TextFieldAutoSize;
	import flash.text.TextFormat;
	import flash.text.TextField;
	import flash.display.Sprite;
	import flash.display.StageAlign;
	import flash.display.StageScaleMode;
 
	/**
	 * A viewer for any file Flash's Loader class can load (PNG, JPEG, JPEG-XR, GIF, SWF)
	 * @author Jackson Dunstan, http://JacksonDunstan.com
	 */
	public class ImageViewer extends Sprite
	{
		private static const MAX_CLICK_DIST:Number = 5;
		private static const UI_SPACING:Number = 5;
 
		private static const URL_DEFAULT_TEXT:String = "Enter URL...";
		private var url:TextField;
		private var urlFocused:Boolean;
 
		private var scrollbar:Sprite;
		private var scrollbarText:TextField;
 
		private var browseFileRef:FileReference;
 
		private var image:Sprite;
 
		private var loader:Loader;
 
		private var mouseDownPos:Point;
		private var dragging:Boolean;
 
		public function ImageViewer()
		{
			stage.align = StageAlign.TOP_LEFT;
			stage.scaleMode = StageScaleMode.NO_SCALE;
 
			init();
		}
 
		private function init(): void
		{
			removeChildren();
 
			browseFileRef = new FileReference();
 
			this.loaderInfo.uncaughtErrorEvents.addEventListener(
				UncaughtErrorEvent.UNCAUGHT_ERROR,
				onUncaughtError
			);
 
			// Create UI elements
 
			var title:TextField = new TextField();
			title.selectable = false;
			title.defaultTextFormat = new TextFormat("_sans", 24);
			title.autoSize = TextFieldAutoSize.LEFT;
			title.text = "Image Viewer";
 
			var subTitle:TextField = new TextField();
			subTitle.selectable = false;
			subTitle.defaultTextFormat = new TextFormat("_sans", 12);
			subTitle.autoSize = TextFieldAutoSize.LEFT;
			subTitle.text = "(supports JPEG, JPEG-XR, PNG, GIF, SWF)";
 
			var linkText:TextField = new TextField();
			linkText.selectable = false;
			linkText.mouseEnabled = false;
			linkText.defaultTextFormat = new TextFormat("_sans", 12, 0x0000ff);
			linkText.autoSize = TextFieldAutoSize.LEFT;
			linkText.htmlText = "by <a href=\"http://jacksondunstan.com\">JacksonDunstan.com</a>";
 
			var link:Sprite = new Sprite();
			link.useHandCursor = true;
			link.buttonMode = true;
			link.addChild(linkText);
 
			url = new TextField();
			url.type = TextFieldType.INPUT;
			url.multiline = false;
			url.defaultTextFormat = new TextFormat("_sans", 12);
			url.border = true;
			url.borderColor = 0x000000;
			url.autoSize = TextFieldAutoSize.LEFT;
			url.text = URL_DEFAULT_TEXT;
			url.autoSize = TextFieldAutoSize.NONE;
			url.width = stage.stageWidth * 0.50;
			url.height = url.getLineMetrics(0).height * 1.5;
			url.addEventListener(FocusEvent.FOCUS_IN, onURLFocusedIn);
			url.addEventListener(FocusEvent.FOCUS_OUT, onURLFocusedOut);
 
			var loadURLButton:Sprite = makeButton("Load");
			loadURLButton.addEventListener(MouseEvent.CLICK, onLoadURLButton);
 
			var browsePrompt:TextField = new TextField();
			browsePrompt.selectable = false;
			browsePrompt.defaultTextFormat = new TextFormat("_sans", 12);
			browsePrompt.autoSize = TextFieldAutoSize.LEFT;
			browsePrompt.text = "or... ";
 
			var browseButton:Sprite = makeButton("Browse");
			browseButton.addEventListener(MouseEvent.CLICK, onBrowseButton);
 
			// Add and position elements
			var curY:Number = 0;
 
			addChild(title);
			curY += title.height + UI_SPACING;
 
			subTitle.y = curY;
			addChild(subTitle);
			curY += subTitle.height + UI_SPACING;
 
			link.y = curY;
			addChild(link);
			curY += link.height + UI_SPACING;
 
			url.y = curY;
			addChild(url);
			curY += url.height + UI_SPACING;
 
			loadURLButton.x = url.width;
			loadURLButton.y = url.y;
			addChild(loadURLButton);
 
			browsePrompt.y = curY;
			addChild(browsePrompt);
 
			browseButton.y = browsePrompt.y;
			addChild(browseButton);
			curY += browseButton.height + UI_SPACING;
 
			// Center some elements
			title.x = (this.width - title.width) / 2;
			link.x = (this.width - link.width) / 2;
			subTitle.x = (this.width - subTitle.width) / 2;
			browsePrompt.x = (this.width - browsePrompt.width) / 2;
			browseButton.x = browsePrompt.x + browsePrompt.width;
 
			centerAllChildren();
		}
 
		private function makeButton(label:String): Sprite
		{
			const PAD:Number = 3;
 
			var tf:TextField = new TextField();
			tf.mouseEnabled = false;
			tf.selectable = false;
			tf.defaultTextFormat = new TextFormat("_sans");
			tf.autoSize = TextFieldAutoSize.LEFT;
			tf.text = label;
			tf.name = "lbl";
 
			var button:Sprite = new Sprite();
			button.buttonMode = true;
			button.graphics.beginFill(0xF5F5F5);
			button.graphics.drawRect(0, 0, tf.width+PAD, tf.height+PAD);
			button.graphics.endFill();
			button.graphics.lineStyle(1);
			button.graphics.drawRect(0, 0, tf.width+PAD, tf.height+PAD);
			button.addChild(tf);
 
			return button;
		}
 
		private function onURLFocusedIn(ev:FocusEvent): void
		{
			if (!this.urlFocused)
			{
				this.url.text = "";
				this.urlFocused = true;
			}
		}
 
		private function onURLFocusedOut(ev:FocusEvent): void
		{
			if (this.url.text == "")
			{
				this.url.text = URL_DEFAULT_TEXT;
				this.urlFocused = false;
			}
		}
 
		private function onBrowseButton(ev:MouseEvent): void
		{
			browseFileRef.addEventListener(Event.SELECT, onBrowseSelect);
			browseFileRef.addEventListener(IOErrorEvent.IO_ERROR, onBrowseIOError);
			browseFileRef.browse(
				[
					new FileFilter("Images", "*.png;*.jpg;*.jpeg;*.jpg;*.jpe;*.jxr;*.gif;*.swf"),
					new FileFilter("All", "*.*")
				]
			);
		}
 
		private function onBrowseSelect(ev:Event): void
		{
			removeChildren();
			showScrollbar();
 
			browseFileRef.addEventListener(Event.OPEN, onBrowseOpen);
			browseFileRef.addEventListener(ProgressEvent.PROGRESS, onLoadProgress);
			browseFileRef.addEventListener(IOErrorEvent.IO_ERROR, onBrowseIOError);
			browseFileRef.addEventListener(Event.COMPLETE, onBrowseFileLoaded);
			try
			{
				browseFileRef.load();
			}
			catch (illegalOpErr:IllegalOperationError)
			{
				fatalError("Browsing not allowed");
			}
			catch (memoryErr:MemoryError)
			{
				fatalError("Out of memory");
			}
			catch (err:Error)
			{
				fatalError("Unknown error");
			}
		}
 
		private function onBrowseOpen(ev:Event): void
		{
			// Load started. Nothing to do.
		}
 
		private function onBrowseFileLoaded(ev:Event): void
		{
			loadSafely(browseFileRef.data, null);
		}
 
		private function onBrowseIOError(ev:IOErrorEvent): void
		{
			fatalError("Browsing error");
		}
 
		private function onLoadURLButton(ev:MouseEvent): void
		{
			loadSafely(null, this.url.text);
		}
 
		private function loadSafely(bytes:ByteArray, url:String): void
		{
			removeChildren();
			showScrollbar();
 
			try
			{
				// Allow loading SWF files with code in them on AIR
				var context:LoaderContext = new LoaderContext();
				context.allowCodeImport = true;
 
				loader = new Loader();
 
				var cli:LoaderInfo = loader.contentLoaderInfo;
				cli.addEventListener(AsyncErrorEvent.ASYNC_ERROR, onLoadAsyncError);
				cli.addEventListener(IOErrorEvent.IO_ERROR, onLoadIOError);
				cli.addEventListener(SecurityErrorEvent.SECURITY_ERROR, onLoadSecurityError);
				cli.addEventListener(Event.COMPLETE, onImageLoaded);
				if (url)
				{
					cli.addEventListener(ProgressEvent.PROGRESS, onLoadProgress);
					loader.load(new URLRequest(url), context);
				}
				else
				{
					loader.loadBytes(bytes, context);
				}
			}
			catch (ioErr:IOError)
			{
				fatalError("Loading errror");
			}
			catch (securityErr:SecurityError)
			{
				fatalError("Security error");
			}
			catch (illegalOpErr:IllegalOperationError)
			{
				fatalError("Loading not supported");
			}
			catch (err:Error)
			{
				fatalError("Unknown error");
			}
		}
 
		private function showScrollbar(): void
		{
			var title:TextField = new TextField();
			title.selectable = false;
			title.defaultTextFormat = new TextFormat("_sans", 24);
			title.autoSize = TextFieldAutoSize.LEFT;
			title.text = "Loading...";
 
			scrollbar = new Sprite();
 
			scrollbarText = new TextField();
			scrollbarText.selectable = false;
			scrollbarText.defaultTextFormat = new TextFormat("_sans", 24, 0xffffff);
			scrollbarText.autoSize = TextFieldAutoSize.LEFT;
 
			setScrollbarPercent(0);
 
			var curY:Number = 0;
 
			addChild(title);
			curY += title.height + UI_SPACING;
 
			scrollbar.y = curY;
			addChild(scrollbar);
 
			addChild(scrollbarText);
 
			centerAllChildren();
		}
 
		private function setScrollbarPercent(percent:Number): void
		{
			var totalWidth:Number = stage.stageWidth * 0.5;
			var totalHeight:Number = this.url.height;
 
			// Draw border
			scrollbar.graphics.lineStyle(1, 0x000000);
			scrollbar.graphics.drawRect(0, 0, totalWidth, totalHeight);
 
			// Draw bar
			scrollbar.graphics.beginFill(0x000000);
			scrollbar.graphics.drawRect(0, 0, totalWidth*percent, totalHeight);
			scrollbar.graphics.endFill();
 
			// Center text on bar
			scrollbarText.text = int(percent) + "%";
			scrollbarText.x = scrollbar.width / 2;
			scrollbarText.y = (scrollbar.height - scrollbarText.height) / 2;
		}
 
		private function onLoadProgress(ev:ProgressEvent): void
		{
			setScrollbarPercent(ev.bytesLoaded / ev.bytesTotal);
		}
 
		private function onLoadAsyncError(ev:AsyncErrorEvent): void
		{
			fatalError("Async error");
		}
 
		private function onLoadIOError(ev:IOErrorEvent): void
		{
			fatalError("Loading error");
		}
 
		private function onLoadSecurityError(ev:SecurityErrorEvent): void
		{
			fatalError("Security error");
		}
 
		private function onImageLoaded(ev:Event): void
		{
			removeChildren();
 
			var loaderInfo:LoaderInfo = ev.target as LoaderInfo;
			image = new Sprite();
 
			// Match frame rate for AVM2 (AS3) SWFs
			if (image is MovieClip)
			{
				stage.frameRate = loaderInfo.frameRate;
				image.addChild(loaderInfo.content);
			}
			// Don't try to add AVM1 (AS1 & AS2) SWFs directly, since that throws an error. Use the
			// Loader instead.
			else if (image is AVM1Movie)
			{
				image.addChild(loader);
			}
			else
			{
				image.addChild(loaderInfo.content);
			}
 
			addChild(image);
 
			// Too big. Put at 0,0 and zoom with click.
			if (image.width > stage.stageWidth || image.height > stage.stageHeight)
			{
				image.scaleX = image.scaleY = getImageScale();
				stage.addEventListener(MouseEvent.MOUSE_DOWN, onMouseDown);
				stage.addEventListener(MouseEvent.MOUSE_UP, onMouseUp);
			}
			// Fits. Center image.
			else
			{
				var bounds:Rectangle = image.getBounds(image);
				image.x = -bounds.x + (stage.stageWidth - bounds.width) / 2;
				image.y = -bounds.y + (stage.stageHeight - bounds.height) / 2;
			}
		}
 
		private function onMouseDown(ev:MouseEvent): void
		{
			mouseDownPos = new Point(ev.stageX, ev.stageY);
			dragging = false;
			stage.addEventListener(MouseEvent.MOUSE_MOVE, onMouseMove);
		}
 
		private function onMouseUp(ev:MouseEvent): void
		{
			if (dragging)
			{
				dragging = false;
				image.stopDrag();
			}
			else if (getClickDistance(ev) < MAX_CLICK_DIST)
			{
				if (image.scaleX < 1 || image.scaleY < 1)
				{
					image.x = image.y = 0;
					image.scaleX = image.scaleY = 1;
				}
				else
				{
					image.x = image.y = 0;
					image.scaleX = image.scaleY = getImageScale();
				}
			}
		}
 
		private function onMouseMove(ev:MouseEvent): void
		{
			if (
				!dragging
				&& image.scaleX == 1
				&& image.scaleY == 1
				&& getClickDistance(ev) > MAX_CLICK_DIST
			)
			{
				dragging = true;
				image.startDrag();
				stage.removeEventListener(MouseEvent.MOUSE_MOVE, onMouseMove);
			}
		}
 
		private function getImageScale(): Number
		{
			return Math.min(
				stage.stageWidth / image.width,
				stage.stageHeight / image.height
			);
		}
 
		private function getClickDistance(ev:MouseEvent): Number
		{
			return Point.distance(new Point(ev.stageX, ev.stageY), mouseDownPos);
		}
 
		private function fatalError(msg:String): void
		{
			removeChildren();
 
			var title:TextField = new TextField();
			title.selectable = false;
			title.defaultTextFormat = new TextFormat("_sans", 24);
			title.autoSize = TextFieldAutoSize.LEFT;
			title.text = msg + ". Please restart and try again.";
			addChild(title);
 
			var restartButton:Sprite = makeButton("Restart");
			restartButton.addEventListener(MouseEvent.CLICK, onRestartButton);
			restartButton.x = (title.width - restartButton.width) / 2;
			restartButton.y = title.height;
			addChild(restartButton);
 
			centerAllChildren();
		}
 
		private function onRestartButton(ev:MouseEvent): void
		{
			init();
		}
 
		private function centerAllChildren(): void
		{
			var padX:Number = (stage.stageWidth - this.width) / 2;
			var padY:Number = (stage.stageHeight - this.height) / 2;
			for (var i:int; i < this.numChildren; ++i)
			{
				var child:DisplayObject = getChildAt(i);
				child.x += padX;
				child.y += padY;
			}
		}
 
		private function onUncaughtError(ev:UncaughtErrorEvent): void
		{
			fatalError("An error occurred: " + ev.error);
		}
	}
}

Launch the viewer. If the image is too large to fit in your browser window, it will be scaled down. Click the image to scale back to 100%. At that point, you can drag it around.

I hope you find this tool useful. If you find any bugs or solutions to security issues, please let me know in the comments.