Source: svgcanvas/svg-exec.js

/**
 * Tools for svg.
 * @module svg
 * @license MIT
 * @copyright 2011 Jeff Schiller
 */

import { jsPDF as JsPDF } from 'jspdf/dist/jspdf.es.min.js'
import 'svg2pdf.js/dist/svg2pdf.es.js'
import html2canvas from 'html2canvas'
import * as hstry from './history.js'
import {
  text2xml, cleanupElement, findDefs, getHref, preventClickDefault,
  toXml, getStrokedBBoxDefaultVisible, encode64, createObjectURL,
  dataURLToObjectURL, walkTree, getBBox as utilsGetBBox
} from './utilities.js'
import {
  transformPoint, transformListToTransform
} from './math.js'
import {
  convertUnit, shortFloat, convertToNum
} from '../common/units.js'
import { isGecko, isChrome, isWebkit } from '../common/browser.js'
import * as pathModule from './path.js'
import { NS } from './namespaces.js'
import * as draw from './draw.js'
import {
  recalculateDimensions
} from './recalculate.js'
import { getParents, getClosest } from '../editor/components/jgraduate/Util.js'

const {
  InsertElementCommand, RemoveElementCommand,
  ChangeElementCommand, BatchCommand
} = hstry

let svgCanvas = null

/**
* @function module:svg-exec.init
* @param {module:svg-exec.SvgCanvas#init} svgContext
* @returns {void}
*/
export const init = (canvas) => {
  svgCanvas = canvas
}

/**
* Main function to set up the SVG content for output.
* @function module:svgcanvas.SvgCanvas#svgCanvasToString
* @returns {string} The SVG image for output
*/
export const svgCanvasToString = () => {
  // keep calling it until there are none to remove
  while (svgCanvas.removeUnusedDefElems() > 0) { } // eslint-disable-line no-empty

  svgCanvas.pathActions.clear(true)

  // Keep SVG-Edit comment on top
  const childNodesElems = svgCanvas.getSvgContent().childNodes
  childNodesElems.forEach(function (node, i) {
    if (i && node.nodeType === 8 && node.data.includes('Created with')) {
      svgCanvas.getSvgContent().firstChild.before(node)
    }
  })

  // Move out of in-group editing mode
  if (svgCanvas.getCurrentGroup()) {
    draw.leaveContext()
    svgCanvas.selectOnly([svgCanvas.getCurrentGroup()])
  }

  const nakedSvgs = []

  // Unwrap gsvg if it has no special attributes (only id and style)
  const gsvgElems = svgCanvas.getSvgContent().querySelectorAll('g[data-gsvg]')
  Array.prototype.forEach.call(gsvgElems, function (element) {
    const attrs = element.attributes
    let len = attrs.length
    for (let i = 0; i < len; i++) {
      if (attrs[i].nodeName === 'id' || attrs[i].nodeName === 'style') {
        len--
      }
    }
    // No significant attributes, so ungroup
    if (len <= 0) {
      const svg = element.firstChild
      nakedSvgs.push(svg)
      element.replaceWith(svg)
    }
  })
  const output = svgCanvas.svgToString(svgCanvas.getSvgContent(), 0)

  // Rewrap gsvg
  if (nakedSvgs.length) {
    Array.prototype.forEach.call(nakedSvgs, function (el) {
      svgCanvas.groupSvgElem(el)
    })
  }

  return output
}

