@emasoft/svg-matrix 1.0.12 → 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.
package/bin/svg-matrix.js CHANGED
@@ -111,6 +111,11 @@ const DEFAULT_CONFIG = {
111
111
  resolveMarkers: true, // Instantiate markers as path geometry
112
112
  resolvePatterns: true, // Expand pattern fills to tiled geometry
113
113
  bakeGradients: true, // Bake gradientTransform into gradient coords
114
+ // NOTE: Verification is ALWAYS enabled - precision is non-negotiable
115
+ // E2E verification precision controls
116
+ clipSegments: 64, // Polygon samples for clip operations (higher = more precise)
117
+ bezierArcs: 8, // Bezier arcs for circles/ellipses (multiple of 4; 8=π/4 optimal)
118
+ e2eTolerance: '1e-10', // E2E verification tolerance (tighter with more segments)
114
119
  };
115
120
 
116
121
  /** @type {CLIConfig} */
@@ -538,6 +543,22 @@ ${colors.bright}FLATTEN OPTIONS:${colors.reset}
538
543
  --no-patterns Skip pattern expansion
539
544
  --no-gradients Skip gradient transform baking
540
545
 
546
+ ${colors.bright}E2E VERIFICATION OPTIONS:${colors.reset}
547
+ --clip-segments <n> Polygon samples for clipping (default: 64)
548
+ Higher = better curve approximation, tighter tolerance
549
+ Recommended: 64 (balanced), 128 (high), 256 (very high)
550
+ --bezier-arcs <n> Bezier arcs for circles/ellipses (default: 8)
551
+ Must be multiple of 4. Multiples of 8 are optimal (π/4).
552
+ 8: ~0.0004% error (π/4 optimal base)
553
+ 16: ~0.000007% error (high precision)
554
+ 32: ~0.0000004% error, 64: ~0.00000001% error
555
+ --e2e-tolerance <exp> E2E verification tolerance exponent (default: 1e-10)
556
+ Examples: 1e-8, 1e-10, 1e-12, 1e-14
557
+ Tighter tolerance requires more clip-segments
558
+
559
+ ${colors.dim}Note: Mathematical verification is ALWAYS enabled.${colors.reset}
560
+ ${colors.dim}Precision is non-negotiable in this library.${colors.reset}
561
+
541
562
  ${colors.bright}EXAMPLES:${colors.reset}
542
563
  svg-matrix flatten input.svg -o output.svg
543
564
  svg-matrix flatten ./svgs/ -o ./output/ --transform-only
