@emasoft/svg-matrix 1.0.10 → 1.0.12

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,992 @@
1
+ /**
2
+ * Flatten Pipeline - Comprehensive SVG flattening with all transform dependencies resolved
3
+ *
4
+ * A TRUE flattened SVG has:
5
+ * - NO transform attributes anywhere
6
+ * - NO clipPath references (boolean operations pre-applied)
7
+ * - NO mask references (converted to clipped geometry)
8
+ * - NO use/symbol references (instances pre-expanded)
9
+ * - NO pattern fill references (patterns pre-tiled)
10
+ * - NO marker references (markers pre-instantiated)
11
+ * - NO gradient transforms (gradientTransform pre-baked)
12
+ *
13
+ * @module flatten-pipeline
14
+ */
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
+
30
+ Decimal.set({ precision: 80 });
31
+
32
+ const D = x => (x instanceof Decimal ? x : new Decimal(x));
33
+
34
+ /**
35
+ * Default options for flatten pipeline.
36
+ */
37
+ const DEFAULT_OPTIONS = {
38
+ precision: 6, // Decimal places in output coordinates
39
+ curveSegments: 20, // Samples per curve for polygon conversion
40
+ resolveUse: true, // Expand <use> elements
41
+ resolveMarkers: true, // Expand marker instances
42
+ resolvePatterns: true, // Expand pattern fills to geometry
43
+ resolveMasks: true, // Convert masks to clip paths
44
+ resolveClipPaths: true, // Apply clipPath boolean operations
45
+ flattenTransforms: true, // Bake transform attributes into coordinates
46
+ bakeGradients: true, // Bake gradientTransform into gradient coords
47
+ removeUnusedDefs: true, // Remove defs that are no longer referenced
48
+ preserveIds: false, // Keep original IDs on expanded elements
49
+ };
50
+
51
+ /**
52
+ * Flatten an SVG string completely - no transform dependencies remain.
53
+ *
54
+ * @param {string} svgString - Raw SVG content
55
+ * @param {Object} [options] - Pipeline options
56
+ * @param {number} [options.precision=6] - Decimal places in output
57
+ * @param {number} [options.curveSegments=20] - Samples per curve
58
+ * @param {boolean} [options.resolveUse=true] - Expand use elements
59
+ * @param {boolean} [options.resolveMarkers=true] - Expand markers
60
+ * @param {boolean} [options.resolvePatterns=true] - Expand patterns
61
+ * @param {boolean} [options.resolveMasks=true] - Convert masks to clips
62
+ * @param {boolean} [options.resolveClipPaths=true] - Apply clipPath booleans
63
+ * @param {boolean} [options.flattenTransforms=true] - Bake transforms
64
+ * @param {boolean} [options.bakeGradients=true] - Bake gradient transforms
65
+ * @param {boolean} [options.removeUnusedDefs=true] - Clean up defs
66
+ * @returns {{svg: string, stats: Object}} Flattened SVG and statistics
67
+ */
68
+ export function flattenSVG(svgString, options = {}) {
69
+ const opts = { ...DEFAULT_OPTIONS, ...options };
70
+ const stats = {
71
+ useResolved: 0,
72
+ markersResolved: 0,
73
+ patternsResolved: 0,
74
+ masksResolved: 0,
75
+ clipPathsApplied: 0,
76
+ transformsFlattened: 0,
77
+ gradientsProcessed: 0,
78
+ defsRemoved: 0,
79
+ errors: [],
80
+ };
81
+
82
+ try {
83
+ // Parse SVG
84
+ const root = parseSVG(svgString);
85
+ if (root.tagName !== 'svg') {
86
+ throw new Error('Root element must be <svg>');
87
+ }
88
+
89
+ // Build defs map
90
+ let defsMap = buildDefsMap(root);
91
+
92
+ // Step 1: Resolve <use> elements (must be first - creates new geometry)
93
+ if (opts.resolveUse) {
94
+ const result = resolveAllUseElements(root, defsMap, opts);
95
+ stats.useResolved = result.count;
96
+ stats.errors.push(...result.errors);
97
+ defsMap = buildDefsMap(root); // Rebuild after modifications
98
+ }
99
+
100
+ // Step 2: Resolve markers (adds geometry to paths)
101
+ if (opts.resolveMarkers) {
102
+ const result = resolveAllMarkers(root, defsMap, opts);
103
+ stats.markersResolved = result.count;
104
+ stats.errors.push(...result.errors);
105
+ }
106
+
107
+ // Step 3: Resolve patterns (expand pattern fills)
108
+ if (opts.resolvePatterns) {
109
+ const result = resolveAllPatterns(root, defsMap, opts);
110
+ stats.patternsResolved = result.count;
111
+ stats.errors.push(...result.errors);
112
+ }
113
+
114
+ // Step 4: Resolve masks (convert to clip geometry)
115
+ if (opts.resolveMasks) {
116
+ const result = resolveAllMasks(root, defsMap, opts);
117
+ stats.masksResolved = result.count;
118
+ stats.errors.push(...result.errors);
119
+ }
120
+
121
+ // Step 5: Apply clipPaths (boolean intersection)
122
+ if (opts.resolveClipPaths) {
123
+ const result = applyAllClipPaths(root, defsMap, opts);
124
+ stats.clipPathsApplied = result.count;
125
+ stats.errors.push(...result.errors);
126
+ }
127
+
128
+ // Step 6: Flatten transforms (bake into coordinates)
129
+ if (opts.flattenTransforms) {
130
+ const result = flattenAllTransforms(root, opts);
131
+ stats.transformsFlattened = result.count;
132
+ stats.errors.push(...result.errors);
133
+ }
134
+
135
+ // Step 7: Bake gradient transforms
136
+ if (opts.bakeGradients) {
137
+ const result = bakeAllGradientTransforms(root, opts);
138
+ stats.gradientsProcessed = result.count;
139
+ stats.errors.push(...result.errors);
140
+ }
141
+
142
+ // Step 8: Remove unused defs
143
+ if (opts.removeUnusedDefs) {
144
+ const result = removeUnusedDefinitions(root);
145
+ stats.defsRemoved = result.count;
146
+ }
147
+
148
+ // Serialize back to SVG
149
+ const svg = serializeSVG(root);
150
+
151
+ return { svg, stats };
152
+
153
+ } catch (error) {
154
+ stats.errors.push(`Pipeline error: ${error.message}`);
155
+ return { svg: svgString, stats }; // Return original on failure
156
+ }
157
+ }
158
+
159
+ // ============================================================================
160
+ // STEP 1: RESOLVE USE ELEMENTS
161
+ // ============================================================================
162
+
163
+ /**
164
+ * Resolve all <use> elements by expanding them inline.
165
+ * @private
166
+ */
167
+ function resolveAllUseElements(root, defsMap, opts) {
168
+ const errors = [];
169
+ let count = 0;
170
+
171
+ const useElements = root.getElementsByTagName('use');
172
+
173
+ for (const useEl of [...useElements]) { // Clone array since we modify DOM
174
+ try {
175
+ const href = useEl.getAttribute('href') || useEl.getAttribute('xlink:href');
176
+ if (!href) continue;
177
+
178
+ const refId = href.replace(/^#/, '');
179
+ const refEl = defsMap.get(refId);
180
+
181
+ if (!refEl) {
182
+ errors.push(`use: referenced element #${refId} not found`);
183
+ continue;
184
+ }
185
+
186
+ // Parse use element data
187
+ const useData = UseSymbolResolver.parseUseElement(useEl);
188
+
189
+ // Resolve the use
190
+ const resolved = UseSymbolResolver.resolveUse(useData, Object.fromEntries(defsMap), {
191
+ samples: opts.curveSegments
192
+ });
193
+
194
+ if (!resolved) {
195
+ errors.push(`use: failed to resolve #${refId}`);
196
+ continue;
197
+ }
198
+
199
+ // Convert resolved to path data
200
+ const pathData = UseSymbolResolver.resolvedUseToPathData(resolved, opts.curveSegments);
201
+
202
+ if (pathData) {
203
+ // Create new path element to replace <use>
204
+ const pathEl = new SVGElement('path', {
205
+ d: pathData,
206
+ ...extractPresentationAttrs(useEl)
207
+ });
208
+
209
+ // Copy style attributes from resolved
210
+ if (resolved.style) {
211
+ for (const [key, val] of Object.entries(resolved.style)) {
212
+ if (val && !pathEl.hasAttribute(key)) {
213
+ pathEl.setAttribute(key, val);
214
+ }
215
+ }
216
+ }
217
+
218
+ // Replace use with path
219
+ if (useEl.parentNode) {
220
+ useEl.parentNode.replaceChild(pathEl, useEl);
221
+ count++;
222
+ }
223
+ }
224
+ } catch (e) {
225
+ errors.push(`use: ${e.message}`);
226
+ }
227
+ }
228
+
229
+ return { count, errors };
230
+ }
231
+
232
+ // ============================================================================
233
+ // STEP 2: RESOLVE MARKERS
234
+ // ============================================================================
235
+
236
+ /**
237
+ * Resolve all marker references by instantiating marker geometry.
238
+ * @private
239
+ */
240
+ function resolveAllMarkers(root, defsMap, opts) {
241
+ const errors = [];
242
+ let count = 0;
243
+
244
+ // Find all elements with marker attributes
245
+ const markerAttrs = ['marker-start', 'marker-mid', 'marker-end', 'marker'];
246
+
247
+ for (const attrName of markerAttrs) {
248
+ const elements = findElementsWithAttribute(root, attrName);
249
+
250
+ for (const el of elements) {
251
+ if (el.tagName !== 'path' && el.tagName !== 'line' && el.tagName !== 'polyline' && el.tagName !== 'polygon') {
252
+ continue;
253
+ }
254
+
255
+ try {
256
+ // Resolve markers for this element
257
+ const markerInstances = MarkerResolver.resolveMarkers(el, Object.fromEntries(defsMap));
258
+
259
+ if (!markerInstances || markerInstances.length === 0) continue;
260
+
261
+ // Convert markers to path data
262
+ const markerPathData = MarkerResolver.markersToPathData(markerInstances, opts.precision);
263
+
264
+ if (markerPathData) {
265
+ // Create new path element for marker geometry
266
+ const markerPath = new SVGElement('path', {
267
+ d: markerPathData,
268
+ fill: el.getAttribute('stroke') || 'currentColor', // Markers typically use stroke color
269
+ });
270
+
271
+ // Insert after the original element
272
+ if (el.parentNode) {
273
+ const nextSibling = el.nextSibling;
274
+ if (nextSibling) {
275
+ el.parentNode.insertBefore(markerPath, nextSibling);
276
+ } else {
277
+ el.parentNode.appendChild(markerPath);
278
+ }
279
+ count++;
280
+ }
281
+ }
282
+
283
+ // Remove marker attributes from original element
284
+ for (const attr of markerAttrs) {
285
+ el.removeAttribute(attr);
286
+ }
287
+ } catch (e) {
288
+ errors.push(`marker: ${e.message}`);
289
+ }
290
+ }
291
+ }
292
+
293
+ return { count, errors };
294
+ }
295
+
296
+ // ============================================================================
297
+ // STEP 3: RESOLVE PATTERNS
298
+ // ============================================================================
299
+
300
+ /**
301
+ * Resolve pattern fills by expanding to tiled geometry.
302
+ * @private
303
+ */
304
+ function resolveAllPatterns(root, defsMap, opts) {
305
+ const errors = [];
306
+ let count = 0;
307
+
308
+ const elementsWithFill = findElementsWithAttribute(root, 'fill');
309
+
310
+ for (const el of elementsWithFill) {
311
+ const fill = el.getAttribute('fill');
312
+ if (!fill || !fill.includes('url(')) continue;
313
+
314
+ const refId = parseUrlReference(fill);
315
+ if (!refId) continue;
316
+
317
+ const patternEl = defsMap.get(refId);
318
+ if (!patternEl || patternEl.tagName !== 'pattern') continue;
319
+
320
+ try {
321
+ // Get element bounding box (approximate from path data or attributes)
322
+ const bbox = getElementBBox(el);
323
+ if (!bbox) continue;
324
+
325
+ // Parse pattern
326
+ const patternData = PatternResolver.parsePatternElement(patternEl);
327
+
328
+ // Resolve pattern to path data
329
+ const patternPathData = PatternResolver.patternToPathData(patternData, bbox, {
330
+ samples: opts.curveSegments
331
+ });
332
+
333
+ if (patternPathData) {
334
+ // Create group with clipped pattern geometry
335
+ const patternGroup = new SVGElement('g', {});
336
+
337
+ const patternPath = new SVGElement('path', {
338
+ d: patternPathData,
339
+ fill: '#000', // Pattern content typically has its own fill
340
+ });
341
+
342
+ patternGroup.appendChild(patternPath);
343
+
344
+ // Replace fill with pattern geometry (clip to original shape)
345
+ el.setAttribute('fill', 'none');
346
+ el.setAttribute('stroke', el.getAttribute('stroke') || 'none');
347
+
348
+ if (el.parentNode) {
349
+ el.parentNode.insertBefore(patternGroup, el);
350
+ count++;
351
+ }
352
+ }
353
+ } catch (e) {
354
+ errors.push(`pattern: ${e.message}`);
355
+ }
356
+ }
357
+
358
+ return { count, errors };
359
+ }
360
+
361
+ // ============================================================================
362
+ // STEP 4: RESOLVE MASKS
363
+ // ============================================================================
364
+
365
+ /**
366
+ * Resolve mask references by converting to clip geometry.
367
+ * @private
368
+ */
369
+ function resolveAllMasks(root, defsMap, opts) {
370
+ const errors = [];
371
+ let count = 0;
372
+
373
+ const elementsWithMask = findElementsWithAttribute(root, 'mask');
374
+
375
+ for (const el of elementsWithMask) {
376
+ const maskRef = el.getAttribute('mask');
377
+ if (!maskRef || !maskRef.includes('url(')) continue;
378
+
379
+ const refId = parseUrlReference(maskRef);
380
+ if (!refId) continue;
381
+
382
+ const maskEl = defsMap.get(refId);
383
+ if (!maskEl || maskEl.tagName !== 'mask') continue;
384
+
385
+ try {
386
+ // Get element bounding box
387
+ const bbox = getElementBBox(el);
388
+ if (!bbox) continue;
389
+
390
+ // Parse mask
391
+ const maskData = MaskResolver.parseMaskElement(maskEl);
392
+
393
+ // Convert mask to clip path data
394
+ const clipPathData = MaskResolver.maskToPathData(maskData, bbox, {
395
+ samples: opts.curveSegments,
396
+ opacityThreshold: 0.5
397
+ });
398
+
399
+ if (clipPathData) {
400
+ // Get original element's path data
401
+ const origPathData = getElementPathData(el, opts.precision);
402
+
403
+ if (origPathData) {
404
+ // Perform boolean intersection
405
+ const origPolygon = ClipPathResolver.pathToPolygon(origPathData, opts.curveSegments);
406
+ const clipPolygon = ClipPathResolver.pathToPolygon(clipPathData, opts.curveSegments);
407
+
408
+ // Apply clip (intersection)
409
+ const clippedPolygon = intersectPolygons(origPolygon, clipPolygon);
410
+
411
+ if (clippedPolygon && clippedPolygon.length > 2) {
412
+ const clippedPath = ClipPathResolver.polygonToPathData(clippedPolygon, opts.precision);
413
+ el.setAttribute('d', clippedPath);
414
+ el.removeAttribute('mask');
415
+ count++;
416
+ }
417
+ }
418
+ }
419
+ } catch (e) {
420
+ errors.push(`mask: ${e.message}`);
421
+ }
422
+ }
423
+
424
+ return { count, errors };
425
+ }
426
+
427
+ // ============================================================================
428
+ // STEP 5: APPLY CLIP PATHS
429
+ // ============================================================================
430
+
431
+ /**
432
+ * Apply clipPath references by performing boolean intersection.
433
+ * @private
434
+ */
435
+ function applyAllClipPaths(root, defsMap, opts) {
436
+ const errors = [];
437
+ let count = 0;
438
+
439
+ const elementsWithClip = findElementsWithAttribute(root, 'clip-path');
440
+
441
+ for (const el of elementsWithClip) {
442
+ const clipRef = el.getAttribute('clip-path');
443
+ if (!clipRef || !clipRef.includes('url(')) continue;
444
+
445
+ const refId = parseUrlReference(clipRef);
446
+ if (!refId) continue;
447
+
448
+ const clipPathEl = defsMap.get(refId);
449
+ if (!clipPathEl || clipPathEl.tagName !== 'clippath') continue;
450
+
451
+ try {
452
+ // Get element path data
453
+ const origPathData = getElementPathData(el, opts.precision);
454
+ if (!origPathData) continue;
455
+
456
+ // Get clip path data from clipPath element's children
457
+ let clipPathData = '';
458
+ for (const child of clipPathEl.children) {
459
+ if (child instanceof SVGElement) {
460
+ const childPath = getElementPathData(child, opts.precision);
461
+ if (childPath) {
462
+ clipPathData += (clipPathData ? ' ' : '') + childPath;
463
+ }
464
+ }
465
+ }
466
+
467
+ if (!clipPathData) continue;
468
+
469
+ // Convert to polygons
470
+ const origPolygon = ClipPathResolver.pathToPolygon(origPathData, opts.curveSegments);
471
+ const clipPolygon = ClipPathResolver.pathToPolygon(clipPathData, opts.curveSegments);
472
+
473
+ // Perform intersection
474
+ const clippedPolygon = intersectPolygons(origPolygon, clipPolygon);
475
+
476
+ if (clippedPolygon && clippedPolygon.length > 2) {
477
+ const clippedPath = ClipPathResolver.polygonToPathData(clippedPolygon, opts.precision);
478
+
479
+ // Update element
480
+ if (el.tagName === 'path') {
481
+ el.setAttribute('d', clippedPath);
482
+ } else {
483
+ // Convert shape to path
484
+ const newPath = new SVGElement('path', {
485
+ d: clippedPath,
486
+ ...extractPresentationAttrs(el)
487
+ });
488
+
489
+ if (el.parentNode) {
490
+ el.parentNode.replaceChild(newPath, el);
491
+ }
492
+ }
493
+
494
+ el.removeAttribute('clip-path');
495
+ count++;
496
+ }
497
+ } catch (e) {
498
+ errors.push(`clipPath: ${e.message}`);
499
+ }
500
+ }
501
+
502
+ return { count, errors };
503
+ }
504
+
505
+ // ============================================================================
506
+ // STEP 6: FLATTEN TRANSFORMS
507
+ // ============================================================================
508
+
509
+ /**
510
+ * Flatten all transform attributes by baking into coordinates.
511
+ * @private
512
+ */
513
+ function flattenAllTransforms(root, opts) {
514
+ const errors = [];
515
+ let count = 0;
516
+
517
+ const elementsWithTransform = findElementsWithAttribute(root, 'transform');
518
+
519
+ for (const el of elementsWithTransform) {
520
+ const transform = el.getAttribute('transform');
521
+ if (!transform) continue;
522
+
523
+ try {
524
+ // Parse transform to matrix
525
+ const ctm = SVGFlatten.parseTransformAttribute(transform);
526
+
527
+ // Get element path data
528
+ const pathData = getElementPathData(el, opts.precision);
529
+ if (!pathData) {
530
+ // For groups, propagate transform to children
531
+ if (el.tagName === 'g') {
532
+ propagateTransformToChildren(el, ctm, opts);
533
+ el.removeAttribute('transform');
534
+ count++;
535
+ }
536
+ continue;
537
+ }
538
+
539
+ // Transform the path data
540
+ const transformedPath = SVGFlatten.transformPathData(pathData, ctm, { precision: opts.precision });
541
+
542
+ // Update or replace element
543
+ if (el.tagName === 'path') {
544
+ el.setAttribute('d', transformedPath);
545
+ } else {
546
+ // Convert shape to path with transformed coordinates
547
+ const newPath = new SVGElement('path', {
548
+ d: transformedPath,
549
+ ...extractPresentationAttrs(el)
550
+ });
551
+
552
+ // Remove shape-specific attributes
553
+ for (const attr of getShapeSpecificAttrs(el.tagName)) {
554
+ newPath.removeAttribute(attr);
555
+ }
556
+
557
+ if (el.parentNode) {
558
+ el.parentNode.replaceChild(newPath, el);
559
+ }
560
+ }
561
+
562
+ el.removeAttribute('transform');
563
+ count++;
564
+ } catch (e) {
565
+ errors.push(`transform: ${e.message}`);
566
+ }
567
+ }
568
+
569
+ return { count, errors };
570
+ }
571
+
572
+ /**
573
+ * Propagate transform to all children of a group.
574
+ * @private
575
+ */
576
+ function propagateTransformToChildren(group, ctm, opts) {
577
+ for (const child of [...group.children]) {
578
+ if (!(child instanceof SVGElement)) continue;
579
+
580
+ if (child.tagName === 'g') {
581
+ // Nested group - compose transforms
582
+ const childTransform = child.getAttribute('transform');
583
+ if (childTransform) {
584
+ const childCtm = SVGFlatten.parseTransformAttribute(childTransform);
585
+ const combined = ctm.mul(childCtm);
586
+ child.setAttribute('transform', matrixToTransform(combined));
587
+ } else {
588
+ child.setAttribute('transform', matrixToTransform(ctm));
589
+ }
590
+ } else {
591
+ // Shape or path - apply transform to coordinates
592
+ const pathData = getElementPathData(child, opts.precision);
593
+ if (pathData) {
594
+ // Compose with any existing transform
595
+ const childTransform = child.getAttribute('transform');
596
+ let combinedCtm = ctm;
597
+ if (childTransform) {
598
+ const childCtm = SVGFlatten.parseTransformAttribute(childTransform);
599
+ combinedCtm = ctm.mul(childCtm);
600
+ }
601
+
602
+ const transformedPath = SVGFlatten.transformPathData(pathData, combinedCtm, { precision: opts.precision });
603
+
604
+ if (child.tagName === 'path') {
605
+ child.setAttribute('d', transformedPath);
606
+ } else {
607
+ // Replace with path element
608
+ const newPath = new SVGElement('path', {
609
+ d: transformedPath,
610
+ ...extractPresentationAttrs(child)
611
+ });
612
+
613
+ group.replaceChild(newPath, child);
614
+ }
615
+
616
+ child.removeAttribute('transform');
617
+ }
618
+ }
619
+ }
620
+ }
621
+
622
+ // ============================================================================
623
+ // STEP 7: BAKE GRADIENT TRANSFORMS
624
+ // ============================================================================
625
+
626
+ /**
627
+ * Bake gradientTransform into gradient coordinates.
628
+ * @private
629
+ */
630
+ function bakeAllGradientTransforms(root, opts) {
631
+ const errors = [];
632
+ let count = 0;
633
+
634
+ // Process linearGradient elements
635
+ const linearGradients = root.getElementsByTagName('linearGradient');
636
+ for (const grad of linearGradients) {
637
+ const gradientTransform = grad.getAttribute('gradientTransform');
638
+ if (!gradientTransform) continue;
639
+
640
+ try {
641
+ const ctm = SVGFlatten.parseTransformAttribute(gradientTransform);
642
+
643
+ // Transform x1,y1,x2,y2
644
+ const x1 = parseFloat(grad.getAttribute('x1') || '0');
645
+ const y1 = parseFloat(grad.getAttribute('y1') || '0');
646
+ const x2 = parseFloat(grad.getAttribute('x2') || '1');
647
+ const y2 = parseFloat(grad.getAttribute('y2') || '0');
648
+
649
+ const [tx1, ty1] = Transforms2D.applyTransform(ctm, x1, y1);
650
+ const [tx2, ty2] = Transforms2D.applyTransform(ctm, x2, y2);
651
+
652
+ grad.setAttribute('x1', tx1.toFixed(opts.precision));
653
+ grad.setAttribute('y1', ty1.toFixed(opts.precision));
654
+ grad.setAttribute('x2', tx2.toFixed(opts.precision));
655
+ grad.setAttribute('y2', ty2.toFixed(opts.precision));
656
+ grad.removeAttribute('gradientTransform');
657
+ count++;
658
+ } catch (e) {
659
+ errors.push(`linearGradient: ${e.message}`);
660
+ }
661
+ }
662
+
663
+ // Process radialGradient elements
664
+ const radialGradients = root.getElementsByTagName('radialGradient');
665
+ for (const grad of radialGradients) {
666
+ const gradientTransform = grad.getAttribute('gradientTransform');
667
+ if (!gradientTransform) continue;
668
+
669
+ try {
670
+ const ctm = SVGFlatten.parseTransformAttribute(gradientTransform);
671
+
672
+ // Transform cx,cy,fx,fy and scale r
673
+ const cx = parseFloat(grad.getAttribute('cx') || '0.5');
674
+ const cy = parseFloat(grad.getAttribute('cy') || '0.5');
675
+ const fx = parseFloat(grad.getAttribute('fx') || cx.toString());
676
+ const fy = parseFloat(grad.getAttribute('fy') || cy.toString());
677
+ const r = parseFloat(grad.getAttribute('r') || '0.5');
678
+
679
+ const [tcx, tcy] = Transforms2D.applyTransform(ctm, cx, cy);
680
+ const [tfx, tfy] = Transforms2D.applyTransform(ctm, fx, fy);
681
+
682
+ // Scale radius by average scale factor
683
+ const scale = Math.sqrt(Math.abs(ctm.data[0][0].toNumber() * ctm.data[1][1].toNumber()));
684
+ const tr = r * scale;
685
+
686
+ grad.setAttribute('cx', tcx.toFixed(opts.precision));
687
+ grad.setAttribute('cy', tcy.toFixed(opts.precision));
688
+ grad.setAttribute('fx', tfx.toFixed(opts.precision));
689
+ grad.setAttribute('fy', tfy.toFixed(opts.precision));
690
+ grad.setAttribute('r', tr.toFixed(opts.precision));
691
+ grad.removeAttribute('gradientTransform');
692
+ count++;
693
+ } catch (e) {
694
+ errors.push(`radialGradient: ${e.message}`);
695
+ }
696
+ }
697
+
698
+ return { count, errors };
699
+ }
700
+
701
+ // ============================================================================
702
+ // STEP 8: REMOVE UNUSED DEFS
703
+ // ============================================================================
704
+
705
+ /**
706
+ * Remove defs that are no longer referenced.
707
+ * @private
708
+ */
709
+ function removeUnusedDefinitions(root) {
710
+ let count = 0;
711
+
712
+ // Collect all url() references in the document
713
+ const usedIds = new Set();
714
+
715
+ const collectReferences = (el) => {
716
+ for (const attrName of el.getAttributeNames()) {
717
+ const val = el.getAttribute(attrName);
718
+ if (val && val.includes('url(')) {
719
+ const refId = parseUrlReference(val);
720
+ if (refId) usedIds.add(refId);
721
+ }
722
+ if (attrName === 'href' || attrName === 'xlink:href') {
723
+ const refId = val?.replace(/^#/, '');
724
+ if (refId) usedIds.add(refId);
725
+ }
726
+ }
727
+
728
+ for (const child of el.children) {
729
+ if (child instanceof SVGElement) {
730
+ collectReferences(child);
731
+ }
732
+ }
733
+ };
734
+
735
+ collectReferences(root);
736
+
737
+ // Remove unreferenced defs
738
+ const defsElements = root.getElementsByTagName('defs');
739
+ for (const defs of defsElements) {
740
+ for (const child of [...defs.children]) {
741
+ if (child instanceof SVGElement) {
742
+ const id = child.getAttribute('id');
743
+ if (id && !usedIds.has(id)) {
744
+ defs.removeChild(child);
745
+ count++;
746
+ }
747
+ }
748
+ }
749
+ }
750
+
751
+ return { count };
752
+ }
753
+
754
+ // ============================================================================
755
+ // UTILITY FUNCTIONS
756
+ // ============================================================================
757
+
758
+ /**
759
+ * Get path data from any shape element.
760
+ * @private
761
+ */
762
+ function getElementPathData(el, precision) {
763
+ const tagName = el.tagName.toLowerCase();
764
+
765
+ if (tagName === 'path') {
766
+ return el.getAttribute('d');
767
+ }
768
+
769
+ // Use GeometryToPath for shape conversion
770
+ const getAttr = (name, def = 0) => {
771
+ const val = el.getAttribute(name);
772
+ return val !== null ? parseFloat(val) : def;
773
+ };
774
+
775
+ switch (tagName) {
776
+ case 'rect':
777
+ return GeometryToPath.rectToPathData(
778
+ getAttr('x'), getAttr('y'),
779
+ getAttr('width'), getAttr('height'),
780
+ getAttr('rx'), getAttr('ry') || null,
781
+ false, precision
782
+ );
783
+ case 'circle':
784
+ return GeometryToPath.circleToPathData(
785
+ getAttr('cx'), getAttr('cy'), getAttr('r'), precision
786
+ );
787
+ case 'ellipse':
788
+ return GeometryToPath.ellipseToPathData(
789
+ getAttr('cx'), getAttr('cy'),
790
+ getAttr('rx'), getAttr('ry'), precision
791
+ );
792
+ case 'line':
793
+ return GeometryToPath.lineToPathData(
794
+ getAttr('x1'), getAttr('y1'),
795
+ getAttr('x2'), getAttr('y2'), precision
796
+ );
797
+ case 'polyline':
798
+ return GeometryToPath.polylineToPathData(
799
+ el.getAttribute('points') || '', precision
800
+ );
801
+ case 'polygon':
802
+ return GeometryToPath.polygonToPathData(
803
+ el.getAttribute('points') || '', precision
804
+ );
805
+ default:
806
+ return null;
807
+ }
808
+ }
809
+
810
+ /**
811
+ * Get approximate bounding box of an element.
812
+ * @private
813
+ */
814
+ function getElementBBox(el) {
815
+ const pathData = getElementPathData(el, 6);
816
+ if (!pathData) return null;
817
+
818
+ try {
819
+ const polygon = ClipPathResolver.pathToPolygon(pathData, 10);
820
+ if (!polygon || polygon.length === 0) return null;
821
+
822
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
823
+ for (const pt of polygon) {
824
+ const x = pt.x instanceof Decimal ? pt.x.toNumber() : pt.x;
825
+ const y = pt.y instanceof Decimal ? pt.y.toNumber() : pt.y;
826
+ if (x < minX) minX = x;
827
+ if (y < minY) minY = y;
828
+ if (x > maxX) maxX = x;
829
+ if (y > maxY) maxY = y;
830
+ }
831
+
832
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
833
+ } catch {
834
+ return null;
835
+ }
836
+ }
837
+
838
+ /**
839
+ * Extract presentation attributes from element.
840
+ * @private
841
+ */
842
+ function extractPresentationAttrs(el) {
843
+ const presentationAttrs = [
844
+ 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
845
+ 'stroke-dasharray', 'stroke-dashoffset', 'stroke-miterlimit', 'stroke-opacity',
846
+ 'fill-opacity', 'opacity', 'fill-rule', 'clip-rule', 'visibility', 'display',
847
+ 'color', 'font-family', 'font-size', 'font-weight', 'font-style',
848
+ 'text-anchor', 'dominant-baseline', 'class', 'style'
849
+ ];
850
+
851
+ const attrs = {};
852
+ for (const name of presentationAttrs) {
853
+ const val = el.getAttribute(name);
854
+ if (val !== null) {
855
+ attrs[name] = val;
856
+ }
857
+ }
858
+ return attrs;
859
+ }
860
+
861
+ /**
862
+ * Get shape-specific attribute names.
863
+ * @private
864
+ */
865
+ function getShapeSpecificAttrs(tagName) {
866
+ const attrs = {
867
+ rect: ['x', 'y', 'width', 'height', 'rx', 'ry'],
868
+ circle: ['cx', 'cy', 'r'],
869
+ ellipse: ['cx', 'cy', 'rx', 'ry'],
870
+ line: ['x1', 'y1', 'x2', 'y2'],
871
+ polyline: ['points'],
872
+ polygon: ['points'],
873
+ };
874
+ return attrs[tagName.toLowerCase()] || [];
875
+ }
876
+
877
+ /**
878
+ * Convert matrix to transform attribute string.
879
+ * @private
880
+ */
881
+ function matrixToTransform(matrix) {
882
+ const a = matrix.data[0][0].toNumber();
883
+ const b = matrix.data[1][0].toNumber();
884
+ const c = matrix.data[0][1].toNumber();
885
+ const d = matrix.data[1][1].toNumber();
886
+ const e = matrix.data[0][2].toNumber();
887
+ const f = matrix.data[1][2].toNumber();
888
+ return `matrix(${a} ${b} ${c} ${d} ${e} ${f})`;
889
+ }
890
+
891
+ /**
892
+ * Intersect two polygons using Sutherland-Hodgman algorithm.
893
+ * @private
894
+ */
895
+ function intersectPolygons(subject, clip) {
896
+ // Use PolygonClip if available, otherwise simple implementation
897
+ try {
898
+ const PolygonClip = require('./polygon-clip.js');
899
+ if (PolygonClip.intersect) {
900
+ return PolygonClip.intersect(subject, clip);
901
+ }
902
+ } catch {
903
+ // Fall through to simple implementation
904
+ }
905
+
906
+ // Simple convex clip implementation
907
+ if (!subject || subject.length < 3 || !clip || clip.length < 3) {
908
+ return subject;
909
+ }
910
+
911
+ let output = [...subject];
912
+
913
+ for (let i = 0; i < clip.length; i++) {
914
+ if (output.length === 0) break;
915
+
916
+ const input = output;
917
+ output = [];
918
+
919
+ const edgeStart = clip[i];
920
+ const edgeEnd = clip[(i + 1) % clip.length];
921
+
922
+ for (let j = 0; j < input.length; j++) {
923
+ const current = input[j];
924
+ const next = input[(j + 1) % input.length];
925
+
926
+ const currentInside = isInsideEdge(current, edgeStart, edgeEnd);
927
+ const nextInside = isInsideEdge(next, edgeStart, edgeEnd);
928
+
929
+ if (currentInside) {
930
+ output.push(current);
931
+ if (!nextInside) {
932
+ output.push(lineIntersect(current, next, edgeStart, edgeEnd));
933
+ }
934
+ } else if (nextInside) {
935
+ output.push(lineIntersect(current, next, edgeStart, edgeEnd));
936
+ }
937
+ }
938
+ }
939
+
940
+ return output;
941
+ }
942
+
943
+ /**
944
+ * Check if point is inside edge (left side).
945
+ * @private
946
+ */
947
+ function isInsideEdge(point, edgeStart, edgeEnd) {
948
+ const px = point.x instanceof Decimal ? point.x.toNumber() : point.x;
949
+ const py = point.y instanceof Decimal ? point.y.toNumber() : point.y;
950
+ const sx = edgeStart.x instanceof Decimal ? edgeStart.x.toNumber() : edgeStart.x;
951
+ const sy = edgeStart.y instanceof Decimal ? edgeStart.y.toNumber() : edgeStart.y;
952
+ const ex = edgeEnd.x instanceof Decimal ? edgeEnd.x.toNumber() : edgeEnd.x;
953
+ const ey = edgeEnd.y instanceof Decimal ? edgeEnd.y.toNumber() : edgeEnd.y;
954
+
955
+ return (ex - sx) * (py - sy) - (ey - sy) * (px - sx) >= 0;
956
+ }
957
+
958
+ /**
959
+ * Find intersection point of two lines.
960
+ * @private
961
+ */
962
+ function lineIntersect(p1, p2, p3, p4) {
963
+ const x1 = p1.x instanceof Decimal ? p1.x.toNumber() : p1.x;
964
+ const y1 = p1.y instanceof Decimal ? p1.y.toNumber() : p1.y;
965
+ const x2 = p2.x instanceof Decimal ? p2.x.toNumber() : p2.x;
966
+ const y2 = p2.y instanceof Decimal ? p2.y.toNumber() : p2.y;
967
+ const x3 = p3.x instanceof Decimal ? p3.x.toNumber() : p3.x;
968
+ const y3 = p3.y instanceof Decimal ? p3.y.toNumber() : p3.y;
969
+ const x4 = p4.x instanceof Decimal ? p4.x.toNumber() : p4.x;
970
+ const y4 = p4.y instanceof Decimal ? p4.y.toNumber() : p4.y;
971
+
972
+ const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
973
+ if (Math.abs(denom) < 1e-10) {
974
+ return { x: D((x1 + x2) / 2), y: D((y1 + y2) / 2) };
975
+ }
976
+
977
+ const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
978
+
979
+ return {
980
+ x: D(x1 + t * (x2 - x1)),
981
+ y: D(y1 + t * (y2 - y1))
982
+ };
983
+ }
984
+
985
+ // ============================================================================
986
+ // EXPORTS
987
+ // ============================================================================
988
+
989
+ export default {
990
+ flattenSVG,
991
+ DEFAULT_OPTIONS
992
+ };