The Other Kiwi's Grass Is Greener

I've recently been building a simple proof-of-concept web application which may or may not see the light of day, but essentially it builds an SVG image directly within the browser through user interaction. One useful feature would be to allow the user to download the completed SVG file (as a backup or for use elsewhere). After quite a bit of experimentation with some of the newer HTML features I now have a (mostly) working solution, which I've reduced down to a simple example using the Kiwi to the left (the SVG for this image comes from a useful tutorial by Chris Coyier). If you can't see a Kiwi then your browser doesn't supporting displaying SVG images and certainly won't support the techniques in the rest of this post, so if you want to follow along you'll need to find a more modern browser.

Given that the SVG image is being built through standard DOM manipulation it is easy to get the text of the SVG file directly via JavaScript. For example, if we assume that the SVG element has the id svg then the following will get the full text of the image.
// get a copy of the DOM element with the id "svg"
var svg = document.getElementById("svg").cloneNode();

// get the text of this element, including the element and all children
var content = svg.outerHTML;

if (content == undefined) {
  // if there is no content then the outerHTML property isn't supported...

  // so create a new DIV element
  var div = document.createElement("div");

  // append the SVG to the DIV
  div.appendChild(svg);

  // use the widely supported innerHTML to get at the text of the SVG
  content = div.innerHTML;
}
This is a little more convoluted than I would like due to the fact that while all browsers support outerHTML not all of them (specifically Chrome) support it on the SVG element. Either way this code allows us to get the text we need to return as an SVG file to the user. Note that I've cloned the SVG node (line 2) rather than just referencing it directly, because changing a nodes parent in Chrome causes it to disappear from the document, which clearly isn't what we want. The more complex part of the problem then is figuring out how to return the text as an SVG to the user.

Given that I was looking at this in the context of a large web application, probably the easiest option would have been to send the text back to the server, which could then return it to the browser with a sensible filename and mime type (image/svg+xml), but this would clearly be rather inefficient, especially as the SVG image grows in size. It would also preclude the technique from being used in simple client side applications which didn't use a web server.

I stumbled upon an almost working example by accident. It turns out that if you simply return the SVG data from within a JavaScript href link the SVG will open within the browser, allowing the user to simply save the page to download the SVG. So we can wrap up the code above in a function:
function saveAsSVG(id) {
  var svg = document.getElementById(id).cloneNode();

  var content = svg.outerHTML;

  if (content == undefined) {
    var div = document.createElement("div");
    div.appendChild(svg);
    content = div.innerHTML;
  }

  return content;
}
And then call this function from a link
<a href="javascript:saveAsSVG('svg');">Download</a>
And you can see what happens with this live version of the above. If you've just tried that you will have hopefully noticed a few problems:
  • the image is missing all it's colour information (the kiwi and grass are both black)
  • when you save the image the browser suggests a HTML filename
  • the file you save is actually a HTML file not an SVG as the browser adds some extra HTML wrapping
While these are all clearly problems I was encouraged that I was at least heading in roughly the right direction. The missing colours I had expected (and will come back to later) and so I focused on the final two related problems first. After searching the web I came across the HTML5 download attribute that the specification seemed to suggest was exactly what I needed. Essentially the new download attribute, when applied to a link, signals to the browser that the item the link points to should be downloaded rather than display. It also allows you to specify a filename that the browser should suggest when the user clicks the link. So using this with the above gets us:
<a href="javascript:saveAsSVG('svg');" download="kiwi.svg">Download</a>
which again you can try for your self with this live version of the above. Now I don't know which browser you are using, but I was developing this in Firefox, and I was amazed to find that this works; clicking the link pops up a "save as" dialog with the correct file name and the saved file is the correct SVG file. Unfortunately trying the same thing in both Opera and Chrome doesn't work. In Opera, the attribute seems to be simply ignored as it doesn't change the behaviour, while in Chrome when you click the link nothing happens, and no errors are reported. As far as Opera is concerned the problem seems to be that I'm running the latest version available for Ubuntu which is v12, while the most recent version for Windows and Mac is 18; so if Opera are abandoning Linux I'm going to abandon testing with Opera.

Now while I could mandate specific browser versions for the app I was working on I really did want to try and find a more general solution. Also I had noticed that even once you have saved the SVG in Firefox the throbber never stops spinning, which usually suggests that the browser isn't really happy with what you have just done! Fortunately the search that led me to the download attribute also pointed me at a related new HTML feature; the blob.

