javascriptsvgbounding-box

How can I get the actual bounding borders of a SVG after rotation?


I need to get the position of the actual bounding borders of a SVG image after its parent being applied CSS rotation. The grey color in the image below indicates its parent element(In my case it is transparent - I added the grey color in the image below just for indicating the parent element's border)

getBoundingClientRect() or getBBox() seems to only show the bounding borders of the original rectangle - the red borders shown below. But what I want is the bounding borders without SVG's transparent background after rotation - like the green borders.

screenshot


$(function() {

  let svgString = '<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 425.58 635.88"><defs><style>.cls-1{fill:#000000;fill-rule:evenodd;}</style></defs><title>1</title><path id="path" class="cls-1" d="M918.94,831.44c-103,110.83-133.9,110.83-236.86,0-68.44-73.68-80.26-150.84-84.92-241.2-4.22-81.95-26.86-194.68,18.05-248.36,70.51-84.25,300.09-84.25,370.59,0,44.92,53.68,22.28,166.41,18.06,248.36C999.2,680.6,987.39,757.77,918.94,831.44Z" transform="translate(-587.72 -278.69)"/></svg>'

  let svgElement = new DOMParser().parseFromString(svgString, 'text/xml').documentElement

  $('#box').html(svgElement)

  $('#box').css({
    transform: 'rotate(60deg)'
  })

  $('#rect').click(function() {

    let rect = svgElement.getBoundingClientRect()

    $('#bounding-border').css({
      left: rect.left + 'px',
      top: rect.top + 'px',
      width: rect.width + 'px',
      height: rect.height + 'px',
    })
  })

  $('#bbox').click(function() {

    let bbox = svgElement.getBBox()

    $('#bounding-border').css({
      left: bbox.left + 'px',
      top: bbox.top + 'px',
      width: bbox.width + 'px',
      height: bbox.height + 'px',
    })
  })
})
#box {
  position: absolute;
  left: 100px;
  top: 100px;
  width: 100px;
  height: 150px;
}

svg {
  path:hover {
    fill: red;
  }
}

#bounding-border {
  position: absolute;
  border: 2px solid red;
  z-index: -1;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>

<button id="rect">getBoundingClientRect</button>
<button id="bbox">getBBox</button>
<div id="box"></div>
<div id="bounding-border"></div>

codepen


