@emasoft/svg-matrix 1.0.10 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,14 @@ 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
98
114
  };
99
115
 
100
116
  /** @type {CLIConfig} */
@@ -487,7 +503,14 @@ ${colors.bright}USAGE:${colors.reset}
487
503
  svg-matrix <command> [options] <input> [-o <output>]
488
504
 
489
505
  ${colors.bright}COMMANDS:${colors.reset}
490
- flatten Flatten SVG transforms into path data
506
+ flatten TRUE flatten: resolve ALL transform dependencies
507
+ - Bakes transform attributes into coordinates
508
+ - Applies clipPath boolean operations
509
+ - Converts masks to clipped geometry
510
+ - Expands use/symbol references inline
511
+ - Instantiates markers as path geometry
512
+ - Expands pattern fills to tiled geometry
513
+ - Bakes gradientTransform into coordinates
491
514
  convert Convert shapes (rect, circle, etc.) to paths
492
515
  normalize Convert paths to absolute cubic Bezier curves
493
516
  info Show SVG file information
@@ -506,10 +529,19 @@ ${colors.bright}OPTIONS:${colors.reset}
506
529
  --log-file <path> Write log to file
507
530
  -h, --help Show help
508
531
 
532
+ ${colors.bright}FLATTEN OPTIONS:${colors.reset}
533
+ --transform-only Only flatten transforms (skip resolvers)
534
+ --no-clip-paths Skip clipPath boolean operations
535
+ --no-masks Skip mask to clip conversion
536
+ --no-use Skip use/symbol expansion
537
+ --no-markers Skip marker instantiation
538
+ --no-patterns Skip pattern expansion
539
+ --no-gradients Skip gradient transform baking
540
+
509
541
  ${colors.bright}EXAMPLES:${colors.reset}
510
542
  svg-matrix flatten input.svg -o output.svg
511
- svg-matrix flatten ./svgs/ -o ./output/
512
- svg-matrix flatten --list files.txt -o ./output/
543
+ svg-matrix flatten ./svgs/ -o ./output/ --transform-only
544
+ svg-matrix flatten --list files.txt -o ./output/ --no-patterns
513
545
  svg-matrix convert input.svg -o output.svg --precision 10
514
546
  svg-matrix info input.svg
515
547
 
@@ -563,8 +595,19 @@ function replacePathD(attrs, newD) {
563
595
  }
564
596
 