A blob "represents a file-like object of immutable, raw data. Blobs represent data that isn't necessarily in a JavaScript-native format", which sounds like exactly what I need, especially as a blob can be referenced by a URL. I'm not going to detail all the possible features or ways of using blobs, but the following single line of code can be used to create a blob from the SVG content we already know how to extract:
var blob = new Blob([content], {type: "image/svg+xml"});
If you remember from before the variable content holds the text of the SVG file we want to create, so this line creates a binary blob from the SVG data with the right mime type. It is also easy to create and destroy URLs that point to these blobs. For example, the following creates and then immediately destroys a URL pointing to the blob.
var url = window.URL.createObjectURL(blob);
window.URL.revokeObjectURL(url);
You can probably get away without explicitly revoking every URL you create as they should be automatically revoked when the page is unloaded, but it is certainly a good idea to release them if you know that a) the URL is invalid and b) you are creating a lot of these URLs. One thing to note is that Chrome (and other WebKit based browsers) use a vendor prefix for the URL object (i.e. it's called webkitURL) so we need to make sure that regardless of the browser we are using we can access the functions we need. Fortunately this is easy to do by adding the following line (outside of any function):
window.URL = window.URL || window.webkitURL;

So the remaining question is how do we string all these bits together so that clicking a link on a page, builds a blob, generates a URL and then allows the user to download the URL. All the solutions I saw while searching the web did this in two steps. Essentially they had a link or button that when clicked would build the blob, generate the URL, and then add a new link to the document. This new link would use the download attribute and the blob URL to allow the user to download the file. While this works it doesn't seem particularly user friendly. Fortunately it is easy to combine all this functionality into a single link.

The trick to doing everything within a single link is to use the onclick event handler of the link to build the blob and generate the URL which it then sets as the href attribute of the link. As long as the onclick event handler returns true then the browser will follow the href which now points at the blob URL and when we combine this with the download attribute the result is that the user is prompted to download the blob. So the function I came up with looks like the following:
function saveAsSVG(id, link) {

  if (link.href != "#") {
    window.URL.revokeObjectURL(link.href);
  }
      
  var svg = document.getElementById(id).cloneNode();

  var content = svg.outerHTML;

  if (content == undefined) {
    var div = document.createElement("div");
    div.appendChild(svg);
    content = div.innerHTML;
  }
  
  var blob = new Blob([content], {type: "image/svg+xml"});

  link.href = window.URL.createObjectURL(blob);

  return true;
}
Essentially this is just all the code we have already seen assembled into a single function, although it assumes that the initial href is # so that it doesn't try and revoke an invalid URL (from testing it seems that trying to revoke something that isn't valid is simply ignored, but this is slightly safer). We can then call this function from a link as follows:
<a onclick="return saveAsSVG('svg',this);" href="#" download="kiwi.svg">Download</a>
And again you can try this for yourself using this live version of the above. Now this should work in all browsers that support these new HTML features which, according to the excellent Can I Use... website, should be most modern browsers. The only remaining issue is that the kiwi and grass are still both black in the downloadable SVG.

The reason the colour information is lost is that it was never part of the SVG to start with. In this example, the SVG is styled using CSS within the web page rather than directly within the SVG. When you download the SVG file you don't get the associated CSS styles and hence the elements revert back to being black. If we had used a self contained SVG image then everything would work and we would have no need to go any further. Fortunately, even when we style the SVG using CSS it is fairly easy to merge the styles into the SVG before the user downloads the image. In this example the page contains the following CSS style element.
<style title="kiwi" type="text/css">
  .ground {
    fill: #94d31b; 
  }

  .kiwi {
    fill: #C19A6B;
  }
</style>
Fortunately it is easy to add CSS styles to the SVG image before creating the blob object. If we assume that there is a function that returns a DOM style element then we can extend our existing function to embed this into the SVG as follows:
function saveAsStyledSVG(id, styles, link) {

  if (link.href != "#") {
    window.URL.revokeObjectURL(link.href);
  }
      
  var svg = document.getElementById(id).cloneNode();
  svg.insertBefore(getStyles(styles),svg.firstChild);

  var content = svg.outerHTML;

  if (content == undefined) {
    var div = document.createElement("div");
    div.appendChild(svg);
    content = div.innerHTML;
  }
  
  var blob = new Blob([content], {type: "image/svg+xml"});

  link.href = window.URL.createObjectURL(blob);

  return true;
}
Here you can see that on line 8 we call a function getStyles to get the DOM element and then append this as the first child of the root element of the SVG document, but the rest of the function is identical (other than the name as we can't overload functions in JavaScript). Now all we need to do is to define the getStyles function, which we do as follows:
function getStyles(names) {

  // create an empty style DOM element
  var styles = document.createElement("style");

  // set the type attribute to text/css
  styles.setAttribute("type","text/css");

  // get easy access to the Array slice method
  var slice = Array.prototype.slice;

  for (var i = 0 ; i < document.styleSheets.length ; ++i) {
    // for each style sheet in the document
    var sheet = document.styleSheets[i];

    if (names == undefined || names.indexOf(sheet.title) != -1) {
      // if we are including all styles or this sheet is one we want

      slice.call(document.styleSheets[i].cssRules).forEach(
        // slice the style sheet into separate rules
        function(rule){
          // create a new text node with the text of each CSS rule
          var text = document.createTextNode("\n"+rule.cssText);

          // add the rule to the style sheet we are building
          styles.appendChild(text);
        }
      );
    }
  }

  // return the completed style sheet
  return styles;
}
Hopefully the comments make it clear how this works, but essentially is finds all the rules within a named set of style sheets and adds them to a newly created style DOM element. It's worth noting that it is much safer to explicitly specify the style sheets you want to use because a) it will be quicker, b) the resultant file will be smaller but most importantly c) you can't access the content of style sheets loaded from another domain than the current page as this goes against the browser security model.

So given these new functions we can now use a link defined as follows to allow the kiwi to be downloaded in all it's glorious colours:
<a onclick="return saveAsStyledSVG('svg',['kiwi'],this);" href="#" download="kiwi.svg">Download</a>
And again here is a live version of the above for you to try, which should allow you to download the full colour SVG.

Hopefully you have found this a useful and interesting exploration of some of the newer features of HTML, but it is worth noting that the techniques discussed could be used for downloading a whole range of different file types without requiring server interaction.

0 comments:

Post a Comment