ekatz
ekatz

Reputation: 1070

SVG - Convert all shapes/primitives to <path>

I'm doing a number of D3.JS operations requiring that I work with SVG paths instead of primitives/shapes (polylines, recs, etc.).

This question is general, but I'd like to know if it is possible to convert any SVG primitive to a path, either with D3 or another script/library.

For reference, here is a link which does it for polylines: https://gist.github.com/andytlr/9283541

I'd like to do this for every primitive. Any ideas? Is this possible?

Upvotes: 7

Views: 2449

Answers (2)

herrstrietzel
herrstrietzel

Reputation: 17215

JavaScript solution

You can also convert all primitives using Jarek Foksa's path-data polyfill:

It's main purpose is to parse a path's d attribute to an array of commands.

getPathData() can also retrieve pathData from any SVGGeometryElement including primitives like <rect>, <circle>, <ellipse>, <polygon>.

Example 1: Convert all primitives

const svgWrp = document.querySelector('.svgWrp');
const svg = document.querySelector('svg');
const primitives = svg.querySelectorAll('path, line, polyline, polygon, circle, rect');
const svgMarkup = document.querySelector('#svgMarkup');
svgMarkup.value = new XMLSerializer().serializeToString(svg);

function convertPrimitives(svg, primitives) {
  primitives.forEach(function(primitive, i) {
    /**
     * get normalized path data: 
     * all coordinates are absolute; 
     * reduced set of commands: M, L, C, Z
     */
    let pathData = primitive.getPathData();

    //get all attributes
    let attributes = [...primitive.attributes];
    let path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    //exclude attributes not needed for paths
    let exclude = ['x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'points', 'height',
      'width'
    ];
    setAttributes(path, attributes, exclude);
    // set d attribute from rounded pathData
    path.setPathData(roundPathData(pathData, 1));
    primitive.replaceWith(path);
  })
  // optional: output new svg markup
  let newSvgMarkup = new XMLSerializer().serializeToString(svg);
  svgMarkup.value = newSvgMarkup;
}

function roundPathData(pathData, decimals = 3) {
  pathData.forEach(function(com, c) {
    let values = com['values'];
    values.forEach(function(val, v) {
      pathData[c]['values'][v] = +val.toFixed(decimals);
    })
  })
  return pathData;
}

function setAttributes(el, attributes, exclude = []) {
  attributes.forEach(function(att, a) {
    if (exclude.indexOf(att.nodeName) === -1) {
      el.setAttribute(att.nodeName, att.nodeValue);
    }
  })
}
<script src="https://cdn.jsdelivr.net/npm/[email protected]/path-data-polyfill.min.js"></script>

<p><button type="button" onclick="convertPrimitives(svg, primitives)">Convert Primitives</button></p>
<div class="svgWrp">
  <svg id="svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 30">
    <polygon id="polygon" fill="#ccc" stroke="green" points="9,22.4 4.1,14 9,5.5 18.8,5.5 23.7,14 18.8,22.4 " />
    <polyline id="polyline" fill="none" stroke="red" points="43,22.4 33.3,22.4 28.4,14 33.3,5.5 43,5.5 47.9,14 " />
    <rect id="rect" x="57.3" y="5.5" rx="2" ry="2" fill="none" stroke="orange" width="16.9" height="16.9" />
    <line id="line" fill="none" stroke="purple" x1="52.6" y1="22.4" x2="52.6" y2="5.5" />
    <circle class="circle" data-att="circle" id="circle" fill="none" stroke="magenta" cx="87.4" cy="14" r="8.5" />
    <path transform="scale(0.9) translate(110,5)" d="M 10 0 A 10 10 0 1 1 1.34 15 L 10 10 z" fill="red" class="segment segment-1 segment-class" id="segment-01" />
  </svg>
</div>
<h3>Svg markup</h3>
<textarea name="svgMarkup" id="svgMarkup" style="width:100%; height:20em;"></textarea>

getPathData() also provides a normalize method to convert any element's geometry to a reduced set of absolute commands - only using:
M, L, C, Z.

