path2d
Version:
Path2D API for node. Can be used for server-side rendering with canvas
799 lines (795 loc) • 24.5 kB
JavaScript
// src/parse-path.ts
var ARG_LENGTH = {
a: 7,
// arc: rx, ry, x-axis-rotation, large-arc-flag, sweep-flag, x, y
c: 6,
// cubic curve: x1, y1, x2, y2, x, y
h: 1,
// horizontal line: x
l: 2,
// line: x, y
m: 2,
// move: x, y
q: 4,
// quadratic curve: x1, y1, x, y
s: 4,
// smooth cubic curve: x2, y2, x, y
t: 2,
// smooth quadratic curve: x, y
v: 1,
// vertical line: y
z: 0
// close path: no arguments
};
var SEGMENT_PATTERN = /([astvzqmhlc])([^astvzqmhlc]*)/gi;
var NUMBER = /-?[0-9]*\.?[0-9]+(?:e[-+]?\d+)?/gi;
function parseValues(args) {
const numbers = args.match(NUMBER);
return numbers ? numbers.map(Number) : [];
}
function parsePath(path) {
const data = [];
const p = String(path).trim();
if (p[0] !== "M" && p[0] !== "m") {
return data;
}
p.replace(SEGMENT_PATTERN, (_, command, args) => {
const theArgs = parseValues(args);
let type = command.toLowerCase();
let theCommand = command;
if (type === "m" && theArgs.length > 2) {
data.push([theCommand, ...theArgs.splice(0, 2)]);
type = "l";
theCommand = theCommand === "m" ? "l" : "L";
}
if (theArgs.length < ARG_LENGTH[type]) {
return "";
}
data.push([theCommand, ...theArgs.splice(0, ARG_LENGTH[type])]);
while (theArgs.length >= ARG_LENGTH[type] && theArgs.length && ARG_LENGTH[type]) {
data.push([theCommand, ...theArgs.splice(0, ARG_LENGTH[type])]);
}
return "";
});
return data;
}
// src/path2d.ts
function rotatePoint(point, angle) {
const nx = point.x * Math.cos(angle) - point.y * Math.sin(angle);
const ny = point.y * Math.cos(angle) + point.x * Math.sin(angle);
point.x = nx;
point.y = ny;
}
function translatePoint(point, dx, dy) {
point.x += dx;
point.y += dy;
}
function scalePoint(point, s) {
point.x *= s;
point.y *= s;
}
var Path2D = class _Path2D {
/** Internal storage for path commands */
#commands;
/**
* Creates a new Path2D object.
*
* @param path - Optional path to initialize from. Can be another Path2D object or an SVG path string
*
* @example
* ```typescript
* // Empty path
* const path1 = new Path2D();
*
* // From SVG path string
* const path2 = new Path2D("M10,10 L100,100 Z");
*
* // Copy from another Path2D
* const path3 = new Path2D(path1);
* ```
*/
constructor(path) {
this.#commands = [];
if (path && path instanceof _Path2D) {
this.#commands.push(...path.#commands);
} else if (path) {
this.#commands = parsePath(path);
}
}
/**
* Adds a custom command to the path's command list.
* This is primarily used internally for extending functionality.
*
* @param command - The path command to add
*/
addCustomCommand(command) {
this.#commands.push(command);
}
/**
* Adds the commands from another Path2D object to this path.
*
* @param path - The Path2D object whose commands should be added to this path
*
* @example
* ```typescript
* const path1 = new Path2D("M10,10 L20,20");
* const path2 = new Path2D("L30,30 Z");
* path1.addPath(path2); // path1 now contains both sets of commands
* ```
*/
addPath(path) {
if (path && path instanceof _Path2D) {
this.#commands.push(...path.#commands);
}
}
/**
* Moves the starting point of a new sub-path to the specified coordinates.
*
* @param x - The x-coordinate of the new starting point
* @param y - The y-coordinate of the new starting point
*
* @example
* ```typescript
* const path = new Path2D();
* path.moveTo(10, 10);
* ```
*/
moveTo(x, y) {
this.#commands.push(["M", x, y]);
}
/**
* Connects the last point in the current sub-path to the specified coordinates with a straight line.
*
* @param x - The x-coordinate of the end point
* @param y - The y-coordinate of the end point
*
* @example
* ```typescript
* const path = new Path2D();
* path.moveTo(10, 10);
* path.lineTo(100, 100);
* ```
*/
lineTo(x, y) {
this.#commands.push(["L", x, y]);
}
/**
* Adds a circular arc to the current path.
*
* @param x - The x-coordinate of the arc's center
* @param y - The y-coordinate of the arc's center
* @param radius - The arc's radius
* @param startAngle - The starting angle in radians
* @param endAngle - The ending angle in radians
* @param counterclockwise - Whether the arc should be drawn counterclockwise (default: false)
*
* @example
* ```typescript
* const path = new Path2D();
* path.arc(50, 50, 25, 0, Math.PI * 2); // Full circle
* path.arc(100, 100, 30, 0, Math.PI, true); // Half circle, counterclockwise
* ```
*/
arc(x, y, radius, startAngle, endAngle, counterclockwise) {
this.#commands.push(["AC", x, y, radius, startAngle, endAngle, !!counterclockwise]);
}
/**
* Adds an arc to the current path with the given control points and radius.
*
* @param x1 - The x-coordinate of the first control point
* @param y1 - The y-coordinate of the first control point
* @param x2 - The x-coordinate of the second control point
* @param y2 - The y-coordinate of the second control point
* @param r - The arc's radius
*
* @example
* ```typescript
* const path = new Path2D();
* path.moveTo(20, 20);
* path.arcTo(100, 20, 100, 100, 50);
* ```
*/
arcTo(x1, y1, x2, y2, r) {
this.#commands.push(["AT", x1, y1, x2, y2, r]);
}
/**
* Adds an elliptical arc to the current path.
*
* @param x - The x-coordinate of the ellipse's center
* @param y - The y-coordinate of the ellipse's center
* @param radiusX - The ellipse's major-axis radius
* @param radiusY - The ellipse's minor-axis radius
* @param rotation - The rotation angle of the ellipse in radians
* @param startAngle - The starting angle in radians
* @param endAngle - The ending angle in radians
* @param counterclockwise - Whether the arc should be drawn counterclockwise (default: false)
*
* @example
* ```typescript
* const path = new Path2D();
* path.ellipse(50, 50, 30, 20, Math.PI / 4, 0, Math.PI * 2);
* ```
*/
ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle, counterclockwise) {
this.#commands.push(["E", x, y, radiusX, radiusY, rotation, startAngle, endAngle, !!counterclockwise]);
}
/**
* Closes the current sub-path by connecting the last point to the first point with a straight line.
*
* @example
* ```typescript
* const path = new Path2D();
* path.moveTo(10, 10);
* path.lineTo(100, 10);
* path.lineTo(100, 100);
* path.closePath(); // Creates a triangle
* ```
*/
closePath() {
this.#commands.push(["Z"]);
}
/**
* Adds a cubic Bézier curve to the current path.
*
* @param cp1x - The x-coordinate of the first control point
* @param cp1y - The y-coordinate of the first control point
* @param cp2x - The x-coordinate of the second control point
* @param cp2y - The y-coordinate of the second control point
* @param x - The x-coordinate of the end point
* @param y - The y-coordinate of the end point
*
* @example
* ```typescript
* const path = new Path2D();
* path.moveTo(20, 20);
* path.bezierCurveTo(20, 100, 200, 100, 200, 20);
* ```
*/
bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) {
this.#commands.push(["C", cp1x, cp1y, cp2x, cp2y, x, y]);
}
/**
* Adds a quadratic Bézier curve to the current path.
*
* @param cpx - The x-coordinate of the control point
* @param cpy - The y-coordinate of the control point
* @param x - The x-coordinate of the end point
* @param y - The y-coordinate of the end point
*
* @example
* ```typescript
* const path = new Path2D();
* path.moveTo(20, 20);
* path.quadraticCurveTo(100, 100, 200, 20);
* ```
*/
quadraticCurveTo(cpx, cpy, x, y) {
this.#commands.push(["Q", cpx, cpy, x, y]);
}
/**
* Adds a rectangle to the current path.
*
* @param x - The x-coordinate of the rectangle's top-left corner
* @param y - The y-coordinate of the rectangle's top-left corner
* @param width - The rectangle's width
* @param height - The rectangle's height
*
* @example
* ```typescript
* const path = new Path2D();
* path.rect(10, 10, 100, 50);
* ```
*/
rect(x, y, width, height) {
this.#commands.push(["R", x, y, width, height]);
}
/**
* Adds a rounded rectangle to the current path.
*
* @param x - The x-coordinate of the rectangle's top-left corner
* @param y - The y-coordinate of the rectangle's top-left corner
* @param w - The rectangle's width
* @param h - The rectangle's height
* @param radii - The corner radii. Can be a number, DOMPointInit, or array of up to 4 values
*
* @example
* ```typescript
* const path = new Path2D();
* path.roundRect(10, 10, 100, 50, 10); // All corners with radius 10
* path.roundRect(10, 70, 100, 50, [10, 20]); // Different horizontal/vertical radii
* path.roundRect(10, 130, 100, 50, [5, 10, 15, 20]); // Each corner different
* ```
*/
roundRect(x, y, w, h, radii) {
if (typeof radii === "undefined") {
this.#commands.push(["RR", x, y, w, h, 0]);
} else {
this.#commands.push(["RR", x, y, w, h, radii]);
}
}
/**
* Builds the path in a canvas rendering context by executing all stored commands.
* This method translates the internal path commands into actual canvas drawing operations.
*
* @param ctx - The canvas rendering context to draw the path in
*
* @internal This method is primarily used internally by the polyfill system
* to render Path2D objects on contexts that don't natively support them.
*/
buildPathInCanvas(ctx) {
let x = 0;
let y = 0;
let endAngle;
let startAngle;
let largeArcFlag;
let sweepFlag;
let endPoint;
let midPoint;
let angle;
let lambda;
let t1;
let t2;
let x1;
let y1;
let r;
let rx;
let ry;
let w;
let h;
let pathType;
let centerPoint;
let ccw;
let radii;
let cpx = null;
let cpy = null;
let qcpx = null;
let qcpy = null;
let startPoint = null;
let currentPoint = null;
ctx.beginPath();
for (let i = 0; i < this.#commands.length; ++i) {
pathType = this.#commands[i][0];
if (pathType !== "S" && pathType !== "s" && pathType !== "C" && pathType !== "c") {
cpx = null;
cpy = null;
}
if (pathType !== "T" && pathType !== "t" && pathType !== "Q" && pathType !== "q") {
qcpx = null;
qcpy = null;
}
let c;
switch (pathType) {
case "m":
case "M":
c = this.#commands[i];
if (pathType === "m") {
x += c[1];
y += c[2];
} else {
x = c[1];
y = c[2];
}
if (pathType === "M" || !startPoint) {
startPoint = { x, y };
}
ctx.moveTo(x, y);
break;
case "l":
c = this.#commands[i];
x += c[1];
y += c[2];
ctx.lineTo(x, y);
break;
case "L":
c = this.#commands[i];
x = c[1];
y = c[2];
ctx.lineTo(x, y);
break;
case "H":
c = this.#commands[i];
x = c[1];
ctx.lineTo(x, y);
break;
case "h":
c = this.#commands[i];
x += c[1];
ctx.lineTo(x, y);
break;
case "V":
c = this.#commands[i];
y = c[1];
ctx.lineTo(x, y);
break;
case "v":
c = this.#commands[i];
y += c[1];
ctx.lineTo(x, y);
break;
case "a":
case "A":
c = this.#commands[i];
if (currentPoint === null) {
throw new Error("This should never happen");
}
if (pathType === "a") {
x += c[6];
y += c[7];
} else {
x = c[6];
y = c[7];
}
rx = c[1];
ry = c[2];
angle = c[3] * Math.PI / 180;
largeArcFlag = !!c[4];
sweepFlag = !!c[5];
endPoint = { x, y };
midPoint = {
x: (currentPoint.x - endPoint.x) / 2,
y: (currentPoint.y - endPoint.y) / 2
};
rotatePoint(midPoint, -angle);
lambda = midPoint.x * midPoint.x / (rx * rx) + midPoint.y * midPoint.y / (ry * ry);
if (lambda > 1) {
lambda = Math.sqrt(lambda);
rx *= lambda;
ry *= lambda;
}
centerPoint = {
x: rx * midPoint.y / ry,
y: -(ry * midPoint.x) / rx
};
t1 = rx * rx * ry * ry;
t2 = rx * rx * midPoint.y * midPoint.y + ry * ry * midPoint.x * midPoint.x;
if (sweepFlag !== largeArcFlag) {
scalePoint(centerPoint, Math.sqrt((t1 - t2) / t2) || 0);
} else {
scalePoint(centerPoint, -Math.sqrt((t1 - t2) / t2) || 0);
}
startAngle = Math.atan2((midPoint.y - centerPoint.y) / ry, (midPoint.x - centerPoint.x) / rx);
endAngle = Math.atan2(-(midPoint.y + centerPoint.y) / ry, -(midPoint.x + centerPoint.x) / rx);
rotatePoint(centerPoint, angle);
translatePoint(centerPoint, (endPoint.x + currentPoint.x) / 2, (endPoint.y + currentPoint.y) / 2);
ctx.save();
ctx.translate(centerPoint.x, centerPoint.y);
ctx.rotate(angle);
ctx.scale(rx, ry);
ctx.arc(0, 0, 1, startAngle, endAngle, !sweepFlag);
ctx.restore();
break;
case "C":
c = this.#commands[i];
cpx = c[3];
cpy = c[4];
x = c[5];
y = c[6];
ctx.bezierCurveTo(c[1], c[2], cpx, cpy, x, y);
break;
case "c":
c = this.#commands[i];
ctx.bezierCurveTo(c[1] + x, c[2] + y, c[3] + x, c[4] + y, c[5] + x, c[6] + y);
cpx = c[3] + x;
cpy = c[4] + y;
x += c[5];
y += c[6];
break;
case "S":
c = this.#commands[i];
if (cpx === null || cpy === null) {
cpx = x;
cpy = y;
}
ctx.bezierCurveTo(2 * x - cpx, 2 * y - cpy, c[1], c[2], c[3], c[4]);
cpx = c[1];
cpy = c[2];
x = c[3];
y = c[4];
break;
case "s":
c = this.#commands[i];
if (cpx === null || cpy === null) {
cpx = x;
cpy = y;
}
ctx.bezierCurveTo(2 * x - cpx, 2 * y - cpy, c[1] + x, c[2] + y, c[3] + x, c[4] + y);
cpx = c[1] + x;
cpy = c[2] + y;
x += c[3];
y += c[4];
break;
case "Q":
c = this.#commands[i];
qcpx = c[1];
qcpy = c[2];
x = c[3];
y = c[4];
ctx.quadraticCurveTo(qcpx, qcpy, x, y);
break;
case "q":
c = this.#commands[i];
qcpx = c[1] + x;
qcpy = c[2] + y;
x += c[3];
y += c[4];
ctx.quadraticCurveTo(qcpx, qcpy, x, y);
break;
case "T":
c = this.#commands[i];
if (qcpx === null || qcpy === null) {
qcpx = x;
qcpy = y;
}
qcpx = 2 * x - qcpx;
qcpy = 2 * y - qcpy;
x = c[1];
y = c[2];
ctx.quadraticCurveTo(qcpx, qcpy, x, y);
break;
case "t":
c = this.#commands[i];
if (qcpx === null || qcpy === null) {
qcpx = x;
qcpy = y;
}
qcpx = 2 * x - qcpx;
qcpy = 2 * y - qcpy;
x += c[1];
y += c[2];
ctx.quadraticCurveTo(qcpx, qcpy, x, y);
break;
case "z":
case "Z":
if (startPoint) {
x = startPoint.x;
y = startPoint.y;
}
startPoint = null;
ctx.closePath();
break;
case "AC":
c = this.#commands[i];
x = c[1];
y = c[2];
r = c[3];
startAngle = c[4];
endAngle = c[5];
ccw = c[6];
ctx.arc(x, y, r, startAngle, endAngle, ccw);
break;
case "AT":
c = this.#commands[i];
x1 = c[1];
y1 = c[2];
x = c[3];
y = c[4];
r = c[5];
ctx.arcTo(x1, y1, x, y, r);
break;
case "E":
c = this.#commands[i];
x = c[1];
y = c[2];
rx = c[3];
ry = c[4];
angle = c[5];
startAngle = c[6];
endAngle = c[7];
ccw = c[8];
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(rx, ry);
ctx.arc(0, 0, 1, startAngle, endAngle, ccw);
ctx.restore();
break;
case "R":
c = this.#commands[i];
x = c[1];
y = c[2];
w = c[3];
h = c[4];
startPoint = { x, y };
ctx.rect(x, y, w, h);
break;
case "RR":
c = this.#commands[i];
x = c[1];
y = c[2];
w = c[3];
h = c[4];
radii = c[5];
startPoint = { x, y };
ctx.roundRect(x, y, w, h, radii);
break;
default:
throw new Error(`Invalid path command: ${pathType}`);
}
if (!currentPoint) {
currentPoint = { x, y };
} else {
currentPoint.x = x;
currentPoint.y = y;
}
}
}
};
// src/round-rect.ts
function isPointObject(point) {
return point !== null && typeof point === "object" && ("x" in point || "y" in point) && (typeof point.x === "number" || typeof point.y === "number" || typeof point.x === "undefined" || typeof point.y === "undefined");
}
function normalizeRadius(radius) {
if (typeof radius === "number") {
return { x: radius, y: radius };
}
return {
x: typeof radius.x === "number" ? radius.x : 0,
y: typeof radius.y === "number" ? radius.y : 0
};
}
function roundRect(x, y, width, height, radii = 0) {
if (typeof radii === "number") {
radii = [radii];
} else if (isPointObject(radii)) {
radii = [radii];
} else if (!Array.isArray(radii)) {
return;
}
if (Array.isArray(radii)) {
if (radii.length === 0 || radii.length > 4) {
throw new RangeError(
`Failed to execute 'roundRect' on '${this.constructor.name}': ${radii.length} radii provided. Between one and four radii are necessary.`
);
}
radii.forEach((v) => {
if (isPointObject(v)) {
const point = v;
if (typeof point.x === "number" && point.x < 0) {
throw new RangeError(
`Failed to execute 'roundRect' on '${this.constructor.name}': Radius value ${point.x} is negative.`
);
}
if (typeof point.y === "number" && point.y < 0) {
throw new RangeError(
`Failed to execute 'roundRect' on '${this.constructor.name}': Radius value ${point.y} is negative.`
);
}
} else if (typeof v !== "number") {
throw new TypeError(
`Failed to execute 'roundRect' on '${this.constructor.name}': Radius value ${v} is not a number or DOMPointInit.`
);
} else if (typeof v === "number" && v < 0) {
throw new RangeError(
`Failed to execute 'roundRect' on '${this.constructor.name}': Radius value ${v} is negative.`
);
}
});
}
const normalizedRadii = radii.map(normalizeRadius);
if (radii.length === 1 && normalizedRadii[0].x === 0 && normalizedRadii[0].y === 0) {
this.rect(x, y, width, height);
return;
}
const maxRadiusX = width / 2;
const maxRadiusY = height / 2;
const tl = {
x: Math.min(maxRadiusX, normalizedRadii[0].x),
y: Math.min(maxRadiusY, normalizedRadii[0].y)
};
let tr = tl;
let br = tl;
let bl = tl;
if (normalizedRadii.length === 2) {
tr = { x: Math.min(maxRadiusX, normalizedRadii[1].x), y: Math.min(maxRadiusY, normalizedRadii[1].y) };
bl = tr;
}
if (normalizedRadii.length === 3) {
tr = { x: Math.min(maxRadiusX, normalizedRadii[1].x), y: Math.min(maxRadiusY, normalizedRadii[1].y) };
bl = tr;
br = { x: Math.min(maxRadiusX, normalizedRadii[2].x), y: Math.min(maxRadiusY, normalizedRadii[2].y) };
}
if (normalizedRadii.length === 4) {
tr = { x: Math.min(maxRadiusX, normalizedRadii[1].x), y: Math.min(maxRadiusY, normalizedRadii[1].y) };
br = { x: Math.min(maxRadiusX, normalizedRadii[2].x), y: Math.min(maxRadiusY, normalizedRadii[2].y) };
bl = { x: Math.min(maxRadiusX, normalizedRadii[3].x), y: Math.min(maxRadiusY, normalizedRadii[3].y) };
}
this.moveTo(x, y + height - bl.y);
if (tl.x === tl.y && tl.x > 0) {
this.arcTo(x, y, x + tl.x, y, tl.x);
} else if (tl.x > 0 || tl.y > 0) {
this.ellipse(x + tl.x, y + tl.y, tl.x, tl.y, 0, Math.PI, Math.PI * 1.5, false);
} else {
this.lineTo(x, y);
}
this.lineTo(x + width - tr.x, y);
if (tr.x === tr.y && tr.x > 0) {
this.arcTo(x + width, y, x + width, y + tr.y, tr.x);
} else if (tr.x > 0 || tr.y > 0) {
this.ellipse(x + width - tr.x, y + tr.y, tr.x, tr.y, 0, Math.PI * 1.5, 0, false);
} else {
this.lineTo(x + width, y);
}
this.lineTo(x + width, y + height - br.y);
if (br.x === br.y && br.x > 0) {
this.arcTo(x + width, y + height, x + width - br.x, y + height, br.x);
} else if (br.x > 0 || br.y > 0) {
this.ellipse(x + width - br.x, y + height - br.y, br.x, br.y, 0, 0, Math.PI * 0.5, false);
} else {
this.lineTo(x + width, y + height);
}
this.lineTo(x + bl.x, y + height);
if (bl.x === bl.y && bl.x > 0) {
this.arcTo(x, y + height, x, y + height - bl.y, bl.x);
} else if (bl.x > 0 || bl.y > 0) {
this.ellipse(x + bl.x, y + height - bl.y, bl.x, bl.y, 0, Math.PI * 0.5, Math.PI, false);
} else {
this.lineTo(x, y + height);
}
this.closePath();
this.moveTo(x, y);
}
// src/apply.ts
function applyPath2DToCanvasRenderingContext(CanvasRenderingContext2D) {
if (!CanvasRenderingContext2D) return;
const cClip = CanvasRenderingContext2D.prototype.clip;
const cFill = CanvasRenderingContext2D.prototype.fill;
const cStroke = CanvasRenderingContext2D.prototype.stroke;
const cIsPointInPath = CanvasRenderingContext2D.prototype.isPointInPath;
CanvasRenderingContext2D.prototype.clip = function clip(...args) {
if (args[0] instanceof Path2D) {
const path = args[0];
const fillRule2 = args[1] !== void 0 ? args[1] : "nonzero";
path.buildPathInCanvas(this);
cClip.apply(this, [fillRule2]);
return;
}
const fillRule = args[0] !== void 0 ? args[0] : "nonzero";
cClip.apply(this, [fillRule]);
};
CanvasRenderingContext2D.prototype.fill = function fill(...args) {
if (args[0] instanceof Path2D) {
const path = args[0];
const fillRule2 = args[1] !== void 0 ? args[1] : "nonzero";
path.buildPathInCanvas(this);
cFill.apply(this, [fillRule2]);
return;
}
const fillRule = args[0] !== void 0 ? args[0] : "nonzero";
cFill.apply(this, [fillRule]);
};
CanvasRenderingContext2D.prototype.stroke = function stroke(path) {
if (path) {
path.buildPathInCanvas(this);
}
cStroke.apply(this);
};
CanvasRenderingContext2D.prototype.isPointInPath = function isPointInPath(...args) {
if (args[0] instanceof Path2D) {
const path = args[0];
const x = args[1];
const y = args[2];
const fillRule = args[3] !== void 0 ? args[3] : "nonzero";
path.buildPathInCanvas(this);
return cIsPointInPath.apply(this, [x, y, fillRule]);
}
return cIsPointInPath.apply(this, args);
};
}
function applyRoundRectToCanvasRenderingContext2D(CanvasRenderingContext2D) {
if (CanvasRenderingContext2D && !CanvasRenderingContext2D.prototype.roundRect) {
CanvasRenderingContext2D.prototype.roundRect = roundRect;
}
}
function applyRoundRectToPath2D(P2D) {
if (P2D && !P2D.prototype.roundRect) {
P2D.prototype.roundRect = roundRect;
}
}
export {
Path2D,
applyPath2DToCanvasRenderingContext,
applyRoundRectToCanvasRenderingContext2D,
applyRoundRectToPath2D,
parsePath,
roundRect
};