Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SVG - Convert all shapes/primitives to <path>

Tags:

svg

d3.js

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?

like image 966
ekatz Avatar asked Nov 09 '22 08:11

ekatz


2 Answers

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.
But it also provides a normalize method to convert any element's geometry to path commands.

element.getPathData({normalize: true});

This method will convert all commands and coordinates to absolute values
and reduce the set of commands to these cubic bézier commands: M,L, C, Z.

Example usage: Convert all primitives

(and convert other commands A, S, Q etc.)

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 = svgWrp.innerHTML;

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({
      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));
    svg.appendChild(path);
    primitive.remove();
  })
  // optional: output new svg markup
  let newSvgMarkup = svgWrp.innerHTML.
  replaceAll("></path>", "/>").
  replace(/([ |\n|\r|\t])/g, " ").
  replace(/  +/g, ' ').trim().
  replaceAll("> <", "><").
  replaceAll("><", ">\n<");
  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 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" 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 = svg.outerHTML;

/**
 * example script
 **/
function getConvertedMarkup(svg, markupEl, decimals = 1) {
  convertPrimitivesNative(svg, decimals);
  //optimize output
  let newSvgMarkup = svg.outerHTML.
  replaceAll("></path>", "/>").
  replace(/^\s+|\s+$|\s+(?=\s)/g, "").
  replaceAll("> <", "><").
  replaceAll("><", ">\n<");
  markupEl.value = newSvgMarkup;
}

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

like image 200
herrstrietzel Avatar answered Dec 06 '22 04:12

herrstrietzel


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

like image 45
ekatz Avatar answered Dec 06 '22 04:12

ekatz