565
597
  /**
566
- * Flatten transforms in SVG content by baking transforms into path data.
567
- * Handles: path elements, shape elements (converted to paths), and nested groups.
598
+ * Flatten SVG completely - no transform dependencies remain.
599
+ *
600
+ * TRUE flattening resolves ALL transform-dependent elements:
601
+ * - Bakes transform attributes into path coordinates
602
+ * - Applies clipPath boolean operations
603
+ * - Converts masks to clipped geometry
604
+ * - Expands use/symbol references inline
605
+ * - Instantiates markers as path geometry
606
+ * - Expands pattern fills to tiled geometry
607
+ * - Bakes gradientTransform into gradient coordinates
608
+ *
609
+ * Use --transform-only flag for legacy behavior (transforms only).
610
+ *
568
611
  * @param {string} inputPath - Input file path
569
612
  * @param {string} outputPath - Output file path
570
613
  * @returns {boolean} True if successful
@@ -572,152 +615,200 @@ function replacePathD(attrs, newD) {
572
615
  function processFlatten(inputPath, outputPath) {
573
616
  try {
574
617
  logDebug(`Processing: ${inputPath}`);
575
- let result = readFileSync(inputPath, 'utf8');
576
- let transformCount = 0;
577
- let pathCount = 0;
578
- let shapeCount = 0;
618
+ const svgContent = readFileSync(inputPath, 'utf8');
579
619
 
580
- // Step 1: Flatten transforms on path elements
581
- // Note: regex captures attrs without the closing /> or >
582
- result = result.replace(/<path\s+([^>]*?)\s*\/?>/gi, (match, attrs) => {
583
- const transform = extractTransform(attrs);
584
- const pathD = extractPathD(attrs);
620
+ // Use legacy transform-only mode if requested
621
+ if (config.transformOnly) {
622
+ return processFlattenLegacy(inputPath, outputPath, svgContent);
623
+ }
585
624
 
586
- if (!transform || !pathD) {
587
- return match; // No transform or no path data, skip
625
+ // Build pipeline options from config
626
+ const pipelineOptions = {
627
+ precision: config.precision,
628
+ curveSegments: 20,
629
+ resolveUse: config.resolveUse,
630
+ resolveMarkers: config.resolveMarkers,
631
+ resolvePatterns: config.resolvePatterns,
632
+ resolveMasks: config.resolveMasks,
633
+ resolveClipPaths: config.resolveClipPaths,
634
+ flattenTransforms: true, // Always flatten transforms
635
+ bakeGradients: config.bakeGradients,
636
+ removeUnusedDefs: true,
637
+ };
638
+
639
+ // Run the full flatten pipeline
640
+ const { svg: flattenedSvg, stats } = FlattenPipeline.flattenSVG(svgContent, pipelineOptions);
641
+
642
+ // Report statistics
643
+ const parts = [];
644
+ if (stats.transformsFlattened > 0) parts.push(`${stats.transformsFlattened} transforms`);
645
+ if (stats.useResolved > 0) parts.push(`${stats.useResolved} use`);
646
+ if (stats.markersResolved > 0) parts.push(`${stats.markersResolved} markers`);
647
+ if (stats.patternsResolved > 0) parts.push(`${stats.patternsResolved} patterns`);
648
+ if (stats.masksResolved > 0) parts.push(`${stats.masksResolved} masks`);
649
+ if (stats.clipPathsApplied > 0) parts.push(`${stats.clipPathsApplied} clipPaths`);
650
+ if (stats.gradientsProcessed > 0) parts.push(`${stats.gradientsProcessed} gradients`);
651
+
652
+ if (parts.length > 0) {
653
+ logInfo(`Flattened: ${parts.join(', ')}`);
654
+ } else {
655
+ logInfo('No transform dependencies found');
656
+ }
657
+
658
+ // Report any errors
659
+ if (stats.errors.length > 0) {
660
+ for (const err of stats.errors.slice(0, 5)) {
661
+ logWarn(err);
662
+ }
663
+ if (stats.errors.length > 5) {
664
+ logWarn(`...and ${stats.errors.length - 5} more errors`);
665
+ }
666
+ }
667
+
668
+ if (!config.dryRun) {
669
+ ensureDir(dirname(outputPath));
670
+ writeFileSync(outputPath, flattenedSvg, 'utf8');
671
+ }
672
+ logSuccess(`${basename(inputPath)} -> ${basename(outputPath)}`);
673
+ return true;
674
+ } catch (error) {
675
+ logError(`Failed: ${inputPath}: ${error.message}`);
676
+ return false;
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Legacy flatten mode - only bakes transform attributes into coordinates.
682
+ * Does NOT resolve clipPaths, masks, use, markers, patterns, or gradients.
683
+ * Use this when you only need transform flattening without boolean operations.
684
+ * @private
685
+ */
686
+ function processFlattenLegacy(inputPath, outputPath, svgContent) {
687
+ let result = svgContent;
688
+ let transformCount = 0;
689
+ let pathCount = 0;
690
+ let shapeCount = 0;
691
+
692
+ // Step 1: Flatten transforms on path elements
693
+ result = result.replace(/<path\s+([^>]*?)\s*\/?>/gi, (match, attrs) => {
694
+ const transform = extractTransform(attrs);
695
+ const pathD = extractPathD(attrs);
696
+
697
+ if (!transform || !pathD) {
698
+ return match;
699
+ }
700
+
701
+ try {
702
+ const ctm = SVGFlatten.parseTransformAttribute(transform);
703
+ const transformedD = SVGFlatten.transformPathData(pathD, ctm, { precision: config.precision });
704
+ const newAttrs = removeTransform(replacePathD(attrs, transformedD));
705
+ transformCount++;
706
+ pathCount++;
707
+ logDebug(`Flattened path transform: ${transform}`);
708
+ return `<path ${newAttrs.trim()}${match.endsWith('/>') ? '/>' : '>'}`;
709
+ } catch (e) {
710
+ logWarn(`Failed to flatten path: ${e.message}`);
711
+ return match;
712
+ }
713
+ });
714
+
715
+ // Step 2: Convert shapes with transforms to flattened paths
716
+ const shapeTypes = ['rect', 'circle', 'ellipse', 'line', 'polygon', 'polyline'];
717
+
718
+ for (const shapeType of shapeTypes) {
719
+ const shapeRegex = new RegExp(`<${shapeType}([^>]*)\\/>`, 'gi');
720
+
721
+ result = result.replace(shapeRegex, (match, attrs) => {
722
+ const transform = extractTransform(attrs);
723
+ if (!transform) {
724
+ return match;
588
725
  }
589
726
 
590
727
  try {
591
- // Parse the transform and build CTM
728
+ const pathD = extractShapeAsPath(shapeType, attrs, config.precision);
729
+ if (!pathD) {
730
+ return match;
731
+ }
732
+
592
733
  const ctm = SVGFlatten.parseTransformAttribute(transform);
593
- // Transform the path data
594
734
  const transformedD = SVGFlatten.transformPathData(pathD, ctm, { precision: config.precision });
595
- // Remove transform and update path data
596
- const newAttrs = removeTransform(replacePathD(attrs, transformedD));
735
+ const attrsToRemove = getShapeSpecificAttrs(shapeType);
736
+ const styleAttrs = removeShapeAttrs(removeTransform(attrs), attrsToRemove);
597
737
  transformCount++;
598
- pathCount++;
599
- logDebug(`Flattened path transform: ${transform}`);
600
- return `<path ${newAttrs.trim()}${match.endsWith('/>') ? '/>' : '>'}`;
738
+ shapeCount++;
739
+ logDebug(`Flattened ${shapeType} transform: ${transform}`);
740
+ return `<path d="${transformedD}"${styleAttrs ? ' ' + styleAttrs : ''}/>`;
601
741
  } catch (e) {
602
- logWarn(`Failed to flatten path: ${e.message}`);
742
+ logWarn(`Failed to flatten ${shapeType}: ${e.message}`);
603
743
  return match;
604
744
  }
605
745
  });
746
+ }
606
747
 
607
- // Step 2: Convert shapes with transforms to flattened paths
608
- const shapeTypes = ['rect', 'circle', 'ellipse', 'line', 'polygon', 'polyline'];
609
-
610
- for (const shapeType of shapeTypes) {
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
- }
748
+ // Step 3: Handle group transforms
749
+ let groupIterations = 0;
750
+ while (groupIterations < CONSTANTS.MAX_GROUP_ITERATIONS) {
751
+ const beforeResult = result;
618
752
 
753
+ result = result.replace(
754
+ /<g([^>]*transform\s*=\s*["']([^"']+)["'][^>]*)>([\s\S]*?)<\/g>/gi,
755
+ (match, gAttrs, groupTransform, content) => {
619
756
  try {
620
- // Extract shape attributes and convert to path using helper
621
- const pathD = extractShapeAsPath(shapeType, attrs, config.precision);
757
+ const groupCtm = SVGFlatten.parseTransformAttribute(groupTransform);
758
+ let modifiedContent = content;
759
+ let childrenModified = false;
622
760
 
623
- if (!pathD) {
624
- return match; // Couldn't convert to path
625
- }
761
+ modifiedContent = modifiedContent.replace(/<path\s+([^>]*?)\s*\/?>/gi, (pathMatch, pathAttrs) => {
762
+ const pathD = extractPathD(pathAttrs);
763
+ if (!pathD) return pathMatch;
626
764
 
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
- }
644
-
645
- // Step 3: Handle group transforms by propagating to children
646
- // This is a simplified approach - for full support, we'd need DOM parsing
647
- // For now, we handle the case where a <g> has a transform and contains paths/shapes
648
- let groupIterations = 0;
649
-
650
- while (groupIterations < CONSTANTS.MAX_GROUP_ITERATIONS) {
651
- const beforeResult = result;
652
-
653
- // Find groups with transforms
654
- result = result.replace(
655
- /<g([^>]*transform\s*=\s*["']([^"']+)["'][^>]*)>([\s\S]*?)<\/g>/gi,
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
- }
765
+ try {
766
+ const childTransform = extractTransform(pathAttrs);
767
+ let combinedCtm = groupCtm;
676
768
 
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;
769
+ if (childTransform) {
770
+ const childCtm = SVGFlatten.parseTransformAttribute(childTransform);
771
+ combinedCtm = groupCtm.mul(childCtm);
685
772
  }
686
- });
687
773
 
688
- if (childrenModified) {
689
- // Remove transform from group
690
- const newGAttrs = removeTransform(gAttrs);
691
- logDebug(`Propagated group transform to children: ${groupTransform}`);
692
- return `<g${newGAttrs}>${modifiedContent}</g>`;
774
+ const transformedD = SVGFlatten.transformPathData(pathD, combinedCtm, { precision: config.precision });
775
+ const newAttrs = removeTransform(replacePathD(pathAttrs, transformedD));
776
+ childrenModified = true;
777
+ transformCount++;
778
+ return `<path ${newAttrs.trim()}${pathMatch.endsWith('/>') ? '/>' : '>'}`;
779
+ } catch (e) {
780
+ logWarn(`Failed to apply group transform to path: ${e.message}`);
781
+ return pathMatch;
693
782
  }
694
- return match;
695
- } catch (e) {
696
- logWarn(`Failed to process group: ${e.message}`);
697
- return match;
783
+ });
784
+
785
+ if (childrenModified) {
786
+ const newGAttrs = removeTransform(gAttrs);
787
+ logDebug(`Propagated group transform to children: ${groupTransform}`);
788
+ return `<g${newGAttrs}>${modifiedContent}</g>`;
698
789
  }
790
+ return match;
791
+ } catch (e) {
792
+ logWarn(`Failed to process group: ${e.message}`);
793
+ return match;
699
794
  }
700
- );
701
-
702
- // Check if anything changed
703
- if (result === beforeResult) {
704
- break;
705
795
  }
706
- groupIterations++;
796
+ );
797
+
798
+ if (result === beforeResult) {
799
+ break;
707
800
  }
801
+ groupIterations++;
802
+ }
708
803
 
