@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 +97 -0
- package/package.json +1 -1
- package/src/clip-path-resolver.js +25 -3
- package/src/flatten-pipeline.js +179 -13
- package/src/geometry-to-path.js +136 -0
- package/src/index.js +3 -1
- package/src/verification.js +1242 -0
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
package/src/flatten-pipeline.js
CHANGED
|
@@ -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
|
-
|
|
471
|
-
const
|
|
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));
|
package/src/geometry-to-path.js
CHANGED
|
@@ -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,
|