@emasoft/svg-matrix 1.0.11 → 1.0.13

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