烘焙转换为 SVG 路径元素命令

发布于 2024-10-19 09:47:52 字数 5664 浏览 6 评论 0原文

tl;dr 摘要:给我资源或帮助修复以下代码,以通过任意矩阵转换 SVG 元素的路径命令。

详细信息
我正在编写一个库来将任意 SVG 形状转换为 元素。当层次结构中没有 transform="..." 元素时,我可以使用它,但现在我想将对象的本地转换烘焙到 路径数据命令本身。

在处理简单的 moveto/lineto 命令时,这主要是有效的(代码如下)。但是,我不确定转换贝塞尔曲线手柄或 arcTo 参数的适当方法。

例如,我能够将此圆角矩形转换为

<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" />

并且在没有任何圆角的情况下进行转换时,我得到了有效的结果:

<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" />

但是,仅转换 < 的 x/y 坐标a href="http://www.w3.org/TR/SVG/paths.html#PathDataEllipticalArcCommands">椭圆弧 命令会产生有趣的结果: 圆角矩形,边界外的角上渗出绿色斑点
虚线是实际转换的矩形,绿色填充是我的路径。

以下是我到目前为止的代码(稍微精简)。我还有一个测试页面,我正在其中测试各种形状。请帮助我确定如何在给定任意变换矩阵的情况下正确变换椭圆弧和各种其他贝塞尔命令。

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;
}

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;
}

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(5

故人的歌 2024-10-26 09:47:52

我制作了一个通用的 SVG 拼合器 flatten.js,它支持所有形状和路径命令:
https://gist.github.com/timo22345/9413158

基本用法:flatten( document.getElementById('svg'));

它的作用:展平元素(将元素转换为路径并展平变换)。
如果参数元素(其 id 高于“svg”)有子元素,或者它的后代有子元素,
这些子元素也被扁平化。

可以展平的内容:整个 SVG 文档、单个形状(路径、圆形、椭圆形等)和组。嵌套组会自动处理。

属性又如何呢?所有属性均被复制。仅删除路径元素中无效的参数(例如 r、rx、ry、cx、cy),但不再需要它们。变换属性也被删除,因为变换被扁平化为路径命令。

如果您想使用非仿射方法修改路径坐标(例如透视扭曲),
您可以使用以下方法将所有线段转换为三次曲线:
flatten(document.getElementById('svg'), true);

还有参数“toAbsolute”(将坐标转换为绝对坐标)和“dec”,
小数点分隔符后的位数。

极端路径和形状测试器:https://jsfiddle.net/fjm9423q/embedded/result/

基本用法示例: http://jsfiddle.net/nrjvmqur/embedded/result/

缺点:文本元素不起作用。这可能是我的下一个目标。

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.

白昼 2024-10-26 09:47:52

如果每个对象(圆圈等)首先转换为路径,那么考虑变换就相当容易了。我制作了一个测试台( http://jsbin.com/oqojan/73 ),您可以在其中测试功能。测试床创建随机路径命令并对路径应用随机变换,然后展平变换。当然,实际上路径命令和变换不是随机的,但对于测试准确性来说这是可以的。

有一个函数 flatten_transformations(),它完成了主要任务:

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);
    }, []);
});

代码使用 Raphael.pathToRelative()、Raphael._pathToAbsolute() 和 Raphael.path2curve()。 Raphael.path2curve() 是错误修复版本。

如果使用参数 normalize_path=true 调用 flatten_transformations(),则所有命令都将转换为 Cubics,并且一切正常。可以通过删除 if (letter == "A") { ... } 以及删除 H、V 和 Z 的处理来简化代码。简化版本可以类似于 这个

但是因为有人可能只想烘焙转换而不是制作所有分段 ->三次标准化,我添加了这种可能性。因此,如果您想使用 normalize_path=false 来展平变换,这意味着椭圆弧参数也必须展平,并且不可能通过简单地将矩阵应用于坐标来处理它们。两个半径(rx ry)、x轴旋转、大弧标志和扫掠标志必须单独处理。所以下面的函数可以展平弧的变换。矩阵参数是一个关系矩阵,它已在 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;
}

旧示例:

