From a7400a7f4a4590fd31d4243d7aa17364a8930808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Wi=C5=9Bniewski?= Date: Fri, 5 Dec 2025 11:39:36 +0100 Subject: [PATCH 1/2] Implement svg path parser and normalizer --- .../src/css/svg/native/configs/path.ts | 5 +- .../src/css/svg/native/processors/colors.ts | 4 + .../src/css/svg/native/processors/index.ts | 1 + .../src/css/svg/native/processors/path.ts | 377 ++++++++++++++++++ 4 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 packages/react-native-reanimated/src/css/svg/native/processors/path.ts diff --git a/packages/react-native-reanimated/src/css/svg/native/configs/path.ts b/packages/react-native-reanimated/src/css/svg/native/configs/path.ts index 2d890ea231d6..ef5ae8036a36 100644 --- a/packages/react-native-reanimated/src/css/svg/native/configs/path.ts +++ b/packages/react-native-reanimated/src/css/svg/native/configs/path.ts @@ -4,13 +4,14 @@ // @ts-ignore RNSVG doesn't export types for web, see https://github.com/software-mansion/react-native-svg/pull/2801 import type { PathProps } from 'react-native-svg'; +import { processOpacity, processPath } from '../processors'; import type { SvgStyleBuilderConfig } from './common'; import { commonSvgProps } from './common'; // TODO: Fix me // @ts-ignore RNSVG doesn't export types for web, see https://github.com/software-mansion/react-native-svg/pull/2801 -// @ts-ignore - remove when more properties are added export const SVG_PATH_PROPERTIES_CONFIG: SvgStyleBuilderConfig = { ...commonSvgProps, - // TODO - add more properties + d: { process: processPath }, + opacity: { process: processOpacity }, }; diff --git a/packages/react-native-reanimated/src/css/svg/native/processors/colors.ts b/packages/react-native-reanimated/src/css/svg/native/processors/colors.ts index 7682466c3b61..b5739bfaf5e5 100644 --- a/packages/react-native-reanimated/src/css/svg/native/processors/colors.ts +++ b/packages/react-native-reanimated/src/css/svg/native/processors/colors.ts @@ -18,6 +18,10 @@ export const processColorSVG: ValueProcessor< > = (value) => { const processed = processColorNumber(value); + if (value === 'none') { + return 0; + } + if (processed) { return processed; } diff --git a/packages/react-native-reanimated/src/css/svg/native/processors/index.ts b/packages/react-native-reanimated/src/css/svg/native/processors/index.ts index 50468ee69f4f..a83b55dbbdd9 100644 --- a/packages/react-native-reanimated/src/css/svg/native/processors/index.ts +++ b/packages/react-native-reanimated/src/css/svg/native/processors/index.ts @@ -2,4 +2,5 @@ export * from './colors'; export * from './opacity'; export * from './others'; +export { processPath } from './path'; export { processStrokeDashArray } from './stroke'; diff --git a/packages/react-native-reanimated/src/css/svg/native/processors/path.ts b/packages/react-native-reanimated/src/css/svg/native/processors/path.ts new file mode 100644 index 000000000000..deefe92d6e3e --- /dev/null +++ b/packages/react-native-reanimated/src/css/svg/native/processors/path.ts @@ -0,0 +1,377 @@ +'use strict'; + +import { ReanimatedError, type ValueProcessor } from '../../../../common'; + +type PathCommand = [string, ...number[]]; + +export const processPath: ValueProcessor = (d) => { + let pathSegments: PathCommand[] = parsePathString(d); + pathSegments = normalizePath(pathSegments); + + const tmpFlat = pathSegments.flatMap((subArr) => subArr); + + return tmpFlat.join(' '); +}; + +const length: Record = { + a: 7, + c: 6, + h: 1, + l: 2, + m: 2, + q: 4, + s: 4, + t: 2, + v: 1, + z: 0, +}; + +const segmentPattern = /([achlmqstvz])([^achlmqstvz]*)/gi; +const numberPattern = /-?[0-9]*\.?[0-9]+(?:e[-+]?\d+)?/gi; + +function parsePathString(d: string): PathCommand[] { + const pathSegments: PathCommand[] = []; + d.replace(segmentPattern, (_, command: string, argsString: string) => { + let type = command.toLowerCase(); + const numbers = argsString.match(numberPattern); + const args = numbers ? numbers.map(Number) : []; + + if (type === 'm' && args.length > length['m']) { + pathSegments.push([command, ...args.splice(0, length['m'])]); + type = 'l'; // If m has more than 2 arguments, use them in implicit l commands + command = command === 'm' ? 'l' : 'L'; + } + + while (true) { + if (args.length === length[type]) { + pathSegments.push([command, ...args]); + return ''; + } + + if (args.length < length[type]) { + throw new ReanimatedError('Wrong path command'); + } + + pathSegments.push([command, ...args.splice(0, length[type])]); + } + }); + return pathSegments; +} + +function normalizePath(pathSegments: PathCommand[]): PathCommand[] { + const absoluteSegments = absolutizePath(pathSegments); + + const out: PathCommand[] = []; + + let curX = 0, + curY = 0; + let startX = 0, + startY = 0; + // Last control point used for S and T commands. + // If previous command wasn't a curve, these default to curX, curY. + let ctrlX = 0, + ctrlY = 0; + + // Reflect the control point around the current point: R = P + (P - C) + const reflect = ( + x: number, + y: number, + oldCtrlX: number, + oldCtrlY: number + ) => { + return [x + (x - oldCtrlX), y + (y - oldCtrlY)]; + }; + + for (const seg of absoluteSegments) { + let cmd = seg[0]; + let args = seg.slice(1) as number[]; + + let nextCtrlX: number | null = null; + let nextCtrlY: number | null = null; + + if (cmd === 'H') { + // H x -> L x curY + cmd = 'L'; + args = [args[0], curY]; + } else if (cmd === 'V') { + // V y -> L curX y + cmd = 'L'; + args = [curX, args[0]]; + } else if (cmd === 'S') { + // S x2 y2 x y -> C (reflected ctrl) x2 y2 x y + const [rX, rY] = reflect(curX, curY, ctrlX, ctrlY); + cmd = 'C'; + args = [rX, rY, args[0], args[1], args[2], args[3]]; + } else if (cmd === 'T') { + // T x y -> Q (reflected ctrl) x y + const [rX, rY] = reflect(curX, curY, ctrlX, ctrlY); + cmd = 'Q'; + args = [rX, rY, args[0], args[1]]; + } + + if (cmd === 'L') { + // L x y -> C (aligned ctrl) x y + const x = args[0]; + const y = args[1]; + + const cp1x = curX + (x - curX) / 3; + const cp1y = curY + (y - curY) / 3; + const cp2x = curX + (2 * (x - curX)) / 3; + const cp2y = curY + (2 * (y - curY)) / 3; + + // So C command doesn't use 'virtual' ctrl points + nextCtrlX = x; + nextCtrlY = y; + + cmd = 'C'; + args = [cp1x, cp1y, cp2x, cp2y, x, y]; + } else if (cmd === 'Q') { + // Q cpX cpY x y -> C (degree elevated) x y + const qCpX = args[0]; + const qCpY = args[1]; + const x = args[2]; + const y = args[3]; + + // This ensures a subsequent 'T' command reflects correctly. + nextCtrlX = qCpX; + nextCtrlY = qCpY; + + const cp1x = curX + (2 / 3) * (qCpX - curX); + const cp1y = curY + (2 / 3) * (qCpY - curY); + const cp2x = x + (2 / 3) * (qCpX - x); + const cp2y = y + (2 / 3) * (qCpY - y); + + cmd = 'C'; + args = [cp1x, cp1y, cp2x, cp2y, x, y]; + } else if (cmd === 'A') { + // A rx ry rot large sweep x y -> Multiple C commands + // Because A expands to multiple segments, we handle it explicitly here + // and skip the generic output phase. + const cubics = arcToCubic( + curX, + curY, + args[0], + args[1], + args[2], + args[3], + args[4], + args[5], + args[6] + ); + + for (const cubicArgs of cubics) { + out.push(['C', ...cubicArgs]); + } + + const last = cubics[cubics.length - 1]; + curX = last[4]; + curY = last[5]; + ctrlX = last[2]; + ctrlY = last[3]; + + continue; + } + + out.push([cmd, ...args]); + + if (cmd === 'M') { + curX = args[0]; + curY = args[1]; + startX = curX; + startY = curY; + ctrlX = curX; + ctrlY = curY; + } else if (cmd === 'C') { + curX = args[4]; + curY = args[5]; + + // If we converted Q->C, we must use the Q control point. + // Otherwise, we use the C control point (args[2], args[3]). + if (nextCtrlX !== null && nextCtrlY !== null) { + ctrlX = nextCtrlX; + ctrlY = nextCtrlY; + } else { + ctrlX = args[2]; + ctrlY = args[3]; + } + } else if (cmd === 'Z') { + curX = startX; + curY = startY; + ctrlX = curX; + ctrlY = curY; + } + } + + return out; +} + +function arcToCubic( + px: number, + py: number, + rx: number, + ry: number, + xAxisRotation: number, + largeArcFlag: number, + sweepFlag: number, + x: number, + y: number +): number[][] { + const radian = (deg: number) => (deg * Math.PI) / 180; + + // Degenerate case: + if (rx === 0 || ry === 0) { + const cp1x = px + (x - px) / 3; + const cp1y = py + (y - py) / 3; + const cp2x = px + (2 * (x - px)) / 3; + const cp2y = py + (2 * (y - py)) / 3; + return [[cp1x, cp1y, cp2x, cp2y, x, y]]; + } + + const phi = radian(xAxisRotation); + const sinPhi = Math.sin(phi); + const cosPhi = Math.cos(phi); + + // endpoint parameterization -> center parameterization + const pxp = (cosPhi * (px - x)) / 2 + (sinPhi * (py - y)) / 2; + const pyp = (-sinPhi * (px - x)) / 2 + (cosPhi * (py - y)) / 2; + + const rxSq = rx * rx; + const rySq = ry * ry; + const pxpSq = pxp * pxp; + const pypSq = pyp * pyp; + + const radCheck = pxpSq / rxSq + pypSq / rySq; + if (radCheck > 1) { + const scale = Math.sqrt(radCheck); + rx *= scale; + ry *= scale; + } + + const sign = largeArcFlag === sweepFlag ? -1 : 1; + const numerator = Math.max(0, rxSq * rySq - rxSq * pypSq - rySq * pxpSq); + const denominator = rxSq * pypSq + rySq * pxpSq; + const coef = sign * Math.sqrt(numerator / denominator); + + const cxp = coef * ((rx * pyp) / ry); + const cyp = coef * (-(ry * pxp) / rx); + + const cx = cosPhi * cxp - sinPhi * cyp + (px + x) / 2; + const cy = sinPhi * cxp + cosPhi * cyp + (py + y) / 2; + + const angle = (uX: number, uY: number, vX: number, vY: number) => { + const acosSign = uX * vY - uY * vX < 0 ? -1 : 1; + const dot = uX * vX + uY * vY; + const ratio = + dot / (Math.sqrt(uX * uX + uY * uY) * Math.sqrt(vX * vX + vY * vY)); + return acosSign * Math.acos(Math.max(-1, Math.min(1, ratio))); + }; + + const startVectorX = (pxp - cxp) / rx; + const startVectorY = (pyp - cyp) / ry; + const endVectorX = (-pxp - cxp) / rx; + const endVectorY = (-pyp - cyp) / ry; + + const startAngle = angle(1, 0, startVectorX, startVectorY); + let deltaAngle = angle(startVectorX, startVectorY, endVectorX, endVectorY); + + if (sweepFlag === 0 && deltaAngle > 0) deltaAngle -= 2 * Math.PI; + if (sweepFlag === 1 && deltaAngle < 0) deltaAngle += 2 * Math.PI; + + // Split into segments + const segments = Math.ceil(Math.abs(deltaAngle) / (Math.PI / 2)); + const segmentAngle = deltaAngle / segments; + const k = (4 / 3) * Math.tan(segmentAngle / 4); + + const cubics: number[][] = []; + + for (let i = 0; i < segments; i++) { + const a1 = startAngle + i * segmentAngle; + const a2 = startAngle + (i + 1) * segmentAngle; + + const cos1 = Math.cos(a1), + sin1 = Math.sin(a1); + const cos2 = Math.cos(a2), + sin2 = Math.sin(a2); + + // Calc control points on unit circle + // p1 = (cos1, sin1) + // p2 = (cos2, sin2) + // cp1 = p1 + k * (-sin1, cos1) + // cp2 = p2 - k * (-sin2, cos2) + const unitX1 = cos1 - k * sin1; + const unitY1 = sin1 + k * cos1; + const unitX2 = cos2 + k * sin2; + const unitY2 = sin2 - k * cos2; + + // Transform to ellipse + const transform = (uX: number, uY: number) => [ + cx + cosPhi * uX * rx - sinPhi * uY * ry, + cy + sinPhi * uX * rx + cosPhi * uY * ry, + ]; + + const [cp1x, cp1y] = transform(unitX1, unitY1); + const [cp2x, cp2y] = transform(unitX2, unitY2); + const [dstX, dstY] = transform(cos2, sin2); + + cubics.push([cp1x, cp1y, cp2x, cp2y, dstX, dstY]); + } + + return cubics; +} + +function absolutizePath(pathSegments: PathCommand[]): PathCommand[] { + let curX = 0, + curY = 0; + let startX = 0, + startY = 0; + + return pathSegments.map((seg) => { + const cmd = seg[0]; + const upperCmd = cmd.toUpperCase(); + const isRelative = cmd != upperCmd; + + const args = seg.slice(1) as number[]; + + if (isRelative) { + if (upperCmd === 'A') { + args[5] += curX; + args[6] += curY; + } else if (upperCmd === 'V') { + args[0] += curY; + } else if (upperCmd === 'H') { + args[0] += curX; + } else { + for (let i = 0; i < args.length; i += 2) { + args[i] += curX; + args[i + 1] += curY; + } + } + } + + switch (upperCmd) { + case 'Z': + curX = startX; + curY = startY; + break; + case 'H': + curX = args[0]; + break; + case 'V': + curY = args[0]; + break; + case 'M': + curX = args[0]; + curY = args[1]; + startX = curX; + startY = curY; + break; + default: + // For L, C, S, Q, T, A the last pair is the new cursor + if (args.length >= 2) { + curX = args[args.length - 2]; + curY = args[args.length - 1]; + } + } + return [upperCmd, ...args]; + }); +} From 538a9f96713e37eb00a021f73bdf1ee7599e54af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Wi=C5=9Bniewski?= Date: Fri, 5 Dec 2025 13:31:21 +0100 Subject: [PATCH 2/2] Implement svg parser tests --- .../src/css/svg/native/configs/path.ts | 4 +- .../native/processors/__tests__/path.test.ts | 176 ++++++++++++++++++ .../src/css/svg/native/processors/index.ts | 2 +- .../src/css/svg/native/processors/path.ts | 17 +- 4 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 packages/react-native-reanimated/src/css/svg/native/processors/__tests__/path.test.ts diff --git a/packages/react-native-reanimated/src/css/svg/native/configs/path.ts b/packages/react-native-reanimated/src/css/svg/native/configs/path.ts index ef5ae8036a36..9ccd9d932f9f 100644 --- a/packages/react-native-reanimated/src/css/svg/native/configs/path.ts +++ b/packages/react-native-reanimated/src/css/svg/native/configs/path.ts @@ -4,7 +4,7 @@ // @ts-ignore RNSVG doesn't export types for web, see https://github.com/software-mansion/react-native-svg/pull/2801 import type { PathProps } from 'react-native-svg'; -import { processOpacity, processPath } from '../processors'; +import { processOpacity, processSVGPath } from '../processors'; import type { SvgStyleBuilderConfig } from './common'; import { commonSvgProps } from './common'; @@ -12,6 +12,6 @@ import { commonSvgProps } from './common'; // @ts-ignore RNSVG doesn't export types for web, see https://github.com/software-mansion/react-native-svg/pull/2801 export const SVG_PATH_PROPERTIES_CONFIG: SvgStyleBuilderConfig = { ...commonSvgProps, - d: { process: processPath }, + d: { process: processSVGPath }, opacity: { process: processOpacity }, }; diff --git a/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/path.test.ts b/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/path.test.ts new file mode 100644 index 000000000000..d6e0080fcb02 --- /dev/null +++ b/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/path.test.ts @@ -0,0 +1,176 @@ +'use strict'; + +import { ReanimatedError } from '../../../../../common'; +import { ERROR_MESSAGES, processSVGPath } from '../path'; + +describe(processSVGPath, () => { + describe('M command', () => { + test.each([ + ['M 10 10', 'M 10 10'], + ['m 10 10', 'M 10 10'], + [ + 'M 10 10 20 20 30 30', + 'M 10 10 C 13.333333333333334 13.333333333333334 16.666666666666668 16.666666666666668 20 20 C 23.333333333333332 23.333333333333332 26.666666666666668 26.666666666666668 30 30', + ], + ['M 10 10 m 20 20', 'M 10 10 M 30 30'], + ['M 10 10 m 20 20 30 30', 'M 10 10 M 30 30 C 40 40 50 50 60 60'], + [ + 'M 0 0 10 10 0 10 0 0 10 0 10 10', + 'M 0 0 C 3.3333333333333335 3.3333333333333335 6.666666666666667 6.666666666666667 10 10 C 6.666666666666666 10 3.333333333333333 10 0 10 C 0 6.666666666666666 0 3.333333333333333 0 0 C 3.3333333333333335 0 6.666666666666667 0 10 0 C 10 3.3333333333333335 10 6.666666666666667 10 10', + ], + ])('for %p returns %p', (input, expected) => { + expect(processSVGPath(input)).toEqual(expected); + }); + }); + + describe('M command errors', () => { + test.each([ + ['M 10', ERROR_MESSAGES.invalidSVGPathCommand('M', [10])], + // If M has more than 2 elements, they are porvided in pairs to implicit L commands (which then turn into C commands) + ['M 10 10 10', ERROR_MESSAGES.invalidSVGPathCommand('L', [10])], + // Regexp skips all symbols that aren't proper commands so the first one taken is v + ['invalid', ERROR_MESSAGES.invalidSVGPathStart('v')], + ])('for %p returns %p', (input, err) => { + expect(() => processSVGPath(input)).toThrow(new ReanimatedError(err)); + }); + }); + + describe('L, H, V commands', () => { + test.each([ + ['M 0 0 L 30 30', 'M 0 0 C 10 10 20 20 30 30'], + ['M 10 10 l 30 30', 'M 10 10 C 20 20 30 30 40 40'], + ['M 0 0 H 30', 'M 0 0 C 10 0 20 0 30 0'], + ['M 10 10 h 30', 'M 10 10 C 20 10 30 10 40 10'], + ['M 0 0 V 30', 'M 0 0 C 0 10 0 20 0 30'], + + ['M 10 10 v 30', 'M 10 10 C 10 20 10 30 10 40'], + + // Implicit L commands (numbers following an L, H, or V that exceed arg length) + // M 0 0 L 30 30 60 60 -> M 0 0 L 30 30 L 60 60 + ['M 0 0 L 30 30 60 60', 'M 0 0 C 10 10 20 20 30 30 C 40 40 50 50 60 60'], + ])('for %p returns %p', (input, expected) => { + expect(processSVGPath(input)).toEqual(expected); + }); + }); + + describe('C and S commands (Cubic)', () => { + test.each([ + ['M 0 0 C 10 10 20 20 30 30', 'M 0 0 C 10 10 20 20 30 30'], + ['M 10 10 c 10 10 20 20 30 30', 'M 10 10 C 20 20 30 30 40 40'], + [ + 'M 0 0 C 10 10 20 20 30 30 S 50 50 60 60', + 'M 0 0 C 10 10 20 20 30 30 C 40 40 50 50 60 60', + ], + [ + 'M 0 0 L 30 30 S 50 50 60 60', + 'M 0 0 C 10 10 20 20 30 30 C 30 30 50 50 60 60', + ], + ])('for %p returns %p', (input, expected) => { + expect(processSVGPath(input)).toEqual(expected); + }); + }); + + describe('Q and T commands', () => { + test.each([ + ['M 0 0 Q 15 15 30 0', 'M 0 0 C 10 10 20 10 30 0'], + [ + 'M 0 0 Q 15 15 30 0 T 60 0', + 'M 0 0 C 10 10 20 10 30 0 C 40 -10 50 -10 60 0', + ], + [ + 'M 0 0 Q 30 30 60 0 Q 90 -30 120 0', + 'M 0 0 C 20 20 40 20 60 0 C 80 -20 100 -20 120 0', + ], + [ + 'M 0 0 L 30 30 T 60 60', + 'M 0 0 C 10 10 20 20 30 30 C 30 30 40 40 60 60', + ], + [ + 'M 0 0 q 15 15 30 0 t 30 0', + 'M 0 0 C 10 10 20 10 30 0 C 40 -10 50 -10 60 0', + ], + [ + 'M 0 0 Q 10 10 20 0 T 40 0 T 60 0', + 'M 0 0 C 6.666666666666666 6.666666666666666 13.333333333333334 6.666666666666666 20 0 C 26.666666666666664 -6.666666666666666 33.333333333333336 -6.666666666666666 40 0 C 46.666666666666664 6.666666666666666 53.333333333333336 6.666666666666666 60 0', + ], + ])('for %p returns %p', (input, expected) => { + expect(processSVGPath(input)).toEqual(expected); + }); + }); + + describe('A (Arc) and Z (Close) commands', () => { + test.each([ + ['M 0 0 A 0 0 0 0 0 30 30', 'M 0 0 C 10 10 20 20 30 30'], + [ + 'M 1 0 A 1 1 0 0 1 0 1', + 'M 1 0 C 1 0.5522847498307933 0.5522847498307935 1 6.123233995736766e-17 1', + ], + [ + 'M -1 0 A 1 1 0 0 1 1 0', + 'M -1 0 C -1 -0.5522847498307932 -0.5522847498307936 -0.9999999999999999 -1.8369701987210297e-16 -1 C 0.5522847498307931 -1 0.9999999999999999 -0.5522847498307936 1 -2.4492935982947064e-16', + ], + [ + 'M 1 0 A 1 1 0 0 0 0 1', + 'M 1 0 C 0.44771525016920677 0 1.1102230246251565e-16 0.44771525016920655 0 0.9999999999999999', + ], + [ + 'M -1 0 A 1 1 0 1 1 1 0', + 'M -1 0 C -1 -0.5522847498307932 -0.5522847498307936 -0.9999999999999999 -1.8369701987210297e-16 -1 C 0.5522847498307931 -1 0.9999999999999999 -0.5522847498307936 1 -2.4492935982947064e-16', + ], + [ + 'M 0 0 A 1 1 0 0 1 4 0', + 'M 0 0 C 0 -1.1045694996615865 0.8954305003384129 -1.9999999999999998 1.9999999999999996 -2 C 3.1045694996615865 -2 4 -1.1045694996615871 4 -4.898587196589413e-16', + ], + [ + 'M 0 0 A 2 1 90 0 1 2 2', + 'M 0 0 C 0.2761423749153967 -1.104569499661586 0.9477152501692064 -1.5522847498307932 1.4999999999999998 -1 C 2.052284749830793 -0.4477152501692068 2.2761423749153966 0.8954305003384129 2 1.9999999999999996', + ], + ['M 0 0 L 30 30 Z', 'M 0 0 C 10 10 20 20 30 30 Z'], + [ + 'M 0 0 L 10 10 Z M 50 50 L 60 60 Z', + 'M 0 0 C 3.3333333333333335 3.3333333333333335 6.666666666666667 6.666666666666667 10 10 Z M 50 50 C 53.333333333333336 53.333333333333336 56.666666666666664 56.666666666666664 60 60 Z', + ], + ])('for %p returns %p', (input, expected) => { + expect(processSVGPath(input)).toEqual(expected); + }); + }); + + describe('Number Parsing', () => { + test.each([ + ['M 1e2 10', 'M 100 10'], + ['M 10 1e-1', 'M 10 0.1'], + ['M .5 .5', 'M 0.5 0.5'], + [ + 'M 10 10-10-10', + 'M 10 10 C 3.333333333333333 3.333333333333333 -3.333333333333334 -3.333333333333334 -10 -10', + ], + ])('for %p returns %p', (input, expected) => { + expect(processSVGPath(input)).toEqual(expected); + }); + }); + + describe('Validation Errors', () => { + test.each([ + ['M 0 0 L 10', ERROR_MESSAGES.invalidSVGPathCommand('L', [10])], + [ + 'M 0 0 C 10 10 20 20', + ERROR_MESSAGES.invalidSVGPathCommand('C', [10, 10, 20, 20]), + ], + [ + 'M 0 0 A 10 10 0 0 1', + ERROR_MESSAGES.invalidSVGPathCommand('A', [10, 10, 0, 0, 1]), + ], + ['bdd M 10 10', 'M 10 10'], + ['bad M 10 10', ERROR_MESSAGES.invalidSVGPathStart('a')], + ['M 10 10 bad', ERROR_MESSAGES.invalidSVGPathCommand('a', [])], + ])('throws error for %p', (input, expectedMsg) => { + if (typeof expectedMsg === 'string' && !expectedMsg.includes('Invalid')) { + expect(processSVGPath(input)).toEqual(expectedMsg); + } else { + expect(() => processSVGPath(input)).toThrow( + new ReanimatedError(expectedMsg) + ); + } + }); + }); +}); diff --git a/packages/react-native-reanimated/src/css/svg/native/processors/index.ts b/packages/react-native-reanimated/src/css/svg/native/processors/index.ts index a83b55dbbdd9..e0808ef6597c 100644 --- a/packages/react-native-reanimated/src/css/svg/native/processors/index.ts +++ b/packages/react-native-reanimated/src/css/svg/native/processors/index.ts @@ -2,5 +2,5 @@ export * from './colors'; export * from './opacity'; export * from './others'; -export { processPath } from './path'; +export { processSVGPath } from './path'; export { processStrokeDashArray } from './stroke'; diff --git a/packages/react-native-reanimated/src/css/svg/native/processors/path.ts b/packages/react-native-reanimated/src/css/svg/native/processors/path.ts index deefe92d6e3e..c6b9b57a9680 100644 --- a/packages/react-native-reanimated/src/css/svg/native/processors/path.ts +++ b/packages/react-native-reanimated/src/css/svg/native/processors/path.ts @@ -2,9 +2,16 @@ import { ReanimatedError, type ValueProcessor } from '../../../../common'; +export const ERROR_MESSAGES = { + invalidSVGPathCommand: (command: unknown, args: unknown) => + `Invalid SVG Path command: ${JSON.stringify(command)} ${JSON.stringify(args)}`, + invalidSVGPathStart: (command: string) => + `Invalid SVG Path: Path must start with "M" or "m", but found "${command}"`, +}; + type PathCommand = [string, ...number[]]; -export const processPath: ValueProcessor = (d) => { +export const processSVGPath: ValueProcessor = (d) => { let pathSegments: PathCommand[] = parsePathString(d); pathSegments = normalizePath(pathSegments); @@ -36,6 +43,10 @@ function parsePathString(d: string): PathCommand[] { const numbers = argsString.match(numberPattern); const args = numbers ? numbers.map(Number) : []; + if (pathSegments.length === 0 && type !== 'm') { + throw new ReanimatedError(ERROR_MESSAGES.invalidSVGPathStart(command)); + } + if (type === 'm' && args.length > length['m']) { pathSegments.push([command, ...args.splice(0, length['m'])]); type = 'l'; // If m has more than 2 arguments, use them in implicit l commands @@ -49,7 +60,9 @@ function parsePathString(d: string): PathCommand[] { } if (args.length < length[type]) { - throw new ReanimatedError('Wrong path command'); + throw new ReanimatedError( + ERROR_MESSAGES.invalidSVGPathCommand(command, args) + ); } pathSegments.push([command, ...args.splice(0, length[type])]);