@emasoft/svg-matrix 1.3.4 → 1.3.6

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.
@@ -5,7 +5,7 @@
5
5
  * Works in both Node.js and browser environments.
6
6
  *
7
7
  * @module svg-matrix-lib
8
- * @version 1.3.4
8
+ * @version 1.3.6
9
9
  * @license MIT
10
10
  *
11
11
  * @example Browser usage:
@@ -32,7 +32,7 @@ Decimal.set({ precision: 80 });
32
32
  /**
33
33
  * Library version
34
34
  */
35
- export const VERSION = "1.3.4";
35
+ export const VERSION = "1.3.6";
36
36
 
37
37
  // Export core classes
38
38
  export { Decimal, Matrix, Vector };
@@ -5,7 +5,7 @@
5
5
  * Provides 69+ operations for cleaning, optimizing, and transforming SVG files.
6
6
  *
7
7
  * @module svg-toolbox-lib
8
- * @version 1.3.4
8
+ * @version 1.3.6
9
9
  * @license MIT
10
10
  *
11
11
  * @example Browser usage:
@@ -34,7 +34,7 @@ import * as SVGToolboxModule from "./svg-toolbox.js";
34
34
  /**
35
35
  * Library version
36
36
  */
37
- export const VERSION = "1.3.4";
37
+ export const VERSION = "1.3.6";
38
38
 