Quadratic Q or T commands are converted as well as
Arcs A and shorthands like V or H )

element.getPathData({normalize: true});

Example 2: Convert all primitives (normalized)

const svgWrp = document.querySelector('.svgWrp');
const svg = document.querySelector('svg');
const primitives = svg.querySelectorAll('path, line, polyline, polygon, circle, rect');
const svgMarkup = document.querySelector('#svgMarkup');
svgMarkup.value = new XMLSerializer().serializeToString(svg);

function convertPrimitives(svg, primitives) {
  primitives.forEach(function(primitive, i) {
    /**
     * get path data: 
     */
    let pathData = primitive.getPathData({normalize:true});

    //get all attributes
    let attributes = [...primitive.attributes];
    let path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    //exclude attributes not needed for paths
    let exclude = ['x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'points', 'height',
      'width'
    ];
    setAttributes(path, attributes, exclude);
    // set d attribute from rounded pathData
    path.setPathData(roundPathData(pathData, 1));
    primitive.replaceWith(path);
  })
  // optional: output new svg markup
  let newSvgMarkup = new XMLSerializer().serializeToString(svg);
  svgMarkup.value = newSvgMarkup;
}

function roundPathData(pathData, decimals = 3) {
  pathData.forEach(function(com, c) {
    let values = com['values'];
    values.forEach(function(val, v) {
      pathData[c]['values'][v] = +val.toFixed(decimals);
    })
  })
  return pathData;
}

function setAttributes(el, attributes, exclude = []) {
  attributes.forEach(function(att, a) {
    if (exclude.indexOf(att.nodeName) === -1) {
      el.setAttribute(att.nodeName, att.nodeValue);
    }
  })
}
<script src="https://cdn.jsdelivr.net/npm/[email protected]/path-data-polyfill.min.js"></script>

<p><button type="button" onclick="convertPrimitives(svg, primitives)">Convert Primitives</button></p>
<div class="svgWrp">
  <svg id="svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 30">
    <polygon id="polygon" fill="#ccc" stroke="green" points="9,22.4 4.1,14 9,5.5 18.8,5.5 23.7,14 18.8,22.4 " />
    <polyline id="polyline" fill="none" stroke="red" points="43,22.4 33.3,22.4 28.4,14 33.3,5.5 43,5.5 47.9,14 " />
    <rect id="rect" x="57.3" y="5.5" rx="2" ry="2" fill="none" stroke="orange" width="16.9" height="16.9" />
    <line id="line" fill="none" stroke="purple" x1="52.6" y1="22.4" x2="52.6" y2="5.5" />
    <circle class="circle" data-att="circle" id="circle" fill="none" stroke="magenta" cx="87.4" cy="14" r="8.5" />
    <path transform="scale(0.9) translate(110,5)" d="M 10 0 A 10 10 0 1 1 1.34 15 L 10 10 z" fill="red" class="segment segment-1 segment-class" id="segment-01" />
  </svg>
</div>
<h3>Svg markup</h3>
<textarea name="svgMarkup" id="svgMarkup" style="width:100%; height:20em;"></textarea>

The above example script will also retain all attributes like class, id, fill etc.

But it will strip attributes like r, cx, rx specific to primitives.

Do we need this polyfill?

Unfortunately, the getPathData() and setPathData() methods are still a svg 2 drafts/proposals – intended to replace the deprecated pathSegList() methods.
Hopefully we will get native browser support in the near future.
Since this polyfill is still rather lightweight (~12.5 KB uncompressed) compared to more advanced svg libraries like (snap.svg, d3 etc.) it won't increase your loading times significantly.

Update: Standalone script (no polyfill dependency)

This is rather a proof of concept – you can convert svg primitives based on pretty basic value calculations – without the need of advanced frameworks/libraries – inspired by this post: Convert all shapes/primitives into path elements of SVG.

