Skip to content

Commit dc7e045

Browse files
authored
Merge pull request #96 from armano2/feat/remove_colinear_transformer
feat: add removeCollinear to optimize SVG path data
2 parents 02a526d + 161e4e1 commit dc7e045

File tree

4 files changed

+137
-0
lines changed

4 files changed

+137
-0
lines changed

src/SVGPathData.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ export class SVGPathData extends TransformableSVG {
5757
return this;
5858
}
5959

60+
removeCollinear() {
61+
this.commands = SVGPathDataTransformer.REMOVE_COLLINEAR(this.commands);
62+
return this;
63+
}
64+
6065
static encode(commands: SVGCommand[]) {
6166
return encodeSVGPath(commands);
6267
}

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 { REMOVE_COLLINEAR } from './transformers/remove_collinear.js';
1819
import { REVERSE_PATH } from './transformers/reverse_path.js';
1920
import type { SVGCommand, TransformFunction } from './types.js';
2021

@@ -816,4 +817,5 @@ export const SVGPathDataTransformer = {
816817
CLONE,
817818
CALCULATE_BOUNDS,
818819
REVERSE_PATH,
820+
REMOVE_COLLINEAR,
819821
};

src/tests/remove_collinear.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, test, expect } from '@jest/globals';
2+
3+
import { SVGPathData } from '../SVGPathData.js';
4+
5+
function testRemoveCollinear(path: string): string {
6+
const pathData = new SVGPathData(path).removeCollinear();
7+
return pathData.encode();
8+
}
9+
10+
describe('Remove collinear points', () => {
11+
test('Horizontal line with collinear point', () => {
12+
expect(testRemoveCollinear('M10,10 L20,10 L30,10')).toEqual('M10 10L30 10');
13+
});
14+
15+
test('Diagonal line with collinear point', () => {
16+
expect(testRemoveCollinear('M10,10 L20,20 L30,30')).toEqual('M10 10L30 30');
17+
});
18+
19+
test('Vertical line with collinear point', () => {
20+
expect(testRemoveCollinear('M10,10 L10,20 L10,30')).toEqual('M10 10L10 30');
21+
});
22+
23+
test('Multiple collinear sections', () => {
24+
expect(
25+
testRemoveCollinear(
26+
'M10,10 L20,10 L30,10 L40,20 L50,30 L60,40 L60,50 L60,60',
27+
),
28+
).toEqual('M10 10L30 10L60 40L60 60');
29+
});
30+
31+
test('Preserves curves', () => {
32+
expect(
33+
testRemoveCollinear('M10,10 C20,20 30,20 40,10 L50,10 L60,10'),
34+
).toEqual('M10 10C20 20 30 20 40 10L60 10');
35+
});
36+
37+
test('Preserves closed paths', () => {
38+
expect(testRemoveCollinear('M10,10 L20,10 L30,10 L30,20 L10,20 Z')).toEqual(
39+
'M10 10L30 10L30 20L10 20z',
40+
);
41+
});
42+
43+
test('Handles multiple subpaths', () => {
44+
expect(
45+
testRemoveCollinear('M10,10 L20,10 L30,10 M40,40 L50,40 L60,40'),
46+
).toEqual('M10 10L30 10M40 40L60 40');
47+
});
48+
49+
// Tests for relative paths
50+
test('Relative horizontal line with collinear point', () => {
51+
expect(testRemoveCollinear('m10,10 l10,0 l10,0')).toEqual('m10 10l20 0');
52+
});
53+
54+
test('Relative diagonal line with collinear point', () => {
55+
expect(testRemoveCollinear('m10,10 l10,10 l10,10')).toEqual('m10 10l20 20');
56+
});
57+
58+
test('Relative vertical line with collinear point', () => {
59+
expect(testRemoveCollinear('m10,10 l0,10 l0,10')).toEqual('m10 10l0 20');
60+
});
61+
62+
test('Mixed relative and absolute commands', () => {
63+
expect(testRemoveCollinear('M10,10 L20,10 l10,0 l10,0')).toEqual(
64+
'M10 10l30 0',
65+
);
66+
});
67+
68+
test('Relative multiple collinear sections', () => {
69+
expect(
70+
testRemoveCollinear(
71+
'm10,10 l10,0 l10,0 l10,10 l10,10 l10,10 l0,10 l0,10',
72+
),
73+
).toEqual('m10 10l20 0l30 30l0 20');
74+
});
75+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { SVGPathData } from '../SVGPathData.js';
2+
import { SVGPathDataTransformer } from '../index.js';
3+
import { arePointsCollinear, type Point } from '../mathUtils.js';
4+
import type { SVGCommand } from '../types.js';
5+
6+
/**
7+
* Process a path and remove collinear points
8+
* @param commands Array of SVG path commands to process (must be absolute)
9+
* @returns New array with collinear points removed
10+
*/
11+
export function REMOVE_COLLINEAR(commands: SVGCommand[]): SVGCommand[] {
12+
if (commands.length <= 2) return commands; // exit early if there are less than 3 points
13+
14+
const results: SVGCommand[] = [];
15+
16+
const points: Point[] = commands.map(
17+
SVGPathDataTransformer.INFO((cmd, pXAbs, pYAbs) => {
18+
// Calculate absolute coordinates and normlise HV
19+
const isRelatve = 'relative' in cmd && cmd.relative;
20+
return [
21+
'x' in cmd ? cmd.x + (isRelatve ? pXAbs : 0) : pXAbs,
22+
'y' in cmd ? cmd.y + (isRelatve ? pYAbs : 0) : pYAbs,
23+
];
24+
}),
25+
);
26+
27+
let prevPoint = points[0];
28+
results.push(commands[0]); // always keep the first point
29+
30+
for (let i = 1; i < commands.length; i++) {
31+
const cmd = commands[i];
32+
const nextCmd = commands[i + 1];
33+
34+
if (
35+
i < commands.length - 1 &&
36+
nextCmd &&
37+
cmd.type & SVGPathData.LINE_COMMANDS &&
38+
nextCmd.type & SVGPathData.LINE_COMMANDS
39+
) {
40+
const nextPoint = points[i + 1];
41+
// Check triplets of points for collinearity
42+
if (arePointsCollinear(prevPoint, points[i], nextPoint)) {
43+
// update next point if its relative
44+
if ('relative' in nextCmd && nextCmd.relative) {
45+
if ('x' in nextCmd) nextCmd.x = nextPoint[0] - prevPoint[0];
46+
if ('y' in nextCmd) nextCmd.y = nextPoint[1] - prevPoint[1];
47+
}
48+
continue;
49+
}
50+
}
51+
results.push(cmd);
52+
prevPoint = points[i];
53+
}
54+
return results;
55+
}

0 commit comments

Comments
 (0)