Solution

  • To this date you can't tweak native methods to get a tight bounding box as expected by the visual boundaries after transformations.

    The best you can do is

    $(function() {
      let svgString =
        `<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 425.58 635.88"><defs><style>.cls-1{fill:#000000;fill-rule:evenodd;}</style></defs><title>1</title><path id="path" class="cls-1" d="M918.94,831.44c-103,110.83-133.9,110.83-236.86,0-68.44-73.68-80.26-150.84-84.92-241.2-4.22-81.95-26.86-194.68,18.05-248.36,70.51-84.25,300.09-84.25,370.59,0,44.92,53.68,22.28,166.41,18.06,248.36C999.2,680.6,987.39,757.77,918.94,831.44Z" 
        transform-origin="center" 
      transform="rotate(60) translate(-587.72 -278.69)"/></svg>`;
    
      let svgElement = new DOMParser().parseFromString(svgString, "text/xml")
        .documentElement;
      let path = svgElement.getElementById("path");
    
      // append svg
      $("#box").html(svgElement);
    
    
      /**
       * "flatten" transformations
       * for hardcoded path data cordinates
       */
      flattenSVGTransformations(svgElement);
    
    
      // get bounding box of flattened svg
      let bb = svgElement.getBBox();
    
    
      // adjust viewBox according to bounding box
      svgElement.setAttribute('viewBox', [bb.x, bb.y, bb.width, bb.height].join())
    
      // get DOM bounding box
      let rect = svgElement.getBoundingClientRect();
    
      // adjust absolutely positioned wrapper
      $("#bounding-border").css({
        left: rect.x + "px",
        top: rect.y + "px",
        width: rect.width + "px",
        height: rect.height + "px"
      });
    
    });
    
    
    /**
     * flattening/detransform helpers
     */
    
    function flattenSVGTransformations(svg) {
      let els = svg.querySelectorAll('text, path, polyline, polygon, line, rect, circle, ellipse');
      els.forEach(el => {
        // convert primitives to paths
        if (el instanceof SVGGeometryElement && el.nodeName !== 'path') {
          let pathData = el.getPathData({
            normalize: true
          });
          let pathNew = document.createElementNS('http://www.w3.org/2000/svg', 'path');
          pathNew.setPathData(pathData);
          copyAttributes(el, pathNew);
          el.replaceWith(pathNew)
          el = pathNew;
        }
        reduceElementTransforms(el);
      });
      // remove group transforms
      let groups = svg.querySelectorAll('g');
      groups.forEach(g => {
        g.removeAttribute('transform');
        g.removeAttribute('transform-origin');
        g.style.removeProperty('transform');
        g.style.removeProperty('transform-origin');
      });
    }
    
    function reduceElementTransforms(el, decimals = 3) {
      let parent = el.farthestViewportElement;
      // check elements transformations
      let matrix = parent.getScreenCTM().inverse().multiply(el.getScreenCTM());
      let {
        a,
        b,
        c,
        d,
        e,
        f
      } = matrix;
      // round matrix
      [a, b, c, d, e, f] = [a, b, c, d, e, f].map(val => {
        return +val.toFixed(3)
      });
      let matrixStr = [a, b, c, d, e, f].join('');
      let isTransformed = matrixStr !== "100100" ? true : false;
      if (isTransformed) {
    
    
        // if text element: consolidate all applied transforms 
        if (el instanceof SVGGeometryElement === false) {
          if (isTransformed) {
            el.setAttribute('transform', transObj.svgTransform);
            el.removeAttribute('transform-origin');
            el.style.removeProperty('transform');
            el.style.removeProperty('transform-origin');
          }
          return false
        }
        /**
         * is geometry elements: 
         * recalculate pathdata
         * according to transforms
         * by matrix transform
         */
        let pathData = el.getPathData({
          normalize: true
        });
        let svg = el.closest("svg");
        pathData.forEach((com, i) => {
          let values = com.values;
          for (let v = 0; v < values.length - 1; v += 2) {
            let [x, y] = [values[v], values[v + 1]];
            let pt = new DOMPoint(x, y);
            let pTrans = pt.matrixTransform(matrix);
            // update coordinates in pathdata array
            pathData[i]["values"][v] = +(pTrans.x).toFixed(decimals);
            pathData[i]["values"][v + 1] = +(pTrans.y).toFixed(decimals);
          }
        });
        // apply pathdata - remove transform
        el.setPathData(pathData);
        el.removeAttribute('transform');
        el.style.removeProperty('transform');
        return pathData;
      }
    }
    
    
    /**
     * get element transforms
     */
    function getElementTransform(el, parent, precision = 6) {
      let matrix = parent.getScreenCTM().inverse().multiply(el.getScreenCTM());
      let matrixVals = [matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f].map(val => {
        return +val.toFixed(precision)
      });
      return matrixVals;
    }
    
    
    /**
     * copy attributes:
     * used for primitive to path conversions
     */
    function copyAttributes(el, newEl) {
      let atts = [...el.attributes];
      let excludedAtts = ['d', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx',
        'ry', 'points', 'height', 'width'
      ];
      for (let a = 0; a < atts.length; a++) {
        let att = atts[a];
        if (excludedAtts.indexOf(att.nodeName) === -1) {
          let attrName = att.nodeName;
          let attrValue = att.nodeValue;
          newEl.setAttribute(attrName, attrValue + '');
        }
      }
    }
    * {
      box-sizing: border-box;
    }
    
    svg {
      display: block;
      outline: 1px solid #ccc;
      overflow: visible;
    }
    
    #box {
      position: absolute;
      width: 75%;
      outline: 1px solid #ccc;
    }
    
    
    #bounding-border {
      position: absolute;
      border: 2px solid rgba(255, 0, 0, 0.5);
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
    
    <!-- path data parser -->
    <script src="https://cdn.jsdelivr.net/npm/path-data-polyfill@1.0.4/path-data-polyfill.min.js"></script>
    
    
    <div id="box"></div>
    <div id="bounding-border"></div>

    Obviously, the detransformation/flattening process will change the current transformation.
    We also need a path data parser to get calculable absolute command coordinates. I'm using Jarek Foksa's getpathData() polyfill (which may soon become obsolete at least for Firefox =).

    Worth noting, we need to adjust the svg's viewBox after flattening – otherwise we won't get the correct layout offsets via getBoundingClientRect() as this method respects the SVG's viewBox and thus returns a 'clipped' bounding box.

    See also