@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.
- package/bin/svg-matrix.js +319 -123
- package/package.json +1 -1
- package/src/clip-path-resolver.js +25 -3
- package/src/flatten-pipeline.js +1158 -0
- package/src/geometry-to-path.js +136 -0
- package/src/index.js +9 -2
- package/src/svg-parser.js +730 -0
- package/src/verification.js +1242 -0
package/bin/svg-matrix.js
CHANGED
|
@@ -20,6 +20,7 @@ import { join, dirname, basename, extname, resolve, isAbsolute } from 'path';
|
|
|
20
20
|
// Import library modules
|
|
21
21
|
import * as SVGFlatten from '../src/svg-flatten.js';
|
|
22
22
|
import * as GeometryToPath from '../src/geometry-to-path.js';
|
|
23
|
+
import * as FlattenPipeline from '../src/flatten-pipeline.js';
|
|
23
24
|
import { VERSION } from '../src/index.js';
|
|
24
25
|
|
|
25
26
|
// ============================================================================
|
|
@@ -81,6 +82,13 @@ let currentOutputFile = null; // Track for cleanup on interrupt
|
|
|
81
82
|
* @property {boolean} recursive - Process folders recursively
|
|
82
83
|
* @property {boolean} overwrite - Overwrite existing files
|
|
83
84
|
* @property {boolean} dryRun - Show what would be done without doing it
|
|
85
|
+
* @property {boolean} transformOnly - Only flatten transforms (legacy mode)
|
|
86
|
+
* @property {boolean} resolveClipPaths - Apply clipPath boolean operations
|
|
87
|
+
* @property {boolean} resolveMasks - Convert masks to clipped geometry
|
|
88
|
+
* @property {boolean} resolveUse - Expand use/symbol references
|
|
89
|
+
* @property {boolean} resolveMarkers - Expand marker references
|
|
90
|
+
* @property {boolean} resolvePatterns - Expand pattern fills
|
|
91
|
+
* @property {boolean} bakeGradients - Bake gradientTransform into coordinates
|
|
84
92
|
*/
|
|
85
93
|
|
|
86
94
|
const DEFAULT_CONFIG = {
|
|
@@ -95,6 +103,19 @@ const DEFAULT_CONFIG = {
|
|
|
95
103
|
recursive: false,
|
|
96
104
|
overwrite: false,
|
|
97
105
|
dryRun: false,
|
|
106
|
+
// Full flatten options - all enabled by default for TRUE flattening
|
|
107
|
+
transformOnly: false, // If true, skip all resolvers (legacy behavior)
|
|
108
|
+
resolveClipPaths: true, // Apply clipPath boolean intersection
|
|
109
|
+
resolveMasks: true, // Convert masks to clipped geometry
|
|
110
|
+
resolveUse: true, // Expand use/symbol elements inline
|
|
111
|
+
resolveMarkers: true, // Instantiate markers as path geometry
|
|
112
|
+
resolvePatterns: true, // Expand pattern fills to tiled geometry
|
|
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)
|
|
98
119
|
};
|
|
99
120
|
|
|
100
121
|
/** @type {CLIConfig} */
|
|
@@ -487,7 +508,14 @@ ${colors.bright}USAGE:${colors.reset}
|
|
|
487
508
|
svg-matrix <command> [options] <input> [-o <output>]
|
|
488
509
|
|
|
489
510
|
${colors.bright}COMMANDS:${colors.reset}
|
|
490
|
-
flatten
|
|
511
|
+
flatten TRUE flatten: resolve ALL transform dependencies
|
|
512
|
+
- Bakes transform attributes into coordinates
|
|
513
|
+
- Applies clipPath boolean operations
|
|
514
|
+
- Converts masks to clipped geometry
|
|
515
|
+
- Expands use/symbol references inline
|
|
516
|
+
- Instantiates markers as path geometry
|
|
517
|
+
- Expands pattern fills to tiled geometry
|
|
518
|
+
- Bakes gradientTransform into coordinates
|
|
491
519
|
convert Convert shapes (rect, circle, etc.) to paths
|
|
492
520
|
normalize Convert paths to absolute cubic Bezier curves
|
|
493
521
|
info Show SVG file information
|
|
@@ -506,10 +534,35 @@ ${colors.bright}OPTIONS:${colors.reset}
|
|
|
506
534
|
--log-file <path> Write log to file
|
|
507
535
|
-h, --help Show help
|
|
508
536
|
|
|
537
|
+
${colors.bright}FLATTEN OPTIONS:${colors.reset}
|
|
538
|
+
--transform-only Only flatten transforms (skip resolvers)
|
|
539
|
+
--no-clip-paths Skip clipPath boolean operations
|
|
540
|
+
--no-masks Skip mask to clip conversion
|
|
541
|
+
--no-use Skip use/symbol expansion
|
|
542
|
+
--no-markers Skip marker instantiation
|
|
543
|
+
--no-patterns Skip pattern expansion
|
|
544
|
+
--no-gradients Skip gradient transform baking
|
|
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
|
+
|
|
509
562
|
${colors.bright}EXAMPLES:${colors.reset}
|
|
510
563
|
svg-matrix flatten input.svg -o output.svg
|
|
511
|
-
svg-matrix flatten ./svgs/ -o ./output/
|
|
512
|
-
svg-matrix flatten --list files.txt -o ./output/
|
|
564
|
+
svg-matrix flatten ./svgs/ -o ./output/ --transform-only
|
|
565
|
+
svg-matrix flatten --list files.txt -o ./output/ --no-patterns
|
|
513
566
|
svg-matrix convert input.svg -o output.svg --precision 10
|
|
514
567
|
svg-matrix info input.svg
|
|
515
568
|
|
|
@@ -563,8 +616,19 @@ function replacePathD(attrs, newD) {
|
|
|
563
616
|
}
|
|
564
617
|
|
|
565
618
|
/**
|
|
566
|
-
* Flatten
|
|
567
|
-
*
|
|
619
|
+
* Flatten SVG completely - no transform dependencies remain.
|
|
620
|
+
*
|
|
621
|
+
* TRUE flattening resolves ALL transform-dependent elements:
|
|
622
|
+
* - Bakes transform attributes into path coordinates
|
|
623
|
+
* - Applies clipPath boolean operations
|
|
624
|
+
* - Converts masks to clipped geometry
|
|
625
|
+
* - Expands use/symbol references inline
|
|
626
|
+
* - Instantiates markers as path geometry
|
|
627
|
+
* - Expands pattern fills to tiled geometry
|
|
628
|
+
* - Bakes gradientTransform into gradient coordinates
|
|
629
|
+
*
|
|
630
|
+
* Use --transform-only flag for legacy behavior (transforms only).
|
|
631
|
+
*
|
|
568
632
|
* @param {string} inputPath - Input file path
|
|
569
633
|
* @param {string} outputPath - Output file path
|
|
570
634
|
* @returns {boolean} True if successful
|
|
@@ -572,152 +636,247 @@ function replacePathD(attrs, newD) {
|
|
|
572
636
|
function processFlatten(inputPath, outputPath) {
|
|
573
637
|
try {
|
|
574
638
|
logDebug(`Processing: ${inputPath}`);
|
|
575
|
-
|
|
576
|
-
let transformCount = 0;
|
|
577
|
-
let pathCount = 0;
|
|
578
|
-
let shapeCount = 0;
|
|
639
|
+
const svgContent = readFileSync(inputPath, 'utf8');
|
|
579
640
|
|
|
580
|
-
//
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
641
|
+
// Use legacy transform-only mode if requested
|
|
642
|
+
if (config.transformOnly) {
|
|
643
|
+
return processFlattenLegacy(inputPath, outputPath, svgContent);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Build pipeline options from config
|
|
647
|
+
const pipelineOptions = {
|
|
648
|
+
precision: config.precision,
|
|
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)
|
|
653
|
+
resolveUse: config.resolveUse,
|
|
654
|
+
resolveMarkers: config.resolveMarkers,
|
|
655
|
+
resolvePatterns: config.resolvePatterns,
|
|
656
|
+
resolveMasks: config.resolveMasks,
|
|
657
|
+
resolveClipPaths: config.resolveClipPaths,
|
|
658
|
+
flattenTransforms: true, // Always flatten transforms
|
|
659
|
+
bakeGradients: config.bakeGradients,
|
|
660
|
+
removeUnusedDefs: true,
|
|
661
|
+
// NOTE: Verification is ALWAYS enabled - precision is non-negotiable
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
// Run the full flatten pipeline
|
|
665
|
+
const { svg: flattenedSvg, stats } = FlattenPipeline.flattenSVG(svgContent, pipelineOptions);
|
|
666
|
+
|
|
667
|
+
// Report statistics
|
|
668
|
+
const parts = [];
|
|
669
|
+
if (stats.transformsFlattened > 0) parts.push(`${stats.transformsFlattened} transforms`);
|
|
670
|
+
if (stats.useResolved > 0) parts.push(`${stats.useResolved} use`);
|
|
671
|
+
if (stats.markersResolved > 0) parts.push(`${stats.markersResolved} markers`);
|
|
672
|
+
if (stats.patternsResolved > 0) parts.push(`${stats.patternsResolved} patterns`);
|
|
673
|
+
if (stats.masksResolved > 0) parts.push(`${stats.masksResolved} masks`);
|
|
674
|
+
if (stats.clipPathsApplied > 0) parts.push(`${stats.clipPathsApplied} clipPaths`);
|
|
675
|
+
if (stats.gradientsProcessed > 0) parts.push(`${stats.gradientsProcessed} gradients`);
|
|
676
|
+
|
|
677
|
+
if (parts.length > 0) {
|
|
678
|
+
logInfo(`Flattened: ${parts.join(', ')}`);
|
|
679
|
+
} else {
|
|
680
|
+
logInfo('No transform dependencies found');
|
|
681
|
+
}
|
|
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
|
+
}
|
|
585
711
|
|
|
586
|
-
|
|
587
|
-
|
|
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
|
+
|
|
726
|
+
// Report any errors
|
|
727
|
+
if (stats.errors.length > 0) {
|
|
728
|
+
for (const err of stats.errors.slice(0, 5)) {
|
|
729
|
+
logWarn(err);
|
|
730
|
+
}
|
|
731
|
+
if (stats.errors.length > 5) {
|
|
732
|
+
logWarn(`...and ${stats.errors.length - 5} more errors`);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (!config.dryRun) {
|
|
737
|
+
ensureDir(dirname(outputPath));
|
|
738
|
+
writeFileSync(outputPath, flattenedSvg, 'utf8');
|
|
739
|
+
}
|
|
740
|
+
logSuccess(`${basename(inputPath)} -> ${basename(outputPath)}`);
|
|
741
|
+
return true;
|
|
742
|
+
} catch (error) {
|
|
743
|
+
logError(`Failed: ${inputPath}: ${error.message}`);
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Legacy flatten mode - only bakes transform attributes into coordinates.
|
|
750
|
+
* Does NOT resolve clipPaths, masks, use, markers, patterns, or gradients.
|
|
751
|
+
* Use this when you only need transform flattening without boolean operations.
|
|
752
|
+
* @private
|
|
753
|
+
*/
|
|
754
|
+
function processFlattenLegacy(inputPath, outputPath, svgContent) {
|
|
755
|
+
let result = svgContent;
|
|
756
|
+
let transformCount = 0;
|
|
757
|
+
let pathCount = 0;
|
|
758
|
+
let shapeCount = 0;
|
|
759
|
+
|
|
760
|
+
// Step 1: Flatten transforms on path elements
|
|
761
|
+
result = result.replace(/<path\s+([^>]*?)\s*\/?>/gi, (match, attrs) => {
|
|
762
|
+
const transform = extractTransform(attrs);
|
|
763
|
+
const pathD = extractPathD(attrs);
|
|
764
|
+
|
|
765
|
+
if (!transform || !pathD) {
|
|
766
|
+
return match;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
try {
|
|
770
|
+
const ctm = SVGFlatten.parseTransformAttribute(transform);
|
|
771
|
+
const transformedD = SVGFlatten.transformPathData(pathD, ctm, { precision: config.precision });
|
|
772
|
+
const newAttrs = removeTransform(replacePathD(attrs, transformedD));
|
|
773
|
+
transformCount++;
|
|
774
|
+
pathCount++;
|
|
775
|
+
logDebug(`Flattened path transform: ${transform}`);
|
|
776
|
+
return `<path ${newAttrs.trim()}${match.endsWith('/>') ? '/>' : '>'}`;
|
|
777
|
+
} catch (e) {
|
|
778
|
+
logWarn(`Failed to flatten path: ${e.message}`);
|
|
779
|
+
return match;
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// Step 2: Convert shapes with transforms to flattened paths
|
|
784
|
+
const shapeTypes = ['rect', 'circle', 'ellipse', 'line', 'polygon', 'polyline'];
|
|
785
|
+
|
|
786
|
+
for (const shapeType of shapeTypes) {
|
|
787
|
+
const shapeRegex = new RegExp(`<${shapeType}([^>]*)\\/>`, 'gi');
|
|
788
|
+
|
|
789
|
+
result = result.replace(shapeRegex, (match, attrs) => {
|
|
790
|
+
const transform = extractTransform(attrs);
|
|
791
|
+
if (!transform) {
|
|
792
|
+
return match;
|
|
588
793
|
}
|
|
589
794
|
|
|
590
795
|
try {
|
|
591
|
-
|
|
796
|
+
const pathD = extractShapeAsPath(shapeType, attrs, config.precision);
|
|
797
|
+
if (!pathD) {
|
|
798
|
+
return match;
|
|
799
|
+
}
|
|
800
|
+
|
|
592
801
|
const ctm = SVGFlatten.parseTransformAttribute(transform);
|
|
593
|
-
// Transform the path data
|
|
594
802
|
const transformedD = SVGFlatten.transformPathData(pathD, ctm, { precision: config.precision });
|
|
595
|
-
|
|
596
|
-
const
|
|
803
|
+
const attrsToRemove = getShapeSpecificAttrs(shapeType);
|
|
804
|
+
const styleAttrs = removeShapeAttrs(removeTransform(attrs), attrsToRemove);
|
|
597
805
|
transformCount++;
|
|
598
|
-
|
|
599
|
-
logDebug(`Flattened
|
|
600
|
-
return `<path ${
|
|
806
|
+
shapeCount++;
|
|
807
|
+
logDebug(`Flattened ${shapeType} transform: ${transform}`);
|
|
808
|
+
return `<path d="${transformedD}"${styleAttrs ? ' ' + styleAttrs : ''}/>`;
|
|
601
809
|
} catch (e) {
|
|
602
|
-
logWarn(`Failed to flatten
|
|
810
|
+
logWarn(`Failed to flatten ${shapeType}: ${e.message}`);
|
|
603
811
|
return match;
|
|
604
812
|
}
|
|
605
813
|
});
|
|
814
|
+
}
|
|
606
815
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const shapeRegex = new RegExp(`<${shapeType}([^>]*)\\/>`, 'gi');
|
|
612
|
-
|
|
613
|
-
result = result.replace(shapeRegex, (match, attrs) => {
|
|
614
|
-
const transform = extractTransform(attrs);
|
|
615
|
-
if (!transform) {
|
|
616
|
-
return match; // No transform, skip
|
|
617
|
-
}
|
|
816
|
+
// Step 3: Handle group transforms
|
|
817
|
+
let groupIterations = 0;
|
|
818
|
+
while (groupIterations < CONSTANTS.MAX_GROUP_ITERATIONS) {
|
|
819
|
+
const beforeResult = result;
|
|
618
820
|
|
|
821
|
+
result = result.replace(
|
|
822
|
+
/<g([^>]*transform\s*=\s*["']([^"']+)["'][^>]*)>([\s\S]*?)<\/g>/gi,
|
|
823
|
+
(match, gAttrs, groupTransform, content) => {
|
|
619
824
|
try {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
if (!pathD) {
|
|
624
|
-
return match; // Couldn't convert to path
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// Parse the transform and build CTM
|
|
628
|
-
const ctm = SVGFlatten.parseTransformAttribute(transform);
|
|
629
|
-
// Transform the path data
|
|
630
|
-
const transformedD = SVGFlatten.transformPathData(pathD, ctm, { precision: config.precision });
|
|
631
|
-
// Build new path element, preserving style attributes
|
|
632
|
-
const attrsToRemove = getShapeSpecificAttrs(shapeType);
|
|
633
|
-
const styleAttrs = removeShapeAttrs(removeTransform(attrs), attrsToRemove);
|
|
634
|
-
transformCount++;
|
|
635
|
-
shapeCount++;
|
|
636
|
-
logDebug(`Flattened ${shapeType} transform: ${transform}`);
|
|
637
|
-
return `<path d="${transformedD}"${styleAttrs ? ' ' + styleAttrs : ''}/>`;
|
|
638
|
-
} catch (e) {
|
|
639
|
-
logWarn(`Failed to flatten ${shapeType}: ${e.message}`);
|
|
640
|
-
return match;
|
|
641
|
-
}
|
|
642
|
-
});
|
|
643
|
-
}
|
|
825
|
+
const groupCtm = SVGFlatten.parseTransformAttribute(groupTransform);
|
|
826
|
+
let modifiedContent = content;
|
|
827
|
+
let childrenModified = false;
|
|
644
828
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
let groupIterations = 0;
|
|
829
|
+
modifiedContent = modifiedContent.replace(/<path\s+([^>]*?)\s*\/?>/gi, (pathMatch, pathAttrs) => {
|
|
830
|
+
const pathD = extractPathD(pathAttrs);
|
|
831
|
+
if (!pathD) return pathMatch;
|
|
649
832
|
|
|
650
|
-
|
|
651
|
-
|
|
833
|
+
try {
|
|
834
|
+
const childTransform = extractTransform(pathAttrs);
|
|
835
|
+
let combinedCtm = groupCtm;
|
|
652
836
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
(match, gAttrs, groupTransform, content) => {
|
|
657
|
-
try {
|
|
658
|
-
const groupCtm = SVGFlatten.parseTransformAttribute(groupTransform);
|
|
659
|
-
let modifiedContent = content;
|
|
660
|
-
let childrenModified = false;
|
|
661
|
-
|
|
662
|
-
// Apply group transform to child paths
|
|
663
|
-
modifiedContent = modifiedContent.replace(/<path\s+([^>]*?)\s*\/?>/gi, (pathMatch, pathAttrs) => {
|
|
664
|
-
const pathD = extractPathD(pathAttrs);
|
|
665
|
-
if (!pathD) return pathMatch;
|
|
666
|
-
|
|
667
|
-
try {
|
|
668
|
-
const childTransform = extractTransform(pathAttrs);
|
|
669
|
-
let combinedCtm = groupCtm;
|
|
670
|
-
|
|
671
|
-
// If child has its own transform, compose them
|
|
672
|
-
if (childTransform) {
|
|
673
|
-
const childCtm = SVGFlatten.parseTransformAttribute(childTransform);
|
|
674
|
-
combinedCtm = groupCtm.mul(childCtm);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
const transformedD = SVGFlatten.transformPathData(pathD, combinedCtm, { precision: config.precision });
|
|
678
|
-
const newAttrs = removeTransform(replacePathD(pathAttrs, transformedD));
|
|
679
|
-
childrenModified = true;
|
|
680
|
-
transformCount++;
|
|
681
|
-
return `<path ${newAttrs.trim()}${pathMatch.endsWith('/>') ? '/>' : '>'}`;
|
|
682
|
-
} catch (e) {
|
|
683
|
-
logWarn(`Failed to apply group transform to path: ${e.message}`);
|
|
684
|
-
return pathMatch;
|
|
837
|
+
if (childTransform) {
|
|
838
|
+
const childCtm = SVGFlatten.parseTransformAttribute(childTransform);
|
|
839
|
+
combinedCtm = groupCtm.mul(childCtm);
|
|
685
840
|
}
|
|
686
|
-
});
|
|
687
841
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
return `<
|
|
842
|
+
const transformedD = SVGFlatten.transformPathData(pathD, combinedCtm, { precision: config.precision });
|
|
843
|
+
const newAttrs = removeTransform(replacePathD(pathAttrs, transformedD));
|
|
844
|
+
childrenModified = true;
|
|
845
|
+
transformCount++;
|
|
846
|
+
return `<path ${newAttrs.trim()}${pathMatch.endsWith('/>') ? '/>' : '>'}`;
|
|
847
|
+
} catch (e) {
|
|
848
|
+
logWarn(`Failed to apply group transform to path: ${e.message}`);
|
|
849
|
+
return pathMatch;
|
|
693
850
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
if (childrenModified) {
|
|
854
|
+
const newGAttrs = removeTransform(gAttrs);
|
|
855
|
+
logDebug(`Propagated group transform to children: ${groupTransform}`);
|
|
856
|
+
return `<g${newGAttrs}>${modifiedContent}</g>`;
|
|
698
857
|
}
|
|
858
|
+
return match;
|
|
859
|
+
} catch (e) {
|
|
860
|
+
logWarn(`Failed to process group: ${e.message}`);
|
|
861
|
+
return match;
|
|
699
862
|
}
|
|
700
|
-
);
|
|
701
|
-
|
|
702
|
-
// Check if anything changed
|
|
703
|
-
if (result === beforeResult) {
|
|
704
|
-
break;
|
|
705
863
|
}
|
|
706
|
-
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
if (result === beforeResult) {
|
|
867
|
+
break;
|
|
707
868
|
}
|
|
869
|
+
groupIterations++;
|
|
870
|
+
}
|
|
708
871
|
|
|
709
|
-
|
|
872
|
+
logInfo(`Flattened ${transformCount} transforms (${pathCount} paths, ${shapeCount} shapes) [legacy mode]`);
|
|
710
873
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
}
|
|
715
|
-
logSuccess(`${basename(inputPath)} -> ${basename(outputPath)}`);
|
|
716
|
-
return true;
|
|
717
|
-
} catch (error) {
|
|
718
|
-
logError(`Failed: ${inputPath}: ${error.message}`);
|
|
719
|
-
return false;
|
|
874
|
+
if (!config.dryRun) {
|
|
875
|
+
ensureDir(dirname(outputPath));
|
|
876
|
+
writeFileSync(outputPath, result, 'utf8');
|
|
720
877
|
}
|
|
878
|
+
logSuccess(`${basename(inputPath)} -> ${basename(outputPath)}`);
|
|
879
|
+
return true;
|
|
721
880
|
}
|
|
722
881
|
|
|
723
882
|
function processConvert(inputPath, outputPath) {
|
|
@@ -845,6 +1004,43 @@ function parseArgs(args) {
|
|
|
845
1004
|
case '--log-file': cfg.logFile = args[++i]; break;
|
|
846
1005
|
case '-h': case '--help': cfg.command = 'help'; break;
|
|
847
1006
|
case '--version': cfg.command = 'version'; break;
|
|
1007
|
+
// Full flatten pipeline options
|
|
1008
|
+
case '--transform-only': cfg.transformOnly = true; break;
|
|
1009
|
+
case '--no-clip-paths': cfg.resolveClipPaths = false; break;
|
|
1010
|
+
case '--no-masks': cfg.resolveMasks = false; break;
|
|
1011
|
+
case '--no-use': cfg.resolveUse = false; break;
|
|
1012
|
+
case '--no-markers': cfg.resolveMarkers = false; break;
|
|
1013
|
+
case '--no-patterns': cfg.resolvePatterns = false; break;
|
|
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
|
|
848
1044
|
default:
|
|
849
1045
|
if (arg.startsWith('-')) { logError(`Unknown option: ${arg}`); process.exit(CONSTANTS.EXIT_ERROR); }
|
|
850
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),
|