Skip to content

Commit a2258f2

Browse files
authored
feat: reverse path (#82)
* feat: basic setup for path reversal * feat(REVERSE_PATH): add path validation * feat(REVERSE_PATH): add tests for unsupported curve commands * feat(REVERSE_PATH): enhance tests for path reversal with edge cases * feat(REVERSE_PATH): add missing check for short curves * chore: simplify error handling * feat: enhance path reversal to support multiple subpaths * feat: add option to preserve subpath order in reverse function * fix: correct compilation issues * test: reuse testReversePath * test: mvoe test file to test folder
1 parent 5ae667e commit a2258f2

File tree

4 files changed

+336
-0
lines changed

4 files changed

+336
-0
lines changed

src/SVGPathData.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,19 @@ export class SVGPathData extends TransformableSVG {
4444
return this;
4545
}
4646

47+
/**
48+
* Reverses the order of path commands to go from end to start
49+
* IMPORTANT: This function expects absolute commands as input.
50+
* @param preserveSubpathOrder If true, keeps subpaths in their original order
51+
*/
52+
reverse(preserveSubpathOrder = true) {
53+
this.commands = SVGPathDataTransformer.REVERSE_PATH(
54+
this.commands,
55+
preserveSubpathOrder,
56+
);
57+
return this;
58+
}
59+
4760
static encode(commands: SVGCommand[]) {
4861
return encodeSVGPath(commands);
4962
}

src/SVGPathDataTransformer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type Point,
1616
} from './mathUtils.js';
1717
import { SVGPathData } from './SVGPathData.js';
18+
import { REVERSE_PATH } from './transformers/reverse_path.js';
1819
import type { SVGCommand, TransformFunction } from './types.js';
1920

2021
// Predefined transforming functions
@@ -814,4 +815,5 @@ export const SVGPathDataTransformer = {
814815
ANNOTATE_ARCS,
815816
CLONE,
816817
CALCULATE_BOUNDS,
818+
REVERSE_PATH,
817819
};

