Encode images for offline usage with HTML5 Canvas

Posted over 1 year ago


Introduction

This is the additional part to the previous tutorial Create a self caching website ready for offline usage with HTML5 and jQuery. Our script is able to cache and restore markup, stylesheets and javascripts. Basically everything you need to make a kick-ass website. All of these assets are simple text and can be cached/restored as is. But how could we cache non-text based content, such as images, in HTML5's localStorage?

How does it basically work?

Images can be represented as Base64 encoded data URI scheme. This technique has been around for quite a while and is supported by all modern browsers.

<img src="data:image/png;base64,..." alt="Image represented as data URI">

To generate this data URI scheme we use another feature of HTML5, the Canvas element. To be more specific, we only use two functions: drawImage() to get the image onto the Canvas element, and toDataURL() to generate the Base64 encoded data URI.

Preload images when online

We need to make sure that every image we want to cache is fully loaded. In that case we create a dummy image object and apply the jQuery load() event.

loadImages: ->                            #get all images from the content and cache them encoded in base64
  self   = @                              #keep a reference to this
  images = $ 'img', @bodyEl               #get all image elements, use <body> as context

  images.each ->                          #process every image
    $(new Image())                        #create a dummy image object
      .attr('src', $(@).attr('src'))      #load from the same path as found in the image element
      .load ->                            #add a load event for the dummy image
        self.loadImageCallback $(@)       #trigger the callback and pass the jquery object for the image

Create the Base64 data URI scheme

This is the pretty small function to generate a Base64 encoded data URI scheme we can store in localStorage. We create a dummy Canvas element and draw the image to encode on it. Then we simply return the output of toDataURL().

encodeImageBase64: (img) ->               #use canvas to get a base64 encoded string of the image
  canvas        = document.createElement('canvas') #create a temporary canvas element
  canvas.width  = img.width               #set width and..
  canvas.height = img.height              #..height of the canvas to fit the complete image
  context       = canvas.getContext('2d') #get the context for the canvas element

  context.drawImage img, 0, 0             #draw the image into the context
  canvas.toDataURL 'image/jpg'            #and return the base64 encoded data url of the complete canvas

Store the data URI scheme in localStorage

We already have a function to store data in the localStorage. Encode the image and store it.

loadImageCallback: (imgEl) ->
  encoded = @encodeImageBase64(imgEl.get(0))    #encode the image to a base64 data URI
  @loadAssetCallback imgEl.attr('src'), encoded #store encoded image in local storage

Restore images when offline

Restoring images from the localStorage cache is pretty simple. We replace the src attribute of the original image with the data URI scheme. That's it!

restoreImages: ->                         #restore all images from cache if possible
  self   = @                              #keep a reference to this
  images = $ 'img', @bodyEl               #get all image elements, use <body> as context

  images.each ->                          #process every image
    imgEl   = $(@)                        #get jquery object for the image element
    content = self.getStorage(imgEl.attr('src')) #get the cached content for the image path

    if content isnt null                  #make sure the image is cached
      imgEl.attr 'src', content           #restore image via the cached base64 data URI

The complete script