709
- logInfo(`Flattened ${transformCount} transforms (${pathCount} paths, ${shapeCount} shapes)`);
804
+ logInfo(`Flattened ${transformCount} transforms (${pathCount} paths, ${shapeCount} shapes) [legacy mode]`);
710
805
 
711
- if (!config.dryRun) {
712
- ensureDir(dirname(outputPath));
713
- writeFileSync(outputPath, result, 'utf8');
714
- }
715
- logSuccess(`${basename(inputPath)} -> ${basename(outputPath)}`);
716
- return true;
717
- } catch (error) {
718
- logError(`Failed: ${inputPath}: ${error.message}`);
719
- return false;
806
+ if (!config.dryRun) {
807
+ ensureDir(dirname(outputPath));
808
+ writeFileSync(outputPath, result, 'utf8');
720
809
  }
810
+ logSuccess(`${basename(inputPath)} -> ${basename(outputPath)}`);
811
+ return true;
721
812
  }
722
813
 
723
814
  function processConvert(inputPath, outputPath) {
@@ -845,6 +936,14 @@ function parseArgs(args) {
845
936
  case '--log-file': cfg.logFile = args[++i]; break;
846
937
  case '-h': case '--help': cfg.command = 'help'; break;
847
938
  case '--version': cfg.command = 'version'; break;
939
+ // Full flatten pipeline options
940
+ case '--transform-only': cfg.transformOnly = true; break;
941
+ case '--no-clip-paths': cfg.resolveClipPaths = false; break;
942
+ case '--no-masks': cfg.resolveMasks = false; break;
943
+ case '--no-use': cfg.resolveUse = false; break;
944
+ case '--no-markers': cfg.resolveMarkers = false; break;
945
+ case '--no-patterns': cfg.resolvePatterns = false; break;
946
+ case '--no-gradients': cfg.bakeGradients = false; break;
848
947
  default:
849
948
  if (arg.startsWith('-')) { logError(`Unknown option: ${arg}`); process.exit(CONSTANTS.EXIT_ERROR); }
850
949
  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.10",
3
+ "version": "1.0.12",
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",
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Test script for postinstall.js Unicode and ASCII fallback support.
4
+ * This tests various terminal environment configurations to ensure
5
+ * proper rendering of box characters.
6
+ */
7
+
8
+ import { spawn } from 'child_process';
9
+
10
+ console.log('\n=== Testing postinstall.js Unicode/ASCII support ===\n');
11
+
12
+ const tests = [
13
+ {
14
+ name: 'Unicode mode (UTF-8 locale)',
15
+ env: { LANG: 'en_US.UTF-8' },
16
+ expectUnicode: true,
17
+ },
18
+ {
19
+ name: 'ASCII fallback (C locale)',
20
+ env: { LANG: 'C' },
21
+ expectUnicode: false,
22
+ },
23
+ {
24
+ name: 'UTF-8 in LC_CTYPE',
25
+ env: { LC_CTYPE: 'en_US.UTF-8', LANG: '' },
26
+ expectUnicode: true,
27
+ },
28
+ ];
29
+
30
+ let passed = 0;
31
+ let failed = 0;
32
+
33
+ async function runTest(test) {
34
+ return new Promise((resolve) => {
35
+ console.log(`▸ Testing: ${test.name}`);
36
+
37
+ // Prepare environment
38
+ const env = { ...process.env, ...test.env };
39
+
40
+ const child = spawn('node', ['scripts/postinstall.js'], {
41
+ env,
42
+ stdio: 'pipe',
43
+ });
44
+
45
+ let output = '';
46
+
47
+ child.stdout.on('data', (data) => {
48
+ output += data.toString();
49
+ });
50
+
51
+ child.on('close', () => {
52
+ // Check for Unicode or ASCII box characters
53
+ const hasUnicodeBox = /[╭╮╰╯─│]/.test(output);
54
+ const hasAsciiBox = /[+\-|]/.test(output);
55
+
56
+ if (test.expectUnicode && hasUnicodeBox) {
57
+ console.log(` ✓ PASS: Unicode box characters detected\n`);
58
+ resolve(true);
59
+ } else if (!test.expectUnicode && hasAsciiBox && !hasUnicodeBox) {
60
+ console.log(` ✓ PASS: ASCII fallback characters detected\n`);
61
+ resolve(true);
62
+ } else {
63
+ console.log(` ✗ FAIL: Expected ${test.expectUnicode ? 'Unicode' : 'ASCII'} but got different output\n`);
64
+ resolve(false);
65
+ }
66
+ });
67
+
68
+ child.on('error', (error) => {
69
+ console.log(` ✗ FAIL: ${error.message}\n`);
70
+ resolve(false);
71
+ });
72
+ });
73
+ }
74
+
75
+ async function runAllTests() {
76
+ for (const test of tests) {
77
+ const result = await runTest(test);
78
+ if (result) {
79
+ passed++;
80
+ } else {
81
+ failed++;
82
+ }
83
+ }
84
+
85
+ console.log('=== Test Results ===');
86
+ console.log(`Passed: ${passed}`);
87
+ console.log(`Failed: ${failed}`);
88
+ console.log(`Total: ${passed + failed}\n`);
89
+
90
+ process.exit(failed > 0 ? 1 : 0);
91
+ }
92
+
93
+ runAllTests();