@emasoft/svg-matrix 1.0.26 → 1.0.27

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/README.md CHANGED
@@ -255,6 +255,36 @@ svgm --show-plugins
255
255
 
256
256
  Run `svgm --help` for all options.
257
257
 
258
+ ### Namespace Preservation
259
+
260
+ Preserve vendor-specific namespaces during optimization:
261
+
262
+ ```bash
263
+ # Preserve Inkscape/Sodipodi namespaces (layers, guides, document settings)
264
+ svgm --preserve-ns inkscape input.svg -o output.svg
265
+
266
+ # Preserve multiple vendor namespaces
267
+ svgm --preserve-ns inkscape,illustrator input.svg -o output.svg
268
+
269
+ # Available namespaces: inkscape, sodipodi, illustrator, sketch, ai, serif, vectornator, figma
270
+ ```
271
+
272
+ ### SVG 2.0 Polyfills
273
+
274
+ Enable browser compatibility for SVG 2.0 features:
275
+
276
+ ```bash
277
+ # Add polyfills for mesh gradients and hatches
278
+ svgm --svg2-polyfills input.svg -o output.svg
279
+
280
+ # Combine with namespace preservation
281
+ svgm --preserve-ns inkscape --svg2-polyfills input.svg -o output.svg
282
+ ```
283
+
284
+ Supported SVG 2.0 features:
285
+ - **Mesh gradients** (`<meshGradient>`) - Rendered via canvas fallback
286
+ - **Hatches** (`<hatch>`) - Converted to SVG 1.1 patterns
287
+
258
288
  ---
259
289
 
260
290
  ### `svg-matrix` - Advanced SVG Processing
@@ -360,6 +390,33 @@ const fixed = await fixInvalidSvg('broken.svg');
360
390
  console.log(fixed.svg); // Fixed SVG string
361
391
  ```
362
392
 
393
+ ### Inkscape Support Module
394
+
395
+ ```javascript
396
+ import { InkscapeSupport } from '@emasoft/svg-matrix';
397
+
398
+ // Check if element is an Inkscape layer
399
+ InkscapeSupport.isInkscapeLayer(element);
400
+
401
+ // Find all layers in document
402
+ const layers = InkscapeSupport.findLayers(doc);
403
+
404
+ // Get document settings from sodipodi:namedview
405
+ const settings = InkscapeSupport.getNamedViewSettings(doc);
406
+ ```
407
+
408
+ ### SVG 2.0 Polyfills Module
409
+
410
+ ```javascript
411
+ import { SVG2Polyfills } from '@emasoft/svg-matrix';
412
+
413
+ // Detect SVG 2.0 features in document
414
+ const features = SVG2Polyfills.detectSVG2Features(doc);
415
+
416
+ // Inject polyfills into document
417
+ SVG2Polyfills.injectPolyfills(doc);
418
+ ```
419
+
363
420
  ### Exclusive Features (Not in SVGO)
364
421
 
365
422
  | Function | Description |
package/bin/svg-matrix.js CHANGED
@@ -91,6 +91,7 @@ let currentOutputFile = null; // Track for cleanup on interrupt
91
91
  * @property {boolean} resolveMarkers - Expand marker references
92
92
  * @property {boolean} resolvePatterns - Expand pattern fills
93
93
  * @property {boolean} bakeGradients - Bake gradientTransform into coordinates
94
+ * @property {string[]} preserveNamespaces - Array of namespace prefixes to preserve
94
95
  */
95
96
 
96
97
  const DEFAULT_CONFIG = {
@@ -120,6 +121,7 @@ const DEFAULT_CONFIG = {
120
121
  bezierArcs: 8, // Bezier arcs for circles/ellipses (multiple of 4; 8=π/4 optimal)
121
122
  e2eTolerance: '1e-10', // E2E verification tolerance (tighter with more segments)
122
123
  preserveVendor: false, // If true, preserve vendor-prefixed properties and editor namespaces
124
+ preserveNamespaces: [], // Array of namespace prefixes to preserve (e.g., ['inkscape', 'sodipodi'])
123
125
  };
124
126
 
125
127
  /** @type {CLIConfig} */
@@ -603,6 +605,8 @@ ${boxLine(` ${colors.dim}--no-patterns${colors.reset} Skip pattern ex
603
605
  ${boxLine(` ${colors.dim}--no-gradients${colors.reset} Skip gradient transform baking`, W)}
604
606
  ${boxLine(` ${colors.dim}--preserve-vendor${colors.reset} Keep vendor prefixes and editor namespaces`, W)}
605
607
  ${boxLine(` ${colors.dim}(inkscape, sodipodi, -webkit-*, etc.)${colors.reset}`, W)}
608
+ ${boxLine(` ${colors.dim}--preserve-ns <list>${colors.reset} Preserve specific namespaces (comma-separated)`, W)}
609
+ ${boxLine(` ${colors.dim}Example: --preserve-ns inkscape,sodipodi${colors.reset}`, W)}
606
610
  ${boxLine('', W)}
607
611
  ${boxDivider(W)}
608
612
  ${boxLine('', W)}
@@ -628,6 +632,7 @@ ${boxLine('', W)}
628
632
  ${boxLine(` ${colors.green}svg-matrix flatten${colors.reset} input.svg -o output.svg`, W)}
629
633
  ${boxLine(` ${colors.green}svg-matrix flatten${colors.reset} ./svgs/ -o ./out/ --transform-only`, W)}
630
634
  ${boxLine(` ${colors.green}svg-matrix flatten${colors.reset} --list files.txt -o ./out/ --no-patterns`, W)}