class SCOW
  constructor: ->
    @headEl      = $ 'head'                 #snapshot of the head element
    @bodyEl      = $ 'body'                 #snapshot of the body element
    @curFileName = @getFileName()           #get the requested path
    @assets      = @getStorage('assets')    #initialize assets index
    
    if @assets is null                      #if there is no assets index yet
      @assets = ['js/jquery.js', 'js/scow.js'] #create it, these two assets are cached by appcache
      @setStorage 'assets', @assets         #and store it
      
    applicationCache.addEventListener 'updateready', -> #if the manifest files are newly cached
      localStorage.clear()                  #clear also local store
    
    if navigator.onLine                     #check if there is a internet connection
      @cacheCurrentFile()                   #if so cache the current file
    else
      @restoreFromCache()                   #try to restore the requested file from cache
      
    if location.host.indexOf('localhost') isnt -1 #check if we are in dev mode
      $('#new-cache').show()                #if so show the link to trigger a new cache via /new_cache 
    
    @updateAssetsIndex()                    #output the current asset index
  
  getFileName: ->
    path     = location.pathname.split('/') #get current pathname and split it by /
    filename = path[path.length - 1]        #last part in array is filename
    
    if filename.length is 0                 #check if the root path / is requested
      filename = 'index.html'               #fallback to index.html

    filename                                #return filename
                                                                                               
  getStorage: (name) ->                     #helper function to read/decode JSON from local storage
    item = localStorage.getItem(name)       #try to read from local storage                        
                                                                                               
    if item isnt null                       #item was found in storage                             
      item = JSON.parse(item)               #json encoded object                                   
                                                                                               
    item                                    #return the object or null if not found                
                                                                                               
  setStorage: (name, value) ->              #helper function to write/encode JSON to local storage 
    item = JSON.stringify(value)            #create json string                                    
                                                                                               
    localStorage.setItem name, item         #write json string to local storage                    
  
  updateAssetsIndex: ->                     #output cached files in a list for the demo
    listEl = $ '#cached-files'              #we dont cache this list element globally because it could change in $body
    
    listEl.children().remove()              #remove all previously added list items
    for asset in @assets                    #for every path in the assets index
      listEl.append "<li>#{asset}</li>"     #append a list item containing the path
      
  isAssetCached: (path) ->                  #check if a asset is already cached - in the assets index
    $.inArray(path, @assets) isnt -1        #return true if already cached otherwise false
                                                                                               
  loadAssetCallback: (path, content) ->     #is called when a file is fully loaded
    unless @isAssetCached(path)             #check if asset already cached
      @assets.push path                     #add path to assets index
      @setStorage path, content             #cache the file with the path as key
      @setStorage 'assets', @assets         #store the new assets index array
      @updateAssetsIndex()                  #update the demo list of the assets index
  
  loadAsset: (path, callback = ->) ->       #load a asset with $.get, allow a additional callback
    $.get path, (content) =>                #get the file content
      @loadAssetCallback path, content      #content loaded, invoke the callback
      callback path, content                #invoke the additional callback
  
  loadAssets: (paths) ->                    #cache either css or scripts
    for path in paths                       #go through all paths
      unless @isAssetCached(path)           #check if asset already cached
        @loadAsset path                     #start loading the asset
    
  getAssets: (selector, src) ->             #extract the assets from the header and return array
    retArr = []                             #array to return
    
    for el in @headEl.find(selector)        #all elements matching the selector
      retArr.push $(el).attr(src)           #read the attribute containing the source path and store it in array
      
    retArr                                  #return a array of the file paths
  
  encodeImageBase64: (img) ->               #use canvas to get a base64 encoded string of the image
    canvas        = document.createElement('canvas') #create a temporary canvas element
    canvas.width  = img.width               #set width and..
    canvas.height = img.height              #..height of the canvas to fit the complete image
    context       = canvas.getContext('2d') #get the context for the canvas element
    
    context.drawImage img, 0, 0             #draw the image into the context
    canvas.toDataURL 'image/jpg'            #and return the base64 encoded data url of the complete canvas
  
  loadImageCallback: (imgEl) ->
    encoded = @encodeImageBase64(imgEl.get(0))    #encode the image to a base64 data URI
    @loadAssetCallback imgEl.attr('src'), encoded #store encoded image in local storage
  
  loadImages: ->                            #get all images from the content and cache them encoded in base64
    self   = @                              #keep a reference to 'this'
    images = $ 'img', @bodyEl               #get all image elements, use <body> as context
    
    images.each ->                          #process every image
      $(new Image())                        #create a dummy image object
        .attr('src', $(@).attr('src'))      #load from the same path as found in the image element
        .load ->                            #add a load event for the dummy image
          self.loadImageCallback $(@)       #trigger the callback and pass the jquery object for the image
    
  cacheCurrentFile: ->                      #cache the requested .html file
    cssAssets = @getAssets('link[rel="stylesheet"]', 'href') #get array of stylesheets
    jsAssets  = @getAssets('script', 'src') #get array of javascripts
    cacheObj  =                             #this will hold the cached page and all assets references
      bodyHtml   : @bodyEl.html()           #cache the content of the current file
      title      : document.title           #cache the title
      stylesheets: cssAssets                #array with all stlesheets
      javascripts: jsAssets                 #array with all javascripts
    
    @loadAssetCallback(@curFileName, cacheObj) #manually invoke the callback and save the object
    @loadAssets cssAssets                   #begin to load all the css assets
    @loadAssets jsAssets                    #same with the javascripts
    @loadImages()                           #begin to load, encode and cache images

  restoreHeader: (paths, wrapper) ->        #reassemble and include the cached files
    combined  = ''                          #include all in one string
      
    for path in paths                       #go through all cached assets
      content = @getStorage(path)           #try to get the content from local storage
      
      if content isnt null                  #check if the requested file is cached
        combined += content                 #add the cached content

    $(wrapper)                              #create a jquery object from the wrapper markup
      .text(combined)                       #set the content
      .appendTo @headEl                     #and append it to the head
    
  restoreImages: ->                         #restore all images from cache if possible
    self   = @                              #keep a reference to 'this'
    images = $ 'img', @bodyEl               #get all image elements, use <body> as context
    
    images.each ->                          #process every image
      imgEl   = $(@)                        #get jquery object for the image element
      content = self.getStorage(imgEl.attr('src')) #get the cached content for the image path
      
      if content isnt null                  #make sure the image is cached
        imgEl.attr 'src', content           #restore image via the cached base64 data URI
  
  restoreFromCache: ->                      #restore requested file and assets from cache
    cached = @getStorage(@curFileName)      #get the cached object with body, title and assets refs
    
    if cached isnt null                     #check if the file is cached
      @bodyEl.html     cached.bodyHtml      #restore body with original dom
      document.title = cached.title         #restore original title
      
      #restore all stylesheets via including them into the header
      @restoreHeader cached.stylesheets, '<style type="text/css"/>'
      
      #restore all javascripts
      @restoreHeader cached.javascripts, '<script type="text/javascript"/>'
      
      @restoreImages()                      #restore all images

