You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
665 lines
16 KiB
665 lines
16 KiB
// SVG Path transformations library |
|
// |
|
// Usage: |
|
// |
|
// SvgPath('...') |
|
// .translate(-150, -100) |
|
// .scale(0.5) |
|
// .translate(-150, -100) |
|
// .toFixed(1) |
|
// .toString() |
|
// |
|
|
|
'use strict'; |
|
|
|
|
|
var pathParse = require('./path_parse'); |
|
var transformParse = require('./transform_parse'); |
|
var matrix = require('./matrix'); |
|
var a2c = require('./a2c'); |
|
var ellipse = require('./ellipse'); |
|
|
|
|
|
// Class constructor |
|
// |
|
function SvgPath(path) { |
|
if (!(this instanceof SvgPath)) { return new SvgPath(path); } |
|
|
|
var pstate = pathParse(path); |
|
|
|
// Array of path segments. |
|
// Each segment is array [command, param1, param2, ...] |
|
this.segments = pstate.segments; |
|
|
|
// Error message on parse error. |
|
this.err = pstate.err; |
|
|
|
// Transforms stack for lazy evaluation |
|
this.__stack = []; |
|
} |
|
|
|
SvgPath.from = function (src) { |
|
if (typeof src === 'string') return new SvgPath(src); |
|
|
|
if (src instanceof SvgPath) { |
|
// Create empty object |
|
var s = new SvgPath(''); |
|
|
|
// Clone properies |
|
s.err = src.err; |
|
s.segments = src.segments.map(function (sgm) { return sgm.slice(); }); |
|
s.__stack = src.__stack.map(function (m) { |
|
return matrix().matrix(m.toArray()); |
|
}); |
|
|
|
return s; |
|
} |
|
|
|
throw new Error('SvgPath.from: invalid param type ' + src); |
|
}; |
|
|
|
|
|
SvgPath.prototype.__matrix = function (m) { |
|
var self = this, i; |
|
|
|
// Quick leave for empty matrix |
|
if (!m.queue.length) { return; } |
|
|
|
this.iterate(function (s, index, x, y) { |
|
var p, result, name, isRelative; |
|
|
|
switch (s[0]) { |
|
|
|
// Process 'assymetric' commands separately |
|
case 'v': |
|
p = m.calc(0, s[1], true); |
|
result = (p[0] === 0) ? [ 'v', p[1] ] : [ 'l', p[0], p[1] ]; |
|
break; |
|
|
|
case 'V': |
|
p = m.calc(x, s[1], false); |
|
result = (p[0] === m.calc(x, y, false)[0]) ? [ 'V', p[1] ] : [ 'L', p[0], p[1] ]; |
|
break; |
|
|
|
case 'h': |
|
p = m.calc(s[1], 0, true); |
|
result = (p[1] === 0) ? [ 'h', p[0] ] : [ 'l', p[0], p[1] ]; |
|
break; |
|
|
|
case 'H': |
|
p = m.calc(s[1], y, false); |
|
result = (p[1] === m.calc(x, y, false)[1]) ? [ 'H', p[0] ] : [ 'L', p[0], p[1] ]; |
|
break; |
|
|
|
case 'a': |
|
case 'A': |
|
// ARC is: ['A', rx, ry, x-axis-rotation, large-arc-flag, sweep-flag, x, y] |
|
|
|
// Drop segment if arc is empty (end point === start point) |
|
/*if ((s[0] === 'A' && s[6] === x && s[7] === y) || |
|
(s[0] === 'a' && s[6] === 0 && s[7] === 0)) { |
|
return []; |
|
}*/ |
|
|
|
// Transform rx, ry and the x-axis-rotation |
|
var ma = m.toArray(); |
|
var e = ellipse(s[1], s[2], s[3]).transform(ma); |
|
|
|
// flip sweep-flag if matrix is not orientation-preserving |
|
if (ma[0] * ma[3] - ma[1] * ma[2] < 0) { |
|
s[5] = s[5] ? '0' : '1'; |
|
} |
|
|
|
// Transform end point as usual (without translation for relative notation) |
|
p = m.calc(s[6], s[7], s[0] === 'a'); |
|
|
|
// Empty arcs can be ignored by renderer, but should not be dropped |
|
// to avoid collisions with `S A S` and so on. Replace with empty line. |
|
if ((s[0] === 'A' && s[6] === x && s[7] === y) || |
|
(s[0] === 'a' && s[6] === 0 && s[7] === 0)) { |
|
result = [ s[0] === 'a' ? 'l' : 'L', p[0], p[1] ]; |
|
break; |
|
} |
|
|
|
// if the resulting ellipse is (almost) a segment ... |
|
if (e.isDegenerate()) { |
|
// replace the arc by a line |
|
result = [ s[0] === 'a' ? 'l' : 'L', p[0], p[1] ]; |
|
} else { |
|
// if it is a real ellipse |
|
// s[0], s[4] and s[5] are not modified |
|
result = [ s[0], e.rx, e.ry, e.ax, s[4], s[5], p[0], p[1] ]; |
|
} |
|
|
|
break; |
|
|
|
case 'm': |
|
// Edge case. The very first `m` should be processed as absolute, if happens. |
|
// Make sense for coord shift transforms. |
|
isRelative = index > 0; |
|
|
|
p = m.calc(s[1], s[2], isRelative); |
|
result = [ 'm', p[0], p[1] ]; |
|
break; |
|
|
|
default: |
|
name = s[0]; |
|
result = [ name ]; |
|
isRelative = (name.toLowerCase() === name); |
|
|
|
// Apply transformations to the segment |
|
for (i = 1; i < s.length; i += 2) { |
|
p = m.calc(s[i], s[i + 1], isRelative); |
|
result.push(p[0], p[1]); |
|
} |
|
} |
|
|
|
self.segments[index] = result; |
|
}, true); |
|
}; |
|
|
|
|
|
// Apply stacked commands |
|
// |
|
SvgPath.prototype.__evaluateStack = function () { |
|
var m, i; |
|
|
|
if (!this.__stack.length) { return; } |
|
|
|
if (this.__stack.length === 1) { |
|
this.__matrix(this.__stack[0]); |
|
this.__stack = []; |
|
return; |
|
} |
|
|
|
m = matrix(); |
|
i = this.__stack.length; |
|
|
|
while (--i >= 0) { |
|
m.matrix(this.__stack[i].toArray()); |
|
} |
|
|
|
this.__matrix(m); |
|
this.__stack = []; |
|
}; |
|
|
|
|
|
// Convert processed SVG Path back to string |
|
// |
|
SvgPath.prototype.toString = function () { |
|
var result = '', prevCmd = '', cmdSkipped = false; |
|
|
|
this.__evaluateStack(); |
|
|
|
for (var i = 0, len = this.segments.length; i < len; i++) { |
|
var segment = this.segments[i]; |
|
var cmd = segment[0]; |
|
|
|
// Command not repeating => store |
|
if (cmd !== prevCmd || cmd === 'm' || cmd === 'M') { |
|
// workaround for FontForge SVG importing bug, keep space between "z m". |
|
if (cmd === 'm' && prevCmd === 'z') result += ' '; |
|
result += cmd; |
|
|
|
cmdSkipped = false; |
|
} else { |
|
cmdSkipped = true; |
|
} |
|
|
|
// Store segment params |
|
for (var pos = 1; pos < segment.length; pos++) { |
|
var val = segment[pos]; |
|
// Space can be skipped |
|
// 1. After command (always) |
|
// 2. For negative value (with '-' at start) |
|
if (pos === 1) { |
|
if (cmdSkipped && val >= 0) result += ' '; |
|
} else if (val >= 0) result += ' '; |
|
|
|
result += val; |
|
} |
|
|
|
prevCmd = cmd; |
|
} |
|
|
|
return result; |
|
}; |
|
|
|
|
|
// Translate path to (x [, y]) |
|
// |
|
SvgPath.prototype.translate = function (x, y) { |
|
this.__stack.push(matrix().translate(x, y || 0)); |
|
return this; |
|
}; |
|
|
|
|
|
// Scale path to (sx [, sy]) |
|
// sy = sx if not defined |
|
// |
|
SvgPath.prototype.scale = function (sx, sy) { |
|
this.__stack.push(matrix().scale(sx, (!sy && (sy !== 0)) ? sx : sy)); |
|
return this; |
|
}; |
|
|
|
|
|
// Rotate path around point (sx [, sy]) |
|
// sy = sx if not defined |
|
// |
|
SvgPath.prototype.rotate = function (angle, rx, ry) { |
|
this.__stack.push(matrix().rotate(angle, rx || 0, ry || 0)); |
|
return this; |
|
}; |
|
|
|
|
|
// Skew path along the X axis by `degrees` angle |
|
// |
|
SvgPath.prototype.skewX = function (degrees) { |
|
this.__stack.push(matrix().skewX(degrees)); |
|
return this; |
|
}; |
|
|
|
|
|
// Skew path along the Y axis by `degrees` angle |
|
// |
|
SvgPath.prototype.skewY = function (degrees) { |
|
this.__stack.push(matrix().skewY(degrees)); |
|
return this; |
|
}; |
|
|
|
|
|
// Apply matrix transform (array of 6 elements) |
|
// |
|
SvgPath.prototype.matrix = function (m) { |
|
this.__stack.push(matrix().matrix(m)); |
|
return this; |
|
}; |
|
|
|
|
|
// Transform path according to "transform" attr of SVG spec |
|
// |
|
SvgPath.prototype.transform = function (transformString) { |
|
if (!transformString.trim()) { |
|
return this; |
|
} |
|
this.__stack.push(transformParse(transformString)); |
|
return this; |
|
}; |
|
|
|
|
|
// Round coords with given decimal precition. |
|
// 0 by default (to integers) |
|
// |
|
SvgPath.prototype.round = function (d) { |
|
var contourStartDeltaX = 0, contourStartDeltaY = 0, deltaX = 0, deltaY = 0, l; |
|
|
|
d = d || 0; |
|
|
|
this.__evaluateStack(); |
|
|
|
this.segments.forEach(function (s) { |
|
var isRelative = (s[0].toLowerCase() === s[0]); |
|
|
|
switch (s[0]) { |
|
case 'H': |
|
case 'h': |
|
if (isRelative) { s[1] += deltaX; } |
|
deltaX = s[1] - s[1].toFixed(d); |
|
s[1] = +s[1].toFixed(d); |
|
return; |
|
|
|
case 'V': |
|
case 'v': |
|
if (isRelative) { s[1] += deltaY; } |
|
deltaY = s[1] - s[1].toFixed(d); |
|
s[1] = +s[1].toFixed(d); |
|
return; |
|
|
|
case 'Z': |
|
case 'z': |
|
deltaX = contourStartDeltaX; |
|
deltaY = contourStartDeltaY; |
|
return; |
|
|
|
case 'M': |
|
case 'm': |
|
if (isRelative) { |
|
s[1] += deltaX; |
|
s[2] += deltaY; |
|
} |
|
|
|
deltaX = s[1] - s[1].toFixed(d); |
|
deltaY = s[2] - s[2].toFixed(d); |
|
|
|
contourStartDeltaX = deltaX; |
|
contourStartDeltaY = deltaY; |
|
|
|
s[1] = +s[1].toFixed(d); |
|
s[2] = +s[2].toFixed(d); |
|
return; |
|
|
|
case 'A': |
|
case 'a': |
|
// [cmd, rx, ry, x-axis-rotation, large-arc-flag, sweep-flag, x, y] |
|
if (isRelative) { |
|
s[6] += deltaX; |
|
s[7] += deltaY; |
|
} |
|
|
|
deltaX = s[6] - s[6].toFixed(d); |
|
deltaY = s[7] - s[7].toFixed(d); |
|
|
|
s[1] = +s[1].toFixed(d); |
|
s[2] = +s[2].toFixed(d); |
|
s[3] = +s[3].toFixed(d + 2); // better precision for rotation |
|
s[6] = +s[6].toFixed(d); |
|
s[7] = +s[7].toFixed(d); |
|
return; |
|
|
|
default: |
|
// a c l q s t |
|
l = s.length; |
|
|
|
if (isRelative) { |
|
s[l - 2] += deltaX; |
|
s[l - 1] += deltaY; |
|
} |
|
|
|
deltaX = s[l - 2] - s[l - 2].toFixed(d); |
|
deltaY = s[l - 1] - s[l - 1].toFixed(d); |
|
|
|
s.forEach(function (val, i) { |
|
if (!i) { return; } |
|
s[i] = +s[i].toFixed(d); |
|
}); |
|
return; |
|
} |
|
}); |
|
|
|
return this; |
|
}; |
|
|
|
|
|
// Apply iterator function to all segments. If function returns result, |
|
// current segment will be replaced to array of returned segments. |
|
// If empty array is returned, current regment will be deleted. |
|
// |
|
SvgPath.prototype.iterate = function (iterator, keepLazyStack) { |
|
var segments = this.segments, |
|
replacements = {}, |
|
needReplace = false, |
|
lastX = 0, |
|
lastY = 0, |
|
countourStartX = 0, |
|
countourStartY = 0; |
|
var i, j, newSegments; |
|
|
|
if (!keepLazyStack) { |
|
this.__evaluateStack(); |
|
} |
|
|
|
segments.forEach(function (s, index) { |
|
|
|
var res = iterator(s, index, lastX, lastY); |
|
|
|
if (Array.isArray(res)) { |
|
replacements[index] = res; |
|
needReplace = true; |
|
} |
|
|
|
var isRelative = (s[0] === s[0].toLowerCase()); |
|
|
|
// calculate absolute X and Y |
|
switch (s[0]) { |
|
case 'm': |
|
case 'M': |
|
lastX = s[1] + (isRelative ? lastX : 0); |
|
lastY = s[2] + (isRelative ? lastY : 0); |
|
countourStartX = lastX; |
|
countourStartY = lastY; |
|
return; |
|
|
|
case 'h': |
|
case 'H': |
|
lastX = s[1] + (isRelative ? lastX : 0); |
|
return; |
|
|
|
case 'v': |
|
case 'V': |
|
lastY = s[1] + (isRelative ? lastY : 0); |
|
return; |
|
|
|
case 'z': |
|
case 'Z': |
|
// That make sence for multiple contours |
|
lastX = countourStartX; |
|
lastY = countourStartY; |
|
return; |
|
|
|
default: |
|
lastX = s[s.length - 2] + (isRelative ? lastX : 0); |
|
lastY = s[s.length - 1] + (isRelative ? lastY : 0); |
|
} |
|
}); |
|
|
|
// Replace segments if iterator return results |
|
|
|
if (!needReplace) { return this; } |
|
|
|
newSegments = []; |
|
|
|
for (i = 0; i < segments.length; i++) { |
|
if (typeof replacements[i] !== 'undefined') { |
|
for (j = 0; j < replacements[i].length; j++) { |
|
newSegments.push(replacements[i][j]); |
|
} |
|
} else { |
|
newSegments.push(segments[i]); |
|
} |
|
} |
|
|
|
this.segments = newSegments; |
|
|
|
return this; |
|
}; |
|
|
|
|
|
// Converts segments from relative to absolute |
|
// |
|
SvgPath.prototype.abs = function () { |
|
|
|
this.iterate(function (s, index, x, y) { |
|
var name = s[0], |
|
nameUC = name.toUpperCase(), |
|
i; |
|
|
|
// Skip absolute commands |
|
if (name === nameUC) { return; } |
|
|
|
s[0] = nameUC; |
|
|
|
switch (name) { |
|
case 'v': |
|
// v has shifted coords parity |
|
s[1] += y; |
|
return; |
|
|
|
case 'a': |
|
// ARC is: ['A', rx, ry, x-axis-rotation, large-arc-flag, sweep-flag, x, y] |
|
// touch x, y only |
|
s[6] += x; |
|
s[7] += y; |
|
return; |
|
|
|
default: |
|
for (i = 1; i < s.length; i++) { |
|
s[i] += i % 2 ? x : y; // odd values are X, even - Y |
|
} |
|
} |
|
}, true); |
|
|
|
return this; |
|
}; |
|
|
|
|
|
// Converts segments from absolute to relative |
|
// |
|
SvgPath.prototype.rel = function () { |
|
|
|
this.iterate(function (s, index, x, y) { |
|
var name = s[0], |
|
nameLC = name.toLowerCase(), |
|
i; |
|
|
|
// Skip relative commands |
|
if (name === nameLC) { return; } |
|
|
|
// Don't touch the first M to avoid potential confusions. |
|
if (index === 0 && name === 'M') { return; } |
|
|
|
s[0] = nameLC; |
|
|
|
switch (name) { |
|
case 'V': |
|
// V has shifted coords parity |
|
s[1] -= y; |
|
return; |
|
|
|
case 'A': |
|
// ARC is: ['A', rx, ry, x-axis-rotation, large-arc-flag, sweep-flag, x, y] |
|
// touch x, y only |
|
s[6] -= x; |
|
s[7] -= y; |
|
return; |
|
|
|
default: |
|
for (i = 1; i < s.length; i++) { |
|
s[i] -= i % 2 ? x : y; // odd values are X, even - Y |
|
} |
|
} |
|
}, true); |
|
|
|
return this; |
|
}; |
|
|
|
|
|
// Converts arcs to cubic bézier curves |
|
// |
|
SvgPath.prototype.unarc = function () { |
|
this.iterate(function (s, index, x, y) { |
|
var new_segments, nextX, nextY, result = [], name = s[0]; |
|
|
|
// Skip anything except arcs |
|
if (name !== 'A' && name !== 'a') { return null; } |
|
|
|
if (name === 'a') { |
|
// convert relative arc coordinates to absolute |
|
nextX = x + s[6]; |
|
nextY = y + s[7]; |
|
} else { |
|
nextX = s[6]; |
|
nextY = s[7]; |
|
} |
|
|
|
new_segments = a2c(x, y, nextX, nextY, s[4], s[5], s[1], s[2], s[3]); |
|
|
|
// Degenerated arcs can be ignored by renderer, but should not be dropped |
|
// to avoid collisions with `S A S` and so on. Replace with empty line. |
|
if (new_segments.length === 0) { |
|
return [ [ s[0] === 'a' ? 'l' : 'L', s[6], s[7] ] ]; |
|
} |
|
|
|
new_segments.forEach(function (s) { |
|
result.push([ 'C', s[2], s[3], s[4], s[5], s[6], s[7] ]); |
|
}); |
|
|
|
return result; |
|
}); |
|
|
|
return this; |
|
}; |
|
|
|
|
|
// Converts smooth curves (with missed control point) to generic curves |
|
// |
|
SvgPath.prototype.unshort = function () { |
|
var segments = this.segments; |
|
var prevControlX, prevControlY, prevSegment; |
|
var curControlX, curControlY; |
|
|
|
// TODO: add lazy evaluation flag when relative commands supported |
|
|
|
this.iterate(function (s, idx, x, y) { |
|
var name = s[0], nameUC = name.toUpperCase(), isRelative; |
|
|
|
// First command MUST be M|m, it's safe to skip. |
|
// Protect from access to [-1] for sure. |
|
if (!idx) { return; } |
|
|
|
if (nameUC === 'T') { // quadratic curve |
|
isRelative = (name === 't'); |
|
|
|
prevSegment = segments[idx - 1]; |
|
|
|
if (prevSegment[0] === 'Q') { |
|
prevControlX = prevSegment[1] - x; |
|
prevControlY = prevSegment[2] - y; |
|
} else if (prevSegment[0] === 'q') { |
|
prevControlX = prevSegment[1] - prevSegment[3]; |
|
prevControlY = prevSegment[2] - prevSegment[4]; |
|
} else { |
|
prevControlX = 0; |
|
prevControlY = 0; |
|
} |
|
|
|
curControlX = -prevControlX; |
|
curControlY = -prevControlY; |
|
|
|
if (!isRelative) { |
|
curControlX += x; |
|
curControlY += y; |
|
} |
|
|
|
segments[idx] = [ |
|
isRelative ? 'q' : 'Q', |
|
curControlX, curControlY, |
|
s[1], s[2] |
|
]; |
|
|
|
} else if (nameUC === 'S') { // cubic curve |
|
isRelative = (name === 's'); |
|
|
|
prevSegment = segments[idx - 1]; |
|
|
|
if (prevSegment[0] === 'C') { |
|
prevControlX = prevSegment[3] - x; |
|
prevControlY = prevSegment[4] - y; |
|
} else if (prevSegment[0] === 'c') { |
|
prevControlX = prevSegment[3] - prevSegment[5]; |
|
prevControlY = prevSegment[4] - prevSegment[6]; |
|
} else { |
|
prevControlX = 0; |
|
prevControlY = 0; |
|
} |
|
|
|
curControlX = -prevControlX; |
|
curControlY = -prevControlY; |
|
|
|
if (!isRelative) { |
|
curControlX += x; |
|
curControlY += y; |
|
} |
|
|
|
segments[idx] = [ |
|
isRelative ? 'c' : 'C', |
|
curControlX, curControlY, |
|
s[1], s[2], s[3], s[4] |
|
]; |
|
} |
|
}); |
|
|
|
return this; |
|
}; |
|
|
|
|
|
module.exports = SvgPath;
|
|
|