UNPKG

svgdom

Version:

Straightforward DOM implementation for SVG, HTML and XML

763 lines (618 loc) 19.9 kB
import { Box, NoBox } from '../other/Box.js' import { Point } from '../other/Point.js' import * as regex from './regex.js' // TODO: use own matrix implementation import { matrixFactory } from './../dom/svg/SVGMatrix.js' import { PointCloud } from './PointCloud.js' const pathHandlers = { M (c, p, r, p0) { p.x = p0.x = c[0] p.y = p0.y = c[1] return new Move(p) }, L (c, p) { const ret = new Line(p.x, p.y, c[0], c[1])// .offset(o) p.x = c[0] p.y = c[1] return ret }, H (c, p) { return pathHandlers.L([ c[0], p.y ], p) }, V (c, p) { return pathHandlers.L([ p.x, c[0] ], p) }, Q (c, p, r) { const ret = Cubic.fromQuad(p, new Point(c[0], c[1]), new Point(c[2], c[3]))// .offset(o) p.x = c[2] p.y = c[3] const reflect = new Point(c[0], c[1]).reflectAt(p) r.x = reflect.x r.y = reflect.y return ret }, T (c, p, r, p0, reflectionIsPossible) { if (reflectionIsPossible) { c = [ r.x, r.y ].concat(c) } else { c = [ p.x, p.y ].concat(c) } return pathHandlers.Q(c, p, r) }, C (c, p, r) { const ret = new Cubic(p, new Point(c[0], c[1]), new Point(c[2], c[3]), new Point(c[4], c[5]))// .offset(o) p.x = c[4] p.y = c[5] const reflect = new Point(c[2], c[3]).reflectAt(p) r.x = reflect.x r.y = reflect.y return ret }, S (c, p, r, p0, reflectionIsPossible) { // reflection makes only sense if this command was preceeded by another beziere command (QTSC) if (reflectionIsPossible) { c = [ r.x, r.y ].concat(c) } else { c = [ p.x, p.y ].concat(c) } return pathHandlers.C(c, p, r) }, Z (c, p, r, p0) { // FIXME: The behavior of Z depends on the command before return pathHandlers.L([ p0.x, p0.y ], p) }, A (c, p, _r) { const ret = new Arc(p, new Point(c[5], c[6]), c[0], c[1], c[2], c[3], c[4]) p.x = c[5] p.y = c[6] return ret } } const mlhvqtcsa = 'mlhvqtcsaz'.split('') for (let i = 0, il = mlhvqtcsa.length; i < il; ++i) { pathHandlers[mlhvqtcsa[i]] = (function (i) { return function (c, p, r, p0, reflectionIsPossible) { if (i === 'H') c[0] = c[0] + p.x else if (i === 'V') c[0] = c[0] + p.y else if (i === 'A') { c[5] = c[5] + p.x c[6] = c[6] + p.y } else { for (let j = 0, jl = c.length; j < jl; ++j) { c[j] = c[j] + (j % 2 ? p.y : p.x) } } return pathHandlers[i](c, p, r, p0, reflectionIsPossible) } })(mlhvqtcsa[i].toUpperCase()) } function pathRegReplace (a, b, c, d) { return c + d.replace(regex.dots, ' .') } function isBeziere (obj) { return obj instanceof Cubic } export const pathParser = (array) => { if (!array) return [] // prepare for parsing const paramCnt = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7, Z: 0 } array = array .replace(regex.numbersWithDots, pathRegReplace) // convert 45.123.123 to 45.123 .123 .replace(regex.pathLetters, ' $& ') // put some room between letters and numbers .replace(regex.hyphen, '$1 -') // add space before hyphen .trim() // trim .split(regex.delimiter) // split into array // array now is an array containing all parts of a path e.g. ['M', '0', '0', 'L', '30', '30' ...] const arr = [] const p = new Point() const p0 = new Point() const r = new Point() let index = 0 const len = array.length let s do { // Test if we have a path letter if (regex.isPathLetter.test(array[index])) { s = array[index] ++index // If last letter was a move command and we got no new, it defaults to [L]ine } else if (s === 'M') { s = 'L' } else if (s === 'm') { s = 'l' } arr.push( pathHandlers[s].call(null, array.slice(index, (index = index + paramCnt[s.toUpperCase()])).map(parseFloat), p, r, p0, isBeziere(arr[arr.length - 1]) ) ) } while (len > index) return arr } class Move { constructor (p) { this.p1 = p.clone() } // FIXME: Use pointcloud bbox () { const p = this.p1 return new Box(p.x, p.y, 0, 0) } getCloud () { return new PointCloud([ this.p1 ]) } length () { return 0 } toPath () { return [ 'M', this.p1.x, this.p1.y ].join(' ') } toPathFragment () { return [ 'M', this.p1.x, this.p1.y ] } transform (matrix) { this.p1.transformO(matrix) return this } } export class Arc { constructor (p1, p2, rx, ry, φ, arc, sweep) { // https://www.w3.org/TR/SVG/implnote.html#ArcCorrectionOutOfRangeRadii if (!rx || !ry) return new Line(p1, p2) rx = Math.abs(rx) ry = Math.abs(ry) this.p1 = p1.clone() this.p2 = p2.clone() this.arc = arc ? 1 : 0 this.sweep = sweep ? 1 : 0 // Calculate cos and sin of angle phi const cosφ = Math.cos(φ / 180 * Math.PI) const sinφ = Math.sin(φ / 180 * Math.PI) // https://www.w3.org/TR/SVG/implnote.html#ArcConversionEndpointToCenter // (eq. 5.1) const p1_ = new Point( (p1.x - p2.x) / 2, (p1.y - p2.y) / 2 ).transform(matrixFactory( cosφ, -sinφ, sinφ, cosφ, 0, 0 )) // (eq. 6.2) // Make sure the radius fit with the arc and correct if neccessary const ratio = (p1_.x ** 2 / rx ** 2) + (p1_.y ** 2 / ry ** 2) // (eq. 6.3) if (ratio > 1) { rx = Math.sqrt(ratio) * rx ry = Math.sqrt(ratio) * ry } // (eq. 5.2) const rxQuad = rx ** 2 const ryQuad = ry ** 2 const divisor1 = rxQuad * p1_.y ** 2 const divisor2 = ryQuad * p1_.x ** 2 const dividend = (rxQuad * ryQuad - divisor1 - divisor2) let c_ if (Math.abs(dividend) < 1e-15) { c_ = new Point(0, 0) } else { c_ = new Point( rx * p1_.y / ry, -ry * p1_.x / rx ).mul(Math.sqrt( dividend / (divisor1 + divisor2) )) } if (this.arc === this.sweep) c_ = c_.mul(-1) // (eq. 5.3) const c = c_.transform(matrixFactory( cosφ, sinφ, -sinφ, cosφ, 0, 0 )).add(new Point( (p1.x + p2.x) / 2, (p1.y + p2.y) / 2 )) const anglePoint = new Point( (p1_.x - c_.x) / rx, (p1_.y - c_.y) / ry ) /* For eq. 5.4 see angleTo function */ // (eq. 5.5) const θ = new Point(1, 0).angleTo(anglePoint) // (eq. 5.6) let Δθ = anglePoint.angleTo(new Point( (-p1_.x - c_.x) / rx, (-p1_.y - c_.y) / ry )) Δθ = (Δθ % (2 * Math.PI)) if (!sweep && Δθ > 0) Δθ -= 2 * Math.PI if (sweep && Δθ < 0) Δθ += 2 * Math.PI this.c = c this.theta = θ * 180 / Math.PI this.theta2 = (θ + Δθ) * 180 / Math.PI this.delta = Δθ * 180 / Math.PI this.rx = rx this.ry = ry this.phi = φ this.cosφ = cosφ this.sinφ = sinφ } static fromCenterForm (c, rx, ry, φ, θ, Δθ) { const cosφ = Math.cos(φ / 180 * Math.PI) const sinφ = Math.sin(φ / 180 * Math.PI) const m = matrixFactory(cosφ, sinφ, -sinφ, cosφ, 0, 0) const p1 = new Point( rx * Math.cos(θ / 180 * Math.PI), ry * Math.sin(θ / 180 * Math.PI) ).transform(m).add(c) const p2 = new Point( rx * Math.cos((θ + Δθ) / 180 * Math.PI), ry * Math.sin((θ + Δθ) / 180 * Math.PI) ).transform(m).add(c) const arc = Math.abs(Δθ) > 180 ? 1 : 0 const sweep = Δθ > 0 ? 1 : 0 return new Arc(p1, p2, rx, ry, φ, arc, sweep) } bbox () { const cloud = this.getCloud() return cloud.bbox() } clone () { return new Arc(this.p1, this.p2, this.rx, this.ry, this.phi, this.arc, this.sweep) } getCloud () { if (this.p1.equals(this.p2)) return new PointCloud([ this.p1 ]) // arc could be rotated. the min and max values then dont lie on multiples of 90 degress but are shifted by the rotation angle // so we first calculate our 0/90 degree angle let θ01 = Math.atan(-this.sinφ / this.cosφ * this.ry / this.rx) * 180 / Math.PI let θ02 = Math.atan(this.cosφ / this.sinφ * this.ry / this.rx) * 180 / Math.PI let θ1 = this.theta let θ2 = this.theta2 if1 < 0 || θ2 < 0) { θ1 += 360 θ2 += 360 } if2 < θ1) { const temp = θ1 θ1 = θ2 θ2 = temp } while01 - 90 > θ01) θ01 -= 90 while01 < θ1) θ01 += 90 while02 - 90 > θ02) θ02 -= 90 while02 < θ1) θ02 += 90 const angleToTest = [ θ01, θ02, (θ01 + 90), (θ02 + 90), (θ01 + 180), (θ02 + 180), (θ01 + 270), (θ02 + 270) ] const points = angleToTest.filter(function (angle) { return (angle > θ1 && angle < θ2) }).map(function (angle) { while (this.theta < angle) angle -= 360 return this.pointAt(((angle - this.theta) % 360) / (this.delta)) // TODO: replace that call with pointAtAngle }.bind(this)).concat(this.p1, this.p2) return new PointCloud(points) } length () { if (this.p1.equals(this.p2)) return 0 const length = this.p2.sub(this.p1).abs() const ret = this.splitAt(0.5) const len1 = ret[0].p2.sub(ret[0].p1).abs() const len2 = ret[1].p2.sub(ret[1].p1).abs() if (len1 + len2 - length < 0.00001) { return len1 + len2 } return ret[0].length() + ret[1].length() } pointAt (t) { if (this.p1.equals(this.p2)) return this.p1.clone() const tInAngle = (this.theta + t * this.delta) / 180 * Math.PI const sinθ = Math.sin(tInAngle) const cosθ = Math.cos(tInAngle) return new Point( this.cosφ * this.rx * cosθ - this.sinφ * this.ry * sinθ + this.c.x, this.sinφ * this.ry * cosθ + this.cosφ * this.rx * sinθ + this.c.y ) } splitAt (t) { const absDelta = Math.abs(this.delta) const delta1 = absDelta * t const delta2 = absDelta * (1 - t) const pointAtT = this.pointAt(t) return [ new Arc(this.p1, pointAtT, this.rx, this.ry, this.phi, delta1 > 180, this.sweep), new Arc(pointAtT, this.p2, this.rx, this.ry, this.phi, delta2 > 180, this.sweep) ] } toPath () { return [ 'M', this.p1.x, this.p1.y, 'A', this.rx, this.ry, this.phi, this.arc, this.sweep, this.p2.x, this.p2.y ].join(' ') } toPathFragment () { return [ 'A', this.rx, this.ry, this.phi, this.arc, this.sweep, this.p2.x, this.p2.y ] } toString () { return `p1: ${this.p1.x.toFixed(4)} ${this.p1.y.toFixed(4)}, p2: ${this.p2.x.toFixed(4)} ${this.p2.y.toFixed(4)}, c: ${this.c.x.toFixed(4)} ${this.c.y.toFixed(4)} theta: ${this.theta.toFixed(4)}, theta2: ${this.theta2.toFixed(4)}, delta: ${this.delta.toFixed(4)}, large: ${this.arc}, sweep: ${this.sweep}` } transform (matrix) { return new Arc(this.p1.transform(matrix), this.p2.transform(matrix), this.rx, this.ry, this.phi, this.arc, this.sweep) } } class Cubic { constructor (p1, c1, c2, p2) { if (p1 instanceof Point) { this.p1 = new Point(p1) this.c1 = new Point(c1) this.c2 = new Point(c2) this.p2 = new Point(p2) } else { this.p1 = new Point(p1.p1) this.c1 = new Point(p1.c1) this.c2 = new Point(p1.c2) this.p2 = new Point(p1.p2) } } static fromQuad (p1, c, p2) { const c1 = p1.mul(1 / 3).add(c.mul(2 / 3)) const c2 = c.mul(2 / 3).add(p2.mul(1 / 3)) return new Cubic(p1, c1, c2, p2) } bbox () { return this.getCloud().bbox() } findRoots () { return this.findRootsX().concat(this.findRootsY()) } findRootsX () { return this.findRootsXY(this.p1.x, this.c1.x, this.c2.x, this.p2.x) } findRootsXY (p1, p2, p3, p4) { const a = 3 * (-p1 + 3 * p2 - 3 * p3 + p4) const b = 6 * (p1 - 2 * p2 + p3) const c = 3 * (p2 - p1) if (a === 0) return [ -c / b ].filter(function (el) { return el > 0 && el < 1 }) if (b * b - 4 * a * c < 0) return [] if (b * b - 4 * a * c === 0) return [ Math.round((-b / (2 * a)) * 100000) / 100000 ].filter(function (el) { return el > 0 && el < 1 }) return [ Math.round((-b + Math.sqrt(b * b - 4 * a * c)) / (2 * a) * 100000) / 100000, Math.round((-b - Math.sqrt(b * b - 4 * a * c)) / (2 * a) * 100000) / 100000 ].filter(function (el) { return el > 0 && el < 1 }) } findRootsY () { return this.findRootsXY(this.p1.y, this.c1.y, this.c2.y, this.p2.y) } flatness () { let ux = Math.pow(3 * this.c1.x - 2 * this.p1.x - this.p2.x, 2) let uy = Math.pow(3 * this.c1.y - 2 * this.p1.y - this.p2.y, 2) const vx = Math.pow(3 * this.c2.x - 2 * this.p2.x - this.p1.x, 2) const vy = Math.pow(3 * this.c2.y - 2 * this.p2.y - this.p1.y, 2) if (ux < vx) { ux = vx } if (uy < vy) { uy = vy } return ux + uy } getCloud () { const points = this.findRoots() .filter(root => root !== 0 && root !== 1) .map(root => this.pointAt(root)) .concat(this.p1, this.p2) return new PointCloud(points) } length () { return this.lengthAt() } lengthAt (t = 1) { const curves = this.splitAt(t)[0].makeFlat(t) let length = 0 for (let i = 0, len = curves.length; i < len; ++i) { length += curves[i].p2.sub(curves[i].p1).abs() } return length } makeFlat (t) { if (this.flatness() > 0.15) { return this.splitAt(0.5) .map(function (el) { return el.makeFlat(t * 0.5) }) .reduce(function (last, current) { return last.concat(current) }, []) } else { this.t_value = t return [ this ] } } pointAt (t) { return new Point( (1 - t) * (1 - t) * (1 - t) * this.p1.x + 3 * (1 - t) * (1 - t) * t * this.c1.x + 3 * (1 - t) * t * t * this.c2.x + t * t * t * this.p2.x, (1 - t) * (1 - t) * (1 - t) * this.p1.y + 3 * (1 - t) * (1 - t) * t * this.c1.y + 3 * (1 - t) * t * t * this.c2.y + t * t * t * this.p2.y ) } splitAt (z) { const x = this.splitAtScalar(z, 'x') const y = this.splitAtScalar(z, 'y') const a = new Cubic( new Point(x[0][0], y[0][0]), new Point(x[0][1], y[0][1]), new Point(x[0][2], y[0][2]), new Point(x[0][3], y[0][3]) ) const b = new Cubic( new Point(x[1][0], y[1][0]), new Point(x[1][1], y[1][1]), new Point(x[1][2], y[1][2]), new Point(x[1][3], y[1][3]) ) return [ a, b ] } splitAtScalar (z, p) { const p1 = this.p1[p] const p2 = this.c1[p] const p3 = this.c2[p] const p4 = this.p2[p] const t = z * z * z * p4 - 3 * z * z * (z - 1) * p3 + 3 * z * (z - 1) * (z - 1) * p2 - (z - 1) * (z - 1) * (z - 1) * p1 return [ [ p1, z * p2 - (z - 1) * p1, z * z * p3 - 2 * z * (z - 1) * p2 + (z - 1) * (z - 1) * p1, t ], [ t, z * z * p4 - 2 * z * (z - 1) * p3 + (z - 1) * (z - 1) * p2, z * p4 - (z - 1) * p3, p4 ] ] } toPath () { return [ 'M', this.p1.x, this.p1.y ].concat(this.toPathFragment()).join(' ') } toPathFragment () { return [ 'C', this.c1.x, this.c1.y, this.c2.x, this.c2.y, this.p2.x, this.p2.y ] } transform (matrix) { this.p1.transformO(matrix) this.c1.transformO(matrix) this.c2.transformO(matrix) this.p2.transformO(matrix) return this } } class Line { constructor (x1, y1, x2, y2) { if (x1 instanceof Object) { this.p1 = new Point(x1) this.p2 = new Point(y1) } else { this.p1 = new Point(x1, y1) this.p2 = new Point(x2, y2) } } bbox () { return this.getCloud().bbox() } getCloud () { return new PointCloud([ this.p1, this.p2 ]) } length () { return this.p2.sub(this.p1).abs() } pointAt (t) { const vec = this.p2.sub(this.p1).mul(t) return this.p1.add(vec) } toPath () { return [ 'M', this.p1.x, this.p1.y, this.p2.x, this.p2.y ].join(' ') } toPathFragment () { return [ 'L', this.p2.x, this.p2.y ] } transform (matrix) { this.p1.transformO(matrix) this.p2.transformO(matrix) return this } } export const pathBBox = function (d) { return pathParser(d).reduce((l, c) => l.merge(c.bbox()), new NoBox()) } export class PathSegmentArray extends Array { bbox () { return this.reduce((l, c) => l.merge(c.bbox()), new NoBox()) } cloud () { return this.reduce( (cloud, segment) => segment.getCloud().merge(cloud), new PointCloud() ) } merge (other) { return this.concat(other) } transform (matrix) { return this.map(segment => segment.transform(matrix)) } } export const getPathSegments = function (d) { return new PathSegmentArray(...pathParser(d)) } export const pointAtLength = function (d, len) { const segs = pathParser(d) const segLengths = segs.map(el => el.length()) const length = segLengths.reduce((l, c) => l + c, 0) let i = 0 let t = len / length // FIXME: Pop Move before using shortcut? // shortcut for trivial cases if (t >= 1) { // Check if there is a p2. If not, use p1 if (segs[segs.length - 1].p2) { return segs[segs.length - 1].p2.native() } else { return segs[segs.length - 1].p1.native() } } if (t <= 0) return segs[0].p1.native() // remove move commands at the very end of the path while (segs[segs.length - 1] instanceof Move) segs.pop() let segEnd = 0 for (const il = segLengths.length; i < il; ++i) { const k = segLengths[i] / length segEnd += k if (segEnd > t) { break } } const ratio = length / segLengths[i] t = ratio * (t - segEnd) + 1 return segs[i].pointAt(t).native() } export const length = function (d) { return pathParser(d) .reduce((l, c) => l + c.length(), 0) } export const debug = function (node) { const parse = pathParser(node.getAttribute('d')) const ret = { paths: parse.map(el => el.toPath()), fragments: parse.map(el => el.toPathFragment().join(' ')), bboxs: parse.map(el => { const box = el.bbox() return [ box.x, box.y, box.width, box.height ] }), bbox: parse.reduce((l, c) => l.merge(c.bbox()), new NoBox()), bboxsTransformed: parse.map(el => { return el.getCloud().transform(node.matrixify()).bbox() }) } return Object.assign({}, ret, { bboxTransformed: ret.bboxsTransformed.reduce((l, c) => l.merge(c), new NoBox()) }) } export const getCloud = (d) => { return pathParser(d).reduce((cloud, segment) => segment.getCloud().merge(cloud), new PointCloud() ) } export const pathFrom = { box ({ x, y, width, height }) { return `M ${x} ${y} h ${width} v ${height} H ${x} V ${y}` }, rect (node) { const width = parseFloat(node.getAttribute('width')) || 0 const height = parseFloat(node.getAttribute('height')) || 0 const x = parseFloat(node.getAttribute('x')) || 0 const y = parseFloat(node.getAttribute('y')) || 0 return `M ${x} ${y} h ${width} v ${height} H ${x} V ${y}` }, circle (node) { const r = parseFloat(node.getAttribute('r')) || 0 const x = parseFloat(node.getAttribute('cx')) || 0 const y = parseFloat(node.getAttribute('cy')) || 0 if (r === 0) return 'M0 0' return `M ${x - r} ${y} A ${r} ${r} 0 0 0 ${x + r} ${y} A ${r} ${r} 0 0 0 ${x - r} ${y}` }, ellipse (node) { const rx = parseFloat(node.getAttribute('rx')) || 0 const ry = parseFloat(node.getAttribute('ry')) || 0 const x = parseFloat(node.getAttribute('cx')) || 0 const y = parseFloat(node.getAttribute('cy')) || 0 return `M ${x - rx} ${y} A ${rx} ${ry} 0 0 0 ${x + rx} ${y} A ${rx} ${ry} 0 0 0 ${x - rx} ${y}` }, line (node) { const x1 = parseFloat(node.getAttribute('x1')) || 0 const x2 = parseFloat(node.getAttribute('x2')) || 0 const y1 = parseFloat(node.getAttribute('y1')) || 0 const y2 = parseFloat(node.getAttribute('y2')) || 0 return `M ${x1} ${y1} L ${x2} ${y2}` }, polygon (node) { return `M ${node.getAttribute('points')} z` }, polyline (node) { return `M ${node.getAttribute('points')}` } }