Created
December 7, 2019 15:09
-
-
Save autaut03/076ef3884d96a45d8a3803030f840fde to your computer and use it in GitHub Desktop.
Fit SVG to it's boundaries
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Big credits to https://github.com/SVG-Edit/svgedit | |
// Example: https://jsfiddle.net/sznpox2m/4/ | |
const visElems = 'a,circle,ellipse,foreignObject,g,image,line,path,polygon,polyline,rect,svg,text,tspan,use'; | |
const visElemsArr = visElems.split(','); | |
const isIdentity = function (m) { | |
return (m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1 && m.e === 0 && m.f === 0); | |
}; | |
const hasMatrixTransform = function (tlist) { | |
if (!tlist) { return false; } | |
let num = tlist.numberOfItems; | |
while (num--) { | |
const xform = tlist.getItem(num); | |
if (xform.type === 1 && !isIdentity(xform.matrix)) { return true; } | |
} | |
return false; | |
}; | |
const getTransformList = function (elem) { | |
if (elem.transform) { | |
return elem.transform.baseVal; | |
} | |
if (elem.gradientTransform) { | |
return elem.gradientTransform.baseVal; | |
} | |
if (elem.patternTransform) { | |
return elem.patternTransform.baseVal; | |
} | |
return null; | |
}; | |
const bboxToObj = function ({x, y, width, height}) { | |
return {x, y, width, height}; | |
}; | |
const walkTree = function (elem, cbFn) { | |
if (elem && elem.nodeType === 1) { | |
cbFn(elem); | |
let i = elem.childNodes.length; | |
while (i--) { | |
walkTree(elem.childNodes.item(i), cbFn); | |
} | |
} | |
}; | |
function groupBBFix (selected) { | |
try { return selected.getBBox(); } catch (e) {} | |
} | |
const getBBox = function (elem) { | |
const selected = elem; | |
if (elem.nodeType !== 1) { return null; } | |
const elname = selected.nodeName; | |
let ret = null; | |
switch (elname) { | |
case 'text': | |
if (selected.textContent === '') { | |
selected.textContent = 'a'; // Some character needed for the selector to use. | |
ret = selected.getBBox(); | |
selected.textContent = ''; | |
} else if (selected.getBBox) { | |
ret = selected.getBBox(); | |
} | |
break; | |
case 'path': | |
if (selected.getBBox) { | |
ret = selected.getBBox(); | |
} | |
break; | |
case 'g': | |
case 'a': | |
ret = groupBBFix(selected); | |
break; | |
default: | |
if (elname === 'use') { | |
ret = groupBBFix(selected); // , true); | |
} | |
if (elname === 'use' || (elname === 'foreignObject')) { | |
if (!ret) { ret = selected.getBBox(); } | |
} else if (visElemsArr.includes(elname)) { | |
if (selected) { | |
try { | |
ret = selected.getBBox(); | |
} catch (err) { | |
// tspan (and textPath apparently) have no `getBBox` in Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=937268 | |
// Re: Chrome returning bbox for containing text element, see: https://bugs.chromium.org/p/chromium/issues/detail?id=349835 | |
const extent = selected.getExtentOfChar(0); // pos+dimensions of the first glyph | |
const width = selected.getComputedTextLength(); // width of the tspan | |
ret = { | |
x: extent.x, | |
y: extent.y, | |
width, | |
height: extent.height | |
}; | |
} | |
} else { | |
// Check if element is child of a foreignObject | |
const fo = $(selected).closest('foreignObject'); | |
if (fo.length) { | |
if (fo[0].getBBox) { | |
ret = fo[0].getBBox(); | |
} | |
} | |
} | |
} | |
} | |
if (ret) { | |
ret = bboxToObj(ret); | |
} | |
// get the bounding box from the DOM (which is in that element's coordinate system) | |
return ret; | |
}; | |
const getPathDFromSegments = function (pathSegments) { | |
let d = ''; | |
$.each(pathSegments, function (j, [singleChar, pts]) { | |
d += singleChar; | |
for (let i = 0; i < pts.length; i += 2) { | |
d += (pts[i] + ',' + pts[i + 1]) + ' '; | |
} | |
}); | |
return d; | |
}; | |
const getPathDFromElement = function (elem) { | |
// Possibly the cubed root of 6, but 1.81 works best | |
let num = 1.81; | |
let d, a, rx, ry; | |
switch (elem.tagName) { | |
case 'ellipse': | |
case 'circle': { | |
a = $(elem).attr(['rx', 'ry', 'cx', 'cy']); | |
const {cx, cy} = a; | |
({rx, ry} = a); | |
if (elem.tagName === 'circle') { | |
ry = $(elem).attr('r'); | |
rx = ry; | |
} | |
d = 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': { | |
const r = $(elem).attr(['rx', 'ry']); | |
({rx, ry} = r); | |
const b = elem.getBBox(); | |
const {x, y} = b, | |
w = b.width, | |
h = b.height; | |
num = 4 - num; // Why? Because! | |
if (!rx && !ry) { | |
// Regular rect | |
d = getPathDFromSegments([ | |
['M', [x, y]], | |
['L', [x + w, y]], | |
['L', [x + w, y + h]], | |
['L', [x, y + h]], | |
['L', [x, y]], | |
['Z', []] | |
]); | |
} else { | |
d = 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; | |
}; | |
const getExtraAttributesForConvertToPath = function (elem) { | |
const 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 () { | |
const a = elem.getAttribute(this); | |
if (a) { | |
attrs[this] = a; | |
} | |
}); | |
return attrs; | |
}; | |
const getBBoxOfElementAsPath = function (elem, addSVGElementFromJson, pathActions) { | |
const path = addSVGElementFromJson({ | |
element: 'path', | |
attr: getExtraAttributesForConvertToPath(elem) | |
}); | |
const eltrans = elem.getAttribute('transform'); | |
if (eltrans) { | |
path.setAttribute('transform', eltrans); | |
} | |
const {parentNode} = elem; | |
if (elem.nextSibling) { | |
elem.before(path); | |
} else { | |
parentNode.append(path); | |
} | |
const d = getPathDFromElement(elem); | |
if (d) { | |
path.setAttribute('d', d); | |
} else { | |
path.remove(); | |
} | |
// Get the correct BBox of the new path, then discard it | |
pathActions.resetOrientation(path); | |
let bb = false; | |
try { | |
bb = path.getBBox(); | |
} catch (e) { | |
// Firefox fails | |
} | |
path.remove(); | |
return bb; | |
}; | |
function bBoxCanBeOptimizedOverNativeGetBBox (angle, hasAMatrixTransform) { | |
const angleModulo90 = angle % 90; | |
const closeTo90 = angleModulo90 < -89.99 || angleModulo90 > 89.99; | |
const closeTo0 = angleModulo90 > -0.001 && angleModulo90 < 0.001; | |
return hasAMatrixTransform || !(closeTo0 || closeTo90); | |
} | |
const NEAR_ZERO = 1e-14; | |
const matrixMultiply = function (...args) { | |
const m = args.reduceRight((prev, m1) => { | |
return m1.multiply(prev); | |
}); | |
if (Math.abs(m.a) < NEAR_ZERO) { m.a = 0; } | |
if (Math.abs(m.b) < NEAR_ZERO) { m.b = 0; } | |
if (Math.abs(m.c) < NEAR_ZERO) { m.c = 0; } | |
if (Math.abs(m.d) < NEAR_ZERO) { m.d = 0; } | |
if (Math.abs(m.e) < NEAR_ZERO) { m.e = 0; } | |
if (Math.abs(m.f) < NEAR_ZERO) { m.f = 0; } | |
return m; | |
}; | |
const transformListToTransform = function (tlist, min, max) { | |
if (tlist === null || tlist === undefined) { | |
// Or should tlist = null have been prevented before this? | |
return svg.createSVGTransformFromMatrix(svg.createSVGMatrix()); | |
} | |
min = min || 0; | |
max = max || (tlist.numberOfItems - 1); | |
min = parseInt(min); | |
max = parseInt(max); | |
if (min > max) { const temp = max; max = min; min = temp; } | |
let m = svg.createSVGMatrix(); | |
for (let i = min; i <= max; ++i) { | |
// if our indices are out of range, just use a harmless identity matrix | |
const mtom = (i >= 0 && i < tlist.numberOfItems | |
? tlist.getItem(i).matrix | |
: svg.createSVGMatrix()); | |
m = matrixMultiply(m, mtom); | |
} | |
return svg.createSVGTransformFromMatrix(m); | |
}; | |
const transformPoint = function (x, y, m) { | |
return {x: m.a * x + m.c * y + m.e, y: m.b * x + m.d * y + m.f}; | |
}; | |
const transformBox = function (l, t, w, h, m) { | |
const tl = transformPoint(l, t, m), | |
tr = transformPoint((l + w), t, m), | |
bl = transformPoint(l, (t + h), m), | |
br = transformPoint((l + w), (t + h), m), | |
minx = Math.min(tl.x, tr.x, bl.x, br.x), | |
maxx = Math.max(tl.x, tr.x, bl.x, br.x), | |
miny = Math.min(tl.y, tr.y, bl.y, br.y), | |
maxy = Math.max(tl.y, tr.y, bl.y, br.y); | |
return { | |
tl, | |
tr, | |
bl, | |
br, | |
aabox: { | |
x: minx, | |
y: miny, | |
width: (maxx - minx), | |
height: (maxy - miny) | |
} | |
}; | |
}; | |
const 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). | |
let bb = getBBox(elem); | |
if (!bb) { | |
return null; | |
} | |
const tlist = getTransformList(elem); | |
const angle = getRotationAngleFromTransformList(tlist); | |
const hasMatrixXForm = hasMatrixTransform(tlist); | |
if (angle || hasMatrixXForm) { | |
let goodBb = false; | |
if (bBoxCanBeOptimizedOverNativeGetBBox(angle, hasMatrixXForm)) { | |
// Get the BBox from the raw path for these elements | |
// TODO: why ellipse and not circle | |
const elemNames = ['ellipse', 'path', 'line', 'polyline', 'polygon']; | |
if (elemNames.includes(elem.tagName)) { | |
goodBb = getBBoxOfElementAsPath(elem, addSVGElementFromJson, pathActions); | |
bb = goodBb; | |
} else if (elem.tagName === 'rect') { | |
// Look for radius | |
const rx = elem.getAttribute('rx'); | |
const ry = elem.getAttribute('ry'); | |
if (rx || ry) { | |
goodBb = getBBoxOfElementAsPath(elem, addSVGElementFromJson, pathActions); | |
bb = goodBb; | |
} | |
} | |
} | |
if (!goodBb) { | |
const {matrix} = transformListToTransform(tlist); | |
bb = transformBox(bb.x, bb.y, bb.width, bb.height, matrix).aabox; | |
} | |
} | |
return bb; | |
}; | |
function getStrokeOffsetForBBox (elem) { | |
const sw = elem.getAttribute('stroke-width'); | |
return (!isNaN(sw) && elem.getAttribute('stroke') !== 'none') ? sw / 2 : 0; | |
} | |
const getStrokedBBox = function (elems, addSVGElementFromJson, pathActions) { | |
if (!elems || !elems.length) { return false; } | |
let fullBb; | |
$.each(elems, function () { | |
if (fullBb) { return; } | |
if (!this.parentNode) { return; } | |
fullBb = getBBoxWithTransform(this, addSVGElementFromJson, pathActions); | |
}); | |
// This shouldn't ever happen... | |
if (fullBb === undefined) { return null; } | |
// fullBb doesn't include the stoke, so this does no good! | |
// if (elems.length == 1) return fullBb; | |
let maxX = fullBb.x + fullBb.width; | |
let maxY = fullBb.y + fullBb.height; | |
let minX = fullBb.x; | |
let minY = fullBb.y; | |
// If only one elem, don't call the potentially slow getBBoxWithTransform method again. | |
if (elems.length === 1) { | |
const offset = getStrokeOffsetForBBox(elems[0]); | |
minX -= offset; | |
minY -= offset; | |
maxX += offset; | |
maxY += offset; | |
} else { | |
$.each(elems, function (i, elem) { | |
const curBb = getBBoxWithTransform(elem, addSVGElementFromJson, pathActions); | |
if (curBb) { | |
const offset = getStrokeOffsetForBBox(elem); | |
minX = Math.min(minX, curBb.x - offset); | |
minY = Math.min(minY, curBb.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) { | |
maxX = Math.max(maxX, curBb.x + curBb.width + offset); | |
maxY = Math.max(maxY, curBb.y + curBb.height + offset); | |
} | |
} | |
}); | |
} | |
fullBb.x = minX; | |
fullBb.y = minY; | |
fullBb.width = maxX - minX; | |
fullBb.height = maxY - minY; | |
return fullBb; | |
}; | |
const getVisibleElements = function (parentElement) { | |
const contentElems = []; | |
$(parentElement).children().each(function (i, elem) { | |
if (elem.getBBox) { | |
contentElems.push(elem); | |
} | |
}); | |
return contentElems.reverse(); | |
}; | |
const getRotationAngleFromTransformList = function (tlist, toRad) { | |
if (!tlist) { return 0; } // <svg> elements have no tlist | |
const N = tlist.numberOfItems; | |
for (let i = 0; i < N; ++i) { | |
const xform = tlist.getItem(i); | |
if (xform.type === 4) { | |
return toRad ? xform.angle * Math.PI / 180.0 : xform.angle; | |
} | |
} | |
return 0.0; | |
}; | |
const svg = document.getElementsByTagName("svg")[0]; | |
const bbox = getStrokedBBox(getVisibleElements(svg)); | |
console.log(svg.getBBox(), bbox); | |
const x = bbox.x - (bbox.width < bbox.height ? ((bbox.height - bbox.width) / 2) : 0); | |
const y = bbox.y - (bbox.width > bbox.height ? ((bbox.width - bbox.height) / 2) : 0); | |
const width = bbox.width < bbox.height ? bbox.height : bbox.width; | |
const height = bbox.width > bbox.height ? bbox.width : bbox.height; | |
svg.setAttribute("viewBox", [x, y, width, height].join(' ')); | |
prompt("Copy to clipboard: Ctrl+C, Enter", svg.outerHTML); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment