diff --git a/lib/box.js b/lib/box.js new file mode 100644 index 0000000..c28e772 --- /dev/null +++ b/lib/box.js @@ -0,0 +1,260 @@ +'use strict'; + +// precision for consider cubic polynom as quadratic one +var epsilon = 0.00000001; + +// New box : empty or parsed from string like '-10 10 300 400' +// +function Box(s) { + if (!(this instanceof Box)) { return new Box(s); } + + // minX, minY, maxX, maxY : are not defined yet + // but empty box has 0 x 0 size + this.width = this.height = 0; + + // parse the string parameter + if (s && s.constructor === String) { + var a = s.trim().split(/\s+/).map(parseFloat); + + this.addX(a[0]).addX(a[0] + a[2]).addY(a[1]).addY(a[1] + a[3]); + } + + return this; +} + +// check if box is not defined yet +// +Box.prototype.isUndefined = function () { + return (typeof this.minX === 'undefined') || (typeof this.minY === 'undefined'); +}; + +// add new X coordinate +// +Box.prototype.addX = function (x) { + if (typeof this.minX === 'undefined') { + this.minX = this.maxX = x; + this.width = 0; + } else { + this.minX = Math.min(this.minX, x); + this.maxX = Math.max(this.maxX, x); + this.width = this.maxX - this.minX; + } + + return this; +}; + +// add new Y coordinate +// +Box.prototype.addY = function (y) { + if (typeof this.minY === 'undefined') { + this.minY = this.maxY = y; + this.height = 0; + } else { + this.minY = Math.min(this.minY, y); + this.maxY = Math.max(this.maxY, y); + this.height = this.maxY - this.minY; + } + + return this; +}; + +// add new point +// +Box.prototype.addPoint = function (x, y) { + return this.addX(x).addY(y); +}; + + +// ------------------------------ +// return [min,max] +// of A[0] * (1-t) * (1-t) + A[1] * 2 * (1-t) * t + A[2] * t * t +// for t in [0,1] +// ------------------------------ +function minmaxQ(A) { + var min = Math.min(A[0], A[2]), + max = Math.max(A[0], A[2]); + + if (A[1] > A[0] ? A[2] >= A[1] : A[2] <= A[1]) { + // if no extremum in ]0,1[ + return [ min, max ]; + } + + // check if the extremum E is min or max + var E = (A[0] * A[2] - A[1] * A[1]) / (A[0] - 2 * A[1] + A[2]); + return E < min ? [ E, max ] : [ min, E ]; +} + +// add new quadratic curve to X coordinate +// +Box.prototype.addXQ = function (A) { + var minmax = minmaxQ(A); + + return this.addX(minmax[0]).addX(minmax[1]); +}; + +// add new quadratic curve to Y coordinate +// +Box.prototype.addYQ = function (A) { + var minmax = minmaxQ(A); + + return this.addY(minmax[0]).addY(minmax[1]); +}; + + +// ------------------------------ +// return [min,max] +// of A[0] * (1-t) * (1-t) * (1-t) + A[1] * 3 * (1-t) * (1-t) * t + A[2] * 3 * (1-t) * t * t + A[3] * t * t * t +// for t in [0,1] +// ------------------------------ +function minmaxC(A) { + // if the polynomial is (almost) quadratic and not cubic + var K = A[0] - 3 * A[1] + 3 * A[2] - A[3]; + if (Math.abs(K) < epsilon) { + return minmaxQ([ A[0], -0.5 * A[0] + 1.5 * A[1], A[0] - 3 * A[1] + 3 * A[2] ]); + } + + + // the reduced discriminant of the derivative + var T = -A[0] * A[2] + A[0] * A[3] - A[1] * A[2] - A[1] * A[3] + A[1] * A[1] + A[2] * A[2]; + + // if the polynomial is monotone in [0,1] + if (T <= 0) { + return [ Math.min(A[0], A[3]), Math.max(A[0], A[3]) ]; + } + var S = Math.sqrt(T); + + // potential extrema + var max = Math.max(A[0], A[3]), + min = Math.min(A[0], A[3]); + + var L = A[0] - 2 * A[1] + A[2]; + // check local extrema + for (var R = (L + S) / K, i = 1; i <= 2; R = (L - S) / K, i++) { + if (R > 0 && R < 1) { + // if the extrema is for R in [0,1] + var Q = A[0] * (1 - R) * (1 - R) * (1 - R) + + A[1] * 3 * (1 - R) * (1 - R) * R + + A[2] * 3 * (1 - R) * R * R + + A[3] * R * R * R; + if (Q < min) { min = Q; } + if (Q > max) { max = Q; } + } + } + + return [ min, max ]; +} + +// add new cubic curve to X coordinate +// +Box.prototype.addXC = function (A) { + var minmax = minmaxC(A); + + return this.addX(minmax[0]).addX(minmax[1]); +}; + +// add new cubic curve to Y coordinate +// +Box.prototype.addYC = function (A) { + var minmax = minmaxC(A); + + return this.addY(minmax[0]).addY(minmax[1]); +}; + +// return a string like '-10 10 300 400' +// +Box.prototype.toViewBoxString = function (pr) { + // if empty box + if (this.isUndefined()) { + return '0 0 0 0'; + } + + // else + return ((typeof pr === 'undefined') ? + [ this.minX, this.minY, this.width, this.height ] + : + [ + this.minX.toFixed(pr), this.minY.toFixed(pr), + this.width.toFixed(pr), this.height.toFixed(pr) + ] + ).join(' '); +}; + +// return the transform that translate and scale to fit in a box +// controlled by the following parameters : +// - type: +// - fit(=none) : scale the box (aspect ratio is not preserved) to fit in the box +// - meet (the default) : scale the box (aspect ratio is preserved) as much as possible +// to cover the destination box +// - slice : scale the box (aspect ratio is preserved) as less as possible to cover the destination box +// - move : translate only (no scale) the box according to x???y??? parameter +// - position x(Min|Mid|Max)Y(Min|Mid|Max). +// example : matrixToBox(src, '100 0 200 300 meet xMidYMin') +// +Box.prototype.matrixToBox = function (parameters) { + var dst = new Box(parameters.match(/(-|\d|\.|\s)+/)[0]); + + // get the action (default is 'meet') + var action = ((parameters + 'meet').match(/(fit|none|meet|slice|move)/))[0]; + + if (action === 'none') { // for compatibility with 'preserveAspectRatio' + action = 'fit'; + } + + // set the scale factors based on the action + var rx, ry; + switch (action) { + case 'fit': + rx = this.width ? dst.width / this.width : 1; + ry = this.height ? dst.height / this.height : 1; + break; + case 'slice' : + if (this.width !== 0 && this.height !== 0) { + rx = ry = Math.max(dst.width / this.width, dst.height / this.height); + break; + } + // else falls through + case 'meet' : + rx = ry = (this.width === 0 && this.height === 0) ? 1 : + Math.min(dst.width / this.width, dst.height / this.height); + break; + case 'move': + rx = ry = 1; + break; + } + + // get the position from string like 'xMidYMax' + var position = {}; + position.X = ((parameters + 'xMid').match(/x(Min|Mid|Max)/i))[1].toLowerCase(); + position.Y = ((parameters + 'YMid').match(/Y(Min|Mid|Max)/i))[1].toLowerCase(); + + // variable that helps to loop over the two boxes + var origin = {}, + box = {}; + box.src = this; + box.dst = dst; + + // set the 'origin' of the two boxes based on the position parameters + for (var c = 'X', i = 1; i <= 2; c = 'Y', i++) { + for (var b = 'src', j = 1; j <= 2; b = 'dst', j++) { + switch (position[c]) { + case 'min': + origin[b + c] = box[b]['min' + c]; + break; + case 'mid': + origin[b + c] = (box[b]['min' + c] + box[b]['max' + c]) / 2; + break; + case 'max': + origin[b + c] = box[b]['max' + c]; + break; + } + } + } + + // return the matrix that is equivalent to + // .translate(-box.src.originX,-box.src.originY) + // .scale(rx,ry) + // .translate(box.dst.originX,box.dst.originY); + return [ rx, 0, 0, ry, origin.dstX - rx * origin.srcX, origin.dstY - ry * origin.srcY ]; +}; + +module.exports = Box; diff --git a/lib/svgpath.js b/lib/svgpath.js index bc6d50e..6b57603 100644 --- a/lib/svgpath.js +++ b/lib/svgpath.js @@ -18,6 +18,7 @@ var transformParse = require('./transform_parse'); var matrix = require('./matrix'); var a2c = require('./a2c'); var ellipse = require('./ellipse'); +var box = require('./box'); // Class constructor @@ -38,6 +39,27 @@ function SvgPath(path) { this.__stack = []; } +// copy path +// +SvgPath.prototype.copy = function () { + var newP = new SvgPath(''); + var i; + + // copy segments + for (i = 0; i < this.segments.length; i++) { + newP.segments[i] = this.segments[i].slice(0); + } + + // copy err + newP.err = this.err; + + // copy __stack + for (i = 0; i < this.__stack.length; i++) { + newP.matrix(this.__stack[i].toArray()); + } + + return newP; +}; SvgPath.prototype.__matrix = function (m) { var self = this, @@ -211,7 +233,7 @@ SvgPath.prototype.matrix = function (m) { }; -// Transform path according to "transform" attr of SVG spec +// Transform path according to 'transform' attr of SVG spec // SvgPath.prototype.transform = function (transformString) { if (!transformString.trim()) { @@ -591,4 +613,67 @@ SvgPath.prototype.unshort = function () { }; +// return the bounding box of an absolute normalized path. +// normalized = without arc segments (A) and without reduced segments (S,T) +// +SvgPath.prototype.getBoundingBox = function () { + var bb = box(); + + if (this.segments.length === 0) { + return bb; + } + + var P = this.copy().abs().unarc().unshort(); + + P.iterate(function (s, i, x, y) { + switch (s[0]) { + case 'H': + bb.addX(s[1]); + break; + case 'V': + bb.addY(s[1]); + break; + case 'M': + case 'L': + bb.addX(s[1]); + bb.addY(s[2]); + break; + case 'Q': + bb.addXQ([ x, s[1], s[3] ]); + bb.addYQ([ y, s[2], s[4] ]); + break; + case 'C': + bb.addXC([ x, s[1], s[3], s[5] ]); + bb.addYC([ y, s[2], s[4], s[6] ]); + break; + } // end switch + }, true); // end iterate + + return bb; +}; + + +// return a string that can be used as a viewBox for the path +// +SvgPath.prototype.toViewBoxString = function (pr) { + return this.getBoundingBox().toViewBoxString(pr); +}; + +// translate and scale the path to fit in a box +// controlled by the following parameters : +// - type: +// - fit(=none) : scale the path (aspect ratio is not preserved) to fit the box +// - meet (the default) : scale the path (aspect ratio is preserved) as much as possible +// to fit the entire path in the box +// - slice : scale the path (aspect ratio is preserved) as less as possible to cover the box +// - move : translate only (no scale) the path according to x???y??? parameter +// - position x(Min|Mid|Max)Y(Min|Mid|Max). +// example : .toBox('-10 10 300 400 meet xMidYMin') +// +SvgPath.prototype.toBox = function (parameters) { + this.matrix(this.getBoundingBox().matrixToBox(parameters)); + + return this; +}; + module.exports = SvgPath; diff --git a/test/api.js b/test/api.js index 0db26bc..56d9c33 100644 --- a/test/api.js +++ b/test/api.js @@ -563,6 +563,36 @@ describe('API', function () { 'M25 25A15 15 0 0 1 50 50' ); }); + }); + + describe('bounding box', function () { + it('get the bounding box', function () { + assert.equal( + svgpath('M10,10 c 10,0 10,10 0,10 s -10,0 0,10 q 10,10 15 20 t 10,0 a25,25 -30 0,1 50,-25z').toViewBoxString(2), + '2.50 9.54 82.50 45.46' + ); + }); + it('matrix to fit in a box', function () { + assert.equal( + svgpath('M10,10 h10 v20').toBox('0 0 100 100').toViewBoxString(), + '25 0 50 100' + ); + + assert.equal( + svgpath('M10,10 h10 v20').toBox('0 0 100 100 slice xMinYMax').toViewBoxString(), + '0 -100 100 200' + ); + + assert.equal( + svgpath('M10,10 h10 v20').toBox('0 0 100 100 fit').toViewBoxString(), + '0 0 100 100' + ); + + assert.equal( + svgpath('M10,10 h10 v20').toBox('0 0 100 100 move xMaxYMid').toViewBoxString(), + '90 40 10 20' + ); + }); }); }); diff --git a/test/box.js b/test/box.js new file mode 100644 index 0000000..0c39409 --- /dev/null +++ b/test/box.js @@ -0,0 +1,127 @@ +'use strict'; + + +var assert = require('assert'); +var box = require('../lib/box'); + +var b, m; + +describe('Box', function () { + + it('default box is undefined with size 0 x 0', function () { + b = box(); + + assert(b.isUndefined()); + assert.equal(b.width, 0); + assert.equal(b.height, 0); + }); + + it('parse from string', function () { + b = box('-1 2 4 5'); + + assert.equal(b.minX, -1); + assert.equal(b.maxX, 3); + assert.equal(b.width, 4); + assert.equal(b.minY, 2); + assert.equal(b.maxY, 7); + assert.equal(b.height, 5); + }); + + it('add a point', function () { + b = box().addPoint(1, 1); + + assert.equal(b.minX, 1); + assert.equal(b.maxX, 1); + assert.equal(b.width, 0); + assert.equal(b.minY, 1); + assert.equal(b.maxY, 1); + assert.equal(b.height, 0); + + b.addX(2); + + assert.equal(b.minX, 1); + assert.equal(b.maxX, 2); + assert.equal(b.width, 1); + assert.equal(b.minY, 1); + assert.equal(b.maxY, 1); + assert.equal(b.height, 0); + + b.addY(3); + + assert.equal(b.minX, 1); + assert.equal(b.maxX, 2); + assert.equal(b.width, 1); + assert.equal(b.minY, 1); + assert.equal(b.maxY, 3); + assert.equal(b.height, 2); + + b.addPoint(4, -5); + + assert.equal(b.minX, 1); + assert.equal(b.maxX, 4); + assert.equal(b.width, 3); + assert.equal(b.minY, -5); + assert.equal(b.maxY, 3); + assert.equal(b.height, 8); + }); + + it('add quadratic curve', function () { + b = box().addXQ([ 0, 3, 1 ]); + + assert.equal(b.minX, 0); + assert.equal(b.maxX, 1.8); + assert.equal(b.width, 1.8); + + b = box().addYQ([ 0, -2, 1 ]); + + assert.equal(b.minY, -0.8); + assert.equal(b.maxY, 1); + assert.equal(b.height, 1.8); + }); + + it('add cubic curve', function () { + b = box().addXC([ 0, -70, 210, 100 ]); + + assert.equal(Math.round(b.minX), -11); + assert.equal(Math.round(b.maxX), 126); + assert.equal(Math.round(b.width), 137); + + b = box().addYC([ 0, 1, 2, 3 ]); + + assert.equal(b.minY, 0); + assert.equal(b.maxY, 3); + assert.equal(b.height, 3); + }); + + it('view box', function () { + b = box().addXC([ 0, -70, 210, 100 ]).addYC([ 0, -30, 70, 40 ]); + + assert.equal(b.toViewBoxString(0), '-11 -6 137 51'); + + b = box('-10 20 30 50'); + + assert.equal(b.minX, -10); + assert.equal(b.maxX, 20); + assert.equal(b.width, 30); + assert.equal(b.minY, 20); + assert.equal(b.maxY, 70); + assert.equal(b.height, 50); + }); + + it('matrix to put in a box', function () { + b = box('-10 0 40 50'); + + m = b.matrixToBox('0 0 100 200'); // default is meet xMidYMid + assert.deepEqual(m, [ 2.5, 0, 0, 2.5, 25, 37.5 ]); + + m = b.matrixToBox('0 0 100 200 slice xMinYMax'); + assert.deepEqual(m, [ 4, 0, 0, 4, 40, 0 ]); + + m = b.matrixToBox('0 0 100 200 fit'); + assert.deepEqual(m, [ 2.5, 0, 0, 4, 25, 0 ]); + + m = b.matrixToBox('0 0 100 200 move xMinYmid'); + assert.deepEqual(m, [ 1, 0, 0, 1, 10, 75 ]); + }); + +});