Baking transforms into SVG Path Element commands
Asked Answered
F

5

30

tl;dr summary: Give me the resources or help fix the below code to transform path commands for SVG <path> elements by an arbitrary matrix.

details:
I'm writing a library to convert any arbitrary SVG shape into a <path> element. I have it working when there are no transform="..." elements in the hierarchy, but now I want to bake the local transform of the object into the path data commands themselves.

This is mostly working (code below) when dealing with the simple moveto/lineto commands. However, I'm not sure of the appropriate way to transform the bezier handles or arcTo parameters.

For example, I am able to convert this rounded rectangle to a <path>:

<rect x="10" y="30" rx="10" ry="20" width="80" height="70" />
--> <path d=​"M20,30 L80,30 A10,20,0,0,1,90,50 L90,80 A10,20,0,0,1,80,100
             L20,100 A10,20,0,0,1,10,80 L10,50 A10,20,0,0,1,20,30" />

And I get a valid result when transforming without any round corners:

<rect x="10" y="30" width="80" height="70"
      transform="translate(-200,0) scale(1.5) rotate(50)" />
--> <path d=​"M10,30 L90,30 L90,100 L10,100 L10,30" />

However, transforming only the x/y coords of the elliptical arc commands yields amusing results: Rounded rectangle with green blobs oozing from the corners outside the boundary
The dotted line is the actual transformed rect, the green fill is my path.

Following is the code I have so far (slightly pared-down). I also have a test page where I'm testing various shapes. Please help me determine how to properly transform the elliptical arc and various other bezier commands given an arbitrary transformation matrix.