我制作了示例,其中包含包含段MQAAQ M的路径,其中应用了变换。该路径位于 g 内部,也应用了反式。并确保这个 g 位于另一个应用了不同变换的 g 内。代码可以:

A)首先规范化所有路径段(感谢 Raphaël 的 path2curve,我对此进行了错误修复 ,在此修复之后,所有可能的路径段组合最终起作用: http://jsbin.com/oqojan/42 原始 Raphaël 2.1.0 存在错误行为,如您在此处看到的,如果没有点击路径的话几次即可生成新曲线。)

B) 然后使用本机函数 getTransformToElement()createSVGPoint()matrixTransform() 展平变换。

唯一缺少的是将圆形、矩形和多边形转换为路径命令的方法,但据我所知,您有一个很好的代码。

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.

三生路 2024-10-26 09:47:52

这是我作为“答案”所做的任何进展的更新日志,以帮助通知其他人;如果我自己能以某种方式解决问题,我就会接受这个。

更新 1:我已经得到了 绝对 arcto 命令完美运行,除非比例不均匀。以下是补充内容:

// 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;
}

感谢这个答案,让问题变得更简单比我使用的提取方法,以及用于提取非均匀尺度的数学。

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.

烟花肆意 2024-10-26 09:47:52

只要将所有坐标转换为绝对坐标,所有贝塞尔曲线都可以正常工作;他们的手柄没有什么神奇之处。至于椭圆弧命令,唯一的通用解决方案(处理非均匀缩放,正如您所指出的,在一般情况下,弧命令无法表示)是首先将它们转换为贝塞尔近似值。

https://github.com/johan /svg-js-utils/blob/df605f3e21cc7fcd2d604eb318fb2466fd6d63a7/paths.js#L56..L113 (在同一文件中使用 absolutizePath ,您的 将 SVG 路径转换为绝对命令 hack)可以实现前者,但还不能实现后者。

如何用贝塞尔曲线最好地近似几何弧?链接转换弧的数学到贝塞尔曲线(每个 0 <α <= π/2 弧段一个贝塞尔曲线段); 这篇论文在页面末尾显示了方程(它更漂亮pdf 版本在第 3.4.1 节末尾)。

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).

萌面超妹 2024-10-26 09:47:52

受到 Timo Kähkönen 的回答和他的展平的启发。 Node.js 要点
我使用 Jarek Foksa 的 getpathData() polyfill 编写了一个类似的帮助程序脚本获取所需的数据。

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://stackoverflow.com/questions/5107134/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>

其工作原理

  • 是将所有 svg 几何元素转换为 。通过调用 element.getpathData({normalize:true}) 我们还可以获取 等基元的路径数据.
    此外,所有命令都仅使用三次贝塞尔曲线和线条转换为绝对命令。 Arctos 转换为三次贝塞尔曲线。
  • 获取每个元素的总变换矩阵,尊重所有继承的变换(例如来自父组)
 let matrix = parent.getScreenCTM().inverse().multiply(el.getScreenCTM());

这实际上只是(不幸的是)已弃用的 getTransformToElement() 方法的替代,该方法也可以像这样

SVGElement.prototype.getTransformToElement = SVGElement.prototype.getTransformToElement || function(toElement) {
    return toElement.getScreenCTM().inverse().multiply(this.getScreenCTM());
};
  • 重新计算所有通过matrixTransform()命令坐标,像这样
let pt = svg.createSVGPoint();
pt.x = x;
pt.y = y;
let pTrans = pt.matrixTransform(matrix);
  • 缩放笔划宽度
  • 删除几何元素和父组元素的所有变换属性和样式属性。此步骤应该最后完成 - 否则我们将无法获得子元素的正确变换矩阵值。

“反变换” 元素?

显然,我们无法将文本元素转换为路径(除非我们使用 opentype.js 或 fontkit 等库)。但是我们可以合并对文本有影响的所有变换并应用独立的变换属性值(就像我们之前对几何元素所做的那样)。

我正在使用基于 AndreaBogazzi 的精彩回答:“查找矩阵变换的旋转和倾斜” 将当前矩阵拆分为单独的变换函数,例如 translate()scale () 等。

用于测试:Codepen 示例

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://stackoverflow.com/questions/5107134/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

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文