renderers/dom/SVGToImage.js

const helpers = require('../../helpers');

/**
 * @name SVGToImage
 * @class Used for downloading an SVG DOM element in your browser
 */
class SVGToImage {

  /**
   * Constructor takes in the element for later use
   *
   * @param {object} element - The SVG element to convert to an image
   */
  constructor (element) {
    // Ensure we got an element
    if (typeof element === 'undefined') {
      throw new Error('No element provided');
    }

    // Validate that the provided element is an HTML element
    if (!this._isValidElement(element)) {
      throw new Error('Provided element is not a valid element');
    }

    // Check the provided element is an SVG element
    if (element.tagName.toLowerCase() !== 'svg') {
      throw new Error('Invalid element provided');
    }

    // Store the element
    this.element = element;
  }

  /**
   * Validates the provided element is an HTMLElement
   * Source: http://stackoverflow.com/a/384380/3886818
   *
   * @param {mixed} element - The element to validate
   *
   * @return {boolean} True if the provided element is valid
   */
  _isValidElement (element) {
    return (typeof element !== 'undefined') &&
           (element !== null) &&
           (typeof element === 'object') &&
           (element.nodeType === 1) &&
           (typeof element.nodeName === 'string');
  }

  /**
   * Downloads the SVG as an image
   *
   * @param {string} name - The name to download the image with
   * @param {object} options - The configurable options
   */
  download (name, options = {}) {
    // Setup default options
    options.format = options.format || 'image/jpeg';

    // Convert it to a data URI
    this._toDataURI(options, (uri) => {
      // We have our data URI

      // Create an image
      const image = new window.Image();
      image.src = uri;

      // Confiugre the image onload callback
      image.onload = function () {
        // Create a canvas element sized to fit the image
        const canvas = document.createElement('canvas');
        canvas.width = image.width;
        canvas.height = image.height;

        // Get the canvas context and draw the image onto it
        const context = canvas.getContext('2d');
        context.drawImage(image, 0, 0, image.width, image.height, 0, 0, canvas.width, canvas.height);

        // Create a link to dynamically click and trigger the download
        const a = document.createElement('a');
        a.download = name;
        a.href = canvas.toDataURL(options.format || 'image/jpeg');
        document.body.appendChild(a);

        // I'm aware that `a.click()` below may not work reliably on all browsers. This is something to explore at a later date.

        // Click and download
        a.click();
      }
    });
  }

  /**
   * Verifies if the supplied URL is external or local
   *
   * @param {string} url - The URL to check
   *
   * @return {boolean} True if the supplied URL is external
   */
  _isExternal (url) {
    return (url) && // We have a URL
           (url.lastIndexOf('http', 0) === 0) && // It starts with http
           (url.lastIndexOf(window.location.host) === -1); // It doesn't contain the current hostname
  }

  /**
   * Inlines all images
   *
   * @param {function} callback - The callback to run after images have been loaded and inlined
   */
  _inlineImages (callback) {
    // Get any images
    const images = this.element.querySelectorAll('image');

    // If there are no images, immediately call the callback
    if (images.length === 0) {
      callback();
      return;
    }

    const promises = [];

    // Iterate over the images
    images.forEach((image) => {
      // Get the href for the image
      const href = image.getAttribute('xlink:href') || image.getAttribute('href');

      // If no href for this image, skip this image
      if (href === null) return;

      // If we had a href, check if it's external
      if (href && this._isExternal(href)) {
        throw new Error('Cannot render embedded images linking to external hosts: ' + href);
      }

      // Create a canvas and image
      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d');
      const img = new window.Image();

      // Create a promise and push it to the promises array
      promises.push(new Promise((resolve, reject) => {
        // Set the image source
        img.src = href;

        // Image load callback
        img.onload = function () {
          // Set the canvases size
          canvas.width = img.width;
          canvas.height = img.height;

          // Draw it onto the canvas
          ctx.drawImage(img, 0, 0);

          // Update the href attribute of the image element
          image.setAttribute('xlink:href', canvas.toDataURL('image/png'));
          image.setAttribute('href', canvas.toDataURL('image/png'));

          // Resolve the promise
          resolve();
        }

        // Image error callback
        img.onerror = function () {
          // Image couldn't be loaded, reject the promise
          reject('Could not load image: ' + href);
        }
      }));
    });

    // Wait for promises to resolve and call the callback
    Promise.all(promises)
           .then(callback)
           .catch(e => { throw new Error(e) });
  }

  /**
   * Converts the element to a data URI
   *
   * @param {object} options - Configuration options
   * @param {function} callback - The callback to run after the element has been converted
   */
  _toDataURI (options = {}, callback) {
    // Setup default options
    options.scale = options.scale || 1;

    // Setup some SVG data
    const xmlns = 'http://www.w3.org/2000/xmlns/';

    // Inline images first
    this._inlineImages(() => {
      // Setup a container <div>
      const outer = document.createElement('div');

      // Clone the element
      const clone = this.element.cloneNode(true);

      // Setup some vars
      let width,
        height,
        svg;

      // If the element is an SVG we work out the size of the SVG using a variety of methods,
      //  depending on how the user has defined the size of their SVG
      if (this.element.tagName !== 'svg') {
        throw new Error('Invalid element provided, must be SVG');
      }

      // Get the width and height
      width = parseInt(this.element.viewBox.baseVal.width || clone.getAttribute('data-width') || clone.style.width);
      height = parseInt(this.element.viewBox.baseVal.height || clone.getAttribute('data-height') || clone.style.height);

      // Configure the clone's wrapper attributes
      clone.setAttribute('version', '1.1');
      clone.setAttributeNS(xmlns, 'xmlns', 'http://www.w3.org/2000/svg');
      clone.setAttributeNS(xmlns, 'xmlns:xlink', 'http://www.w3.org/1999/xlink');
      clone.setAttribute('width', width * options.scale);
      clone.setAttribute('height', height * options.scale);
      clone.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
      outer.appendChild(clone);

      // Setup the SVG by adding the XML doctype
      const doctype = '<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">';

      // Combine the doctype and the innerHTML of the cloned SVG to get the final product
      svg = doctype + outer.innerHTML;

      // Create the URI
      const uri = 'data:image/svg+xml;base64,' + helpers.svgToBase64(svg, window.btoa);

      // Run the callback
      callback(uri);
    });
  }

}

module.exports = SVGToImage;