function flattenToPaths(el,transform,svg){
  if (!svg) svg=el; while(svg && svg.tagName!='svg') svg=svg.parentNode;
  var doc = el.ownerDocument;
  var svgNS = svg.getAttribute('xmlns');

  // Identity transform if nothing passed in
  if (!transform) transform= svg.createSVGMatrix();

  // Calculate local transform matrix for the object
  var localMatrix = svg.createSVGMatrix();
  for (var xs=el.transform.baseVal,i=xs.numberOfItems-1;i>=0;--i){
    localMatrix = xs.getItem(i).matrix.multiply(localMatrix);
  }
  // Transform the local transform by whatever was recursively passed in
  transform = transform.multiply(localMatrix);

  var path = doc.createElementNS(svgNS,'path');
  switch(el.tagName){
    case 'rect':
      path.setAttribute('stroke',el.getAttribute('stroke'));
      var x  = el.getAttribute('x')*1,     y  = el.getAttribute('y')*1,
          w  = el.getAttribute('width')*1, h  = el.getAttribute('height')*1,
          rx = el.getAttribute('rx')*1,    ry = el.getAttribute('ry')*1;
      if (rx && !el.hasAttribute('ry')) ry=rx;
      else if (ry && !el.hasAttribute('rx')) rx=ry;
      if (rx>w/2) rx=w/2;
      if (ry>h/2) ry=h/2;
      path.setAttribute('d',
        'M'+(x+rx)+','+y+
        'L'+(x+w-rx)+','+y+
        ((rx||ry) ? ('A'+rx+','+ry+',0,0,'+(rx*ry<0?0:1)+','+(x+w)+','+(y+ry)) : '') +
        'L'+(x+w)+','+(y+h-ry)+
        ((rx||ry) ? ('A'+rx+','+ry+',0,0,'+(rx*ry<0?0:1)+','+(x+w-rx)+','+(y+h)) : '')+
        'L'+(x+rx)+','+(y+h)+
        ((rx||ry) ? ('A'+rx+','+ry+',0,0,'+(rx*ry<0?0:1)+','+x+','+(y+h-ry)) : '')+
        'L'+x+','+(y+ry)+
        ((rx||ry) ? ('A'+rx+','+ry+',0,0,'+(rx*ry<0?0:1)+','+(x+rx)+','+y) : '')
      );
    break;

    case 'circle':
      var cx = el.getAttribute('cx')*1, cy = el.getAttribute('cy')*1,
          r  = el.getAttribute('r')*1,  r0 = r/2+','+r/2;
      path.setAttribute('d','M'+cx+','+(cy-r)+' A'+r0+',0,0,0,'+cx+','+(cy+r)+' '+r0+',0,0,0,'+cx+','+(cy-r) );
    break;

    case 'ellipse':
      var cx = el.getAttribute('cx')*1, cy = el.getAttribute('cy')*1,
          rx = el.getAttribute('rx')*1, ry = el.getAttribute('ry')*1;
      path.setAttribute('d','M'+cx+','+(cy-ry)+' A'+rx+','+ry+',0,0,0,'+cx+','+(cy+ry)+' '+rx+','+ry+',0,0,0,'+cx+','+(cy-ry) );
    break;

    case 'line':
      var x1=el.getAttribute('x1')*1, y1=el.getAttribute('y1')*1,
          x2=el.getAttribute('x2')*1, y2=el.getAttribute('y2')*1;
      path.setAttribute('d','M'+x1+','+y1+'L'+x2+','+y2);
    break;

    case 'polyline':
    case 'polygon':
      for (var i=0,l=[],pts=el.points,len=pts.numberOfItems;i<len;++i){
        var p = pts.getItem(i);
        l[i] = p.x+','+p.y;
      }
      path.setAttribute('d',"M"+l.shift()+"L"+l.join(' ') + (el.tagName=='polygon') ? 'z' : '');
    break;

    case 'path':
      path = el.cloneNode(false);
    break;
  }

  // Convert local space by the transform matrix
  var x,y;
  var pt = svg.createSVGPoint();
  var setXY = function(x,y,xN,yN){
    pt.x = x; pt.y = y;
    pt = pt.matrixTransform(transform);
    if (xN) seg[xN] = pt.x;
    if (yN) seg[yN] = pt.y;
  };

  // Extract rotation and scale from the transform
  var rotation = Math.atan2(transform.b,transform.d)*180/Math.PI;
  var sx = Math.sqrt(transform.a*transform.a+transform.c*transform.c);
  var sy = Math.sqrt(transform.b*transform.b+transform.d*transform.d);

  // FIXME: Must translate any Horizontal or Vertical lineto commands into absolute moveto
  for (var segs=path.pathSegList,c=segs.numberOfItems,i=0;i<c;++i){
    var seg = segs.getItem(i);

    // Odd-numbered path segments are all relative
    // http://www.w3.org/TR/SVG/paths.html#InterfaceSVGPathSeg
    var isRelative = (seg.pathSegType%2==1);
    var hasX = seg.x != null;
    var hasY = seg.y != null;
    if (hasX) x = isRelative ? x+seg.x : seg.x;
    if (hasY) y = isRelative ? y+seg.y : seg.y;
    if (hasX || hasY) setXY( x, y, hasX && 'x', hasY && 'y' );

    if (seg.x1 != null) setXY( seg.x1, seg.y1, 'x1', 'y1' );
    if (seg.x2 != null) setXY( seg.x2, seg.y2, 'x2', 'y2' );
    if (seg.angle != null){
      seg.angle += rotation;
      seg.r1 *= sx; // FIXME; only works for uniform scale
      seg.r2 *= sy; // FIXME; only works for uniform scale
    }
  }

  return path;
}
Faery answered 1/3, 2011 at 0:44 Comment(1)
For the curious, the motivation for this library is because I actually want to turn every object into a polygon of sampled points so that I can perform complex-plane non-affine transformations on them.Faery
G
22

I have made a general SVG flattener flatten.js, that supports all shapes and path commands: https://gist.github.com/timo22345/9413158

Basic usage: flatten(document.getElementById('svg'));

What it does: Flattens elements (converts elements to paths and flattens transformations). If the argument element (whose id is above 'svg') has children, or it's descendants has children, these children elements are flattened also.

What can be flattened: entire SVG document, individual shapes (path, circle, ellipse etc.) and groups. Nested groups are handled automatically.

How about attributes? All attributes are copied. Only arguments that are not valid in path element, are dropped (eg. r, rx, ry, cx, cy), but they are not needed anymore. Also transform attribute is dropped, because transformations are flattened to path commands.

If you want to modify path coordinates using non-affine methods (eg. perspective distort), you can convert all segments to cubic curves using: flatten(document.getElementById('svg'), true);

