1131 lines
40 KiB
JavaScript
1131 lines
40 KiB
JavaScript
/**
|
|
* 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 * 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 { resetListMap } from './svgtransformlist.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 '../common/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 $id = 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();
|
|
$id = svgCanvas.$id;
|
|
};
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// reset transform lists
|
|
resetListMap();
|
|
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);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Serializes the current drawing into SVG XML text and passes it to the 'saved' handler.
|
|
* This function also includes the XML prolog. Clients of the `SvgCanvas` bind their save
|
|
* function to the 'saved' event.
|
|
* @function module:svgcanvas.SvgCanvas#save
|
|
* @param {module:svgcanvas.SaveOptions} opts
|
|
* @fires module:svgcanvas.SvgCanvas#event:saved
|
|
* @returns {void}
|
|
*/
|
|
export const save = function (opts) {
|
|
// remove the selected outline before serializing
|
|
svgCanvas.clearSelection();
|
|
// Update save options if provided
|
|
if (opts) {
|
|
const saveOptions = svgCanvas.mergeDeep(svgContext_.getSvgOption(), opts);
|
|
for (const [ key, value ] of Object.entries(saveOptions)) {
|
|
svgContext_.setSvgOption(key, value);
|
|
}
|
|
}
|
|
svgContext_.setSvgOption('apply', true);
|
|
|
|
// no need for doctype, see https://jwatt.org/svg/authoring/#doctype-declaration
|
|
const str = svgCanvas.svgCanvasToString();
|
|
svgContext_.call('saved', str);
|
|
};
|
|
/**
|
|
* @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();
|
|
|
|
if (!$id('export_canvas')) {
|
|
const canvasEx = document.createElement('CANVAS');
|
|
canvasEx.id = 'export_canvas';
|
|
canvasEx.style.display = 'none';
|
|
document.body.appendChild(canvasEx);
|
|
}
|
|
const c = $id('export_canvas');
|
|
c.style.width = svgCanvas.contentW + "px";
|
|
c.style.height = svgCanvas.contentH + "px";
|
|
const canvg = svgContext_.getcanvg();
|
|
const ctx = c.getContext('2d');
|
|
const v = canvg.fromString(ctx, svg);
|
|
// Render only first frame, ignoring animations.
|
|
await v.render();
|
|
// Todo: Make async/await utility in place of `toBlob`, so we can remove this constructor
|
|
return new Promise((resolve) => {
|
|
const dataURLType = type.toLowerCase();
|
|
const datauri = quality
|
|
? c.toDataURL('image/' + dataURLType, quality)
|
|
: c.toDataURL('image/' + dataURLType);
|
|
let bloburl;
|
|
/**
|
|
* Called when `bloburl` is available for export.
|
|
* @returns {void}
|
|
*/
|
|
function done() {
|
|
const obj = {
|
|
datauri, bloburl, svg, issues, issueCodes, type: imgType,
|
|
mimeType, quality, exportWindowName
|
|
};
|
|
if (!opts.avoidEvent) {
|
|
svgContext_.call('exported', obj);
|
|
}
|
|
resolve(obj);
|
|
}
|
|
if (c.toBlob) {
|
|
c.toBlob((blob) => {
|
|
bloburl = createObjectURL(blob);
|
|
done();
|
|
}, mimeType, quality);
|
|
return;
|
|
}
|
|
bloburl = dataURLToObjectURL(datauri);
|
|
done();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* @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
|
|
|
|
// Todo: Give options to use predefined jsPDF formats like "a4", etc. from pull-down (with option to keep customizable)
|
|
const doc = jsPDF({
|
|
orientation,
|
|
unit,
|
|
format: [ res.w, res.h ]
|
|
// , compressPdf: true
|
|
});
|
|
const docTitle = svgCanvas.getDocumentTitle();
|
|
doc.setProperties({
|
|
title: docTitle /* ,
|
|
subject: '',
|
|
author: '',
|
|
keywords: '',
|
|
creator: '' */
|
|
});
|
|
const { issues, issueCodes } = getIssues();
|
|
// const svg = this.svgCanvasToString();
|
|
// await doc.addSvgAsImage(svg)
|
|
await doc.svg(svgContext_.getSVGContent(), { x: 0, y: 0, width: res.w, height: res.h });
|
|
|
|
// doc.output('save'); // Works to open in a new
|
|
// window; todo: configure this and other export
|
|
// options to optionally work in this manner as
|
|
// opposed to opening a new tab
|
|
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;
|
|
};
|
|
/**
|
|
* 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
|
|
const fillStrokeElems = svgcontent.querySelectorAll('[fill="url(#' + grad.id + ')"],[stroke="url(#' + grad.id + ')"]');
|
|
if (!fillStrokeElems.length) { 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');
|
|
}
|
|
}
|
|
});
|
|
};
|