39
39
  /**
40
40
  * Default export for browser global (window.SVGToolbox)
@@ -56,6 +56,14 @@ import {
56
56
  findElementsWithAttribute,
57
57
  } from "./svg-parser.js";
58
58
  import { flattenSVG } from "./flatten-pipeline.js";
59
+ import {
60
+ validateInkscapeDocument,
61
+ detectInkscapeDocument,
62
+ getPolyfillRequirements,
63
+ InkscapeValidationSeverity,
64
+ INKSCAPE_ATTRIBUTES,
65
+ SODIPODI_ATTRIBUTES,
66
+ } from "./inkscape-support.js";
59
67
  import {
60
68
  referencesProps as _referencesProps,
61
69
  inheritableAttrs,
@@ -266,6 +274,7 @@ const SVG11_ATTRIBUTES = new Set([
266
274
  "filter",
267
275
  "filterRes",
268
276
  "filterUnits",
277
+ "filterPrimitiveUnits",
269
278
  "flood-color",
270
279
  "flood-opacity",
271
280
  "font-family",
@@ -2845,6 +2854,175 @@ export const removeEmptyAttrs = createOperation((doc, _options = {}) => {
2845
2854
  return doc;
2846
2855
  });
2847
2856
 
2857
+ /**
2858
+ * Convert Inkscape SVG to plain/standard SVG.
2859
+ *
2860
+ * This function removes all Inkscape-specific and Sodipodi-specific content
2861
+ * from an SVG file, producing a clean SVG that conforms to SVG 1.1 or 2.0 specs.
2862
+ *
2863
+ * What gets removed:
2864
+ * - All sodipodi:* elements (namedview, guide)
2865
+ * - All inkscape:* elements (path-effect, perspective, page, grid, clipboard, box3dside)
2866
+ * - All sodipodi:* attributes (nodetypes, type, cx, cy, rx, ry, start, end, etc.)
2867
+ * - All inkscape:* attributes (groupmode, label, version, collect, etc.)
2868
+ * - Inkscape/Sodipodi namespace declarations (xmlns:inkscape, xmlns:sodipodi)
2869
+ * - SVG 1.2 flowText elements (flowRoot, flowPara, flowRegion, etc.) - Inkscape-specific
2870
+ *
2871
+ * What is preserved:
2872
+ * - All standard SVG elements and attributes
2873
+ * - Visual appearance (the SVG renders identically)
2874
+ * - IDs, classes, and CSS styles
2875
+ * - Standard namespaces (svg, xlink, xml)
2876
+ *
2877
+ * @param {Object} options - Conversion options
2878
+ * @param {boolean} [options.removeFlowText=true] - Remove SVG 1.2 flowText elements (not browser-supported)
2879
+ * @param {boolean} [options.removeEmptyDefs=true] - Remove empty <defs> elements after cleanup
2880
+ * @param {boolean} [options.removeEmptyGroups=false] - Remove groups that become empty after stripping
2881
+ * @returns {Object} The cleaned SVG document
2882
+ *
2883
+ * @example
2884
+ * // Convert Inkscape SVG to plain SVG
2885
+ * const plainSVG = convertToPlainSVG(doc);
2886
+ *
2887
+ * @example
2888
+ * // Keep flowText elements (for manual conversion later)
2889
+ * const plainSVG = convertToPlainSVG(doc, { removeFlowText: false });
2890
+ */
2891
+ export const convertToPlainSVG = createOperation((doc, options = {}) => {
2892
+ const {
2893
+ removeFlowText = true,
2894
+ removeEmptyDefs = true,
2895
+ removeEmptyGroups = false,
2896
+ } = options;
2897
+
2898
+ // Inkscape/Sodipodi namespace prefixes to remove
2899
+ const inkscapePrefixes = ["inkscape", "sodipodi"];
2900
+
2901
+ // SVG 1.2 flowText elements (Inkscape-specific, not browser-supported)
2902
+ const flowTextElements = [
2903
+ "flowRoot",
2904
+ "flowPara",
2905
+ "flowRegion",
2906
+ "flowSpan",
2907
+ "flowDiv",
2908
+ "flowLine",
2909
+ ];
2910
+
2911
+ // STEP 1: Remove all Inkscape/Sodipodi namespaced elements
2912
+ // Why: These elements are Inkscape UI data and not part of standard SVG
2913
+ // Use [...el.children] to create a copy of the array before iterating (avoids live collection issues)
2914
+ const removeInkscapeElements = (el) => {
2915
+ for (const child of [...el.children]) {
2916
+ if (isElement(child)) {
2917
+ const tagColonIdx = child.tagName.indexOf(":");
2918
+ if (tagColonIdx > 0) {
2919
+ const prefix = child.tagName.substring(0, tagColonIdx);
2920
+ if (inkscapePrefixes.includes(prefix)) {
2921
+ el.removeChild(child);
2922
+ continue;
2923
+ }
2924
+ }
2925
+ // Recurse into non-inkscape children
2926
+ removeInkscapeElements(child);
2927
+ }
2928
+ }
2929
+ };
2930
+ removeInkscapeElements(doc);
2931
+
2932
+ // STEP 2: Remove SVG 1.2 flowText elements if requested
2933
+ // Why: flowText is from SVG 1.2 draft, only Inkscape supports it, no browsers do
2934
+ if (removeFlowText) {
2935
+ const removeFlowTextElements = (el) => {
2936
+ for (const child of [...el.children]) {
2937
+ if (isElement(child)) {
2938
+ if (flowTextElements.includes(child.tagName)) {
2939
+ el.removeChild(child);
2940
+ continue;
2941
+ }
2942
+ removeFlowTextElements(child);
2943
+ }
2944
+ }
2945
+ };
2946
+ removeFlowTextElements(doc);
2947
+ }
2948
+
2949
+ // STEP 3: Remove all Inkscape/Sodipodi namespaced attributes
2950
+ // Why: These attributes store Inkscape editor state, not visual data
2951
+ const removeInkscapeAttributes = (el) => {
2952
+ for (const attrName of [...el.getAttributeNames()]) {
2953
+ // Skip namespace declarations (handled in step 4)
2954
+ if (attrName.startsWith("xmlns:")) continue;
2955
+
2956
+ const colonIdx = attrName.indexOf(":");
2957
+ if (colonIdx > 0) {
2958
+ const prefix = attrName.substring(0, colonIdx);
2959
+ if (inkscapePrefixes.includes(prefix)) {
2960
+ el.removeAttribute(attrName);
2961
+ }
2962
+ }
2963
+ }
2964
+
2965
+ // Recurse into children
2966
+ for (const child of el.children) {
2967
+ if (isElement(child)) removeInkscapeAttributes(child);
2968
+ }
2969
+ };
2970
+ removeInkscapeAttributes(doc);
2971
+
2972
+ // STEP 4: Remove Inkscape/Sodipodi namespace declarations from root SVG
2973
+ // Why: After removing all prefixed elements/attributes, declarations are orphaned
2974
+ for (const prefix of inkscapePrefixes) {
2975
+ doc.removeAttribute(`xmlns:${prefix}`);
2976
+ }
2977
+
2978
+ // STEP 5: Remove empty <defs> elements if requested
2979
+ // Why: Inkscape often puts items in defs that are now removed
2980
+ if (removeEmptyDefs) {
2981
+ const removeEmptyDefsElements = (el) => {
2982
+ for (const child of [...el.children]) {
2983
+ if (isElement(child)) {
2984
+ if (child.tagName === "defs" && child.children.length === 0) {
2985
+ el.removeChild(child);
2986
+ } else {
2987
+ removeEmptyDefsElements(child);
2988
+ }
2989
+ }
2990
+ }
2991
+ };
2992
+ removeEmptyDefsElements(doc);
2993
+ }
2994
+
2995
+ // STEP 6: Remove empty groups if requested
2996
+ // Why: Groups that only contained Inkscape elements may now be empty
2997
+ if (removeEmptyGroups) {
2998
+ const removeEmptyGroupElements = (el) => {
2999
+ let changed = true;
3000
+ // Iterate until no more changes (handles nested empty groups)
3001
+ while (changed) {
3002
+ changed = false;
3003
+ for (const child of [...el.children]) {
3004
+ if (isElement(child)) {
3005
+ if (child.tagName === "g" && child.children.length === 0) {
3006
+ // Only remove if no attributes that might matter (like id for references)
3007
+ const hasId = child.hasAttribute("id");
3008
+ const hasClass = child.hasAttribute("class");
3009
+ if (!hasId && !hasClass) {
3010
+ el.removeChild(child);
3011
+ changed = true;
3012
+ }
3013
+ } else {
3014
+ removeEmptyGroupElements(child);
3015
+ }
3016
+ }
3017
+ }
3018
+ }
3019
+ };
3020
+ removeEmptyGroupElements(doc);
3021
+ }
3022
+
3023
+ return doc;
3024
+ });
3025
+
2848
3026
  /**
2849
3027
  * Remove viewBox if matches dimensions
2850
3028
  */