There are also arguments 'toAbsolute' (convert coordinates to absolute) and 'dec', number of digits after decimal separator.

Extreme path and shape tester: https://jsfiddle.net/fjm9423q/embedded/result/

Basic usage example: http://jsfiddle.net/nrjvmqur/embedded/result/

CONS: text element is not working. It could be my next goal.

Gwenni answered 7/3, 2014 at 15:32 Comment(4)
This answer no longer appears to work correctly with Chrome v50.0.2661.102 on Windows.Faery
Thanks, @Phrogz. Chrome dropped support for SVGElement.prototype.getTransformToElement. I updated the examples to make use of shim.Aplite
How are you supposed to use your function when it has no return values?!Suburb
Could you supply a link to the algorithm for computing the flattened path?Suburb
G
5

If every object (circles etc) are converted first to paths, then taking transforms into account is rather easy. I made a testbed ( http://jsbin.com/oqojan/73 ) where you can test the functionality. The testbed creates random path commands and applies random transforms to paths and then flattens transforms. Of course in reality the path commands and transforms are not random, but for testing accuracy it is fine.

There is a function flatten_transformations(), which makes the main task:

function flatten_transformations(path_elem, normalize_path, to_relative, dec) {

    // Rounding coordinates to dec decimals
    if (dec || dec === 0) {
        if (dec > 15) dec = 15;
        else if (dec < 0) dec = 0;
    }
    else dec = false;

    function r(num) {
        if (dec !== false) return Math.round(num * Math.pow(10, dec)) / Math.pow(10, dec);
        else return num;
    }

    // For arc parameter rounding
    var arc_dec = (dec !== false) ? 6 : false;
    arc_dec = (dec && dec > 6) ? dec : arc_dec;

    function ra(num) {
        if (arc_dec !== false) return Math.round(num * Math.pow(10, arc_dec)) / Math.pow(10, arc_dec);
        else return num;
    }

    var arr;
    //var pathDOM = path_elem.node;
    var pathDOM = path_elem;
    var d = pathDOM.getAttribute("d").trim();

    // If you want to retain current path commans, set normalize_path to false
    if (!normalize_path) { // Set to false to prevent possible re-normalization. 
        arr = Raphael.parsePathString(d); // str to array
        arr = Raphael._pathToAbsolute(arr); // mahvstcsqz -> uppercase
    }
    // If you want to modify path data using nonAffine methods,
    // set normalize_path to true
    else arr = Raphael.path2curve(d); // mahvstcsqz -> MC
    var svgDOM = pathDOM.ownerSVGElement;

    // Get the relation matrix that converts path coordinates
    // to SVGroot's coordinate space
    var matrix = pathDOM.getTransformToElement(svgDOM);

    // The following code can bake transformations
    // both normalized and non-normalized data
    // Coordinates have to be Absolute in the following
    var i = 0,
        j, m = arr.length,
        letter = "",
        x = 0,
        y = 0,
        point, newcoords = [],
        pt = svgDOM.createSVGPoint(),
        subpath_start = {};
    subpath_start.x = "";
    subpath_start.y = "";
    for (; i < m; i++) {
        letter = arr[i][0].toUpperCase();
        newcoords[i] = [];
        newcoords[i][0] = arr[i][0];

        if (letter == "A") {
            x = arr[i][6];
            y = arr[i][7];

            pt.x = arr[i][6];
            pt.y = arr[i][7];
            newcoords[i] = arc_transform(arr[i][4], arr[i][5], arr[i][6], arr[i][4], arr[i][5], pt, matrix);
            // rounding arc parameters
            // x,y are rounded normally
            // other parameters at least to 5 decimals
            // because they affect more than x,y rounding
            newcoords[i][7] = ra(newcoords[i][8]); //rx
            newcoords[i][9] = ra(newcoords[i][10]); //ry
            newcoords[i][11] = ra(newcoords[i][12]); //x-axis-rotation
            newcoords[i][6] = r(newcoords[i][6]); //x
            newcoords[i][7] = r(newcoords[i][7]); //y
        }
        else if (letter != "Z") {
            // parse other segs than Z and A
            for (j = 1; j < arr[i].length; j = j + 2) {
                if (letter == "V") y = arr[i][j];
                else if (letter == "H") x = arr[i][j];
                else {
                    x = arr[i][j];
                    y = arr[i][j + 1];
                }
                pt.x = x;
                pt.y = y;
                point = pt.matrixTransform(matrix);
                newcoords[i][j] = r(point.x);
                newcoords[i][j + 1] = r(point.y);
            }
        }
        if ((letter != "Z" && subpath_start.x == "") || letter == "M") {
            subpath_start.x = x;
            subpath_start.y = y;
        }
        if (letter == "Z") {
            x = subpath_start.x;
            y = subpath_start.y;
        }
        if (letter == "V" || letter == "H") newcoords[i][0] = "L";
    }
    if (to_relative) newcoords = Raphael.pathToRelative(newcoords);
    newcoords = newcoords.flatten().join(" ").replace(/\s*([A-Z])\s*/gi, "$1").replace(/\s*([-])/gi, "$1");
    return newcoords;
} // function flatten_transformations​​​​​

// Helper tool to piece together Raphael's paths into strings again
Array.prototype.flatten || (Array.prototype.flatten = function() {
  return this.reduce(function(a, b) {
      return a.concat('function' === typeof b.flatten ? b.flatten() : b);
    }, []);
});

The code uses Raphael.pathToRelative(), Raphael._pathToAbsolute() and Raphael.path2curve(). The Raphael.path2curve() is bugfixed version.

If flatten_transformations() is called using argument normalize_path=true, then all commands are converted to Cubics and everything is fine. And the code can be simplified by removing if (letter == "A") { ... } and also removing handling of H, V and Z. The simplified version can be something like this.

But because someone may want to only bake transformations and not to make All Segs -> Cubics normalization, I added there a possibility to this. So, if you want to flatten transformations with normalize_path=false, this means that Elliptical Arc parameters have to be flattened also and it's not possible to handle them by simply applying matrix to coordinates. Two radiis (rx ry), x-axis-rotation, large-arc-flag and sweep-flag have to handle separately. So the following function can flatten transformations of Arcs. The matrix parameter is a relation matrix which comes from is used already in flatten_transformations().

// Origin: http://devmaster.net/forums/topic/4947-transforming-an-ellipse/
function arc_transform(a_rh, a_rv, a_offsetrot, large_arc_flag, sweep_flag, endpoint, matrix, svgDOM) {
    function NEARZERO(B) {
        if (Math.abs(B) < 0.0000000000000001) return true;
        else return false;
    }

    var rh, rv, rot;

    var m = []; // matrix representation of transformed ellipse
    var s, c; // sin and cos helpers (the former offset rotation)
    var A, B, C; // ellipse implicit equation:
    var ac, A2, C2; // helpers for angle and halfaxis-extraction.
    rh = a_rh;
    rv = a_rv;

    a_offsetrot = a_offsetrot * (Math.PI / 180); // deg->rad
    rot = a_offsetrot;

    s = parseFloat(Math.sin(rot));
    c = parseFloat(Math.cos(rot));

    // build ellipse representation matrix (unit circle transformation).
    // the 2x2 matrix multiplication with the upper 2x2 of a_mat is inlined.
    m[0] = matrix.a * +rh * c + matrix.c * rh * s;
    m[1] = matrix.b * +rh * c + matrix.d * rh * s;
    m[2] = matrix.a * -rv * s + matrix.c * rv * c;
    m[3] = matrix.b * -rv * s + matrix.d * rv * c;

    // to implict equation (centered)
    A = (m[0] * m[0]) + (m[2] * m[2]);
    C = (m[1] * m[1]) + (m[3] * m[3]);
    B = (m[0] * m[1] + m[2] * m[3]) * 2.0;

    // precalculate distance A to C
    ac = A - C;

    // convert implicit equation to angle and halfaxis:
    if (NEARZERO(B)) {
        a_offsetrot = 0;
        A2 = A;
        C2 = C;
    } else {
        if (NEARZERO(ac)) {
            A2 = A + B * 0.5;
            C2 = A - B * 0.5;
            a_offsetrot = Math.PI / 4.0;
        } else {
            // Precalculate radical:
            var K = 1 + B * B / (ac * ac);

            // Clamp (precision issues might need this.. not likely, but better save than sorry)
            if (K < 0) K = 0;
            else K = Math.sqrt(K);

            A2 = 0.5 * (A + C + K * ac);
            C2 = 0.5 * (A + C - K * ac);
            a_offsetrot = 0.5 * Math.atan2(B, ac);
        }
    }

    // This can get slightly below zero due to rounding issues.
    // it's save to clamp to zero in this case (this yields a zero length halfaxis)
    if (A2 < 0) A2 = 0;
    else A2 = Math.sqrt(A2);
    if (C2 < 0) C2 = 0;
    else C2 = Math.sqrt(C2);

    // now A2 and C2 are half-axis:
    if (ac <= 0) {
        a_rv = A2;
        a_rh = C2;
    } else {
        a_rv = C2;
        a_rh = A2;
    }

    // If the transformation matrix contain a mirror-component 
    // winding order of the ellise needs to be changed.
    if ((matrix.a * matrix.d) - (matrix.b * matrix.c) < 0) {
        if (!sweep_flag) sweep_flag = 1;
        else sweep_flag = 0;
    }

    // Finally, transform arc endpoint. This takes care about the
    // translational part which we ignored at the whole math-showdown above.
    endpoint = endpoint.matrixTransform(matrix);

    // Radians back to degrees
    a_offsetrot = a_offsetrot * 180 / Math.PI;

    var r = ["A", a_rh, a_rv, a_offsetrot, large_arc_flag, sweep_flag, endpoint.x, endpoint.y];
    return r;
}

OLD EXAMPLE:

I made an example that has a path with segments M Q A A Q M, which has transformations applied. The path is inside g that also has trans applied. And to make very sure this g is inside another g which has different transformations applied. And the code can:

A) First normalize those all path segments (thanks to Raphaël's path2curve, to which I made a bug fix, and after this fix all possible path segment combinations worked finally: http://jsbin.com/oqojan/42. The original Raphaël 2.1.0 has buggy behavior as you can see here, if not click paths few times to generate new curves.)

B) Then flatten transformations using native functions getTransformToElement(), createSVGPoint() and matrixTransform().