src/tests/reverse_path.test.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { describe, test, expect } from '@jest/globals';
2+
import { SVGPathData } from '../index.js';
3+
4+
export function testReversePath(input: string, preserveSubpathOrder?: boolean) {
5+
return new SVGPathData(input).reverse(preserveSubpathOrder).encode();
6+
}
7+
8+
describe('Reverse paths', () => {
9+
describe('Valid', () => {
10+
test('empty path', () => {
11+
const input = '';
12+
const expected = '';
13+
expect(testReversePath(input)).toEqual(expected);
14+
});
15+
16+
test('single point path', () => {
17+
const input = 'M10,10';
18+
// A single point path results in just a move command
19+
const expected = 'M10 10';
20+
expect(testReversePath(input)).toEqual(expected);
21+
});
22+
23+
test('simple line path', () => {
24+
const input = 'M10,10 L20,20 L30,10';
25+
const expected = 'M30 10L20 20L10 10';
26+
expect(testReversePath(input)).toEqual(expected);
27+
});
28+
29+
test('horizontal and vertical lines', () => {
30+
const input = 'M10,10 H30 V30 H10';
31+
const expected = 'M10 30H30V10H10';
32+
expect(testReversePath(input)).toEqual(expected);
33+
});
34+
35+
test('closed path (with Z command)', () => {
36+
const input = 'M10,10 L20,20 L30,10 Z';
37+
// The Z command is preserved in the reversed path
38+
const expected = 'M30 10L20 20L10 10z';
39+
expect(testReversePath(input)).toEqual(expected);
40+
});
41+
42+
test('path with cubic bezier curves', () => {
43+
const input = 'M10,10 C20,20 40,20 50,10';
44+
// Reversed path with flipped control points
45+
const expected = 'M50 10C40 20 20 20 10 10';
46+
expect(testReversePath(input)).toEqual(expected);
47+
});
48+
49+
test('path with cubic bezier curve as second command', () => {
50+
const input = 'M10,10 C20,20 30,30 40,10';
51+
const expected = 'M40 10C30 30 20 20 10 10';
52+
expect(testReversePath(input)).toEqual(expected);
53+
});
54+
55+
test('path closed both explicitly and implicitly', () => {
56+
const input = 'M10,10 L20,20 L30,10 L10,10 Z'; // Note: Last point (10,10) matches first point + Z
57+
// Should still reverse correctly and maintain Z
58+
const expected = 'M10 10L30 10L20 20L10 10z';
59+
expect(testReversePath(input)).toEqual(expected);
60+
});
61+
62+
test('path closed only implicitly (without Z command)', () => {
63+
const input = 'M10,10 L20,20 L30,10 L10,10'; // Note: Last point matches first point, but no Z
64+
// Should still reverse correctly and maintain implicit closure
65+
const expected = 'M10 10L30 10L20 20L10 10';
66+
expect(testReversePath(input)).toEqual(expected);
67+
});
68+
69+
test('complex mixed path with multiple command types', () => {
70+
const input = 'M10,10 H30 V30 L40,40 C50,50 60,40 70,30 H80 V20 Z';
71+
const expected = 'M80 20V30H70C60 40 50 50 40 40L30 30V10H10z';
72+
expect(testReversePath(input)).toEqual(expected);
73+
});
74+
75+
test('bezier curve with high precision coordinates (C)', () => {
76+
const input =
77+
'M10.123456789,10.987654321 C20.111222333,20.444555666 40.777888999,20.111222333 50.555666777,10.333222111';
78+
const expected =
79+
'M50.555666777 10.333222111C40.777888999 20.111222333 20.111222333 20.444555666 10.123456789 10.987654321';
80+
expect(testReversePath(input)).toEqual(expected);
81+
});
82+
83+
test('path with multiple subpaths (multiple M and Z commands)', () => {
84+
const input = 'M10,10 L20,20 Z M30,30 L40,40 Z';
85+
const expected = 'M20 20L10 10zM40 40L30 30z';
86+
expect(testReversePath(input)).toEqual(expected);
87+
});
88+
89+
test('path with multiple open subpaths (multiple M commands without Z)', () => {
90+
const input = 'M10,10 L20,20 M30,30 L40,40';
91+
const expected = 'M20 20L10 10M40 40L30 30';
92+
expect(testReversePath(input)).toEqual(expected);
93+
});
94+
95+
test('path with multiple subpaths and reversed subpath order', () => {
96+
const input = 'M10,10 L20,20 Z M30,30 L40,40 Z';
97+
const expected = 'M40 40L30 30zM20 20L10 10z';
98+
expect(testReversePath(input, false)).toEqual(expected);
99+
});
100+
101+
test('path with multiple open subpaths and reversed subpath order', () => {
102+
const input = 'M10,10 L20,20 M30,30 L40,40 M50,50 L60,60';
103+
const expected = 'M60 60L50 50M40 40L30 30M20 20L10 10';
104+
expect(testReversePath(input, false)).toEqual(expected);
105+
});
106+
107+
// New tests for combined HVL commands
108+
test('mixed H, V, and L commands', () => {
109+
const input = 'M10,10 H20 V20 L30,30';
110+
const expected = 'M30 30L20 20V10H10';
111+
expect(testReversePath(input)).toEqual(expected);
112+
});
113+
114+
test('alternating H and V commands', () => {
115+
const input = 'M10,10 H20 V20 H10 V30 H30';
116+
const expected = 'M30 30H10V20H20V10H10';
117+
expect(testReversePath(input)).toEqual(expected);
118+
});
119+
120+
test('H, V, and L commands with varying coordinates', () => {
121+
const input = 'M10,10 H40 V30 L20,40 H10 V10';
122+
const expected = 'M10 10V40H20L40 30V10H10';
123+
expect(testReversePath(input)).toEqual(expected);
124+
});
125+
126+
test('H and L commands in sequence', () => {
127+
const input = 'M10,10 H30 L40,20 H50';
128+
const expected = 'M50 20H40L30 10H10';
129+
expect(testReversePath(input)).toEqual(expected);
130+
});
131+
132+
test('V and L commands in sequence', () => {
133+
const input = 'M10,10 V30 L20,40 V50';
134+
const expected = 'M20 50V40L10 30V10';
135+
expect(testReversePath(input)).toEqual(expected);
136+
});
137+
});
138+
139+
describe('Invalid', () => {
140+
test('throw on relative commands', () => {
141+
const input = 'm10,10 l10,10 l10,-10';
142+
expect(() => testReversePath(input)).toThrow(
143+
'Relative command are not supported convert first with `toAbs()`',
144+
);
145+
});
146+
147+
test('throw on quadratic bezier curve (Q)', () => {
148+
const input = 'M10,10 Q25,25 40,10';
149+
expect(() => testReversePath(input)).toThrow(
150+
'Unsupported command: Q (quadratic bezier)',
151+
);
152+
});
153+
154+
test('throw on smooth cubic bezier curve (S)', () => {
155+
const input = 'M10,10 S25,25 40,10';
156+
expect(() => testReversePath(input)).toThrow(
157+
'Unsupported command: S (smooth cubic bezier)',
158+
);
159+
});
160+
161+
test('throw on smooth quadratic bezier curve (T)', () => {
162+
const input = 'M10,10 T40,10';
163+
expect(() => testReversePath(input)).toThrow(
164+
'Unsupported command: T (smooth quadratic bezier)',
165+
);
166+
});
167+
168+
test('throw on arc commands (A)', () => {
169+
const input = 'M10,10 A5,5 0 0 1 20,20';
170+
expect(() => testReversePath(input)).toThrow(
171+
'Unsupported command: A (arc)',
172+
);
173+
});
174+
});
175+
});

