Developing HTML5 Applications, Part 3
- Introducing Online Image Processor
- Complementing the File API
- Handling Errors
- Conclusion
HTML5 has captured the attention of many web developers because of its exciting features for creating powerful browser-based applications. Imagine creating a smartphone application out of nothing more than HTML, JavaScript, CSS, and DOM, and have this application run in the smartphone's HTML5-compliant browser. Those developers not wanting to learn a smartphone technology (such as Apple's iOS or Google's Android) should find HTML5 to be an attractive (and simpler) alternative for creating smartphone apps.
Unfortunately, HTML5 is far from being finishedit will be years before this specification is finalizedand the various browsers that claim HTML5 support are inconsistent in how well they support various HTML5 features. Although this lack of consistency makes it difficult to create portable HTML5 applications, it's not an insurmountable problem, and this challenge to creating portable HTML5 applications is the impetus behind this article and its predecessors.
This article wraps up my three-part Developing HTML5 Applications series, which focuses on designing portable HTML5 applications. Part 1 provided an overview of HTML5, emphasizing features usdd by the applications presented in Parts 2 and 3 of this series. It also discussed testing these features in various browsers, and presented a JavaScript-based browser-identification library to help in writing portable HTML5 applications. Part 2 focused on design in the context of an example application called Animated Starry Diorama.
Part 3 continues to focus on design, but does so in the context of Online Image Processor (OIP, for short), a utility-oriented application whose only HTML5 dependencies are its canvas and File API features. After introducing OIP and focusing on its design, the article examines an HTML5 companion API to the File API for saving content to files in the local filesystem, and discusses various error scenarios that might arise when running OIP or other HTML5 applications.
Introducing Online Image Processor
As its name suggests, Online Image Processor is an HTML5 application that performs image-processing operations on loaded images. For brevity, OIP supports only convert-to-grayscale and sepia tone operations. It also supports an undo feature to restore the original image.
You can try out OIP by pointing your browser to my website. Alternatively, you can access this application from this article's accompanying code file. Figure 1 shows you what OIP's user interface looks like on Firefox.
Figure 1 The Firefox browser reveals that OIP has sepia-toned an image. Click to enlarge.
When the canvas cannot completely display an image, you can view different parts of the image by pressing a mouse button and dragging in an appropriate direction. Because you cannot drag past the viewer's dimensions, you might need to perform multiple drags.
If you run OIP from the accompanying code file on Chrome, you'll see a similar user interface. However, because of security restrictions (discussed later), you cannot load an image. This problem doesn't occur when you point your Chrome browser to my website URL for OIP.
In contrast to Firefox and Chrome, Safari's and Opera's support for HTML5's File API is much more limited. As a result, it's not possible to load an image in the same manner. Figure 2 shows you the web page that you'll initially encounter when you run OIP with Safari or Opera.
Figure 2 Select a file via the HTML form and click the Submit button. Click to enlarge.
Safari and Opera present a simple web page whose HTML form lets you choose an image file and then click the Submit button. Clicking this button uploads the selected image file to my server, where a PHP script saves the file and generates the web page shown in Figure 3.
Figure 3 You can perform the same image-processing operations from this web page. Click to enlarge.
Click the Save button when visible (it's not shown for Chrome, which doesn't support saving an image) to save the processed image to a file. Because there's no way to specify a filename with Firefox or Safari, the file is saved with a randomly generated filename.
Application Design
OIP is composed of OIP.html, OIP.js, OIP_kf.php, and OIP_uf.php, which collectively define this application. I organized OIP around the first two files to simplify maintenance. The latter two files are needed to support Opera and Safari.
OIP.html provides the necessary HTML structure and a script for integrating this application into a browser. Listing 1 presents the contents of this HTML file.
Listing 1OIP.html
<html> <head> <title> Online Image Processor </title> </head> <body> <script type="text/javascript" src="BrowserDetect.js"> </script> <script type="text/javascript"> var version; if (((version = getFirefoxVersion()) != null && cmp(version, "3.6") >= 0) || ((version = getChromeVersion()) != null && cmp(version, "6.0.472.63") >= 0)) { document.write("<div style='text-align:center'>"); document.write("<input type='file' id='input'"); document.write("onchange=\"loadImage(this.files); "+ "document.getElementById('input').value = '';\">"); document.write("<\/div>"); document.write("<p>"); var WIDTH = 640; var HEIGHT = 480; document.write("<div style='text-align: center'>"); document.write("<canvas id='vcanvas' width='"+WIDTH+"' "+ "height='"+HEIGHT+"'><\/canvas>"); document.write("<\/div>"); document.write("<script type='text\/javascript' "+ "src='OIP.js'><\/script>"); document.write("<p>"); document.write("<div style='text-align: center'>"); document.write("<select id='processor_list' "+ "onchange='process()'>"); document.write("<option selected value='undo'>"); document.write("Undo"); document.write("<\/option>"); document.write("<option value='ctgs'>"); document.write("Convert to grayscale"); document.write("<\/option>"); document.write("<option value='st'>"); document.write("Sepia tone"); document.write("<\/option>"); document.write("<\/select>"); if (getFirefoxVersion() != null) document.write(" <input type='submit' value='save' "+ "onclick='saveToFile()'>"); document.write("<\/div>"); } else if (((version = getSafariVersion()) != null && cmp(version, "5.0.2") >= 0) || ((version = getOperaVersion()) != null && cmp(version, "10.62") >= 0)) { document.write("<form action='http:\/\/tutortutor.ca\/cgi-bin"+ "\/OIP_uf.php' method='post' "+ "enctype='multipart\/form-data' "+ "style='text-align: center'>"); document.write("<label for='file'>Filename: <\/label>"); document.write("<input type='file' name='file' id='file'>"); document.write("<br>"); document.write("<input type='submit' name='submit' "+ "value='Submit'>"); document.write("<\/form>"); } else { document.write("<div style='text-align: center'>"); document.write("Your browser cannot properly execute this "+ "HTML5 application."); document.write("<\/div>"); } </script> </body> </html>
OIP.html includes the BrowserDetect.js JavaScript library that I presented in Part 1 of this series. This HTML file's small script subsequently calls most of this library's functions to verify that the current browser is capable of supporting the application's HTML5 features.
If the current browser is a recent version of Firefox or Chrome, the script writes the HTML for specifying the canvas element and including OIP.js's source code.
The <input> tag that precedes the canvas element is used to load an image. Loading takes place via the loadImage(this.files) call in this tag's onchange handler.
loadImage(this.files) is followed by document.getElementById('input').value = '' to clear the selected file. Before implementing undo, I used this assignment to force Firefox to reload an image when asked to do so after processing the image.
If the current browser is Opera or Safari, the script writes alternate HTML that describes a file-upload form. The HTML doesn’t also describe a canvas because that task is taken care of by a PHP script.
The file-upload form works with the PHP script located in OIP_uf.php to assist with a file upload. This form includes a pair of necessary items for uploading a file to this script:
- The <form> tag’s enctype='multipart/form-data' attribute identifies the content type to use when submitting the form. multipart/form-data indicates that the form requires binary data to be uploaded.
- The <input> tag’s type='file' attribute indicates that the input is to be processed as a filea browse button appears next to the input field when the form is displayed in a browser.
OIP.js contains the core of this application. This file's JavaScript code consists of several global variables and functions, and a little bit of inline code. Listing 2 presents the contents of this JavaScript file.
Listing 2OIP.js
// OIP.js // Obtain viewer canvas object and its context. var vcanvas = document.getElementById("vcanvas"); var vcontext = vcanvas.getContext('2d'); // Render a solid black viewer canvas -- black is the default fill. vcontext.fillRect(0, 0, WIDTH, HEIGHT); // Create original image canvas object and obtain its context. var oicanvas = document.createElement("canvas"); var oicontext = oicanvas.getContext('2d'); // Create processed image canvas object and obtain its context. var picanvas = document.createElement("canvas"); var picontext = picanvas.getContext('2d'); var image = new Image(); var dragging; var dragox; var dragoy; var dragdx; var dragdy; var prevdragdx; var prevdragdy; // Load selected image from first entry in files sequence. When the image is // loaded, handleReaderLoadEnd() is called to assign the image src to // image.src. This ultimately results in the function assigned to image.onload // executing, which initializes variables for performing picture movement on // the viewer canvas, and renders the loaded image on the original image, // processed image, and viewer canvases. function loadImage(files) { vcanvas.onmousemove = null; vcanvas.onmousedown = null; vcanvas.onmouseup = null; if (getSafariVersion() == null && getOperaVersion() == null) { var file = document.getElementById('input').files[0]; var reader = new FileReader(); reader.onloadend = handleReaderLoadEnd; reader.readAsDataURL(file); } else { image.src = filepath; } image.onload = function() { if (typeof(filename) != "undefined") { var img = new Image(); img.src = "http://tutortutor.ca/cgi-bin/OIP_kf.php?file="+filename; } dragging = false; dragox = 0; dragoy = 0; dragdx = 0; dragdy = 0; prevdragdx = 0; prevdragdy = 0; vcanvas.onmousemove = doMouseMove; vcanvas.onmousedown = doMouseDown; vcanvas.onmouseup = doMouseUp; document.onmouseup = doMouseUp; oicanvas.width = image.width; oicanvas.height = image.height; oicontext.drawImage(image, 0, 0); picanvas.width = image.width; picanvas.height = image.height; picontext.drawImage(image, 0, 0); drawImage(); } } // Convert image to grayscale. function ctgs() { var id = picontext.getImageData(0, 0, image.width, image.height); for (var i = 0; i < id.data.length; i += 4) { var val = id.data[i]*0.3+id.data[i+1]*0.59+id.data[i+2]*0.11; id.data[i] = val; id.data[i+1] = val; id.data[i+2] = val; } picontext.putImageData(id, 0, 0); drawImage(); } // Handle mouse movement events by dragging image over viewer canvas if // dragging is in effect. function doMouseMove(evt) { if (dragging) { if (image.width > WIDTH) { dragdx = evt.screenX-dragox+prevdragdx; if (dragdx < 0) dragdx = 0; else if (dragdx+WIDTH > image.width) dragdx = image.width-WIDTH; } if (image.height > HEIGHT) { dragdy = evt.screenY-dragoy+prevdragdy; if (dragdy < 0) dragdy = 0; else if (dragdy+HEIGHT > image.height) dragdy = image.height-HEIGHT; } drawImage(); } } // Handle mouse button pressed events by initiating a drag operation if image // does not completely fit on visible portion of viewer canvas. function doMouseDown(evt) { if (image.width < WIDTH && image.height < HEIGHT) return; dragging = true; dragox = evt.screenX; dragoy = evt.screenY; } // Handle mouse button released events to terminate a drag operation. function doMouseUp(evt) { dragging = false; prevdragdx = dragdx; prevdragdy = dragdy; } // Draw image on viewer canvas. function drawImage() { // Must clear viewer canvas. If canvas not cleared, if a larger image is // displayed, and if a smaller image is then displayed, parts of the larger // image will appear. vcontext.fillRect(0, 0, WIDTH, HEIGHT); vcontext.drawImage(picanvas, dragdx, dragdy, Math.min(image.width, WIDTH), Math.min(image.height, HEIGHT), (image.width < WIDTH) ? (WIDTH-image.width)/2 : 0, (image.height < HEIGHT) ? (HEIGHT-image.height)/2 : 0, Math.min(image.width, WIDTH), Math.min(image.height, HEIGHT)); } // Respond to the end of the image-load operation that was initiated in // loadImage(files). function handleReaderLoadEnd(event) { image.src = event.target.result; } // Process image by converting to grayscale or sepia toning image, or undo all // image-processing operations. function process() { var op = document.getElementById('processor_list').value; if (op == "ctgs") ctgs(); else if (op == "st") st(); else undo(); } // Save canvas contents to file with randomly generated filename. function saveToFile() { var strData = picanvas.toDataURL("image/png"); document.location.href = strData.replace("image/png", "image/octet-stream"); } // Sepia tone image. function st() { ctgs(); var id = picontext.getImageData(0, 0, image.width, image.height); for (var i = 0; i < id.data.length; i += 4) { id.data[i] = Math.min(id.data[i]+40, 255); id.data[i+1] = Math.min(id.data[i+1]+20, 255); id.data[i+2] = Math.max(id.data[i+2]-20, 0); } picontext.putImageData(id, 0, 0); drawImage(); } // Undo all image-processing operations. function undo() { var id = oicontext.getImageData(0, 0, image.width, image.height); picontext.putImageData(id, 0, 0); drawImage(); }
OIP.js begins by declaring several global variables. You typically want to avoid declaring global variables because name conflicts can arise when multiple script files are included. However, OIP avoids this problem because OIP.html includes only a single script file.
The following global variables are declared:
- vcanvas stores a reference to the canvas element created in OIP.html or OIP_uf.php (discussed later). This viewer canvas serves as a viewport into an image whose dimensions might exceed this canvas’s dimensions. If the entire image is larger than what this canvas can display, OIP lets you drag the image around so that you can view different portions of the image on this canvas.
- vcontext stores a reference to the viewer canvas’s 2D drawing context so that an image can be rendered on the canvas.
- oicanvas stores a reference to a background canvas element that stores the entire image. This original image canvas exists to support OIP’s undo feature. The contents of this canvas replace the processed image canvas (discussed later) following an undo operation.
- oicontext stores a reference to the original image canvas’s 2D drawing context so that an image that’s just been loaded can be rendered on the original image canvas.
- picanvas stores a reference to a background canvas element that stores a processed image. This processed image canvas exists to support OIP’s convert-to-grayscale and sepia tone image-processing operations. A portion of this canvas is rendered to the viewer canvas following an image-processing operation.
- picontext stores a reference to the processed image canvas’s 2D drawing context so that the processed image canvas’s contents can be replaced with a processed image or the original image.
- image stores a reference to a JavaScript Image object that’s used to load the image to be processed and make it available to the OIP script. The image will be loaded from the local filesystem (Firefox or Chrome) or the server (Safari or Opera).
- dragging is a Boolean variable that indicates whether (true) or not (false) the image is being dragged across the viewer canvas.
- dragox stores the drag origin’s x value. The drag origin stores the mouse’s screen coordinates when the mouse button is pressed.
- dragoy stores the drag origin’s y value.
- dragdx stores a delta x value that’s calculated during each mouse movement. This value identifies the x component of a pixel in the image that should appear in the upper-left corner of the viewer canvas.
- dragdy stores a delta y value that’s calculated during each mouse movement. This value identifies the y component of a pixel in the image that should appear in the upper-left corner of the viewer canvas.
- prevdragdx stores the previous dragdx value for use when calculating a new dragdx value. Without this variable, a lengthy drag operation requiring several mouse presses and drags would result in each subsequent drag causing the viewer canvas to display the image such that the image’s upper-left corner coincides with the viewer canvas’s upper-left corner. You would be unable to drag parts of the image into view.
- prevdragdy stores the previous dragdy value for use when calculating a new dragdy value.
OIP.js next declares several functions, beginning with loadImage(files). This function is responsible for making the user-selected image available to JavaScript via an Image object. loadImage(files) accomplishes the following tasks:
- Deactivate the viewer canvas’s mouse handlers in case a mouse handler executes concurrently with loadImage(files). I cannot see this happening with the supported browsers in this article because JavaScript is single-threaded. However, should this script support a future browser where this parallelism happens, I want to be prepared for that possibility. (I’ve encountered many weird anomalies and wouldn’t be surprised if this situation was to arise at some point.)
- If neither Safari nor Opera is the current browser, attempt to load the image and make its URL available to Image object image via the FileReader object named reader and its readAsDataURL(file) method. The handleReaderLoadEnd(event) function that’s assigned to reader’s onloadend property is invoked when image loading finishesthis function assigns the URL that’s stored in event.target.result to image’s src property via image.src = event.target.result;. (I’m not concerned about the scenario where the user chooses a non-image file in this version of the application. I’ll address this possibility later in the article, and I provide a second version of OIP in this article’s code archive that takes this possibility into account.)
- An anonymous function is assigned to image’s onload property. This function is called when the image is loaded and takes care of several tasks. The first task is to interact with a PHP script stored in an OIP_kf.php fileI’ll discuss this task later when I present the PHP portion of this application. The second task resets the image-dragging variables in anticipation of the user dragging a large image across the viewer canvas. The third task assigns mouse handlers to the viewer canvas and the enclosing document to support image draggingthe document.onmouseup = doMouseUp; assignment addresses the situation where the user releases the mouse button when the mouse cursor is not over the viewer canvas. The remaining tasks render the loaded image onto the original image canvas (to support undo), render the loaded image onto the processed image canvas (which gets copied to the viewer canvas), and call the drawImage() function to perform this copy so that (at least part of) the image is displayed.
However, if Safari or Opera is the current browser, the image is loaded via an image.src = filepath; assignment. I’ll explain where the filepath global variable comes from when I talk about PHP later on.
The process(), ctgs(), st(), and undo() functions take care of image processing, as follows:
- process() is the entry-point function into the image-processing portion of the script. It’s called from the <select> tag’s onchange handler (see Listing 1) and calls one of the other three functions based on the value that’s been selected by the user.
- ctgs() performs the convert-to-grayscale operation. It first calls the processed image canvas context’s getImageData(x, y, width, height) method to obtain an array of the image’s RGB bytes. It then iterates over this array (taking into account that each pixel corresponds to 4 bytesalpha is included), performing a grayscale operation on each pixel by multiplying each corresponding byte by a floating-point value. When the iteration finishes, ctgs() calls the complementary putImageData(imagedata, x, y) method to render the changed contents onto the processed image canvas. The drawImage() function is subsequently called to copy (at least part of) the processed image canvas to the viewer canvas, so that the user can see the resulting grayscaled image.
- st() performs the sepia tone operation. It behaves in a similar fashion to ctgs(), but first calls this function to convert the image to grayscale prior to carrying out the sepia tone logic.
- undo() cancels all previously performed image-processing operations by copying the contents of the original image canvas to the processed image canvas. It then calls drawImage() to make the original image visible to the user.
The final function that I'll discuss is saveToFile(), which is responsible for saving the contents of the processed image canvas to a file. Although the technique I've chosen for saving the file is simple (two lines), it's not very good and is not supported by Chrome.
The first line, var strData = picanvas.toDataURL("image/png");, calls the processed image canvas context's toDataURL(string) method to return a string containing a Base64-encoded PNG representation of the image's contents.
The second line, document.location.href = strData.replace("image/png", "image/octet-stream");, tells the browser to present a dialog box for saving the image. Unlike Firefox and Safari, Opera lets the user choose a storage location and enter a filename.
The PHP Alternative
I previously mentioned that OIP_uf.php is one of two PHP files needed to support the Opera and Safari browsers. When the user launches OIP from one of those browsers, selects a file via the resulting form, and clicks Submit, Listing 3's OIP_uf.php PHP script file executes.
Listing 3OIP_uf.php
#!/usr/bin/php <?php $type = $_FILES["file"]["type"]; if (($type == "image/gif" || $type == "image/jpeg") && ($_FILES["file"]["size"] < 3000000)) { if ($_FILES["file"]["error"] > 0) { echo "Error: ".$_FILES["file"]["error"]."<br>"; } else { $name = basename($_FILES["file"]["tmp_name"]); $name = $name . (($type == "image/gif") ? ".gif" : ".jpg"); move_uploaded_file($_FILES["file"]["tmp_name"], "/home/jfriesen/public_html/temp/".$name); echo "<h1 style='text-align: center'>"; echo "Online Image Processor"; echo "</h1>"; echo "<div style='text-align: center'>\n"; echo "<canvas id='vcanvas' width=640 height=480>\n"; echo "</canvas>\n"; echo "<script type='text/javascript' ". "src='/common/BrowserDetect.js'>\n"; echo "</script>\n"; echo "<script type='text/javascript'>\n"; echo "var WIDTH = 640;\n"; echo "var HEIGHT = 480;\n"; echo "</script>\n"; echo "<script type='text/javascript' src='/software/OIP/OIP.js'>\n"; echo "</script>\n"; echo "<script type='text/javascript'>\n"; echo "var filename = '".$name."';\n"; echo "var filepath = 'http://tutortutor.ca/temp/".$name."';\n"; echo "loadImage(null);\n"; echo "</script>\n"; echo "<p>\n"; echo "<div style='text-align: center'>\n"; echo "<select id='processor_list' onchange='process()'>\n"; echo "<option selected value='undo'>\n"; echo "Undo\n"; echo "</option>\n"; echo "<option value='ctgs'>\n"; echo "Convert to grayscale\n"; echo "</option>\n"; echo "<option value='st'>\n"; echo "Sepia Tone\n"; echo "</option>\n"; echo "</select>\n"; echo " <input type='submit' value='save' onclick='saveToFile()'>"; echo "</div>\n"; } } else { echo "Invalid file!"; } ?>
Listing 3 begins with the #!/usr/bin/php preamble to identify the source file as a PHP file to my web server.
Listing 3 first accesses the MIME type ($type = $_FILES["file"]["type"]) of the uploaded file. It then verifies that this type is one of image/gif or image/jpegfeel free to support additional image types. If verification fails, the PHP script outputs Invalid file!.
Because files are temporarily stored on my server, I've chosen to limit the maximum length of the image file that can be uploaded to 2,999,999 bytes. If the user uploads a file with a larger length, the script outputs Invalid file!.
Moving on, the script checks for any error that occurred during the upload ($_FILES["file"]["error"] > 0), and outputs a message identifying this error if the file could not be uploaded successfully. If there was no error, the script's real work begins.
The script obtains the uploaded file's temporary name ($name = basename($_FILES["file"]["tmp_name"])) and extension, and moves this file to my /home/jfriesen/public_html/temp/ directory so that it will hang around just long enough for OIP.js to access it.
The script then outputs HTML that's similar to Listing 1's HTML that's written when Firefox or Chrome is detected. I don't escape forward slashes like I do when outputting HTML with JavaScript (no <\/canvas> tag, for example) because this escaping isn't necessary in PHP.
Look carefully at Listing 3's HTML-generation code and you'll discover JavaScript filename and filepath global variable declarations. These variables identify the name of the temporary file without and with its http://tutortutor.ca/temp/ prefix, respectively.
Once an uploaded file is moved to /home/jfriesen/public_html/temp/, it remains on the server until deleted, which takes up server space. The temporary file should be deleted after OIS.js has loaded the file via the image object.
To solve this problem, I place the following code fragment at the start of the anonymous function assigned to image's onload property (see Listing 2):
if (typeof(filename) != "undefined") { var img = new Image(); img.src = "http://tutortutor.ca/cgi-bin/OIP_kf.php?file="+filename; }
The if statement executes after the image has loaded, and prevents the rest of the code from executing in a Firefox or Chrome context. Instead, the code executes on Safari or Opera because only these browsers cause Listing 3's PHP script to run and declare global variable filename.
The code guarded by the if statement creates an Image object and uses this object to run the PHP script in OIP_kf.php (see Listing 4), which kills the temporary file (identified via filename). This code works because Image objects make HTTP GET requests.
Listing 4OIP_kf.php
#!/usr/bin/php <?php unlink("/home/jfriesen/public_html/temp/".$_GET['file']); ?>