The only one that lacks is the way to convert Circles, Rectangles and Polygons to path commands, but as far as I know, you have an excellent code for it.

Gwenni answered 27/10, 2012 at 18:11 Comment(3)
That's a really cool test bed, which demonstrates another property of the problem: if your paths employ not just fills but also strokes, it is even more complicated still to reproduce the original looks, since the stroked path really is an outline shape with a volume, derived from its stroke properies (width, linecap and maybe others I forgot). Given a skewing or shearing transform, you actually also have derive the outline's path bake all the transforms into it, and render that with no stroke, and a fill colour from the original curve's stroke, on top of its fill curve, if it had one.Alvinaalvine
But should you venture into solving that problem too, do make another answer rather than retweaking this one a third time - it would in all likelihood make the answer very hard to read, for all the complexity, when most of the time what you want is just a hack that applies all the scaling, rotation and translations into a nice path, which the above does a great job of already.Alvinaalvine
In my testbed path strokes are not transformed. If they have to take into account, AFAIK strokes have to be converted to paths. The same applies to texts, text strokes and all other objects than paths and their strokes. It surely is possible, almost. Only fonts are hard, because SVG has not support for path extract of whichever font (= machine font).Aplite
F
2

This is an updated log of any forward progress I am making as an 'answer', to help inform others; if I somehow solve the problem on my own, I'll just accept this.