/**
* Sub function ran on each SVG element to convert it to a string as desired.
* @function module:svgcanvas.SvgCanvas#svgToString
* @param {Element} elem - The SVG element to convert
* @param {Integer} indent - Number of spaces to indent this tag
* @returns {string} The given element as an SVG tag
*/
export const svgToString = function (elem, indent) {
  const curConfig = svgCanvas.getCurConfig()
  const nsMap = svgCanvas.getNsMap()
  const out = []
  const unit = curConfig.baseUnit
  const unitRe = new RegExp('^-?[\\d\\.]+' + unit + '$')

  if (elem) {
    cleanupElement(elem)
    const attrs = [...elem.attributes]
    const childs = elem.childNodes
    attrs.sort((a, b) => {
      return a.name > b.name ? -1 : 1
    })

    for (let i = 0; i < indent; i++) { out.push(' ') }
    out.push('<'); out.push(elem.nodeName)
    if (elem.id === 'svgcontent') {
      // Process root element separately
      const res = svgCanvas.getResolution()

      const vb = ''
      // TODO: Allow this by dividing all values by current baseVal
      // Note that this also means we should properly deal with this on import
      // if (curConfig.baseUnit !== 'px') {
      //   const unit = curConfig.baseUnit;
      //   const unitM = getTypeMap()[unit];
      //   res.w = shortFloat(res.w / unitM);
      //   res.h = shortFloat(res.h / unitM);
      //   vb = ' viewBox="' + [0, 0, res.w, res.h].join(' ') + '"';
      //   res.w += unit;
      //   res.h += unit;
      // }

      if (unit !== 'px') {
        res.w = convertUnit(res.w, unit) + unit
        res.h = convertUnit(res.h, unit) + unit
      }

      out.push(' width="' + res.w + '" height="' + res.h + '"' + vb + ' xmlns="' + NS.SVG + '"')

      const nsuris = {}

      // Check elements for namespaces, add if found
      const csElements = elem.querySelectorAll('*')
      const cElements = Array.prototype.slice.call(csElements)
      cElements.push(elem)
      Array.prototype.forEach.call(cElements, function (el) {
        // const el = this;
        // for some elements have no attribute
        const uri = el.namespaceURI
        if (uri && !nsuris[uri] && nsMap[uri] && nsMap[uri] !== 'xmlns' && nsMap[uri] !== 'xml') {
          nsuris[uri] = true
          out.push(' xmlns:' + nsMap[uri] + '="' + uri + '"')
        }
        if (el.attributes.length > 0) {
          for (const [, attr] of Object.entries(el.attributes)) {
            const u = attr.namespaceURI
            if (u && !nsuris[u] && nsMap[u] !== 'xmlns' && nsMap[u] !== 'xml') {
              nsuris[u] = true
              out.push(' xmlns:' + nsMap[u] + '="' + u + '"')
            }
          }
        }
      })

      let i = attrs.length
      const attrNames = ['width', 'height', 'xmlns', 'x', 'y', 'viewBox', 'id', 'overflow']
      while (i--) {
        const attr = attrs[i]
        const attrVal = toXml(attr.value)

        // Namespaces have already been dealt with, so skip
        if (attr.nodeName.startsWith('xmlns:')) { continue }

        // only serialize attributes we don't use internally
        if (attrVal !== '' && !attrNames.includes(attr.localName) && (!attr.namespaceURI || nsMap[attr.namespaceURI])) {
          out.push(' ')
          out.push(attr.nodeName); out.push('="')
          out.push(attrVal); out.push('"')
        }
      }
    } else {
      // Skip empty defs
      if (elem.nodeName === 'defs' && !elem.firstChild) { return '' }

      const mozAttrs = ['-moz-math-font-style', '_moz-math-font-style']
      for (let i = attrs.length - 1; i >= 0; i--) {
        const attr = attrs[i]
        let attrVal = toXml(attr.value)
        // remove bogus attributes added by Gecko
        if (mozAttrs.includes(attr.localName)) { continue }
        if (attrVal === 'null') {
          const styleName = attr.localName.replace(/-[a-z]/g, (s) => s[1].toUpperCase())
          if (Object.prototype.hasOwnProperty.call(elem.style, styleName)) { continue }
        }
        if (attrVal !== '') {
          if (attrVal.startsWith('pointer-events')) { continue }
          if (attr.localName === 'class' && attrVal.startsWith('se_')) { continue }
          out.push(' ')
          if (attr.localName === 'd') { attrVal = svgCanvas.pathActions.convertPath(elem, true) }
          if (!isNaN(attrVal)) {
            attrVal = shortFloat(attrVal)
          } else if (unitRe.test(attrVal)) {
            attrVal = shortFloat(attrVal) + unit
          }

          // Embed images when saving
          if (svgCanvas.getSvgOptionApply() &&
            elem.nodeName === 'image' &&
            attr.localName === 'href' &&
            svgCanvas.getSvgOptionImages() &&
            svgCanvas.getSvgOptionImages() === 'embed'
          ) {
            const img = svgCanvas.getEncodableImages(attrVal)
            if (img) { attrVal = img }
          }

          // map various namespaces to our fixed namespace prefixes
          // (the default xmlns attribute itself does not get a prefix)
          if (!attr.namespaceURI || attr.namespaceURI === NS.SVG || nsMap[attr.namespaceURI]) {
            out.push(attr.nodeName); out.push('="')
            out.push(attrVal); out.push('"')
          }
        }
      }
    }

    if (elem.hasChildNodes()) {
      out.push('>')
      indent++
      let bOneLine = false

      for (let i = 0; i < childs.length; i++) {
        const child = childs.item(i)
        switch (child.nodeType) {
          case 1: // element node
            out.push('\n')
            out.push(svgCanvas.svgToString(child, indent))
            break
          case 3: { // text node
            const str = child.nodeValue.replace(/^\s+|\s+$/g, '')
            if (str !== '') {
              bOneLine = true
              out.push(String(toXml(str)))
            }
            break
          } case 4: // cdata node
            out.push('\n')
            out.push(new Array(indent + 1).join(' '))
            out.push('<![CDATA[')
            out.push(child.nodeValue)
            out.push(']]>')
            break
          case 8: // comment
            out.push('\n')
            out.push(new Array(indent + 1).join(' '))
            out.push('<!--')
            out.push(child.data)
            out.push('-->')
            break
        } // switch on node type
      }
      indent--
      if (!bOneLine) {
        out.push('\n')
        for (let i = 0; i < indent; i++) { out.push(' ') }
      }
      out.push('</'); out.push(elem.nodeName); out.push('>')
    } else {
      out.push('/>')
    }
  }
  return out.join('')
} // end svgToString()

