@emasoft/svg-matrix 1.0.27 → 1.0.29

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.
Files changed (46) hide show
  1. package/README.md +325 -0
  2. package/bin/svg-matrix.js +994 -378
  3. package/bin/svglinter.cjs +4172 -433
  4. package/bin/svgm.js +744 -184
  5. package/package.json +16 -4
  6. package/src/animation-references.js +71 -52
  7. package/src/arc-length.js +160 -96
  8. package/src/bezier-analysis.js +257 -117
  9. package/src/bezier-intersections.js +411 -148
  10. package/src/browser-verify.js +240 -100
  11. package/src/clip-path-resolver.js +350 -142
  12. package/src/convert-path-data.js +279 -134
  13. package/src/css-specificity.js +78 -70
  14. package/src/flatten-pipeline.js +751 -263
  15. package/src/geometry-to-path.js +511 -182
  16. package/src/index.js +191 -46
  17. package/src/inkscape-support.js +404 -0
  18. package/src/marker-resolver.js +278 -164
  19. package/src/mask-resolver.js +209 -98
  20. package/src/matrix.js +147 -67
  21. package/src/mesh-gradient.js +187 -96
  22. package/src/off-canvas-detection.js +201 -104
  23. package/src/path-analysis.js +187 -107
  24. package/src/path-data-plugins.js +628 -167
  25. package/src/path-simplification.js +0 -1
  26. package/src/pattern-resolver.js +125 -88
  27. package/src/polygon-clip.js +111 -66
  28. package/src/svg-boolean-ops.js +194 -118
  29. package/src/svg-collections.js +48 -19
  30. package/src/svg-flatten.js +282 -164
  31. package/src/svg-parser.js +427 -200
  32. package/src/svg-rendering-context.js +147 -104
  33. package/src/svg-toolbox.js +16411 -3298
  34. package/src/svg2-polyfills.js +114 -245
  35. package/src/transform-decomposition.js +46 -41
  36. package/src/transform-optimization.js +89 -68
  37. package/src/transforms2d.js +49 -16
  38. package/src/transforms3d.js +58 -22
  39. package/src/use-symbol-resolver.js +150 -110
  40. package/src/vector.js +67 -15
  41. package/src/vendor/README.md +110 -0
  42. package/src/vendor/inkscape-hatch-polyfill.js +401 -0
  43. package/src/vendor/inkscape-hatch-polyfill.min.js +8 -0
  44. package/src/vendor/inkscape-mesh-polyfill.js +843 -0
  45. package/src/vendor/inkscape-mesh-polyfill.min.js +8 -0
  46. package/src/verification.js +288 -124
@@ -13,47 +13,57 @@
13
13
  * @module flatten-pipeline
14
14
  */
15
15
 
16
- import Decimal from 'decimal.js';
17
- import { Matrix } from './matrix.js';
18
- import * as Transforms2D from './transforms2d.js';
19
- import * as SVGFlatten from './svg-flatten.js';
20
- import * as ClipPathResolver from './clip-path-resolver.js';
21
- import * as MaskResolver from './mask-resolver.js';
22
- import * as UseSymbolResolver from './use-symbol-resolver.js';
23
- import * as PatternResolver from './pattern-resolver.js';
24
- import * as MarkerResolver from './marker-resolver.js';
25
- import * as MeshGradient from './mesh-gradient.js';
26
- import * as GeometryToPath from './geometry-to-path.js';
27
- import { parseSVG, SVGElement, buildDefsMap, parseUrlReference, serializeSVG, findElementsWithAttribute } from './svg-parser.js';
28
- import { Logger } from './logger.js';
29
- import * as Verification from './verification.js';
16
+ import Decimal from "decimal.js";
17
+ import { Matrix as _Matrix } from "./matrix.js";
18
+ import * as Transforms2D from "./transforms2d.js";
19
+ import * as SVGFlatten from "./svg-flatten.js";
20
+ import * as ClipPathResolver from "./clip-path-resolver.js";
21
+ import * as MaskResolver from "./mask-resolver.js";
22
+ import * as UseSymbolResolver from "./use-symbol-resolver.js";
23
+ import * as PatternResolver from "./pattern-resolver.js";
24
+ import * as MarkerResolver from "./marker-resolver.js";
25
+ import * as _MeshGradient from "./mesh-gradient.js";
26
+ import * as GeometryToPath from "./geometry-to-path.js";
27
+ import {
28
+ parseSVG,
29
+ SVGElement,
30
+ buildDefsMap,
31
+ parseUrlReference,
32
+ serializeSVG,
33
+ findElementsWithAttribute,
34
+ } from "./svg-parser.js";
35
+ import { Logger as _Logger } from "./logger.js";
36
+ import * as Verification from "./verification.js";
37
+ import { parseCSSIds } from "./animation-references.js";
38
+ import * as PolygonClip from "./polygon-clip.js";
30
39
 
31
40
  Decimal.set({ precision: 80 });
32
41
 
33
- const D = x => (x instanceof Decimal ? x : new Decimal(x));
42
+ const D = (x) => (x instanceof Decimal ? x : new Decimal(x));
34
43
 
35
44
  /**
36
45
  * Default options for flatten pipeline.
37
46
  */
38
47
  const DEFAULT_OPTIONS = {
39
- precision: 6, // Decimal places in output coordinates
40
- curveSegments: 20, // Samples per curve for polygon conversion (visual output)
41
- clipSegments: 64, // Higher samples for clip polygon accuracy (affects E2E precision)
42
- bezierArcs: 8, // Bezier arcs for circles/ellipses (must be multiple of 4)
43
- // 8: π/4 optimal base (~0.0004% error)
44
- // 16: π/8 (~0.000007% error), 32: π/16, 64: π/32 (~0.00000001% error)
45
- resolveUse: true, // Expand <use> elements
46
- resolveMarkers: true, // Expand marker instances
47
- resolvePatterns: true, // Expand pattern fills to geometry
48
- resolveMasks: true, // Convert masks to clip paths
49
- resolveClipPaths: true, // Apply clipPath boolean operations
50
- flattenTransforms: true, // Bake transform attributes into coordinates
51
- bakeGradients: true, // Bake gradientTransform into gradient coords
52
- removeUnusedDefs: true, // Remove defs that are no longer referenced
53
- preserveIds: false, // Keep original IDs on expanded elements
48
+ precision: 6, // Decimal places in output coordinates
49
+ curveSegments: 20, // Samples per curve for polygon conversion (visual output)
50
+ clipSegments: 64, // Higher samples for clip polygon accuracy (affects E2E precision)
51
+ bezierArcs: 8, // Bezier arcs for circles/ellipses (must be multiple of 4)
52
+ // 8: π/4 optimal base (~0.0004% error)
53
+ // 16: π/8 (~0.000007% error), 32: π/16, 64: π/32 (~0.00000001% error)
54
+ resolveUse: true, // Expand <use> elements
55
+ resolveMarkers: true, // Expand marker instances
56
+ resolvePatterns: true, // Expand pattern fills to geometry
57
+ resolveMasks: true, // Convert masks to clip paths
58
+ resolveClipPaths: true, // Apply clipPath boolean operations
59
+ flattenTransforms: true, // Bake transform attributes into coordinates
60
+ bakeGradients: true, // Bake gradientTransform into gradient coords
61
+ removeUnusedDefs: true, // Remove defs that are no longer referenced
62
+ preserveIds: false, // Keep original IDs on expanded elements
63
+ svg2Polyfills: false, // Apply SVG 2.0 polyfills for backwards compatibility (not yet implemented)
54
64
  // NOTE: Verification is ALWAYS enabled - precision is non-negotiable
55
65
  // E2E verification tolerance (configurable for different accuracy needs)
56
- e2eTolerance: '1e-10', // Default: 1e-10 (very tight with high clipSegments)
66
+ e2eTolerance: "1e-10", // Default: 1e-10 (very tight with high clipSegments)
57
67
  };
58
68
 
59
69
  /**
@@ -103,13 +113,19 @@ export function flattenSVG(svgString, options = {}) {
103
113
  try {
104
114
  // Parse SVG
105
115
  const root = parseSVG(svgString);
106
- if (root.tagName !== 'svg') {
107
- throw new Error('Root element must be <svg>');
116
+ if (root.tagName !== "svg") {
117
+ throw new Error("Root element must be <svg>");
108
118
  }
109
119
 
110
120
  // Build defs map
111
121
  let defsMap = buildDefsMap(root);
112
122
 
123
+ // CRITICAL: Collect all ID references BEFORE any processing
124
+ // This ensures we don't remove defs that were referenced before resolution
125
+ const referencedIds = opts.removeUnusedDefs
126
+ ? collectAllReferences(root)
127
+ : null;
128
+
113
129
  // Step 1: Resolve <use> elements (must be first - creates new geometry)
114
130
  if (opts.resolveUse) {
115
131
  const result = resolveAllUseElements(root, defsMap, opts);
@@ -160,9 +176,9 @@ export function flattenSVG(svgString, options = {}) {
160
176
  stats.errors.push(...result.errors);
161
177
  }
162
178
 
163
- // Step 8: Remove unused defs
164
- if (opts.removeUnusedDefs) {
165
- const result = removeUnusedDefinitions(root);
179
+ // Step 8: Remove unused defs (using pre-collected references from before processing)
180
+ if (opts.removeUnusedDefs && referencedIds) {
181
+ const result = removeUnusedDefinitions(root, referencedIds);
166
182
  stats.defsRemoved = result.count;
167
183
  }
168
184
 
@@ -170,7 +186,6 @@ export function flattenSVG(svgString, options = {}) {
170
186
  const svg = serializeSVG(root);
171
187
 
172
188
  return { svg, stats };
173
-
174
189
  } catch (error) {
175
190
  stats.errors.push(`Pipeline error: ${error.message}`);
176
191
  return { svg: svgString, stats }; // Return original on failure
@@ -183,20 +198,94 @@ export function flattenSVG(svgString, options = {}) {
183
198
 
184
199
  /**
185
200
  * Resolve all <use> elements by expanding them inline.
201
+ * Converts use references to concrete geometry or cloned elements with transforms.
202
+ *
203
+ * @param {SVGElement} root - Root SVG element
204
+ * @param {Map<string, SVGElement>} defsMap - Map of definition IDs to elements
205
+ * @param {Object} opts - Processing options
206
+ * @returns {{count: number, errors: Array<string>}} Resolution results
186
207
  * @private
187
208
  */