jQuery -> (new SCOW)                        #initially create the class when the DOM is ready

Disadvantage of using Canvas to generate the data URI

It turned out that generating these data URI schemes is pretty easy with HTML5's Canvas element. But there is one major drawback: the size of the encoded image. Since it's Base64 encoded it should have an additional size of 1/3 of the original image. This is not true if we encode the image with the Canvas function toDataURL(). In my tests it's almost 4 times the size of the original image!

Let us run a little test, first with javascript and the toDataURL() function. Tested with the latest version of Chrome.

$.get 'images/occupy_ffm1.jpg', (img) =>
  enc = @encodeImageBase64($('img:first').get(0))
  console.log "Image Size: #{Math.floor(img.length / 1024)}kb  Base64 Size: #{Math.floor(enc.length / 1024)}kb"
  #=> Image Size: 123kb  Base64 Size: 491kb

See? Now let's try to encode it to Base64 with Ruby.

require 'base64'

img = File.open('images/occupy_ffm1.jpg').read
enc = Base64.encode64(img)

puts "Image Size: #{img.length / 1024}kb  Base64 Size: #{enc.length / 1024}kb"
#=> Image Size: 128kb  Base64 Size: 173kb

That seems correct, 0.35 times larger than the original size.

I've mentioned the localStorage limit of 3-5MB in the previous tutorial. You can imagine that this limit is reached pretty quickly if you cache some images.

If you really need to cache a lot of images you should use Application Cache instead. The offline manifest (see previous tutorial) could look like this:

CACHE MANIFEST

CACHE:
index.html
offline.html
js/jquery.js
js/scow.js
images/occupy_ffm1.jpg
images/occupy_ffm2.jpg

FALLBACK:
/ offline.html

NETWORK:
*