@emasoft/svg-matrix 1.0.26 → 1.0.28

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.
@@ -874,6 +874,49 @@ export const removeUnknownsAndDefaults = createOperation(
874
874
  css.includes(':not(');
875
875
  });
876
876
 
877
+ // Map lowercase SVG tag names to their canonical mixed-case forms
878
+ // SVG is case-sensitive but XML parsers may lowercase tag names
879
+ const tagNameMap = {
880
+ 'clippath': 'clipPath',
881
+ 'textpath': 'textPath',
882
+ 'lineargradient': 'linearGradient',
883
+ 'radialgradient': 'radialGradient',
884
+ 'meshgradient': 'meshGradient',
885
+ 'hatchpath': 'hatchPath',
886
+ 'solidcolor': 'solidColor',
887
+ 'foreignobject': 'foreignObject',
888
+ 'feblend': 'feBlend',
889
+ 'fecolormatrix': 'feColorMatrix',
890
+ 'fecomponenttransfer': 'feComponentTransfer',
891
+ 'fecomposite': 'feComposite',
892
+ 'feconvolvematrix': 'feConvolveMatrix',
893
+ 'fediffuselighting': 'feDiffuseLighting',
894
+ 'fedisplacementmap': 'feDisplacementMap',
895
+ 'fedistantlight': 'feDistantLight',
896
+ 'fedropshadow': 'feDropShadow',
897
+ 'feflood': 'feFlood',
898
+ 'fefunca': 'feFuncA',
899
+ 'fefuncb': 'feFuncB',
900
+ 'fefuncg': 'feFuncG',
901
+ 'fefuncr': 'feFuncR',
902
+ 'fegaussianblur': 'feGaussianBlur',
903
+ 'feimage': 'feImage',
904
+ 'femerge': 'feMerge',
905
+ 'femergenode': 'feMergeNode',
906
+ 'femorphology': 'feMorphology',
907
+ 'feoffset': 'feOffset',
908
+ 'fepointlight': 'fePointLight',
909
+ 'fespecularlighting': 'feSpecularLighting',
910
+ 'fespotlight': 'feSpotLight',
911
+ 'fetile': 'feTile',
912
+ 'feturbulence': 'feTurbulence',
913
+ 'animatemotion': 'animateMotion',
914
+ 'animatetransform': 'animateTransform',
915
+ };
916
+ const canonicalTagName = (tag) => tagNameMap[tag] || tag;
917
+
918
+ // Known SVG elements - include both mixed-case and lowercase variants
919
+ // because XML parsers may lowercase tag names
877
920
  const knownElements = [
878
921
  "svg",
879
922
  "g",
@@ -887,13 +930,22 @@ export const removeUnknownsAndDefaults = createOperation(
887
930
  "text",
888
931
  "tspan",
889
932
  "tref",
890
- "textPath",
933
+ "textPath", "textpath",
891
934
  "defs",
892
- "clipPath",
935
+ "clipPath", "clippath",
893
936
  "mask",
894
937
  "pattern",
895
- "linearGradient",
896
- "radialGradient",
938
+ "linearGradient", "lineargradient",
939
+ "radialGradient", "radialgradient",
940
+ // SVG 2.0 gradient elements (mesh gradients)
941
+ "meshGradient", "meshgradient",
942
+ "meshrow",
943
+ "meshpatch",
944
+ // SVG 2.0 hatch elements
945
+ "hatch",
946
+ "hatchpath", "hatchPath",
947
+ // SVG 2.0 solid color
948
+ "solidcolor", "solidColor",
897
949
  "stop",
898
950
  "image",
899
951
  "use",
@@ -902,38 +954,38 @@ export const removeUnknownsAndDefaults = createOperation(
902
954
  "title",
903
955
  "desc",
904
956
  "metadata",
905
- "foreignObject",
957
+ "foreignObject", "foreignobject",
906
958
  "switch",
907
959
  "a",
908
960
  "filter",
909
- "feBlend",
910
- "feColorMatrix",
911
- "feComponentTransfer",
912
- "feComposite",
913
- "feConvolveMatrix",
914
- "feDiffuseLighting",
915
- "feDisplacementMap",
916
- "feDistantLight",
917
- "feDropShadow",
918
- "feFlood",
919
- "feFuncA",
920
- "feFuncB",
921
- "feFuncG",
922
- "feFuncR",
923
- "feGaussianBlur",
924
- "feImage",
925
- "feMerge",
926
- "feMergeNode",
927
- "feMorphology",
928
- "feOffset",
929
- "fePointLight",
930
- "feSpecularLighting",
931
- "feSpotLight",
932
- "feTile",
933
- "feTurbulence",
961
+ "feBlend", "feblend",
962
+ "feColorMatrix", "fecolormatrix",
963
+ "feComponentTransfer", "fecomponenttransfer",
964
+ "feComposite", "fecomposite",
965
+ "feConvolveMatrix", "feconvolvematrix",
966
+ "feDiffuseLighting", "fediffuselighting",
967
+ "feDisplacementMap", "fedisplacementmap",
968
+ "feDistantLight", "fedistantlight",
969
+ "feDropShadow", "fedropshadow",
970
+ "feFlood", "feflood",
971
+ "feFuncA", "fefunca",
972
+ "feFuncB", "fefuncb",
973
+ "feFuncG", "fefuncg",
974
+ "feFuncR", "fefuncr",
975
+ "feGaussianBlur", "fegaussianblur",
976
+ "feImage", "feimage",
977
+ "feMerge", "femerge",
978
+ "feMergeNode", "femergenode",
979
+ "feMorphology", "femorphology",
980
+ "feOffset", "feoffset",
981
+ "fePointLight", "fepointlight",
982
+ "feSpecularLighting", "fespecularlighting",
983
+ "feSpotLight", "fespotlight",
984
+ "feTile", "fetile",
985
+ "feTurbulence", "feturbulence",
934
986
  "animate",
935
- "animateMotion",
936
- "animateTransform",
987
+ "animateMotion", "animatemotion",
988
+ "animateTransform", "animatetransform",
937
989
  "set",
938
990
  "mpath",
939
991
  "view",
@@ -1123,7 +1175,9 @@ export const removeUnknownsAndDefaults = createOperation(
1123
1175
  // Remove unknown children (but protect known ones)
1124
1176
  // P4-1: Also validate parent-child relationships per SVG spec
1125
1177
  const parentTagLower = el.tagName.toLowerCase();
1126
- const allowedChildren = allowedChildrenPerElement[parentTagLower];
1178
+ // Try lowercase key first, then canonical mixed-case (case-insensitive lookup)
1179
+ const allowedChildren = allowedChildrenPerElement[parentTagLower] ||
1180
+ allowedChildrenPerElement[canonicalTagName(parentTagLower)];
1127
1181
 
1128
1182
  for (const child of [...el.children]) {
1129
1183
  if (isElement(child)) {
@@ -1142,7 +1196,10 @@ export const removeUnknownsAndDefaults = createOperation(
1142
1196
 
1143
1197
  // P4-1: Check if child is allowed for this parent
1144
1198
  // Only validate if we have rules for this parent
1145
- if (allowedChildren && !allowedChildren.has(childTagLower)) {
1199
+ // Use case-insensitive check: try both lowercase and canonical form
1200
+ if (allowedChildren &&
1201
+ !allowedChildren.has(childTagLower) &&
1202
+ !allowedChildren.has(canonicalTagName(childTagLower))) {
1146
1203
  // Invalid child for this parent - remove it
1147
1204
  el.removeChild(child);
1148
1205
  continue;
@@ -1323,12 +1380,42 @@ export const removeHiddenElements = createOperation((doc, options = {}) => {
1323
1380
 
1324
1381
  /**
1325
1382
  * Remove empty text elements
1383
+ * Recursively checks for text content in child elements (tspan, etc.)
1384
+ * Preserves text elements with namespace attributes when preserveNamespaces is set
1326
1385
  */
1327
1386
  export const removeEmptyText = createOperation((doc, options = {}) => {
1328
1387
  const textElements = doc.getElementsByTagName("text");
1329
1388
 
1389
+ // Helper to get all text content recursively from element and children
1390
+ const getTextContentRecursive = (el) => {
1391
+ let text = el.textContent || '';
1392
+ if (el.children) {
1393
+ for (const child of el.children) {
1394
+ text += getTextContentRecursive(child);
1395
+ }
1396
+ }
1397
+ return text;
1398
+ };
1399
+
1400
+ // Helper to check if element has preserved namespace attributes
1401
+ const hasPreservedNsAttrs = (el, preserveNs) => {
1402
+ if (!preserveNs || preserveNs.length === 0) return false;
1403
+ const attrNames = el.getAttributeNames ? el.getAttributeNames() : [];
1404
+ return attrNames.some(attr =>
1405
+ preserveNs.some(ns => attr.startsWith(ns + ':'))
1406
+ );
1407
+ };
1408
+
1409
+ const preserveNamespaces = options.preserveNamespaces || [];
1410
+
1330
1411
  for (const text of [...textElements]) {
1331
- if (!text.textContent || text.textContent.trim() === "") {
1412
+ const recursiveContent = getTextContentRecursive(text);
1413
+ const isEmpty = !recursiveContent || recursiveContent.trim() === "";
1414
+
1415
+ // Don't remove if it has preserved namespace attributes (e.g., Inkscape tile metadata)
1416
+ const hasNsAttrs = hasPreservedNsAttrs(text, preserveNamespaces);
1417
+
1418
+ if (isEmpty && !hasNsAttrs) {
1332
1419
  if (text.parentNode) {
1333
1420
  text.parentNode.removeChild(text);
1334
1421
  }
@@ -1340,14 +1427,16 @@ export const removeEmptyText = createOperation((doc, options = {}) => {
1340
1427
 
1341
1428
  /**
1342
1429
  * Remove empty container elements
1430
+ * Note: Patterns with xlink:href/href inherit content and are NOT empty
1343
1431
  */
1344
1432
  export const removeEmptyContainers = createOperation((doc, options = {}) => {
1433
+ // Include both mixed-case and lowercase variants for case-insensitive matching
1345
1434
  const containers = [
1346
1435
  "g",
1347
1436
  "defs",
1348
1437
  "symbol",
1349
1438
  "marker",
1350
- "clipPath",
1439
+ "clipPath", "clippath",
1351
1440
  "mask",
1352
1441
  "pattern",
1353
1442
  ];
@@ -1358,8 +1447,21 @@ export const removeEmptyContainers = createOperation((doc, options = {}) => {
1358
1447
  if (isElement(child)) processElement(child);
1359
1448
  }
1360
1449
 
1361
- // Remove if container is empty
1362
- if (containers.includes(el.tagName) && el.children.length === 0) {
1450
+ const tagLower = el.tagName?.toLowerCase();
1451
+
1452
+ // Skip if not a container type
1453
+ if (!containers.includes(el.tagName) && !containers.includes(tagLower)) {
1454
+ return;
1455
+ }
1456
+
1457
+ // Don't remove if element has href/xlink:href (inherits content from reference)
1458
+ // This is common for patterns that reference other patterns
1459
+ if (el.getAttribute('href') || el.getAttribute('xlink:href')) {
1460
+ return;
1461
+ }
1462
+
1463
+ // Remove if container is truly empty (no children)
1464
+ if (el.children.length === 0) {
1363
1465
  if (el.parentNode) {
1364
1466
  el.parentNode.removeChild(el);
1365
1467
  }
@@ -1456,9 +1558,18 @@ export const removeDesc = createOperation((doc, options = {}) => {
1456
1558
  * @param {boolean} options.preserveVendor - If true, preserves all editor namespace elements and attributes
1457
1559
  */
1458
1560
  export const removeEditorsNSData = createOperation((doc, options = {}) => {
1459
- // Skip if preserveVendor is enabled - editor namespaces are vendor-specific
1460
- if (options.preserveVendor) return doc;
1561
+ // preserveVendor = true preserves ALL editor namespaces (backward compat)
1562
+ if (options.preserveVendor === true) return doc;
1563
+
1564
+ // preserveNamespaces = array of prefixes to preserve selectively
1565
+ const preserveNamespaces = options.preserveNamespaces || [];
1566
+
1567
+ // Handle namespace aliases: sodipodi and inkscape always go together
1568
+ const normalizedPreserve = new Set(preserveNamespaces);
1569
+ if (normalizedPreserve.has('sodipodi')) normalizedPreserve.add('inkscape');
1570
+ if (normalizedPreserve.has('inkscape')) normalizedPreserve.add('sodipodi');
1461
1571
 
1572
+ // Filter out preserved namespaces from the removal list
1462
1573
  const editorPrefixes = [
1463
1574
  "inkscape",
1464
1575
  "sodipodi",
@@ -1468,7 +1579,10 @@ export const removeEditorsNSData = createOperation((doc, options = {}) => {
1468
1579
  "serif",
1469
1580
  "vectornator",
1470
1581
  "figma",
1471
- ];
1582
+ ].filter(prefix => !normalizedPreserve.has(prefix));
1583
+
1584
+ // If all namespaces are preserved, exit early
1585
+ if (editorPrefixes.length === 0) return doc;
1472
1586
 
1473
1587
  // FIRST: Remove editor-specific elements (sodipodi:namedview, etc.)
1474
1588
  // This must happen BEFORE checking remaining prefixes to avoid SVGO bug #1530