Update 1: I've got the absolute arcto command working perfectly except in cases of non-uniform scale. Here were the additions:

// Extract rotation and scale from the transform
var rotation = Math.atan2(transform.b,transform.d)*180/Math.PI;
var sx = Math.sqrt(transform.a*transform.a+transform.c*transform.c);
var sy = Math.sqrt(transform.b*transform.b+transform.d*transform.d);

//inside the processing of segments
if (seg.angle != null){
  seg.angle += rotation;
  // FIXME; only works for uniform scale
  seg.r1 *= sx;
  seg.r2 *= sy;
}

Thanks to this answer for a simpler extraction method than I was using, and for the math for extracting non-uniform scale.

Faery answered 1/3, 2011 at 17:4 Comment(1)
@allenhwkim Nope, I have not made any more progress than what is represented here and on my website.Faery
A
2

As long as you translate all coordinates to absolute coordinates, all béziers will work just fine; there is nothing magical about the their handles. As for the elliptical arc commands, the only general solution (handling non-uniform scaling, as you point out, which the arc command can not represent, in the general case) is to first convert them to their bézier approximations.

https://github.com/johan/svg-js-utils/blob/df605f3e21cc7fcd2d604eb318fb2466fd6d63a7/paths.js#L56..L113 (uses absolutizePath in the same file, a straight port of your Convert SVG Path to Absolute Commands hack) does the former, but not yet the latter.