/**
* This function sets the current drawing as the input SVG XML.
* @function module:svgcanvas.SvgCanvas#setSvgString
* @param {string} xmlString - The SVG as XML text.
* @param {boolean} [preventUndo=false] - Indicates if we want to do the
* changes without adding them to the undo stack - e.g. for initializing a
* drawing on page load.
* @fires module:svgcanvas.SvgCanvas#event:setnonce
* @fires module:svgcanvas.SvgCanvas#event:unsetnonce
* @fires module:svgcanvas.SvgCanvas#event:changed
* @returns {boolean} This function returns `false` if the set was
*     unsuccessful, `true` otherwise.
*/
export const setSvgString = function (xmlString, preventUndo) {
  const curConfig = svgCanvas.getCurConfig()
  const dataStorage = svgCanvas.getDataStorage()
  try {
    // convert string into XML document
    const newDoc = text2xml(xmlString)
    if (newDoc.firstElementChild &&
      newDoc.firstElementChild.namespaceURI !== NS.SVG) {
      return false
    }

    svgCanvas.prepareSvg(newDoc)

    const batchCmd = new BatchCommand('Change Source')

    // remove old svg document
    const { nextSibling } = svgCanvas.getSvgContent()

    svgCanvas.getSvgContent().remove()
    const oldzoom = svgCanvas.getSvgContent()
    batchCmd.addSubCommand(new RemoveElementCommand(oldzoom, nextSibling, svgCanvas.getSvgRoot()))

    // set new svg document
    // If DOM3 adoptNode() available, use it. Otherwise fall back to DOM2 importNode()
    if (svgCanvas.getDOMDocument().adoptNode) {
      svgCanvas.setSvgContent(svgCanvas.getDOMDocument().adoptNode(newDoc.documentElement))
    } else {
      svgCanvas.setSvgContent(svgCanvas.getDOMDocument().importNode(newDoc.documentElement, true))
    }

    svgCanvas.getSvgRoot().append(svgCanvas.getSvgContent())
    const content = svgCanvas.getSvgContent()

    svgCanvas.current_drawing_ = new draw.Drawing(svgCanvas.getSvgContent(), svgCanvas.getIdPrefix())

    // retrieve or set the nonce
    const nonce = svgCanvas.getCurrentDrawing().getNonce()
    if (nonce) {
      svgCanvas.call('setnonce', nonce)
    } else {
      svgCanvas.call('unsetnonce')
    }

    // change image href vals if possible
    const elements = content.querySelectorAll('image')
    Array.prototype.forEach.call(elements, function (image) {
      preventClickDefault(image)
      const val = svgCanvas.getHref(image)
      if (val) {
        if (val.startsWith('data:')) {
          // Check if an SVG-edit data URI
          const m = val.match(/svgedit_url=(.*?);/)
          // const m = val.match(/svgedit_url=(?<url>.*?);/);
          if (m) {
            const url = decodeURIComponent(m[1])
            // const url = decodeURIComponent(m.groups.url);
            const iimg = new Image()
            iimg.addEventListener('load', () => {
              image.setAttributeNS(NS.XLINK, 'xlink:href', url)
            })
            iimg.src = url
          }
        }
        // Add to encodableImages if it loads
        svgCanvas.embedImage(val)
      }
    })
    // Duplicate id replace changes
    const nodes = content.querySelectorAll('[id]')
    const ids = {}
    const totalNodes = nodes.length

    for (let i = 0; i < totalNodes; i++) {
      const currentId = nodes[i].id ? nodes[i].id : 'undefined'
      if (isNaN(ids[currentId])) {
        ids[currentId] = 0
      }
      ids[currentId]++
    }

    Object.entries(ids).forEach(([key, value]) => {
      if (value > 1) {
        const nodes = content.querySelectorAll('[id="' + key + '"]')
        for (let i = 1; i < nodes.length; i++) {
          nodes[i].setAttribute('id', svgCanvas.getNextId())
        }
      }
    })

    // Wrap child SVGs in group elements
    const svgElements = content.querySelectorAll('svg')
    Array.prototype.forEach.call(svgElements, function (element) {
      // Skip if it's in a <defs>
      if (getClosest(element.parentNode, 'defs')) { return }

      svgCanvas.uniquifyElems(element)

      // Check if it already has a gsvg group
      const pa = element.parentNode
      if (pa.childNodes.length === 1 && pa.nodeName === 'g') {
        dataStorage.put(pa, 'gsvg', element)
        pa.id = pa.id || svgCanvas.getNextId()
      } else {
        svgCanvas.groupSvgElem(element)
      }
    })

    // For Firefox: Put all paint elems in defs
    if (isGecko()) {
      const svgDefs = findDefs()
      const findElems = content.querySelectorAll('linearGradient, radialGradient, pattern')
      Array.prototype.forEach.call(findElems, function (ele) {
        svgDefs.appendChild(ele)
      })
    }

    // Set ref element for <use> elements

    // TODO: This should also be done if the object is re-added through "redo"
    svgCanvas.setUseData(content)

    svgCanvas.convertGradients(content)

    const attrs = {
      id: 'svgcontent',
      overflow: curConfig.show_outside_canvas ? 'visible' : 'hidden'
    }

    let percs = false

    // determine proper size
    if (content.getAttribute('viewBox')) {
      const viBox = content.getAttribute('viewBox')
      const vb = viBox.split(' ')
      attrs.width = vb[2]
      attrs.height = vb[3]
      // handle content that doesn't have a viewBox
    } else {
      ['width', 'height'].forEach(function (dim) {
        // Set to 100 if not given
        const val = content.getAttribute(dim) || '100%'
        if (String(val).substr(-1) === '%') {
          // Use user units if percentage given
          percs = true
        } else {
          attrs[dim] = convertToNum(dim, val)
        }
      })
    }

    // identify layers
    draw.identifyLayers()

    // Give ID for any visible layer children missing one
    const chiElems = content.children
    Array.prototype.forEach.call(chiElems, function (chiElem) {
      const visElems = chiElem.querySelectorAll(svgCanvas.getVisElems())
      Array.prototype.forEach.call(visElems, function (elem) {
        if (!elem.id) { elem.id = svgCanvas.getNextId() }
      })
    })

    // Percentage width/height, so let's base it on visible elements
    if (percs) {
      const bb = getStrokedBBoxDefaultVisible()
      attrs.width = bb.width + bb.x
      attrs.height = bb.height + bb.y
    }

    // Just in case negative numbers are given or
    // result from the percs calculation
    if (attrs.width <= 0) { attrs.width = 100 }
    if (attrs.height <= 0) { attrs.height = 100 }

    for (const [key, value] of Object.entries(attrs)) {
      content.setAttribute(key, value)
    }
    svgCanvas.contentW = attrs.width
    svgCanvas.contentH = attrs.height

    batchCmd.addSubCommand(new InsertElementCommand(svgCanvas.getSvgContent()))
    // update root to the correct size
    const width = content.getAttribute('width')
    const height = content.getAttribute('height')
    const changes = { width: width, height: height }
    batchCmd.addSubCommand(new ChangeElementCommand(svgCanvas.getSvgRoot(), changes))

    // reset zoom
    svgCanvas.setZoom(1)

    svgCanvas.clearSelection()
    pathModule.clearData()
    svgCanvas.getSvgRoot().append(svgCanvas.selectorManager.selectorParentGroup)

    if (!preventUndo) svgCanvas.addCommandToHistory(batchCmd)
    svgCanvas.call('changed', [svgCanvas.getSvgContent()])
  } catch (e) {
    console.error(e)
    return false
  }

  return true
}