@@ -13805,6 +13983,28 @@ export const fixInvalidSVG = createOperation((doc, options = {}) => {
13805
13983
  reason: `Element <${tagName}> is missing required attribute '${attr}' (or 'xlink:href')`,
13806
13984
  });
13807
13985
  }
13986
+ } else if (tagName === "stop" && attr === "offset") {
13987
+ // SVG 2 CR 2016 §13.8: Mesh gradient stops use 'path' attribute, NOT 'offset'
13988
+ // Reference: https://www.w3.org/TR/2016/CR-SVG2-20160915/pservers.html
13989
+ // Linear/radial gradient stops require 'offset', mesh stops use 'path' instead
13990
+ let parent = el.parentNode;
13991
+ let isMeshStop = false;
13992
+ while (parent && parent.tagName) {
13993
+ const parentTag = parent.tagName.toLowerCase();
13994
+ if (parentTag === "meshpatch" || parentTag === "meshgradient") {
13995
+ isMeshStop = true;
13996
+ break;
13997
+ }
13998
+ parent = parent.parentNode;
13999
+ }
14000
+ if (!isMeshStop && !el.hasAttribute(attr)) {
14001
+ fixes.push({
14002
+ type: "missing_required_attribute",
14003
+ element: tagName,
14004
+ attr: attr,
14005
+ reason: `Element <${tagName}> is missing required attribute '${attr}'`,
14006
+ });
14007
+ }
13808
14008
  } else {
13809
14009
  if (!el.hasAttribute(attr)) {
13810
14010
  fixes.push({
@@ -14171,6 +14371,7 @@ export const fixInvalidSVG = createOperation((doc, options = {}) => {
14171
14371
  maskUnits: ["userSpaceOnUse", "objectBoundingBox"],
14172
14372
  maskContentUnits: ["userSpaceOnUse", "objectBoundingBox"],
14173
14373
  filterUnits: ["userSpaceOnUse", "objectBoundingBox"],
14374
+ filterPrimitiveUnits: ["userSpaceOnUse", "objectBoundingBox"],
14174
14375
  primitiveUnits: ["userSpaceOnUse", "objectBoundingBox"],
14175
14376
  markerUnits: ["strokeWidth", "userSpaceOnUse"],
14176
14377
  // Other
@@ -15392,6 +15593,8 @@ export async function validateSVGAsync(input, options = {}) {
15392
15593
  const outputFile = options.outputFile || null;
15393
15594
  const outputFormat = (options.outputFormat || "json").toLowerCase();
15394
15595
  const includeSource = options.includeSource === true;
15596
+ const validateInkscape = options.validateInkscape === true;
15597
+ const inkscapeStrict = options.inkscapeStrict === true;
15395
15598
 
15396
15599
  // Validate outputFormat
15397
15600
  const validFormats = ["text", "json", "xml", "yaml"];
@@ -16143,11 +16346,20 @@ export async function validateSVGAsync(input, options = {}) {
16143
16346
  // - title/desc (text content for accessibility - not SVG structure)
16144
16347
  // - metadata (can contain arbitrary XML metadata like Dublin Core, RDF)
16145
16348
  // - Namespaced elements (e.g., d:testDescription, rdf:RDF)
16349
+ // - Elements in non-SVG namespaces (e.g., <foo xmlns="http://example.org/foo">)
16146
16350
  const isNamespacedElement = tagName.includes(":");
16351
+ const isNonSvgNamespace =
16352
+ el.namespaceURI && el.namespaceURI !== "http://www.w3.org/2000/svg";
16353
+ // Check for explicit xmlns attribute declaring a non-SVG namespace
16354
+ const xmlns = el.getAttribute ? el.getAttribute("xmlns") : null;
16355
+ const hasNonSvgXmlns =
16356
+ xmlns && xmlns !== "http://www.w3.org/2000/svg";
16147
16357
  const inNonSvgContext =
16148
16358
  insideNonSvgContext ||
16149
16359
  NON_SVG_CONTEXT_ELEMENTS.has(tagLower) ||
16150
- isNamespacedElement;
16360
+ isNamespacedElement ||
16361
+ isNonSvgNamespace ||
16362
+ hasNonSvgXmlns;
16151
16363
 
16152
16364
  // Skip validation entirely if we're in non-SVG context
16153
16365
  if (!inNonSvgContext) {
@@ -16206,11 +16418,20 @@ export async function validateSVGAsync(input, options = {}) {
16206
16418
  // - title/desc (text content elements)
16207
16419
  // - metadata (arbitrary XML metadata content)
16208
16420
  // - Namespaced elements (e.g., d:testDescription, rdf:RDF)
16421
+ // - Elements in non-SVG namespaces (e.g., <foo xmlns="http://example.org/foo">)
16209
16422
  const isNamespacedElement = el.tagName.includes(":");
16423
+ const isNonSvgNamespace =
16424
+ el.namespaceURI && el.namespaceURI !== "http://www.w3.org/2000/svg";
16425
+ // Check for explicit xmlns attribute declaring a non-SVG namespace
16426
+ const xmlns = el.getAttribute ? el.getAttribute("xmlns") : null;
16427
+ const hasNonSvgXmlns =
16428
+ xmlns && xmlns !== "http://www.w3.org/2000/svg";
16210
16429
  const inNonSvgContext =
16211
16430
  insideNonSvgContext ||
16212
16431
  NON_SVG_CONTEXT_ELEMENTS.has(tagName) ||
16213
- isNamespacedElement;
16432
+ isNamespacedElement ||
16433
+ isNonSvgNamespace ||
16434
+ hasNonSvgXmlns;
16214
16435
 
16215
16436
  // Skip attribute validation entirely if we're in non-SVG context
16216
16437
  if (!inNonSvgContext) {
@@ -16336,6 +16557,26 @@ export async function validateSVGAsync(input, options = {}) {
16336
16557
  }
16337
16558
  } else {
16338
16559
  for (const attr of required) {
16560
+ // SVG 2 CR 2016 §13.8: Mesh gradient stops use 'path' attribute, NOT 'offset'
16561
+ // Reference: https://www.w3.org/TR/2016/CR-SVG2-20160915/pservers.html
16562
+ // Quote: "offset - does not apply to mesh gradients"
16563
+ // "path - applies only to mesh gradients"
16564
+ // The 'path' attribute contains a single c/C/l/L bezier command defining one edge
16565
+ // of the Coons patch. Linear/radial gradient stops still require 'offset'.
16566
+ if (tagName === "stop" && attr === "offset") {
16567
+ // Check if this stop is inside a mesh gradient structure by walking up the tree
16568
+ let parent = el.parentNode;
16569
+ let isMeshStop = false;
16570
+ while (parent && parent.tagName) {
16571
+ const parentTag = parent.tagName.toLowerCase();
16572
+ if (parentTag === "meshpatch" || parentTag === "meshgradient") {
16573
+ isMeshStop = true;
16574
+ break;
16575
+ }
16576
+ parent = parent.parentNode;
16577
+ }
16578
+ if (isMeshStop) continue; // Skip offset requirement - mesh stops use 'path' instead
16579
+ }
16339
16580
  if (!el.hasAttribute(attr)) {
16340
16581
  issues.push(
16341
16582
  createIssue(
@@ -16446,6 +16687,7 @@ export async function validateSVGAsync(input, options = {}) {
16446
16687
  maskUnits: new Set(["userSpaceOnUse", "objectBoundingBox"]),
16447
16688
  maskContentUnits: new Set(["userSpaceOnUse", "objectBoundingBox"]),
16448
16689
  filterUnits: new Set(["userSpaceOnUse", "objectBoundingBox"]),
16690
+ filterPrimitiveUnits: new Set(["userSpaceOnUse", "objectBoundingBox"]),
16449
16691
  primitiveUnits: new Set(["userSpaceOnUse", "objectBoundingBox"]),
16450
16692
  patternUnits: new Set(["userSpaceOnUse", "objectBoundingBox"]),
16451
16693
  patternContentUnits: new Set(["userSpaceOnUse", "objectBoundingBox"]),
@@ -17617,6 +17859,37 @@ export async function validateSVGAsync(input, options = {}) {
17617
17859
  detectEventHandlers(doc);
17618
17860
  detectAccessibilityIssues();
17619
17861
 
17862
+ // Inkscape namespace validation (if enabled)
17863
+ // Always use strict mode - a file either conforms to the spec or it doesn't
17864
+ let inkscapeValidation = null;
17865
+ if (validateInkscape && doc) {
17866
+ inkscapeValidation = validateInkscapeDocument(doc, {
17867
+ strict: true, // Always strict - unknown attributes are errors
17868
+ warnFlowText: true,
17869
+ checkPolyfillNeeds: true,
17870
+ });
17871
+ // Add Inkscape validation issues to the main issues array
17872
+ for (const inkIssue of inkscapeValidation.issues) {
17873
+ // Map Inkscape severity to ValidationSeverity
17874
+ let severity = ValidationSeverity.INFO;
17875
+ if (inkIssue.severity === InkscapeValidationSeverity.ERROR) {
17876
+ severity = ValidationSeverity.ERROR;
17877
+ } else if (inkIssue.severity === InkscapeValidationSeverity.WARNING) {
17878
+ severity = ValidationSeverity.WARNING;
17879
+ }
17880
+ issues.push({
17881
+ type: `inkscape-${inkIssue.type}`,
17882
+ reason: inkIssue.message,
17883
+ severity,
17884
+ line: inkIssue.line || 1,
17885
+ column: inkIssue.column || 1,
17886
+ element: inkIssue.element || null,
17887
+ attribute: inkIssue.attribute || null,
17888
+ context: inkIssue.context || null,
17889
+ });
17890
+ }
17891
+ }
17892
+
17620
17893
  // Sort issues by line, then by column (for consistent, predictable output)
17621
17894
  issues.sort((a, b) => {
17622
17895
  if (a.line !== b.line) return a.line - b.line;
@@ -17657,6 +17930,19 @@ export async function validateSVGAsync(input, options = {}) {
17657
17930
  summary,
17658
17931
  };
17659
17932
 
17933
+ // Add Inkscape-specific info if validation was performed
17934
+ if (inkscapeValidation) {
17935
+ result.inkscape = {
17936
+ isInkscapeDocument: inkscapeValidation.isInkscape,
17937
+ inkscapeVersion: inkscapeValidation.version || null,
17938
+ polyfillsNeeded: inkscapeValidation.polyfillsNeeded || [],
17939
+ hasFlowText: inkscapeValidation.hasFlowText || false,
17940
+ inkscapeIssueCount: inkscapeValidation.issues.length,
17941
+ inkscapeErrorCount: inkscapeValidation.summary.errors,
17942
+ inkscapeWarningCount: inkscapeValidation.summary.warnings,
17943
+ };
17944
+ }
17945
+
17660
17946
  // Export to file if requested
17661
17947
  if (outputFile) {
17662
17948
  try {
@@ -20349,7 +20635,7 @@ export default {
20349
20635
  removeEmptyText,
20350
20636
  removeEmptyContainers,
20351
20637
 
20352
- // Category 2: Removal (12)
20638
+ // Category 2: Removal (13)
20353
20639
  removeDoctype,
20354
20640
  removeXMLProcInst,
20355
20641
  removeComments,
@@ -20362,6 +20648,7 @@ export default {
20362
20648
  removeXMLNS,
20363
20649
  removeRasterImages,
20364
20650
  removeScriptElement,
20651
+ convertToPlainSVG, // Converts Inkscape SVG to plain/standard SVG
20365
20652
 
20366
20653
  // Category 3: Conversion (10)
20367
20654
  convertShapesToPath,
@@ -151,6 +151,7 @@ export const SVG11_ATTRIBUTES = new Set([
151
151
  "filter",
152
152
  "filterres",
153
153
  "filterunits",
154
+ "filterprimitiveunits",
154
155
  "flood-color",
155
156
  "flood-opacity",
156
157
  "font-family",
package/src/svgm-lib.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * comprehensive SVG manipulation (SVGToolbox). Works in Node.js and browser.
6
6
  *
7
7
  * @module svgm-lib
8
- * @version 1.3.4
8
+ * @version 1.3.6
9
9
  * @license MIT
10
10
  *
11
11
  * @example Browser usage:
@@ -49,7 +49,7 @@ Decimal.set({ precision: 80 });
49
49
  /**
50
50
  * Library version
51
51
  */
52
- export const VERSION = "1.3.4";
52
+ export const VERSION = "1.3.6";
53
53
 
54
54
  // Export math classes
55
55
  export { Decimal, Matrix, Vector };