UNPKG

svg-parse-path-normalized

Version:

Parse svg path data from string and normalize it to calculable value arrays

693 lines (581 loc) 25.1 kB
/** * Standalone pathData parser * including normalization options * returns a pathData array compliant * with the w3C SVGPathData interface draft * https://svgwg.org/specs/paths/#InterfaceSVGPathData * Usage example: * let options = { normalize:false, toAbsolute: true, toLonghands: true, arcToCubic:true, quadraticToCubic: true, lineToCubic: true, debug: false, decimals: -1 } let d = "M 0 0 ... z" let pathDataAbs = parsePathDataNormalized(d, options) * revert to d attribute string * (options: round to decimals; minify=omit repeated commands) let d = pathDataToD(pathData, decimals = -1, minify = false) path.setAttribute('d', d) * See example codepen: * https://codepen.io/herrstrietzel/pen/NWJpOYR */ (function (root, factory) { if (typeof module !== 'undefined' && module.exports) { // CommonJS / Node.js module.exports = factory(); } else if (typeof define === 'function' && define.amd) { // AMD define([], factory); } else { // Browser root.parsepathData = factory(); } })(this, function () { var parsepathData = {}; function parsePathDataNormalized(d, options = {}) { // define options let defaults = { normalize: null, toAbsolute: true, toLonghands: true, arcToCubic: false, quadraticToCubic: false, lineToCubic: false, debug: false, decimals: -1 } options = { ...defaults, ...options } let { normalize, toAbsolute, toLonghands, arcToCubic, quadraticToCubic, lineToCubic, debug, decimals } = options; /** * normalize:true acts as shorthand * to normalize to all absolute, arcs or quadratics to cubics, */ if (normalize === true) { toAbsolute = true toLonghands = true arcToCubic = true quadraticToCubic = true } /** * normalize:false acts as shorthand * to keep the original pathdata as it is, */ else if (normalize === false) { toAbsolute = false toLonghands = false arcToCubic = false quadraticToCubic = false lineToCubic = false } d = d // remove new lines, tabs an comma with whitespace .replace(/[\n\r\t|,]/g, " ") // pre trim left and right whitespace .trim() // add space before minus sign .replace(/(\d)-/g, '$1 -') // decompose multiple adjacent decimal delimiters like 0.5.5.5 => 0.5 0.5 0.5 .replace(/(\.)(?=(\d+\.\d+)+)(\d+)/g, "$1$3 ") let pathData = []; let cmdRegEx = /([mlcqazvhst])([^mlcqazvhst]*)/gi; let commands = d.match(cmdRegEx); // valid command value lengths let comLengths = { m: 2, a: 7, c: 6, h: 1, l: 2, q: 4, s: 4, t: 2, v: 1, z: 0 }; // collect errors in debug mode let errors = []; let firstCommand = d.substring(0, 1).toLowerCase(); let hasM = firstCommand == 'm'; let hasQuadratics = quadraticToCubic ? /[qt]/gi.test(d) : false; let hasArcs = arcToCubic ? /[a]/gi.test(d) : false; let hasShorthands = toLonghands ? /[vhst]/gi.test(d) : false; let hasRelative = toAbsolute ? /[lcqamts]/g.test(d.substring(1, d.length - 1)) : false; // dummy pathdata for error reporting let dummyPathData = [ { type: 'M', values: [0, 0], errors: [] }, { type: 'L', values: [0, 0] } ]; // offsets for absolute conversion let offX, offY, lastX, lastY, M, lastType='m'; // no M starting command – return dummy pathdata if (!hasM) { dummyPathData[0].errors.push('No starting M command') return dummyPathData; } for (let c = 0; c < commands.length; c++) { let com = commands[c]; let type = com.substring(0, 1); let typeRel = type.toLowerCase(); let typeAbs = type.toUpperCase(); let isRel = type === typeRel; let chunkSize = comLengths[typeRel]; // split values to array let values = com.substring(1, com.length) .trim() .split(" ").filter(Boolean); /** * A - Arc commands * large arc and sweep flags * are boolean and can be concatenated like * 11 or 01 * or be concatenated with the final on path points like * 1110 10 => 1 1 10 10 */ if (typeRel === "a" && values.length != comLengths.a) { let n = 0, arcValues = []; for (let i = 0; i < values.length; i++) { let value = values[i]; // reset counter if (n >= chunkSize) { n = 0; } // if 3. or 4. parameter longer than 1 if ((n === 3 || n === 4) && value.length > 1) { let largeArc = n === 3 ? value.substring(0, 1) : ""; let sweep = n === 3 ? value.substring(1, 2) : value.substring(0, 1); let finalX = n === 3 ? value.substring(2) : value.substring(1); let comN = [largeArc, sweep, finalX].filter(Boolean); arcValues.push(comN); n += comN.length; } else { // regular arcValues.push(value); n++; } } values = arcValues.flat().filter(Boolean); } if (debug && typeRel === "a" && (values[3] > 1 || values[4] > 1 || values[3] < 0 || values[4] < 0)) { errors.push( `${c}. command (${type}) has invalid flag values: ${values[3]} ${values[4]}` ); } // string to number values = values.map(Number) // if string contains repeated shorthand commands - split them let hasMultiple = values.length > chunkSize; let chunk = hasMultiple ? values.slice(0, chunkSize) : values; let comChunks = [{ type: type, values: chunk }]; if (debug && comChunks[0].values.length < chunkSize) { errors.push( `${c}. command (${type}) has ${chunk.length }/${chunkSize} - ${chunk.length} values too few ` ); } // has implicit or repeated commands – split into chunks if (hasMultiple) { let typeImplicit = typeRel === "m" ? (isRel ? "l" : "L") : type; for (let i = chunkSize; i < values.length; i += chunkSize) { let chunk = values.slice(i, i + chunkSize); comChunks.push({ type: typeImplicit, values: chunk }); if (debug && chunk.length !== chunkSize) { let overhead = Math.ceil(chunkSize / chunk.length); let ideal = overhead * chunkSize let feedback = chunk.length < ideal ? 'too few' : 'too many'; let diff = Math.abs(chunk.length + chunkSize - ideal); errors.push( `${i}. command (${type}) has ${chunk.length + chunkSize} values - ${diff} values ${feedback} - should be ${overhead} commands with ${chunkSize} values per command` ); } } } //search for omited M commands for(let i=0, len=comChunks.length; i<len; i++){ let com=comChunks[i]; if(com.type.toLowerCase()!=='m' && lastType==='z'){ hasRelative=true; comChunks.splice(i, 0, { type: 'M', values: [M.x, M.y] }); i++; } } // no relative, shorthand or arc command - return current if (normalize === false || (!hasRelative && !hasQuadratics && !hasShorthands && !hasArcs && !lineToCubic)) { comChunks.forEach((com) => { pathData.push(com); }); } /** * convert to absolute * init offset from 1st M */ else { if (c === 0) { offX = values[0]; offY = values[1]; lastX = offX; lastY = offY; M = { x: values[0], y: values[1] }; } let typeFirst = comChunks[0].type; typeAbs = typeFirst.toUpperCase() // first M is always absolute isRel = typeFirst.toLowerCase() === typeFirst && pathData.length ? true : false; for (let i = 0; i < comChunks.length; i++) { let com = comChunks[i]; let type = com.type; let values = com.values; let valuesL = values.length; let comPrev = comChunks[i - 1] ? comChunks[i - 1] : c > 0 && pathData[pathData.length - 1] ? pathData[pathData.length - 1] : comChunks[i]; let valuesPrev = comPrev.values; let valuesPrevL = valuesPrev.length; isRel = comChunks.length > 1 ? type.toLowerCase() === type && pathData.length : isRel; if (isRel) { com.type = comChunks.length > 1 ? type.toUpperCase() : typeAbs; switch (typeRel) { case "a": com.values = [ values[0], values[1], values[2], values[3], values[4], values[5] + offX, values[6] + offY ]; break; case "h": case "v": com.values = type === "h" ? [values[0] + offX] : [values[0] + offY]; break; case "m": case "l": case "t": //update last M if (type === 'm') { M = { x: values[0] + offX, y: values[1] + offY }; } com.values = [values[0] + offX, values[1] + offY]; break; case "c": com.values = [ values[0] + offX, values[1] + offY, values[2] + offX, values[3] + offY, values[4] + offX, values[5] + offY ]; break; case "q": case "s": com.values = [ values[0] + offX, values[1] + offY, values[2] + offX, values[3] + offY ]; break; case 'z': case 'Z': lastX = M.x; lastY = M.y; break; } } // is absolute else { offX = 0; offY = 0; // set new M if(type==='M'){ M = { x: values[0], y: values[1]}; } } /** * convert shorthands */ let shorthandTypes = ["H", "V", "S", "T"]; if ((toLonghands && shorthandTypes.includes(typeAbs)) || (arcToCubic && shorthandTypes.includes(typeAbs)) || quadraticToCubic) { let cp1X, cp1Y, cpN1X, cpN1Y, cp2X, cp2Y; if (com.type === "H" || com.type === "V") { com.values = com.type === "H" ? [com.values[0], lastY] : [lastX, com.values[0]]; com.type = "L"; } else if (com.type === "T" || com.type === "S") { [cp1X, cp1Y] = [valuesPrev[0], valuesPrev[1]]; [cp2X, cp2Y] = valuesPrevL > 2 ? [valuesPrev[2], valuesPrev[3]] : [valuesPrev[0], valuesPrev[1]]; // new control point cpN1X = com.type === "T" ? lastX * 2 - cp1X : lastX * 2 - cp2X; cpN1Y = com.type === "T" ? lastY * 2 - cp1Y : lastY * 2 - cp2Y; com.values = [cpN1X, cpN1Y, com.values].flat(); com.type = com.type === "T" ? "Q" : "C"; } } /** * linetos to cubic * facilitates morphing animations */ if (lineToCubic && com.type === 'L') { com = { type: 'C', values: [lastX, lastY, com.values[0], com.values[1], com.values[0], com.values[1]] } } /** * convert arcs */ if ((arcToCubic && com.type === 'A')) { p0 = { x: lastX, y: lastY } if (typeRel === 'a') { let comArc = arcToBezier(p0, com.values) comArc.forEach(seg => { pathData.push(seg); }) } } else { // update last type for omitted M commands lastType=type.toLowerCase(); // add to pathData array pathData.push(com); } // update offsets lastX = valuesL > 1 ? values[valuesL - 2] + offX : typeRel === "h" ? values[0] + offX : lastX; lastY = valuesL > 1 ? values[valuesL - 1] + offY : typeRel === "v" ? values[0] + offY : lastY; offX = lastX; offY = lastY; } } } /** * path data has errors: * return dummyPathData * including an error report */ if (debug && errors.length) { dummyPathData[0].errors.push(errors) return dummyPathData } /** * first M is always absolute/uppercase - * unless it adds relative linetos * (facilitates d concatenating) */ pathData[0].type = "M"; /** * convert quadratics to cubic */ if (hasQuadratics && quadraticToCubic) { for (let i = 0; i < pathData.length; i++) { let com = pathData[i]; if (com.type === 'Q' && hasQuadratics && quadraticToCubic) { let comPrev = pathData[i - 1]; let comPrevValues = comPrev.values; let comPrevValuesL = comPrevValues.length; let p0 = { x: comPrevValues[comPrevValuesL - 2], y: comPrevValues[comPrevValuesL - 1] } pathData[i] = quadratic2Cubic(p0, com.values) } } } // round coordinates if (decimals > -1) { pathData = pathData.map(com => { return { type: com.type, values: com.values.map(val => { return +val.toFixed(decimals) }) } }); } return pathData; } /** * convert quadratic commands to cubic */ function quadratic2Cubic(p0, values) { let cp1 = { x: p0.x + 2 / 3 * (values[0] - p0.x), y: p0.y + 2 / 3 * (values[1] - p0.y) } let cp2 = { x: values[2] + 2 / 3 * (values[0] - values[2]), y: values[3] + 2 / 3 * (values[1] - values[3]) } return ({ type: "C", values: [cp1.x, cp1.y, cp2.x, cp2.y, values[2], values[3]] }); } /** * convert arctocommands to cubic bezier * based on puzrin's a2c.js * https://github.com/fontello/svgpath/blob/master/lib/a2c.js * returns pathData array */ function arcToBezier(p0, values, splitSegments = 1) { const TAU = Math.PI * 2; let [rx, ry, rotation, largeArcFlag, sweepFlag, x, y] = values; if (rx === 0 || ry === 0) { return [] } let phi = rotation ? rotation * TAU / 360 : 0; let sinphi = phi ? Math.sin(phi) : 0 let cosphi = phi ? Math.cos(phi) : 1 let pxp = cosphi * (p0.x - x) / 2 + sinphi * (p0.y - y) / 2 let pyp = -sinphi * (p0.x - x) / 2 + cosphi * (p0.y - y) / 2 if (pxp === 0 && pyp === 0) { return [] } rx = Math.abs(rx) ry = Math.abs(ry) let lambda = pxp * pxp / (rx * rx) + pyp * pyp / (ry * ry) if (lambda > 1) { let lambdaRt = Math.sqrt(lambda); rx *= lambdaRt ry *= lambdaRt } /** * parametrize arc to * get center point start and end angles */ let rxsq = rx * rx, rysq = rx === ry ? rxsq : ry * ry let pxpsq = pxp * pxp, pypsq = pyp * pyp let radicant = (rxsq * rysq) - (rxsq * pypsq) - (rysq * pxpsq) if (radicant <= 0) { radicant = 0 } else { radicant /= (rxsq * pypsq) + (rysq * pxpsq) radicant = Math.sqrt(radicant) * (largeArcFlag === sweepFlag ? -1 : 1) } let centerxp = radicant ? radicant * rx / ry * pyp : 0 let centeryp = radicant ? radicant * -ry / rx * pxp : 0 let centerx = cosphi * centerxp - sinphi * centeryp + (p0.x + x) / 2 let centery = sinphi * centerxp + cosphi * centeryp + (p0.y + y) / 2 let vx1 = (pxp - centerxp) / rx let vy1 = (pyp - centeryp) / ry let vx2 = (-pxp - centerxp) / rx let vy2 = (-pyp - centeryp) / ry // get start and end angle const vectorAngle = (ux, uy, vx, vy) => { let dot = +(ux * vx + uy * vy).toFixed(9) if (dot === 1 || dot === -1) { return dot === 1 ? 0 : Math.PI } dot = dot > 1 ? 1 : (dot < -1 ? -1 : dot) let sign = (ux * vy - uy * vx < 0) ? -1 : 1 return sign * Math.acos(dot); } let ang1 = vectorAngle(1, 0, vx1, vy1), ang2 = vectorAngle(vx1, vy1, vx2, vy2) if (sweepFlag === 0 && ang2 > 0) { ang2 -= Math.PI * 2 } else if (sweepFlag === 1 && ang2 < 0) { ang2 += Math.PI * 2 } let ratio = +(Math.abs(ang2) / (TAU / 4)).toFixed(0) || 1 // increase segments for more accurate length calculations let segments = ratio * splitSegments; ang2 /= segments let pathDataArc = []; /** * If 90 degree circular arc, use a constant * https://pomax.github.io/bezierinfo/#circles_cubic * k=0.551784777779014 */ const angle90 = 1.5707963267948966; const k = 0.551785 let a = ang2 === angle90 ? k : ( ang2 === -angle90 ? -k : 4 / 3 * Math.tan(ang2 / 4) ); let cos2 = ang2 ? Math.cos(ang2) : 1; let sin2 = ang2 ? Math.sin(ang2) : 0; let type = 'C' const approxUnitArc = (ang1, ang2, a, cos2, sin2) => { let x1 = ang1 != ang2 ? Math.cos(ang1) : cos2; let y1 = ang1 != ang2 ? Math.sin(ang1) : sin2; let x2 = Math.cos(ang1 + ang2); let y2 = Math.sin(ang1 + ang2); return [ { x: x1 - y1 * a, y: y1 + x1 * a }, { x: x2 + y2 * a, y: y2 - x2 * a }, { x: x2, y: y2 } ]; } for (let i = 0; i < segments; i++) { let com = { type: type, values: [] } let curve = approxUnitArc(ang1, ang2, a, cos2, sin2); curve.forEach((pt) => { let x = pt.x * rx let y = pt.y * ry com.values.push(cosphi * x - sinphi * y + centerx, sinphi * x + cosphi * y + centery) }) pathDataArc.push(com); ang1 += ang2 } return pathDataArc; } // wrapper for stringified path data output Array.prototype.toD = function (decimals = -1, minify = false) { return pathDataToD(this, decimals, minify); } /** * serialize pathData array to * d attribute string */ function pathDataToD(pathData, decimals = -1, minify = false) { // implicit l command if (pathData[1].type === "l" && minify) { pathData[0].type = "m"; } let d = `${pathData[0].type}${pathData[0].values.join(" ")}`; for (let i = 1; i < pathData.length; i++) { let com0 = pathData[i - 1]; let com = pathData[i]; let { type, values } = com; // minify arctos if (minify && type === 'A' || type === 'a') { values = [values[0], values[1], values[2], [values[3], values[4], values[5]].join(''), values[6]] } // round if (values.length && decimals > -1) { values = values.map(val => { return typeof val === 'number' ? +val.toFixed(decimals) : val }) } // omit type for repeated commands type = (com0.type === com.type && com.type.toLowerCase() != 'm' && minify) ? " " : ( (com0.type === "m" && com.type === "l") || (com0.type === "M" && com.type === "l") || (com0.type === "M" && com.type === "L") ) && minify ? " " : com.type; d += `${type}${values.join(" ")}`; } if (minify) { d = d .replaceAll(" 0.", " .") .replaceAll(" -", "-") .replaceAll("-0.", "-.") .replaceAll("Z", "z"); } return d; } // shorthand/wrapper for parsing parsepathData.parseD = parsePathDataNormalized; parsepathData.parsePathDataNormalized = parsePathDataNormalized; parsepathData.pathDataToD = pathDataToD; parsepathData.arcToBezier = arcToBezier; return parsepathData; }); if (typeof module === 'undefined') { var { parsePathDataNormalized, parseD, pathDataToD, arcToBezier } = parsepathData; }