/**
* This function imports the input SVG XML as a `<symbol>` in the `<defs>`, then adds a
* `<use>` to the current layer.
* @function module:svgcanvas.SvgCanvas#importSvgString
* @param {string} xmlString - The SVG as XML text.
* @fires module:svgcanvas.SvgCanvas#event:changed
* @returns {null|Element} This function returns null if the import was unsuccessful, or the element otherwise.
* @todo
* - properly handle if namespace is introduced by imported content (must add to svgcontent
* and update all prefixes in the imported node)
* - properly handle recalculating dimensions, `recalculateDimensions()` doesn't handle
* arbitrary transform lists, but makes some assumptions about how the transform list
* was obtained
*/
export const importSvgString = function (xmlString) {
  const dataStorage = svgCanvas.getDataStorage()
  let j; let ts; let useEl
  try {
    // Get unique ID
    const uid = encode64(xmlString.length + xmlString).substr(0, 32)

    let useExisting = false
    // Look for symbol and make sure symbol exists in image
    if (svgCanvas.getImportIds(uid) && svgCanvas.getImportIds(uid).symbol) {
      const parents = getParents(svgCanvas.getImportIds(uid).symbol, '#svgroot')
      if (parents?.length) {
        useExisting = true
      }
    }

    const batchCmd = new BatchCommand('Import Image')
    let symbol
    if (useExisting) {
      symbol = svgCanvas.getImportIds(uid).symbol
      ts = svgCanvas.getImportIds(uid).xform
    } else {
      // convert string into XML document
      const newDoc = text2xml(xmlString)

      svgCanvas.prepareSvg(newDoc)

      // import new svg document into our document
      // If DOM3 adoptNode() available, use it. Otherwise fall back to DOM2 importNode()
      const svg = svgCanvas.getDOMDocument().adoptNode
        ? svgCanvas.getDOMDocument().adoptNode(newDoc.documentElement)
        : svgCanvas.getDOMDocument().importNode(newDoc.documentElement, true)

      svgCanvas.uniquifyElems(svg)

      const innerw = convertToNum('width', svg.getAttribute('width'))
      const innerh = convertToNum('height', svg.getAttribute('height'))
      const innervb = svg.getAttribute('viewBox')
      // if no explicit viewbox, create one out of the width and height
      const vb = innervb ? innervb.split(' ') : [0, 0, innerw, innerh]
      for (j = 0; j < 4; ++j) {
        vb[j] = Number(vb[j])
      }

      // TODO: properly handle preserveAspectRatio
      const // canvasw = +svgContent.getAttribute('width'),
        canvash = Number(svgCanvas.getSvgContent().getAttribute('height'))
      // imported content should be 1/3 of the canvas on its largest dimension

      ts = innerh > innerw ? 'scale(' + (canvash / 3) / vb[3] + ')' : 'scale(' + (canvash / 3) / vb[2] + ')'

      // Hack to make recalculateDimensions understand how to scale
      ts = 'translate(0) ' + ts + ' translate(0)'

      symbol = svgCanvas.getDOMDocument().createElementNS(NS.SVG, 'symbol')
      const defs = findDefs()

      if (isGecko()) {
        // Move all gradients into root for Firefox, workaround for this bug:
        // https://bugzilla.mozilla.org/show_bug.cgi?id=353575
        // TODO: Make this properly undo-able.
        const elements = svg.querySelectorAll('linearGradient, radialGradient, pattern')
        Array.prototype.forEach.call(elements, function (el) {
          defs.appendChild(el)
        })
      }

      while (svg.firstChild) {
        const first = svg.firstChild
        symbol.append(first)
      }
      const attrs = svg.attributes
      for (const attr of attrs) { // Ok for `NamedNodeMap`
        symbol.setAttribute(attr.nodeName, attr.value)
      }
      symbol.id = svgCanvas.getNextId()

      // Store data
      svgCanvas.setImportIds(uid, {
        symbol,
        xform: ts
      })

      findDefs().append(symbol)
      batchCmd.addSubCommand(new InsertElementCommand(symbol))
    }

    useEl = svgCanvas.getDOMDocument().createElementNS(NS.SVG, 'use')
    useEl.id = svgCanvas.getNextId()
    svgCanvas.setHref(useEl, '#' + symbol.id);

    (svgCanvas.getCurrentGroup() || svgCanvas.getCurrentDrawing().getCurrentLayer()).append(useEl)
    batchCmd.addSubCommand(new InsertElementCommand(useEl))
    svgCanvas.clearSelection()

    useEl.setAttribute('transform', ts)
    recalculateDimensions(useEl)
    dataStorage.put(useEl, 'symbol', symbol)
    dataStorage.put(useEl, 'ref', symbol)
    svgCanvas.addToSelection([useEl])

    // TODO: Find way to add this in a recalculateDimensions-parsable way
    // if (vb[0] !== 0 || vb[1] !== 0) {
    //   ts = 'translate(' + (-vb[0]) + ',' + (-vb[1]) + ') ' + ts;
    // }
    svgCanvas.addCommandToHistory(batchCmd)
    svgCanvas.call('changed', [svgCanvas.getSvgContent()])
  } catch (e) {
    console.error(e)
    return null
  }

  // we want to return the element so we can automatically select it
  return useEl
}
/**
 * Function to run when image data is found.
 * @callback module:svgcanvas.ImageEmbeddedCallback
 * @param {string|false} result Data URL
 * @returns {void}
 */