209
+ /**
210
+ * SMIL animation element names that must be preserved during use resolution.
211
+ * Converting these to paths would destroy the animation functionality.
212
+ */
213
+ // IMPORTANT: Use lowercase for case-insensitive matching with tagName.toLowerCase()
214
+ const SMIL_ANIMATION_ELEMENTS = new Set([
215
+ "animate",
216
+ "animatetransform",
217
+ "animatemotion",
218
+ "animatecolor",
219
+ "set",
220
+ ]);
221
+
222
+ /**
223
+ * Elements that must be preserved during flattening (not converted to paths).
224
+ * These elements contain content that cannot be represented as path data.
225
+ * IMPORTANT: Use lowercase for case-insensitive matching with tagName.toLowerCase()
226
+ */
227
+ const PRESERVE_ELEMENTS = new Set([
228
+ "foreignobject",
229
+ "audio",
230
+ "video",
231
+ "iframe",
232
+ "script",
233
+ "style",
234
+ ...SMIL_ANIMATION_ELEMENTS,
235
+ ]);
236
+
237
+ /**
238
+ * Check if an element or any of its descendants contains elements that must be preserved.
239
+ *
240
+ * @param {SVGElement} el - Element to check
241
+ * @returns {boolean} True if element or descendants contain preserve elements
242
+ * @private
243
+ */
244
+ function containsPreserveElements(el) {
245
+ if (!el) return false;
246
+
247
+ const tagName = el.tagName?.toLowerCase();
248
+ if (PRESERVE_ELEMENTS.has(tagName)) {
249
+ return true;
250
+ }
251
+
252
+ // Check children recursively (use tagName check instead of instanceof)
253
+ for (const child of el.children || []) {
254
+ if (child && child.tagName && containsPreserveElements(child)) {
255
+ return true;
256
+ }
257
+ }
258
+
259
+ return false;
260
+ }
261
+
188
262
  function resolveAllUseElements(root, defsMap, opts) {
189
263
  const errors = [];
190
264
  let count = 0;
191
265
 
192
- const useElements = root.getElementsByTagName('use');
266
+ const useElements = root.getElementsByTagName("use");
267
+
268
+ // Convert defsMap from Map<id, SVGElement> to {id: parsedData} format
269
+ // that UseSymbolResolver.resolveUse() expects (objects with .type property)
270
+ const parsedDefs = {};
271
+ for (const [id, el] of defsMap.entries()) {
272
+ const tagName = el.tagName.toLowerCase();
273
+ if (tagName === "symbol") {
274
+ parsedDefs[id] = UseSymbolResolver.parseSymbolElement(el);
275
+ parsedDefs[id].type = "symbol";
276
+ } else {
277
+ parsedDefs[id] = UseSymbolResolver.parseChildElement(el);
278
+ }
279
+ }
193
280
 
194
- for (const useEl of [...useElements]) { // Clone array since we modify DOM
281
+ for (const useEl of [...useElements]) {
282
+ // Clone array since we modify DOM
195
283
  try {
196
- const href = useEl.getAttribute('href') || useEl.getAttribute('xlink:href');
284
+ const href =
285
+ useEl.getAttribute("href") || useEl.getAttribute("xlink:href");
197
286
  if (!href) continue;
198
287
 
199
- const refId = href.replace(/^#/, '');
288
+ const refId = href.replace(/^#/, "");
200
289
  const refEl = defsMap.get(refId);
201
290
 
202
291
  if (!refEl) {
@@ -204,12 +293,91 @@ function resolveAllUseElements(root, defsMap, opts) {
204
293
  continue;
205
294
  }
206
295
 
296
+ // CRITICAL: Check if referenced element OR the use element itself contains SMIL animations
297
+ // In SVG, animations can be children of <use> elements (animate the use instance)
298
+ // If so, we must clone instead of converting to path to preserve functionality
299
+ const refHasPreserve = containsPreserveElements(refEl);
300
+ const useHasPreserve = containsPreserveElements(useEl);
301
+
302
+ if (refHasPreserve || useHasPreserve) {
303
+ // Clone the referenced element with all children (preserves animations)
304
+ const clonedEl = refEl.cloneNode(true);
305
+
306
+ // Get use element positioning and transform
307
+ const useX = parseFloat(useEl.getAttribute("x") || "0");
308
+ const useY = parseFloat(useEl.getAttribute("y") || "0");
309
+ const useTransform = useEl.getAttribute("transform") || "";
310
+
311
+ // Build combined transform: use transform + translation from x/y
312
+ let combinedTransform = "";
313
+ if (useTransform) {
314
+ combinedTransform = useTransform;
315
+ }
316
+ if (useX !== 0 || useY !== 0) {
317
+ const translatePart = `translate(${useX}, ${useY})`;
318
+ combinedTransform = combinedTransform
319
+ ? `${combinedTransform} ${translatePart}`
320
+ : translatePart;
321
+ }
322
+
323
+ // Create a group to wrap the cloned content with the transform
324
+ const wrapperGroup = new SVGElement("g", {});
325
+ if (combinedTransform) {
326
+ wrapperGroup.setAttribute("transform", combinedTransform);
327
+ }
328
+
329
+ // Copy presentation attributes from use element to wrapper
330
+ const presentationAttrs = extractPresentationAttrs(useEl);
331
+ for (const [key, val] of Object.entries(presentationAttrs)) {
332
+ if (val && !wrapperGroup.hasAttribute(key)) {
333
+ wrapperGroup.setAttribute(key, val);
334
+ }
335
+ }
336
+
337
+ // Handle symbol vs regular element
338
+ const refTagName = refEl.tagName.toLowerCase();
339
+ if (refTagName === "symbol") {
340
+ // For symbols, add all children to the wrapper (skip the symbol wrapper)
341
+ // Use tagName check instead of instanceof since cloneNode may not preserve class type
342
+ for (const child of clonedEl.children || []) {
343
+ if (child && child.tagName) {
344
+ wrapperGroup.appendChild(child.cloneNode(true));
345
+ }
346
+ }
347
+ } else {
348
+ // For regular elements, add the cloned element
349
+ // Remove the ID to avoid duplicates
350
+ clonedEl.removeAttribute("id");
351
+ wrapperGroup.appendChild(clonedEl);
352
+ }
353
+
354
+ // CRITICAL: Also preserve animation children from the use element itself
355
+ // SMIL animations can be direct children of <use> to animate the instance
356
+ for (const child of useEl.children || []) {
357
+ if (child && child.tagName) {
358
+ const childTagName = child.tagName.toLowerCase();
359
+ if (SMIL_ANIMATION_ELEMENTS.has(childTagName)) {
360
+ // Clone the animation and add to wrapper
361
+ wrapperGroup.appendChild(child.cloneNode(true));
362
+ }
363
+ }
364
+ }
365
+
366
+ // Replace use with the wrapper group
367
+ if (useEl.parentNode) {
368
+ useEl.parentNode.replaceChild(wrapperGroup, useEl);
369
+ count++;
370
+ }
371
+ continue;
372
+ }
373
+
374
+ // Standard path conversion for elements without preserve elements
207
375
  // Parse use element data
208
376
  const useData = UseSymbolResolver.parseUseElement(useEl);
209
377
 
210
- // Resolve the use
211
- const resolved = UseSymbolResolver.resolveUse(useData, Object.fromEntries(defsMap), {
212
- samples: opts.curveSegments
378
+ // Resolve the use with properly formatted defs (plain objects with .type)
379
+ const resolved = UseSymbolResolver.resolveUse(useData, parsedDefs, {
380
+ samples: opts.curveSegments,
213
381
  });
214
382
 
215
383
  if (!resolved) {
@@ -218,13 +386,16 @@ function resolveAllUseElements(root, defsMap, opts) {
218
386
  }
219
387
 
220
388
  // Convert resolved to path data
221
- const pathData = UseSymbolResolver.resolvedUseToPathData(resolved, opts.curveSegments);
389
+ const pathData = UseSymbolResolver.resolvedUseToPathData(
390
+ resolved,
391
+ opts.curveSegments,
392
+ );
222
393
 
223
394
  if (pathData) {
224
395
  // Create new path element to replace <use>
225
- const pathEl = new SVGElement('path', {
396
+ const pathEl = new SVGElement("path", {
226
397
  d: pathData,
227
- ...extractPresentationAttrs(useEl)
398
+ ...extractPresentationAttrs(useEl),
228
399
  });
229
400
 
230
401
  // Copy style attributes from resolved
@@ -256,6 +427,12 @@ function resolveAllUseElements(root, defsMap, opts) {
256
427
 
257
428
  /**
258
429
  * Resolve all marker references by instantiating marker geometry.
430
+ * Expands markers into concrete path elements placed at path vertices.
431
+ *
432
+ * @param {SVGElement} root - Root SVG element
433
+ * @param {Map<string, SVGElement>} defsMap - Map of definition IDs to elements
434
+ * @param {Object} opts - Processing options
435
+ * @returns {{count: number, errors: Array<string>}} Resolution results
259
436
  * @private
260
437
  */
261
438
  function resolveAllMarkers(root, defsMap, opts) {
@@ -263,30 +440,41 @@ function resolveAllMarkers(root, defsMap, opts) {
263
440
  let count = 0;
264
441
 
265
442
  // Find all elements with marker attributes
266
- const markerAttrs = ['marker-start', 'marker-mid', 'marker-end', 'marker'];
443
+ const markerAttrs = ["marker-start", "marker-mid", "marker-end", "marker"];
267
444
 
268
445
  for (const attrName of markerAttrs) {
269
446
  const elements = findElementsWithAttribute(root, attrName);
270
447
 
271
448
  for (const el of elements) {
272
- if (el.tagName !== 'path' && el.tagName !== 'line' && el.tagName !== 'polyline' && el.tagName !== 'polygon') {
449
+ if (
450
+ el.tagName !== "path" &&
451
+ el.tagName !== "line" &&
452
+ el.tagName !== "polyline" &&
453
+ el.tagName !== "polygon"
454
+ ) {
273
455
  continue;
274
456
  }
275
457
 
276
458
  try {
277
459
  // Resolve markers for this element
278
- const markerInstances = MarkerResolver.resolveMarkers(el, Object.fromEntries(defsMap));
460
+ const markerInstances = MarkerResolver.resolveMarkers(
461
+ el,
462
+ Object.fromEntries(defsMap),
463
+ );
279
464
 
280
465
  if (!markerInstances || markerInstances.length === 0) continue;
281
466
 
282
467
  // Convert markers to path data
283
- const markerPathData = MarkerResolver.markersToPathData(markerInstances, opts.precision);
468
+ const markerPathData = MarkerResolver.markersToPathData(
469
+ markerInstances,
470
+ opts.precision,
471
+ );
284
472
 
285
473
  if (markerPathData) {
286
474
  // Create new path element for marker geometry
287
- const markerPath = new SVGElement('path', {
475
+ const markerPath = new SVGElement("path", {
288
476
  d: markerPathData,
289
- fill: el.getAttribute('stroke') || 'currentColor', // Markers typically use stroke color
477
+ fill: el.getAttribute("stroke") || "currentColor", // Markers typically use stroke color
290
478
  });
291
479
 
292
480
  // Insert after the original element
@@ -320,40 +508,54 @@ function resolveAllMarkers(root, defsMap, opts) {
320
508
 
321
509
  /**
322
510
  * Resolve pattern fills by expanding to tiled geometry.
511
+ * Converts pattern fills into concrete geometry elements.
512
+ *
513
+ * @param {SVGElement} root - Root SVG element
514
+ * @param {Map<string, SVGElement>} defsMap - Map of definition IDs to elements
515
+ * @param {Object} opts - Processing options
516
+ * @returns {{count: number, errors: Array<string>}} Resolution results
323
517
  * @private
324
518
  */
325
519
  function resolveAllPatterns(root, defsMap, opts) {
326
520
  const errors = [];
327
521
  let count = 0;
328
522
 
329
- const elementsWithFill = findElementsWithAttribute(root, 'fill');
523
+ const elementsWithFill = findElementsWithAttribute(root, "fill");
330
524
 
331
525
  for (const el of elementsWithFill) {
332
- const fill = el.getAttribute('fill');
333
- if (!fill || !fill.includes('url(')) continue;
526
+ const fill = el.getAttribute("fill");
527
+ if (!fill || !fill.includes("url(")) continue;
334
528
 
335
529
  const refId = parseUrlReference(fill);
336
530
  if (!refId) continue;
337
531
 
338
532
  const patternEl = defsMap.get(refId);
339
- if (!patternEl || patternEl.tagName !== 'pattern') continue;
533
+ if (!patternEl || patternEl.tagName !== "pattern") continue;
340
534
 
341
535
  try {
342
536
  // 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');
537
+ const patternUnits =
538
+ patternEl.getAttribute("patternUnits") || "objectBoundingBox";
539
+ const patternContentUnits =
540
+ patternEl.getAttribute("patternContentUnits") || "userSpaceOnUse";
541
+ const patternTransform = patternEl.getAttribute("patternTransform");
346
542
 
347
543
  // PatternResolver handles these cases, but warn about complex patterns
348
544
  // 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`);
545
+ if (patternUnits !== "objectBoundingBox") {
546
+ errors.push(
547
+ `pattern ${refId}: non-default patternUnits="${patternUnits}" may cause rendering issues`,
548
+ );
351
549
  }
352
- if (patternContentUnits !== 'userSpaceOnUse') {
353
- errors.push(`pattern ${refId}: non-default patternContentUnits="${patternContentUnits}" may cause rendering issues`);
550
+ if (patternContentUnits !== "userSpaceOnUse") {
551
+ errors.push(
552
+ `pattern ${refId}: non-default patternContentUnits="${patternContentUnits}" may cause rendering issues`,
553
+ );
354
554
  }
355
555
  if (patternTransform) {
356
- errors.push(`pattern ${refId}: patternTransform present - complex transformation may cause rendering issues`);
556
+ errors.push(
557
+ `pattern ${refId}: patternTransform present - complex transformation may cause rendering issues`,
558
+ );
357
559
  }
358
560
 
359
561
  // Get element bounding box (approximate from path data or attributes)
@@ -364,24 +566,28 @@ function resolveAllPatterns(root, defsMap, opts) {
364
566
  const patternData = PatternResolver.parsePatternElement(patternEl);
365
567
 
366
568
  // Resolve pattern to path data
367
- const patternPathData = PatternResolver.patternToPathData(patternData, bbox, {
368
- samples: opts.curveSegments
369
- });
569
+ const patternPathData = PatternResolver.patternToPathData(
570
+ patternData,
571
+ bbox,
572
+ {
573
+ samples: opts.curveSegments,
574
+ },
575
+ );
370
576
 
371
577
  if (patternPathData) {
372
578
  // Create group with clipped pattern geometry
373
- const patternGroup = new SVGElement('g', {});
579
+ const patternGroup = new SVGElement("g", {});
374
580
 
375
- const patternPath = new SVGElement('path', {
581
+ const patternPath = new SVGElement("path", {
376
582
  d: patternPathData,
377
- fill: '#000', // Pattern content typically has its own fill
583
+ fill: "#000", // Pattern content typically has its own fill
378
584
  });
379
585
 
380
586
  patternGroup.appendChild(patternPath);
381
587
 
382
588
  // Replace fill with pattern geometry (clip to original shape)
383
- el.setAttribute('fill', 'none');
384
- el.setAttribute('stroke', el.getAttribute('stroke') || 'none');
589
+ el.setAttribute("fill", "none");
590
+ el.setAttribute("stroke", el.getAttribute("stroke") || "none");
385
591
 
386
592
  if (el.parentNode) {
387
593
  el.parentNode.insertBefore(patternGroup, el);
@@ -402,37 +608,48 @@ function resolveAllPatterns(root, defsMap, opts) {
402
608
 
403
609
  /**
404
610
  * Resolve mask references by converting to clip geometry.
611
+ * Converts mask elements into clipping paths with boolean intersection.
612
+ *
613
+ * @param {SVGElement} root - Root SVG element
614
+ * @param {Map<string, SVGElement>} defsMap - Map of definition IDs to elements
615
+ * @param {Object} opts - Processing options
616
+ * @returns {{count: number, errors: Array<string>}} Resolution results
405
617
  * @private
406
618
  */
407
619
  function resolveAllMasks(root, defsMap, opts) {
408
620
  const errors = [];
409
621
  let count = 0;
410
622
 
411
- const elementsWithMask = findElementsWithAttribute(root, 'mask');
623
+ const elementsWithMask = findElementsWithAttribute(root, "mask");
412
624
 
413
625
  for (const el of elementsWithMask) {
414
- const maskRef = el.getAttribute('mask');
415
- if (!maskRef || !maskRef.includes('url(')) continue;
626
+ const maskRef = el.getAttribute("mask");
627
+ if (!maskRef || !maskRef.includes("url(")) continue;
416
628
 
417
629
  const refId = parseUrlReference(maskRef);
418
630
  if (!refId) continue;
419
631
 
420
632
  const maskEl = defsMap.get(refId);
421
- if (!maskEl || maskEl.tagName !== 'mask') continue;
633
+ if (!maskEl || maskEl.tagName !== "mask") continue;
422
634
 
423
635
  try {
424
636
  // Check coordinate system units
425
- const maskUnits = maskEl.getAttribute('maskUnits') || 'objectBoundingBox';
426
- const maskContentUnits = maskEl.getAttribute('maskContentUnits') || 'userSpaceOnUse';
637
+ const maskUnits = maskEl.getAttribute("maskUnits") || "objectBoundingBox";
638
+ const maskContentUnits =
639
+ maskEl.getAttribute("maskContentUnits") || "userSpaceOnUse";
427
640
 
428
641
  // Default for mask is different from clipPath:
429
642
  // maskUnits defaults to objectBoundingBox
430
643
  // maskContentUnits defaults to userSpaceOnUse
431
- if (maskUnits !== 'objectBoundingBox') {
432
- errors.push(`mask ${refId}: non-default maskUnits="${maskUnits}" may cause rendering issues`);
644
+ if (maskUnits !== "objectBoundingBox") {
645
+ errors.push(
646
+ `mask ${refId}: non-default maskUnits="${maskUnits}" may cause rendering issues`,
647
+ );
433
648
  }
434
- if (maskContentUnits !== 'userSpaceOnUse') {
435
- errors.push(`mask ${refId}: non-default maskContentUnits="${maskContentUnits}" may cause rendering issues`);
649
+ if (maskContentUnits !== "userSpaceOnUse") {
650
+ errors.push(
651
+ `mask ${refId}: non-default maskContentUnits="${maskContentUnits}" may cause rendering issues`,
652
+ );
436
653
  }
437
654
 
438
655
  // Get element bounding box
@@ -445,7 +662,7 @@ function resolveAllMasks(root, defsMap, opts) {
445
662
  // Convert mask to clip path data
446
663
  const clipPathData = MaskResolver.maskToPathData(maskData, bbox, {
447
664
  samples: opts.curveSegments,
448
- opacityThreshold: 0.5
665
+ opacityThreshold: 0.5,
449
666
  });
450
667
 
451
668
  if (clipPathData) {
@@ -454,16 +671,25 @@ function resolveAllMasks(root, defsMap, opts) {
454
671
 
455
672
  if (origPathData) {
456
673
  // Perform boolean intersection
457
- const origPolygon = ClipPathResolver.pathToPolygon(origPathData, opts.curveSegments);
458
- const clipPolygon = ClipPathResolver.pathToPolygon(clipPathData, opts.curveSegments);
674
+ const origPolygon = ClipPathResolver.pathToPolygon(
675
+ origPathData,
676
+ opts.curveSegments,
677
+ );
678
+ const clipPolygon = ClipPathResolver.pathToPolygon(
679
+ clipPathData,
680
+ opts.curveSegments,
681
+ );
459
682
 
460
683
  // Apply clip (intersection)
461
684
  const clippedPolygon = intersectPolygons(origPolygon, clipPolygon);
462
685
 
463
686
  if (clippedPolygon && clippedPolygon.length > 2) {
464
- const clippedPath = ClipPathResolver.polygonToPathData(clippedPolygon, opts.precision);
465
- el.setAttribute('d', clippedPath);
466
- el.removeAttribute('mask');
687
+ const clippedPath = ClipPathResolver.polygonToPathData(
688
+ clippedPolygon,
689
+ opts.precision,
690
+ );
691
+ el.setAttribute("d", clippedPath);
692
+ el.removeAttribute("mask");
467
693
  count++;
468
694
  }
469
695
  }
@@ -483,6 +709,12 @@ function resolveAllMasks(root, defsMap, opts) {
483
709
  /**
484
710
  * Apply clipPath references by performing boolean intersection.
485
711
  * Also computes the difference (outside parts) and verifies area conservation (E2E).
712
+ *
713
+ * @param {SVGElement} root - Root SVG element
714
+ * @param {Map<string, SVGElement>} defsMap - Map of definition IDs to elements
715
+ * @param {Object} opts - Processing options
716
+ * @param {Object} stats - Statistics object to update with results
717
+ * @returns {{count: number, errors: Array<string>}} Clipping results
486
718
  * @private
487
719
  */
488
720
  function applyAllClipPaths(root, defsMap, opts, stats) {
@@ -497,28 +729,36 @@ function applyAllClipPaths(root, defsMap, opts, stats) {
497
729
  stats.clipOutsideFragments = []; // Store outside fragments for potential reconstruction
498
730
  }
499
731
 
500
- const elementsWithClip = findElementsWithAttribute(root, 'clip-path');
732
+ const elementsWithClip = findElementsWithAttribute(root, "clip-path");
501
733
 
502
734
  for (const el of elementsWithClip) {
503
- const clipRef = el.getAttribute('clip-path');
504
- if (!clipRef || !clipRef.includes('url(')) continue;
735
+ const clipRef = el.getAttribute("clip-path");
736
+ if (!clipRef || !clipRef.includes("url(")) continue;
505
737
 
506
738
  const refId = parseUrlReference(clipRef);
507
739
  if (!refId) continue;
508
740
 
509
741
  const clipPathEl = defsMap.get(refId);
510
- if (!clipPathEl || clipPathEl.tagName !== 'clippath') continue;
742
+ // SVG tagNames may be case-preserved, check both lowercase and camelCase
743
+ if (
744
+ !clipPathEl ||
745
+ (clipPathEl.tagName !== "clippath" && clipPathEl.tagName !== "clipPath")
746
+ )
747
+ continue;
511
748
 
512
749
  try {
513
750
  // Check coordinate system units
514
- const clipPathUnits = clipPathEl.getAttribute('clipPathUnits') || 'userSpaceOnUse';
751
+ const clipPathUnits =
752
+ clipPathEl.getAttribute("clipPathUnits") || "userSpaceOnUse";
515
753
 
516
754
  // userSpaceOnUse is the default and normal case for clipPath
517
755
  // objectBoundingBox means coordinates are 0-1 relative to bounding box
518
- if (clipPathUnits === 'objectBoundingBox') {
756
+ if (clipPathUnits === "objectBoundingBox") {
519
757
  // This requires transforming clip coordinates based on target element's bbox
520
758
  // which is complex - warn about it
521
- errors.push(`clipPath ${refId}: objectBoundingBox units require bbox-relative coordinate transformation`);
759
+ errors.push(
760
+ `clipPath ${refId}: objectBoundingBox units require bbox-relative coordinate transformation`,
761
+ );
522
762
  // Note: We continue processing, but results may be incorrect
523
763
  }
524
764
 
@@ -527,12 +767,13 @@ function applyAllClipPaths(root, defsMap, opts, stats) {
527
767
  if (!origPathData) continue;
528
768
 
529
769
  // Get clip path data from clipPath element's children
530
- let clipPathData = '';
770
+ let clipPathData = "";
531
771
  for (const child of clipPathEl.children) {
532
- if (child instanceof SVGElement) {
772
+ // Use tagName check instead of instanceof
773
+ if (child && child.tagName) {
533
774
  const childPath = getElementPathData(child, opts.precision);
534
775
  if (childPath) {
535
- clipPathData += (clipPathData ? ' ' : '') + childPath;
776
+ clipPathData += (clipPathData ? " " : "") + childPath;
536
777
  }
537
778
  }
538
779
  }
@@ -542,34 +783,44 @@ function applyAllClipPaths(root, defsMap, opts, stats) {
542
783
  // Convert to polygons using higher segment count for clip accuracy
543
784
  // clipSegments (default 64) provides better curve approximation for E2E verification
544
785
  const clipSegs = opts.clipSegments || 64;
545
- const origPolygon = ClipPathResolver.pathToPolygon(origPathData, clipSegs);
546
- const clipPolygon = ClipPathResolver.pathToPolygon(clipPathData, clipSegs);
786
+ const origPolygon = ClipPathResolver.pathToPolygon(
787
+ origPathData,
788
+ clipSegs,
789
+ );
790
+ const clipPolygon = ClipPathResolver.pathToPolygon(
791
+ clipPathData,
792
+ clipSegs,
793
+ );
547
794
 
548
795
  // Perform intersection (clipped result - what's kept)
549
796
  const clippedPolygon = intersectPolygons(origPolygon, clipPolygon);
550
797
 
551
798
  // Convert polygon arrays to proper format for verification
552
- const origForVerify = origPolygon.map(p => ({
799
+ const origForVerify = origPolygon.map((p) => ({
553
800
  x: p.x instanceof Decimal ? p.x : D(p.x),
554
- y: p.y instanceof Decimal ? p.y : D(p.y)
801
+ y: p.y instanceof Decimal ? p.y : D(p.y),
555
802
  }));
556
- const clipForVerify = clipPolygon.map(p => ({
803
+ const clipForVerify = clipPolygon.map((p) => ({
557
804
  x: p.x instanceof Decimal ? p.x : D(p.x),
558
- y: p.y instanceof Decimal ? p.y : D(p.y)
805
+ y: p.y instanceof Decimal ? p.y : D(p.y),
559
806
  }));
560
807
 
561
808
  // VERIFICATION: Verify polygon intersection is valid (ALWAYS runs)
562
809
  if (clippedPolygon && clippedPolygon.length > 2) {
563
- const clippedForVerify = clippedPolygon.map(p => ({
810
+ const clippedForVerify = clippedPolygon.map((p) => ({
564
811
  x: p.x instanceof Decimal ? p.x : D(p.x),
565
- y: p.y instanceof Decimal ? p.y : D(p.y)
812
+ y: p.y instanceof Decimal ? p.y : D(p.y),
566
813
  }));
567
814
 
568
- const polyResult = Verification.verifyPolygonIntersection(origForVerify, clipForVerify, clippedForVerify);
815
+ const polyResult = Verification.verifyPolygonIntersection(
816
+ origForVerify,
817
+ clipForVerify,
818
+ clippedForVerify,
819
+ );
569
820
  stats.verifications.polygons.push({
570
821
  element: el.tagName,
571
822
  clipPathId: refId,
572
- ...polyResult
823
+ ...polyResult,
573
824
  });
574
825
  if (polyResult.valid) {
575
826
  stats.verifications.passed++;
@@ -580,25 +831,33 @@ function applyAllClipPaths(root, defsMap, opts, stats) {
580
831
 
581
832
  // E2E VERIFICATION: Compute difference (outside parts) and verify area conservation
582
833
  // This ensures: area(original) = area(clipped) + area(outside)
583
- const outsideFragments = Verification.computePolygonDifference(origForVerify, clipForVerify);
834
+ const outsideFragments = Verification.computePolygonDifference(
835
+ origForVerify,
836
+ clipForVerify,
837
+ );
584
838
 
585
839
  // Store outside fragments (marked invisible) for potential reconstruction
586
840
  stats.clipOutsideFragments.push({
587
- elementId: el.getAttribute('id') || `clip-${count}`,
841
+ elementId: el.getAttribute("id") || `clip-${count}`,
588
842
  clipPathId: refId,
589
843
  fragments: outsideFragments,
590
- visible: false // These are the "thrown away" parts, stored invisibly
844
+ visible: false, // These are the "thrown away" parts, stored invisibly
591
845
  });
592
846
 
593
847
  // E2E Verification: area(original) = area(clipped) + area(outside)
594
848
  // Pass configurable tolerance (default 1e-10 with 64 clipSegments)
595
- const e2eTolerance = opts.e2eTolerance || '1e-10';
596
- const e2eResult = Verification.verifyClipPathE2E(origForVerify, clippedForVerify, outsideFragments, e2eTolerance);
849
+ const e2eTolerance = opts.e2eTolerance || "1e-10";
850
+ const e2eResult = Verification.verifyClipPathE2E(
851
+ origForVerify,
852
+ clippedForVerify,
853
+ outsideFragments,
854
+ e2eTolerance,
855
+ );
597
856
  stats.verifications.e2e.push({
598
857
  element: el.tagName,
599
858
  clipPathId: refId,
600
- type: 'clip-area-conservation',
601
- ...e2eResult
859
+ type: "clip-area-conservation",
860
+ ...e2eResult,
602
861
  });
603
862
  if (e2eResult.valid) {
604
863
  stats.verifications.passed++;
@@ -609,16 +868,19 @@ function applyAllClipPaths(root, defsMap, opts, stats) {
609
868
  }
610
869
 
611
870
  if (clippedPolygon && clippedPolygon.length > 2) {
612
- const clippedPath = ClipPathResolver.polygonToPathData(clippedPolygon, opts.precision);
871
+ const clippedPath = ClipPathResolver.polygonToPathData(
872
+ clippedPolygon,
873
+ opts.precision,
874
+ );
613
875
 
614
876
  // Update element
615
- if (el.tagName === 'path') {
616
- el.setAttribute('d', clippedPath);
877
+ if (el.tagName === "path") {
878
+ el.setAttribute("d", clippedPath);
617
879
  } else {
618
880
  // Convert shape to path
619
- const newPath = new SVGElement('path', {
881
+ const newPath = new SVGElement("path", {
620
882
  d: clippedPath,
621
- ...extractPresentationAttrs(el)
883
+ ...extractPresentationAttrs(el),
622
884
  });
623
885
 
624
886
  if (el.parentNode) {
@@ -626,7 +888,7 @@ function applyAllClipPaths(root, defsMap, opts, stats) {
626
888
  }
627
889
  }
628
890
 
629
- el.removeAttribute('clip-path');
891
+ el.removeAttribute("clip-path");
630
892
  count++;
631
893
  }
632
894
  } catch (e) {
@@ -643,16 +905,22 @@ function applyAllClipPaths(root, defsMap, opts, stats) {
643
905
 
644
906
  /**
645
907
  * Flatten all transform attributes by baking into coordinates.
908
+ * Applies transformation matrices directly to path coordinates and removes transform attributes.
909
+ *
910
+ * @param {SVGElement} root - Root SVG element
911
+ * @param {Object} opts - Processing options
912
+ * @param {Object} stats - Statistics object to update with results
913
+ * @returns {{count: number, errors: Array<string>}} Flattening results
646
914
  * @private
647
915
  */
648
916
  function flattenAllTransforms(root, opts, stats) {
649
917
  const errors = [];
650
918
  let count = 0;
651
919
 
652
- const elementsWithTransform = findElementsWithAttribute(root, 'transform');
920
+ const elementsWithTransform = findElementsWithAttribute(root, "transform");
653
921
 
654
922
  for (const el of elementsWithTransform) {
655
- const transform = el.getAttribute('transform');
923
+ const transform = el.getAttribute("transform");
656
924
  if (!transform) continue;
657
925
 
658
926
  try {
@@ -664,7 +932,7 @@ function flattenAllTransforms(root, opts, stats) {
664
932
  stats.verifications.matrices.push({
665
933
  element: el.tagName,
666
934
  transform,
667
- ...matrixResult
935
+ ...matrixResult,
668
936
  });
669
937
  if (matrixResult.valid) {
670
938
  stats.verifications.passed++;
@@ -677,9 +945,9 @@ function flattenAllTransforms(root, opts, stats) {
677
945
  const pathData = getElementPathData(el, opts.precision);
678
946
  if (!pathData) {
679
947
  // For groups, propagate transform to children
680
- if (el.tagName === 'g') {
948
+ if (el.tagName === "g") {
681
949
  propagateTransformToChildren(el, ctm, opts, stats);
682
- el.removeAttribute('transform');
950
+ el.removeAttribute("transform");
683
951
  count++;
684
952
  }
685
953
  continue;
@@ -690,14 +958,16 @@ function flattenAllTransforms(root, opts, stats) {
690
958
  // Extract a few key points from the path for verification
691
959
  const polygon = ClipPathResolver.pathToPolygon(pathData, 4);
692
960
  if (polygon && polygon.length > 0) {
693
- testPoints = polygon.slice(0, 4).map(p => ({
961
+ testPoints = polygon.slice(0, 4).map((p) => ({
694
962
  x: p.x instanceof Decimal ? p.x : D(p.x),
695
- y: p.y instanceof Decimal ? p.y : D(p.y)
963
+ y: p.y instanceof Decimal ? p.y : D(p.y),
696
964
  }));
697
965
  }
698
966
 
699
967
  // Transform the path data
700
- const transformedPath = SVGFlatten.transformPathData(pathData, ctm, { precision: opts.precision });
968
+ const transformedPath = SVGFlatten.transformPathData(pathData, ctm, {
969
+ precision: opts.precision,
970
+ });
701
971
 
702
972
  // VERIFICATION: Verify transform round-trip accuracy for each test point (ALWAYS runs)
703
973
  for (let i = 0; i < testPoints.length; i++) {
@@ -706,7 +976,7 @@ function flattenAllTransforms(root, opts, stats) {
706
976
  stats.verifications.transforms.push({
707
977
  element: el.tagName,
708
978
  pointIndex: i,
709
- ...rtResult
979
+ ...rtResult,
710
980
  });
711
981
  if (rtResult.valid) {
712
982
  stats.verifications.passed++;
@@ -717,13 +987,13 @@ function flattenAllTransforms(root, opts, stats) {
717
987
  }
718
988
 
719
989
  // Update or replace element
720
- if (el.tagName === 'path') {
721
- el.setAttribute('d', transformedPath);
990
+ if (el.tagName === "path") {
991
+ el.setAttribute("d", transformedPath);
722
992
  } else {
723
993
  // Convert shape to path with transformed coordinates
724
- const newPath = new SVGElement('path', {
994
+ const newPath = new SVGElement("path", {
725
995
  d: transformedPath,
726
- ...extractPresentationAttrs(el)
996
+ ...extractPresentationAttrs(el),
727
997
  });
728
998
 
729
999
  // Remove shape-specific attributes
@@ -736,7 +1006,7 @@ function flattenAllTransforms(root, opts, stats) {
736
1006
  }
737
1007
  }
738
1008
 
739
- el.removeAttribute('transform');
1009
+ el.removeAttribute("transform");
740
1010
  count++;
741
1011
  } catch (e) {
742
1012
  errors.push(`transform: ${e.message}`);
@@ -748,28 +1018,36 @@ function flattenAllTransforms(root, opts, stats) {
748
1018
 
749
1019
  /**
750
1020
  * Propagate transform to all children of a group.
1021
+ * Applies parent group transform to all child elements recursively.
1022
+ *
1023
+ * @param {SVGElement} group - Group element with transform
1024
+ * @param {Matrix} ctm - Current transformation matrix
1025
+ * @param {Object} opts - Processing options
1026
+ * @param {Object} stats - Statistics object to update with results
1027
+ * @returns {void}
751
1028
  * @private
752
1029
  */
753
1030
  function propagateTransformToChildren(group, ctm, opts, stats) {
754
1031
  for (const child of [...group.children]) {
755
- if (!(child instanceof SVGElement)) continue;
1032
+ // Use tagName check instead of instanceof
1033
+ if (!(child && child.tagName)) continue;
756
1034
 
757
- if (child.tagName === 'g') {
1035
+ if (child.tagName === "g") {
758
1036
  // Nested group - compose transforms
759
- const childTransform = child.getAttribute('transform');
1037
+ const childTransform = child.getAttribute("transform");
760
1038
  if (childTransform) {
761
1039
  const childCtm = SVGFlatten.parseTransformAttribute(childTransform);
762
1040
  const combined = ctm.mul(childCtm);
763
- child.setAttribute('transform', matrixToTransform(combined));
1041
+ child.setAttribute("transform", matrixToTransform(combined));
764
1042
  } else {
765
- child.setAttribute('transform', matrixToTransform(ctm));
1043
+ child.setAttribute("transform", matrixToTransform(ctm));
766
1044
  }
767
1045
  } else {
768
1046
  // Shape or path - apply transform to coordinates
769
1047
  const pathData = getElementPathData(child, opts.precision);
770
1048
  if (pathData) {
771
1049
  // Compose with any existing transform
772
- const childTransform = child.getAttribute('transform');
1050
+ const childTransform = child.getAttribute("transform");
773
1051
  let combinedCtm = ctm;
774
1052
  if (childTransform) {
775
1053
  const childCtm = SVGFlatten.parseTransformAttribute(childTransform);
@@ -780,8 +1058,8 @@ function propagateTransformToChildren(group, ctm, opts, stats) {
780
1058
  const matrixResult = Verification.verifyMatrixInversion(combinedCtm);
781
1059
  stats.verifications.matrices.push({
782
1060
  element: child.tagName,
783
- context: 'group-propagation',
784
- ...matrixResult
1061
+ context: "group-propagation",
1062
+ ...matrixResult,
785
1063
  });
786
1064
  if (matrixResult.valid) {
787
1065
  stats.verifications.passed++;
@@ -790,21 +1068,25 @@ function propagateTransformToChildren(group, ctm, opts, stats) {
790
1068
  stats.verifications.allPassed = false;
791
1069
  }
792
1070
 
793
- const transformedPath = SVGFlatten.transformPathData(pathData, combinedCtm, { precision: opts.precision });
1071
+ const transformedPath = SVGFlatten.transformPathData(
1072
+ pathData,
1073
+ combinedCtm,
1074
+ { precision: opts.precision },
1075
+ );
794
1076
 
795
- if (child.tagName === 'path') {
796
- child.setAttribute('d', transformedPath);
1077
+ if (child.tagName === "path") {
1078
+ child.setAttribute("d", transformedPath);
797
1079
  } else {
798
1080
  // Replace with path element
799
- const newPath = new SVGElement('path', {
1081
+ const newPath = new SVGElement("path", {
800
1082
  d: transformedPath,
801
- ...extractPresentationAttrs(child)
1083
+ ...extractPresentationAttrs(child),
802
1084
  });
803
1085
 
804
1086
  group.replaceChild(newPath, child);
805
1087
  }
806
1088
 
807
- child.removeAttribute('transform');
1089
+ child.removeAttribute("transform");
808
1090
  }
809
1091
  }
810
1092
  }
@@ -816,6 +1098,12 @@ function propagateTransformToChildren(group, ctm, opts, stats) {
816
1098
 
817
1099
  /**
818
1100
  * Bake gradientTransform into gradient coordinates.
1101
+ * Applies gradient transforms directly to gradient coordinate attributes.
1102
+ *
1103
+ * @param {SVGElement} root - Root SVG element
1104
+ * @param {Object} opts - Processing options
1105
+ * @param {Object} stats - Statistics object to update with results
1106
+ * @returns {{count: number, errors: Array<string>}} Processing results
819
1107
  * @private
820
1108
  */
821
1109
  function bakeAllGradientTransforms(root, opts, stats) {
@@ -823,19 +1111,19 @@ function bakeAllGradientTransforms(root, opts, stats) {
823
1111
  let count = 0;
824
1112
 
825
1113
  // Process linearGradient elements
826
- const linearGradients = root.getElementsByTagName('linearGradient');
1114
+ const linearGradients = root.getElementsByTagName("linearGradient");
827
1115
  for (const grad of linearGradients) {
828
- const gradientTransform = grad.getAttribute('gradientTransform');
1116
+ const gradientTransform = grad.getAttribute("gradientTransform");
829
1117
  if (!gradientTransform) continue;
830
1118
 
831
1119
  try {
832
1120
  const ctm = SVGFlatten.parseTransformAttribute(gradientTransform);
833
1121
 
834
1122
  // Transform x1,y1,x2,y2
835
- const x1 = parseFloat(grad.getAttribute('x1') || '0');
836
- const y1 = parseFloat(grad.getAttribute('y1') || '0');
837
- const x2 = parseFloat(grad.getAttribute('x2') || '1');
838
- const y2 = parseFloat(grad.getAttribute('y2') || '0');
1123
+ const x1 = parseFloat(grad.getAttribute("x1") || "0");
1124
+ const y1 = parseFloat(grad.getAttribute("y1") || "0");
1125
+ const x2 = parseFloat(grad.getAttribute("x2") || "1");
1126
+ const y2 = parseFloat(grad.getAttribute("y2") || "0");
839
1127
 
840
1128
  const [tx1, ty1] = Transforms2D.applyTransform(ctm, x1, y1);
841
1129
  const [tx2, ty2] = Transforms2D.applyTransform(ctm, x2, y2);
@@ -843,13 +1131,18 @@ function bakeAllGradientTransforms(root, opts, stats) {
843
1131
  // VERIFICATION: Verify linear gradient transform (ALWAYS runs)
844
1132
  const gradResult = Verification.verifyLinearGradientTransform(
845
1133
  { x1, y1, x2, y2 },
846
- { x1: tx1.toNumber(), y1: ty1.toNumber(), x2: tx2.toNumber(), y2: ty2.toNumber() },
847
- ctm
1134
+ {
1135
+ x1: tx1.toNumber(),
1136
+ y1: ty1.toNumber(),
1137
+ x2: tx2.toNumber(),
1138
+ y2: ty2.toNumber(),
1139
+ },
1140
+ ctm,
848
1141
  );
849
1142
  stats.verifications.gradients.push({
850
- gradientId: grad.getAttribute('id') || 'unknown',
851
- type: 'linear',
852
- ...gradResult
1143
+ gradientId: grad.getAttribute("id") || "unknown",
1144
+ type: "linear",
1145
+ ...gradResult,
853
1146
  });
854
1147
  if (gradResult.valid) {
855
1148
  stats.verifications.passed++;
@@ -858,11 +1151,11 @@ function bakeAllGradientTransforms(root, opts, stats) {
858
1151
  stats.verifications.allPassed = false;
859
1152
  }
860
1153
 
861
- grad.setAttribute('x1', tx1.toFixed(opts.precision));
862
- grad.setAttribute('y1', ty1.toFixed(opts.precision));
863
- grad.setAttribute('x2', tx2.toFixed(opts.precision));
864
- grad.setAttribute('y2', ty2.toFixed(opts.precision));
865
- grad.removeAttribute('gradientTransform');
1154
+ grad.setAttribute("x1", tx1.toFixed(opts.precision));
1155
+ grad.setAttribute("y1", ty1.toFixed(opts.precision));
1156
+ grad.setAttribute("x2", tx2.toFixed(opts.precision));
1157
+ grad.setAttribute("y2", ty2.toFixed(opts.precision));
1158
+ grad.removeAttribute("gradientTransform");
866
1159
  count++;
867
1160
  } catch (e) {
868
1161
  errors.push(`linearGradient: ${e.message}`);
@@ -870,34 +1163,36 @@ function bakeAllGradientTransforms(root, opts, stats) {
870
1163
  }
871
1164
 
872
1165
  // Process radialGradient elements
873
- const radialGradients = root.getElementsByTagName('radialGradient');
1166
+ const radialGradients = root.getElementsByTagName("radialGradient");
874
1167
  for (const grad of radialGradients) {
875
- const gradientTransform = grad.getAttribute('gradientTransform');
1168
+ const gradientTransform = grad.getAttribute("gradientTransform");
876
1169
  if (!gradientTransform) continue;
877
1170
 
878
1171
  try {
879
1172
  const ctm = SVGFlatten.parseTransformAttribute(gradientTransform);
880
1173
 
881
1174
  // Transform cx,cy,fx,fy and scale r
882
- const cx = parseFloat(grad.getAttribute('cx') || '0.5');
883
- const cy = parseFloat(grad.getAttribute('cy') || '0.5');
884
- const fx = parseFloat(grad.getAttribute('fx') || cx.toString());
885
- const fy = parseFloat(grad.getAttribute('fy') || cy.toString());
886
- const r = parseFloat(grad.getAttribute('r') || '0.5');
1175
+ const cx = parseFloat(grad.getAttribute("cx") || "0.5");
1176
+ const cy = parseFloat(grad.getAttribute("cy") || "0.5");
1177
+ const fx = parseFloat(grad.getAttribute("fx") || cx.toString());
1178
+ const fy = parseFloat(grad.getAttribute("fy") || cy.toString());
1179
+ const r = parseFloat(grad.getAttribute("r") || "0.5");
887
1180
 
888
1181
  const [tcx, tcy] = Transforms2D.applyTransform(ctm, cx, cy);
889
1182
  const [tfx, tfy] = Transforms2D.applyTransform(ctm, fx, fy);
890
1183
 
891
1184
  // Scale radius by average scale factor
892
- const scale = Math.sqrt(Math.abs(ctm.data[0][0].toNumber() * ctm.data[1][1].toNumber()));
1185
+ const scale = Math.sqrt(
1186
+ Math.abs(ctm.data[0][0].toNumber() * ctm.data[1][1].toNumber()),
1187
+ );
893
1188
  const tr = r * scale;
894
1189
 
895
- grad.setAttribute('cx', tcx.toFixed(opts.precision));
896
- grad.setAttribute('cy', tcy.toFixed(opts.precision));
897
- grad.setAttribute('fx', tfx.toFixed(opts.precision));
898
- grad.setAttribute('fy', tfy.toFixed(opts.precision));
899
- grad.setAttribute('r', tr.toFixed(opts.precision));
900
- grad.removeAttribute('gradientTransform');
1190
+ grad.setAttribute("cx", tcx.toFixed(opts.precision));
1191
+ grad.setAttribute("cy", tcy.toFixed(opts.precision));
1192
+ grad.setAttribute("fx", tfx.toFixed(opts.precision));
1193
+ grad.setAttribute("fy", tfy.toFixed(opts.precision));
1194
+ grad.setAttribute("r", tr.toFixed(opts.precision));
1195
+ grad.removeAttribute("gradientTransform");
901
1196
  count++;
902
1197
  } catch (e) {
903
1198
  errors.push(`radialGradient: ${e.message}`);
@@ -912,44 +1207,121 @@ function bakeAllGradientTransforms(root, opts, stats) {
912
1207
  // ============================================================================
913
1208
 
914
1209
  /**
915
- * Remove defs that are no longer referenced.
1210
+ * Collect all ID references in the document.
1211
+ *
1212
+ * This must be called BEFORE any processing that removes references (like resolveMarkers).
1213
+ * Otherwise, we'll incorrectly remove defs that were just used.
1214
+ *
1215
+ * IMPORTANT: This function must find ALL referenced elements, including:
1216
+ * - Elements directly referenced from the document (fill="url(#grad)", xlink:href="#symbol")
1217
+ * - Elements referenced from within <defs> (glyphRef xlink:href="#glyph", gradient xlink:href="#other")
1218
+ * - Elements referenced via any attribute (not just url() and href)
1219
+ *
916
1220
  * @private
917
1221
  */
918
- function removeUnusedDefinitions(root) {
919
- let count = 0;
920
-
921
- // Collect all url() references in the document
1222
+ function collectAllReferences(root) {
922
1223
  const usedIds = new Set();
923
1224
 
1225
+ // Collect references from an element and all its children
924
1226
  const collectReferences = (el) => {
1227
+ // Check for <style> elements and parse their CSS content for url(#id) references
1228
+ // This is CRITICAL for SVG 2.0 features like shape-inside: url(#textShape)
1229
+ if (el.tagName && el.tagName.toLowerCase() === "style") {
1230
+ const cssContent = el.textContent || "";
1231
+ if (cssContent) {
1232
+ // Parse all url(#id) references from CSS (e.g., shape-inside: url(#textShape), fill: url(#grad))
1233
+ const cssIds = parseCSSIds(cssContent);
1234
+ cssIds.forEach((id) => usedIds.add(id));
1235
+ }
1236
+ }
1237
+
925
1238
  for (const attrName of el.getAttributeNames()) {
926
1239
  const val = el.getAttribute(attrName);
927
- if (val && val.includes('url(')) {
1240
+ if (!val) continue;
1241
+
1242
+ // Check for url() references (fill, stroke, clip-path, mask, filter, marker, etc.)
1243
+ if (val.includes("url(")) {
928
1244
  const refId = parseUrlReference(val);
929
- if (refId) usedIds.add(refId);
1245
+ if (refId) {
1246
+ usedIds.add(refId);
1247
+ }
930
1248
  }
931
- if (attrName === 'href' || attrName === 'xlink:href') {
932
- const refId = val?.replace(/^#/, '');
933
- if (refId) usedIds.add(refId);
1249
+
1250
+ // Check for href/xlink:href references (use, image, altGlyph, glyphRef, etc.)
1251
+ if (attrName === "href" || attrName === "xlink:href") {
1252
+ const refId = val.replace(/^#/, "");
1253
+ if (refId && refId !== val) {
1254
+ // Only local refs starting with #
1255
+ usedIds.add(refId);
1256
+ }
934
1257
  }
935
1258
  }
936
1259
 
1260
+ // Recursively check children (including within <defs>)
1261
+ // Use tagName check instead of instanceof
937
1262
  for (const child of el.children) {
938
- if (child instanceof SVGElement) {
1263
+ if (child && child.tagName) {
939
1264
  collectReferences(child);
940
1265
  }
941
1266
  }
942
1267
  };
943
1268
 
1269
+ // Collect all references from the entire document
944
1270
  collectReferences(root);
945
1271
 
946
- // Remove unreferenced defs
947
- const defsElements = root.getElementsByTagName('defs');
1272
+ return usedIds;
1273
+ }
1274
+
1275
+ /**
1276
+ * Remove defs that are not in the referencedIds set.
1277
+ *
1278
+ * IMPORTANT: An element should NOT be removed if:
1279
+ * 1. It has an ID that is referenced, OR
1280
+ * 2. It contains (anywhere in its subtree) an element with a referenced ID
1281
+ *
1282
+ * This handles cases like:
1283
+ * <defs>
1284
+ * <g id="container"> <!-- Not referenced, but contains referenced gradients -->
1285
+ * <linearGradient id="grad1"> <!-- Referenced -->
1286
+ *
1287
+ * @param {SVGElement} root - The root SVG element
1288
+ * @param {Set<string>} referencedIds - Set of IDs that were referenced before processing
1289
+ * @private
1290
+ */
1291
+ function removeUnusedDefinitions(root, referencedIds) {
1292
+ let count = 0;
1293
+
1294
+ // Check if an element or any of its descendants has a referenced ID
1295
+ function hasReferencedDescendant(el) {
1296
+ // Check self
1297
+ const id = el.getAttribute("id");
1298
+ if (id && referencedIds.has(id)) {
1299
+ return true;
1300
+ }
1301
+
1302
+ // Check children recursively (use tagName check instead of instanceof)
1303
+ for (const child of el.children || []) {
1304
+ if (child && child.tagName && hasReferencedDescendant(child)) {
1305
+ return true;
1306
+ }
1307
+ }
1308
+
1309
+ return false;
1310
+ }
1311
+
1312
+ // Remove unreferenced elements from <defs>
1313
+ const defsElements = root.getElementsByTagName("defs");
948
1314
  for (const defs of defsElements) {
949
1315
  for (const child of [...defs.children]) {
950
- if (child instanceof SVGElement) {
951
- const id = child.getAttribute('id');
952
- if (id && !usedIds.has(id)) {
1316
+ // Use tagName check instead of instanceof
1317
+ if (child && child.tagName) {
1318
+ // Only remove if neither the element nor any of its descendants are referenced
1319
+ // ALSO never remove foreignObject, audio, video (preserve elements)
1320
+ const childTagName = child.tagName.toLowerCase();
1321
+ if (PRESERVE_ELEMENTS.has(childTagName)) {
1322
+ continue; // Never remove preserve elements from defs
1323
+ }
1324
+ if (!hasReferencedDescendant(child)) {
953
1325
  defs.removeChild(child);
954
1326
  count++;
955
1327
  }
@@ -966,13 +1338,18 @@ function removeUnusedDefinitions(root) {
966
1338
 
967
1339
  /**
968
1340
  * Get path data from any shape element.
1341
+ * Converts basic shapes (rect, circle, ellipse, etc.) to path data.
1342
+ *
1343
+ * @param {SVGElement} el - Element to convert
1344
+ * @param {number} precision - Decimal precision for coordinates
1345
+ * @returns {string|null} Path data string or null if not convertible
969
1346
  * @private
970
1347
  */
971
1348
  function getElementPathData(el, precision) {
972
1349
  const tagName = el.tagName.toLowerCase();
973
1350
 
974
- if (tagName === 'path') {
975
- return el.getAttribute('d');
1351
+ if (tagName === "path") {
1352
+ return el.getAttribute("d");
976
1353
  }
977
1354
 
978
1355
  // Use GeometryToPath for shape conversion
@@ -982,34 +1359,49 @@ function getElementPathData(el, precision) {
982
1359
  };
983
1360
 
984
1361
  switch (tagName) {
985
- case 'rect':
1362
+ case "rect":
986
1363
  return GeometryToPath.rectToPathData(
987
- getAttr('x'), getAttr('y'),
988
- getAttr('width'), getAttr('height'),
989
- getAttr('rx'), getAttr('ry') || null,
990
- false, precision
1364
+ getAttr("x"),
1365
+ getAttr("y"),
1366
+ getAttr("width"),
1367
+ getAttr("height"),
1368
+ getAttr("rx"),
1369
+ getAttr("ry") || null,
1370
+ false,
1371
+ precision,
991
1372
  );
992
- case 'circle':
1373
+ case "circle":
993
1374
  return GeometryToPath.circleToPathData(
994
- getAttr('cx'), getAttr('cy'), getAttr('r'), precision
1375
+ getAttr("cx"),
1376
+ getAttr("cy"),
1377
+ getAttr("r"),
1378
+ precision,
995
1379
  );
996
- case 'ellipse':
1380
+ case "ellipse":
997
1381
  return GeometryToPath.ellipseToPathData(
998
- getAttr('cx'), getAttr('cy'),
999
- getAttr('rx'), getAttr('ry'), precision
1382
+ getAttr("cx"),
1383
+ getAttr("cy"),
1384
+ getAttr("rx"),
1385
+ getAttr("ry"),
1386
+ precision,
1000
1387
  );
1001
- case 'line':
1388
+ case "line":
1002
1389
  return GeometryToPath.lineToPathData(
1003
- getAttr('x1'), getAttr('y1'),
1004
- getAttr('x2'), getAttr('y2'), precision
1390
+ getAttr("x1"),
1391
+ getAttr("y1"),
1392
+ getAttr("x2"),
1393
+ getAttr("y2"),
1394
+ precision,
1005
1395
  );
1006
- case 'polyline':
1396
+ case "polyline":
1007
1397
  return GeometryToPath.polylineToPathData(
1008
- el.getAttribute('points') || '', precision
1398
+ el.getAttribute("points") || "",
1399
+ precision,
1009
1400
  );
1010
- case 'polygon':
1401
+ case "polygon":
1011
1402
  return GeometryToPath.polygonToPathData(
1012
- el.getAttribute('points') || '', precision
1403
+ el.getAttribute("points") || "",
1404
+ precision,
1013
1405
  );
1014
1406
  default:
1015
1407
  return null;
@@ -1018,6 +1410,10 @@ function getElementPathData(el, precision) {
1018
1410
 
1019
1411
  /**
1020
1412
  * Get approximate bounding box of an element.
1413
+ * Calculates bounding box by sampling points from the element's geometry.
1414
+ *
1415
+ * @param {SVGElement} el - Element to measure
1416
+ * @returns {{x: number, y: number, width: number, height: number}|null} Bounding box or null if calculation fails
1021
1417
  * @private
1022
1418
  */
1023
1419
  function getElementBBox(el) {
@@ -1028,16 +1424,47 @@ function getElementBBox(el) {
1028
1424
  const polygon = ClipPathResolver.pathToPolygon(pathData, 10);
1029
1425
  if (!polygon || polygon.length === 0) return null;
1030
1426
 
1031
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1427
+ let minX = Infinity,
1428
+ minY = Infinity,
1429
+ maxX = -Infinity,
1430
+ maxY = -Infinity;
1032
1431
  for (const pt of polygon) {
1033
- const x = pt.x instanceof Decimal ? pt.x.toNumber() : pt.x;
1034
- const y = pt.y instanceof Decimal ? pt.y.toNumber() : pt.y;
1432
+ // Validate point has coordinates
1433
+ if (!pt || (pt.x === undefined && pt.y === undefined)) continue;
1434
+
1435
+ // Convert to numbers, defaulting to 0 for invalid values
1436
+ const x =
1437
+ pt.x instanceof Decimal
1438
+ ? pt.x.toNumber()
1439
+ : typeof pt.x === "number"
1440
+ ? pt.x
1441
+ : 0;
1442
+ const y =
1443
+ pt.y instanceof Decimal
1444
+ ? pt.y.toNumber()
1445
+ : typeof pt.y === "number"
1446
+ ? pt.y
1447
+ : 0;
1448
+
1449
+ // Skip NaN values
1450
+ if (isNaN(x) || isNaN(y)) continue;
1451
+
1035
1452
  if (x < minX) minX = x;
1036
1453
  if (y < minY) minY = y;
1037
1454
  if (x > maxX) maxX = x;
1038
1455
  if (y > maxY) maxY = y;
1039
1456
  }
1040
1457
 
1458
+ // Validate that we found at least one valid point
1459
+ if (
1460
+ minX === Infinity ||
1461
+ maxX === -Infinity ||
1462
+ minY === Infinity ||
1463
+ maxY === -Infinity
1464
+ ) {
1465
+ return null;
1466
+ }
1467
+
1041
1468
  return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
1042
1469
  } catch {
1043
1470
  return null;
@@ -1064,53 +1491,74 @@ function getElementBBox(el) {
1064
1491
  function extractPresentationAttrs(el) {
1065
1492
  const presentationAttrs = [
1066
1493
  // Stroke properties
1067
- 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
1068
- 'stroke-dasharray', 'stroke-dashoffset', 'stroke-miterlimit', 'stroke-opacity',
1069
- 'vector-effect', // Affects stroke rendering (non-scaling-stroke)
1494
+ "stroke",
1495
+ "stroke-width",
1496
+ "stroke-linecap",
1497
+ "stroke-linejoin",
1498
+ "stroke-dasharray",
1499
+ "stroke-dashoffset",
1500
+ "stroke-miterlimit",
1501
+ "stroke-opacity",
1502
+ "vector-effect", // Affects stroke rendering (non-scaling-stroke)
1070
1503
 
1071
1504
  // Fill properties
1072
- 'fill', 'fill-opacity', 'fill-rule',
1505
+ "fill",
1506
+ "fill-opacity",
1507
+ "fill-rule",
1073
1508
 
1074
1509
  // 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
1510
+ "clip-path", // Clips geometry - MUST NOT BE LOST
1511
+ "mask", // Masks transparency - MUST NOT BE LOST
1512
+ "filter", // Visual effects - MUST NOT BE LOST
1513
+ "opacity", // Element opacity
1079
1514
 
1080
1515
  // Clip/fill rules
1081
- 'clip-rule',
1516
+ "clip-rule",
1082
1517
 
1083
1518
  // 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
1519
+ "marker", // Shorthand for all markers
1520
+ "marker-start", // Start of path
1521
+ "marker-mid", // Vertices
1522
+ "marker-end", // End of path
1088
1523
 
1089
1524
  // Visibility
1090
- 'visibility', 'display',
1525
+ "visibility",
1526
+ "display",
1091
1527
 
1092
1528
  // Color
1093
- 'color',
1529
+ "color",
1094
1530
 
1095
1531
  // 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',
1532
+ "font-family",
1533
+ "font-size",
1534
+ "font-weight",
1535
+ "font-style",
1536
+ "text-anchor",
1537
+ "dominant-baseline",
1538
+ "alignment-baseline",
1539
+ "letter-spacing",
1540
+ "word-spacing",
1541
+ "text-decoration",
1099
1542
 
1100
1543
  // Rendering hints
1101
- 'shape-rendering', 'text-rendering', 'image-rendering', 'color-rendering',
1544
+ "shape-rendering",
1545
+ "text-rendering",
1546
+ "image-rendering",
1547
+ "color-rendering",
1102
1548
 
1103
1549
  // Paint order (affects stroke/fill/marker rendering order)
1104
- 'paint-order',
1550
+ "paint-order",
1105
1551
 
1106
1552
  // Event handling (visual feedback)
1107
- 'pointer-events', 'cursor',
1553
+ "pointer-events",
1554
+ "cursor",
1108
1555
 
1109
1556
  // Preserve class and style for CSS targeting
1110
- 'class', 'style',
1557
+ "class",
1558
+ "style",
1111
1559
 
1112
1560
  // ID must be preserved for references
1113
- 'id'
1561
+ "id",
1114
1562
  ];
1115
1563
 
1116
1564
  const attrs = {};
@@ -1133,20 +1581,32 @@ function extractPresentationAttrs(el) {
1133
1581
  */
1134
1582
  function getShapeSpecificAttrs(tagName) {
1135
1583
  const attrs = {
1136
- rect: ['x', 'y', 'width', 'height', 'rx', 'ry'],
1137
- circle: ['cx', 'cy', 'r'],
1138
- ellipse: ['cx', 'cy', 'rx', 'ry'],
1139
- line: ['x1', 'y1', 'x2', 'y2'],
1140
- polyline: ['points'],
1141
- polygon: ['points'],
1584
+ rect: ["x", "y", "width", "height", "rx", "ry"],
1585
+ circle: ["cx", "cy", "r"],
1586
+ ellipse: ["cx", "cy", "rx", "ry"],
1587
+ line: ["x1", "y1", "x2", "y2"],
1588
+ polyline: ["points"],
1589
+ polygon: ["points"],
1142
1590
  // Image element has position/size attributes that don't apply to paths
1143
- image: ['x', 'y', 'width', 'height', 'href', 'xlink:href', 'preserveAspectRatio'],
1591
+ image: [
1592
+ "x",
1593
+ "y",
1594
+ "width",
1595
+ "height",
1596
+ "href",
1597
+ "xlink:href",
1598
+ "preserveAspectRatio",
1599
+ ],
1144
1600
  };
1145
1601
  return attrs[tagName.toLowerCase()] || [];
1146
1602
  }
1147
1603
 
1148
1604
  /**
1149
1605
  * Convert matrix to transform attribute string.
1606
+ * Converts a Matrix object to SVG matrix() transform format.
1607
+ *
1608
+ * @param {Matrix} matrix - Transformation matrix
1609
+ * @returns {string} SVG transform attribute value
1150
1610
  * @private
1151
1611
  */
1152
1612
  function matrixToTransform(matrix) {
@@ -1161,24 +1621,32 @@ function matrixToTransform(matrix) {
1161
1621
 
1162
1622
  /**
1163
1623
  * Intersect two polygons using Sutherland-Hodgman algorithm.
1624
+ * Computes the boolean intersection of two polygons.
1625
+ *
1626
+ * @param {Array<{x: Decimal, y: Decimal}>} subject - Subject polygon vertices
1627
+ * @param {Array<{x: Decimal, y: Decimal}>} clip - Clip polygon vertices
1628
+ * @returns {Array<{x: Decimal, y: Decimal}>} Intersection polygon vertices
1164
1629
  * @private
1165
1630
  */
1166
1631
  function intersectPolygons(subject, clip) {
1167
- // Use PolygonClip if available, otherwise simple implementation
1168
- try {
1169
- const PolygonClip = require('./polygon-clip.js');
1170
- if (PolygonClip.intersect) {
1171
- return PolygonClip.intersect(subject, clip);
1172
- }
1173
- } catch {
1174
- // Fall through to simple implementation
1175
- }
1176
-
1177
- // Simple convex clip implementation
1632
+ // Validate inputs
1178
1633
  if (!subject || subject.length < 3 || !clip || clip.length < 3) {
1179
1634
  return subject;
1180
1635
  }
1181
1636
 
1637
+ // Use advanced polygon intersection from polygon-clip module if available
1638
+ // Falls back to simple convex clip implementation
1639
+ if (PolygonClip && PolygonClip.polygonIntersection) {
1640
+ try {
1641
+ return PolygonClip.polygonIntersection(subject, clip);
1642
+ } catch (_error) {
1643
+ // Fall through to simple implementation on error
1644
+ // Error is intentionally not logged to avoid noise from expected edge cases
1645
+ }
1646
+ }
1647
+
1648
+ // Simple convex clip implementation (Sutherland-Hodgman)
1649
+
1182
1650
  let output = [...subject];
1183
1651
 
1184
1652
  for (let i = 0; i < clip.length; i++) {
@@ -1213,13 +1681,21 @@ function intersectPolygons(subject, clip) {
1213
1681
 
1214
1682
  /**
1215
1683
  * Check if point is inside edge (left side).
1684
+ * Uses cross product to determine if point is on the left (inside) of an edge.
1685
+ *
1686
+ * @param {{x: Decimal|number, y: Decimal|number}} point - Point to test
1687
+ * @param {{x: Decimal|number, y: Decimal|number}} edgeStart - Edge start vertex
1688
+ * @param {{x: Decimal|number, y: Decimal|number}} edgeEnd - Edge end vertex
1689
+ * @returns {boolean} True if point is inside (left of) the edge
1216
1690
  * @private
1217
1691
  */
1218
1692
  function isInsideEdge(point, edgeStart, edgeEnd) {
1219
1693
  const px = point.x instanceof Decimal ? point.x.toNumber() : point.x;
1220
1694
  const py = point.y instanceof Decimal ? point.y.toNumber() : point.y;
1221
- const sx = edgeStart.x instanceof Decimal ? edgeStart.x.toNumber() : edgeStart.x;
1222
- const sy = edgeStart.y instanceof Decimal ? edgeStart.y.toNumber() : edgeStart.y;
1695
+ const sx =
1696
+ edgeStart.x instanceof Decimal ? edgeStart.x.toNumber() : edgeStart.x;
1697
+ const sy =
1698
+ edgeStart.y instanceof Decimal ? edgeStart.y.toNumber() : edgeStart.y;
1223
1699
  const ex = edgeEnd.x instanceof Decimal ? edgeEnd.x.toNumber() : edgeEnd.x;
1224
1700
  const ey = edgeEnd.y instanceof Decimal ? edgeEnd.y.toNumber() : edgeEnd.y;
1225
1701
 
@@ -1228,7 +1704,16 @@ function isInsideEdge(point, edgeStart, edgeEnd) {
1228
1704
 
1229
1705
  /**
1230
1706
  * Find intersection point of two lines.
1707
+ *
1708
+ * When lines are nearly parallel (determinant near zero), returns the midpoint
1709
+ * of the first line segment as a reasonable fallback.
1710
+ *
1231
1711
  * @private
1712
+ * @param {Object} p1 - First point of first line
1713
+ * @param {Object} p2 - Second point of first line
1714
+ * @param {Object} p3 - First point of second line
1715
+ * @param {Object} p4 - Second point of second line
1716
+ * @returns {Object} Intersection point with x, y as Decimal
1232
1717
  */
1233
1718
  function lineIntersect(p1, p2, p3, p4) {
1234
1719
  const x1 = p1.x instanceof Decimal ? p1.x.toNumber() : p1.x;
@@ -1241,6 +1726,9 @@ function lineIntersect(p1, p2, p3, p4) {
1241
1726
  const y4 = p4.y instanceof Decimal ? p4.y.toNumber() : p4.y;
1242
1727
 
1243
1728
  const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
1729
+
1730
+ // Tolerance of 1e-10 chosen to match Decimal precision expectations
1731
+ // For nearly-parallel lines, return midpoint of first segment as fallback
1244
1732
  if (Math.abs(denom) < 1e-10) {
1245
1733
  return { x: D((x1 + x2) / 2), y: D((y1 + y2) / 2) };
1246
1734
  }
@@ -1249,7 +1737,7 @@ function lineIntersect(p1, p2, p3, p4) {
1249
1737
 
1250
1738
  return {
1251
1739
  x: D(x1 + t * (x2 - x1)),
1252
- y: D(y1 + t * (y2 - y1))
1740
+ y: D(y1 + t * (y2 - y1)),
1253
1741
  };
1254
1742
  }
1255
1743
 
@@ -1259,5 +1747,5 @@ function lineIntersect(p1, p2, p3, p4) {
1259
1747
 
1260
1748
  export default {
1261
1749
  flattenSVG,
1262
- DEFAULT_OPTIONS
1750
+ DEFAULT_OPTIONS,
1263
1751
  };