How to best approximate a geometrical arc with a Bezier curve? links the math for converting arcs to béziers (one bézier segment per 0 < α <= π/2 arc segment); this paper shows the equations at the end of the page (its prettier pdf rendition has it at the end of section 3.4.1).

Alvinaalvine answered 9/7, 2012 at 1:0 Comment(1)
And if you don't mind standing on the shoulders of giants, you can of course reuse Dmitry Baranovskiy's (MIT licensed) Raphael.path2curve instead of reimplementing it yourself, like this: github.com/johan/svg-js-utils/commit/…Alvinaalvine
E
0

Inspired by Timo Kähkönen's answer and his flatten.js gist
I've written a similar helper script using Jarek Foksa's getpathData() polyfill to get the required data.

btnConvert.addEventListener('click', () => {
  flattenSVGTransformations(svg)
  output.value = new XMLSerializer().serializeToString(svg)
})

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) {
    // matrix to readable transfomr functions
    let transObj = qrDecomposeMatrix(matrix);
    // scale stroke-width
    let scale = (transObj.scaleX + transObj.scaleY) / 2;
    scaleStrokeWidth(el, scale)
    // 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 = svg.createSVGPoint();
        pt.x = x;
        pt.y = 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;
  }
}

function scaleStrokeWidth(el, scale) {
  let styles = window.getComputedStyle(el);
  let strokeWidth = styles.getPropertyValue('stroke-width');
  let stroke = styles.getPropertyValue('stroke');
  strokeWidth = stroke != 'none' ? parseFloat(strokeWidth) * scale : 0;
  // exclude text elements, since they remain transformed
  if (strokeWidth && el.nodeName.toLowerCase() !== 'text') {
    el.setAttribute('stroke-width', strokeWidth);
    el.style.removeProperty('stroke-width');
  }
}
/**
 * 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 + '');
    }
  }
}
/**
 *  Decompose matrix to readable transform properties 
 *  translate() rotate() scale() etc.
 *  based on @AndreaBogazzi's answer
 *  https://mcmap.net/q/37704/-find-the-rotation-and-skew-of-a-matrix-transformation#32125700
 *  return object with seperate transform properties 
 *  and ready to use css or svg attribute strings
 */
function qrDecomposeMatrix(matrix, precision = 3) {
  let {a,b,c,d,e,f} = matrix;
  // matrix is array
  if (Array.isArray(matrix)) {
    [a, b, c, d, e, f] = matrix;
  }
  let angle = Math.atan2(b, a),
    denom = Math.pow(a, 2) + Math.pow(b, 2),
    scaleX = Math.sqrt(denom),
    scaleY = (a * d - c * b) / scaleX,
    skewX = Math.atan2(a * c + b * d, denom) / (Math.PI / 180),
    translateX = e ? e : 0,
    translateY = f ? f : 0,
    rotate = angle ? angle / (Math.PI / 180) : 0;
  let transObj = {
    translateX: translateX,
    translateY: translateY,
    rotate: rotate,
    scaleX: scaleX,
    scaleY: scaleY,
    skewX: skewX,
    skewY: 0
  };
  let cssTransforms = [];
  let svgTransforms = [];
  for (let prop in transObj) {
    transObj[prop] = +parseFloat(transObj[prop]).toFixed(precision);
    let val = transObj[prop];
    let unit = "";
    if (prop == "rotate" || prop == "skewX") {
      unit = "deg";
    }
    if (prop.indexOf("translate") != -1) {
      unit = "px";
    }
    // combine these properties
    let convert = ["scaleX", "scaleY", "translateX", "translateY"];
    if (val !== 0) {
      cssTransforms.push(`${prop}(${val}${unit})`);
    }
    if (convert.indexOf(prop) == -1 && val !== 0) {
      svgTransforms.push(`${prop}(${val})`);
    } else if (prop == "scaleX") {
      svgTransforms.push(
        `scale(${+scaleX.toFixed(precision)} ${+scaleY.toFixed(precision)})`
      );
    } else if (prop == "translateX") {
      svgTransforms.push(
        `translate(${transObj.translateX} ${transObj.translateY})`
      );
    }
  }
  // append css style string to object
  transObj.cssTransform = cssTransforms.join(" ");
  transObj.svgTransform = svgTransforms.join(" ");
  return transObj;
}
svg {
  width: 50%;
  border: 1px solid #ccc;
  overflow: visible
}