But as I fiddled around with my own clunky conversion script, I quickly realised that there were some challenges (that Jarek Foksa's normalizing implementations solves flawlessly) such as:

Relative i.e percentage based units

<circle cx="50%" cy="50%" r="25%" />  

OK ... I guess we need to calculate these relative values to absolute coordinates according to the parent svg's boundaries as defined by viewBox property ... maybe no viewBox available at all ... or width/height values.

Or something like rx, ry properties to apply rounded borders to a <rect> element – for a decent conversion we'll need to add some curvy commands like a, c or s.

Paths vs. primitives
It's true that a <path> element can draw just any shape a primitive can offer via cubic or quadratic spline commands – even in a more efficient way due to it's concatenating abilities (combining multiple shapes) and furthermore its relative or shorthand commands.
But it doesn't support relative units – however the shapes you need to convert might heavily depend on relative dimensions (e.g. circular gauges pie charts etc.)

Conclusion
It's not too difficult to write your custom conversion script, but pay attention to some tricky details.

const svg = document.querySelector('svg');
const svgMarkup = document.querySelector('#svgMarkup');
svgMarkup.value = new XMLSerializer().serializeToString(svg);

/**
 * example script
 **/
function getConvertedMarkup(svg, markupEl, decimals = 1) {
  convertPrimitivesNative(svg, decimals);
  markupEl.value = new XMLSerializer().serializeToString(svg);
}

/**
 * parse svg attributes and convert relative units
 **/
function parseSvgAttributes(svg, atts) {
  let calcW = 0;
  let calcH = 0;
  let calcR = 0;

  //1. check viewBox
  let viewBoxAtt = svg.getAttribute('viewBox');
  let viewBox = viewBoxAtt ? viewBoxAtt.split(' ') : [];
  [calcW, calcH] = [viewBox[2], viewBox[3]];

  //2. check width attributes
  if (!calcW || !calcH) {
    widthAtt = svg.getAttribute('width') ? parseFloat(svg.getAttribute('width')) : '';
    heightAtt = svg.getAttribute('height') ? parseFloat(svg.getAttribute('height')) : '';
    [calcW, calcH] = [widthAtt, heightAtt];
  }
  //3. calculate by getBBox()
  if (!calcW || !calcH) {
    let bb = svg.getBBox();
    [calcW, calcH] = [(calcW ? calcW : bb.width), (calcH ? calcH : bb.height)];
  }

  // calculate relative radius: needed for non square aspect ratios
  calcR = Math.sqrt(Math.pow(calcW, 2) + Math.pow(calcH, 2)) / Math.sqrt(2);

  let attArr = [...atts];
  let attObj = {};
  attArr.forEach(function(att) {
    let attName = att.nodeName;
    // convert percentages to absolute svg units
    let val = att.nodeValue;
    let percentAtts = ['x', 'y', 'x1', 'y1', 'x2', 'y2', 'r', 'rx', 'ry', 'cx', 'cy', 'width', 'height']
    if (val.toString().indexOf('%') !== -1 && percentAtts.indexOf(attName) !== -1) {
      // strip units
      val = parseFloat(val);
      switch (attName) {
        case 'cx':
        case 'rx':
        case 'width':
        case 'x':
        case 'x1':
        case 'x2':
          val = 1 / 100 * val * calcW;
          break;
        case 'cy':
        case 'ry':
        case 'height':
        case 'y':
        case 'y1':
        case 'y2':
          val = 1 / 100 * val * calcH;
          break;
        case 'r':
          val = 1 / 100 * val * calcR;
          break;
      }
    }
    attObj[att.nodeName] = val;
  });
  return attObj;
}

/**
 * convert primitive attributes to relative path commands
 */
function convertPrimitivesNative(svg, decimals = 3) {
  let primitives = svg.querySelectorAll('line, polyline, polygon, circle, ellipse, rect');

  if (primitives.length) {
    primitives.forEach(function(primitive) {
      let pathData = [];
      let type = primitive.nodeName;
      let atts = parseSvgAttributes(svg, primitive.attributes, 2);
      let path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
      //exclude attributes not needed for paths
      let exclude = ['x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'points', 'height',
        'width'
      ];
      switch (type) {
        case 'rect':
          let [rx, ry] = [atts.rx, atts.ry];
          rx = !rx && ry ? ry : rx;
          ry = !ry && rx ? rx : ry;
          let [x, y, width, height] = [atts.x, atts.y, atts.width, atts.height];
          let [widthInner, heightInner] = [width - rx * 2, height - ry * 2];
          if (rx) {
            pathData.push({
              type: 'M',
              values: [x, (y + ry)]
            }, {
              type: 'a',
              values: [rx, ry, 0, 0, 1, rx, -ry]
            }, {
              type: 'h',
              values: [widthInner]
            }, {
              type: 'a',
              values: [rx, ry, 0, 0, 1, rx, ry]
            }, {
              type: 'v',
              values: [heightInner]
            }, {
              type: 'a',
              values: [rx, ry, 0, 0, 1, -rx, ry]
            }, {
              type: 'h',
              values: [-widthInner]
            }, {
              type: 'a',
              values: [rx, ry, 0, 0, 1, -rx, -ry]
            }, {
              type: 'z',
              values: []
            });

          } else {
            pathData.push({
              type: 'M',
              values: [x, y]
            }, {
              type: 'h',
              values: [width]
            }, {
              type: 'v',
              values: [height]
            }, {
              type: 'h',
              values: [-width]
            }, {
              type: 'z',
              values: []
            });
          }
          break;

        case 'line':
          let [x1, y1, x2, y2] = [atts.x1, atts.y1, atts.x2, atts.y2];
          pathData.push({
            type: 'M',
            values: [x1, y1]
          }, {
            type: 'l',
            values: [(x2 - x1), (y2 - y1)]
          });
          break;

        case 'circle':
        case 'ellipse':
          if (type == 'circle') {
            let r = atts.r;
            let [cX, cY] = [atts.cx, atts.cy - atts.r];
            pathData.push({
              type: 'M',
              values: [cX, cY]
            }, {
              type: 'a',
              values: [r, r, 0, 0, 1, r, r]
            }, {
              type: 'a',
              values: [r, r, 0, 0, 1, -r, r]
            }, {
              type: 'a',
              values: [r, r, 0, 0, 1, -r, -r]
            }, {
              type: 'a',
              values: [r, r, 0, 0, 1, r, -r]
            }, {
              type: 'z',
              values: []
            });

          } else {
            let rx = atts.rx;
            let ry = atts.ry;
            let [cX, cY] = [atts.cx, atts.cy - atts.ry];
            pathData.push({
              type: 'M',
              values: [cX, cY]
            }, {
              type: 'a',
              values: [rx, ry, 0, 0, 1, rx, ry]
            }, {
              type: 'a',
              values: [rx, ry, 0, 0, 1, -rx, ry]
            }, {
              type: 'a',
              values: [rx, ry, 0, 0, 1, -rx, -ry]
            }, {
              type: 'a',
              values: [rx, ry, 0, 0, 1, rx, -ry]
            }, {
              type: 'z',
              values: []
            });
          }
          break;

        case 'polygon':
        case 'polyline':
          let closePath = type == 'polygon' ? 'z' : '';
          let points = atts.points.replace(/^\s+|\s+$|\s+(?=\s)/g, "").replaceAll(",", " ");
          let pointArr = points.split(' ');
          pathData.push({
            type: 'M',
            values: [+pointArr[0], +pointArr[1]]
          });

          for (let i = 2; i < pointArr.length; i += 2) {
            let [x0, y0] = [+pointArr[i - 2], +pointArr[i - 1]];
            let [x, y] = [+pointArr[i], +pointArr[i + 1]];
            let com = {};

            if (y == y0) {
              com = {
                type: 'h',
                values: [x - x0]
              }
            } else if (x == x0) {
              com = {
                type: 'v',
                values: [y - y0]
              }
            } else {
              com = {
                type: 'l',
                values: [x - x0, y - y0]
              }
            }
            pathData.push(com);
          }
          if (closePath) {
            pathData.push({
              type: 'z',
              values: []
            });
          }
          break;

          //paths
        default:
          let dClean = atts.d.replace(/^\s+|\s+$|\s+(?=\s)/g, "").replaceAll(",", " ");
          let dArr = dClean.replace(/([a-zA-Z])/g, " | $1").split(' | ');
          dArr.shift();
          for (let i = 0; i < dArr.length; i++) {
            let command = dArr[i].trim().split(' ');
            let type = command.shift();

            command = command.map((x) => {
              return parseFloat(x);
            });
            pathData.push({
              type: type,
              values: command
            });
          }
          break;
      }

      // copy primitive's attributes to path
      setAttributes(path, atts, exclude);
      // round coordinates and replace primitive with path
      path.setPathDataOpt(pathData, decimals);
      primitive.replaceWith(path);
    })
  }
};


function setAttributes(el, attributes, exclude = []) {
  for (key in attributes) {
    if (exclude.indexOf(key) === -1) {
      el.setAttribute(key, attributes[key]);
    }
  }
}

function getAttributes(el) {
  let attArr = [...el.attributes];
  let attObj = {};
  attArr.forEach(function(att) {
    attObj[att.nodeName] = att.nodeValue;
  });
  return attObj;
}


/**
 * return rounded path data 
 * based on:
 * https://github.com/jarek-foksa/path-data-polyfill/blob/master/path-data-polyfill.js
 */
if (!SVGPathElement.prototype.setPathDataOpt) {
  SVGPathElement.prototype.setPathDataOpt = function(pathData, decimals = 3) {
    let d = "";
    if (pathData.length) {
      for (let i = 0; i < pathData.length; i++) {
        let seg = pathData[i];
        let [type, values] = [seg.type, seg.values];
        let valArr = [];
        if (values.length) {
          for (let v = 0; v < values.length; v++) {
            val = parseFloat(values[v]);
            valArr.push(+val.toFixed(decimals));
          }
        }
        d += type;
        if (valArr.length) {
          d += valArr.join(" ").trim();
        }
      }
      d = d.
      replaceAll(' -', '-').
      replaceAll(' 0.', ' .').
      replaceAll(' z', 'z');
      this.setAttribute("d", d);
    }
  };
}
<p><button type="button" onclick="getConvertedMarkup(svg, svgMarkup, 2)">Convert Primitives</button></p>
<svg id="svg" xmlns="http://www.w3.org/2000/svg" data-width="150px" data-height="30px" viewBox="0 0 150 30">
<polygon id="polygon" fill="#CCCCCC" stroke="#E3000F" points="7.9,22.8 3,14.3 7.9,5.8 17.6,5.8 22.5,14.3 17.6,22.8 " />
<polyline id="polyline" fill="none" stroke="#E3000F" points="40.9,22.8 31.1,22.8 26.2,14.3 31.1,5.8 
 40.9,5.8 45.8,14.3 " />
<rect id="rect" x="37.5%" y="20%" rx="2%" ry="5%" fill="none" stroke="#E3000F" width="6%" height="56%" />
<line id="line" fill="none" stroke="#E3000F" x1="50.5" y1="22.8" x2="52.5" y2="5.8" />
<circle id="circle" fill="none" stroke="#E3000F" cx="52%" cy="49%" r="8%" />
<ellipse id="ellipse" fill="none" stroke="#E3000F" cx="68%" cy="49%" rx="7%" ry="25%" />
<path id="piechart" transform="scale(0.9) translate(130, 6)" d="M 10 0 A 10 10 0 1 1 1.34 15 L 10 10 z"
 fill="red" class="segment segment-1 segment-class" id="segment-01" />
</svg>
<h3>Output</h3>
<textarea name="svgMarkup" id="svgMarkup" style="width:100%; height:20em;"></textarea>

Codepen converter example

Upvotes: 3

ekatz
ekatz

Reputation: 1070

I found this github site which has a set of java functions for converting shapes to paths: https://github.com/JFXtras/jfxtras-labs/blob/2.2/src/main/java/jfxtras/labs/util/ShapeConverter.java

Upvotes: 1

Related Questions