/**
* Converts a given image file to a data URL when possible, then runs a given callback.
* @function module:svgcanvas.SvgCanvas#embedImage
* @param {string} src - The path/URL of the image
* @returns {Promise<string|false>} Resolves to a Data URL (string|false)
*/
export const embedImage = function (src) {
  // Todo: Remove this Promise in favor of making an async/await `Image.load` utility
  return new Promise(function (resolve, reject) {
    // load in the image and once it's loaded, get the dimensions
    const imgI = new Image()
    imgI.addEventListener('load', (e) => {
      // create a canvas the same size as the raster image
      const cvs = document.createElement('canvas')
      cvs.width = e.currentTarget.width
      cvs.height = e.currentTarget.height
      // load the raster image into the canvas
      cvs.getContext('2d').drawImage(e.currentTarget, 0, 0)
      // retrieve the data: URL
      try {
        let urldata = ';svgedit_url=' + encodeURIComponent(src)
        urldata = cvs.toDataURL().replace(';base64', urldata + ';base64')
        svgCanvas.setEncodableImages(src, urldata)
      } catch (e) {
        svgCanvas.setEncodableImages(src, false)
      }
      svgCanvas.setGoodImage(src)
      resolve(svgCanvas.getEncodableImages(src))
    })
    imgI.addEventListener('error', (e) => {
      reject(new Error(`error loading image: ${e.currentTarget.attributes.src.value}`))
    })
    imgI.setAttribute('src', src)
  })
}

/**
* @typedef {PlainObject} module:svgcanvas.IssuesAndCodes
* @property {string[]} issueCodes The locale-independent code names
* @property {string[]} issues The localized descriptions
*/

/**
* Codes only is useful for locale-independent detection.
* @returns {module:svgcanvas.IssuesAndCodes}
*/
function getIssues () {
  const uiStrings = svgCanvas.getUIStrings()
  // remove the selected outline before serializing
  svgCanvas.clearSelection()

  // Check for known CanVG issues
  const issues = []
  const issueCodes = []

  // Selector and notice
  const issueList = {
    feGaussianBlur: uiStrings.exportNoBlur,
    foreignObject: uiStrings.exportNoforeignObject,
    '[stroke-dasharray]': uiStrings.exportNoDashArray
  }
  const content = svgCanvas.getSvgContent()

  // Add font/text check if Canvas Text API is not implemented
  if (!('font' in document.querySelector('CANVAS').getContext('2d'))) {
    issueList.text = uiStrings.exportNoText
  }

  for (const [sel, descr] of Object.entries(issueList)) {
    if (content.querySelectorAll(sel).length) {
      issueCodes.push(sel)
      issues.push(descr)
    }
  }
  return { issues, issueCodes }
}
/**
* @typedef {PlainObject} module:svgcanvas.ImageExportedResults
* @property {string} datauri Contents as a Data URL
* @property {string} bloburl May be the empty string
* @property {string} svg The SVG contents as a string
* @property {string[]} issues The localization messages of `issueCodes`
* @property {module:svgcanvas.IssueCode[]} issueCodes CanVG issues found with the SVG
* @property {"PNG"|"JPEG"|"BMP"|"WEBP"|"ICO"} type The chosen image type
* @property {"image/png"|"image/jpeg"|"image/bmp"|"image/webp"} mimeType The image MIME type
* @property {Float} quality A decimal between 0 and 1 (for use with JPEG or WEBP)
* @property {string} exportWindowName A convenience for passing along a `window.name` to target a window on which the export could be added
*/

