@emasoft/svg-matrix 1.0.18 → 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Douglas-Peucker Path Simplification Algorithm
3
+ *
4
+ * Reduces the number of points in a polyline while preserving its shape.
5
+ * The algorithm recursively finds the point furthest from the line segment
6
+ * and keeps it if the distance exceeds the tolerance.
7
+ *
8
+ * Time complexity: O(n^2) worst case, O(n log n) average
9
+ *
10
+ * @module douglas-peucker
11
+ */
12
+
13
+ /**
14
+ * Calculate perpendicular distance from a point to a line segment.
15
+ * @param {{x: number, y: number}} point - The point
16
+ * @param {{x: number, y: number}} lineStart - Line segment start
17
+ * @param {{x: number, y: number}} lineEnd - Line segment end
18
+ * @returns {number} Perpendicular distance
19
+ */
20
+ export function perpendicularDistance(point, lineStart, lineEnd) {
21
+ const dx = lineEnd.x - lineStart.x;
22
+ const dy = lineEnd.y - lineStart.y;
23
+
24
+ // Handle degenerate case where line segment is a point
25
+ const lineLengthSq = dx * dx + dy * dy;
26
+ if (lineLengthSq < 1e-10) {
27
+ const pdx = point.x - lineStart.x;
28
+ const pdy = point.y - lineStart.y;
29
+ return Math.sqrt(pdx * pdx + pdy * pdy);
30
+ }
31
+
32
+ // Calculate perpendicular distance using cross product formula
33
+ // |((y2-y1)*x0 - (x2-x1)*y0 + x2*y1 - y2*x1)| / sqrt((y2-y1)^2 + (x2-x1)^2)
34
+ const numerator = Math.abs(
35
+ dy * point.x - dx * point.y + lineEnd.x * lineStart.y - lineEnd.y * lineStart.x
36
+ );
37
+ const denominator = Math.sqrt(lineLengthSq);
38
+
39
+ return numerator / denominator;
40
+ }
41
+
42
+ /**
43
+ * Douglas-Peucker simplification algorithm (recursive implementation).
44
+ * @param {Array<{x: number, y: number}>} points - Array of points
45
+ * @param {number} tolerance - Maximum allowed deviation
46
+ * @returns {Array<{x: number, y: number}>} Simplified points
47
+ */
48
+ export function douglasPeucker(points, tolerance) {
49
+ if (points.length <= 2) {
50
+ return points;
51
+ }
52
+
53
+ // Find the point with maximum distance from the line between first and last
54
+ let maxDistance = 0;
55
+ let maxIndex = 0;
56
+
57
+ const first = points[0];
58
+ const last = points[points.length - 1];
59
+
60
+ for (let i = 1; i < points.length - 1; i++) {
61
+ const dist = perpendicularDistance(points[i], first, last);
62
+ if (dist > maxDistance) {
63
+ maxDistance = dist;
64
+ maxIndex = i;
65
+ }
66
+ }
67
+
68
+ // If max distance exceeds tolerance, recursively simplify both halves
69
+ if (maxDistance > tolerance) {
70
+ const leftHalf = douglasPeucker(points.slice(0, maxIndex + 1), tolerance);
71
+ const rightHalf = douglasPeucker(points.slice(maxIndex), tolerance);
72
+
73
+ // Combine results (remove duplicate point at junction)
74
+ return leftHalf.slice(0, -1).concat(rightHalf);
75
+ } else {
76
+ // All points are within tolerance, keep only endpoints
77
+ return [first, last];
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Visvalingam-Whyatt simplification algorithm.
83
+ * Removes points based on the area of the triangle they form.
84
+ * Better for preserving overall shape character than Douglas-Peucker.
85
+ *
86
+ * Time complexity: O(n log n) with heap, O(n^2) without
87
+ *
88
+ * @param {Array<{x: number, y: number}>} points - Array of points
89
+ * @param {number} minArea - Minimum triangle area to keep
90
+ * @returns {Array<{x: number, y: number}>} Simplified points
91
+ */
92
+ export function visvalingamWhyatt(points, minArea) {
93
+ if (points.length <= 2) {
94
+ return points;
95
+ }
96
+
97
+ // Calculate triangle area for three points
98
+ const triangleArea = (p1, p2, p3) => {
99
+ return Math.abs(
100
+ (p2.x - p1.x) * (p3.y - p1.y) - (p3.x - p1.x) * (p2.y - p1.y)
101
+ ) / 2;
102
+ };
103
+
104
+ // Create a copy with area information
105
+ const pts = points.map((p, i) => ({ ...p, index: i }));
106
+
107
+ // Calculate initial areas
108
+ const areas = new Array(pts.length).fill(Infinity);
109
+ for (let i = 1; i < pts.length - 1; i++) {
110
+ areas[i] = triangleArea(pts[i - 1], pts[i], pts[i + 1]);
111
+ }
112
+
113
+ // Repeatedly remove the point with smallest area
114
+ const kept = new Array(pts.length).fill(true);
115
+ let remaining = pts.length;
116
+
117
+ while (remaining > 2) {
118
+ // Find point with minimum area
119
+ let minAreaValue = Infinity;
120
+ let minIndex = -1;
121
+
122
+ for (let i = 1; i < pts.length - 1; i++) {
123
+ if (kept[i] && areas[i] < minAreaValue) {
124
+ minAreaValue = areas[i];
125
+ minIndex = i;
126
+ }
127
+ }
128
+
129
+ // Stop if minimum area exceeds threshold
130
+ if (minAreaValue >= minArea || minIndex === -1) {
131
+ break;
132
+ }
133
+
134
+ // Remove this point
135
+ kept[minIndex] = false;
136
+ remaining--;
137
+
138
+ // Update areas of neighbors
139
+ let prevIndex = minIndex - 1;
140
+ while (prevIndex >= 0 && !kept[prevIndex]) prevIndex--;
141
+
142
+ let nextIndex = minIndex + 1;
143
+ while (nextIndex < pts.length && !kept[nextIndex]) nextIndex++;
144
+
145
+ if (prevIndex > 0) {
146
+ let prevPrevIndex = prevIndex - 1;
147
+ while (prevPrevIndex >= 0 && !kept[prevPrevIndex]) prevPrevIndex--;
148
+ if (prevPrevIndex >= 0 && nextIndex < pts.length) {
149
+ areas[prevIndex] = triangleArea(pts[prevPrevIndex], pts[prevIndex], pts[nextIndex]);
150
+ }
151
+ }
152
+
153
+ if (nextIndex < pts.length - 1) {
154
+ let nextNextIndex = nextIndex + 1;
155
+ while (nextNextIndex < pts.length && !kept[nextNextIndex]) nextNextIndex++;
156
+ if (nextNextIndex < pts.length && prevIndex >= 0) {
157
+ areas[nextIndex] = triangleArea(pts[prevIndex], pts[nextIndex], pts[nextNextIndex]);
158
+ }
159
+ }
160
+ }
161
+
162
+ // Collect kept points
163
+ return pts.filter((_, i) => kept[i]);
164
+ }
165
+
166
+ /**
167
+ * Simplify a polyline using the specified algorithm.
168
+ * @param {Array<{x: number, y: number}>} points - Array of points
169
+ * @param {number} tolerance - Simplification tolerance
170
+ * @param {'douglas-peucker' | 'visvalingam'} algorithm - Algorithm to use
171
+ * @returns {Array<{x: number, y: number}>} Simplified points
172
+ */
173
+ export function simplifyPolyline(points, tolerance, algorithm = 'douglas-peucker') {
174
+ if (algorithm === 'visvalingam') {
175
+ // For Visvalingam, tolerance is the minimum triangle area
176
+ return visvalingamWhyatt(points, tolerance * tolerance);
177
+ }
178
+ return douglasPeucker(points, tolerance);
179
+ }
180
+
181
+ /**
182
+ * Extract points from SVG path commands (L, l, H, h, V, v only).
183
+ * @param {Array<{command: string, args: number[]}>} commands - Path commands
184
+ * @returns {Array<{x: number, y: number}>} Extracted points
185
+ */
186
+ export function extractPolylinePoints(commands) {
187
+ const points = [];
188
+ let cx = 0, cy = 0;
189
+ let startX = 0, startY = 0;
190
+
191
+ for (const { command, args } of commands) {
192
+ switch (command) {
193
+ case 'M':
194
+ cx = args[0]; cy = args[1];
195
+ startX = cx; startY = cy;
196
+ points.push({ x: cx, y: cy });
197
+ break;
198
+ case 'm':
199
+ cx += args[0]; cy += args[1];
200
+ startX = cx; startY = cy;
201
+ points.push({ x: cx, y: cy });
202
+ break;
203
+ case 'L':
204
+ cx = args[0]; cy = args[1];
205
+ points.push({ x: cx, y: cy });
206
+ break;
207
+ case 'l':
208
+ cx += args[0]; cy += args[1];
209
+ points.push({ x: cx, y: cy });
210
+ break;
211
+ case 'H':
212
+ cx = args[0];
213
+ points.push({ x: cx, y: cy });
214
+ break;
215
+ case 'h':
216
+ cx += args[0];
217
+ points.push({ x: cx, y: cy });
218
+ break;
219
+ case 'V':
220
+ cy = args[0];
221
+ points.push({ x: cx, y: cy });
222
+ break;
223
+ case 'v':
224
+ cy += args[0];
225
+ points.push({ x: cx, y: cy });
226
+ break;
227
+ case 'Z':
228
+ case 'z':
229
+ if (cx !== startX || cy !== startY) {
230
+ points.push({ x: startX, y: startY });
231
+ }
232
+ cx = startX; cy = startY;
233
+ break;
234
+ // For curves (C, S, Q, T, A), we just track the endpoint
235
+ case 'C':
236
+ cx = args[4]; cy = args[5]; break;
237
+ case 'c':
238
+ cx += args[4]; cy += args[5]; break;
239
+ case 'S': case 'Q':
240
+ cx = args[2]; cy = args[3]; break;
241
+ case 's': case 'q':
242
+ cx += args[2]; cy += args[3]; break;
243
+ case 'T':
244
+ cx = args[0]; cy = args[1]; break;
245
+ case 't':
246
+ cx += args[0]; cy += args[1]; break;
247
+ case 'A':
248
+ cx = args[5]; cy = args[6]; break;
249
+ case 'a':
250
+ cx += args[5]; cy += args[6]; break;
251
+ }
252
+ }
253
+
254
+ return points;
255
+ }
256
+
257
+ /**
258
+ * Rebuild path commands from simplified polyline points.
259
+ * @param {Array<{x: number, y: number}>} points - Simplified points
260
+ * @param {boolean} closed - Whether the path is closed
261
+ * @returns {Array<{command: string, args: number[]}>} Path commands
262
+ */
263
+ export function rebuildPathFromPoints(points, closed = false) {
264
+ if (points.length === 0) return [];
265
+
266
+ const commands = [];
267
+
268
+ // First point is M
269
+ commands.push({ command: 'M', args: [points[0].x, points[0].y] });
270
+
271
+ // Remaining points are L
272
+ for (let i = 1; i < points.length; i++) {
273
+ commands.push({ command: 'L', args: [points[i].x, points[i].y] });
274
+ }
275
+
276
+ if (closed) {
277
+ commands.push({ command: 'Z', args: [] });
278
+ }
279
+
280
+ return commands;
281
+ }
282
+
283
+ /**
284
+ * Check if a path is a pure polyline (only M, L, H, V, Z commands).
285
+ * @param {Array<{command: string, args: number[]}>} commands - Path commands
286
+ * @returns {boolean} True if pure polyline
287
+ */
288
+ export function isPurePolyline(commands) {
289
+ const polylineCommands = new Set(['M', 'm', 'L', 'l', 'H', 'h', 'V', 'v', 'Z', 'z']);
290
+ return commands.every(cmd => polylineCommands.has(cmd.command));
291
+ }
292
+
293
+ /**
294
+ * Simplify a path if it's a pure polyline.
295
+ * @param {Array<{command: string, args: number[]}>} commands - Path commands
296
+ * @param {number} tolerance - Simplification tolerance
297
+ * @param {string} algorithm - Algorithm to use
298
+ * @returns {{commands: Array<{command: string, args: number[]}>, simplified: boolean, originalPoints: number, simplifiedPoints: number}}
299
+ */
300
+ export function simplifyPath(commands, tolerance, algorithm = 'douglas-peucker') {
301
+ if (!isPurePolyline(commands) || commands.length < 3) {
302
+ return {
303
+ commands,
304
+ simplified: false,
305
+ originalPoints: 0,
306
+ simplifiedPoints: 0
307
+ };
308
+ }
309
+
310
+ const points = extractPolylinePoints(commands);
311
+ const originalCount = points.length;
312
+
313
+ if (originalCount < 3) {
314
+ return {
315
+ commands,
316
+ simplified: false,
317
+ originalPoints: originalCount,
318
+ simplifiedPoints: originalCount
319
+ };
320
+ }
321
+
322
+ // Check if path is closed
323
+ const isClosed = commands[commands.length - 1].command.toLowerCase() === 'z';
324
+
325
+ const simplifiedPoints = simplifyPolyline(points, tolerance, algorithm);
326
+ const simplifiedCount = simplifiedPoints.length;
327
+
328
+ if (simplifiedCount >= originalCount) {
329
+ return {
330
+ commands,
331
+ simplified: false,
332
+ originalPoints: originalCount,
333
+ simplifiedPoints: originalCount
334
+ };
335
+ }
336
+
337
+ const newCommands = rebuildPathFromPoints(simplifiedPoints, isClosed);
338
+
339
+ return {
340
+ commands: newCommands,
341
+ simplified: true,
342
+ originalPoints: originalCount,
343
+ simplifiedPoints: simplifiedCount
344
+ };
345
+ }
346
+
347
+ export default {
348
+ perpendicularDistance,
349
+ douglasPeucker,
350
+ visvalingamWhyatt,
351
+ simplifyPolyline,
352
+ extractPolylinePoints,
353
+ rebuildPathFromPoints,
354
+ isPurePolyline,
355
+ simplifyPath
356
+ };
@@ -339,6 +339,23 @@ function resolveAllPatterns(root, defsMap, opts) {
339
339
  if (!patternEl || patternEl.tagName !== 'pattern') continue;
340
340
 
341
341
  try {
342
+ // Check coordinate system units - skip if non-default
343
+ const patternUnits = patternEl.getAttribute('patternUnits') || 'objectBoundingBox';
344
+ const patternContentUnits = patternEl.getAttribute('patternContentUnits') || 'userSpaceOnUse';
345
+ const patternTransform = patternEl.getAttribute('patternTransform');
346
+
347
+ // PatternResolver handles these cases, but warn about complex patterns
348
+ // that might need special handling or cause issues
349
+ if (patternUnits !== 'objectBoundingBox') {
350
+ errors.push(`pattern ${refId}: non-default patternUnits="${patternUnits}" may cause rendering issues`);
351
+ }
352
+ if (patternContentUnits !== 'userSpaceOnUse') {
353
+ errors.push(`pattern ${refId}: non-default patternContentUnits="${patternContentUnits}" may cause rendering issues`);
354
+ }
355
+ if (patternTransform) {
356
+ errors.push(`pattern ${refId}: patternTransform present - complex transformation may cause rendering issues`);
357
+ }
358
+
342
359
  // Get element bounding box (approximate from path data or attributes)
343
360
  const bbox = getElementBBox(el);
344
361
  if (!bbox) continue;
@@ -404,6 +421,20 @@ function resolveAllMasks(root, defsMap, opts) {
404
421
  if (!maskEl || maskEl.tagName !== 'mask') continue;
405
422
 
406
423
  try {
424
+ // Check coordinate system units
425
+ const maskUnits = maskEl.getAttribute('maskUnits') || 'objectBoundingBox';
426
+ const maskContentUnits = maskEl.getAttribute('maskContentUnits') || 'userSpaceOnUse';
427
+
428
+ // Default for mask is different from clipPath:
429
+ // maskUnits defaults to objectBoundingBox
430
+ // maskContentUnits defaults to userSpaceOnUse
431
+ if (maskUnits !== 'objectBoundingBox') {
432
+ errors.push(`mask ${refId}: non-default maskUnits="${maskUnits}" may cause rendering issues`);
433
+ }
434
+ if (maskContentUnits !== 'userSpaceOnUse') {
435
+ errors.push(`mask ${refId}: non-default maskContentUnits="${maskContentUnits}" may cause rendering issues`);
436
+ }
437
+
407
438
  // Get element bounding box
408
439
  const bbox = getElementBBox(el);
409
440
  if (!bbox) continue;
@@ -479,6 +510,18 @@ function applyAllClipPaths(root, defsMap, opts, stats) {
479
510
  if (!clipPathEl || clipPathEl.tagName !== 'clippath') continue;
480
511
 
481
512
  try {
513
+ // Check coordinate system units
514
+ const clipPathUnits = clipPathEl.getAttribute('clipPathUnits') || 'userSpaceOnUse';
515
+
516
+ // userSpaceOnUse is the default and normal case for clipPath
517
+ // objectBoundingBox means coordinates are 0-1 relative to bounding box
518
+ if (clipPathUnits === 'objectBoundingBox') {
519
+ // This requires transforming clip coordinates based on target element's bbox
520
+ // which is complex - warn about it
521
+ errors.push(`clipPath ${refId}: objectBoundingBox units require bbox-relative coordinate transformation`);
522
+ // Note: We continue processing, but results may be incorrect
523
+ }
524
+
482
525
  // Get element path data
483
526
  const origPathData = getElementPathData(el, opts.precision);
484
527
  if (!origPathData) continue;
@@ -1004,14 +1047,70 @@ function getElementBBox(el) {
1004
1047
  /**
1005
1048
  * Extract presentation attributes from element.
1006
1049
  * @private
1050
+ *
1051
+ * CRITICAL: This function must include ALL SVG presentation attributes
1052
+ * that affect visual rendering. Missing attributes will cause SILENT
1053
+ * RENDERING BUGS when shapes are converted to paths.
1054
+ *
1055
+ * Categories:
1056
+ * - Stroke properties (width, caps, joins, dashes)
1057
+ * - Fill properties (opacity, rule)
1058
+ * - Clipping/Masking (clip-path, mask, filter) - NON-INHERITABLE but CRITICAL
1059
+ * - Marker properties (marker, marker-start/mid/end)
1060
+ * - Text properties (font, spacing, decoration)
1061
+ * - Rendering hints (shape-rendering, text-rendering, etc.)
1062
+ * - Visual effects (opacity, paint-order, vector-effect)
1007
1063
  */
1008
1064
  function extractPresentationAttrs(el) {
1009
1065
  const presentationAttrs = [
1010
- 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
1066
+ // Stroke properties
1067
+ 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
1011
1068
  'stroke-dasharray', 'stroke-dashoffset', 'stroke-miterlimit', 'stroke-opacity',
1012
- 'fill-opacity', 'opacity', 'fill-rule', 'clip-rule', 'visibility', 'display',
1013
- 'color', 'font-family', 'font-size', 'font-weight', 'font-style',
1014
- 'text-anchor', 'dominant-baseline', 'class', 'style'
1069
+ 'vector-effect', // Affects stroke rendering (non-scaling-stroke)
1070
+
1071
+ // Fill properties
1072
+ 'fill', 'fill-opacity', 'fill-rule',
1073
+
1074
+ // CRITICAL: Non-inheritable but must be preserved on element
1075
+ 'clip-path', // Clips geometry - MUST NOT BE LOST
1076
+ 'mask', // Masks transparency - MUST NOT BE LOST
1077
+ 'filter', // Visual effects - MUST NOT BE LOST
1078
+ 'opacity', // Element opacity
1079
+
1080
+ // Clip/fill rules
1081
+ 'clip-rule',
1082
+
1083
+ // Marker properties - arrows, dots, etc on paths
1084
+ 'marker', // Shorthand for all markers
1085
+ 'marker-start', // Start of path
1086
+ 'marker-mid', // Vertices
1087
+ 'marker-end', // End of path
1088
+
1089
+ // Visibility
1090
+ 'visibility', 'display',
1091
+
1092
+ // Color
1093
+ 'color',
1094
+
1095
+ // Text properties
1096
+ 'font-family', 'font-size', 'font-weight', 'font-style',
1097
+ 'text-anchor', 'dominant-baseline', 'alignment-baseline',
1098
+ 'letter-spacing', 'word-spacing', 'text-decoration',
1099
+
1100
+ // Rendering hints
1101
+ 'shape-rendering', 'text-rendering', 'image-rendering', 'color-rendering',
1102
+
1103
+ // Paint order (affects stroke/fill/marker rendering order)
1104
+ 'paint-order',
1105
+
1106
+ // Event handling (visual feedback)
1107
+ 'pointer-events', 'cursor',
1108
+
1109
+ // Preserve class and style for CSS targeting
1110
+ 'class', 'style',
1111
+
1112
+ // ID must be preserved for references
1113
+ 'id'
1015
1114
  ];
1016
1115
 
1017
1116
  const attrs = {};
@@ -1027,6 +1126,10 @@ function extractPresentationAttrs(el) {
1027
1126
  /**
1028
1127
  * Get shape-specific attribute names.
1029
1128
  * @private
1129
+ *
1130
+ * These are attributes specific to each shape element that should NOT be
1131
+ * copied when converting to a <path>. The geometry is encoded in the 'd'
1132
+ * attribute instead.
1030
1133
  */
1031
1134
  function getShapeSpecificAttrs(tagName) {
1032
1135
  const attrs = {
@@ -1036,6 +1139,8 @@ function getShapeSpecificAttrs(tagName) {
1036
1139
  line: ['x1', 'y1', 'x2', 'y2'],
1037
1140
  polyline: ['points'],
1038
1141
  polygon: ['points'],
1142
+ // Image element has position/size attributes that don't apply to paths
1143
+ image: ['x', 'y', 'width', 'height', 'href', 'xlink:href', 'preserveAspectRatio'],
1039
1144
  };
1040
1145
  return attrs[tagName.toLowerCase()] || [];
1041
1146
  }