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?
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
.
(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.
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.
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
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With