635
+ ${boxLine(` ${colors.green}svg-matrix flatten${colors.reset} input.svg -o out.svg --preserve-ns inkscape,sodipodi`, W)}
631
636
  ${boxLine(` ${colors.green}svg-matrix convert${colors.reset} input.svg -o output.svg -p 10`, W)}
632
637
  ${boxLine(` ${colors.green}svg-matrix info${colors.reset} input.svg`, W)}
633
638
  ${boxLine('', W)}
@@ -867,6 +872,7 @@ function processFlatten(inputPath, outputPath) {
867
872
  flattenTransforms: true, // Always flatten transforms
868
873
  bakeGradients: config.bakeGradients,
869
874
  removeUnusedDefs: true,
875
+ preserveNamespaces: config.preserveNamespaces, // Pass namespace preservation to pipeline
870
876
  // NOTE: Verification is ALWAYS enabled - precision is non-negotiable
871
877
  };
872
878
 
@@ -1221,8 +1227,8 @@ async function testToolboxFunction(fnName, originalContent, originalSize, output
1221
1227
 
1222
1228
  const startTime = Date.now();
1223
1229
  const doc = parseSVG(originalContent);
1224
- // Pass preserveVendor option from config to toolbox functions
1225
- await fn(doc, { preserveVendor: config.preserveVendor });
1230
+ // Pass preserveVendor and preserveNamespaces options from config to toolbox functions
1231
+ await fn(doc, { preserveVendor: config.preserveVendor, preserveNamespaces: config.preserveNamespaces });
1226
1232
  const output = serializeSVG(doc);
1227
1233
  const outputSize = Buffer.byteLength(output);
1228
1234
  result.timeMs = Date.now() - startTime;
@@ -1385,6 +1391,16 @@ function parseArgs(args) {
1385
1391
  case '--no-gradients': cfg.bakeGradients = false; break;
1386
1392
  // Vendor preservation option
1387
1393
  case '--preserve-vendor': cfg.preserveVendor = true; break;
1394
+ // Namespace preservation option (comma-separated list)
1395
+ case '--preserve-ns': {
1396
+ const namespaces = args[++i];
1397
+ if (!namespaces) {
1398
+ logError('--preserve-ns requires a comma-separated list of namespaces');
1399
+ process.exit(CONSTANTS.EXIT_ERROR);
1400
+ }
1401
+ cfg.preserveNamespaces = namespaces.split(',').map(ns => ns.trim()).filter(ns => ns.length > 0);
1402
+ break;
1403
+ }
1388
1404
  // E2E verification precision options
1389
1405
  case '--clip-segments': {
1390
1406
  const segs = parseInt(args[++i], 10);
package/bin/svgm.js CHANGED
@@ -20,6 +20,7 @@ import { join, dirname, basename, extname, resolve, isAbsolute } from 'path';
20
20
  import { VERSION } from '../src/index.js';
21
21
  import * as SVGToolbox from '../src/svg-toolbox.js';
22
22
  import { parseSVG, serializeSVG } from '../src/svg-parser.js';
23
+ import { injectPolyfills, detectSVG2Features } from '../src/svg2-polyfills.js';
23
24
 
24
25
  // ============================================================================
25
26
  // CONSTANTS
@@ -62,6 +63,8 @@ const DEFAULT_CONFIG = {
62
63
  exclude: [],
63
64
  quiet: false,
64
65
  datauri: null,
66
+ preserveNamespaces: [],
67
+ svg2Polyfills: false,
65
68
  showPlugins: false,
66
69
  };
67
70
 
@@ -222,7 +225,7 @@ async function optimizeSvg(content, options = {}) {
222
225
  const fn = SVGToolbox[pluginName];
223
226
  if (fn && typeof fn === 'function') {
224
227
  try {
225
- await fn(doc, { precision: options.precision });
228
+ await fn(doc, { precision: options.precision, preserveNamespaces: options.preserveNamespaces });
226
229
  } catch (e) {
227
230
  // Skip failed optimizations silently
228
231
  }
@@ -235,7 +238,7 @@ async function optimizeSvg(content, options = {}) {
235
238
  const fn = SVGToolbox[pluginName];
236
239
  if (fn && typeof fn === 'function') {
237
240
  try {
238
- await fn(doc, { precision: options.precision });
241
+ await fn(doc, { precision: options.precision, preserveNamespaces: options.preserveNamespaces });
239
242
  } catch (e) {
240
243
  // Skip failed optimizations silently
241
244
  }
@@ -243,6 +246,14 @@ async function optimizeSvg(content, options = {}) {
243
246
  }
244
247
  }
245
248
 
249
+ // Inject SVG 2 polyfills if requested
250
+ if (options.svg2Polyfills) {
251
+ const features = detectSVG2Features(doc);
252
+ if (features.meshGradients.length > 0 || features.hatches.length > 0) {
253
+ injectPolyfills(doc);
254
+ }
255
+ }
256
+
246
257
  let result = serializeSVG(doc);
247
258
 
248
259
  // Pretty print if requested, otherwise minify (SVGO default behavior)
@@ -384,6 +395,10 @@ Options:
384
395
  regular expression pattern.
385
396
  -q, --quiet Only output error messages
386
397
  --show-plugins Show available plugins and exit
398
+ --preserve-ns <NS,...> Preserve vendor namespaces (inkscape, sodipodi,
399
+ illustrator, figma, etc.). Comma-separated.
400
+ --svg2-polyfills Inject JavaScript polyfills for SVG 2 features
401
+ (mesh gradients, hatches) for browser support
387
402
  --no-color Output plain text without color
388
403
  -h, --help Display help for command
389
404
 
@@ -517,6 +532,15 @@ function parseArgs(args) {
517
532
  cfg.showPlugins = true;
518
533
  break;
519
534
 
535
+ case '--preserve-ns':
536
+ i++;
537
+ cfg.preserveNamespaces = args[i].split(',').map(s => s.trim().toLowerCase());
538
+ break;
539
+
540
+ case '--svg2-polyfills':
541
+ cfg.svg2Polyfills = true;
542
+ break;
543
+
520
544
  case '--no-color':
521
545
  // Already handled in colors initialization
522
546
  break;
@@ -561,6 +585,8 @@ async function main() {
561
585
  eol: config.eol,
562
586
  finalNewline: config.finalNewline,
563
587
  datauri: config.datauri,
588
+ preserveNamespaces: config.preserveNamespaces,
589
+ svg2Polyfills: config.svg2Polyfills,
564
590
  };
565
591
 
566
592
  // Handle string input
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emasoft/svg-matrix",
3
- "version": "1.0.26",
3
+ "version": "1.0.27",
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",
package/src/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * SVG path conversion, and 2D/3D affine transformations using Decimal.js.
6
6
  *
7
7
  * @module @emasoft/svg-matrix
8
- * @version 1.0.26
8
+ * @version 1.0.27
9
9
  * @license MIT
10
10
  *
11
11
  * @example
@@ -46,6 +46,8 @@ import * as MeshGradient from './mesh-gradient.js';
46
46
  import * as SVGParser from './svg-parser.js';
47
47
  import * as FlattenPipeline from './flatten-pipeline.js';
48
48
  import * as Verification from './verification.js';
49
+ import * as InkscapeSupport from './inkscape-support.js';
50
+ import * as SVG2Polyfills from './svg2-polyfills.js';
49
51
  import { Logger, LogLevel, setLogLevel, getLogLevel as getLoggerLevel, enableFileLogging, disableFileLogging } from './logger.js';
50
52
 
51
53
  // SVGO-inspired precision modules
@@ -85,7 +87,7 @@ Decimal.set({ precision: 80 });
85
87
  * Library version
86
88
  * @constant {string}
87
89
  */
88
- export const VERSION = '1.0.26';
90
+ export const VERSION = '1.0.27';
89
91
 
90
92
  /**
91
93
  * Default precision for path output (decimal places)
@@ -120,6 +122,7 @@ export { ClipPathResolver, MaskResolver, PatternResolver };
120
122
  export { UseSymbolResolver, MarkerResolver };
121
123
  export { MeshGradient };
122
124
  export { SVGParser, FlattenPipeline, Verification };
125
+ export { InkscapeSupport, SVG2Polyfills };
123
126
 
124
127
  // ============================================================================
125
128
  // SVGO-INSPIRED PRECISION MODULES
@@ -577,6 +580,8 @@ export default {
577
580
  SVGParser,
578
581
  FlattenPipeline,
579
582
  Verification,
583
+ InkscapeSupport,
584
+ SVG2Polyfills,
580
585
 
581
586
  // Logging
582
587
  Logger,
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Inkscape/Sodipodi Support Module
3
+ *
4
+ * Provides utilities for preserving and manipulating Inkscape-specific SVG features
5
+ * including layers, guides, document settings, and arc parameters.
6
+ *
7
+ * @module inkscape-support
8
+ */
9
+
10
+ // Inkscape namespace URIs
11
+ export const INKSCAPE_NS = 'http://www.inkscape.org/namespaces/inkscape';
12
+ export const SODIPODI_NS = 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd';
13
+
14
+ // Inkscape-specific element and attribute prefixes
15
+ export const INKSCAPE_PREFIXES = ['inkscape', 'sodipodi'];
16
+
17
+ /**
18
+ * Check if an element is an Inkscape layer.
19
+ * Inkscape uses `<g inkscape:groupmode="layer">` for layers.
20
+ *
21
+ * @param {Object} element - SVG element to check
22
+ * @returns {boolean} True if the element is an Inkscape layer
23
+ */
24
+ export function isInkscapeLayer(element) {
25
+ if (!element || element.tagName !== 'g') return false;
26
+ return element.getAttribute('inkscape:groupmode') === 'layer';
27
+ }
28
+
29
+ /**
30
+ * Get the label of an Inkscape layer.
31
+ *
32
+ * @param {Object} element - Inkscape layer element
33
+ * @returns {string|null} Layer label or null if not set
34
+ */
35
+ export function getLayerLabel(element) {
36
+ return element?.getAttribute('inkscape:label') || null;
37
+ }
38
+
39
+ /**
40
+ * Find all Inkscape layers in a document.
41
+ *
42
+ * @param {Object} doc - Parsed SVG document
43
+ * @returns {Array<{element: Object, label: string|null, id: string|null}>} Array of layer info objects
44
+ */
45
+ export function findLayers(doc) {
46
+ const layers = [];
47
+
48
+ const walk = (el) => {
49
+ if (!el || !el.children) return;
50
+ if (isInkscapeLayer(el)) {
51
+ layers.push({
52
+ element: el,
53
+ label: getLayerLabel(el),
54
+ id: el.getAttribute('id')
55
+ });
56
+ }
57
+ for (const child of el.children) {
58
+ walk(child);
59
+ }
60
+ };
61
+
62
+ walk(doc);
63
+ return layers;
64
+ }
65
+
66
+ /**
67
+ * Get sodipodi:namedview document settings.
68
+ * Contains Inkscape document settings like page color, grid, guides.
69
+ *
70
+ * @param {Object} doc - Parsed SVG document
71
+ * @returns {Object|null} Named view settings or null if not found
72
+ */
73
+ export function getNamedViewSettings(doc) {
74
+ // Find namedview element - may be direct child or nested
75
+ let namedview = null;
76
+
77
+ const findNamedview = (el) => {
78
+ if (!el) return;
79
+ if (el.tagName === 'sodipodi:namedview') {
80
+ namedview = el;
81
+ return;
82
+ }
83
+ if (el.children) {
84
+ for (const child of el.children) {
85
+ findNamedview(child);
86
+ if (namedview) return;
87
+ }
88
+ }
89
+ };
90
+
91
+ findNamedview(doc);
92
+ if (!namedview) return null;
93
+
94
+ return {
95
+ pagecolor: namedview.getAttribute('pagecolor'),
96
+ bordercolor: namedview.getAttribute('bordercolor'),
97
+ borderopacity: namedview.getAttribute('borderopacity'),
98
+ showgrid: namedview.getAttribute('showgrid'),
99
+ showguides: namedview.getAttribute('showguides'),
100
+ guidetolerance: namedview.getAttribute('guidetolerance'),
101
+ inkscapeZoom: namedview.getAttribute('inkscape:zoom'),
102
+ inkscapeCx: namedview.getAttribute('inkscape:cx'),
103
+ inkscapeCy: namedview.getAttribute('inkscape:cy'),
104
+ inkscapeWindowWidth: namedview.getAttribute('inkscape:window-width'),
105
+ inkscapeWindowHeight: namedview.getAttribute('inkscape:window-height'),
106
+ inkscapeCurrentLayer: namedview.getAttribute('inkscape:current-layer')
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Find all sodipodi:guide elements (guidelines).
112
+ *
113
+ * @param {Object} doc - Parsed SVG document
114
+ * @returns {Array<{position: string, orientation: string, id: string|null}>} Array of guide info
115
+ */
116
+ export function findGuides(doc) {
117
+ const guides = [];
118
+
119
+ const walk = (el) => {
120
+ if (!el || !el.children) return;
121
+ if (el.tagName === 'sodipodi:guide') {
122
+ guides.push({
123
+ position: el.getAttribute('position'),
124
+ orientation: el.getAttribute('orientation'),
125
+ id: el.getAttribute('id'),
126
+ inkscapeColor: el.getAttribute('inkscape:color'),
127
+ inkscapeLabel: el.getAttribute('inkscape:label')
128
+ });
129
+ }
130
+ for (const child of el.children) {
131
+ walk(child);
132
+ }
133
+ };
134
+
135
+ walk(doc);
136
+ return guides;
137
+ }
138
+
139
+ /**
140
+ * Get sodipodi arc parameters from a path element.
141
+ * Inkscape stores original arc parameters for shapes converted from ellipses.
142
+ *
143
+ * @param {Object} element - SVG element (typically a path)
144
+ * @returns {Object|null} Arc parameters or null if not an arc
145
+ */
146
+ export function getArcParameters(element) {
147
+ if (!element) return null;
148
+
149
+ const type = element.getAttribute('sodipodi:type');
150
+ if (type !== 'arc') return null;
151
+
152
+ return {
153
+ type: 'arc',
154
+ cx: element.getAttribute('sodipodi:cx'),
155
+ cy: element.getAttribute('sodipodi:cy'),
156
+ rx: element.getAttribute('sodipodi:rx'),
157
+ ry: element.getAttribute('sodipodi:ry'),
158
+ start: element.getAttribute('sodipodi:start'),
159
+ end: element.getAttribute('sodipodi:end'),
160
+ open: element.getAttribute('sodipodi:open')
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Get node types from a path element.
166
+ * Inkscape stores node types (corner, smooth, symmetric, auto) for path editing.
167
+ *
168
+ * @param {Object} element - SVG path element
169
+ * @returns {string|null} Node types string (c=corner, s=smooth, z=symmetric, a=auto)
170
+ */
171
+ export function getNodeTypes(element) {
172
+ return element?.getAttribute('sodipodi:nodetypes') || null;
173
+ }
174
+
175
+ /**
176
+ * Get export settings from an element.
177
+ *
178
+ * @param {Object} element - SVG element
179
+ * @returns {Object|null} Export settings or null if not set
180
+ */
181
+ export function getExportSettings(element) {
182
+ if (!element) return null;
183
+
184
+ const filename = element.getAttribute('inkscape:export-filename');
185
+ const xdpi = element.getAttribute('inkscape:export-xdpi');
186
+ const ydpi = element.getAttribute('inkscape:export-ydpi');
187
+
188
+ if (!filename && !xdpi && !ydpi) return null;
189
+
190
+ return {
191
+ filename,
192
+ xdpi: xdpi ? parseFloat(xdpi) : null,
193
+ ydpi: ydpi ? parseFloat(ydpi) : null
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Check if element is part of a tiled clone.
199
+ *
200
+ * @param {Object} element - SVG element
201
+ * @returns {boolean} True if element is a tiled clone
202
+ */
203
+ export function isTiledClone(element) {
204
+ return element?.hasAttribute('inkscape:tiled-clone-of') || false;
205
+ }
206
+
207
+ /**
208
+ * Get tiled clone source ID.
209
+ *
210
+ * @param {Object} element - SVG element
211
+ * @returns {string|null} Source element ID or null
212
+ */
213
+ export function getTiledCloneSource(element) {
214
+ return element?.getAttribute('inkscape:tiled-clone-of') || null;
215
+ }
216
+
217
+ /**
218
+ * Check if document has Inkscape namespaces declared.
219
+ *
220
+ * @param {Object} doc - Parsed SVG document
221
+ * @returns {boolean} True if Inkscape namespaces are present
222
+ */
223
+ export function hasInkscapeNamespaces(doc) {
224
+ const svg = doc.documentElement || doc;
225
+ const hasInkscape = svg.getAttribute('xmlns:inkscape') === INKSCAPE_NS;
226
+ const hasSodipodi = svg.getAttribute('xmlns:sodipodi') === SODIPODI_NS;
227
+ return hasInkscape || hasSodipodi;
228
+ }
229
+
230
+ /**
231
+ * Ensure Inkscape namespace declarations are present.
232
+ * Adds xmlns:inkscape and xmlns:sodipodi if missing.
233
+ *
234
+ * @param {Object} doc - Parsed SVG document
235
+ * @returns {Object} The document (modified in place)
236
+ */
237
+ export function ensureInkscapeNamespaces(doc) {
238
+ const svg = doc.documentElement || doc;
239
+
240
+ if (!svg.getAttribute('xmlns:inkscape')) {
241
+ svg.setAttribute('xmlns:inkscape', INKSCAPE_NS);
242
+ }
243
+ if (!svg.getAttribute('xmlns:sodipodi')) {
244
+ svg.setAttribute('xmlns:sodipodi', SODIPODI_NS);
245
+ }
246
+
247
+ return doc;
248
+ }
@@ -1456,9 +1456,18 @@ export const removeDesc = createOperation((doc, options = {}) => {
1456
1456
  * @param {boolean} options.preserveVendor - If true, preserves all editor namespace elements and attributes
1457
1457
  */
1458
1458
  export const removeEditorsNSData = createOperation((doc, options = {}) => {
1459
- // Skip if preserveVendor is enabled - editor namespaces are vendor-specific
1460
- if (options.preserveVendor) return doc;
1459
+ // preserveVendor = true preserves ALL editor namespaces (backward compat)
1460
+ if (options.preserveVendor === true) return doc;
1461
+
1462
+ // preserveNamespaces = array of prefixes to preserve selectively
1463
+ const preserveNamespaces = options.preserveNamespaces || [];
1464
+
1465
+ // Handle namespace aliases: sodipodi and inkscape always go together
1466
+ const normalizedPreserve = new Set(preserveNamespaces);
1467
+ if (normalizedPreserve.has('sodipodi')) normalizedPreserve.add('inkscape');
1468
+ if (normalizedPreserve.has('inkscape')) normalizedPreserve.add('sodipodi');
1461
1469
 
1470
+ // Filter out preserved namespaces from the removal list
1462
1471
  const editorPrefixes = [
1463
1472
  "inkscape",
1464
1473
  "sodipodi",
@@ -1468,7 +1477,10 @@ export const removeEditorsNSData = createOperation((doc, options = {}) => {
1468
1477
  "serif",
1469
1478
  "vectornator",
1470
1479
  "figma",
1471
- ];
1480
+ ].filter(prefix => !normalizedPreserve.has(prefix));
1481
+
1482
+ // If all namespaces are preserved, exit early
1483
+ if (editorPrefixes.length === 0) return doc;
1472
1484
 
1473
1485
  // FIRST: Remove editor-specific elements (sodipodi:namedview, etc.)
1474
1486
  // This must happen BEFORE checking remaining prefixes to avoid SVGO bug #1530
@@ -0,0 +1,444 @@
1
+ /**
2
+ * SVG 2.0 Polyfill Generator
3
+ *
4
+ * Detects SVG 2.0 features (mesh gradients, hatches) and generates inline
5
+ * JavaScript polyfills for browser compatibility.
6
+ *
7
+ * Uses the existing mesh-gradient.js math for Coons patch evaluation.
8
+ * All polyfills are embedded inline in the SVG for self-contained output.
9
+ *
10
+ * @module svg2-polyfills
11
+ */
12
+
13
+ /**
14
+ * SVG 2.0 features that can be polyfilled
15
+ */
16
+ export const SVG2_FEATURES = {
17
+ MESH_GRADIENT: 'meshGradient',
18
+ HATCH: 'hatch',
19
+ CONTEXT_PAINT: 'context-paint',
20
+ AUTO_START_REVERSE: 'auto-start-reverse'
21
+ };
22
+
23
+ /**
24
+ * Detect SVG 2.0 features that need polyfills in a document.
25
+ *
26
+ * @param {Object} doc - Parsed SVG document
27
+ * @returns {{meshGradients: string[], hatches: string[], contextPaint: boolean, autoStartReverse: boolean}} Detected features
28
+ */
29
+ export function detectSVG2Features(doc) {
30
+ const features = {
31
+ meshGradients: [],
32
+ hatches: [],
33
+ contextPaint: false,
34
+ autoStartReverse: false
35
+ };
36
+
37
+ const walk = (el) => {
38
+ if (!el) return;
39
+
40
+ // Check tag name for mesh gradient (case-insensitive)
41
+ const tagName = el.tagName?.toLowerCase();
42
+ if (tagName === 'meshgradient') {
43
+ const id = el.getAttribute('id');
44
+ if (id) features.meshGradients.push(id);
45
+ }
46
+
47
+ // Check for hatch element
48
+ if (tagName === 'hatch') {
49
+ const id = el.getAttribute('id');
50
+ if (id) features.hatches.push(id);
51
+ }
52
+
53
+ // Check for context-paint in fill/stroke
54
+ const fill = el.getAttribute('fill');
55
+ const stroke = el.getAttribute('stroke');
56
+ if (fill === 'context-fill' || fill === 'context-stroke' ||
57
+ stroke === 'context-fill' || stroke === 'context-stroke') {
58
+ features.contextPaint = true;
59
+ }
60
+
61
+ // Check for auto-start-reverse in markers
62
+ const orient = el.getAttribute('orient');
63
+ if (orient === 'auto-start-reverse') {
64
+ features.autoStartReverse = true;
65
+ }
66
+
67
+ // Recurse into children
68
+ if (el.children) {
69
+ for (const child of el.children) {
70
+ walk(child);
71
+ }
72
+ }
73
+ };
74
+
75
+ walk(doc);
76
+ return features;
77
+ }
78
+
79
+ /**
80
+ * Check if document needs any SVG 2 polyfills.
81
+ *
82
+ * @param {Object} doc - Parsed SVG document
83
+ * @returns {boolean} True if polyfills are needed
84
+ */
85
+ export function needsPolyfills(doc) {
86
+ const features = detectSVG2Features(doc);
87
+ return features.meshGradients.length > 0 ||
88
+ features.hatches.length > 0 ||
89
+ features.contextPaint ||
90
+ features.autoStartReverse;
91
+ }
92
+
93
+ /**
94
+ * Generate the mesh gradient polyfill code.
95
+ * This polyfill renders mesh gradients to canvas and uses them as image fills.
96
+ *
97
+ * @returns {string} JavaScript polyfill code
98
+ */
99
+ function generateMeshPolyfillCode() {
100
+ return `
101
+ // Mesh Gradient Polyfill - SVG 2.0 to Canvas fallback
102
+ // Generated by svg-matrix
103
+ (function() {
104
+ 'use strict';
105
+
106
+ // Skip if browser supports mesh gradients natively
107
+ if (typeof document.createElementNS('http://www.w3.org/2000/svg', 'meshGradient').x !== 'undefined') {
108
+ return;
109
+ }
110
+
111
+ // Find all mesh gradients
112
+ var meshes = document.querySelectorAll('meshGradient, meshgradient');
113
+ if (!meshes.length) return;
114
+
115
+ // Parse color string to RGBA
116
+ function parseColor(str) {
117
+ if (!str) return {r: 0, g: 0, b: 0, a: 255};
118
+ var m = str.match(/rgba?\\s*\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)(?:\\s*,\\s*([\\d.]+))?\\s*\\)/);
119
+ if (m) return {r: +m[1], g: +m[2], b: +m[3], a: m[4] ? Math.round(+m[4] * 255) : 255};
120
+ if (str[0] === '#') {
121
+ var hex = str.slice(1);
122
+ if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
123
+ return {r: parseInt(hex.slice(0,2), 16), g: parseInt(hex.slice(2,4), 16), b: parseInt(hex.slice(4,6), 16), a: 255};
124
+ }
125
+ return {r: 0, g: 0, b: 0, a: 255};
126
+ }
127
+
128
+ // Bilinear color interpolation
129
+ function bilinearColor(c00, c10, c01, c11, u, v) {
130
+ var mu = 1 - u, mv = 1 - v;
131
+ return {
132
+ r: Math.round(mu*mv*c00.r + u*mv*c10.r + mu*v*c01.r + u*v*c11.r),
133
+ g: Math.round(mu*mv*c00.g + u*mv*c10.g + mu*v*c01.g + u*v*c11.g),
134
+ b: Math.round(mu*mv*c00.b + u*mv*c10.b + mu*v*c01.b + u*v*c11.b),
135
+ a: Math.round(mu*mv*c00.a + u*mv*c10.a + mu*v*c01.a + u*v*c11.a)
136
+ };
137
+ }
138
+
139
+ // Evaluate cubic Bezier at t
140
+ function evalBezier(p0, p1, p2, p3, t) {
141
+ var mt = 1 - t, mt2 = mt * mt, mt3 = mt2 * mt;
142
+ var t2 = t * t, t3 = t2 * t;
143
+ return {
144
+ x: mt3*p0.x + 3*mt2*t*p1.x + 3*mt*t2*p2.x + t3*p3.x,
145
+ y: mt3*p0.y + 3*mt2*t*p1.y + 3*mt*t2*p2.y + t3*p3.y
146
+ };
147
+ }
148
+
149
+ // Process each mesh gradient
150
+ meshes.forEach(function(mesh) {
151
+ var id = mesh.getAttribute('id');
152
+ if (!id) return;
153
+
154
+ // Create canvas for rasterization
155
+ var canvas = document.createElement('canvas');
156
+ var ctx = canvas.getContext('2d');
157
+
158
+ // Get bounding box from referencing elements
159
+ var refs = document.querySelectorAll('[fill="url(#' + id + ')"], [stroke="url(#' + id + ')"]');
160
+ if (!refs.length) return;
161
+
162
+ var bbox = refs[0].getBBox();
163
+ var size = Math.max(bbox.width, bbox.height, 256);
164
+ canvas.width = canvas.height = size;
165
+
166
+ // Parse mesh patches
167
+ var patches = [];
168
+ var rows = mesh.querySelectorAll('meshrow');
169
+ rows.forEach(function(row) {
170
+ var rowPatches = row.querySelectorAll('meshpatch');
171
+ rowPatches.forEach(function(patch) {
172
+ var stops = patch.querySelectorAll('stop');
173
+ if (stops.length >= 4) {
174
+ patches.push({
175
+ colors: [
176
+ parseColor(stops[0].getAttribute('stop-color') || stops[0].style.stopColor),
177
+ parseColor(stops[1].getAttribute('stop-color') || stops[1].style.stopColor),
178
+ parseColor(stops[2].getAttribute('stop-color') || stops[2].style.stopColor),
179
+ parseColor(stops[3].getAttribute('stop-color') || stops[3].style.stopColor)
180
+ ]
181
+ });
182
+ }
183
+ });
184
+ });
185
+
186
+ // Render patches with bilinear interpolation
187
+ if (patches.length > 0) {
188
+ var imgData = ctx.createImageData(size, size);
189
+ var data = imgData.data;
190
+ var patch = patches[0];
191
+ for (var y = 0; y < size; y++) {
192
+ for (var x = 0; x < size; x++) {
193
+ var u = x / (size - 1);
194
+ var v = y / (size - 1);
195
+ var c = bilinearColor(patch.colors[0], patch.colors[1], patch.colors[2], patch.colors[3], u, v);
196
+ var i = (y * size + x) * 4;
197
+ data[i] = c.r;
198
+ data[i+1] = c.g;
199
+ data[i+2] = c.b;
200
+ data[i+3] = c.a;
201
+ }
202
+ }
203
+ ctx.putImageData(imgData, 0, 0);
204
+ }
205
+
206
+ // Create pattern from canvas
207
+ var dataUrl = canvas.toDataURL('image/png');
208
+ var pattern = document.createElementNS('http://www.w3.org/2000/svg', 'pattern');
209
+ pattern.setAttribute('id', id + '_polyfill');
210
+ pattern.setAttribute('patternUnits', 'objectBoundingBox');
211
+ pattern.setAttribute('width', '1');
212
+ pattern.setAttribute('height', '1');
213
+
214
+ var img = document.createElementNS('http://www.w3.org/2000/svg', 'image');
215
+ img.setAttribute('href', dataUrl);
216
+ img.setAttribute('width', '1');
217
+ img.setAttribute('height', '1');
218
+ img.setAttribute('preserveAspectRatio', 'none');
219
+ pattern.appendChild(img);
220
+
221
+ // Add pattern to defs
222
+ var defs = mesh.closest('svg').querySelector('defs') || (function() {
223
+ var d = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
224
+ mesh.closest('svg').insertBefore(d, mesh.closest('svg').firstChild);
225
+ return d;
226
+ })();
227
+ defs.appendChild(pattern);
228
+
229
+ // Update references to use polyfill pattern
230
+ refs.forEach(function(ref) {
231
+ if (ref.getAttribute('fill') === 'url(#' + id + ')') {
232
+ ref.setAttribute('fill', 'url(#' + id + '_polyfill)');
233
+ }
234
+ if (ref.getAttribute('stroke') === 'url(#' + id + ')') {
235
+ ref.setAttribute('stroke', 'url(#' + id + '_polyfill)');
236
+ }
237
+ });
238
+ });
239
+ })();
240
+ `;
241
+ }
242
+
243
+ /**
244
+ * Generate the hatch pattern polyfill code.
245
+ * Converts SVG 2 hatch elements to SVG 1.1 pattern elements.
246
+ *
247
+ * @returns {string} JavaScript polyfill code
248
+ */
249
+ function generateHatchPolyfillCode() {
250
+ return `
251
+ // Hatch Pattern Polyfill - SVG 2.0 to SVG 1.1 pattern conversion
252
+ // Generated by svg-matrix
253
+ (function() {
254
+ 'use strict';
255
+
256
+ // Find all hatch elements
257
+ var hatches = document.querySelectorAll('hatch');
258
+ if (!hatches.length) return;
259
+
260
+ hatches.forEach(function(hatch) {
261
+ var id = hatch.getAttribute('id');
262
+ if (!id) return;
263
+
264
+ // Get hatch properties
265
+ var href = hatch.getAttribute('href') || hatch.getAttribute('xlink:href');
266
+ var hatchUnits = hatch.getAttribute('hatchUnits') || 'objectBoundingBox';
267
+ var hatchContentUnits = hatch.getAttribute('hatchContentUnits') || 'userSpaceOnUse';
268
+ var pitch = parseFloat(hatch.getAttribute('pitch')) || 8;
269
+ var rotate = parseFloat(hatch.getAttribute('rotate')) || 0;
270
+ var transform = hatch.getAttribute('transform') || '';
271
+
272
+ // Create equivalent pattern
273
+ var pattern = document.createElementNS('http://www.w3.org/2000/svg', 'pattern');
274
+ pattern.setAttribute('id', id + '_polyfill');
275
+ pattern.setAttribute('patternUnits', hatchUnits);
276
+ pattern.setAttribute('width', pitch);
277
+ pattern.setAttribute('height', pitch);
278
+
279
+ if (transform || rotate) {
280
+ var fullTransform = transform;
281
+ if (rotate) fullTransform += ' rotate(' + rotate + ')';
282
+ pattern.setAttribute('patternTransform', fullTransform.trim());
283
+ }
284
+
285
+ // Copy hatchpath children as lines
286
+ var hatchpaths = hatch.querySelectorAll('hatchpath, hatchPath');
287
+ hatchpaths.forEach(function(hp) {
288
+ var d = hp.getAttribute('d');
289
+ var strokeColor = hp.getAttribute('stroke') || 'black';
290
+ var strokeWidth = hp.getAttribute('stroke-width') || '1';
291
+
292
+ var line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
293
+ line.setAttribute('d', d || 'M0,0 L' + pitch + ',0');
294
+ line.setAttribute('stroke', strokeColor);
295
+ line.setAttribute('stroke-width', strokeWidth);
296
+ line.setAttribute('fill', 'none');
297
+ pattern.appendChild(line);
298
+ });
299
+
300
+ // If no hatchpaths, create default diagonal line
301
+ if (!hatchpaths.length) {
302
+ var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
303
+ line.setAttribute('x1', '0');
304
+ line.setAttribute('y1', '0');
305
+ line.setAttribute('x2', pitch);
306
+ line.setAttribute('y2', pitch);
307
+ line.setAttribute('stroke', 'black');
308
+ line.setAttribute('stroke-width', '1');
309
+ pattern.appendChild(line);
310
+ }
311
+
312
+ // Add pattern to defs
313
+ var defs = hatch.closest('svg').querySelector('defs') || (function() {
314
+ var d = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
315
+ hatch.closest('svg').insertBefore(d, hatch.closest('svg').firstChild);
316
+ return d;
317
+ })();
318
+ defs.appendChild(pattern);
319
+
320
+ // Update references
321
+ var refs = document.querySelectorAll('[fill="url(#' + id + ')"], [stroke="url(#' + id + ')"]');
322
+ refs.forEach(function(ref) {
323
+ if (ref.getAttribute('fill') === 'url(#' + id + ')') {
324
+ ref.setAttribute('fill', 'url(#' + id + '_polyfill)');
325
+ }
326
+ if (ref.getAttribute('stroke') === 'url(#' + id + ')') {
327
+ ref.setAttribute('stroke', 'url(#' + id + '_polyfill)');
328
+ }
329
+ });
330
+ });
331
+ })();
332
+ `;
333
+ }
334
+
335
+ /**
336
+ * Generate complete polyfill script based on detected features.
337
+ *
338
+ * @param {{meshGradients: string[], hatches: string[], contextPaint: boolean, autoStartReverse: boolean}} features - Detected features
339
+ * @returns {string|null} Complete polyfill script or null if none needed
340
+ */
341
+ export function generatePolyfillScript(features) {
342
+ const parts = [];
343
+
344
+ parts.push('/* SVG 2.0 Polyfills - Generated by svg-matrix */');
345
+
346
+ if (features.meshGradients.length > 0) {
347
+ parts.push(generateMeshPolyfillCode());
348
+ }
349
+
350
+ if (features.hatches.length > 0) {
351
+ parts.push(generateHatchPolyfillCode());
352
+ }
353
+
354
+ if (parts.length === 1) {
355
+ return null; // Only header, no actual polyfills
356
+ }
357
+
358
+ return parts.join('\n');
359
+ }
360
+
361
+ /**
362
+ * Inject polyfill script into SVG document.
363
+ *
364
+ * @param {Object} doc - Parsed SVG document
365
+ * @param {Object} [options] - Options
366
+ * @param {boolean} [options.force=false] - Force injection even if no features detected
367
+ * @returns {Object} The document (modified in place)
368
+ */
369
+ export function injectPolyfills(doc, options = {}) {
370
+ const features = detectSVG2Features(doc);
371
+
372
+ // Check if polyfills are needed
373
+ if (!options.force &&
374
+ features.meshGradients.length === 0 &&
375
+ features.hatches.length === 0) {
376
+ return doc;
377
+ }
378
+
379
+ const script = generatePolyfillScript(features);
380
+ if (!script) return doc;
381
+
382
+ // Find or create the SVG root
383
+ const svg = doc.documentElement || doc;
384
+
385
+ // Create script element
386
+ // Note: We need to handle this based on the parser being used
387
+ const scriptEl = {
388
+ tagName: 'script',
389
+ attributes: new Map([['type', 'text/javascript']]),
390
+ children: [],
391
+ textContent: script,
392
+ getAttribute(name) { return this.attributes.get(name); },
393
+ setAttribute(name, value) { this.attributes.set(name, value); },
394
+ hasAttribute(name) { return this.attributes.has(name); },
395
+ removeAttribute(name) { this.attributes.delete(name); },
396
+ getAttributeNames() { return [...this.attributes.keys()]; },
397
+ appendChild(child) { this.children.push(child); },
398
+ removeChild(child) {
399
+ const idx = this.children.indexOf(child);
400
+ if (idx >= 0) this.children.splice(idx, 1);
401
+ }
402
+ };
403
+
404
+ // Insert script at beginning of SVG (after any xml declaration)
405
+ if (svg.children && svg.children.length > 0) {
406
+ svg.children.unshift(scriptEl);
407
+ } else if (svg.children) {
408
+ svg.children.push(scriptEl);
409
+ }
410
+
411
+ return doc;
412
+ }
413
+
414
+ /**
415
+ * Remove polyfill scripts from SVG document.
416
+ *
417
+ * @param {Object} doc - Parsed SVG document
418
+ * @returns {Object} The document (modified in place)
419
+ */
420
+ export function removePolyfills(doc) {
421
+ const walk = (el) => {
422
+ if (!el || !el.children) return;
423
+
424
+ // Remove script elements that are svg-matrix polyfills
425
+ el.children = el.children.filter(child => {
426
+ if (child.tagName === 'script') {
427
+ const content = child.textContent || '';
428
+ if (content.includes('SVG 2.0 Polyfill') ||
429
+ content.includes('Generated by svg-matrix')) {
430
+ return false;
431
+ }
432
+ }
433
+ return true;
434
+ });
435
+
436
+ // Recurse
437
+ for (const child of el.children) {
438
+ walk(child);
439
+ }
440
+ };
441
+
442
+ walk(doc);
443
+ return doc;
444
+ }