/**
* Generates a PNG (or JPG, BMP, WEBP) Data URL based on the current image,
* then calls "exported" with an object including the string, image
* information, and any issues found.
* @function module:svgcanvas.SvgCanvas#rasterExport
* @param {"PNG"|"JPEG"|"BMP"|"WEBP"|"ICO"} [imgType="PNG"]
* @param {Float} [quality] Between 0 and 1
* @param {string} [exportWindowName]
* @param {PlainObject} [opts]
* @param {boolean} [opts.avoidEvent]
* @fires module:svgcanvas.SvgCanvas#event:exported
* @todo Confirm/fix ICO type
* @returns {Promise<module:svgcanvas.ImageExportedResults>} Resolves to {@link module:svgcanvas.ImageExportedResults}
*/
export const rasterExport = async function (imgType, quality, exportWindowName, opts = {}) {
  const type = imgType === 'ICO' ? 'BMP' : (imgType || 'PNG')
  const mimeType = 'image/' + type.toLowerCase()
  const { issues, issueCodes } = getIssues()
  const svg = svgCanvas.svgCanvasToString()

  const iframe = document.createElement('iframe')
  iframe.onload = function () {
    const iframedoc = iframe.contentDocument || iframe.contentWindow.document
    const ele = svgCanvas.getSvgContent()
    const cln = ele.cloneNode(true)
    iframedoc.body.appendChild(cln)
    setTimeout(function () {
      // eslint-disable-next-line promise/catch-or-return
      html2canvas(iframedoc.body, { useCORS: true, allowTaint: true }).then((canvas) => {
        return new Promise((resolve) => {
          const dataURLType = type.toLowerCase()
          const datauri = quality
            ? canvas.toDataURL('image/' + dataURLType, quality)
            : canvas.toDataURL('image/' + dataURLType)
          iframe.parentNode.removeChild(iframe)
          let bloburl

          function done () {
            const obj = {
              datauri,
              bloburl,
              svg,
              issues,
              issueCodes,
              type: imgType,
              mimeType,
              quality,
              exportWindowName
            }
            if (!opts.avoidEvent) {
              svgCanvas.call('exported', obj)
            }
            resolve(obj)
          }
          if (canvas.toBlob) {
            canvas.toBlob((blob) => {
              bloburl = createObjectURL(blob)
              done()
            }, mimeType, quality)
            return
          }
          bloburl = dataURLToObjectURL(datauri)
          done()
        })
      })
    }, 1000)
  }
  document.body.appendChild(iframe)
}

/**
* @typedef {void|"save"|"arraybuffer"|"blob"|"datauristring"|"dataurlstring"|"dataurlnewwindow"|"datauri"|"dataurl"} external:jsPDF.OutputType
* @todo Newer version to add also allows these `outputType` values "bloburi"|"bloburl" which return strings, so document here and for `outputType` of `module:svgcanvas.PDFExportedResults` below if added
*/
/**
* @typedef {PlainObject} module:svgcanvas.PDFExportedResults
* @property {string} svg The SVG PDF output
* @property {string|ArrayBuffer|Blob|window} output The output based on the `outputType`;
* if `undefined`, "datauristring", "dataurlstring", "datauri",
* or "dataurl", will be a string (`undefined` gives a document, while the others
* build as Data URLs; "datauri" and "dataurl" change the location of the current page); if
* "arraybuffer", will return `ArrayBuffer`; if "blob", returns a `Blob`;
* if "dataurlnewwindow", will change the current page's location and return a string
* if in Safari and no window object is found; otherwise opens in, and returns, a new `window`
* object; if "save", will have the same return as "dataurlnewwindow" if
* `navigator.getUserMedia` support is found without `URL.createObjectURL` support; otherwise
* returns `undefined` but attempts to save
* @property {external:jsPDF.OutputType} outputType
* @property {string[]} issues The human-readable localization messages of corresponding `issueCodes`
* @property {module:svgcanvas.IssueCode[]} issueCodes
* @property {string} exportWindowName
*/

