UNPKG

svgedit

Version:

Powerful SVG-Editor for your browser

1,107 lines (993 loc) 40.2 kB
/** * Tools for svg. * @module svg * @license MIT * @copyright 2011 Jeff Schiller */ import { 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 svgContext_ = null; let svgCanvas = null; /** * @function module:svg-exec.init * @param {module:svg-exec.SvgCanvas#init} svgContext * @returns {void} */ export const init = function (svgContext) { svgContext_ = svgContext; svgCanvas = svgContext_.getCanvas(); }; /** * 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 = function () { // 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 = svgContext_.getSVGContent().childNodes; childNodesElems.forEach(function (node, i) { if (i && node.nodeType === 8 && node.data.includes('Created with')) { svgContext_.getSVGContent().firstChild.before(node); } }); // Move out of in-group editing mode if (svgContext_.getCurrentGroup()) { draw.leaveContext(); svgCanvas.selectOnly([ svgContext_.getCurrentGroup() ]); } const nakedSvgs = []; // Unwrap gsvg if it has no special attributes (only id and style) const gsvgElems = svgContext_.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(svgContext_.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 = svgContext_.getCurConfig(); const nsMap = svgContext_.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 (svgContext_.getSvgOptionApply() && elem.nodeName === 'image' && attr.localName === 'href' && svgContext_.getSvgOptionImages() && svgContext_.getSvgOptionImages() === 'embed' ) { const img = svgContext_.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 = svgContext_.getCurConfig(); const dataStorage = svgContext_.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 } = svgContext_.getSVGContent(); svgContext_.getSVGContent().remove(); const oldzoom = svgContext_.getSVGContent(); batchCmd.addSubCommand(new RemoveElementCommand(oldzoom, nextSibling, svgContext_.getSVGRoot())); // set new svg document // If DOM3 adoptNode() available, use it. Otherwise fall back to DOM2 importNode() if (svgContext_.getDOMDocument().adoptNode) { svgContext_.setSVGContent(svgContext_.getDOMDocument().adoptNode(newDoc.documentElement)); } else { svgContext_.setSVGContent(svgContext_.getDOMDocument().importNode(newDoc.documentElement, true)); } svgContext_.getSVGRoot().append(svgContext_.getSVGContent()); const content = svgContext_.getSVGContent(); svgCanvas.current_drawing_ = new draw.Drawing(svgContext_.getSVGContent(), svgContext_.getIdPrefix()); // retrieve or set the nonce const nonce = svgCanvas.getCurrentDrawing().getNonce(); if (nonce) { svgContext_.call('setnonce', nonce); } else { svgContext_.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(svgContext_.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(svgContext_.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(svgContext_.getSVGRoot(), changes)); // reset zoom svgContext_.setCurrentZoom(1); svgCanvas.clearSelection(); pathModule.clearData(); svgContext_.getSVGRoot().append(svgCanvas.selectorManager.selectorParentGroup); if (!preventUndo) svgContext_.addCommandToHistory(batchCmd); svgContext_.call('changed', [ svgContext_.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 = svgContext_.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 (svgContext_.getImportIds(uid) && svgContext_.getImportIds(uid).symbol) { const parents = getParents(svgContext_.getImportIds(uid).symbol, '#svgroot'); if (parents.length) { useExisting = true; } } const batchCmd = new BatchCommand('Import Image'); let symbol; if (useExisting) { symbol = svgContext_.getImportIds(uid).symbol; ts = svgContext_.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 = svgContext_.getDOMDocument().adoptNode ? svgContext_.getDOMDocument().adoptNode(newDoc.documentElement) : svgContext_.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(svgContext_.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 = svgContext_.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 svgContext_.setImportIds(uid, { symbol, xform: ts }); findDefs().append(symbol); batchCmd.addSubCommand(new InsertElementCommand(symbol)); } useEl = svgContext_.getDOMDocument().createElementNS(NS.SVG, 'use'); useEl.id = svgCanvas.getNextId(); svgCanvas.setHref(useEl, '#' + symbol.id); (svgContext_.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; // } svgContext_.addCommandToHistory(batchCmd); svgContext_.call('changed', [ svgContext_.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'); svgContext_.setEncodableImages(src, urldata); } catch (e) { svgContext_.setEncodableImages(src, false); } svgCanvas.setGoodImage(src); resolve(svgContext_.getEncodableImages(src)); }); imgI.addEventListener("error", (e) => { reject(`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 = svgContext_.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 = svgContext_.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 = svgContext_.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) { svgContext_.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 = svgContext_.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); svgContext_.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 svgContext_.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 = svgContext_.getDataStorage(); const id = svgCanvas.getHref(el).substr(1); const refElem = svgCanvas.getElem(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 = svgContext_.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 = svgContext_.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) svgContext_.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 = svgContext_.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'); } } }); };