@@ -626,6 +647,9 @@ function processFlatten(inputPath, outputPath) {
626
647
  const pipelineOptions = {
627
648
  precision: config.precision,
628
649
  curveSegments: 20,
650
+ clipSegments: config.clipSegments, // Higher segments for clip accuracy (default 64)
651
+ bezierArcs: config.bezierArcs, // Bezier arcs for circles/ellipses (default 16)
652
+ e2eTolerance: config.e2eTolerance, // Configurable E2E tolerance (default 1e-10)
629
653
  resolveUse: config.resolveUse,
630
654
  resolveMarkers: config.resolveMarkers,
631
655
  resolvePatterns: config.resolvePatterns,
@@ -634,6 +658,7 @@ function processFlatten(inputPath, outputPath) {
634
658
  flattenTransforms: true, // Always flatten transforms
635
659
  bakeGradients: config.bakeGradients,
636
660
  removeUnusedDefs: true,
661
+ // NOTE: Verification is ALWAYS enabled - precision is non-negotiable
637
662
  };
638
663
 
639
664
  // Run the full flatten pipeline
@@ -655,6 +680,49 @@ function processFlatten(inputPath, outputPath) {
655
680
  logInfo('No transform dependencies found');
656
681
  }
657
682
 
683
+ // Report verification results (ALWAYS - precision is non-negotiable)
684
+ if (stats.verifications) {
685
+ const v = stats.verifications;
686
+ const total = v.passed + v.failed;
687
+ if (total > 0) {
688
+ const verifyStatus = v.allPassed
689
+ ? `${colors.green}VERIFIED${colors.reset}`
690
+ : `${colors.red}${v.failed} FAILED${colors.reset}`;
691
+ logInfo(`Verification: ${v.passed}/${total} - ${verifyStatus}`);
692
+
693
+ // Show detailed results in verbose mode
694
+ if (config.verbose) {
695
+ if (v.matrices.length > 0) {
696
+ logDebug(` Matrix verifications: ${v.matrices.filter(m => m.valid).length}/${v.matrices.length} passed`);
697
+ }
698
+ if (v.transforms.length > 0) {
699
+ logDebug(` Transform round-trips: ${v.transforms.filter(t => t.valid).length}/${v.transforms.length} passed`);
700
+ }
701
+ if (v.polygons.length > 0) {
702
+ logDebug(` Polygon intersections: ${v.polygons.filter(p => p.valid).length}/${v.polygons.length} passed`);
703
+ }
704
+ if (v.gradients.length > 0) {
705
+ logDebug(` Gradient transforms: ${v.gradients.filter(g => g.valid).length}/${v.gradients.length} passed`);
706
+ }
707
+ if (v.e2e && v.e2e.length > 0) {
708
+ logDebug(` E2E area conservation: ${v.e2e.filter(e => e.valid).length}/${v.e2e.length} passed`);
709
+ }
710
+ }
711
+
712
+ // Always show failed verifications (not just in verbose mode)
713
+ const allVerifications = [...v.matrices, ...v.transforms, ...v.polygons, ...v.gradients, ...(v.e2e || [])];
714
+ const failed = allVerifications.filter(vr => !vr.valid);
715
+ if (failed.length > 0) {
716
+ for (const f of failed.slice(0, 3)) {
717
+ logError(`${colors.red}VERIFICATION FAILED:${colors.reset} ${f.message}`);
718
+ }
719
+ if (failed.length > 3) {
720
+ logError(`...and ${failed.length - 3} more failed verifications`);
721
+ }
722
+ }
723
+ }
724
+ }
725
+
658
726
  // Report any errors
659
727
  if (stats.errors.length > 0) {
660
728
  for (const err of stats.errors.slice(0, 5)) {
@@ -944,6 +1012,35 @@ function parseArgs(args) {
944
1012
  case '--no-markers': cfg.resolveMarkers = false; break;
945
1013
  case '--no-patterns': cfg.resolvePatterns = false; break;
946
1014
  case '--no-gradients': cfg.bakeGradients = false; break;
1015
+ // E2E verification precision options
1016
+ case '--clip-segments': {
1017
+ const segs = parseInt(args[++i], 10);
1018
+ if (isNaN(segs) || segs < 8 || segs > 512) {
1019
+ logError('clip-segments must be between 8 and 512');
1020
+ process.exit(CONSTANTS.EXIT_ERROR);
1021
+ }
1022
+ cfg.clipSegments = segs;
1023
+ break;
1024
+ }
1025
+ case '--bezier-arcs': {
1026
+ const arcs = parseInt(args[++i], 10);
1027
+ if (isNaN(arcs) || arcs < 4 || arcs > 128) {
1028
+ logError('bezier-arcs must be between 4 and 128');
1029
+ process.exit(CONSTANTS.EXIT_ERROR);
1030
+ }
1031
+ cfg.bezierArcs = arcs;
1032
+ break;
1033
+ }
1034
+ case '--e2e-tolerance': {
1035
+ const tol = args[++i];
1036
+ if (!/^1e-\d+$/.test(tol)) {
1037
+ logError('e2e-tolerance must be in format 1e-N (e.g., 1e-10, 1e-12)');
1038
+ process.exit(CONSTANTS.EXIT_ERROR);
1039
+ }
1040
+ cfg.e2eTolerance = tol;
1041
+ break;
1042
+ }
1043
+ // NOTE: --verify removed - verification is ALWAYS enabled
947
1044
  default:
948
1045
  if (arg.startsWith('-')) { logError(`Unknown option: ${arg}`); process.exit(CONSTANTS.EXIT_ERROR); }
949
1046
  if (['flatten', 'convert', 'normalize', 'info', 'help', 'version'].includes(arg) && cfg.command === 'help') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emasoft/svg-matrix",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "description": "Arbitrary-precision matrix, vector and affine transformation library for JavaScript using decimal.js",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -41,6 +41,10 @@ import {
41
41
  parseTransformAttribute,
42
42
  transformPathData
43
43
  } from './svg-flatten.js';
44
+ import {
45
+ circleToPathDataHP,
46
+ ellipseToPathDataHP
47
+ } from './geometry-to-path.js';
44
48
  import { Logger } from './logger.js';
45
49
 
46
50
  // Alias for cleaner code
@@ -414,14 +418,32 @@ function removeDuplicateConsecutive(points) {
414
418
  * const ctm = Transforms2D.translation(10, 20);
415
419
  * const polygon = shapeToPolygon(rect, ctm, 30);
416
420
  */
417
- export function shapeToPolygon(element, ctm = null, samples = DEFAULT_CURVE_SAMPLES) {
421
+ /**
422
+ * Convert SVG shape to polygon.
423
+ *
424
+ * @param {Object} element - Shape element (circle, ellipse, rect, etc.)
425
+ * @param {Matrix} ctm - Current transform matrix (optional)
426
+ * @param {number} samples - Samples per curve for polygon conversion
427
+ * @param {number} bezierArcs - Number of Bezier arcs for circles/ellipses (4=standard, 16 or 64=HP)
428
+ */
429
+ export function shapeToPolygon(element, ctm = null, samples = DEFAULT_CURVE_SAMPLES, bezierArcs = 4) {
418
430
  let pathData;
419
431
  switch (element.type) {
420
432
  case 'circle':
421
- pathData = circleToPath(D(element.cx || 0), D(element.cy || 0), D(element.r || 0));
433
+ // Use high-precision Bezier arcs for better curve approximation
434
+ if (bezierArcs > 4) {
435
+ pathData = circleToPathDataHP(element.cx || 0, element.cy || 0, element.r || 0, bezierArcs, 10);
436
+ } else {
437
+ pathData = circleToPath(D(element.cx || 0), D(element.cy || 0), D(element.r || 0));
438
+ }
422
439
  break;
423
440
  case 'ellipse':
424
- pathData = ellipseToPath(D(element.cx || 0), D(element.cy || 0), D(element.rx || 0), D(element.ry || 0));
441
+ // Use high-precision Bezier arcs for better curve approximation
442
+ if (bezierArcs > 4) {
443
+ pathData = ellipseToPathDataHP(element.cx || 0, element.cy || 0, element.rx || 0, element.ry || 0, bezierArcs, 10);
444
+ } else {
445
+ pathData = ellipseToPath(D(element.cx || 0), D(element.cy || 0), D(element.rx || 0), D(element.ry || 0));
446
+ }
425
447
  break;
426
448
  case 'rect':
427
449
  pathData = rectToPath(D(element.x || 0), D(element.y || 0), D(element.width || 0), D(element.height || 0),
@@ -26,6 +26,7 @@ import * as MeshGradient from './mesh-gradient.js';
26
26
  import * as GeometryToPath from './geometry-to-path.js';
27
27
  import { parseSVG, SVGElement, buildDefsMap, parseUrlReference, serializeSVG, findElementsWithAttribute } from './svg-parser.js';
28
28
  import { Logger } from './logger.js';
29
+ import * as Verification from './verification.js';
29
30
 
30
31
  Decimal.set({ precision: 80 });
31
32
 
@@ -36,7 +37,11 @@ const D = x => (x instanceof Decimal ? x : new Decimal(x));
36
37
  */
37
38
  const DEFAULT_OPTIONS = {
38
39
  precision: 6, // Decimal places in output coordinates
39
- curveSegments: 20, // Samples per curve for polygon conversion
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)
40
45
  resolveUse: true, // Expand <use> elements
41
46
  resolveMarkers: true, // Expand marker instances
42
47
  resolvePatterns: true, // Expand pattern fills to geometry
@@ -46,6 +51,9 @@ const DEFAULT_OPTIONS = {
46
51
  bakeGradients: true, // Bake gradientTransform into gradient coords
47
52
  removeUnusedDefs: true, // Remove defs that are no longer referenced
48
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)
49
57
  };
50
58
 
51
59
  /**
@@ -77,6 +85,19 @@ export function flattenSVG(svgString, options = {}) {
77
85
  gradientsProcessed: 0,
78
86
  defsRemoved: 0,
79
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: [],
80
101
  };
81
102
 
82
103
  try {
@@ -120,21 +141,21 @@ export function flattenSVG(svgString, options = {}) {
120
141
 
121
142
  // Step 5: Apply clipPaths (boolean intersection)
122
143
  if (opts.resolveClipPaths) {
123
- const result = applyAllClipPaths(root, defsMap, opts);
144
+ const result = applyAllClipPaths(root, defsMap, opts, stats);
124
145
  stats.clipPathsApplied = result.count;
125
146
  stats.errors.push(...result.errors);
126
147
  }
127
148
 
128
149
  // Step 6: Flatten transforms (bake into coordinates)
129
150
  if (opts.flattenTransforms) {
130
- const result = flattenAllTransforms(root, opts);
151
+ const result = flattenAllTransforms(root, opts, stats);
131
152
  stats.transformsFlattened = result.count;
132
153
  stats.errors.push(...result.errors);
133
154
  }
134
155
 
135
156
  // Step 7: Bake gradient transforms
136
157
  if (opts.bakeGradients) {
137
- const result = bakeAllGradientTransforms(root, opts);
158
+ const result = bakeAllGradientTransforms(root, opts, stats);
138
159
  stats.gradientsProcessed = result.count;
139
160
  stats.errors.push(...result.errors);
140
161
  }
@@ -430,12 +451,21 @@ function resolveAllMasks(root, defsMap, opts) {
430
451
 
431
452
  /**
432
453
  * Apply clipPath references by performing boolean intersection.
454
+ * Also computes the difference (outside parts) and verifies area conservation (E2E).
433
455
  * @private
434
456
  */
435
- function applyAllClipPaths(root, defsMap, opts) {
457
+ function applyAllClipPaths(root, defsMap, opts, stats) {
436
458
  const errors = [];
437
459
  let count = 0;
438
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
+
439
469
  const elementsWithClip = findElementsWithAttribute(root, 'clip-path');
440
470
 
441
471
  for (const el of elementsWithClip) {
@@ -466,13 +496,75 @@ function applyAllClipPaths(root, defsMap, opts) {
466
496
 
467
497
  if (!clipPathData) continue;
468
498
 
469
- // Convert to polygons
470
- const origPolygon = ClipPathResolver.pathToPolygon(origPathData, opts.curveSegments);
471
- const clipPolygon = ClipPathResolver.pathToPolygon(clipPathData, opts.curveSegments);
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);
472
504
 
473
- // Perform intersection
505
+ // Perform intersection (clipped result - what's kept)
474
506
  const clippedPolygon = intersectPolygons(origPolygon, clipPolygon);
475
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
+
476
568
  if (clippedPolygon && clippedPolygon.length > 2) {
477
569
  const clippedPath = ClipPathResolver.polygonToPathData(clippedPolygon, opts.precision);
478
570
 
@@ -510,7 +602,7 @@ function applyAllClipPaths(root, defsMap, opts) {
510
602
  * Flatten all transform attributes by baking into coordinates.
511
603
  * @private
512
604
  */
513
- function flattenAllTransforms(root, opts) {
605
+ function flattenAllTransforms(root, opts, stats) {
514
606
  const errors = [];
515
607
  let count = 0;
516
608
 
@@ -524,21 +616,63 @@ function flattenAllTransforms(root, opts) {
524
616
  // Parse transform to matrix
525
617
  const ctm = SVGFlatten.parseTransformAttribute(transform);
526
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
+
527
633
  // Get element path data
528
634
  const pathData = getElementPathData(el, opts.precision);
529
635
  if (!pathData) {
530
636
  // For groups, propagate transform to children
531
637
  if (el.tagName === 'g') {
532
- propagateTransformToChildren(el, ctm, opts);
638
+ propagateTransformToChildren(el, ctm, opts, stats);
533
639
  el.removeAttribute('transform');
534
640
  count++;
535
641
  }
536
642
  continue;
537
643
  }
538
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
+
539
656
  // Transform the path data
540
657
  const transformedPath = SVGFlatten.transformPathData(pathData, ctm, { precision: opts.precision });
541
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
+
542
676
  // Update or replace element
543
677
  if (el.tagName === 'path') {
544
678
  el.setAttribute('d', transformedPath);
@@ -573,7 +707,7 @@ function flattenAllTransforms(root, opts) {
573
707
  * Propagate transform to all children of a group.
574
708
  * @private
575
709
  */
576
- function propagateTransformToChildren(group, ctm, opts) {
710
+ function propagateTransformToChildren(group, ctm, opts, stats) {
577
711
  for (const child of [...group.children]) {
578
712
  if (!(child instanceof SVGElement)) continue;
579
713
 
@@ -599,6 +733,20 @@ function propagateTransformToChildren(group, ctm, opts) {
599
733
  combinedCtm = ctm.mul(childCtm);
600
734
  }
601
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
+
602
750
  const transformedPath = SVGFlatten.transformPathData(pathData, combinedCtm, { precision: opts.precision });
603
751
 
604
752
  if (child.tagName === 'path') {
@@ -627,7 +775,7 @@ function propagateTransformToChildren(group, ctm, opts) {
627
775
  * Bake gradientTransform into gradient coordinates.
628
776
  * @private
629
777
  */
630
- function bakeAllGradientTransforms(root, opts) {
778
+ function bakeAllGradientTransforms(root, opts, stats) {
631
779
  const errors = [];
632
780
  let count = 0;
633
781
 
@@ -649,6 +797,24 @@ function bakeAllGradientTransforms(root, opts) {
649
797
  const [tx1, ty1] = Transforms2D.applyTransform(ctm, x1, y1);
650
798
  const [tx2, ty2] = Transforms2D.applyTransform(ctm, x2, y2);
651
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
+
652
818
  grad.setAttribute('x1', tx1.toFixed(opts.precision));
653
819
  grad.setAttribute('y1', ty1.toFixed(opts.precision));
654
820
  grad.setAttribute('x2', tx2.toFixed(opts.precision));
@@ -3,6 +3,11 @@ import { Matrix } from './matrix.js';
3
3
 
4
4
  const D = x => (x instanceof Decimal ? x : new Decimal(x));
5
5
 
6
+ /**
7
+ * Standard kappa for 90° arcs (4 Bezier curves per circle).
8
+ * kappa = 4/3 * (sqrt(2) - 1) ≈ 0.5522847498
9
+ * Maximum radial error: ~0.027%
10
+ */
6
11
  export function getKappa() {
7
12
  const two = new Decimal(2);
8
13
  const three = new Decimal(3);
@@ -10,6 +15,137 @@ export function getKappa() {
10
15
  return four.mul(two.sqrt().minus(1)).div(three);
11
16
  }
12
17
 
18
+ /**
19
+ * Compute the optimal Bezier control point distance for any arc angle.
20
+ * Formula: L = (4/3) * tan(theta/4) where theta is in radians
21
+ *
22
+ * This is the generalization of the kappa constant.
23
+ * For 90° (π/2): L = (4/3) * tan(π/8) ≈ 0.5522847498 (standard kappa)
24
+ *
25
+ * References:
26
+ * - Spencer Mortensen's optimal Bezier circle approximation:
27
+ * https://spencermortensen.com/articles/bezier-circle/
28
+ * Derives the optimal kappa for minimizing radial error in quarter-circle arcs.
29
+ *
30
+ * - Akhil's ellipse approximation with π/4 step angle:
31
+ * https://www.blog.akhil.cc/ellipse
32
+ * Shows that π/4 (45°) is the optimal step angle for Bezier arc approximation,
33
+ * based on Maisonobe's derivation. This allows precomputing the alpha coefficient.
34
+ *
35
+ * - Math Stack Exchange derivation:
36
+ * https://math.stackexchange.com/questions/873224
37
+ * General formula for control point distance for any arc angle.
38
+ *
39
+ * @param {Decimal|number} thetaRadians - Arc angle in radians
40
+ * @returns {Decimal} Control point distance factor (multiply by radius)
41
+ */
42
+ export function getKappaForArc(thetaRadians) {
43
+ const theta = D(thetaRadians);
44
+ const four = new Decimal(4);
45
+ const three = new Decimal(3);
46
+ // L = (4/3) * tan(theta/4)
47
+ return four.div(three).mul(Decimal.tan(theta.div(four)));
48
+ }
49
+
50
+ /**
51
+ * High-precision circle to path using N Bezier arcs.
52
+ * More arcs = better approximation of the true circle.
53
+ *
54
+ * IMPORTANT: Arc count should be a multiple of 4 (for symmetry) and ideally
55
+ * a multiple of 8 (for optimal π/4 step angle as per Maisonobe's derivation).
56
+ * Reference: https://www.blog.akhil.cc/ellipse
57
+ *
58
+ * Error analysis (measured):
59
+ * - 4 arcs (90° = π/2 each): ~0.027% max radial error (standard)
60
+ * - 8 arcs (45° = π/4 each): ~0.0004% max radial error (optimal base)
61
+ * - 16 arcs (22.5° = π/8 each): ~0.000007% max radial error
62
+ * - 32 arcs (11.25° = π/16 each): ~0.0000004% max radial error
63
+ * - 64 arcs (5.625° = π/32 each): ~0.00000001% max radial error
64
+ *
65
+ * @param {number|Decimal} cx - Center X
66
+ * @param {number|Decimal} cy - Center Y
67
+ * @param {number|Decimal} r - Radius
68
+ * @param {number} arcs - Number of Bezier arcs (must be multiple of 4; 8, 16, 32, 64 recommended)
69
+ * @param {number} precision - Decimal precision for output
70
+ * @returns {string} SVG path data
71
+ */
72
+ export function circleToPathDataHP(cx, cy, r, arcs = 8, precision = 6) {
73
+ return ellipseToPathDataHP(cx, cy, r, r, arcs, precision);
74
+ }
75
+
76
+ /**
77
+ * High-precision ellipse to path using N Bezier arcs.
78
+ * More arcs = better approximation of the true ellipse.
79
+ *
80
+ * Arc count must be a multiple of 4 for proper symmetry.
81
+ * Multiples of 8 are optimal (π/4 step angle per Maisonobe).
82
+ *
83
+ * @param {number|Decimal} cx - Center X
84
+ * @param {number|Decimal} cy - Center Y
85
+ * @param {number|Decimal} rx - Radius X
86
+ * @param {number|Decimal} ry - Radius Y
87
+ * @param {number} arcs - Number of Bezier arcs (must be multiple of 4; 8, 16, 32, 64 recommended)
88
+ * @param {number} precision - Decimal precision for output
89
+ * @returns {string} SVG path data
90
+ */
91
+ export function ellipseToPathDataHP(cx, cy, rx, ry, arcs = 8, precision = 6) {
92
+ // Enforce multiple of 4 for symmetry
93
+ if (arcs % 4 !== 0) {
94
+ arcs = Math.ceil(arcs / 4) * 4;
95
+ }
96
+ const cxD = D(cx), cyD = D(cy), rxD = D(rx), ryD = D(ry);
97
+ const f = v => formatNumber(v, precision);
98
+
99
+ // Angle per arc in radians
100
+ const PI = Decimal.acos(-1);
101
+ const TWO_PI = PI.mul(2);
102
+ const arcAngle = TWO_PI.div(arcs);
103
+
104
+ // Control point distance for this arc angle
105
+ const kappa = getKappaForArc(arcAngle);
106
+
107
+ // Generate path
108
+ const commands = [];
109
+
110
+ for (let i = 0; i < arcs; i++) {
111
+ const startAngle = arcAngle.mul(i);
112
+ const endAngle = arcAngle.mul(i + 1);
113
+
114
+ // Start and end points on ellipse
115
+ const cosStart = Decimal.cos(startAngle);
116
+ const sinStart = Decimal.sin(startAngle);
117
+ const cosEnd = Decimal.cos(endAngle);
118
+ const sinEnd = Decimal.sin(endAngle);
119
+
120
+ const x0 = cxD.plus(rxD.mul(cosStart));
121
+ const y0 = cyD.plus(ryD.mul(sinStart));
122
+ const x3 = cxD.plus(rxD.mul(cosEnd));
123
+ const y3 = cyD.plus(ryD.mul(sinEnd));
124
+
125
+ // Tangent vectors at start and end (perpendicular to radius, scaled by kappa)
126
+ // Tangent at angle θ: (-sin(θ), cos(θ))
127
+ // Control point 1: start + kappa * tangent_at_start * radius
128
+ // Control point 2: end - kappa * tangent_at_end * radius
129
+ const tx0 = sinStart.neg(); // tangent x at start
130
+ const ty0 = cosStart; // tangent y at start
131
+ const tx3 = sinEnd.neg(); // tangent x at end
132
+ const ty3 = cosEnd; // tangent y at end
133
+
134
+ const x1 = x0.plus(kappa.mul(rxD).mul(tx0));
135
+ const y1 = y0.plus(kappa.mul(ryD).mul(ty0));
136
+ const x2 = x3.minus(kappa.mul(rxD).mul(tx3));
137
+ const y2 = y3.minus(kappa.mul(ryD).mul(ty3));
138
+
139
+ if (i === 0) {
140
+ commands.push(`M ${f(x0)} ${f(y0)}`);
141
+ }
142
+ commands.push(`C ${f(x1)} ${f(y1)} ${f(x2)} ${f(y2)} ${f(x3)} ${f(y3)}`);
143
+ }
144
+
145
+ commands.push('Z');
146
+ return commands.join(' ');
147
+ }
148
+
13
149
  function formatNumber(value, precision = 6) {
14
150
  // Use toFixed to preserve trailing zeros for consistent output formatting
15
151
  return value.toFixed(precision);
package/src/index.js CHANGED
@@ -46,6 +46,7 @@ import * as MeshGradient from './mesh-gradient.js';
46
46
  import * as TextToPath from './text-to-path.js';
47
47
  import * as SVGParser from './svg-parser.js';
48
48
  import * as FlattenPipeline from './flatten-pipeline.js';
49
+ import * as Verification from './verification.js';
49
50
  import { Logger, LogLevel, setLogLevel, getLogLevel as getLoggerLevel, enableFileLogging, disableFileLogging } from './logger.js';
50
51
 
51
52
  // Set high-precision default (80 significant digits) on module load
@@ -91,7 +92,7 @@ export { SVGFlatten, BrowserVerify };
91
92
  export { ClipPathResolver, MaskResolver, PatternResolver };
92
93
  export { UseSymbolResolver, MarkerResolver };
93
94
  export { MeshGradient, TextToPath };
94
- export { SVGParser, FlattenPipeline };
95
+ export { SVGParser, FlattenPipeline, Verification };
95
96
 
96
97
  // ============================================================================
97
98
  // LOGGING: Configurable logging control
@@ -426,6 +427,7 @@ export default {
426
427
  TextToPath,
427
428
  SVGParser,
428
429
  FlattenPipeline,
430
+ Verification,
429
431
 
430
432
  // Logging
431
433
  Logger,