/**
* Generates a PDF based on the current image, then calls "exportedPDF" with
* an object including the string, the data URL, and any issues found.
* @function module:svgcanvas.SvgCanvas#exportPDF
* @param {string} [exportWindowName] Will also be used for the download file name here
* @param {external:jsPDF.OutputType} [outputType="dataurlstring"]
* @fires module:svgcanvas.SvgCanvas#event:exportedPDF
* @returns {Promise<module:svgcanvas.PDFExportedResults>} Resolves to {@link module:svgcanvas.PDFExportedResults}
*/
export const exportPDF = async (
  exportWindowName,
  outputType = isChrome() ? 'save' : undefined
) => {
  const res = svgCanvas.getResolution()
  const orientation = res.w > res.h ? 'landscape' : 'portrait'
  const unit = 'pt' // curConfig.baseUnit; // We could use baseUnit, but that is presumably not intended for export purposes
  const iframe = document.createElement('iframe')
  iframe.onload = function () {
    const iframedoc = iframe.contentDocument || iframe.contentWindow.document
    const ele = svgCanvas.getSvgContent()
    const cln = ele.cloneNode(true)
    iframedoc.body.appendChild(cln)
    setTimeout(function () {
      // eslint-disable-next-line promise/catch-or-return
      html2canvas(iframedoc.body, { useCORS: true, allowTaint: true }).then((canvas) => {
        const imgData = canvas.toDataURL('image/png')
        const doc = new JsPDF({
          orientation: orientation,
          unit: unit,
          format: [res.w, res.h]
        })
        const docTitle = svgCanvas.getDocumentTitle()
        doc.setProperties({
          title: docTitle
        })
        doc.addImage(imgData, 'PNG', 0, 0, res.w, res.h)
        iframe.parentNode.removeChild(iframe)
        const { issues, issueCodes } = getIssues()
        outputType = outputType || 'dataurlstring'
        const obj = { issues, issueCodes, exportWindowName, outputType }
        obj.output = doc.output(outputType, outputType === 'save' ? (exportWindowName || 'svg.pdf') : undefined)
        svgCanvas.call('exportedPDF', obj)
        return obj
      })
    }, 1000)
  }
  document.body.appendChild(iframe)
}
/**
* Ensure each element has a unique ID.
* @function module:svgcanvas.SvgCanvas#uniquifyElems
* @param {Element} g - The parent element of the tree to give unique IDs
* @returns {void}
*/
export const uniquifyElemsMethod = function (g) {
  const ids = {}
  // TODO: Handle markers and connectors. These are not yet re-identified properly
  // as their referring elements do not get remapped.
  //
  // <marker id='se_marker_end_svg_7'/>
  // <polyline id='svg_7' se:connector='svg_1 svg_6' marker-end='url(#se_marker_end_svg_7)'/>
  //
  // Problem #1: if svg_1 gets renamed, we do not update the polyline's se:connector attribute
  // Problem #2: if the polyline svg_7 gets renamed, we do not update the marker id nor the polyline's marker-end attribute
  const refElems = ['filter', 'linearGradient', 'pattern', 'radialGradient', 'symbol', 'textPath', 'use']

  walkTree(g, function (n) {
    // if it's an element node
    if (n.nodeType === 1) {
      // and the element has an ID
      if (n.id) {
        // and we haven't tracked this ID yet
        if (!(n.id in ids)) {
          // add this id to our map
          ids[n.id] = { elem: null, attrs: [], hrefs: [] }
        }
        ids[n.id].elem = n
      }

      // now search for all attributes on this element that might refer
      // to other elements
      svgCanvas.getrefAttrs().forEach(function (attr) {
        const attrnode = n.getAttributeNode(attr)
        if (attrnode) {
          // the incoming file has been sanitized, so we should be able to safely just strip off the leading #
          const url = svgCanvas.getUrlFromAttr(attrnode.value)
          const refid = url ? url.substr(1) : null
          if (refid) {
            if (!(refid in ids)) {
              // add this id to our map
              ids[refid] = { elem: null, attrs: [], hrefs: [] }
            }
            ids[refid].attrs.push(attrnode)
          }
        }
      })

      // check xlink:href now
      const href = svgCanvas.getHref(n)
      // TODO: what if an <image> or <a> element refers to an element internally?
      if (href && refElems.includes(n.nodeName)) {
        const refid = href.substr(1)
        if (refid) {
          if (!(refid in ids)) {
            // add this id to our map
            ids[refid] = { elem: null, attrs: [], hrefs: [] }
          }
          ids[refid].hrefs.push(n)
        }
      }
    }
  })

  // in ids, we now have a map of ids, elements and attributes, let's re-identify
  for (const oldid in ids) {
    if (!oldid) { continue }
    const { elem } = ids[oldid]
    if (elem) {
      const newid = svgCanvas.getNextId()

      // assign element its new id
      elem.id = newid

      // remap all url() attributes
      const { attrs } = ids[oldid]
      let j = attrs.length
      while (j--) {
        const attr = attrs[j]
        attr.ownerElement.setAttribute(attr.name, 'url(#' + newid + ')')
      }

      // remap all href attributes
      const hreffers = ids[oldid].hrefs
      let k = hreffers.length
      while (k--) {
        const hreffer = hreffers[k]
        svgCanvas.setHref(hreffer, '#' + newid)
      }
    }
  }
}

/**
* Assigns reference data for each use element.
* @function module:svgcanvas.SvgCanvas#setUseData
* @param {Element} parent
* @returns {void}
*/
export const setUseDataMethod = function (parent) {
  let elems = parent

  if (parent.tagName !== 'use') {
    // elems = elems.find('use');
    elems = elems.querySelectorAll('use')
  }

  Array.prototype.forEach.call(elems, function (el, _) {
    const dataStorage = svgCanvas.getDataStorage()
    const id = svgCanvas.getHref(el).substr(1)
    const refElem = svgCanvas.getElement(id)
    if (!refElem) { return }
    dataStorage.put(el, 'ref', refElem)
    if (refElem.tagName === 'symbol' || refElem.tagName === 'svg') {
      dataStorage.put(el, 'symbol', refElem)
      dataStorage.put(el, 'ref', refElem)
    }
  })
}

