@emasoft/svg-matrix 1.0.30 → 1.0.31
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 +310 -61
- package/bin/svglinter.cjs +102 -3
- package/bin/svgm.js +236 -27
- package/package.json +1 -1
- package/src/animation-optimization.js +137 -17
- package/src/animation-references.js +123 -6
- package/src/arc-length.js +213 -4
- package/src/bezier-analysis.js +217 -21
- package/src/bezier-intersections.js +275 -12
- package/src/browser-verify.js +237 -4
- package/src/clip-path-resolver.js +168 -0
- package/src/convert-path-data.js +479 -28
- package/src/css-specificity.js +73 -10
- package/src/douglas-peucker.js +219 -2
- package/src/flatten-pipeline.js +284 -26
- package/src/geometry-to-path.js +250 -25
- package/src/gjk-collision.js +236 -33
- package/src/index.js +261 -3
- package/src/inkscape-support.js +86 -28
- package/src/logger.js +48 -3
- package/src/marker-resolver.js +278 -74
- package/src/mask-resolver.js +265 -66
- package/src/matrix.js +44 -5
- package/src/mesh-gradient.js +352 -102
- package/src/off-canvas-detection.js +382 -13
- package/src/path-analysis.js +192 -18
- package/src/path-data-plugins.js +309 -5
- package/src/path-optimization.js +129 -5
- package/src/path-simplification.js +188 -32
- package/src/pattern-resolver.js +454 -106
- package/src/polygon-clip.js +324 -1
- package/src/svg-boolean-ops.js +226 -9
- package/src/svg-collections.js +7 -5
- package/src/svg-flatten.js +386 -62
- package/src/svg-parser.js +179 -8
- package/src/svg-rendering-context.js +235 -6
- package/src/svg-toolbox.js +45 -8
- package/src/svg2-polyfills.js +40 -10
- package/src/transform-decomposition.js +258 -32
- package/src/transform-optimization.js +259 -13
- package/src/transforms2d.js +82 -9
- package/src/transforms3d.js +62 -10
- package/src/use-symbol-resolver.js +286 -42
- package/src/vector.js +64 -8
- package/src/verification.js +392 -1
package/bin/svg-matrix.js
CHANGED
|
@@ -237,15 +237,22 @@ process.on("SIGTERM", () => handleGracefulExit("SIGTERM")); // kill command
|
|
|
237
237
|
* @returns {void}
|
|
238
238
|
*/
|
|
239
239
|
function writeToLogFile(message) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
240
|
+
// Why: Validate message parameter to prevent crashes
|
|
241
|
+
if (!message || !config.logFile) return;
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
// Why: Ensure log directory exists before writing
|
|
245
|
+
const logDir = dirname(config.logFile);
|
|
246
|
+
if (!existsSync(logDir)) {
|
|
247
|
+
mkdirSync(logDir, { recursive: true });
|
|
248
248
|
}
|
|
249
|
+
|
|
250
|
+
const timestamp = new Date().toISOString();
|
|
251
|
+
// eslint-disable-next-line no-control-regex -- ANSI escape codes are intentional for log stripping
|
|
252
|
+
const cleanMessage = message.replace(/\x1b\[[0-9;]*m/g, "");
|
|
253
|
+
appendFileSync(config.logFile, `[${timestamp}] ${cleanMessage}\n`);
|
|
254
|
+
} catch {
|
|
255
|
+
/* ignore */
|
|
249
256
|
}
|
|
250
257
|
}
|
|
251
258
|
|
|
@@ -319,6 +326,10 @@ function showProgress(current, total, filename) {
|
|
|
319
326
|
// Why: Don't show progress in quiet mode or when there's only one file
|
|
320
327
|
if (config.quiet || total <= 1) return;
|
|
321
328
|
|
|
329
|
+
// Why: Validate parameters to prevent crashes
|
|
330
|
+
if (!filename || typeof current !== "number" || typeof total !== "number")
|
|
331
|
+
return;
|
|
332
|
+
|
|
322
333
|
// Why: In verbose mode, newline before progress to avoid overwriting debug output
|
|
323
334
|
if (config.verbose && current > 1) {
|
|
324
335
|
process.stdout.write("\n");
|
|
@@ -355,6 +366,16 @@ function showProgress(current, total, filename) {
|
|
|
355
366
|
* @throws {Error} If file is invalid or too large
|
|
356
367
|
*/
|
|
357
368
|
function validateSvgFile(filePath) {
|
|
369
|
+
// Why: Validate parameter to prevent crashes
|
|
370
|
+
if (!filePath || typeof filePath !== "string") {
|
|
371
|
+
throw new Error("Invalid file path: must be a non-empty string");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Why: Check file exists before attempting to stat
|
|
375
|
+
if (!existsSync(filePath)) {
|
|
376
|
+
throw new Error(`File not found: ${filePath}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
358
379
|
const stats = statSync(filePath);
|
|
359
380
|
|
|
360
381
|
// Why: Prevent memory exhaustion from huge files
|
|
@@ -365,15 +386,27 @@ function validateSvgFile(filePath) {
|
|
|
365
386
|
}
|
|
366
387
|
|
|
367
388
|
// Why: Read only first 1KB to check header - don't load entire file
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
389
|
+
// Use try-finally to ensure fd is always closed, preventing file descriptor leak
|
|
390
|
+
let fd;
|
|
391
|
+
try {
|
|
392
|
+
fd = openSync(filePath, "r");
|
|
393
|
+
const buffer = Buffer.alloc(1024);
|
|
394
|
+
readSync(fd, buffer, 0, 1024, 0);
|
|
395
|
+
const header = buffer.toString("utf8");
|
|
396
|
+
|
|
397
|
+
// Why: SVG files must have an <svg> element - if not, it's not a valid SVG
|
|
398
|
+
if (!CONSTANTS.SVG_HEADER_PATTERN.test(header)) {
|
|
399
|
+
throw new Error("Not a valid SVG file (missing <svg> element)");
|
|
400
|
+
}
|
|
401
|
+
} finally {
|
|
402
|
+
// Why: Always close file descriptor to prevent resource leak
|
|
403
|
+
if (fd !== undefined) {
|
|
404
|
+
try {
|
|
405
|
+
closeSync(fd);
|
|
406
|
+
} catch {
|
|
407
|
+
/* ignore close errors */
|
|
408
|
+
}
|
|
409
|
+
}
|
|
377
410
|
}
|
|
378
411
|
|
|
379
412
|
return true;
|
|
@@ -383,34 +416,9 @@ function validateSvgFile(filePath) {
|
|
|
383
416
|
// WRITE VERIFICATION
|
|
384
417
|
// Why: Detect silent write failures. Some filesystems (especially network
|
|
385
418
|
// shares) may appear to write successfully but fail to persist data.
|
|
386
|
-
// Verification
|
|
419
|
+
// Verification is done inline where needed (see processFlatten/Convert/Normalize)
|
|
420
|
+
// rather than in a dedicated function to avoid double memory usage.
|
|
387
421
|
// ============================================================================
|
|
388
|
-
/**
|
|
389
|
-
* Verify file was written correctly by comparing content.
|
|
390
|
-
* @param {string} filePath - Path to file
|
|
391
|
-
* @param {string} expectedContent - Expected file content
|
|
392
|
-
* @returns {boolean} True if verification passed
|
|
393
|
-
* @throws {Error} If verification failed
|
|
394
|
-
* @private
|
|
395
|
-
*/
|
|
396
|
-
function _verifyWriteSuccess(filePath, expectedContent) {
|
|
397
|
-
// Why: Read back what was written and compare
|
|
398
|
-
const actualContent = readFileSync(filePath, "utf8");
|
|
399
|
-
|
|
400
|
-
// Why: Compare lengths first (fast), then content if needed
|
|
401
|
-
if (actualContent.length !== expectedContent.length) {
|
|
402
|
-
throw new Error(
|
|
403
|
-
`Write verification failed: size mismatch (expected ${expectedContent.length}, got ${actualContent.length})`,
|
|
404
|
-
);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// Why: Full content comparison to catch bit flips or encoding issues
|
|
408
|
-
if (actualContent !== expectedContent) {
|
|
409
|
-
throw new Error("Write verification failed: content mismatch");
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
return true;
|
|
413
|
-
}
|
|
414
422
|
|
|
415
423
|
// ============================================================================
|
|
416
424
|
// CRASH LOG
|
|
@@ -473,6 +481,8 @@ ${JSON.stringify({ ...config, logFile: config.logFile ? "[redacted]" : null }, n
|
|
|
473
481
|
* @returns {string} Normalized path
|
|
474
482
|
*/
|
|
475
483
|
function normalizePath(p) {
|
|
484
|
+
// Why: Validate parameter to prevent crashes
|
|
485
|
+
if (!p || typeof p !== "string") return "";
|
|
476
486
|
return p.replace(/\\/g, "/");
|
|
477
487
|
}
|
|
478
488
|
|
|
@@ -482,6 +492,10 @@ function normalizePath(p) {
|
|
|
482
492
|
* @returns {string} Absolute normalized path
|
|
483
493
|
*/
|
|
484
494
|
function resolvePath(p) {
|
|
495
|
+
// Why: Validate parameter to prevent crashes
|
|
496
|
+
if (!p || typeof p !== "string") {
|
|
497
|
+
throw new Error("Invalid path: must be a non-empty string");
|
|
498
|
+
}
|
|
485
499
|
return isAbsolute(p)
|
|
486
500
|
? normalizePath(p)
|
|
487
501
|
: normalizePath(resolve(process.cwd(), p));
|
|
@@ -493,6 +507,8 @@ function resolvePath(p) {
|
|
|
493
507
|
* @returns {boolean} True if directory exists
|
|
494
508
|
*/
|
|
495
509
|
function isDir(p) {
|
|
510
|
+
// Why: Validate parameter to prevent crashes from invalid types
|
|
511
|
+
if (!p || typeof p !== "string") return false;
|
|
496
512
|
try {
|
|
497
513
|
return statSync(p).isDirectory();
|
|
498
514
|
} catch {
|
|
@@ -506,6 +522,8 @@ function isDir(p) {
|
|
|
506
522
|
* @returns {boolean} True if file exists
|
|
507
523
|
*/
|
|
508
524
|
function isFile(p) {
|
|
525
|
+
// Why: Validate parameter to prevent crashes from invalid types
|
|
526
|
+
if (!p || typeof p !== "string") return false;
|
|
509
527
|
try {
|
|
510
528
|
return statSync(p).isFile();
|
|
511
529
|
} catch {
|
|
@@ -519,6 +537,10 @@ function isFile(p) {
|
|
|
519
537
|
* @returns {void}
|
|
520
538
|
*/
|
|
521
539
|
function ensureDir(dir) {
|
|
540
|
+
// Why: Validate parameter to prevent crashes
|
|
541
|
+
if (!dir || typeof dir !== "string") {
|
|
542
|
+
throw new Error("Invalid directory path: must be a non-empty string");
|
|
543
|
+
}
|
|
522
544
|
if (!existsSync(dir)) {
|
|
523
545
|
mkdirSync(dir, { recursive: true });
|
|
524
546
|
logDebug(`Created directory: ${dir}`);
|
|
@@ -532,6 +554,14 @@ function ensureDir(dir) {
|
|
|
532
554
|
* @returns {string[]} Array of SVG file paths
|
|
533
555
|
*/
|
|
534
556
|
function getSvgFiles(dir, recursive = false) {
|
|
557
|
+
// Why: Validate parameter to prevent crashes
|
|
558
|
+
if (!dir || typeof dir !== "string") {
|
|
559
|
+
throw new Error("Invalid directory path: must be a non-empty string");
|
|
560
|
+
}
|
|
561
|
+
if (!existsSync(dir)) {
|
|
562
|
+
throw new Error(`Directory not found: ${dir}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
535
565
|
const files = [];
|
|
536
566
|
function scan(d) {
|
|
537
567
|
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
@@ -555,16 +585,28 @@ function getSvgFiles(dir, recursive = false) {
|
|
|
555
585
|
* @returns {string[]} Array of resolved file paths
|
|
556
586
|
*/
|
|
557
587
|
function parseFileList(listPath) {
|
|
588
|
+
// Why: Validate parameter to prevent crashes
|
|
589
|
+
if (!listPath || typeof listPath !== "string") {
|
|
590
|
+
throw new Error("Invalid list file path: must be a non-empty string");
|
|
591
|
+
}
|
|
592
|
+
if (!existsSync(listPath)) {
|
|
593
|
+
throw new Error(`List file not found: ${listPath}`);
|
|
594
|
+
}
|
|
595
|
+
|
|
558
596
|
const content = readFileSync(listPath, "utf8");
|
|
559
597
|
const files = [];
|
|
560
598
|
for (const line of content.split(/\r?\n/)) {
|
|
561
599
|
const trimmed = line.trim();
|
|
562
600
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
601
|
+
try {
|
|
602
|
+
const resolved = resolvePath(trimmed);
|
|
603
|
+
if (isFile(resolved)) files.push(resolved);
|
|
604
|
+
else if (isDir(resolved))
|
|
605
|
+
files.push(...getSvgFiles(resolved, config.recursive));
|
|
606
|
+
else logWarn(`File not found: ${trimmed}`);
|
|
607
|
+
} catch (e) {
|
|
608
|
+
logWarn(`Invalid path in list: ${trimmed} - ${e.message}`);
|
|
609
|
+
}
|
|
568
610
|
}
|
|
569
611
|
return files;
|
|
570
612
|
}
|
|
@@ -584,10 +626,17 @@ function parseFileList(listPath) {
|
|
|
584
626
|
* @returns {number} Parsed value or default
|
|
585
627
|
*/
|
|
586
628
|
function extractNumericAttr(attrs, attrName, defaultValue = 0) {
|
|
629
|
+
// Why: Validate parameters to prevent crashes
|
|
630
|
+
if (!attrs || typeof attrs !== "string") return defaultValue;
|
|
631
|
+
if (!attrName || typeof attrName !== "string") return defaultValue;
|
|
632
|
+
|
|
587
633
|
// Why: Use word boundary \b to avoid matching 'rx' when looking for 'x'
|
|
588
634
|
const regex = new RegExp(`\\b${attrName}\\s*=\\s*["']([^"']+)["']`, "i");
|
|
589
635
|
const match = attrs.match(regex);
|
|
590
|
-
|
|
636
|
+
if (!match) return defaultValue;
|
|
637
|
+
const val = parseFloat(match[1]);
|
|
638
|
+
// Why: Return default if parsing fails or results in NaN/Infinity
|
|
639
|
+
return isNaN(val) || !isFinite(val) ? defaultValue : val;
|
|
591
640
|
}
|
|
592
641
|
|
|
593
642
|
/**
|
|
@@ -598,6 +647,18 @@ function extractNumericAttr(attrs, attrName, defaultValue = 0) {
|
|
|
598
647
|
* @returns {string|null} Path data or null if extraction failed
|
|
599
648
|
*/
|
|
600
649
|
function extractShapeAsPath(shapeType, attrs, precision) {
|
|
650
|
+
// Why: Validate parameters to prevent crashes
|
|
651
|
+
if (!shapeType || typeof shapeType !== "string") return null;
|
|
652
|
+
if (!attrs || typeof attrs !== "string") return null;
|
|
653
|
+
// Why: Validate precision is a valid finite number within acceptable range
|
|
654
|
+
if (
|
|
655
|
+
typeof precision !== "number" ||
|
|
656
|
+
!isFinite(precision) ||
|
|
657
|
+
precision < 1 ||
|
|
658
|
+
precision > CONSTANTS.MAX_PRECISION
|
|
659
|
+
)
|
|
660
|
+
return null;
|
|
661
|
+
|
|
601
662
|
switch (shapeType) {
|
|
602
663
|
case "rect": {
|
|
603
664
|
const x = extractNumericAttr(attrs, "x");
|
|
@@ -606,7 +667,8 @@ function extractShapeAsPath(shapeType, attrs, precision) {
|
|
|
606
667
|
const h = extractNumericAttr(attrs, "height");
|
|
607
668
|
const rx = extractNumericAttr(attrs, "rx");
|
|
608
669
|
const ry = extractNumericAttr(attrs, "ry", rx); // ry defaults to rx per SVG spec
|
|
609
|
-
|
|
670
|
+
// Why: Validate dimensions and corner radii are positive and finite
|
|
671
|
+
if (w <= 0 || h <= 0 || rx < 0 || ry < 0) return null;
|
|
610
672
|
return GeometryToPath.rectToPathData(
|
|
611
673
|
x,
|
|
612
674
|
y,
|
|
@@ -622,6 +684,7 @@ function extractShapeAsPath(shapeType, attrs, precision) {
|
|
|
622
684
|
const cx = extractNumericAttr(attrs, "cx");
|
|
623
685
|
const cy = extractNumericAttr(attrs, "cy");
|
|
624
686
|
const r = extractNumericAttr(attrs, "r");
|
|
687
|
+
// Why: Validate radius is positive (already checked by extractNumericAttr for NaN/Infinity)
|
|
625
688
|
if (r <= 0) return null;
|
|
626
689
|
return GeometryToPath.circleToPathData(cx, cy, r, precision);
|
|
627
690
|
}
|
|
@@ -630,6 +693,7 @@ function extractShapeAsPath(shapeType, attrs, precision) {
|
|
|
630
693
|
const cy = extractNumericAttr(attrs, "cy");
|
|
631
694
|
const rx = extractNumericAttr(attrs, "rx");
|
|
632
695
|
const ry = extractNumericAttr(attrs, "ry");
|
|
696
|
+
// Why: Validate radii are positive (already checked by extractNumericAttr for NaN/Infinity)
|
|
633
697
|
if (rx <= 0 || ry <= 0) return null;
|
|
634
698
|
return GeometryToPath.ellipseToPathData(cx, cy, rx, ry, precision);
|
|
635
699
|
}
|
|
@@ -663,6 +727,9 @@ function extractShapeAsPath(shapeType, attrs, precision) {
|
|
|
663
727
|
* @returns {string[]} Attribute names to remove
|
|
664
728
|
*/
|
|
665
729
|
function getShapeSpecificAttrs(shapeType) {
|
|
730
|
+
// Why: Validate parameter and return empty array for invalid input
|
|
731
|
+
if (!shapeType || typeof shapeType !== "string") return [];
|
|
732
|
+
|
|
666
733
|
const attrMap = {
|
|
667
734
|
rect: ["x", "y", "width", "height", "rx", "ry"],
|
|
668
735
|
circle: ["cx", "cy", "r"],
|
|
@@ -681,8 +748,13 @@ function getShapeSpecificAttrs(shapeType) {
|
|
|
681
748
|
* @returns {string} Cleaned attributes
|
|
682
749
|
*/
|
|
683
750
|
function removeShapeAttrs(attrs, attrsToRemove) {
|
|
751
|
+
// Why: Validate parameters to prevent crashes
|
|
752
|
+
if (!attrs || typeof attrs !== "string") return "";
|
|
753
|
+
if (!Array.isArray(attrsToRemove)) return attrs;
|
|
754
|
+
|
|
684
755
|
let result = attrs;
|
|
685
756
|
for (const attrName of attrsToRemove) {
|
|
757
|
+
if (!attrName || typeof attrName !== "string") continue;
|
|
686
758
|
result = result.replace(
|
|
687
759
|
new RegExp(`\\b${attrName}\\s*=\\s*["'][^"']*["']`, "gi"),
|
|
688
760
|
"",
|
|
@@ -726,6 +798,8 @@ const B = supportsUnicode()
|
|
|
726
798
|
* @returns {string} String without ANSI codes
|
|
727
799
|
*/
|
|
728
800
|
function stripAnsi(s) {
|
|
801
|
+
// Why: Validate parameter to prevent crashes
|
|
802
|
+
if (!s || typeof s !== "string") return "";
|
|
729
803
|
// eslint-disable-next-line no-control-regex -- ANSI escape codes are intentional for terminal color stripping
|
|
730
804
|
return s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
731
805
|
}
|
|
@@ -737,9 +811,14 @@ function stripAnsi(s) {
|
|
|
737
811
|
* @returns {string} Formatted line
|
|
738
812
|
*/
|
|
739
813
|
function boxLine(content, width = 70) {
|
|
740
|
-
|
|
814
|
+
// Why: Validate content parameter to prevent crashes
|
|
815
|
+
let safeContent = content;
|
|
816
|
+
if (safeContent === undefined || safeContent === null) safeContent = "";
|
|
817
|
+
if (typeof safeContent !== "string") safeContent = String(safeContent);
|
|
818
|
+
|
|
819
|
+
const visible = stripAnsi(safeContent).length;
|
|
741
820
|
const padding = Math.max(0, width - visible - 2);
|
|
742
|
-
return `${colors.cyan}${B.v}${colors.reset} ${
|
|
821
|
+
return `${colors.cyan}${B.v}${colors.reset} ${safeContent}${" ".repeat(padding)}${colors.cyan}${B.v}${colors.reset}`;
|
|
743
822
|
}
|
|
744
823
|
|
|
745
824
|
/**
|
|
@@ -749,10 +828,14 @@ function boxLine(content, width = 70) {
|
|
|
749
828
|
* @returns {string} Formatted header
|
|
750
829
|
*/
|
|
751
830
|
function boxHeader(title, width = 70) {
|
|
752
|
-
|
|
753
|
-
|
|
831
|
+
// Why: Validate title parameter to prevent crashes
|
|
832
|
+
let safeTitle = title;
|
|
833
|
+
if (safeTitle === undefined || safeTitle === null) safeTitle = "";
|
|
834
|
+
if (typeof safeTitle !== "string") safeTitle = String(safeTitle);
|
|
835
|
+
|
|
836
|
+
const visible = stripAnsi(safeTitle).length;
|
|
754
837
|
const padding = Math.max(0, width - visible - 2);
|
|
755
|
-
return `${colors.cyan}${B.v}${colors.reset} ${colors.bright}${
|
|
838
|
+
return `${colors.cyan}${B.v}${colors.reset} ${colors.bright}${safeTitle}${colors.reset}${" ".repeat(padding)}${colors.cyan}${B.v}${colors.reset}`;
|
|
756
839
|
}
|
|
757
840
|
|
|
758
841
|
/**
|
|
@@ -886,6 +969,12 @@ ${colors.cyan}${B.bl}${hr}${B.br}${colors.reset}
|
|
|
886
969
|
* @returns {void}
|
|
887
970
|
*/
|
|
888
971
|
function showCommandHelp(command) {
|
|
972
|
+
// Why: Validate command parameter to prevent crashes
|
|
973
|
+
if (!command || typeof command !== "string") {
|
|
974
|
+
showHelp();
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
|
|
889
978
|
const W = 72;
|
|
890
979
|
const hr = B.h.repeat(W);
|
|
891
980
|
|
|
@@ -1034,6 +1123,8 @@ function showVersion() {
|
|
|
1034
1123
|
* @returns {string|null} Transform value or null
|
|
1035
1124
|
*/
|
|
1036
1125
|
function extractTransform(attrs) {
|
|
1126
|
+
// Why: Validate parameter to prevent crashes
|
|
1127
|
+
if (!attrs || typeof attrs !== "string") return null;
|
|
1037
1128
|
const match = attrs.match(/transform\s*=\s*["']([^"']+)["']/i);
|
|
1038
1129
|
return match ? match[1] : null;
|
|
1039
1130
|
}
|
|
@@ -1044,6 +1135,8 @@ function extractTransform(attrs) {
|
|
|
1044
1135
|
* @returns {string} Attributes without transform
|
|
1045
1136
|
*/
|
|
1046
1137
|
function removeTransform(attrs) {
|
|
1138
|
+
// Why: Validate parameter to prevent crashes
|
|
1139
|
+
if (!attrs || typeof attrs !== "string") return "";
|
|
1047
1140
|
return attrs.replace(/\s*transform\s*=\s*["'][^"']*["']/gi, "");
|
|
1048
1141
|
}
|
|
1049
1142
|
|
|
@@ -1053,6 +1146,8 @@ function removeTransform(attrs) {
|
|
|
1053
1146
|
* @returns {string|null} Path data or null
|
|
1054
1147
|
*/
|
|
1055
1148
|
function extractPathD(attrs) {
|
|
1149
|
+
// Why: Validate parameter to prevent crashes
|
|
1150
|
+
if (!attrs || typeof attrs !== "string") return null;
|
|
1056
1151
|
const match = attrs.match(/\bd\s*=\s*["']([^"']+)["']/i);
|
|
1057
1152
|
return match ? match[1] : null;
|
|
1058
1153
|
}
|
|
@@ -1064,7 +1159,12 @@ function extractPathD(attrs) {
|
|
|
1064
1159
|
* @returns {string} Updated attributes
|
|
1065
1160
|
*/
|
|
1066
1161
|
function replacePathD(attrs, newD) {
|
|
1067
|
-
|
|
1162
|
+
// Why: Validate parameters to prevent crashes
|
|
1163
|
+
if (!attrs || typeof attrs !== "string") return "";
|
|
1164
|
+
let safeD = newD;
|
|
1165
|
+
if (safeD === undefined || safeD === null) safeD = "";
|
|
1166
|
+
if (typeof safeD !== "string") safeD = String(safeD);
|
|
1167
|
+
return attrs.replace(/(\bd\s*=\s*["'])[^"']+["']/i, `$1${safeD}"`);
|
|
1068
1168
|
}
|
|
1069
1169
|
|
|
1070
1170
|
/**
|
|
@@ -1086,6 +1186,16 @@ function replacePathD(attrs, newD) {
|
|
|
1086
1186
|
* @returns {boolean} True if successful
|
|
1087
1187
|
*/
|
|
1088
1188
|
function processFlatten(inputPath, outputPath) {
|
|
1189
|
+
// Why: Validate parameters to prevent crashes
|
|
1190
|
+
if (!inputPath || typeof inputPath !== "string") {
|
|
1191
|
+
logError("Invalid input path: must be a non-empty string");
|
|
1192
|
+
return false;
|
|
1193
|
+
}
|
|
1194
|
+
if (!outputPath || typeof outputPath !== "string") {
|
|
1195
|
+
logError("Invalid output path: must be a non-empty string");
|
|
1196
|
+
return false;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1089
1199
|
try {
|
|
1090
1200
|
logDebug(`Processing: ${inputPath}`);
|
|
1091
1201
|
const svgContent = readFileSync(inputPath, "utf8");
|
|
@@ -1116,10 +1226,20 @@ function processFlatten(inputPath, outputPath) {
|
|
|
1116
1226
|
};
|
|
1117
1227
|
|
|
1118
1228
|
// Run the full flatten pipeline
|
|
1119
|
-
const
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
)
|
|
1229
|
+
const result = FlattenPipeline.flattenSVG(svgContent, pipelineOptions);
|
|
1230
|
+
|
|
1231
|
+
// Why: Validate result structure to prevent crashes on unexpected return values
|
|
1232
|
+
if (!result || typeof result !== "object") {
|
|
1233
|
+
throw new Error("FlattenPipeline.flattenSVG returned invalid result");
|
|
1234
|
+
}
|
|
1235
|
+
if (!result.svg || typeof result.svg !== "string") {
|
|
1236
|
+
throw new Error("FlattenPipeline.flattenSVG returned invalid SVG");
|
|
1237
|
+
}
|
|
1238
|
+
if (!result.stats || typeof result.stats !== "object") {
|
|
1239
|
+
throw new Error("FlattenPipeline.flattenSVG returned invalid stats");
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const { svg: flattenedSvg, stats } = result;
|
|
1123
1243
|
|
|
1124
1244
|
// Report statistics
|
|
1125
1245
|
const parts = [];
|
|
@@ -1232,6 +1352,20 @@ function processFlatten(inputPath, outputPath) {
|
|
|
1232
1352
|
* @private
|
|
1233
1353
|
*/
|
|
1234
1354
|
function processFlattenLegacy(inputPath, outputPath, svgContent) {
|
|
1355
|
+
// Why: Validate parameters to prevent crashes
|
|
1356
|
+
if (!inputPath || typeof inputPath !== "string") {
|
|
1357
|
+
logError("Invalid input path: must be a non-empty string");
|
|
1358
|
+
return false;
|
|
1359
|
+
}
|
|
1360
|
+
if (!outputPath || typeof outputPath !== "string") {
|
|
1361
|
+
logError("Invalid output path: must be a non-empty string");
|
|
1362
|
+
return false;
|
|
1363
|
+
}
|
|
1364
|
+
if (!svgContent || typeof svgContent !== "string") {
|
|
1365
|
+
logError("Invalid SVG content: must be a non-empty string");
|
|
1366
|
+
return false;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1235
1369
|
let result = svgContent;
|
|
1236
1370
|
let transformCount = 0;
|
|
1237
1371
|
let pathCount = 0;
|
|
@@ -1377,6 +1511,13 @@ function processFlattenLegacy(inputPath, outputPath, svgContent) {
|
|
|
1377
1511
|
groupIterations++;
|
|
1378
1512
|
}
|
|
1379
1513
|
|
|
1514
|
+
// Why: Warn when iteration limit reached - may indicate deeply nested transforms
|
|
1515
|
+
if (groupIterations >= CONSTANTS.MAX_GROUP_ITERATIONS) {
|
|
1516
|
+
logWarn(
|
|
1517
|
+
`Group transform propagation reached maximum iterations (${CONSTANTS.MAX_GROUP_ITERATIONS}). Deeply nested groups may not be fully flattened.`,
|
|
1518
|
+
);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1380
1521
|
logInfo(
|
|
1381
1522
|
`Flattened ${transformCount} transforms (${pathCount} paths, ${shapeCount} shapes) [legacy mode]`,
|
|
1382
1523
|
);
|
|
@@ -1396,6 +1537,16 @@ function processFlattenLegacy(inputPath, outputPath, svgContent) {
|
|
|
1396
1537
|
* @returns {boolean} True if successful
|
|
1397
1538
|
*/
|
|
1398
1539
|
function processConvert(inputPath, outputPath) {
|
|
1540
|
+
// Why: Validate parameters to prevent crashes
|
|
1541
|
+
if (!inputPath || typeof inputPath !== "string") {
|
|
1542
|
+
logError("Invalid input path: must be a non-empty string");
|
|
1543
|
+
return false;
|
|
1544
|
+
}
|
|
1545
|
+
if (!outputPath || typeof outputPath !== "string") {
|
|
1546
|
+
logError("Invalid output path: must be a non-empty string");
|
|
1547
|
+
return false;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1399
1550
|
try {
|
|
1400
1551
|
logDebug(`Converting: ${inputPath}`);
|
|
1401
1552
|
let result = readFileSync(inputPath, "utf8");
|
|
@@ -1457,6 +1608,16 @@ function processConvert(inputPath, outputPath) {
|
|
|
1457
1608
|
* @returns {boolean} True if successful
|
|
1458
1609
|
*/
|
|
1459
1610
|
function processNormalize(inputPath, outputPath) {
|
|
1611
|
+
// Why: Validate parameters to prevent crashes
|
|
1612
|
+
if (!inputPath || typeof inputPath !== "string") {
|
|
1613
|
+
logError("Invalid input path: must be a non-empty string");
|
|
1614
|
+
return false;
|
|
1615
|
+
}
|
|
1616
|
+
if (!outputPath || typeof outputPath !== "string") {
|
|
1617
|
+
logError("Invalid output path: must be a non-empty string");
|
|
1618
|
+
return false;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1460
1621
|
try {
|
|
1461
1622
|
logDebug(`Normalizing: ${inputPath}`);
|
|
1462
1623
|
let result = readFileSync(inputPath, "utf8");
|
|
@@ -1488,6 +1649,12 @@ function processNormalize(inputPath, outputPath) {
|
|
|
1488
1649
|
* @returns {boolean} True if successful
|
|
1489
1650
|
*/
|
|
1490
1651
|
function processInfo(inputPath) {
|
|
1652
|
+
// Why: Validate parameter to prevent crashes
|
|
1653
|
+
if (!inputPath || typeof inputPath !== "string") {
|
|
1654
|
+
logError("Invalid input path: must be a non-empty string");
|
|
1655
|
+
return false;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1491
1658
|
try {
|
|
1492
1659
|
const svg = readFileSync(inputPath, "utf8");
|
|
1493
1660
|
const vb = svg.match(/viewBox\s*=\s*["']([^"']+)["']/i)?.[1] || "not set";
|
|
@@ -1623,6 +1790,20 @@ async function testToolboxFunction(
|
|
|
1623
1790
|
originalSize,
|
|
1624
1791
|
outputDir,
|
|
1625
1792
|
) {
|
|
1793
|
+
// Why: Validate parameters to prevent crashes
|
|
1794
|
+
if (!fnName || typeof fnName !== "string") {
|
|
1795
|
+
return {
|
|
1796
|
+
name: "invalid",
|
|
1797
|
+
status: "error",
|
|
1798
|
+
error: "Invalid function name",
|
|
1799
|
+
outputSize: 0,
|
|
1800
|
+
sizeDiff: 0,
|
|
1801
|
+
sizeDiffPercent: 0,
|
|
1802
|
+
outputFile: null,
|
|
1803
|
+
timeMs: 0,
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1626
1807
|
const result = {
|
|
1627
1808
|
name: fnName,
|
|
1628
1809
|
status: "unknown",
|
|
@@ -1634,6 +1815,23 @@ async function testToolboxFunction(
|
|
|
1634
1815
|
timeMs: 0,
|
|
1635
1816
|
};
|
|
1636
1817
|
|
|
1818
|
+
// Why: Validate remaining parameters after result initialization
|
|
1819
|
+
if (!originalContent || typeof originalContent !== "string") {
|
|
1820
|
+
result.status = "error";
|
|
1821
|
+
result.error = "Invalid original content";
|
|
1822
|
+
return result;
|
|
1823
|
+
}
|
|
1824
|
+
if (typeof originalSize !== "number" || originalSize < 0) {
|
|
1825
|
+
result.status = "error";
|
|
1826
|
+
result.error = "Invalid original size";
|
|
1827
|
+
return result;
|
|
1828
|
+
}
|
|
1829
|
+
if (!outputDir || typeof outputDir !== "string") {
|
|
1830
|
+
result.status = "error";
|
|
1831
|
+
result.error = "Invalid output directory";
|
|
1832
|
+
return result;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1637
1835
|
try {
|
|
1638
1836
|
const fn = SVGToolbox[fnName];
|
|
1639
1837
|
if (!fn) {
|
|
@@ -1846,10 +2044,18 @@ function parseArgs(args) {
|
|
|
1846
2044
|
switch (arg) {
|
|
1847
2045
|
case "-o":
|
|
1848
2046
|
case "--output":
|
|
2047
|
+
if (i + 1 >= args.length) {
|
|
2048
|
+
logError("-o/--output requires a value");
|
|
2049
|
+
process.exit(CONSTANTS.EXIT_ERROR);
|
|
2050
|
+
}
|
|
1849
2051
|
cfg.output = args[++i];
|
|
1850
2052
|
break;
|
|
1851
2053
|
case "-l":
|
|
1852
2054
|
case "--list":
|
|
2055
|
+
if (i + 1 >= args.length) {
|
|
2056
|
+
logError("-l/--list requires a value");
|
|
2057
|
+
process.exit(CONSTANTS.EXIT_ERROR);
|
|
2058
|
+
}
|
|
1853
2059
|
cfg.listFile = args[++i];
|
|
1854
2060
|
break;
|
|
1855
2061
|
case "-r":
|
|
@@ -1858,6 +2064,10 @@ function parseArgs(args) {
|
|
|
1858
2064
|
break;
|
|
1859
2065
|
case "-p":
|
|
1860
2066
|
case "--precision": {
|
|
2067
|
+
if (i + 1 >= args.length) {
|
|
2068
|
+
logError("-p/--precision requires a value");
|
|
2069
|
+
process.exit(CONSTANTS.EXIT_ERROR);
|
|
2070
|
+
}
|
|
1861
2071
|
const precision = parseInt(args[++i], 10);
|
|
1862
2072
|
if (
|
|
1863
2073
|
isNaN(precision) ||
|
|
@@ -1889,6 +2099,10 @@ function parseArgs(args) {
|
|
|
1889
2099
|
cfg.verbose = true;
|
|
1890
2100
|
break;
|
|
1891
2101
|
case "--log-file":
|
|
2102
|
+
if (i + 1 >= args.length) {
|
|
2103
|
+
logError("--log-file requires a value");
|
|
2104
|
+
process.exit(CONSTANTS.EXIT_ERROR);
|
|
2105
|
+
}
|
|
1892
2106
|
cfg.logFile = args[++i];
|
|
1893
2107
|
break;
|
|
1894
2108
|
case "-h":
|
|
@@ -1936,6 +2150,12 @@ function parseArgs(args) {
|
|
|
1936
2150
|
break;
|
|
1937
2151
|
// Namespace preservation option (comma-separated list)
|
|
1938
2152
|
case "--preserve-ns": {
|
|
2153
|
+
if (!argValue && i + 1 >= args.length) {
|
|
2154
|
+
logError(
|
|
2155
|
+
"--preserve-ns requires a comma-separated list of namespaces",
|
|
2156
|
+
);
|
|
2157
|
+
process.exit(CONSTANTS.EXIT_ERROR);
|
|
2158
|
+
}
|
|
1939
2159
|
const namespaces = argValue || args[++i];
|
|
1940
2160
|
if (!namespaces) {
|
|
1941
2161
|
logError(
|
|
@@ -1947,6 +2167,13 @@ function parseArgs(args) {
|
|
|
1947
2167
|
.split(",")
|
|
1948
2168
|
.map((ns) => ns.trim().toLowerCase())
|
|
1949
2169
|
.filter((ns) => ns.length > 0);
|
|
2170
|
+
// Why: Reject empty array after filtering whitespace-only entries
|
|
2171
|
+
if (cfg.preserveNamespaces.length === 0) {
|
|
2172
|
+
logError(
|
|
2173
|
+
"--preserve-ns list is empty after filtering whitespace",
|
|
2174
|
+
);
|
|
2175
|
+
process.exit(CONSTANTS.EXIT_ERROR);
|
|
2176
|
+
}
|
|
1950
2177
|
break;
|
|
1951
2178
|
}
|
|
1952
2179
|
case "--svg2-polyfills":
|
|
@@ -1958,6 +2185,10 @@ function parseArgs(args) {
|
|
|
1958
2185
|
break;
|
|
1959
2186
|
// E2E verification precision options
|
|
1960
2187
|
case "--clip-segments": {
|
|
2188
|
+
if (i + 1 >= args.length) {
|
|
2189
|
+
logError("--clip-segments requires a value");
|
|
2190
|
+
process.exit(CONSTANTS.EXIT_ERROR);
|
|
2191
|
+
}
|
|
1961
2192
|
const segs = parseInt(args[++i], 10);
|
|
1962
2193
|
if (isNaN(segs) || segs < 8 || segs > 512) {
|
|
1963
2194
|
logError("clip-segments must be between 8 and 512");
|
|
@@ -1967,15 +2198,28 @@ function parseArgs(args) {
|
|
|
1967
2198
|
break;
|
|
1968
2199
|
}
|
|
1969
2200
|
case "--bezier-arcs": {
|
|
2201
|
+
if (i + 1 >= args.length) {
|
|
2202
|
+
logError("--bezier-arcs requires a value");
|
|
2203
|
+
process.exit(CONSTANTS.EXIT_ERROR);
|
|
2204
|
+
}
|
|
1970
2205
|
const arcs = parseInt(args[++i], 10);
|
|
1971
2206
|
if (isNaN(arcs) || arcs < 4 || arcs > 128) {
|
|
1972
2207
|
logError("bezier-arcs must be between 4 and 128");
|
|
1973
2208
|
process.exit(CONSTANTS.EXIT_ERROR);
|
|
1974
2209
|
}
|
|
2210
|
+
// Why: bezier-arcs must be multiple of 4 for proper arc approximation
|
|
2211
|
+
if (arcs % 4 !== 0) {
|
|
2212
|
+
logError("bezier-arcs must be a multiple of 4");
|
|
2213
|
+
process.exit(CONSTANTS.EXIT_ERROR);
|
|
2214
|
+
}
|
|
1975
2215
|
cfg.bezierArcs = arcs;
|
|
1976
2216
|
break;
|
|
1977
2217
|
}
|
|
1978
2218
|
case "--e2e-tolerance": {
|
|
2219
|
+
if (i + 1 >= args.length) {
|
|
2220
|
+
logError("--e2e-tolerance requires a value");
|
|
2221
|
+
process.exit(CONSTANTS.EXIT_ERROR);
|
|
2222
|
+
}
|
|
1979
2223
|
const tol = args[++i];
|
|
1980
2224
|
if (!/^1e-\d+$/.test(tol)) {
|
|
1981
2225
|
logError("e2e-tolerance must be in format 1e-N (e.g., 1e-10, 1e-12)");
|
|
@@ -2044,6 +2288,11 @@ function gatherInputFiles() {
|
|
|
2044
2288
|
* @returns {string} Output file path
|
|
2045
2289
|
*/
|
|
2046
2290
|
function getOutputPath(inputPath) {
|
|
2291
|
+
// Why: Validate parameter to prevent crashes
|
|
2292
|
+
if (!inputPath || typeof inputPath !== "string") {
|
|
2293
|
+
throw new Error("Invalid input path: must be a non-empty string");
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2047
2296
|
if (!config.output) {
|
|
2048
2297
|
const dir = dirname(inputPath);
|
|
2049
2298
|
const base = basename(inputPath, ".svg");
|