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;