diff --git a/editor/select.js b/editor/select.js index 4ac342ef..3b2e35de 100644 --- a/editor/select.js +++ b/editor/select.js @@ -33,7 +33,8 @@ var gripRadius = svgedit.browser.isTouch() ? 10 : 4; // Parameters: // id - integer to internally indentify the selector // elem - DOM element associated with this selector -svgedit.select.Selector = function(id, elem) { +// bbox - Optional bbox to use for initialization (prevents duplicate getBBox call). +svgedit.select.Selector = function(id, elem, bbox) { // this is the selector's unique number this.id = id; @@ -77,7 +78,7 @@ svgedit.select.Selector = function(id, elem) { 'w' : null }; - this.reset(this.selectedElement); + this.reset(this.selectedElement, bbox); }; @@ -86,10 +87,11 @@ svgedit.select.Selector = function(id, elem) { // // Parameters: // e - DOM element associated with this selector -svgedit.select.Selector.prototype.reset = function(e) { +// bbox - Optional bbox to use for reset (prevents duplicate getBBox call). +svgedit.select.Selector.prototype.reset = function(e, bbox) { this.locked = true; this.selectedElement = e; - this.resize(); + this.resize(bbox); this.selectorGroup.setAttribute('display', 'inline'); }; @@ -136,7 +138,8 @@ svgedit.select.Selector.prototype.showGrips = function(show) { // Function: svgedit.select.Selector.resize // Updates the selector to match the element's size -svgedit.select.Selector.prototype.resize = function() { +// bbox - Optional bbox to use for resize (prevents duplicate getBBox call). +svgedit.select.Selector.prototype.resize = function(bbox) { var selectedBox = this.selectorRect, mgr = selectorManager_, selectedGrips = mgr.selectorGrips, @@ -162,7 +165,11 @@ svgedit.select.Selector.prototype.resize = function() { m.e *= current_zoom; m.f *= current_zoom; - var bbox = svgedit.utilities.getBBox(selected); + if (!bbox) { + bbox = svgedit.utilities.getBBox(selected); + } + // TODO: svgedit.utilities.getBBox (previous line) already knows to call getStrokedBBox when tagName === 'g'. Remove this? + // TODO: svgedit.utilities.getBBox doesn't exclude 'gsvg' and calls getStrokedBBox for any 'g'. Should getBBox be updated? if (tagName === 'g' && !$.data(selected, 'gsvg')) { // The bbox for a group does not include stroke vals, so we // get the bbox based on its children. @@ -413,7 +420,8 @@ svgedit.select.SelectorManager.prototype.initGroup = function() { // // Parameters: // elem - DOM element to get the selector for -svgedit.select.SelectorManager.prototype.requestSelector = function(elem) { +// bbox - Optional bbox to use for reset (prevents duplicate getBBox call). +svgedit.select.SelectorManager.prototype.requestSelector = function(elem, bbox) { if (elem == null) {return null;} var i, N = this.selectors.length; @@ -425,13 +433,13 @@ svgedit.select.SelectorManager.prototype.requestSelector = function(elem) { for (i = 0; i < N; ++i) { if (this.selectors[i] && !this.selectors[i].locked) { this.selectors[i].locked = true; - this.selectors[i].reset(elem); + this.selectors[i].reset(elem, bbox); this.selectorMap[elem.id] = this.selectors[i]; return this.selectors[i]; } } // if we reached here, no available selectors were found, we create one - this.selectors[N] = new svgedit.select.Selector(N, elem); + this.selectors[N] = new svgedit.select.Selector(N, elem, bbox); this.selectorParentGroup.appendChild(this.selectors[N].selectorGroup); this.selectorMap[elem.id] = this.selectors[N]; return this.selectors[N]; diff --git a/editor/svgcanvas.js b/editor/svgcanvas.js index 67e9344e..9137f6ce 100644 --- a/editor/svgcanvas.js +++ b/editor/svgcanvas.js @@ -601,140 +601,9 @@ var getIntersectionList = this.getIntersectionList = function(rect) { // // Returns: // A single bounding box object -getStrokedBBox = this.getStrokedBBox = function(elems) { +var getStrokedBBox = this.getStrokedBBox = function(elems) { if (!elems) {elems = getVisibleElements();} - if (!elems.length) {return false;} - // Make sure the expected BBox is returned if the element is a group - var getCheckedBBox = function(elem) { - // TODO: Fix issue with rotated groups. Currently they work - // fine in FF, but not in other browsers (same problem mentioned - // in Issue 339 comment #2). - - var bb = svgedit.utilities.getBBox(elem); - if (!bb) { - return null; - } - var angle = svgedit.utilities.getRotationAngle(elem); - - if ((angle && angle % 90) || - svgedit.math.hasMatrixTransform(svgedit.transformlist.getTransformList(elem))) { - // Accurate way to get BBox of rotated element in Firefox: - // Put element in group and get its BBox - var good_bb = false; - // Get the BBox from the raw path for these elements - var elemNames = ['ellipse', 'path', 'line', 'polyline', 'polygon']; - if (elemNames.indexOf(elem.tagName) >= 0) { - bb = good_bb = canvas.convertToPath(elem, true); - } else if (elem.tagName == 'rect') { - // Look for radius - var rx = elem.getAttribute('rx'); - var ry = elem.getAttribute('ry'); - if (rx || ry) { - bb = good_bb = canvas.convertToPath(elem, true); - } - } - - if (!good_bb) { - // Must use clone else FF freaks out - var clone = elem.cloneNode(true); - var g = document.createElementNS(NS.SVG, 'g'); - var parent = elem.parentNode; - parent.appendChild(g); - g.appendChild(clone); - bb = svgedit.utilities.bboxToObj(g.getBBox()); - parent.removeChild(g); - } - - // Old method: Works by giving the rotated BBox, - // this is (unfortunately) what Opera and Safari do - // natively when getting the BBox of the parent group -// var angle = angle * Math.PI / 180.0; -// var rminx = Number.MAX_VALUE, rminy = Number.MAX_VALUE, -// rmaxx = Number.MIN_VALUE, rmaxy = Number.MIN_VALUE; -// var cx = round(bb.x + bb.width/2), -// cy = round(bb.y + bb.height/2); -// var pts = [ [bb.x - cx, bb.y - cy], -// [bb.x + bb.width - cx, bb.y - cy], -// [bb.x + bb.width - cx, bb.y + bb.height - cy], -// [bb.x - cx, bb.y + bb.height - cy] ]; -// var j = 4; -// while (j--) { -// var x = pts[j][0], -// y = pts[j][1], -// r = Math.sqrt( x*x + y*y ); -// var theta = Math.atan2(y,x) + angle; -// x = round(r * Math.cos(theta) + cx); -// y = round(r * Math.sin(theta) + cy); -// -// // now set the bbox for the shape after it's been rotated -// if (x < rminx) rminx = x; -// if (y < rminy) rminy = y; -// if (x > rmaxx) rmaxx = x; -// if (y > rmaxy) rmaxy = y; -// } -// -// bb.x = rminx; -// bb.y = rminy; -// bb.width = rmaxx - rminx; -// bb.height = rmaxy - rminy; - } - return bb; - }; - - var full_bb; - $.each(elems, function() { - if (full_bb) {return;} - if (!this.parentNode) {return;} - full_bb = getCheckedBBox(this); - }); - - // This shouldn't ever happen... - if (full_bb == null) {return null;} - - // full_bb doesn't include the stoke, so this does no good! -// if (elems.length == 1) return full_bb; - - var max_x = full_bb.x + full_bb.width; - var max_y = full_bb.y + full_bb.height; - var min_x = full_bb.x; - var min_y = full_bb.y; - - // FIXME: same re-creation problem with this function as getCheckedBBox() above - var getOffset = function(elem) { - var sw = elem.getAttribute('stroke-width'); - var offset = 0; - if (elem.getAttribute('stroke') != 'none' && !isNaN(sw)) { - offset += sw/2; - } - return offset; - }; - var bboxes = []; - $.each(elems, function(i, elem) { - var cur_bb = getCheckedBBox(elem); - if (cur_bb) { - var offset = getOffset(elem); - min_x = Math.min(min_x, cur_bb.x - offset); - min_y = Math.min(min_y, cur_bb.y - offset); - bboxes.push(cur_bb); - } - }); - - full_bb.x = min_x; - full_bb.y = min_y; - - $.each(elems, function(i, elem) { - var cur_bb = bboxes[i]; - // ensure that elem is really an element node - if (cur_bb && elem.nodeType == 1) { - var offset = getOffset(elem); - max_x = Math.max(max_x, cur_bb.x + cur_bb.width + offset); - max_y = Math.max(max_y, cur_bb.y + cur_bb.height + offset); - } - }); - - full_bb.width = max_x - min_x; - full_bb.height = max_y - min_y; - return full_bb; + return svgedit.utilities.getStrokedBBox(elems, addSvgElementFromJson, pathActions) }; // Function: getVisibleElements @@ -1071,7 +940,9 @@ var addToSelection = this.addToSelection = function(elemsToAdd, showGrips) { var i = elemsToAdd.length; while (i--) { var elem = elemsToAdd[i]; - if (!elem || !svgedit.utilities.getBBox(elem)) {continue;} + if (!elem) {continue;} + var bbox = svgedit.utilities.getBBox(elem); + if (!bbox) {continue;} if (elem.tagName === 'a' && elem.childNodes.length === 1) { // Make "a" element's child be the selected element @@ -1086,7 +957,7 @@ var addToSelection = this.addToSelection = function(elemsToAdd, showGrips) { // only the first selectedBBoxes element is ever used in the codebase these days // if (j == 0) selectedBBoxes[0] = svgedit.utilities.getBBox(elem); j++; - var sel = selectorManager.requestSelector(elem); + var sel = selectorManager.requestSelector(elem, bbox); if (selectedElements.length > 1) { sel.showGrips(false); @@ -6576,179 +6447,29 @@ this.setSegType = function(new_type) { this.convertToPath = function(elem, getBBox) { if (elem == null) { var elems = selectedElements; - $.each(selectedElements, function(i, elem) { + $.each(elems, function(i, elem) { if (elem) {canvas.convertToPath(elem);} }); return; } - - if (!getBBox) { - var batchCmd = new svgedit.history.BatchCommand('Convert element to Path'); - } - - var attrs = getBBox?{}:{ - 'fill': cur_shape.fill, - 'fill-opacity': cur_shape.fill_opacity, - 'stroke': cur_shape.stroke, - 'stroke-width': cur_shape.stroke_width, - 'stroke-dasharray': cur_shape.stroke_dasharray, - 'stroke-linejoin': cur_shape.stroke_linejoin, - 'stroke-linecap': cur_shape.stroke_linecap, - 'stroke-opacity': cur_shape.stroke_opacity, - 'opacity': cur_shape.opacity, - 'visibility':'hidden' - }; - - // any attribute on the element not covered by the above - // TODO: make this list global so that we can properly maintain it - // TODO: what about @transform, @clip-rule, @fill-rule, etc? - $.each(['marker-start', 'marker-end', 'marker-mid', 'filter', 'clip-path'], function() { - if (elem.getAttribute(this)) { - attrs[this] = elem.getAttribute(this); - } - }); - - var path = addSvgElementFromJson({ - 'element': 'path', - 'attr': attrs - }); - - var eltrans = elem.getAttribute('transform'); - if (eltrans) { - path.setAttribute('transform', eltrans); - } - - var id = elem.id; - var parent = elem.parentNode; - if (elem.nextSibling) { - parent.insertBefore(path, elem); + if (getBBox) { + return svgedit.utilities.getBBoxOfElementAsPath(elem, addSvgElementFromJson, pathActions) } else { - parent.appendChild(path); - } - - var d = ''; - - var joinSegs = function(segs) { - $.each(segs, function(j, seg) { - var i; - var l = seg[0], pts = seg[1]; - d += l; - for (i = 0; i < pts.length; i+=2) { - d += (pts[i] +','+pts[i+1]) + ' '; - } - }); - }; - - // Possibly the cubed root of 6, but 1.81 works best - var num = 1.81; - var a, rx; - switch (elem.tagName) { - case 'ellipse': - case 'circle': - a = $(elem).attr(['rx', 'ry', 'cx', 'cy']); - var cx = a.cx, cy = a.cy; - rx = a.rx; - ry = a.ry; - if (elem.tagName == 'circle') { - rx = ry = $(elem).attr('r'); - } - - joinSegs([ - ['M',[(cx-rx),(cy)]], - ['C',[(cx-rx),(cy-ry/num), (cx-rx/num),(cy-ry), (cx),(cy-ry)]], - ['C',[(cx+rx/num),(cy-ry), (cx+rx),(cy-ry/num), (cx+rx),(cy)]], - ['C',[(cx+rx),(cy+ry/num), (cx+rx/num),(cy+ry), (cx),(cy+ry)]], - ['C',[(cx-rx/num),(cy+ry), (cx-rx),(cy+ry/num), (cx-rx),(cy)]], - ['Z',[]] - ]); - break; - case 'path': - d = elem.getAttribute('d'); - break; - case 'line': - a = $(elem).attr(['x1', 'y1', 'x2', 'y2']); - d = 'M'+a.x1+','+a.y1+'L'+a.x2+','+a.y2; - break; - case 'polyline': - case 'polygon': - d = 'M' + elem.getAttribute('points'); - break; - case 'rect': - var r = $(elem).attr(['rx', 'ry']); - rx = r.rx; - ry = r.ry; - var b = elem.getBBox(); - var x = b.x, y = b.y, w = b.width, h = b.height; - num = 4 - num; // Why? Because! - - if (!rx && !ry) { - // Regular rect - joinSegs([ - ['M',[x, y]], - ['L',[x+w, y]], - ['L',[x+w, y+h]], - ['L',[x, y+h]], - ['L',[x, y]], - ['Z',[]] - ]); - } else { - joinSegs([ - ['M',[x, y+ry]], - ['C',[x, y+ry/num, x+rx/num, y, x+rx, y]], - ['L',[x+w-rx, y]], - ['C',[x+w-rx/num, y, x+w, y+ry/num, x+w, y+ry]], - ['L',[x+w, y+h-ry]], - ['C',[x+w, y+h-ry/num, x+w-rx/num, y+h, x+w-rx, y+h]], - ['L',[x+rx, y+h]], - ['C',[x+rx/num, y+h, x, y+h-ry/num, x, y+h-ry]], - ['L',[x, y+ry]], - ['Z',[]] - ]); - } - break; - default: - path.parentNode.removeChild(path); - break; - } - - if (d) { - path.setAttribute('d', d); - } - - if (!getBBox) { - // Replace the current element with the converted one - - // Reorient if it has a matrix - if (eltrans) { - var tlist = svgedit.transformlist.getTransformList(path); - if (svgedit.math.hasMatrixTransform(tlist)) { - pathActions.resetOrientation(path); - } - } - - var nextSibling = elem.nextSibling; - batchCmd.addSubCommand(new svgedit.history.RemoveElementCommand(elem, nextSibling, parent)); - batchCmd.addSubCommand(new svgedit.history.InsertElementCommand(path)); - - clearSelection(); - elem.parentNode.removeChild(elem); - path.setAttribute('id', id); - path.removeAttribute('visibility'); - addToSelection([path], true); - - addCommandToHistory(batchCmd); - - } else { - // Get the correct BBox of the new path, then discard it - pathActions.resetOrientation(path); - var bb = false; - try { - bb = path.getBBox(); - } catch(e) { - // Firefox fails - } - path.parentNode.removeChild(path); - return bb; + // TODO: Why is this applying attributes from cur_shape, then inside utilities.convertToPath it's pulling addition attributes from elem? + // TODO: If convertToPath is called with one elem, cur_shape and elem are probably the same; but calling with multiple is a bug or cool feature. + var attrs = { + 'fill': cur_shape.fill, + 'fill-opacity': cur_shape.fill_opacity, + 'stroke': cur_shape.stroke, + 'stroke-width': cur_shape.stroke_width, + 'stroke-dasharray': cur_shape.stroke_dasharray, + 'stroke-linejoin': cur_shape.stroke_linejoin, + 'stroke-linecap': cur_shape.stroke_linecap, + 'stroke-opacity': cur_shape.stroke_opacity, + 'opacity': cur_shape.opacity, + 'visibility':'hidden' + }; + return svgedit.utilities.convertToPath(elem, attrs, addSvgElementFromJson, pathActions, clearSelection, addToSelection, svgedit.history, addCommandToHistory); } }; diff --git a/editor/svgutils.js b/editor/svgutils.js index d3d9b011..3fef5302 100644 --- a/editor/svgutils.js +++ b/editor/svgutils.js @@ -529,6 +529,447 @@ svgedit.utilities.getBBox = function(elem) { return ret; }; +// Function: getPathDFromSegments +// Create a path 'd' attribute from path segments. +// Each segment is an array of the form: [singleChar, [x,y, x,y, ...]] +// +// Parameters: +// pathSegments - An array of path segments to be converted +// +// Returns: +// The converted path d attribute. +svgedit.utilities.getPathDFromSegments = function(pathSegments) { + var d = ''; + + $.each(pathSegments, function(j, seg) { + var i; + var pts = seg[1]; + d += seg[0]; + for (i = 0; i < pts.length; i+=2) { + d += (pts[i] +','+pts[i+1]) + ' '; + } + }); + + return d; +}; + +// Function: getPathDFromElement +// Make a path 'd' attribute from a simple SVG element shape. +// +// Parameters: +// elem - The element to be converted +// +// Returns: +// The path d attribute or undefined if the element type is unknown. +svgedit.utilities.getPathDFromElement = function(elem) { + + // Possibly the cubed root of 6, but 1.81 works best + var num = 1.81; + var d, a, rx, ry; + switch (elem.tagName) { + case 'ellipse': + case 'circle': + a = $(elem).attr(['rx', 'ry', 'cx', 'cy']); + var cx = a.cx, cy = a.cy; + rx = a.rx; + ry = a.ry; + if (elem.tagName == 'circle') { + rx = ry = $(elem).attr('r'); + } + + d = svgedit.utilities.getPathDFromSegments([ + ['M',[(cx-rx),(cy)]], + ['C',[(cx-rx),(cy-ry/num), (cx-rx/num),(cy-ry), (cx),(cy-ry)]], + ['C',[(cx+rx/num),(cy-ry), (cx+rx),(cy-ry/num), (cx+rx),(cy)]], + ['C',[(cx+rx),(cy+ry/num), (cx+rx/num),(cy+ry), (cx),(cy+ry)]], + ['C',[(cx-rx/num),(cy+ry), (cx-rx),(cy+ry/num), (cx-rx),(cy)]], + ['Z',[]] + ]); + break; + case 'path': + d = elem.getAttribute('d'); + break; + case 'line': + a = $(elem).attr(['x1', 'y1', 'x2', 'y2']); + d = 'M'+a.x1+','+a.y1+'L'+a.x2+','+a.y2; + break; + case 'polyline': + d = 'M' + elem.getAttribute('points'); + break; + case 'polygon': + d = 'M' + elem.getAttribute('points') + ' Z'; + break; + case 'rect': + var r = $(elem).attr(['rx', 'ry']); + rx = r.rx; + ry = r.ry; + var b = elem.getBBox(); + var x = b.x, y = b.y, w = b.width, h = b.height; + num = 4 - num; // Why? Because! + + if (!rx && !ry) { + // Regular rect + d = svgedit.utilities.getPathDFromSegments([ + ['M',[x, y]], + ['L',[x+w, y]], + ['L',[x+w, y+h]], + ['L',[x, y+h]], + ['L',[x, y]], + ['Z',[]] + ]); + } else { + d = svgedit.utilities.getPathDFromSegments([ + ['M',[x, y+ry]], + ['C',[x, y+ry/num, x+rx/num, y, x+rx, y]], + ['L',[x+w-rx, y]], + ['C',[x+w-rx/num, y, x+w, y+ry/num, x+w, y+ry]], + ['L',[x+w, y+h-ry]], + ['C',[x+w, y+h-ry/num, x+w-rx/num, y+h, x+w-rx, y+h]], + ['L',[x+rx, y+h]], + ['C',[x+rx/num, y+h, x, y+h-ry/num, x, y+h-ry]], + ['L',[x, y+ry]], + ['Z',[]] + ]); + } + break; + default: + break; + } + + return d; + +}; + +// Function: getExtraAttributesForConvertToPath +// Get a set of attributes from an element that is useful for convertToPath. +// +// Parameters: +// elem - The element to be probed +// +// Returns: +// An object with attributes. +svgedit.utilities.getExtraAttributesForConvertToPath = function(elem) { + var attrs = {} ; + // TODO: make this list global so that we can properly maintain it + // TODO: what about @transform, @clip-rule, @fill-rule, etc? + $.each(['marker-start', 'marker-end', 'marker-mid', 'filter', 'clip-path'], function() { + var a = elem.getAttribute(this); + if (a) { + attrs[this] = a; + } + }); + return attrs; +}; + +// Function: getBBoxOfElementAsPath +// Get the BBox of an element-as-path +// +// Parameters: +// elem - The DOM element to be probed +// addSvgElementFromJson - Function to add the path element to the current layer. See canvas.addSvgElementFromJson +// pathActions - If a transform exists, pathActions.resetOrientation() is used. See: canvas.pathActions. +// +// Returns: +// The resulting path's bounding box object. +svgedit.utilities.getBBoxOfElementAsPath = function(elem, addSvgElementFromJson, pathActions) { + + var path = addSvgElementFromJson({ + 'element': 'path', + 'attr': svgedit.utilities.getExtraAttributesForConvertToPath(elem) + }); + + var eltrans = elem.getAttribute('transform'); + if (eltrans) { + path.setAttribute('transform', eltrans); + } + + var parent = elem.parentNode; + if (elem.nextSibling) { + parent.insertBefore(path, elem); + } else { + parent.appendChild(path); + } + + var d = svgedit.utilities.getPathDFromElement(elem); + if (d) + path.setAttribute('d', d); + else + path.parentNode.removeChild(path); + + // Get the correct BBox of the new path, then discard it + pathActions.resetOrientation(path); + var bb = false; + try { + bb = path.getBBox(); + } catch(e) { + // Firefox fails + } + path.parentNode.removeChild(path); + return bb; +}; + +// Function: convertToPath +// Convert selected element to a path. +// +// Parameters: +// elem - The DOM element to be converted +// attrs - Apply attributes to new path. see canvas.convertToPath +// addSvgElementFromJson - Function to add the path element to the current layer. See canvas.addSvgElementFromJson +// pathActions - If a transform exists, pathActions.resetOrientation() is used. See: canvas.pathActions. +// clearSelection - see canvas.clearSelection +// addToSelection - see canvas.addToSelection +// history - see svgedit.history +// addCommandToHistory - see canvas.addCommandToHistory +// +// Returns: +// The converted path element or null if the DOM element was not recognized. +svgedit.utilities.convertToPath = function(elem, attrs, addSvgElementFromJson, pathActions, clearSelection, addToSelection, history, addCommandToHistory) { + + var batchCmd = new history.BatchCommand('Convert element to Path'); + + // Any attribute on the element not covered by the passed-in attributes + attrs = $.extend({}, attrs, svgedit.utilities.getExtraAttributesForConvertToPath(elem)); + + var path = addSvgElementFromJson({ + 'element': 'path', + 'attr': attrs + }); + + var eltrans = elem.getAttribute('transform'); + if (eltrans) { + path.setAttribute('transform', eltrans); + } + + var id = elem.id; + var parent = elem.parentNode; + if (elem.nextSibling) { + parent.insertBefore(path, elem); + } else { + parent.appendChild(path); + } + + var d = svgedit.utilities.getPathDFromElement(elem); + if (d) { + path.setAttribute('d', d); + + // Replace the current element with the converted one + + // Reorient if it has a matrix + if (eltrans) { + var tlist = svgedit.transformlist.getTransformList(path); + if (svgedit.math.hasMatrixTransform(tlist)) { + pathActions.resetOrientation(path); + } + } + + var nextSibling = elem.nextSibling; + batchCmd.addSubCommand(new history.RemoveElementCommand(elem, nextSibling, parent)); + batchCmd.addSubCommand(new history.InsertElementCommand(path)); + + clearSelection(); + elem.parentNode.removeChild(elem); + path.setAttribute('id', id); + path.removeAttribute('visibility'); + addToSelection([path], true); + + addCommandToHistory(batchCmd); + + return path; + } else { + // the elem.tagName was not recognized, so no "d" attribute. Remove it, so we've haven't changed anything. + path.parentNode.removeChild(path); + return null; + } + +}; + +// Function: bBoxCanBeOptimizedOverNativeGetBBox +// Can the bbox be optimized over the native getBBox? The optimized bbox is the same as the native getBBox when +// the rotation angle is a multiple of 90 degrees and there are no complex transforms. +// Getting an optimized bbox can be dramatically slower, so we want to make sure it's worth it. +// +// The best example for this is a circle rotate 45 degrees. The circle doesn't get wider or taller when rotated +// about it's center. +// +// The standard, unoptimized technique gets the native bbox of the circle, rotates the box 45 degrees, uses +// that width and height, and applies any transforms to get the final bbox. This means the calculated bbox +// is much wider than the original circle. If the angle had been 0, 90, 180, etc. both techniques render the +// same bbox. +// +// The optimization is not needed if the rotation is a multiple 90 degrees. The default technique is to call +// getBBox then apply the angle and any transforms. +// +// Parameters: +// angle - The rotation angle in degrees +// hasMatrixTransform - True if there is a matrix transform +// +// Returns: +// True if the bbox can be optimized. +function bBoxCanBeOptimizedOverNativeGetBBox(angle, hasMatrixTransform) { + var angleModulo90 = angle % 90; + var closeTo90 = angleModulo90 < -89.99 || angleModulo90 > 89.99; + var closeTo0 = angleModulo90 > -0.001 && angleModulo90 < 0.001; + return hasMatrixTransform || ! (closeTo0 || closeTo90); +} + +// Function: getBBoxWithTransform +// Get bounding box that includes any transforms. +// +// Parameters: +// elem - The DOM element to be converted +// addSvgElementFromJson - Function to add the path element to the current layer. See canvas.addSvgElementFromJson +// pathActions - If a transform exists, pathActions.resetOrientation() is used. See: canvas.pathActions. +// +// Returns: +// A single bounding box object +svgedit.utilities.getBBoxWithTransform = function(elem, addSvgElementFromJson, pathActions) { + // TODO: Fix issue with rotated groups. Currently they work + // fine in FF, but not in other browsers (same problem mentioned + // in Issue 339 comment #2). + + var bb = svgedit.utilities.getBBox(elem); + + if (!bb) { + return null; + } + + var tlist = svgedit.transformlist.getTransformList(elem); + var angle = svgedit.utilities.getRotationAngleFromTransformList(tlist); + var hasMatrixTransform = svgedit.math.hasMatrixTransform(tlist); + + if (angle || hasMatrixTransform) { + + var good_bb = false; + if (bBoxCanBeOptimizedOverNativeGetBBox(angle, hasMatrixTransform)) { + // Get the BBox from the raw path for these elements + // TODO: why ellipse and not circle + var elemNames = ['ellipse', 'path', 'line', 'polyline', 'polygon']; + if (elemNames.indexOf(elem.tagName) >= 0) { + bb = good_bb = svgedit.utilities.getBBoxOfElementAsPath(elem, addSvgElementFromJson, pathActions); + } else if (elem.tagName == 'rect') { + // Look for radius + var rx = elem.getAttribute('rx'); + var ry = elem.getAttribute('ry'); + if (rx || ry) { + bb = good_bb = svgedit.utilities.getBBoxOfElementAsPath(elem, addSvgElementFromJson, pathActions); + } + } + } + + if (!good_bb) { + + var matrix = svgedit.math.transformListToTransform(tlist).matrix; + bb = svgedit.math.transformBox(bb.x, bb.y, bb.width, bb.height, matrix).aabox; + + // Old technique that was exceedingly slow with large documents. + // + // Accurate way to get BBox of rotated element in Firefox: + // Put element in group and get its BBox + // + // Must use clone else FF freaks out + //var clone = elem.cloneNode(true); + //var g = document.createElementNS(NS.SVG, 'g'); + //var parent = elem.parentNode; + //parent.appendChild(g); + //g.appendChild(clone); + //var bb2 = svgedit.utilities.bboxToObj(g.getBBox()); + //parent.removeChild(g); + } + + } + return bb; +}; + +// TODO: This is problematic with large stroke-width and, for example, a single horizontal line. The calculated BBox extends way beyond left and right sides. +function getStrokeOffsetForBBox(elem) { + var sw = elem.getAttribute('stroke-width'); + return (!isNaN(sw) && elem.getAttribute('stroke') != 'none') ? sw/2 : 0; +}; + +// Function: getStrokedBBox +// Get the bounding box for one or more stroked and/or transformed elements +// +// Parameters: +// elems - Array with DOM elements to check +// addSvgElementFromJson - Function to add the path element to the current layer. See canvas.addSvgElementFromJson +// pathActions - If a transform exists, pathActions.resetOrientation() is used. See: canvas.pathActions. +// +// Returns: +// A single bounding box object +svgedit.utilities.getStrokedBBox = function(elems, addSvgElementFromJson, pathActions) { + if (!elems || !elems.length) {return false;} + + var full_bb; + $.each(elems, function() { + if (full_bb) {return;} + if (!this.parentNode) {return;} + full_bb = svgedit.utilities.getBBoxWithTransform(this, addSvgElementFromJson, pathActions); + }); + + // This shouldn't ever happen... + if (full_bb === undefined) {return null;} + + // full_bb doesn't include the stoke, so this does no good! + // if (elems.length == 1) return full_bb; + + var max_x = full_bb.x + full_bb.width; + var max_y = full_bb.y + full_bb.height; + var min_x = full_bb.x; + var min_y = full_bb.y; + + // If only one elem, don't call the potentially slow getBBoxWithTransform method again. + if (elems.length === 1) { + var offset = getStrokeOffsetForBBox(elems[0]); + min_x -= offset; + min_y -= offset; + max_x += offset; + max_y += offset; + } else { + $.each(elems, function(i, elem) { + var cur_bb = svgedit.utilities.getBBoxWithTransform(elem, addSvgElementFromJson, pathActions); + if (cur_bb) { + var offset = getStrokeOffsetForBBox(elem); + min_x = Math.min(min_x, cur_bb.x - offset); + min_y = Math.min(min_y, cur_bb.y - offset); + // TODO: The old code had this test for max, but not min. I suspect this test should be for both min and max + if (elem.nodeType == 1) { + max_x = Math.max(max_x, cur_bb.x + cur_bb.width + offset); + max_y = Math.max(max_y, cur_bb.y + cur_bb.height + offset); + } + } + }); + } + + full_bb.x = min_x; + full_bb.y = min_y; + full_bb.width = max_x - min_x; + full_bb.height = max_y - min_y; + return full_bb; +}; + + +// Function: svgedit.utilities.getRotationAngleFromTransformList +// Get the rotation angle of the given transform list. +// +// Parameters: +// tlist - List of transforms +// to_rad - Boolean that when true returns the value in radians rather than degrees +// +// Returns: +// Float with the angle in degrees or radians +svgedit.utilities.getRotationAngleFromTransformList = function(tlist, to_rad) { + if (!tlist) {return 0;} // elements have no tlist + var N = tlist.numberOfItems; + var i; + for (i = 0; i < N; ++i) { + var xform = tlist.getItem(i); + if (xform.type == 4) { + return to_rad ? xform.angle * Math.PI / 180.0 : xform.angle; + } + } + return 0.0; +}; + // Function: svgedit.utilities.getRotationAngle // Get the rotation angle of the given/selected DOM element // @@ -542,16 +983,7 @@ svgedit.utilities.getRotationAngle = function(elem, to_rad) { var selected = elem || editorContext_.getSelectedElements()[0]; // find the rotation transform (if any) and set it var tlist = svgedit.transformlist.getTransformList(selected); - if(!tlist) {return 0;} // elements have no tlist - var N = tlist.numberOfItems; - var i; - for (i = 0; i < N; ++i) { - var xform = tlist.getItem(i); - if (xform.type == 4) { - return to_rad ? xform.angle * Math.PI / 180.0 : xform.angle; - } - } - return 0.0; + return svgedit.utilities.getRotationAngleFromTransformList(tlist, to_rad) }; // Function getRefElem diff --git a/test/all_tests.html b/test/all_tests.html index c595e0e7..270a1a6e 100644 --- a/test/all_tests.html +++ b/test/all_tests.html @@ -12,6 +12,7 @@ + diff --git a/test/qunit/qunit-assert-close.js b/test/qunit/qunit-assert-close.js new file mode 100644 index 00000000..4b71e3df --- /dev/null +++ b/test/qunit/qunit-assert-close.js @@ -0,0 +1,106 @@ +/** + * Checks that the first two arguments are equal, or are numbers close enough to be considered equal + * based on a specified maximum allowable difference. + * + * @example assert.close(3.141, Math.PI, 0.001); + * + * @param Number actual + * @param Number expected + * @param Number maxDifference (the maximum inclusive difference allowed between the actual and expected numbers) + * @param String message (optional) + */ +function close(actual, expected, maxDifference, message) { + var actualDiff = (actual === expected) ? 0 : Math.abs(actual - expected), + result = actualDiff <= maxDifference; + message = message || (actual + " should be within " + maxDifference + " (inclusive) of " + expected + (result ? "" : ". Actual: " + actualDiff)); + QUnit.push(result, actual, expected, message); +} + + +/** + * Checks that the first two arguments are equal, or are numbers close enough to be considered equal + * based on a specified maximum allowable difference percentage. + * + * @example assert.close.percent(155, 150, 3.4); // Difference is ~3.33% + * + * @param Number actual + * @param Number expected + * @param Number maxPercentDifference (the maximum inclusive difference percentage allowed between the actual and expected numbers) + * @param String message (optional) + */ +close.percent = function closePercent(actual, expected, maxPercentDifference, message) { + var actualDiff, result; + if (actual === expected) { + actualDiff = 0; + result = actualDiff <= maxPercentDifference; + } + else if (actual !== 0 && expected !== 0 && expected !== Infinity && expected !== -Infinity) { + actualDiff = Math.abs(100 * (actual - expected) / expected); + result = actualDiff <= maxPercentDifference; + } + else { + // Dividing by zero (0)! Should return `false` unless the max percentage was `Infinity` + actualDiff = Infinity; + result = maxPercentDifference === Infinity; + } + message = message || (actual + " should be within " + maxPercentDifference + "% (inclusive) of " + expected + (result ? "" : ". Actual: " + actualDiff + "%")); + + QUnit.push(result, actual, expected, message); +}; + + +/** + * Checks that the first two arguments are numbers with differences greater than the specified + * minimum difference. + * + * @example assert.notClose(3.1, Math.PI, 0.001); + * + * @param Number actual + * @param Number expected + * @param Number minDifference (the minimum exclusive difference allowed between the actual and expected numbers) + * @param String message (optional) + */ +function notClose(actual, expected, minDifference, message) { + var actualDiff = Math.abs(actual - expected), + result = actualDiff > minDifference; + message = message || (actual + " should not be within " + minDifference + " (exclusive) of " + expected + (result ? "" : ". Actual: " + actualDiff)); + QUnit.push(result, actual, expected, message); +} + + +/** + * Checks that the first two arguments are numbers with differences greater than the specified + * minimum difference percentage. + * + * @example assert.notClose.percent(156, 150, 3.5); // Difference is 4.0% + * + * @param Number actual + * @param Number expected + * @param Number minPercentDifference (the minimum exclusive difference percentage allowed between the actual and expected numbers) + * @param String message (optional) + */ +notClose.percent = function notClosePercent(actual, expected, minPercentDifference, message) { + var actualDiff, result; + if (actual === expected) { + actualDiff = 0; + result = actualDiff > minPercentDifference; + } + else if (actual !== 0 && expected !== 0 && expected !== Infinity && expected !== -Infinity) { + actualDiff = Math.abs(100 * (actual - expected) / expected); + result = actualDiff > minPercentDifference; + } + else { + // Dividing by zero (0)! Should only return `true` if the min percentage was `Infinity` + actualDiff = Infinity; + result = minPercentDifference !== Infinity; + } + message = message || (actual + " should not be within " + minPercentDifference + "% (exclusive) of " + expected + (result ? "" : ". Actual: " + actualDiff + "%")); + + QUnit.push(result, actual, expected, message); +}; + + +//QUnit.extend(QUnit.assert, { +// close: close, +// notClose: notClose +//}); \ No newline at end of file diff --git a/test/svgutils_bbox_test.html b/test/svgutils_bbox_test.html new file mode 100644 index 00000000..2ae7927e --- /dev/null +++ b/test/svgutils_bbox_test.html @@ -0,0 +1,525 @@ + + + + + Unit Tests for svgutils.js BBox functions + + + + + + + + + + + + + + + + +

Unit Tests for svgutils.js BBox functions

+

+

+
    +
    + + diff --git a/test/svgutils_performance_test.html b/test/svgutils_performance_test.html new file mode 100644 index 00000000..79c6eb8c --- /dev/null +++ b/test/svgutils_performance_test.html @@ -0,0 +1,247 @@ + + + + + + + Performance Unit Tests for svgutils.js + + + + + + + + + + + + + + + + +

    Performance Unit Tests for svgutils.js

    +

    +

    +
      + +
      +
      + + + + + + + + +
      + + + + + + + + + Layer 1 + + + + + + + + + + + + Some text + + + + Layer 2 + + + + +
      +
      +
      + + + diff --git a/test/svgutils_test.html b/test/svgutils_test.html index d81e8aa9..c5cb6d54 100644 --- a/test/svgutils_test.html +++ b/test/svgutils_test.html @@ -10,6 +10,7 @@ + @@ -131,5 +362,6 @@

        +