You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

793 lines
24 KiB
JavaScript

/*!
* SvgParser
* A library to convert an SVG string to parse-able segments for CAD/CAM use
* Licensed under the MIT license
*/
(function(root){
'use strict';
function SvgParser(){
// the SVG document
this.svg;
// the top level SVG element of the SVG document
this.svgRoot;
this.allowedElements = ['svg','circle','ellipse','path','polygon','polyline','rect', 'line'];
this.conf = {
tolerance: 2, // max bound for bezier->line segment conversion, in native SVG units
toleranceSvg: 0.005 // fudge factor for browser inaccuracy in SVG unit handling
};
}
SvgParser.prototype.config = function(config){
this.conf.tolerance = config.tolerance;
}
SvgParser.prototype.load = function(svgString){
if(!svgString || typeof svgString !== 'string'){
throw Error('invalid SVG string');
}
var parser = new DOMParser();
var svg = parser.parseFromString(svgString, "image/svg+xml");
this.svgRoot = false;
if(svg){
this.svg = svg;
for(var i=0; i<svg.childNodes.length; i++){
// svg document may start with comments or text nodes
var child = svg.childNodes[i];
if(child.tagName && child.tagName == 'svg'){
this.svgRoot = child;
break;
}
}
} else {
throw new Error("Failed to parse SVG string");
}
if(!this.svgRoot){
throw new Error("SVG has no children");
}
return this.svgRoot;
}
// use the utility functions in this class to prepare the svg for CAD-CAM/nest related operations
SvgParser.prototype.cleanInput = function(){
// apply any transformations, so that all path positions etc will be in the same coordinate space
this.applyTransform(this.svgRoot);
// remove any g elements and bring all elements to the top level
this.flatten(this.svgRoot);
// remove any non-contour elements like text
this.filter(this.allowedElements);
// split any compound paths into individual path elements
this.recurse(this.svgRoot, this.splitPath);
return this.svgRoot;
}
// return style node, if any
SvgParser.prototype.getStyle = function(){
if(!this.svgRoot){
return false;
}
for(var i=0; i<this.svgRoot.childNodes.length; i++){
var el = this.svgRoot.childNodes[i];
if(el.tagName == 'style'){
return el;
}
}
return false;
}
// set the given path as absolute coords (capital commands)
// from http://stackoverflow.com/a/9677915/433888
SvgParser.prototype.pathToAbsolute = function(path){
if(!path || path.tagName != 'path'){
throw Error('invalid path');
}
var seglist = path.pathSegList;
var x=0, y=0, x0=0, y0=0, x1=0, y1=0, x2=0, y2=0;
for(var i=0; i<seglist.numberOfItems; i++){
var command = seglist.getItem(i).pathSegTypeAsLetter;
var s = seglist.getItem(i);
if (/[MLHVCSQTA]/.test(command)){
if ('x' in s) x=s.x;
if ('y' in s) y=s.y;
}
else{
if ('x1' in s) x1=x+s.x1;
if ('x2' in s) x2=x+s.x2;
if ('y1' in s) y1=y+s.y1;
if ('y2' in s) y2=y+s.y2;
if ('x' in s) x+=s.x;
if ('y' in s) y+=s.y;
switch(command){
case 'm': seglist.replaceItem(path.createSVGPathSegMovetoAbs(x,y),i); break;
case 'l': seglist.replaceItem(path.createSVGPathSegLinetoAbs(x,y),i); break;
case 'h': seglist.replaceItem(path.createSVGPathSegLinetoHorizontalAbs(x),i); break;
case 'v': seglist.replaceItem(path.createSVGPathSegLinetoVerticalAbs(y),i); break;
case 'c': seglist.replaceItem(path.createSVGPathSegCurvetoCubicAbs(x,y,x1,y1,x2,y2),i); break;
case 's': seglist.replaceItem(path.createSVGPathSegCurvetoCubicSmoothAbs(x,y,x2,y2),i); break;
case 'q': seglist.replaceItem(path.createSVGPathSegCurvetoQuadraticAbs(x,y,x1,y1),i); break;
case 't': seglist.replaceItem(path.createSVGPathSegCurvetoQuadraticSmoothAbs(x,y),i); break;
case 'a': seglist.replaceItem(path.createSVGPathSegArcAbs(x,y,s.r1,s.r2,s.angle,s.largeArcFlag,s.sweepFlag),i); break;
case 'z': case 'Z': x=x0; y=y0; break;
}
}
// Record the start of a subpath
if (command=='M' || command=='m') x0=x, y0=y;
}
};
// takes an SVG transform string and returns corresponding SVGMatrix
// from https://github.com/fontello/svgpath
SvgParser.prototype.transformParse = function(transformString){
var operations = {
matrix: true,
scale: true,
rotate: true,
translate: true,
skewX: true,
skewY: true
};
var CMD_SPLIT_RE = /\s*(matrix|translate|scale|rotate|skewX|skewY)\s*\(\s*(.+?)\s*\)[\s,]*/;
var PARAMS_SPLIT_RE = /[\s,]+/;
var matrix = new Matrix();
var cmd, params;
// Split value into ['', 'translate', '10 50', '', 'scale', '2', '', 'rotate', '-45', '']
transformString.split(CMD_SPLIT_RE).forEach(function (item) {
// Skip empty elements
if (!item.length) { return; }
// remember operation
if (typeof operations[item] !== 'undefined') {
cmd = item;
return;
}
// extract params & att operation to matrix
params = item.split(PARAMS_SPLIT_RE).map(function (i) {
return +i || 0;
});
// If params count is not correct - ignore command
switch (cmd) {
case 'matrix':
if (params.length === 6) {
matrix.matrix(params);
}
return;
case 'scale':
if (params.length === 1) {
matrix.scale(params[0], params[0]);
} else if (params.length === 2) {
matrix.scale(params[0], params[1]);
}
return;
case 'rotate':
if (params.length === 1) {
matrix.rotate(params[0], 0, 0);
} else if (params.length === 3) {
matrix.rotate(params[0], params[1], params[2]);
}
return;
case 'translate':
if (params.length === 1) {
matrix.translate(params[0], 0);
} else if (params.length === 2) {
matrix.translate(params[0], params[1]);
}
return;
case 'skewX':
if (params.length === 1) {
matrix.skewX(params[0]);
}
return;
case 'skewY':
if (params.length === 1) {
matrix.skewY(params[0]);
}
return;
}
});
return matrix;
}
// recursively apply the transform property to the given element
SvgParser.prototype.applyTransform = function(element, globalTransform){
globalTransform = globalTransform || '';
var transformString = element.getAttribute('transform') || '';
transformString = globalTransform + transformString;
var transform, scale, rotate;
if(transformString && transformString.length > 0){
var transform = this.transformParse(transformString);
}
if(!transform){
transform = new Matrix();
}
var tarray = transform.toArray();
// decompose affine matrix to rotate, scale components (translate is just the 3rd column)
var rotate = Math.atan2(tarray[1], tarray[3])*180/Math.PI;
var scale = Math.sqrt(tarray[0]*tarray[0]+tarray[2]*tarray[2]);
if(element.tagName == 'g' || element.tagName == 'svg' || element.tagName == 'defs' || element.tagName == 'clipPath'){
element.removeAttribute('transform');
var children = Array.prototype.slice.call(element.childNodes);
for(var i=0; i<children.length; i++){
if(children[i].tagName){ // skip text nodes
this.applyTransform(children[i], transformString);
}
}
}
else if(transform && !transform.isIdentity()){
const id = element.getAttribute('id')
const className = element.getAttribute('class')
switch(element.tagName){
case 'ellipse':
// the goal is to remove the transform property, but an ellipse without a transform will have no rotation
// for the sake of simplicity, we will replace the ellipse with a path, and apply the transform to that path
var path = this.svg.createElementNS(element.namespaceURI, 'path');
var move = path.createSVGPathSegMovetoAbs(parseFloat(element.getAttribute('cx'))-parseFloat(element.getAttribute('rx')),element.getAttribute('cy'));
var arc1 = path.createSVGPathSegArcAbs(parseFloat(element.getAttribute('cx'))+parseFloat(element.getAttribute('rx')),element.getAttribute('cy'),element.getAttribute('rx'),element.getAttribute('ry'),0,1,0);
var arc2 = path.createSVGPathSegArcAbs(parseFloat(element.getAttribute('cx'))-parseFloat(element.getAttribute('rx')),element.getAttribute('cy'),element.getAttribute('rx'),element.getAttribute('ry'),0,1,0);
path.pathSegList.appendItem(move);
path.pathSegList.appendItem(arc1);
path.pathSegList.appendItem(arc2);
path.pathSegList.appendItem(path.createSVGPathSegClosePath());
var transformProperty = element.getAttribute('transform');
if(transformProperty){
path.setAttribute('transform', transformProperty);
}
element.parentElement.replaceChild(path, element);
element = path;
case 'path':
this.pathToAbsolute(element);
var seglist = element.pathSegList;
var prevx = 0;
var prevy = 0;
let transformedPath = '';
for(var i=0; i<seglist.numberOfItems; i++){
var s = seglist.getItem(i);
var command = s.pathSegTypeAsLetter;
if(command == 'H'){
seglist.replaceItem(element.createSVGPathSegLinetoAbs(s.x,prevy),i);
s = seglist.getItem(i);
}
else if(command == 'V'){
seglist.replaceItem(element.createSVGPathSegLinetoAbs(prevx,s.y),i);
s = seglist.getItem(i);
}
// currently only works for uniform scale, no skew
// todo: fully support arbitrary affine transforms...
else if(command == 'A'){
seglist.replaceItem(element.createSVGPathSegArcAbs(s.x,s.y,s.r1*scale,s.r2*scale,s.angle+rotate,s.largeArcFlag,s.sweepFlag),i);
s = seglist.getItem(i);
}
const transPoints = {};
if('x' in s && 'y' in s){
var transformed = transform.calc(s.x, s.y);
prevx = s.x;
prevy = s.y;
transPoints.x = transformed[0];
transPoints.y = transformed[1];
}
if('x1' in s && 'y1' in s){
var transformed = transform.calc(s.x1, s.y1);
transPoints.x1 = transformed[0];
transPoints.y1 = transformed[1];
}
if('x2' in s && 'y2' in s){
var transformed = transform.calc(s.x2, s.y2);
transPoints.x2 = transformed[0];
transPoints.y2 = transformed[1];
}
let commandStringTransformed = ``;
//MLHVCSQTA
//H and V are transformed to "L" commands above so we don't need to handle them. All lowercase (relative) are already handled too (converted to absolute)
switch(command) {
case 'M':
commandStringTransformed += `${command} ${transPoints.x} ${transPoints.y}`;
break;
case 'L':
commandStringTransformed += `${command} ${transPoints.x} ${transPoints.y}`;
break;
case 'C':
commandStringTransformed += `${command} ${transPoints.x1} ${transPoints.y1} ${transPoints.x2} ${transPoints.y2} ${transPoints.x} ${transPoints.y}`;
break;
case 'S':
commandStringTransformed += `${command} ${transPoints.x2} ${transPoints.y2} ${transPoints.x} ${transPoints.y}`;
break;
case 'Q':
commandStringTransformed += `${command} ${transPoints.x1} ${transPoints.y1} ${transPoints.x} ${transPoints.y}`;
break;
case 'T':
commandStringTransformed += `${command} ${transPoints.x} ${transPoints.y}`;
break;
case 'A':
const largeArcFlag = s.largeArcFlag ? 1 : 0;
const sweepFlag = s.sweepFlag ? 1 : 0;
commandStringTransformed += `${command} ${s.r1} ${s.r2} ${s.angle} ${largeArcFlag} ${sweepFlag} ${transPoints.x} ${transPoints.y}`
break;
case 'H':
commandStringTransformed += `L ${transPoints.x} ${transPoints.y}`
break;
case 'V':
commandStringTransformed += `L ${transPoints.x} ${transPoints.y}`
break;
case 'Z':
case 'z':
commandStringTransformed += command;
break;
default:
console.log('FOUND COMMAND NOT HANDLED BY COMMAND STRING BUILDER', command);
break;
}
transformedPath += commandStringTransformed;
}
element.setAttribute('d', transformedPath);
element.removeAttribute('transform');
break;
case 'circle':
var transformed = transform.calc(element.getAttribute('cx'), element.getAttribute('cy'));
element.setAttribute('cx', transformed[0]);
element.setAttribute('cy', transformed[1]);
// skew not supported
element.setAttribute('r', element.getAttribute('r')*scale);
break;
case 'line':
const transformedStartPt = transform.calc(element.getAttribute('x1'), element.getAttribute('y1'));
const transformedEndPt = transform.calc(element.getAttribute('x2'), element.getAttribute('y2'));
element.setAttribute('x1', transformedStartPt[0].toString());
element.setAttribute('y1', transformedStartPt[1].toString());
element.setAttribute('x2', transformedEndPt[0].toString());
element.setAttribute('y2', transformedEndPt[1].toString());
break;
case 'rect':
// similar to the ellipse, we'll replace rect with polygon
var polygon = this.svg.createElementNS(element.namespaceURI, 'polygon');
var p1 = this.svgRoot.createSVGPoint();
var p2 = this.svgRoot.createSVGPoint();
var p3 = this.svgRoot.createSVGPoint();
var p4 = this.svgRoot.createSVGPoint();
p1.x = parseFloat(element.getAttribute('x')) || 0;
p1.y = parseFloat(element.getAttribute('y')) || 0;
p2.x = p1.x + parseFloat(element.getAttribute('width'));
p2.y = p1.y;
p3.x = p2.x;
p3.y = p1.y + parseFloat(element.getAttribute('height'));
p4.x = p1.x;
p4.y = p3.y;
polygon.points.appendItem(p1);
polygon.points.appendItem(p2);
polygon.points.appendItem(p3);
polygon.points.appendItem(p4);
var transformProperty = element.getAttribute('transform');
if(transformProperty){
polygon.setAttribute('transform', transformProperty);
}
element.parentElement.replaceChild(polygon, element);
element = polygon;
case 'polygon':
case 'polyline':
let transformedPoly = ''
for(var i=0; i<element.points.numberOfItems; i++){
var point = element.points.getItem(i);
var transformed = transform.calc(point.x, point.y);
const pointPairString = `${transformed[0]},${transformed[1]} `;
transformedPoly += pointPairString;
}
element.setAttribute('points', transformedPoly);
element.removeAttribute('transform');
break;
}
if(id) {
element.setAttribute('id', id);
}
if(className){
element.setAttribute('class', className);
}
}
}
// bring all child elements to the top level
SvgParser.prototype.flatten = function(element){
for(var i=0; i<element.childNodes.length; i++){
this.flatten(element.childNodes[i]);
}
if(element.tagName != 'svg'){
while(element.childNodes.length > 0){
element.parentElement.appendChild(element.childNodes[0]);
}
}
}
// remove all elements with tag name not in the whitelist
// use this to remove <text>, <g> etc that don't represent shapes
SvgParser.prototype.filter = function(whitelist, element){
if(!whitelist || whitelist.length == 0){
throw Error('invalid whitelist');
}
element = element || this.svgRoot;
for(var i=0; i<element.childNodes.length; i++){
this.filter(whitelist, element.childNodes[i]);
}
if(element.childNodes.length == 0 && whitelist.indexOf(element.tagName) < 0){
element.parentElement.removeChild(element);
}
}
// split a compound path (paths with M, m commands) into an array of paths
SvgParser.prototype.splitPath = function(path){
if(!path || path.tagName != 'path' || !path.parentElement){
return false;
}
var seglist = [];
// make copy of seglist (appending to new path removes it from the original pathseglist)
for(var i=0; i<path.pathSegList.numberOfItems; i++){
seglist.push(path.pathSegList.getItem(i));
}
var x=0, y=0, x0=0, y0=0;
var paths = [];
var p;
var lastM = 0;
for(var i=seglist.length-1; i>=0; i--){
if(i > 0 && seglist[i].pathSegTypeAsLetter == 'M' || seglist[i].pathSegTypeAsLetter == 'm'){
lastM = i;
break;
}
}
if(lastM == 0){
return false; // only 1 M command, no need to split
}
for( i=0; i<seglist.length; i++){
var s = seglist[i];
var command = s.pathSegTypeAsLetter;
if(command == 'M' || command == 'm'){
p = path.cloneNode();
p.setAttribute('d','');
paths.push(p);
}
if (/[MLHVCSQTA]/.test(command)){
if ('x' in s) x=s.x;
if ('y' in s) y=s.y;
p.pathSegList.appendItem(s);
}
else{
if ('x' in s) x+=s.x;
if ('y' in s) y+=s.y;
if(command == 'm'){
p.pathSegList.appendItem(path.createSVGPathSegMovetoAbs(x,y));
}
else{
if(command == 'Z' || command == 'z'){
x = x0;
y = y0;
}
p.pathSegList.appendItem(s);
}
}
// Record the start of a subpath
if (command=='M' || command=='m'){
x0=x, y0=y;
}
}
var addedPaths = [];
for(i=0; i<paths.length; i++){
// don't add trivial paths from sequential M commands
if(paths[i].pathSegList.numberOfItems > 1){
path.parentElement.insertBefore(paths[i], path);
addedPaths.push(paths[i]);
}
}
path.remove();
return addedPaths;
}
// recursively run the given function on the given element
SvgParser.prototype.recurse = function(element, func){
// only operate on original DOM tree, ignore any children that are added. Avoid infinite loops
var children = Array.prototype.slice.call(element.childNodes);
for(var i=0; i<children.length; i++){
this.recurse(children[i], func);
}
func(element);
}
// return a polygon from the given SVG element in the form of an array of points
SvgParser.prototype.polygonify = function(element){
var poly = [];
var i;
switch(element.tagName){
case 'polygon':
case 'polyline':
for(i=0; i<element.points.numberOfItems; i++){
var point = element.points.getItem(i);
poly.push({ x: point.x, y: point.y });
}
break;
case 'rect':
var p1 = {};
var p2 = {};
var p3 = {};
var p4 = {};
p1.x = parseFloat(element.getAttribute('x')) || 0;
p1.y = parseFloat(element.getAttribute('y')) || 0;
p2.x = p1.x + parseFloat(element.getAttribute('width'));
p2.y = p1.y;
p3.x = p2.x;
p3.y = p1.y + parseFloat(element.getAttribute('height'));
p4.x = p1.x;
p4.y = p3.y;
poly.push(p1);
poly.push(p2);
poly.push(p3);
poly.push(p4);
break;
case 'circle':
var radius = parseFloat(element.getAttribute('r'));
var cx = parseFloat(element.getAttribute('cx'));
var cy = parseFloat(element.getAttribute('cy'));
// num is the smallest number of segments required to approximate the circle to the given tolerance
var num = Math.ceil((2*Math.PI)/Math.acos(1 - (this.conf.tolerance/radius)));
if(num < 3){
num = 3;
}
for(var i=0; i<num; i++){
var theta = i * ( (2*Math.PI) / num);
var point = {};
point.x = radius*Math.cos(theta) + cx;
point.y = radius*Math.sin(theta) + cy;
poly.push(point);
}
break;
case 'ellipse':
// same as circle case. There is probably a way to reduce points but for convenience we will just flatten the equivalent circular polygon
var rx = parseFloat(element.getAttribute('rx'))
var ry = parseFloat(element.getAttribute('ry'));
var maxradius = Math.max(rx, ry);
var cx = parseFloat(element.getAttribute('cx'));
var cy = parseFloat(element.getAttribute('cy'));
var num = Math.ceil((2*Math.PI)/Math.acos(1 - (this.conf.tolerance/maxradius)));
if(num < 3){
num = 3;
}
for(var i=0; i<num; i++){
var theta = i * ( (2*Math.PI) / num);
var point = {};
point.x = rx*Math.cos(theta) + cx;
point.y = ry*Math.sin(theta) + cy;
poly.push(point);
}
break;
case 'path':
// we'll assume that splitpath has already been run on this path, and it only has one M/m command
var seglist = element.pathSegList;
var firstCommand = seglist.getItem(0);
var lastCommand = seglist.getItem(seglist.numberOfItems-1);
var x=0, y=0, x0=0, y0=0, x1=0, y1=0, x2=0, y2=0, prevx=0, prevy=0, prevx1=0, prevy1=0, prevx2=0, prevy2=0;
for(var i=0; i<seglist.numberOfItems; i++){
var s = seglist.getItem(i);
var command = s.pathSegTypeAsLetter;
prevx = x;
prevy = y;
prevx1 = x1;
prevy1 = y1;
prevx2 = x2;
prevy2 = y2;
if (/[MLHVCSQTA]/.test(command)){
if ('x1' in s) x1=s.x1;
if ('x2' in s) x2=s.x2;
if ('y1' in s) y1=s.y1;
if ('y2' in s) y2=s.y2;
if ('x' in s) x=s.x;
if ('y' in s) y=s.y;
}
else{
if ('x1' in s) x1=x+s.x1;
if ('x2' in s) x2=x+s.x2;
if ('y1' in s) y1=y+s.y1;
if ('y2' in s) y2=y+s.y2;
if ('x' in s) x+=s.x;
if ('y' in s) y+=s.y;
}
switch(command){
// linear line types
case 'm':
case 'M':
case 'l':
case 'L':
case 'h':
case 'H':
case 'v':
case 'V':
var point = {};
point.x = x;
point.y = y;
poly.push(point);
break;
// Quadratic Beziers
case 't':
case 'T':
// implicit control point
if(i > 0 && /[QqTt]/.test(seglist.getItem(i-1).pathSegTypeAsLetter)){
x1 = prevx + (prevx-prevx1);
y1 = prevy + (prevy-prevy1);
}
else{
x1 = prevx;
y1 = prevy;
}
case 'q':
case 'Q':
var pointlist = GeometryUtil.QuadraticBezier.linearize({x: prevx, y: prevy}, {x: x, y: y}, {x: x1, y: y1}, this.conf.tolerance);
pointlist.shift(); // firstpoint would already be in the poly
for(var j=0; j<pointlist.length; j++){
var point = {};
point.x = pointlist[j].x;
point.y = pointlist[j].y;
poly.push(point);
}
break;
case 's':
case 'S':
if(i > 0 && /[CcSs]/.test(seglist.getItem(i-1).pathSegTypeAsLetter)){
x1 = prevx + (prevx-prevx2);
y1 = prevy + (prevy-prevy2);
}
else{
x1 = prevx;
y1 = prevy;
}
case 'c':
case 'C':
var pointlist = GeometryUtil.CubicBezier.linearize({x: prevx, y: prevy}, {x: x, y: y}, {x: x1, y: y1}, {x: x2, y: y2}, this.conf.tolerance);
pointlist.shift(); // firstpoint would already be in the poly
for(var j=0; j<pointlist.length; j++){
var point = {};
point.x = pointlist[j].x;
point.y = pointlist[j].y;
poly.push(point);
}
break;
case 'a':
case 'A':
var pointlist = GeometryUtil.Arc.linearize({x: prevx, y: prevy}, {x: x, y: y}, s.r1, s.r2, s.angle, s.largeArcFlag,s.sweepFlag, this.conf.tolerance);
pointlist.shift();
for(var j=0; j<pointlist.length; j++){
var point = {};
point.x = pointlist[j].x;
point.y = pointlist[j].y;
poly.push(point);
}
break;
case 'z': case 'Z': x=x0; y=y0; break;
}
// Record the start of a subpath
if (command=='M' || command=='m') x0=x, y0=y;
}
break;
}
// do not include last point if coincident with starting point
while(poly.length > 0 && GeometryUtil.almostEqual(poly[0].x,poly[poly.length-1].x, this.conf.toleranceSvg) && GeometryUtil.almostEqual(poly[0].y,poly[poly.length-1].y, this.conf.toleranceSvg)){
poly.pop();
}
return poly;
};
// expose public methods
var parser = new SvgParser();
root.SvgParser = {
config: parser.config.bind(parser),
load: parser.load.bind(parser),
getStyle: parser.getStyle.bind(parser),
clean: parser.cleanInput.bind(parser),
polygonify: parser.polygonify.bind(parser)
};
}(window));