src/transformers/reverse_path.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { SVGPathData } from '../index.js';
2+
import { SVGPathDataTransformer } from '../SVGPathDataTransformer.js';
3+
import type { SVGCommand } from '../types.js';
4+
5+
type SVGCommandXY = SVGCommand & { x: number; y: number; relative: boolean };
6+
7+
/**
8+
* Reverses the order of path commands to go from end to start
9+
* IMPORTANT: This function expects absolute commands as input.
10+
* It doesn't convert relative to absolute - use SVGPathDataTransformer.TO_ABS() first if needed.
11+
* @param commands SVG path commands in absolute form to reverse
12+
* @param preserveSubpathOrder If true, keeps subpaths in their original order
13+
* @returns New SVG commands in reverse order with absolute coordinates
14+
*/
15+
export function REVERSE_PATH(
16+
commands: SVGCommand[],
17+
preserveSubpathOrder = true,
18+
): SVGCommand[] {
19+
if (commands.length < 2) return commands;
20+
21+
// Extract absolute points using the transformer to track current position
22+
const normalized = SVGPathDataTransformer.INFO((command, px, py) => ({
23+
...command,
24+
x: command.x ?? px,
25+
y: command.y ?? py,
26+
relative: command.relative ?? false,
27+
}));
28+
29+
const result: SVGCommand[] = [];
30+
let processing: SVGCommandXY[] = [];
31+
32+
for (const original of commands) {
33+
const cmd: SVGCommandXY = normalized(original);
34+
// Start a new subpath if needed
35+
if (cmd.type === SVGPathData.MOVE_TO && processing.length > 0) {
36+
if (preserveSubpathOrder) {
37+
result.push(...reverseSubpath(processing));
38+
} else {
39+
result.unshift(...reverseSubpath(processing));
40+
}
41+
processing = []; // Clear the current subpath
42+
}
43+
processing.push(cmd);
44+
}
45+
46+
if (processing.length > 0) {
47+
if (preserveSubpathOrder) {
48+
result.push(...reverseSubpath(processing));
49+
} else {
50+
result.unshift(...reverseSubpath(processing));
51+
}
52+
}
53+
54+
// Join the reversed subpaths in original order
55+
return result;
56+
}
57+
58+
function reverseSubpath(commands: SVGCommandXY[]): SVGCommand[] {
59+
// Check if path is explicitly closed (ends with CLOSE_PATH)
60+
const isExplicitlyClosed =
61+
commands[commands.length - 1]?.type === SVGPathData.CLOSE_PATH;
62+
63+
// Start with a move to the last explicit point
64+
// (if path ends with Z, use the point before Z)
65+
const startPointIndex = isExplicitlyClosed
66+
? commands.length - 2
67+
: commands.length - 1;
68+
69+
const reversed: SVGCommand[] = [
70+
{
71+
type: SVGPathData.MOVE_TO,
72+
relative: false,
73+
x: commands[startPointIndex].x,
74+
y: commands[startPointIndex].y,
75+
},
76+
];
77+
78+
// Process each segment in reverse order
79+
for (let i = startPointIndex; i > 0; i--) {
80+
const curCmd = commands[i];
81+
const prevPoint = commands[i - 1];
82+
83+
if (curCmd.relative) {
84+
throw new Error(
85+
'Relative command are not supported convert first with `toAbs()`',
86+
);
87+
}
88+
89+
// Handle the current command type
90+
switch (curCmd.type) {
91+
case SVGPathData.HORIZ_LINE_TO:
92+
reversed.push({
93+
type: SVGPathData.HORIZ_LINE_TO,
94+
relative: false,
95+
x: prevPoint.x,
96+
});
97+
break;
98+
case SVGPathData.VERT_LINE_TO:
99+
reversed.push({
100+
type: SVGPathData.VERT_LINE_TO,
101+
relative: false,
102+
y: prevPoint.y,
103+
});
104+
break;
105+
106+
case SVGPathData.LINE_TO:
107+
case SVGPathData.MOVE_TO:
108+
reversed.push({
109+
type: SVGPathData.LINE_TO,
110+
relative: false,
111+
x: prevPoint.x,
112+
y: prevPoint.y,
113+
});
114+
break;
115+
116+
case SVGPathData.CURVE_TO:
117+
reversed.push({
118+
type: SVGPathData.CURVE_TO,
119+
relative: false,
120+
x: prevPoint.x,
121+
y: prevPoint.y,
122+
x1: curCmd.x2,
123+
y1: curCmd.y2,
124+
x2: curCmd.x1,
125+
y2: curCmd.y1,
126+
});
127+
break;
128+
129+
case SVGPathData.SMOOTH_CURVE_TO:
130+
throw new Error(`Unsupported command: S (smooth cubic bezier)`);
131+
case SVGPathData.SMOOTH_QUAD_TO:
132+
throw new Error(`Unsupported command: T (smooth quadratic bezier)`);
133+
case SVGPathData.ARC:
134+
throw new Error(`Unsupported command: A (arc)`);
135+
case SVGPathData.QUAD_TO:
136+
throw new Error(`Unsupported command: Q (quadratic bezier)`);
137+
}
138+
}
139+
140+
// If the original path was explicitly closed, preserve the Z command
141+
if (isExplicitlyClosed) {
142+
reversed.push({ type: SVGPathData.CLOSE_PATH });
143+
}
144+
145+
return reversed;
146+
}

0 commit comments

Comments
 (0)