/**
* Looks at DOM elements inside the `<defs>` to see if they are referred to,
* removes them from the DOM if they are not.
* @function module:svgcanvas.SvgCanvas#removeUnusedDefElems
* @returns {Integer} The number of elements that were removed
*/
export const removeUnusedDefElemsMethod = function () {
  const defs = svgCanvas.getSvgContent().getElementsByTagNameNS(NS.SVG, 'defs')
  if (!defs || !defs.length) { return 0 }

  // if (!defs.firstChild) { return; }

  const defelemUses = []
  let numRemoved = 0
  const attrs = ['fill', 'stroke', 'filter', 'marker-start', 'marker-mid', 'marker-end']
  const alen = attrs.length

  const allEls = svgCanvas.getSvgContent().getElementsByTagNameNS(NS.SVG, '*')
  const allLen = allEls.length

  let i; let j
  for (i = 0; i < allLen; i++) {
    const el = allEls[i]
    for (j = 0; j < alen; j++) {
      const ref = svgCanvas.getUrlFromAttr(el.getAttribute(attrs[j]))
      if (ref) {
        defelemUses.push(ref.substr(1))
      }
    }

    // gradients can refer to other gradients
    const href = getHref(el)
    if (href && href.startsWith('#')) {
      defelemUses.push(href.substr(1))
    }
  }

  Array.prototype.forEach.call(defs, function (def, i) {
    const defelems = def.querySelectorAll('linearGradient, radialGradient, filter, marker, svg, symbol')
    i = defelems.length
    while (i--) {
      const defelem = defelems[i]
      const { id } = defelem
      if (!defelemUses.includes(id)) {
        // Not found, so remove (but remember)
        svgCanvas.setRemovedElements(id, defelem)
        defelem.remove()
        numRemoved++
      }
    }
  })

  return numRemoved
}
/**
* Converts gradients from userSpaceOnUse to objectBoundingBox.
* @function module:svgcanvas.SvgCanvas#convertGradients
* @param {Element} elem
* @returns {void}
*/
export const convertGradientsMethod = function (elem) {
  let elems = elem.querySelectorAll('linearGradient, radialGradient')
  if (!elems.length && isWebkit()) {
    // Bug in webkit prevents regular *Gradient selector search
    elems = Array.prototype.filter.call(elem.querySelectorAll('*'), function (curThis) {
      return (curThis.tagName.includes('Gradient'))
    })
  }
  Array.prototype.forEach.call(elems, function (grad) {
    if (grad.getAttribute('gradientUnits') === 'userSpaceOnUse') {
      const svgContent = svgCanvas.getSvgContent()
      // TODO: Support more than one element with this ref by duplicating parent grad
      let fillStrokeElems = svgContent.querySelectorAll('[fill="url(#' + grad.id + ')"],[stroke="url(#' + grad.id + ')"]')
      if (!fillStrokeElems.length) {
        const tmpFillStrokeElems = svgContent.querySelectorAll('[*|href="#' + grad.id + '"]')
        if (!tmpFillStrokeElems.length) {
          return
        } else {
          if ((tmpFillStrokeElems[0].tagName === 'linearGradient' || tmpFillStrokeElems[0].tagName === 'radialGradient') && tmpFillStrokeElems[0].getAttribute('gradientUnits') === 'userSpaceOnUse') {
            fillStrokeElems = svgContent.querySelectorAll('[fill="url(#' + tmpFillStrokeElems[0].id + ')"],[stroke="url(#' + tmpFillStrokeElems[0].id + ')"]')
          } else {
            return
          }
        }
      }
      // get object's bounding box
      const bb = utilsGetBBox(fillStrokeElems[0])

      // This will occur if the element is inside a <defs> or a <symbol>,
      // in which we shouldn't need to convert anyway.
      if (!bb) { return }
      if (grad.tagName === 'linearGradient') {
        const gCoords = {
          x1: grad.getAttribute('x1'),
          y1: grad.getAttribute('y1'),
          x2: grad.getAttribute('x2'),
          y2: grad.getAttribute('y2')
        }

        // If has transform, convert
        const tlist = grad.gradientTransform.baseVal
        if (tlist && tlist.numberOfItems > 0) {
          const m = transformListToTransform(tlist).matrix
          const pt1 = transformPoint(gCoords.x1, gCoords.y1, m)
          const pt2 = transformPoint(gCoords.x2, gCoords.y2, m)

          gCoords.x1 = pt1.x
          gCoords.y1 = pt1.y
          gCoords.x2 = pt2.x
          gCoords.y2 = pt2.y
          grad.removeAttribute('gradientTransform')
        }
        grad.setAttribute('x1', (gCoords.x1 - bb.x) / bb.width)
        grad.setAttribute('y1', (gCoords.y1 - bb.y) / bb.height)
        grad.setAttribute('x2', (gCoords.x2 - bb.x) / bb.width)
        grad.setAttribute('y2', (gCoords.y2 - bb.y) / bb.height)
        grad.removeAttribute('gradientUnits')
      }
    }
  })
}