Skip to content

Instantly share code, notes, and snippets.

@autaut03
Created December 7, 2019 15:09
Show Gist options
  • Save autaut03/076ef3884d96a45d8a3803030f840fde to your computer and use it in GitHub Desktop.
Save autaut03/076ef3884d96a45d8a3803030f840fde to your computer and use it in GitHub Desktop.
Fit SVG to it's boundaries
// 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