textarea {
  width: 100%;
  min-height: 20em;
}
<p><button id="btnConvert">Flatten</button></p>
<svg id="svg" viewBox="0 0 100 100" overflow="visible">
  <rect x="50" y="0" width="80" height="70"rx="5%" ry="5%" fill="green" stroke="#ccc"  stroke-width="5"
      transform="translate(10,0) scale(0.5) rotate(50)" />
  
  <g class="g-class" style="transform:rotate(-5deg) translate(15px, 15px) scale(0.8) skewX(20deg) skewY(10deg)">
    <g id="g-class" transform-origin="20 -10" transform="rotate(-33 50 50) translate(-10 -10) scale(1.5)">
      <g transform="rotate(-33 50 50) translate(-10 -10) scale(1.5)">
        <path fill="#444" class="icon icon-home" id="icon-home" style="transform: translate(38px, 2px);" d="M10.16,20.12h-5.2v13h-3.44v-16.72l-7.72-8.72l-7.72,8.72v16.72h-3.44v-13h-5.24l16.4-17.4Z" />
      </g>
      <text id="textEl" x="10%" y="40%" text-anchor="middle" style="font-family:Georgia;font-size:50%; stroke-width:1.5px; transform:translateX(-20px)" paint-order="stroke" stroke="#ccc">Text</text>
    </g>
  </g>
</svg>
<h3>Output converted</h3>
<textarea id="output" cols="30" rows="10"></textarea>

<script src="https://cdn.jsdelivr.net/npm/[email protected]/path-data-polyfill.min.js"></script>

How it works

  • convert all svg geometry elements to <path>. By calling element.getpathData({normalize:true}) we get path data also for primitives like <rect> or <circle>.
    Besides, all commands are converted to absolute commands using only cubic béziers and linetos. Arctos are converted to cubic béziers.
  • get the total transformation matrix for each element respecting all inherited transforms (e.g from parent groups)
 let matrix = parent.getScreenCTM().inverse().multiply(el.getScreenCTM());

This is actually a just replacement for the (unfortunately) deprecated getTransformToElement() method which could also be polyfilled like so

SVGElement.prototype.getTransformToElement = SVGElement.prototype.getTransformToElement || function(toElement) {
    return toElement.getScreenCTM().inverse().multiply(this.getScreenCTM());
};
  • recalculate all command coordinates via matrixTransform() like so
let pt = svg.createSVGPoint();
pt.x = x;
pt.y = y;
let pTrans = pt.matrixTransform(matrix);
  • scale stroke widths
  • remove all transform attributes and style properties for geometry elements and parent <g> group elements. This step should be done last – otherwise we won't get the correct transform matrix values for child elements.

"Detransform" <text> elements?

Obviously, we can't convert text elements to paths (unless, we're using a library like opentype.js or fontkit). But we can consolidate all the transformations that have an effect on a text and apply a self-contained transform attribute value (as we did before for geometry elements).

I'm using qrDecomposeMatrix() helper function based on AndreaBogazzi's great answer: "Find the Rotation and Skew of a Matrix transformation" to split the current matrix to seperate transform functions like translate(), scale() etc.

For testing: Codepen example

Emileemilee answered 1/8, 2023 at 16:37 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.