From d4956078d40ca0c42061a845637571098078ba94 Mon Sep 17 00:00:00 2001 From: Andrea Bogazzi Date: Sat, 7 Sep 2024 21:13:15 +0200 Subject: [PATCH] Fix(): path parsing performance regression (#10123) --- CHANGELOG.md | 2 + src/benchmarks/boundingBoxFromPoint.mjs | 69 +++ src/config.ts | 8 +- src/parser/constants.ts | 2 - src/shapes/Path.ts | 2 +- src/util/misc/boundingBoxFromPoints.ts | 39 +- .../path/__snapshots__/index.spec.ts.snap | 462 ++++++++++++++++++ src/util/path/index.spec.ts | 108 +++- src/util/path/index.ts | 107 ++-- src/util/path/regex.ts | 51 +- src/util/path/typedefs.ts | 22 + test/unit/path_utils.js | 120 ----- 12 files changed, 769 insertions(+), 223 deletions(-) create mode 100644 src/benchmarks/boundingBoxFromPoint.mjs delete mode 100644 test/unit/path_utils.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 37bccc430ec..a3f7a802409 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [next] +- Fix(): path parsing performance [#10123](https://github.com/fabricjs/fabric.js/pull/10123) + ## [6.4.1] - fix(): Package.json had wrong path to types for extensions [#10115](https://github.com/fabricjs/fabric.js/pull/10115) diff --git a/src/benchmarks/boundingBoxFromPoint.mjs b/src/benchmarks/boundingBoxFromPoint.mjs new file mode 100644 index 00000000000..99e2ffaa75d --- /dev/null +++ b/src/benchmarks/boundingBoxFromPoint.mjs @@ -0,0 +1,69 @@ +/* eslint-disable no-restricted-syntax */ +import { util, Point } from '../../dist/index.mjs'; + +const makeBBoxOld = (points) => { + if (points.length === 0) { + return { + left: 0, + top: 0, + width: 0, + height: 0, + }; + } + + const { min, max } = points.reduce( + ({ min, max }, curr) => { + return { + min: min.min(curr), + max: max.max(curr), + }; + }, + { min: new Point(points[0]), max: new Point(points[0]) }, + ); + + const size = max.subtract(min); + + return { + left: min.x, + top: min.y, + width: size.x, + height: size.y, + }; +}; + +const size = 10000; +const arraySize = 6000; + +const points = new Array(arraySize) + .fill(0) + .map( + () => new Point((Math.random() - 0.5) * 1000, (Math.random() - 0.5) * 1000), + ); + +const benchmark = (callback) => { + const start = Date.now(); + callback(); + return Date.now() - start; +}; + +const fabric = benchmark(() => { + for (let i = 0; i < size; i++) { + util.makeBoundingBoxFromPoints(points); + } +}); + +const older = benchmark(() => { + for (let i = 0; i < size; i++) { + makeBBoxOld(points); + } +}); + +console.log({ + fabric, + older, +}); + +/** + * On Node 20 macbook pro m1 + * { fabric: 139, older: 1089 } + */ diff --git a/src/config.ts b/src/config.ts index 5202b84a226..f9da752225c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -96,9 +96,13 @@ class BaseConfiguration { /** * If disabled boundsOfCurveCache is not used. For apps that make heavy usage of pencil drawing probably disabling it is better - * @default true + * With the standard behaviour of fabric to translate all curves in absolute commands and by not subtracting the starting point from + * the curve is very hard to hit any cache. + * Enable only if you know why it could be useful. + * Candidate for removal/simplification + * @default false */ - cachesBoundsOfCurve = true; + cachesBoundsOfCurve = false; /** * Map of font files diff --git a/src/parser/constants.ts b/src/parser/constants.ts index 38c05ca896a..fe7ff27317c 100644 --- a/src/parser/constants.ts +++ b/src/parser/constants.ts @@ -5,8 +5,6 @@ export const reNum = String.raw`(?:[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)`; export const svgNS = 'http://www.w3.org/2000/svg'; -export const commaWsp = String.raw`(?:\s+,?\s*|,\s*|$)`; - export const reFontDeclaration = new RegExp( '(normal|italic)?\\s*(normal|small-caps)?\\s*' + '(normal|bold|bolder|lighter|100|200|300|400|500|600|700|800|900)?\\s*(' + diff --git a/src/shapes/Path.ts b/src/shapes/Path.ts index 2a85ae2bd48..def914840bf 100644 --- a/src/shapes/Path.ts +++ b/src/shapes/Path.ts @@ -299,7 +299,7 @@ export class Path< case 'L': // lineto, absolute x = command[1]; y = command[2]; - bounds.push(new Point(subpathStartX, subpathStartY), new Point(x, y)); + bounds.push({ x: subpathStartX, y: subpathStartY }, { x, y }); break; case 'M': // moveTo, absolute diff --git a/src/util/misc/boundingBoxFromPoints.ts b/src/util/misc/boundingBoxFromPoints.ts index ae880c159e3..8d3d74aed43 100644 --- a/src/util/misc/boundingBoxFromPoints.ts +++ b/src/util/misc/boundingBoxFromPoints.ts @@ -1,5 +1,4 @@ import type { XY } from '../../Point'; -import { Point } from '../../Point'; import type { TBBox } from '../../typedefs'; /** @@ -8,31 +7,23 @@ import type { TBBox } from '../../typedefs'; * @return {Object} Object with left, top, width, height properties */ export const makeBoundingBoxFromPoints = (points: XY[]): TBBox => { - if (points.length === 0) { - return { - left: 0, - top: 0, - width: 0, - height: 0, - }; - } - - const { min, max } = points.reduce( - ({ min, max }, curr) => { - return { - min: min.min(curr), - max: max.max(curr), - }; - }, - { min: new Point(points[0]), max: new Point(points[0]) }, - ); + let left = 0, + top = 0, + width = 0, + height = 0; - const size = max.subtract(min); + for (let i = 0, len = points.length; i < len; i++) { + const { x, y } = points[i]; + if (x > width || !i) width = x; + if (x < left || !i) left = x; + if (y > height || !i) height = y; + if (y < top || !i) top = y; + } return { - left: min.x, - top: min.y, - width: size.x, - height: size.y, + left, + top, + width: width - left, + height: height - top, }; }; diff --git a/src/util/path/__snapshots__/index.spec.ts.snap b/src/util/path/__snapshots__/index.spec.ts.snap index 5bf39ccb1aa..2002b7b0400 100644 --- a/src/util/path/__snapshots__/index.spec.ts.snap +++ b/src/util/path/__snapshots__/index.spec.ts.snap @@ -1,5 +1,76 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Path Utils fabric.util.getRegularPolygonPath 1`] = ` +[ + [ + "M", + 0, + -50, + ], + [ + "L", + 47.552825814757675, + -15.450849718747369, + ], + [ + "L", + 29.389262614623657, + 40.45084971874737, + ], + [ + "L", + -29.38926261462365, + 40.45084971874737, + ], + [ + "L", + -47.55282581475768, + -15.450849718747364, + ], + [ + "Z", + ], +] +`; + +exports[`Path Utils fabric.util.getRegularPolygonPath 2`] = ` +[ + [ + "M", + 24.999999999999993, + -43.30127018922194, + ], + [ + "L", + 50, + -1.1102230246251565e-14, + ], + [ + "L", + 25.000000000000018, + 43.301270189221924, + ], + [ + "L", + -24.99999999999999, + 43.30127018922194, + ], + [ + "L", + -50, + 2.8327694488239898e-14, + ], + [ + "L", + -25.00000000000006, + -43.301270189221896, + ], + [ + "Z", + ], +] +`; + exports[`Path Utils getPathSegmentsInfo operates as expected 1`] = ` [ { @@ -380,3 +451,394 @@ exports[`Path Utils makePathSimpler can parse paths that return NaN segments 1`] ], ] `; + +exports[`Path Utils parsePath Path written with uncommon scenario 1`] = ` +[ + [ + "a", + 10.56, + 10.56, + 0, + 0, + 0, + -1.484, + -0.133, + ], + [ + "a", + 10.56, + 10.56, + 0, + 0, + 0, + -1.484, + -0.133, + ], + [ + "a", + 10.56, + 10.56, + 0, + 0, + 0, + -1.484, + -0.133, + ], + [ + "a", + 10.56, + 10.56, + 0, + 0, + 0, + -1.484, + -0.133, + ], +] +`; + +exports[`Path Utils parsePath can parse path string 1`] = ` +[ + [ + "M", + 2, + 5, + ], + [ + "l", + 2, + -2, + ], + [ + "L", + 4, + 4, + ], + [ + "h", + 3, + ], + [ + "H", + 9, + ], + [ + "C", + 8, + 3, + 10, + 3, + 10, + 3, + ], + [ + "c", + 1, + -1, + 2, + 0, + 1, + 1, + ], + [ + "S", + 8, + 5, + 9, + 7, + ], + [ + "v", + 1, + ], + [ + "s", + 2, + -1, + 1, + 2, + ], + [ + "Q", + 9, + 10, + 10, + 11, + ], + [ + "T", + 12, + 11, + ], + [ + "t", + -1, + -1, + ], + [ + "v", + 2, + ], + [ + "T", + 10, + 12, + ], + [ + "S", + 9, + 12, + 7, + 11, + ], + [ + "c", + 0, + -1, + 0, + -1, + -2, + -2, + ], + [ + "z", + ], + [ + "m", + 0, + 2, + ], + [ + "l", + 1, + 0, + ], + [ + "l", + 0, + 1, + ], + [ + "l", + -1, + 0, + ], + [ + "z", + ], + [ + "M", + 1, + 1, + ], + [ + "a", + 1, + 1, + 30, + 1, + 0, + 2, + 2, + ], + [ + "A", + 2, + 2, + 30, + 1, + 0, + 6, + 6, + ], +] +`; + +exports[`Path Utils parsePath can parse path string 2`] = ` +[ + [ + "M", + 2, + 5, + ], + [ + "L", + 4, + 3, + ], + [ + "L", + 4, + 4, + ], + [ + "L", + 7, + 4, + ], + [ + "L", + 9, + 4, + ], + [ + "C", + 8, + 3, + 10, + 3, + 10, + 3, + ], + [ + "C", + 11, + 2, + 12, + 3, + 11, + 4, + ], + [ + "C", + 10, + 5, + 8, + 5, + 9, + 7, + ], + [ + "L", + 9, + 8, + ], + [ + "C", + 9, + 8, + 11, + 7, + 10, + 10, + ], + [ + "Q", + 9, + 10, + 10, + 11, + ], + [ + "Q", + 11, + 12, + 12, + 11, + ], + [ + "Q", + 13, + 10, + 11, + 10, + ], + [ + "L", + 11, + 12, + ], + [ + "Q", + 11, + 12, + 10, + 12, + ], + [ + "C", + 10, + 12, + 9, + 12, + 7, + 11, + ], + [ + "C", + 7, + 10, + 7, + 10, + 5, + 9, + ], + [ + "Z", + ], + [ + "M", + 2, + 7, + ], + [ + "L", + 3, + 7, + ], + [ + "L", + 3, + 8, + ], + [ + "L", + 2, + 8, + ], + [ + "Z", + ], + [ + "M", + 1, + 1, + ], + [ + "C", + 0.44771525016920655, + 1.5522847498307935, + 0.44771525016920655, + 2.4477152501692068, + 1, + 3, + ], + [ + "C", + 1.5522847498307935, + 3.5522847498307932, + 2.4477152501692068, + 3.5522847498307932, + 3, + 3, + ], + [ + "C", + 2.1715728752538106, + 3.8284271247461903, + 2.1715728752538106, + 5.17157287525381, + 3.0000000000000004, + 6, + ], + [ + "C", + 3.8284271247461903, + 6.82842712474619, + 5.17157287525381, + 6.82842712474619, + 6, + 6, + ], +] +`; diff --git a/src/util/path/index.spec.ts b/src/util/path/index.spec.ts index 7d169fd397e..6dc550d3784 100644 --- a/src/util/path/index.spec.ts +++ b/src/util/path/index.spec.ts @@ -1,6 +1,112 @@ -import { getPathSegmentsInfo, parsePath, makePathSimpler } from '.'; +import { + getPathSegmentsInfo, + parsePath, + makePathSimpler, + getRegularPolygonPath, + joinPath, +} from '.'; +import type { TSimplePathData } from './typedefs'; describe('Path Utils', () => { + describe('parsePath', () => { + const path = + 'M 2 5 l 2 -2 L 4 4 h 3 H 9 C 8 3 10 3 10 3 c 1 -1 2 0 1 1 S 8 5 9 7 v 1 s 2 -1 1 2 Q 9 10 10 11 T 12 11 t -1 -1 v 2 T 10 12 S 9 12 7 11 c 0 -1 0 -1 -2 -2 z m 0 2 l 1 0 l 0 1 l -1 0 z M 1 1 a 1 1 30 1 0 2 2 A 2 2 30 1 0 6 6'; + test('can parse path string', () => { + const parsed = parsePath(path); + expect(parsed).toMatchSnapshot(); + const simplified = makePathSimpler(parsed); + expect(simplified).toMatchSnapshot(); + }); + test('Path written with uncommon scenario', () => { + const path = + 'a10.56 10.56 0 00-1.484-.133a10.56 , 10.56 0, 0,0-1.484-.133a10.56 , 10.56 0, 0,0-1.484-.133a1.056e+1 , 105.6e-1,0,0,0-1.484-.133'; + const parsed = parsePath(path); + expect(parsed[0]).toEqual(parsed[1]); + expect(parsed[1]).toEqual(parsed[2]); + expect(parsed[2]).toEqual(parsed[3]); + expect(parsed).toMatchSnapshot(); + }); + test('fabric.util.parsePath can parse arcs correctly when no spaces between flags', () => { + const pathWithWeirdArc = 'a10.56 10.56 0 00-1.484-.133'; + const expected = ['a', 10.56, 10.56, 0, 0, 0, -1.484, -0.133]; + const parsed = parsePath(pathWithWeirdArc); + const command = parsed[0]; + expect(command).toEqual(expected); + }); + test('getPathSegmentsInfo', () => { + const parsed = makePathSimpler(parsePath(path)); + const infos = getPathSegmentsInfo(parsed); + // 'the command 0 a M has a length 0'); + expect(infos[0].length).toBe(0); + // 'the command 1 a L has a length 2.828', + expect(infos[1].length.toFixed(5)).toBe('2.82843'); + // 'the command 2 a L with one step on Y has a length 1', + expect(infos[2].length).toBe(1); + // 'the command 3 a L with 3 step on X has a length 3' + expect(infos[3].length).toBe(3); + // 'the command 4 a L with 2 step on X has a length 0', + expect(infos[4].length).toBe(2); + // 'the command 5 a C has a approximated length of 2.062', + expect(infos[5].length.toFixed(5)).toBe('2.06242'); + // 'the command 6 a C has a approximated length of 2.828', + expect(infos[6].length.toFixed(5)).toBe('2.82832'); + // 'the command 7 a C has a approximated length of 4.189', + expect(infos[7].length.toFixed(5)).toBe('4.18970'); + // 'the command 8 a L with 1 step on the Y has an exact length of 1', + expect(infos[8].length).toBe(1); + // 'the command 9 a C has a approximated length of 3.227', + expect(infos[9].length.toFixed(5)).toBe('3.22727'); + // 'the command 10 a Q has a approximated length of 1.540', + expect(infos[10].length.toFixed(5)).toBe('1.54026'); + // 'the command 11 a Q has a approximated length of 2.295', + expect(infos[11].length.toFixed(5)).toBe('2.29556'); + }); + test('fabric.util.getPathSegmentsInfo test Z command', () => { + const parsed = makePathSimpler(parsePath('M 0 0 h 20, v 20 L 0, 20 Z')); + const infos = getPathSegmentsInfo(parsed); + // 'the command 0 a M has a length 0' + expect(infos[0].length).toBe(0); + // 'the command 1 a L has length 20' + expect(infos[1].length).toBe(20); + // 'the command 2 a L has length 20' + expect(infos[2].length).toBe(20); + // 'the command 3 a L has length 20' + expect(infos[3].length).toBe(20); + // 'the command 4 a Z has length 20' + expect(infos[4].length).toBe(20); + }); + }); + test('fabric.util.getRegularPolygonPath', () => { + const penta = getRegularPolygonPath(5, 50); + const hexa = getRegularPolygonPath(6, 50); + // 'regular pentagon should match', + expect(penta).toMatchSnapshot(); + // 'regular hexagon should match', + expect(hexa).toMatchSnapshot(); + }); + + test('fabric.util.joinPath', () => { + const pathData: TSimplePathData = [ + ['M', 3.12345678, 2.12345678], + ['L', 1.00001111, 2.40001111], + ['Z'], + ] as const; + const digit = 2; + const expected = 'M 3.12 2.12 L 1 2.4 Z'; + const result = joinPath(pathData, digit); + expect(result).toBe(expected); + }); + + test('fabric.util.joinPath without rounding', () => { + const pathData: TSimplePathData = [ + ['M', 3.12345678, 2.12345678], + ['L', 1.00001111, 2.40001111], + ['Z'], + ] as const; + const expected = 'M 3.12345678 2.12345678 L 1.00001111 2.40001111 Z'; + const result = joinPath(pathData); + expect(result).toBe(expected); + }); describe('makePathSimpler', () => { test('can parse paths that return NaN segments', () => { expect( diff --git a/src/util/path/index.ts b/src/util/path/index.ts index 60fc84c05d7..bcc60f111b9 100644 --- a/src/util/path/index.ts +++ b/src/util/path/index.ts @@ -19,16 +19,17 @@ import type { TPathSegmentInfoCommon, TEndPathInfo, TParsedArcCommand, + TComplexParsedCommandType, } from './typedefs'; import type { XY } from '../../Point'; import { Point } from '../../Point'; -import { rePathCommand } from './regex'; -import { cleanupSvgAttribute } from '../internals/cleanupSvgAttribute'; +import { reArcCommandPoints, rePathCommand } from './regex'; +import { reNum } from '../../parser/constants'; /** * Commands that may be repeated */ -const repeatedCommands: Record = { +const repeatedCommands: Record = { m: 'l', M: 'L', }; @@ -829,8 +830,19 @@ export const getPointOnPath = ( }; const rePathCmdAll = new RegExp(rePathCommand, 'gi'); -const rePathCmd = new RegExp(rePathCommand, 'i'); - +const regExpArcCommandPoints = new RegExp(reArcCommandPoints, 'g'); +const reMyNum = new RegExp(reNum, 'gi'); +const commandLengths = { + m: 2, + l: 2, + h: 1, + v: 1, + c: 6, + s: 4, + q: 4, + t: 2, + a: 7, +} as const; /** * * @param {string} pathString @@ -843,56 +855,49 @@ const rePathCmd = new RegExp(rePathCommand, 'i'); * ]; */ export const parsePath = (pathString: string): TComplexPathData => { - // clean the string - // add spaces around the numbers - pathString = cleanupSvgAttribute(pathString); + const chain: TComplexPathData = []; + const all = pathString.match(rePathCmdAll) ?? []; + for (const matchStr of all) { + // take match string and save the first letter as the command + const commandLetter = matchStr[0] as TComplexParsedCommandType; + // in case of Z we have very little to do + if (commandLetter === 'z' || commandLetter === 'Z') { + chain.push([commandLetter]); + continue; + } + const commandLength = + commandLengths[ + commandLetter.toLowerCase() as keyof typeof commandLengths + ]; - const res: TComplexPathData = []; - for (let [matchStr] of pathString.matchAll(rePathCmdAll)) { - const chain: TComplexPathData = []; - let paramArr: RegExpExecArray | null; - do { - paramArr = rePathCmd.exec(matchStr); - if (!paramArr) { - break; + let paramArr = []; + if (commandLetter === 'a' || commandLetter === 'A') { + // the arc command ha some peculariaties that requires a special regex other than numbers + // it is possible to avoid using a space between the sweep and large arc flags, making them either + // 00, 01, 10 or 11, making them identical to a plain number for the regex reMyNum + // reset the regexp + regExpArcCommandPoints.lastIndex = 0; + for (let out = null; (out = regExpArcCommandPoints.exec(matchStr)); ) { + paramArr.push(...out.slice(1)); } - // ignore undefined match groups - const filteredGroups = paramArr.filter((g) => g); - // remove the first element from the match array since it's just the whole command - filteredGroups.shift(); - // if we can't parse the number, just interpret it as a string - // (since it's probably the path command) - const command = filteredGroups.map((g) => { - const numParse = Number.parseFloat(g); - if (Number.isNaN(numParse)) { - return g; - } else { - return numParse; - } - }); - chain.push(command as any); - // stop now if it's a z command - if (filteredGroups.length <= 1) { - break; - } - // remove the last part of the chained command - filteredGroups.shift(); - // ` ?` is to support commands with optional spaces between flags - matchStr = matchStr.replace( - new RegExp(`${filteredGroups.join(' ?')} ?$`), - '', - ); - } while (paramArr); - // add the chain, convert multiple m's to l's in the process - chain.reverse().forEach((c, idx) => { - const transformed = repeatedCommands[c[0]]; - if (idx > 0 && (transformed == 'l' || transformed == 'L')) { - c[0] = transformed; + } else { + paramArr = matchStr.match(reMyNum) || []; + } + + // inspect the length of paramArr, if is longer than commandLength + // we are dealing with repeated commands + for (let i = 0; i < paramArr.length; i += commandLength) { + const newCommand = new Array(commandLength) as TComplexParsedCommand; + const transformedCommand = repeatedCommands[commandLetter]; + newCommand[0] = + i > 0 && transformedCommand ? transformedCommand : commandLetter; + for (let j = 0; j < commandLength; j++) { + newCommand[j + 1] = parseFloat(paramArr[i + j]); } - res.push(c); - }); + chain.push(newCommand); + } } - return res; + return chain; }; /** diff --git a/src/util/path/regex.ts b/src/util/path/regex.ts index ffa16e01b0f..600a3be6859 100644 --- a/src/util/path/regex.ts +++ b/src/util/path/regex.ts @@ -1,39 +1,46 @@ import { reNum } from '../../parser/constants'; +const commaWsp = `\\s*,?\\s*`; + /** * p for param * using "bad naming" here because it makes the regex much easier to read + * p is a number that is preceded by an arbitary number of spaces, maybe 0, + * a comma or not, and then possibly more spaces or not. */ -const p = `(${reNum})`; +const p = `${commaWsp}(${reNum})`; + +// const reMoveToCommand = `(M) ?(?:${p}${p} ?)+`; -const reMoveToCommand = `(M) (?:${p} ${p} ?)+`; +// const reLineCommand = `(L) ?(?:${p}${p} ?)+`; -const reLineCommand = `(L) (?:${p} ${p} ?)+`; +// const reHorizontalLineCommand = `(H) ?(?:${p} ?)+`; -const reHorizontalLineCommand = `(H) (?:${p} ?)+`; +// const reVerticalLineCommand = `(V) ?(?:${p} ?)+`; -const reVerticalLineCommand = `(V) (?:${p} ?)+`; +// const reClosePathCommand = String.raw`(Z)\s*`; -const reClosePathCommand = String.raw`(Z)\s*`; +// const reCubicCurveCommand = `(C) ?(?:${p}${p}${p}${p}${p}${p} ?)+`; -const reCubicCurveCommand = `(C) (?:${p} ${p} ${p} ${p} ${p} ${p} ?)+`; +// const reCubicCurveShortcutCommand = `(S) ?(?:${p}${p}${p}${p} ?)+`; -const reCubicCurveShortcutCommand = `(S) (?:${p} ${p} ${p} ${p} ?)+`; +// const reQuadraticCurveCommand = `(Q) ?(?:${p}${p}${p}${p} ?)+`; -const reQuadraticCurveCommand = `(Q) (?:${p} ${p} ${p} ${p} ?)+`; +// const reQuadraticCurveShortcutCommand = `(T) ?(?:${p}${p} ?)+`; -const reQuadraticCurveShortcutCommand = `(T) (?:${p} ${p} ?)+`; +export const reArcCommandPoints = `${p}${p}${p}${commaWsp}([01])${commaWsp}([01])${p}${p}`; +// const reArcCommand = `(A) ?(?:${reArcCommandPoints} ?)+`; -const reArcCommand = `(A) (?:${p} ${p} ${p} ([01]) ?([01]) ${p} ${p} ?)+`; +// export const rePathCommandGroups = +// `(?:(?:${reMoveToCommand})` + +// `|(?:${reLineCommand})` + +// `|(?:${reHorizontalLineCommand})` + +// `|(?:${reVerticalLineCommand})` + +// `|(?:${reClosePathCommand})` + +// `|(?:${reCubicCurveCommand})` + +// `|(?:${reCubicCurveShortcutCommand})` + +// `|(?:${reQuadraticCurveCommand})` + +// `|(?:${reQuadraticCurveShortcutCommand})` + +// `|(?:${reArcCommand}))`; -export const rePathCommand = - `(?:(?:${reMoveToCommand})` + - `|(?:${reLineCommand})` + - `|(?:${reHorizontalLineCommand})` + - `|(?:${reVerticalLineCommand})` + - `|(?:${reClosePathCommand})` + - `|(?:${reCubicCurveCommand})` + - `|(?:${reCubicCurveShortcutCommand})` + - `|(?:${reQuadraticCurveCommand})` + - `|(?:${reQuadraticCurveShortcutCommand})` + - `|(?:${reArcCommand}))`; +export const rePathCommand = '[mzlhvcsqta][^mzlhvcsqta]*'; diff --git a/src/util/path/typedefs.ts b/src/util/path/typedefs.ts index dc70b717227..231f0f4887e 100644 --- a/src/util/path/typedefs.ts +++ b/src/util/path/typedefs.ts @@ -293,6 +293,28 @@ export type TSimpleParsedCommand = export type TSimpleParseCommandType = 'L' | 'M' | 'C' | 'Q' | 'Z'; +export type TComplexParsedCommandType = + | 'M' + | 'L' + | 'C' + | 'Q' + | 'Z' + | 'z' + | 'm' + | 'l' + | 'h' + | 'v' + | 'c' + | 's' + | 'q' + | 't' + | 'a' + | 'H' + | 'V' + | 'S' + | 'T' + | 'A'; + /** * A series of simple paths */ diff --git a/test/unit/path_utils.js b/test/unit/path_utils.js deleted file mode 100644 index 5cd9f0d9c4c..00000000000 --- a/test/unit/path_utils.js +++ /dev/null @@ -1,120 +0,0 @@ -(function() { - QUnit.module('fabric.util - path.js'); - // eslint-disable-next-line max-len - var path = 'M 2 5 l 2 -2 L 4 4 h 3 H 9 C 8 3 10 3 10 3 c 1 -1 2 0 1 1 S 8 5 9 7 v 1 s 2 -1 1 2 Q 9 10 10 11 T 12 11 t -1 -1 v 2 T 10 12 S 9 12 7 11 c 0 -1 0 -1 -2 -2 z m 0 2 l 1 0 l 0 1 l -1 0 z M 1 1 a 1 1 30 1 0 2 2 A 2 2 30 1 0 6 6'; - // eslint-disable-next-line - var expectedParse = [['M',2,5],['l',2,-2],['L',4,4],['h',3],['H',9],['C',8,3,10,3,10,3],['c',1,-1,2,0,1,1],['S',8,5,9,7],['v',1],['s',2,-1,1,2],['Q',9,10,10,11],['T',12,11],['t',-1,-1],['v',2],['T',10,12],['S',9,12,7,11],['c',0,-1,0,-1,-2,-2],['z'],['m',0,2],['l',1,0],['l',0,1],['l',-1,0],['z'],['M', 1, 1], ['a', 1, 1, 30, 1, 0, 2, 2],['A', 2,2,30,1,0,6,6]]; - // eslint-disable-next-line - var expectedSimplified = [['M', 2, 5], ['L', 4, 3], ['L', 4, 4], ['L', 7, 4], ['L', 9, 4], ['C', 8, 3, 10, 3, 10, 3], ['C', 11, 2, 12, 3, 11, 4], ['C', 10, 5, 8, 5, 9, 7], ['L', 9, 8], ['C', 9, 8, 11, 7, 10, 10], ['Q', 9, 10, 10, 11], ['Q', 11, 12, 12, 11], ['Q', 13, 10, 11, 10], ['L', 11, 12], ['Q', 11, 12, 10, 12], ['C', 10, 12, 9, 12, 7, 11], ['C', 7, 10, 7, 10, 5, 9], ['Z'], ['M', 2, 7], ['L', 3, 7], ['L', 3, 8], ['L', 2, 8], ['Z'], ['M', 1, 1], ['C', 1.5522847498307932, 0.4477152501692063, 2.4477152501692068, 0.44771525016920666, 3, 1], ['C', 3.5522847498307932, 1.5522847498307937, 3.5522847498307932, 2.4477152501692063, 3, 3], ['C', 3.82842712474619, 2.1715728752538093, 5.17157287525381, 2.1715728752538097, 6, 3], ['C', 6.82842712474619, 3.82842712474619, 6.828427124746191, 5.17157287525381, 6, 6]]; - QUnit.test('fabric.util.parsePath', function(assert) { - assert.ok(typeof fabric.util.parsePath === 'function'); - assert.ok(typeof fabric.util.makePathSimpler === 'function'); - var parsed = fabric.util.parsePath(path); - parsed.forEach(function(command, index) { - assert.deepEqual(command, expectedParse[index], 'should be parsed in an array of commands ' + index); - }); - var simplified = fabric.util.makePathSimpler(parsed); - simplified.forEach(function(command, index) { - if (index > 23) { - // because firefox i have no idea. - return; - } - assert.deepEqual(command, expectedSimplified[index], 'should contain a subset of equivalent commands ' + index); - }); - }); - QUnit.test('fabric.util.parsePath can parse arcs correctly when no spaces between flags', function(assert) { - // eslint-disable-next-line max-len - var pathWithWeirdArc = 'a10.56 10.56 0 00-1.484-.133'; - var expected = ['a', 10.56, 10.56, 0, 0, 0, -1.484, -0.133]; - var parsed = fabric.util.parsePath(pathWithWeirdArc); - var command = parsed[0]; - assert.deepEqual(command, expected, 'Arc should be parsed correctly.'); - }); - QUnit.test('fabric.util.getPathSegmentsInfo', function(assert) { - assert.ok(typeof fabric.util.getPathSegmentsInfo === 'function'); - var parsed = fabric.util.makePathSimpler(fabric.util.parsePath(path)); - var infos = fabric.util.getPathSegmentsInfo(parsed); - assert.equal(infos[0].length, 0, 'the command 0 a M has a length 0'); - assert.equal(infos[1].length.toFixed(5), 2.82843, 'the command 1 a L has a length 2.828'); - assert.equal(infos[2].length, 1, 'the command 2 a L with one step on Y has a length 1'); - assert.equal(infos[3].length, 3, 'the command 3 a L with 3 step on X has a length 3'); - assert.equal(infos[4].length, 2, 'the command 4 a L with 2 step on X has a length 0'); - assert.equal(infos[5].length.toFixed(5), 2.06242, 'the command 5 a C has a approximated length of 2.062'); - assert.equal(infos[6].length.toFixed(5), 2.82832, 'the command 6 a C has a approximated length of 2.828'); - assert.equal(infos[7].length.toFixed(5), 4.18970, 'the command 7 a C has a approximated length of 4.189'); - assert.equal(infos[8].length, 1, 'the command 8 a L with 1 step on the Y has an exact length of 1'); - assert.equal(infos[9].length.toFixed(5), 3.22727, 'the command 9 a C has a approximated length of 3.227'); - assert.equal(infos[10].length.toFixed(5), 1.54026, 'the command 10 a Q has a approximated length of 1.540'); - assert.equal(infos[11].length.toFixed(5), 2.29556, 'the command 11 a Q has a approximated length of 2.295'); - }); - - QUnit.test('fabric.util.getPathSegmentsInfo test Z command', function(assert) { - assert.ok(typeof fabric.util.getPathSegmentsInfo === 'function'); - var parsed = fabric.util.makePathSimpler(fabric.util.parsePath('M 0 0 h 20, v 20 L 0, 20 Z')); - var infos = fabric.util.getPathSegmentsInfo(parsed); - assert.deepEqual(infos[0].length, 0, 'the command 0 a M has a length 0'); - assert.deepEqual(infos[1].length, 20, 'the command 1 a L has length 20'); - assert.deepEqual(infos[2].length, 20, 'the command 2 a L has length 20'); - assert.deepEqual(infos[3].length, 20, 'the command 3 a L has length 20'); - assert.deepEqual(infos[4].length, 20, 'the command 4 a Z has length 20'); - }); - - QUnit.test('fabric.util.getRegularPolygonPath', function (assert) { - - const roundDecimals = (commands) => commands.map(([cmd, x, y]) => { - if (cmd !== 'Z') { - return [cmd, x.toFixed(4), y.toFixed(4)]; - } - return ['Z']; - }) - - assert.ok(typeof fabric.util.getRegularPolygonPath === 'function'); - var penta = fabric.util.getRegularPolygonPath(5, 50); - var hexa = fabric.util.getRegularPolygonPath(6, 50); - - var expetedPenta = [ - ["M", 3.061616997868383e-15, -50], - ["L", 47.552825814757675, -15.450849718747369], - ["L", 29.389262614623657, 40.45084971874737], - ["L", -29.38926261462365, 40.45084971874737], - ["L", -47.55282581475768, -15.450849718747364], - ["Z"] - ]; - - var expetedHexa = [ - ["M", 24.999999999999993, -43.30127018922194], - ["L", 50, -1.1102230246251565e-14], - ["L", 25.000000000000018, 43.301270189221924], - ["L", -24.99999999999999, 43.30127018922194], - ["L", -50, 2.8327694488239898e-14], - ["L", -25.00000000000006, -43.301270189221896], - ["Z"] - ]; - - assert.deepEqual(roundDecimals(penta), roundDecimals(expetedPenta), 'regular pentagon should match'); - assert.deepEqual(roundDecimals(hexa), roundDecimals(expetedHexa), 'regular hexagon should match'); - }); - - QUnit.test('fabric.util.joinPath', function (assert) { - const pathData = [ - ["M", 3.12345678, 2.12345678], - ["L", 1.00001111, 2.40001111], - ["Z"], - ]; - const digit = 2; - const expected = "M 3.12 2.12 L 1 2.4 Z"; - const result = fabric.util.joinPath(pathData, digit); - assert.equal(result, expected, 'path data should have the specified number or less of fraction digits.'); - }); - - QUnit.test('fabric.util.joinPath without rounding', function (assert) { - const pathData = [ - ["M", 3.12345678, 2.12345678], - ["L", 1.00001111, 2.40001111], - ["Z"], - ]; - const expected = "M 3.12345678 2.12345678 L 1.00001111 2.40001111 Z"; - const result = fabric.util.joinPath(pathData); - assert.equal(result, expected, 'path data should have the specified number or less of fraction digits.'); - }); -})();