fabric
Version:
Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.
660 lines (556 loc) • 19.4 kB
text/typescript
import { describe, expect, it } from 'vitest';
import { Path } from './Path';
import type { TSimpleParsedCommand } from '../util';
import { FabricObject, getFabricDocument, version } from '../../fabric';
const REFERENCE_PATH_OBJECT = {
version: version,
type: 'Path',
originX: 'center' as const,
originY: 'center' as const,
left: 200,
top: 200,
width: 200,
height: 200,
fill: 'red',
stroke: 'blue',
strokeWidth: 0,
strokeDashArray: null,
strokeLineCap: 'butt' as const,
strokeDashOffset: 0,
strokeLineJoin: 'miter' as const,
strokeMiterLimit: 4,
scaleX: 1,
scaleY: 1,
angle: 0,
flipX: false,
flipY: false,
opacity: 1,
path: [
['M', 100, 100],
['L', 300, 100],
['L', 200, 300],
['Z'],
] as TSimpleParsedCommand[],
shadow: null,
visible: true,
backgroundColor: '',
fillRule: 'nonzero' as const,
paintFirst: 'fill' as const,
globalCompositeOperation: 'source-over' as const,
skewX: 0,
skewY: 0,
strokeUniform: false,
};
function getPathElement(path: string) {
const namespace = 'http://www.w3.org/2000/svg';
const el = getFabricDocument().createElementNS(namespace, 'path');
el.setAttributeNS(namespace, 'd', path);
el.setAttributeNS(namespace, 'fill', 'red');
el.setAttributeNS(namespace, 'stroke', 'blue');
el.setAttributeNS(namespace, 'stroke-width', String(1));
el.setAttributeNS(namespace, 'stroke-linecap', 'butt');
el.setAttributeNS(namespace, 'stroke-linejoin', 'miter');
el.setAttributeNS(namespace, 'stroke-miterlimit', String(4));
return el;
}
function makePathObject() {
return new Promise<Path>((resolve) => {
const path = new Path('M 100 100 L 300 100 L 200 300 z', {
fill: 'red',
stroke: 'blue',
strokeLineCap: 'butt',
strokeLineJoin: 'miter',
strokeMiterLimit: 4,
strokeWidth: 0,
});
resolve(path);
});
}
function updatePath(
pathObject: Path,
value: string | TSimpleParsedCommand[],
preservePosition: boolean,
) {
const { left, top } = pathObject;
pathObject._setPath(value);
if (preservePosition) {
pathObject.set({ left, top });
}
}
describe('Path', () => {
it('constructor', async () => {
expect(Path, 'Path class should exist').toBeTruthy();
const path = await makePathObject();
expect(path, 'should be instance of Path').toBeInstanceOf(Path);
expect(path, 'should be instance of FabricObject').toBeInstanceOf(
FabricObject,
);
expect(path.constructor, 'type should be Path').toHaveProperty(
'type',
'Path',
);
expect(
// @ts-expect-error -- creating an empty path for testing
() => new Path(),
'should not throw error on empty path',
).not.toThrow();
});
it('initialize', () => {
const path = new Path('M 100 100 L 200 100 L 170 200 z', {
top: 0,
strokeWidth: 0,
});
expect(path.left, 'left should be 150').toBe(150);
expect(path.top, 'top should be 0').toBe(0);
});
it('initialize with strokeWidth', () => {
const path = new Path('M 100 100 L 200 100 L 170 200 z', {
strokeWidth: 50,
});
expect(path.left, 'left should be 150').toBe(150);
expect(path.top, 'top should be 150').toBe(150);
});
it('initialize with strokeWidth with originX and originY center/center', () => {
const path = new Path('M 100 100 L 200 100 L 170 200 z', {
strokeWidth: 4,
originX: 'center',
originY: 'center',
});
expect(path.left, 'left should be 150').toBe(150);
expect(path.top, 'top should be 150').toBe(150);
});
it('initialize with strokeWidth with originX and originY top/left', () => {
const path = new Path('M 100 100 L 200 100 L 170 200 z', {
strokeWidth: 4,
originX: 'left',
originY: 'top',
});
expect(path.left, 'left should be 98').toBe(98);
expect(path.top, 'top should be 98').toBe(98);
});
it('initialize with strokeWidth with originX and originY bottom/right', () => {
const path = new Path('M 100 100 L 200 100 L 170 200 z', {
strokeWidth: 4,
originX: 'right',
originY: 'bottom',
});
expect(path.left, 'left should be 202').toBe(202);
expect(path.top, 'top should be 202').toBe(202);
});
it('set path after initialization', async () => {
const path = new Path(
'M 100 100 L 200 100 L 170 200 z',
REFERENCE_PATH_OBJECT,
);
updatePath(path, REFERENCE_PATH_OBJECT.path, true);
expect(path.toObject(), 'path object should match reference').toEqual(
REFERENCE_PATH_OBJECT,
);
updatePath(path, REFERENCE_PATH_OBJECT.path, false);
const opts: typeof REFERENCE_PATH_OBJECT & {
sourcePath?: string;
} = {
...REFERENCE_PATH_OBJECT,
};
// @ts-expect-error -- deleting intentionally
delete opts.path;
path.set(opts);
updatePath(path, 'M 100 100 L 300 100 L 200 300 z', true);
const cleanPath = await makePathObject();
expect(
path.toObject(),
'path object should match clean path object',
).toEqual(cleanPath.toObject());
});
it('Path initialized with strokeWidth takes that in account for positioning', async () => {
const path = new Path(
'M 100 100 L 200 100 L 170 200 z',
REFERENCE_PATH_OBJECT,
);
updatePath(path, REFERENCE_PATH_OBJECT.path, true);
expect(path.toObject(), 'path object should match reference').toEqual(
REFERENCE_PATH_OBJECT,
);
updatePath(path, REFERENCE_PATH_OBJECT.path, false);
const opts: typeof REFERENCE_PATH_OBJECT & {
sourcePath?: string;
} = {
...REFERENCE_PATH_OBJECT,
};
// @ts-expect-error -- deleting intentionally
delete opts.path;
path.set(opts);
updatePath(path, 'M 100 100 L 300 100 L 200 300 z', true);
const cleanPath = await makePathObject();
expect(
path.toObject(),
'path object should match clean path object',
).toEqual(cleanPath.toObject());
});
it('toString', async () => {
const path = await makePathObject();
expect(path.toString, 'toString should be a function').toBeTypeOf(
'function',
);
expect(path.toString(), 'toString should return expected string').toBe(
'#<Path (4): { "top": 200, "left": 200 }>',
);
});
it('toObject', async () => {
const path = await makePathObject();
expect(path.toObject, 'toObject should be a function').toBeTypeOf(
'function',
);
expect(path.toObject(), 'toObject should match reference').toEqual(
REFERENCE_PATH_OBJECT,
);
});
it('toObject with defaults', async () => {
const path = await makePathObject();
path.top = Path.getDefaults().top;
path.left = Path.getDefaults().left;
path.includeDefaultValues = false;
const obj = path.toObject();
expect(obj.top, 'top is available also when equal to prototype').toBe(
Path.getDefaults().top,
);
expect(obj.left, 'left is available also when equal to prototype').toBe(
Path.getDefaults().left,
);
});
it('toSVG', async () => {
const path = await makePathObject();
expect(path.toSVG, 'toSVG should be a function').toBeTypeOf('function');
expect(path.toSVG(), 'SVG output should match expected').toEqualSVG(
'<g transform="matrix(1 0 0 1 200 200)" >\n<path style="stroke: rgb(0,0,255); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(255,0,0); fill-rule: nonzero; opacity: 1;" transform=" translate(-200, -200)" d="M 100 100 L 300 100 L 200 300 Z" stroke-linecap="round" />\n</g>\n',
);
});
it('toSVG of path with a strokeWidth', async () => {
const path = await makePathObject();
path.strokeWidth = 2;
expect(path.toSVG(), 'SVG output should match expected').toEqualSVG(
'<g transform="matrix(1 0 0 1 200 200)" >\n<path style="stroke: rgb(0,0,255); stroke-width: 2; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(255,0,0); fill-rule: nonzero; opacity: 1;" transform=" translate(-200, -200)" d="M 100 100 L 300 100 L 200 300 Z" stroke-linecap="round" />\n</g>\n',
);
});
it('toSVG with a clipPath path', async () => {
const path = await makePathObject();
path.clipPath = await makePathObject();
expect(path.toSVG(), 'path clipPath toSVG should match').toEqualSVG(
'<g transform="matrix(1 0 0 1 200 200)" clip-path="url(#CLIPPATH_0)" >\n<clipPath id="CLIPPATH_0" >\n\t<path transform="matrix(1 0 0 1 200 200) translate(-200, -200)" d="M 100 100 L 300 100 L 200 300 Z" stroke-linecap="round" />\n</clipPath>\n<path style="stroke: rgb(0,0,255); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(255,0,0); fill-rule: nonzero; opacity: 1;" transform=" translate(-200, -200)" d="M 100 100 L 300 100 L 200 300 Z" stroke-linecap="round" />\n</g>\n',
);
});
it('toSVG with a clipPath path absolutePositioned', async () => {
const path = await makePathObject();
path.clipPath = await makePathObject();
path.clipPath.absolutePositioned = true;
expect(
path.toSVG(),
'path clipPath toSVG absolute should match',
).toEqualSVG(
'<g clip-path="url(#CLIPPATH_0)" >\n<g transform="matrix(1 0 0 1 200 200)" >\n<clipPath id="CLIPPATH_0" >\n\t<path transform="matrix(1 0 0 1 200 200) translate(-200, -200)" d="M 100 100 L 300 100 L 200 300 Z" stroke-linecap="round" />\n</clipPath>\n<path style="stroke: rgb(0,0,255); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(255,0,0); fill-rule: nonzero; opacity: 1;" transform=" translate(-200, -200)" d="M 100 100 L 300 100 L 200 300 Z" stroke-linecap="round" />\n</g>\n</g>\n',
);
});
it('path array not shared when cloned', async () => {
const originalPath = await makePathObject();
const clonedPath = await originalPath.clone();
clonedPath.path[0][1] = 200;
expect(
originalPath.path[0][1],
'original path should not be modified',
).toBe(100);
});
it('toDatalessObject', async () => {
const path = await makePathObject();
expect(
path.toDatalessObject,
'toDatalessObject should be a function',
).toBeTypeOf('function');
expect(
path.toDatalessObject(),
'if not sourcePath the object is same',
).toEqual(REFERENCE_PATH_OBJECT);
});
it('toDatalessObject with sourcePath', async () => {
const path = await makePathObject();
const src = 'http://example.com/';
path.sourcePath = src;
const clonedRef: typeof REFERENCE_PATH_OBJECT & {
sourcePath?: string;
} = {
...REFERENCE_PATH_OBJECT,
};
clonedRef.sourcePath = src;
// @ts-expect-error -- deleting intentionally
delete clonedRef.path;
expect(
path.toDatalessObject(),
'if sourcePath the object looses path',
).toEqual(clonedRef);
});
it('complexity', async () => {
const path = await makePathObject();
expect(path.complexity, 'complexity should be a function').toBeTypeOf(
'function',
);
});
it('fromObject', async () => {
expect(Path.fromObject, 'fromObject should be a function').toBeTypeOf(
'function',
);
const path = await Path.fromObject(REFERENCE_PATH_OBJECT);
expect(path, 'should be instance of Path').toBeInstanceOf(Path);
expect(path.toObject(), 'path object should match reference').toEqual(
REFERENCE_PATH_OBJECT,
);
});
it('fromObject with sourcePath', async () => {
expect(Path.fromObject, 'fromObject should be a function').toBeTypeOf(
'function',
);
const path = await Path.fromObject(REFERENCE_PATH_OBJECT);
expect(path, 'should be instance of Path').toBeInstanceOf(Path);
expect(path.toObject(), 'path object should match reference').toEqual(
REFERENCE_PATH_OBJECT,
);
});
it('fromElement', async () => {
expect(Path.fromElement, 'fromElement should be a function').toBeTypeOf(
'function',
);
const namespace = 'http://www.w3.org/2000/svg';
const elPath = getFabricDocument().createElementNS(namespace, 'path');
elPath.setAttributeNS(namespace, 'd', 'M 100 100 L 300 100 L 200 300 z');
elPath.setAttributeNS(namespace, 'fill', 'red');
elPath.setAttributeNS(namespace, 'opacity', '1');
elPath.setAttributeNS(namespace, 'stroke', 'blue');
elPath.setAttributeNS(namespace, 'stroke-width', '0');
elPath.setAttributeNS(namespace, 'stroke-dasharray', '5, 2');
elPath.setAttributeNS(namespace, 'stroke-linecap', 'round');
elPath.setAttributeNS(namespace, 'stroke-linejoin', 'bevel');
elPath.setAttributeNS(namespace, 'stroke-miterlimit', '5');
elPath.setAttributeNS(namespace, 'transform', 'scale(2)');
const path = await Path.fromElement(elPath);
expect(path, 'should be instance of Path').toBeInstanceOf(Path);
expect(path.toObject(), 'path object should match reference').toEqual({
...REFERENCE_PATH_OBJECT,
strokeDashArray: [5, 2],
strokeLineCap: 'round',
strokeLineJoin: 'bevel',
strokeMiterLimit: 5,
});
const ANGLE_DEG = 90;
elPath.setAttributeNS(namespace, 'transform', 'rotate(' + ANGLE_DEG + ')');
const rotatedPath = await Path.fromElement(elPath);
expect(
rotatedPath.get('transformMatrix'),
'transform matrix should match expected',
).toEqual([0, 1, -1, 0, 0, 0]);
});
it('numbers with leading decimal point', async () => {
expect(Path.fromElement, 'fromElement should be a function').toBeTypeOf(
'function',
);
const namespace = 'http://www.w3.org/2000/svg';
const elPath = getFabricDocument().createElementNS(namespace, 'path');
elPath.setAttributeNS(namespace, 'd', 'M 100 100 L 300 100 L 200 300 z');
elPath.setAttributeNS(namespace, 'transform', 'scale(.2)');
const path = await Path.fromElement(elPath);
expect(path, 'should be instance of Path').toBeInstanceOf(Path);
// @ts-expect-error -- TODO: check why transformMatrix is not set on the Path as potential type?
expect(path.transformMatrix, 'transform has been parsed').toEqual([
0.2, 0, 0, 0.2, 0, 0,
]);
});
it('multiple sequences in path commands', async () => {
const el = getPathElement('M100 100 l 200 200 300 300 400 -50 z');
const obj = await Path.fromElement(el);
expect(obj.path[0], 'first path command should match').toEqual([
'M',
100,
100,
]);
expect(obj.path[1], 'second path command should match').toEqual([
'L',
300,
300,
]);
expect(obj.path[2], 'third path command should match').toEqual([
'L',
600,
600,
]);
expect(obj.path[3], 'fourth path command should match').toEqual([
'L',
1000,
550,
]);
const el2 = getPathElement(
'c 0,-53.25604 43.17254,-96.42858 96.42857,-96.42857 53.25603,0 96.42857,43.17254 96.42857,96.42857',
);
const obj2 = await Path.fromElement(el2);
expect(obj2.path[0], 'first cubic command should match').toEqual([
'C',
0,
-53.25604,
43.17254,
-96.42858,
96.42857,
-96.42857,
]);
expect(obj2.path[1], 'second cubic command should match').toEqual([
'C',
149.6846,
-96.42857,
192.85714,
-53.256029999999996,
192.85714,
0,
]);
});
it('multiple M/m coordinates converted all L', async () => {
const el = getPathElement(
'M100 100 200 200 150 50 m 300 300 400 -50 50 100',
);
const obj = await Path.fromElement(el);
expect(obj.path[0], 'first path command should match').toEqual([
'M',
100,
100,
]);
expect(obj.path[1], 'second path command should match').toEqual([
'L',
200,
200,
]);
expect(obj.path[2], 'third path command should match').toEqual([
'L',
150,
50,
]);
expect(obj.path[3], 'fourth path command should match').toEqual([
'M',
450,
350,
]);
expect(obj.path[4], 'fifth path command should match').toEqual([
'L',
850,
300,
]);
expect(obj.path[5], 'sixth path command should match').toEqual([
'L',
900,
400,
]);
});
it('multiple M/m commands converted all as M commands', async () => {
const el = getPathElement(
'M100 100 M 200 200 M150 50 m 300 300 m 400 -50 m 50 100',
);
const obj = await Path.fromElement(el);
expect(obj.path[0], 'first path command should match').toEqual([
'M',
100,
100,
]);
expect(obj.path[1], 'second path command should match').toEqual([
'M',
200,
200,
]);
expect(obj.path[2], 'third path command should match').toEqual([
'M',
150,
50,
]);
expect(obj.path[3], 'fourth path command should match').toEqual([
'M',
450,
350,
]);
expect(obj.path[4], 'fifth path command should match').toEqual([
'M',
850,
300,
]);
expect(obj.path[5], 'sixth path command should match').toEqual([
'M',
900,
400,
]);
});
it('compressed path commands', async () => {
const el = getPathElement(
'M56.224 84.12C-.047.132-.138.221-.322.215.046-.131.137-.221.322-.215z',
);
const obj = await Path.fromElement(el);
expect(obj.path[0], 'first path command should match').toEqual([
'M',
56.224,
84.12,
]);
expect(obj.path[1], 'second path command should match').toEqual([
'C',
-0.047,
0.132,
-0.138,
0.221,
-0.322,
0.215,
]);
expect(obj.path[2], 'third path command should match').toEqual([
'C',
0.046,
-0.131,
0.137,
-0.221,
0.322,
-0.215,
]);
expect(obj.path[3], 'fourth path command should match').toEqual(['Z']);
});
it('compressed path commands with e^x', async () => {
const el = getPathElement(
'M56.224e2 84.12E-2C-.047.132-.138.221-.322.215.046-.131.137-.221.322-.215m-.050 -20.100z',
);
const obj = await Path.fromElement(el);
expect(obj.path[0], 'first path command should match').toEqual([
'M',
5622.4,
0.8412,
]);
expect(obj.path[1], 'second path command should match').toEqual([
'C',
-0.047,
0.132,
-0.138,
0.221,
-0.322,
0.215,
]);
expect(obj.path[2], 'third path command should match').toEqual([
'C',
0.046,
-0.131,
0.137,
-0.221,
0.322,
-0.215,
]);
expect(obj.path[3], 'fourth path command should match').toEqual([
'M',
0.272,
-20.315,
]);
expect(obj.path[4], 'fifth path command should match').toEqual(['Z']);
});
it('can parse arcs with rx and ry set to 0', () => {
const path = new Path(
'M62.87543,168.19448H78.75166a0,0,0,0,1,0,0v1.9884a6.394,6.394,0,0,1-6.394,6.394H69.26939a6.394,6.394,0,0,1-6.394-6.394v-1.9884A0,0,0,0,1,62.87543,168.19448Z',
);
expect(
path.path.length,
'path should have correct number of commands',
).toBe(9);
});
});