@emasoft/svg-matrix 1.0.28 → 1.0.30

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.
Files changed (46) hide show
  1. package/README.md +325 -0
  2. package/bin/svg-matrix.js +985 -378
  3. package/bin/svglinter.cjs +4172 -433
  4. package/bin/svgm.js +723 -180
  5. package/package.json +16 -4
  6. package/src/animation-references.js +71 -52
  7. package/src/arc-length.js +160 -96
  8. package/src/bezier-analysis.js +257 -117
  9. package/src/bezier-intersections.js +411 -148
  10. package/src/browser-verify.js +240 -100
  11. package/src/clip-path-resolver.js +350 -142
  12. package/src/convert-path-data.js +279 -134
  13. package/src/css-specificity.js +78 -70
  14. package/src/flatten-pipeline.js +751 -263
  15. package/src/geometry-to-path.js +511 -182
  16. package/src/index.js +191 -46
  17. package/src/inkscape-support.js +18 -7
  18. package/src/marker-resolver.js +278 -164
  19. package/src/mask-resolver.js +209 -98
  20. package/src/matrix.js +147 -67
  21. package/src/mesh-gradient.js +187 -96
  22. package/src/off-canvas-detection.js +201 -104
  23. package/src/path-analysis.js +187 -107
  24. package/src/path-data-plugins.js +628 -167
  25. package/src/path-simplification.js +0 -1
  26. package/src/pattern-resolver.js +125 -88
  27. package/src/polygon-clip.js +111 -66
  28. package/src/svg-boolean-ops.js +194 -118
  29. package/src/svg-collections.js +22 -18
  30. package/src/svg-flatten.js +282 -164
  31. package/src/svg-parser.js +427 -200
  32. package/src/svg-rendering-context.js +147 -104
  33. package/src/svg-toolbox.js +16381 -3370
  34. package/src/svg2-polyfills.js +93 -224
  35. package/src/transform-decomposition.js +46 -41
  36. package/src/transform-optimization.js +89 -68
  37. package/src/transforms2d.js +49 -16
  38. package/src/transforms3d.js +58 -22
  39. package/src/use-symbol-resolver.js +150 -110
  40. package/src/vector.js +67 -15
  41. package/src/vendor/README.md +110 -0
  42. package/src/vendor/inkscape-hatch-polyfill.js +401 -0
  43. package/src/vendor/inkscape-hatch-polyfill.min.js +8 -0
  44. package/src/vendor/inkscape-mesh-polyfill.js +843 -0
  45. package/src/vendor/inkscape-mesh-polyfill.min.js +8 -0
  46. package/src/verification.js +288 -124
package/src/svg-parser.js CHANGED
@@ -7,16 +7,39 @@
7
7
  * @module svg-parser
8
8
  */
9
9
 
10
- import Decimal from 'decimal.js';
10
+ import Decimal from "decimal.js";
11
11
 
12
12
  Decimal.set({ precision: 80 });
13
13
 
14
+ /**
15
+ * Recursively set ownerDocument on an element and all its descendants.
16
+ * @param {SVGElement} el - Element to set ownerDocument on
17
+ * @param {SVGDocument} doc - The document object
18
+ * @returns {void}
19
+ */
20
+ function setOwnerDocument(el, doc) {
21
+ el.ownerDocument = doc;
22
+ for (const child of el.children) {
23
+ // eslint-disable-next-line no-use-before-define -- Class hoisting is intentional
24
+ if (child instanceof SVGElement) {
25
+ setOwnerDocument(child, doc);
26
+ }
27
+ }
28
+ }
29
+
14
30
  /**
15
31
  * Parse an SVG string into a DOM-like element tree.
16
32
  * @param {string} svgString - Raw SVG content
17
33
  * @returns {SVGElement} Root element with DOM-like interface
18
34
  */
19
35
  export function parseSVG(svgString) {
36
+ if (typeof svgString !== 'string') {
37
+ throw new Error('parseSVG: input must be a string');
38
+ }
39
+ if (svgString.trim().length === 0) {
40
+ throw new Error('parseSVG: input cannot be empty');
41
+ }
42
+
20
43
  // Normalize whitespace but preserve content
21
44
  const normalized = svgString.trim();
22
45
 
@@ -24,25 +47,86 @@ export function parseSVG(svgString) {
24
47
  const root = parseElement(normalized, 0);
25
48
 
26
49
  if (!root.element) {
27
- throw new Error('Failed to parse SVG: no root element found');
50
+ throw new Error("Failed to parse SVG: no root element found");
28
51
  }
29
52
 
53
+ // Create document and set ownerDocument on all elements
54
+ // eslint-disable-next-line no-use-before-define -- Class hoisting is intentional
55
+ const doc = new SVGDocument(root.element);
56
+ setOwnerDocument(root.element, doc);
57
+
30
58
  return root.element;
31
59
  }
32
60
 
61
+ /**
62
+ * Simple document-like object that provides createElement functionality.
63
+ * Used by ownerDocument to allow element creation in DOM manipulation functions.
64
+ * @class
65
+ * @property {SVGElement|null} documentElement - Root SVG element of the document
66
+ */
67
+ class SVGDocument {
68
+ /**
69
+ * Creates a new SVGDocument instance.
70
+ * @param {SVGElement|null} rootElement - The root element of the document
71
+ */
72
+ constructor(rootElement = null) {
73
+ this.documentElement = rootElement;
74
+ }
75
+
76
+ /**
77
+ * Create a new SVGElement with the given tag name.
78
+ * @param {string} tagName - Element tag name
79
+ * @returns {SVGElement}
80
+ */
81
+ createElement(tagName) {
82
+ // eslint-disable-next-line no-use-before-define -- Class hoisting is intentional
83
+ const el = new SVGElement(tagName);
84
+ el.ownerDocument = this;
85
+ return el;
86
+ }
87
+
88
+ /**
89
+ * Create a new SVGElement with namespace (for compatibility).
90
+ * @param {string} namespace - Namespace URI (ignored, always SVG)
91
+ * @param {string} tagName - Element tag name
92
+ * @returns {SVGElement}
93
+ */
94
+ createElementNS(namespace, tagName) {
95
+ return this.createElement(tagName);
96
+ }
97
+ }
98
+
33
99
  /**
34
100
  * SVG Element class with DOM-like interface.
35
101
  * Provides getAttribute, querySelectorAll, children, etc.
102
+ * @class
103
+ * @property {string} tagName - Element tag name (preserves original case)
104
+ * @property {string} nodeName - Uppercase element tag name
105
+ * @property {Object} _attributes - Internal attributes storage
106
+ * @property {Array<SVGElement|string>} children - Child elements and text nodes
107
+ * @property {Array<SVGElement|string>} childNodes - Alias for children
108
+ * @property {string} textContent - Text content of the element
109
+ * @property {SVGElement|null} parentNode - Parent element reference
110
+ * @property {SVGDocument|null} ownerDocument - Document that owns this element
36
111
  */
37
112
  export class SVGElement {
38
- constructor(tagName, attributes = {}, children = [], textContent = '') {
39
- this.tagName = tagName.toLowerCase();
113
+ /**
114
+ * Creates a new SVGElement instance.
115
+ * @param {string} tagName - Element tag name
116
+ * @param {Object} attributes - Element attributes (key-value pairs)
117
+ * @param {Array<SVGElement|string>} children - Child elements and text nodes
118
+ * @param {string} textContent - Text content of the element
119
+ */
120
+ constructor(tagName, attributes = {}, children = [], textContent = "") {
121
+ // Preserve original case for W3C SVG compliance (linearGradient, clipPath, etc.)
122
+ this.tagName = tagName;
40
123
  this.nodeName = tagName.toUpperCase();
41
124
  this._attributes = { ...attributes };
42
125
  this.children = children;
43
126
  this.childNodes = children;
44
127
  this.textContent = textContent;
45
128
  this.parentNode = null;
129
+ this.ownerDocument = null; // Will be set by parseSVG or SVGDocument.createElement
46
130
 
47
131
  // Set parent references
48
132
  for (const child of children) {
@@ -107,7 +191,8 @@ export class SVGElement {
107
191
  const search = (el) => {
108
192
  for (const child of el.children) {
109
193
  if (child instanceof SVGElement) {
110
- if (child.tagName === tag || tag === '*') {
194
+ // Case-insensitive tag matching (tagName preserves original case)
195
+ if (child.tagName.toLowerCase() === tag || tag === "*") {
111
196
  results.push(child);
112
197
  }
113
198
  search(child);
@@ -126,7 +211,7 @@ export class SVGElement {
126
211
  */
127
212
  getElementById(id) {
128
213
  const search = (el) => {
129
- if (el.getAttribute('id') === id) {
214
+ if (el.getAttribute("id") === id) {
130
215
  return el;
131
216
  }
132
217
  for (const child of el.children) {
@@ -188,31 +273,59 @@ export class SVGElement {
188
273
 
189
274
  /**
190
275
  * Clone element (deep by default).
276
+ * DOM spec: Cloned nodes preserve ownerDocument from original.
191
277
  * @param {boolean} deep - Clone children too
192
278
  * @returns {SVGElement}
193
279
  */
194
280
  cloneNode(deep = true) {
195
281
  const clonedChildren = deep
196
- ? this.children.map(c => c instanceof SVGElement ? c.cloneNode(true) : c)
282
+ ? // eslint-disable-next-line no-confusing-arrow -- Prettier multiline format
283
+ this.children.map((c) =>
284
+ c instanceof SVGElement ? c.cloneNode(true) : c,
285
+ )
197
286
  : [];
198
- return new SVGElement(this.tagName, { ...this._attributes }, clonedChildren, this.textContent);
287
+ const cloned = new SVGElement(
288
+ this.tagName,
289
+ { ...this._attributes },
290
+ clonedChildren,
291
+ this.textContent,
292
+ );
293
+ // Preserve ownerDocument - set on cloned element and all its children
294
+ if (this.ownerDocument) {
295
+ const setDocRecursive = (el, doc) => {
296
+ el.ownerDocument = doc;
297
+ for (const child of el.children) {
298
+ if (child instanceof SVGElement) setDocRecursive(child, doc);
299
+ }
300
+ };
301
+ setDocRecursive(cloned, this.ownerDocument);
302
+ }
303
+ return cloned;
199
304
  }
200
305
 
201
306
  /**
202
307
  * Append child element.
308
+ * DOM spec: If child already has a parent, remove it first.
203
309
  * @param {SVGElement} child
310
+ * @returns {SVGElement} The appended child
204
311
  */
205
312
  appendChild(child) {
313
+ // DOM spec: Remove child from its current parent before appending
314
+ if (child instanceof SVGElement && child.parentNode) {
315
+ child.parentNode.removeChild(child);
316
+ }
206
317
  if (child instanceof SVGElement) {
207
318
  child.parentNode = this;
208
319
  }
209
320
  this.children.push(child);
210
321
  this.childNodes = this.children;
322
+ return child;
211
323
  }
212
324
 
213
325
  /**
214
326
  * Remove child element.
215
327
  * @param {SVGElement} child
328
+ * @returns {SVGElement} The removed child
216
329
  */
217
330
  removeChild(child) {
218
331
  const idx = this.children.indexOf(child);
@@ -223,14 +336,21 @@ export class SVGElement {
223
336
  child.parentNode = null;
224
337
  }
225
338
  }
339
+ return child;
226
340
  }
227
341
 
228
342
  /**
229
343
  * Insert child before reference node.
344
+ * DOM spec: Removes newChild from its current parent before inserting.
230
345
  * @param {SVGElement} newChild
231
346
  * @param {SVGElement} refChild
347
+ * @returns {SVGElement} The inserted child
232
348
  */
233
349
  insertBefore(newChild, refChild) {
350
+ // DOM spec: Remove newChild from its current parent first
351
+ if (newChild instanceof SVGElement && newChild.parentNode) {
352
+ newChild.parentNode.removeChild(newChild);
353
+ }
234
354
  const idx = this.children.indexOf(refChild);
235
355
  if (idx >= 0) {
236
356
  if (newChild instanceof SVGElement) {
@@ -241,14 +361,21 @@ export class SVGElement {
241
361
  } else {
242
362
  this.appendChild(newChild);
243
363
  }
364
+ return newChild;
244
365
  }
245
366
 
246
367
  /**
247
368
  * Replace child element.
369
+ * DOM spec: Removes newChild from its current parent before replacing.
248
370
  * @param {SVGElement} newChild
249
371
  * @param {SVGElement} oldChild
372
+ * @returns {SVGElement} The replaced (old) child
250
373
  */
251
374
  replaceChild(newChild, oldChild) {
375
+ // DOM spec: Remove newChild from its current parent first
376
+ if (newChild instanceof SVGElement && newChild.parentNode) {
377
+ newChild.parentNode.removeChild(newChild);
378
+ }
252
379
  const idx = this.children.indexOf(oldChild);
253
380
  if (idx >= 0) {
254
381
  if (oldChild instanceof SVGElement) {
@@ -260,6 +387,7 @@ export class SVGElement {
260
387
  this.children[idx] = newChild;
261
388
  this.childNodes = this.children;
262
389
  }
390
+ return oldChild;
263
391
  }
264
392
 
265
393
  /**
@@ -267,10 +395,10 @@ export class SVGElement {
267
395
  * @returns {Object}
268
396
  */
269
397
  get style() {
270
- const styleAttr = this.getAttribute('style') || '';
398
+ const styleAttr = this.getAttribute("style") || "";
271
399
  const styles = {};
272
- styleAttr.split(';').forEach(pair => {
273
- const [key, val] = pair.split(':').map(s => s.trim());
400
+ styleAttr.split(";").forEach((pair) => {
401
+ const [key, val] = pair.split(":").map((s) => s.trim());
274
402
  if (key && val) {
275
403
  styles[key] = val;
276
404
  }
@@ -323,35 +451,57 @@ export class SVGElement {
323
451
  * @returns {string}
324
452
  */
325
453
  serialize(indent = 0, minify = false) {
326
- const pad = minify ? '' : ' '.repeat(indent);
454
+ const pad = minify ? "" : " ".repeat(indent);
455
+
456
+ // Check for CDATA pending marker (used by embedExternalDependencies for scripts)
457
+ const needsCDATA = this._attributes["data-cdata-pending"] === "true";
458
+
459
+ // Build attributes string, excluding internal marker attributes
327
460
  const attrs = Object.entries(this._attributes)
461
+ .filter(([k]) => k !== "data-cdata-pending") // Remove internal marker
328
462
  .map(([k, v]) => `${k}="${escapeAttr(v)}"`)
329
- .join(' ');
463
+ .join(" ");
330
464
 
331
- const attrStr = attrs ? ' ' + attrs : '';
465
+ const attrStr = attrs ? " " + attrs : "";
332
466
 
333
467
  // Self-closing tag for empty elements
334
468
  if (this.children.length === 0 && !this.textContent) {
335
469
  return `${pad}<${this.tagName}${attrStr}/>`;
336
470
  }
337
471
 
338
- const separator = minify ? '' : '\n';
472
+ const separator = minify ? "" : "\n";
339
473
 
340
474
  // Serialize children (including animation elements inside text elements)
475
+ /* eslint-disable no-confusing-arrow -- Prettier multiline format */
341
476
  const childStr = this.children
342
- .map(c => c instanceof SVGElement ? c.serialize(indent + 1, minify) : escapeText(c))
477
+ .map((c) =>
478
+ c instanceof SVGElement
479
+ ? c.serialize(indent + 1, minify)
480
+ : escapeText(c),
481
+ )
343
482
  .join(separator);
483
+ /* eslint-enable no-confusing-arrow */
344
484
 
345
485
  // CRITICAL FIX: Elements can have BOTH textContent AND children
346
486
  // Example: <text>Some text<set attributeName="display" .../></text>
347
487
  // We must include both, not choose one or the other
348
- let content = '';
488
+ let content = "";
349
489
  if (this.textContent && this.children.length > 0) {
350
490
  // Both text and children - combine them
351
- content = escapeText(this.textContent) + separator + childStr;
491
+ if (needsCDATA) {
492
+ // Wrap textContent in CDATA without escaping for script/style elements
493
+ content = `<![CDATA[\n${this.textContent}\n]]>` + separator + childStr;
494
+ } else {
495
+ content = escapeText(this.textContent) + separator + childStr;
496
+ }
352
497
  } else if (this.textContent) {
353
498
  // Only text content
354
- content = escapeText(this.textContent);
499
+ if (needsCDATA) {
500
+ // Wrap in CDATA without escaping for script/style elements
501
+ content = `<![CDATA[\n${this.textContent}\n]]>`;
502
+ } else {
503
+ content = escapeText(this.textContent);
504
+ }
355
505
  } else {
356
506
  // Only children
357
507
  content = childStr;
@@ -378,8 +528,8 @@ export class SVGElement {
378
528
  */
379
529
  get innerHTML() {
380
530
  return this.children
381
- .map(c => c instanceof SVGElement ? c.serialize(0) : escapeText(c))
382
- .join('\n');
531
+ .map((c) => (c instanceof SVGElement ? c.serialize(0) : escapeText(c)))
532
+ .join("\n");
383
533
  }
384
534
  }
385
535
 
@@ -391,13 +541,15 @@ export class SVGElement {
391
541
  * Check if whitespace should be preserved based on xml:space attribute.
392
542
  * BUG FIX 2: Helper function to check xml:space="preserve" on element or ancestors
393
543
  * @private
544
+ * @param {SVGElement} element - Element to check for xml:space attribute
545
+ * @returns {boolean} True if whitespace should be preserved, false otherwise
394
546
  */
395
- function shouldPreserveWhitespace(element) {
547
+ function _shouldPreserveWhitespace(element) {
396
548
  let current = element;
397
549
  while (current) {
398
- const xmlSpace = current.getAttribute('xml:space');
399
- if (xmlSpace === 'preserve') return true;
400
- if (xmlSpace === 'default') return false;
550
+ const xmlSpace = current.getAttribute("xml:space");
551
+ if (xmlSpace === "preserve") return true;
552
+ if (xmlSpace === "default") return false;
401
553
  current = current.parentNode;
402
554
  }
403
555
  return false;
@@ -409,49 +561,53 @@ function shouldPreserveWhitespace(element) {
409
561
  * @param {string} str - SVG string to parse
410
562
  * @param {number} pos - Current position in string
411
563
  * @param {boolean} inheritPreserveSpace - Whether xml:space="preserve" is inherited from ancestor
564
+ * @returns {{element: SVGElement|null, endPos: number}} Parsed element and final position
412
565
  */
413
566
  function parseElement(str, pos, inheritPreserveSpace = false) {
567
+ // Use local variable to avoid reassigning parameter (ESLint no-param-reassign)
568
+ let position = pos;
569
+
414
570
  // Skip whitespace and comments
415
- while (pos < str.length) {
416
- const ws = str.slice(pos).match(/^(\s+)/);
571
+ while (position < str.length) {
572
+ const ws = str.slice(position).match(/^(\s+)/);
417
573
  if (ws) {
418
- pos += ws[1].length;
574
+ position += ws[1].length;
419
575
  continue;
420
576
  }
421
577
 
422
578
  // Skip comments
423
- if (str.slice(pos, pos + 4) === '<!--') {
424
- const endComment = str.indexOf('-->', pos + 4);
579
+ if (str.slice(position, position + 4) === "<!--") {
580
+ const endComment = str.indexOf("-->", position + 4);
425
581
  if (endComment === -1) break;
426
- pos = endComment + 3;
582
+ position = endComment + 3;
427
583
  continue;
428
584
  }
429
585
 
430
586
  // Skip CDATA
431
- if (str.slice(pos, pos + 9) === '<![CDATA[') {
432
- const endCdata = str.indexOf(']]>', pos + 9);
587
+ if (str.slice(position, position + 9) === "<![CDATA[") {
588
+ const endCdata = str.indexOf("]]>", position + 9);
433
589
  if (endCdata === -1) break;
434
- pos = endCdata + 3;
590
+ position = endCdata + 3;
435
591
  continue;
436
592
  }
437
593
 
438
594
  // Skip processing instructions (<?xml ...?>)
439
- if (str.slice(pos, pos + 2) === '<?') {
440
- const endPI = str.indexOf('?>', pos + 2);
595
+ if (str.slice(position, position + 2) === "<?") {
596
+ const endPI = str.indexOf("?>", position + 2);
441
597
  if (endPI === -1) break;
442
- pos = endPI + 2;
598
+ position = endPI + 2;
443
599
  continue;
444
600
  }
445
601
 
446
602
  // Skip DOCTYPE (can contain internal subset with brackets)
447
- if (str.slice(pos, pos + 9).toUpperCase() === '<!DOCTYPE') {
603
+ if (str.slice(position, position + 9).toUpperCase() === "<!DOCTYPE") {
448
604
  let depth = 0;
449
- let i = pos + 9;
605
+ let i = position + 9;
450
606
  while (i < str.length) {
451
- if (str[i] === '[') depth++;
452
- else if (str[i] === ']') depth--;
453
- else if (str[i] === '>' && depth === 0) {
454
- pos = i + 1;
607
+ if (str[i] === "[") depth++;
608
+ else if (str[i] === "]") depth--;
609
+ else if (str[i] === ">" && depth === 0) {
610
+ position = i + 1;
455
611
  break;
456
612
  }
457
613
  i++;
@@ -466,151 +622,169 @@ function parseElement(str, pos, inheritPreserveSpace = false) {
466
622
  break;
467
623
  }
468
624
 
469
- if (pos >= str.length || str[pos] !== '<') {
470
- return { element: null, endPos: pos };
625
+ if (position >= str.length || str[position] !== "<") {
626
+ return { element: null, endPos: position };
471
627
  }
472
628
 
473
629
  // Parse opening tag
474
- const tagMatch = str.slice(pos).match(/^<([a-zA-Z][a-zA-Z0-9_:-]*)/);
630
+ const tagMatch = str.slice(position).match(/^<([a-zA-Z][a-zA-Z0-9_:-]*)/);
475
631
  if (!tagMatch) {
476
- return { element: null, endPos: pos };
632
+ return { element: null, endPos: position };
477
633
  }
478
634
 
479
635
  const tagName = tagMatch[1];
480
- pos += tagMatch[0].length;
636
+ position += tagMatch[0].length;
481
637
 
482
638
  // Parse attributes
483
639
  const attributes = {};
484
- while (pos < str.length) {
640
+ while (position < str.length) {
485
641
  // Skip whitespace
486
- const ws = str.slice(pos).match(/^(\s+)/);
642
+ const ws = str.slice(position).match(/^(\s+)/);
487
643
  if (ws) {
488
- pos += ws[1].length;
644
+ position += ws[1].length;
489
645
  }
490
646
 
491
647
  // Check for end of tag
492
- if (str[pos] === '>') {
493
- pos++;
648
+ if (str[position] === ">") {
649
+ position++;
494
650
  break;
495
651
  }
496
652
 
497
- if (str.slice(pos, pos + 2) === '/>') {
498
- pos += 2;
653
+ if (str.slice(position, position + 2) === "/>") {
654
+ position += 2;
499
655
  // Self-closing tag
500
656
  return {
501
657
  element: new SVGElement(tagName, attributes, []),
502
- endPos: pos
658
+ endPos: position,
503
659
  };
504
660
  }
505
661
 
506
662
  // Parse attribute
507
- const attrMatch = str.slice(pos).match(/^([a-zA-Z][a-zA-Z0-9_:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/);
663
+ const attrMatch = str
664
+ .slice(position)
665
+ .match(/^([a-zA-Z][a-zA-Z0-9_:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/);
508
666
  if (attrMatch) {
509
667
  const attrName = attrMatch[1];
510
- const attrValue = attrMatch[2] !== undefined ? attrMatch[2] : attrMatch[3];
668
+ const attrValue =
669
+ attrMatch[2] !== undefined ? attrMatch[2] : attrMatch[3];
511
670
  attributes[attrName] = unescapeAttr(attrValue);
512
- pos += attrMatch[0].length;
671
+ position += attrMatch[0].length;
513
672
  } else {
514
673
  // Boolean attribute or malformed - skip
515
- const boolAttr = str.slice(pos).match(/^([a-zA-Z][a-zA-Z0-9_:-]*)/);
674
+ const boolAttr = str.slice(position).match(/^([a-zA-Z][a-zA-Z0-9_:-]*)/);
516
675
  if (boolAttr) {
517
- attributes[boolAttr[1]] = '';
518
- pos += boolAttr[0].length;
676
+ attributes[boolAttr[1]] = "";
677
+ position += boolAttr[0].length;
519
678
  } else {
520
- pos++;
679
+ position++;
521
680
  }
522
681
  }
523
682
  }
524
683
 
525
684
  // Parse children
526
685
  const children = [];
527
- let textContent = '';
686
+ let textContent = "";
528
687
  const closingTag = `</${tagName}>`;
529
688
 
530
- while (pos < str.length) {
689
+ while (position < str.length) {
531
690
  // Check for closing tag
532
- if (str.slice(pos, pos + closingTag.length).toLowerCase() === closingTag.toLowerCase()) {
533
- pos += closingTag.length;
691
+ if (
692
+ str.slice(position, position + closingTag.length).toLowerCase() ===
693
+ closingTag.toLowerCase()
694
+ ) {
695
+ position += closingTag.length;
534
696
  break;
535
697
  }
536
698
 
537
699
  // Check for child element
538
- if (str[pos] === '<' && str[pos + 1] !== '/') {
700
+ if (str[position] === "<" && str[position + 1] !== "/") {
539
701
  // Check for CDATA
540
- if (str.slice(pos, pos + 9) === '<![CDATA[') {
541
- const endCdata = str.indexOf(']]>', pos + 9);
702
+ if (str.slice(position, position + 9) === "<![CDATA[") {
703
+ const endCdata = str.indexOf("]]>", position + 9);
542
704
  if (endCdata !== -1) {
543
- textContent += str.slice(pos + 9, endCdata);
544
- pos = endCdata + 3;
705
+ textContent += str.slice(position + 9, endCdata);
706
+ position = endCdata + 3;
545
707
  continue;
546
708
  }
547
709
  }
548
710
 
549
711
  // Check for comment
550
- if (str.slice(pos, pos + 4) === '<!--') {
551
- const endComment = str.indexOf('-->', pos + 4);
712
+ if (str.slice(position, position + 4) === "<!--") {
713
+ const endComment = str.indexOf("-->", position + 4);
552
714
  if (endComment !== -1) {
553
- pos = endComment + 3;
715
+ position = endComment + 3;
554
716
  continue;
555
717
  }
556
718
  }
557
719
 
558
720
  // BUG FIX 1: Pass down xml:space inheritance to children
559
721
  // Determine what to pass: if current element has xml:space, use it; otherwise inherit
560
- const currentXmlSpace = attributes['xml:space'];
561
- const childInheritPreserve = currentXmlSpace === 'preserve' ? true :
562
- currentXmlSpace === 'default' ? false :
563
- inheritPreserveSpace;
564
-
565
- const child = parseElement(str, pos, childInheritPreserve);
722
+ const currentXmlSpace = attributes["xml:space"];
723
+ const childInheritPreserve =
724
+ currentXmlSpace === "preserve"
725
+ ? true
726
+ : currentXmlSpace === "default"
727
+ ? false
728
+ : inheritPreserveSpace;
729
+
730
+ const child = parseElement(str, position, childInheritPreserve);
566
731
  if (child.element) {
567
732
  children.push(child.element);
568
- pos = child.endPos;
733
+ position = child.endPos;
569
734
  } else {
570
- pos++;
735
+ position++;
571
736
  }
572
- } else if (str[pos] === '<' && str[pos + 1] === '/') {
737
+ } else if (str[position] === "<" && str[position + 1] === "/") {
573
738
  // Closing tag for this element
574
- const closeMatch = str.slice(pos).match(/^<\/([a-zA-Z][a-zA-Z0-9_:-]*)>/);
739
+ const closeMatch = str
740
+ .slice(position)
741
+ .match(/^<\/([a-zA-Z][a-zA-Z0-9_:-]*)>/);
575
742
  if (closeMatch && closeMatch[1].toLowerCase() === tagName.toLowerCase()) {
576
- pos += closeMatch[0].length;
743
+ position += closeMatch[0].length;
577
744
  break;
578
745
  }
579
- pos++;
746
+ position++;
580
747
  } else {
581
748
  // Text content
582
- const nextTag = str.indexOf('<', pos);
749
+ const nextTag = str.indexOf("<", position);
583
750
  if (nextTag === -1) {
584
- textContent += str.slice(pos);
585
- pos = str.length;
751
+ textContent += str.slice(position);
752
+ position = str.length;
586
753
  } else {
587
- textContent += str.slice(pos, nextTag);
588
- pos = nextTag;
754
+ textContent += str.slice(position, nextTag);
755
+ position = nextTag;
589
756
  }
590
757
  }
591
758
  }
592
759
 
593
760
  // BUG FIX 1: Create element first to set up parent references
594
- const element = new SVGElement(tagName, attributes, children, '');
761
+ const element = new SVGElement(tagName, attributes, children, "");
595
762
 
596
763
  // BUG FIX 1: Check xml:space on this element, otherwise use inherited value
597
764
  // xml:space="preserve" means preserve whitespace
598
765
  // xml:space="default" means collapse/trim whitespace
599
766
  // If not specified, inherit from ancestor
600
- const currentXmlSpace = attributes['xml:space'];
601
- const preserveWhitespace = currentXmlSpace === 'preserve' ? true :
602
- currentXmlSpace === 'default' ? false :
603
- inheritPreserveSpace;
604
-
605
- const processedText = preserveWhitespace ? unescapeText(textContent) : unescapeText(textContent.trim());
767
+ const currentXmlSpace = attributes["xml:space"];
768
+ const preserveWhitespace =
769
+ currentXmlSpace === "preserve"
770
+ ? true
771
+ : currentXmlSpace === "default"
772
+ ? false
773
+ : inheritPreserveSpace;
774
+
775
+ const processedText = preserveWhitespace
776
+ ? unescapeText(textContent)
777
+ : unescapeText(textContent.trim());
606
778
  element.textContent = processedText;
607
779
 
608
- return { element, endPos: pos };
780
+ return { element, endPos: position };
609
781
  }
610
782
 
611
783
  /**
612
784
  * Parse CSS-like selector into matchers.
613
785
  * @private
786
+ * @param {string} selector - CSS-like selector string
787
+ * @returns {Array<{tag: string|null, id: string|null, classes: Array<string>, attrs: Array<{name: string, value: string|undefined}>}>} Array of matcher objects
614
788
  */
615
789
  function parseSelector(selector) {
616
790
  const matchers = [];
@@ -633,7 +807,7 @@ function parseSelector(selector) {
633
807
  const idMatch = remaining.match(/#([a-zA-Z][a-zA-Z0-9_-]*)/);
634
808
  if (idMatch) {
635
809
  matcher.id = idMatch[1];
636
- remaining = remaining.replace(idMatch[0], '');
810
+ remaining = remaining.replace(idMatch[0], "");
637
811
  }
638
812
 
639
813
  // Classes
@@ -643,7 +817,9 @@ function parseSelector(selector) {
643
817
  }
644
818
 
645
819
  // Attributes [attr] or [attr=value]
646
- const attrMatches = remaining.matchAll(/\[([a-zA-Z][a-zA-Z0-9_:-]*)(?:=["']?([^"'\]]+)["']?)?\]/g);
820
+ const attrMatches = remaining.matchAll(
821
+ /\[([a-zA-Z][a-zA-Z0-9_:-]*)(?:=["']?([^"'\]]+)["']?)?\]/g,
822
+ );
647
823
  for (const m of attrMatches) {
648
824
  matcher.attrs.push({ name: m[1], value: m[2] });
649
825
  }
@@ -657,20 +833,25 @@ function parseSelector(selector) {
657
833
  /**
658
834
  * Check if element matches all selector matchers.
659
835
  * @private
836
+ * @param {SVGElement} el - Element to check
837
+ * @param {Array<{tag: string|null, id: string|null, classes: Array<string>, attrs: Array<{name: string, value: string|undefined}>}>} matchers - Array of matcher objects
838
+ * @returns {boolean} True if element matches any matcher
660
839
  */
661
840
  function matchesAllSelectors(el, matchers) {
662
- return matchers.some(matcher => {
663
- if (matcher.tag && el.tagName !== matcher.tag) return false;
664
- if (matcher.id && el.getAttribute('id') !== matcher.id) return false;
841
+ return matchers.some((matcher) => {
842
+ // Case-insensitive tag matching (tagName preserves original case)
843
+ if (matcher.tag && el.tagName.toLowerCase() !== matcher.tag) return false;
844
+ if (matcher.id && el.getAttribute("id") !== matcher.id) return false;
665
845
 
666
- const elClasses = (el.getAttribute('class') || '').split(/\s+/);
846
+ const elClasses = (el.getAttribute("class") || "").split(/\s+/);
667
847
  for (const cls of matcher.classes) {
668
848
  if (!elClasses.includes(cls)) return false;
669
849
  }
670
850
 
671
851
  for (const attr of matcher.attrs) {
672
852
  if (!el.hasAttribute(attr.name)) return false;
673
- if (attr.value !== undefined && el.getAttribute(attr.name) !== attr.value) return false;
853
+ if (attr.value !== undefined && el.getAttribute(attr.name) !== attr.value)
854
+ return false;
674
855
  }
675
856
 
676
857
  return true;
@@ -680,124 +861,159 @@ function matchesAllSelectors(el, matchers) {
680
861
  /**
681
862
  * Escape text content for XML.
682
863
  * @private
864
+ * @param {string|undefined|null} str - Text to escape
865
+ * @returns {string} Escaped text
683
866
  */
684
867
  function escapeText(str) {
685
- if (typeof str !== 'string') return String(str);
686
- return str
687
- .replace(/&/g, '&amp;')
688
- .replace(/</g, '&lt;')
689
- .replace(/>/g, '&gt;');
868
+ // Filter out undefined/null values to prevent "undefined" string in output
869
+ if (str === undefined || str === null) return "";
870
+ if (typeof str !== "string") return String(str);
871
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
690
872
  }
691
873
 
692
874
  /**
693
875
  * Escape attribute value for XML.
694
876
  * @private
877
+ * @param {string} str - Attribute value to escape
878
+ * @returns {string} Escaped attribute value
695
879
  */
696
880
  function escapeAttr(str) {
697
- if (typeof str !== 'string') return String(str);
881
+ if (typeof str !== "string") return String(str);
698
882
  return str
699
- .replace(/&/g, '&amp;')
700
- .replace(/</g, '&lt;')
701
- .replace(/>/g, '&gt;')
702
- .replace(/"/g, '&quot;')
703
- .replace(/'/g, '&#39;');
883
+ .replace(/&/g, "&amp;")
884
+ .replace(/</g, "&lt;")
885
+ .replace(/>/g, "&gt;")
886
+ .replace(/"/g, "&quot;")
887
+ .replace(/'/g, "&#39;");
704
888
  }
705
889
 
706
890
  /**
707
891
  * Unescape attribute value from XML.
708
892
  * Handles numeric entities (decimal and hex) and named entities.
709
893
  * @private
894
+ * @param {string} str - Attribute value to unescape
895
+ * @returns {string} Unescaped attribute value
710
896
  */
711
897
  function unescapeAttr(str) {
712
898
  if (!str) return str;
713
- return str
714
- // Decode hex entities first: &#x41; or &#X41; -> A (case-insensitive)
715
- // BUG FIX 1 & 4: Use fromCodePoint for surrogate pairs, validate code point range
716
- .replace(/&#[xX]([0-9A-Fa-f]+);/g, (match, hex) => {
717
- const codePoint = parseInt(hex, 16);
718
- // BUG FIX 2: Validate XML-invalid characters (NULL and control characters)
719
- const isXMLInvalid = codePoint === 0 ||
720
- (codePoint >= 0x1 && codePoint <= 0x8) ||
721
- (codePoint >= 0xB && codePoint <= 0xC) ||
722
- (codePoint >= 0xE && codePoint <= 0x1F) ||
723
- codePoint === 0xFFFE || codePoint === 0xFFFF;
724
- // BUG FIX 4: Validate code point range (0x0 to 0x10FFFF, excluding surrogates)
725
- if (isXMLInvalid || codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) {
726
- return '\uFFFD'; // Replacement character for invalid code points
727
- }
728
- return String.fromCodePoint(codePoint);
729
- })
730
- // Decode decimal entities: &#65; -> A
731
- // BUG FIX 1 & 4: Use fromCodePoint for surrogate pairs, validate code point range
732
- .replace(/&#(\d+);/g, (match, dec) => {
733
- const codePoint = parseInt(dec, 10);
734
- // BUG FIX 2: Validate XML-invalid characters (NULL and control characters)
735
- const isXMLInvalid = codePoint === 0 ||
736
- (codePoint >= 0x1 && codePoint <= 0x8) ||
737
- (codePoint >= 0xB && codePoint <= 0xC) ||
738
- (codePoint >= 0xE && codePoint <= 0x1F) ||
739
- codePoint === 0xFFFE || codePoint === 0xFFFF;
740
- // BUG FIX 4: Validate code point range (0x0 to 0x10FFFF, excluding surrogates)
741
- if (isXMLInvalid || codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) {
742
- return '\uFFFD'; // Replacement character for invalid code points
743
- }
744
- return String.fromCodePoint(codePoint);
745
- })
746
- // Then named entities (order matters - & last to avoid double-decoding)
747
- .replace(/&quot;/g, '"')
748
- .replace(/&#39;/g, "'")
749
- .replace(/&apos;/g, "'")
750
- .replace(/&nbsp;/g, '\u00A0') // BUG FIX 3: Add support for non-breaking space entity
751
- .replace(/&lt;/g, '<')
752
- .replace(/&gt;/g, '>')
753
- .replace(/&amp;/g, '&');
899
+ return (
900
+ str
901
+ // Decode hex entities first: &#x41; or &#X41; -> A (case-insensitive)
902
+ // BUG FIX 1 & 4: Use fromCodePoint for surrogate pairs, validate code point range
903
+ .replace(/&#[xX]([0-9A-Fa-f]+);/g, (match, hex) => {
904
+ const codePoint = parseInt(hex, 16);
905
+ // BUG FIX 2: Validate XML-invalid characters (NULL and control characters)
906
+ const isXMLInvalid =
907
+ codePoint === 0 ||
908
+ (codePoint >= 0x1 && codePoint <= 0x8) ||
909
+ (codePoint >= 0xb && codePoint <= 0xc) ||
910
+ (codePoint >= 0xe && codePoint <= 0x1f) ||
911
+ codePoint === 0xfffe ||
912
+ codePoint === 0xffff;
913
+ // BUG FIX 4: Validate code point range (0x0 to 0x10FFFF, excluding surrogates)
914
+ if (
915
+ isXMLInvalid ||
916
+ codePoint > 0x10ffff ||
917
+ (codePoint >= 0xd800 && codePoint <= 0xdfff)
918
+ ) {
919
+ return "\uFFFD"; // Replacement character for invalid code points
920
+ }
921
+ return String.fromCodePoint(codePoint);
922
+ })
923
+ // Decode decimal entities: &#65; -> A
924
+ // BUG FIX 1 & 4: Use fromCodePoint for surrogate pairs, validate code point range
925
+ .replace(/&#(\d+);/g, (match, dec) => {
926
+ const codePoint = parseInt(dec, 10);
927
+ // BUG FIX 2: Validate XML-invalid characters (NULL and control characters)
928
+ const isXMLInvalid =
929
+ codePoint === 0 ||
930
+ (codePoint >= 0x1 && codePoint <= 0x8) ||
931
+ (codePoint >= 0xb && codePoint <= 0xc) ||
932
+ (codePoint >= 0xe && codePoint <= 0x1f) ||
933
+ codePoint === 0xfffe ||
934
+ codePoint === 0xffff;
935
+ // BUG FIX 4: Validate code point range (0x0 to 0x10FFFF, excluding surrogates)
936
+ if (
937
+ isXMLInvalid ||
938
+ codePoint > 0x10ffff ||
939
+ (codePoint >= 0xd800 && codePoint <= 0xdfff)
940
+ ) {
941
+ return "\uFFFD"; // Replacement character for invalid code points
942
+ }
943
+ return String.fromCodePoint(codePoint);
944
+ })
945
+ // Then named entities (order matters - & last to avoid double-decoding)
946
+ .replace(/&quot;/g, '"')
947
+ .replace(/&#39;/g, "'")
948
+ .replace(/&apos;/g, "'")
949
+ .replace(/&nbsp;/g, "\u00A0") // BUG FIX 3: Add support for non-breaking space entity
950
+ .replace(/&lt;/g, "<")
951
+ .replace(/&gt;/g, ">")
952
+ .replace(/&amp;/g, "&")
953
+ );
754
954
  }
755
955
 
756
956
  /**
757
957
  * Unescape text content from XML.
758
958
  * Handles numeric entities (decimal and hex) and named entities.
759
959
  * @private
960
+ * @param {string} str - Text content to unescape
961
+ * @returns {string} Unescaped text content
760
962
  */
761
963
  function unescapeText(str) {
762
964
  if (!str) return str;
763
- return str
764
- // Decode hex entities: &#x41; or &#X41; -> A (case-insensitive)
765
- // BUG FIX 1 & 4: Use fromCodePoint for surrogate pairs, validate code point range
766
- .replace(/&#[xX]([0-9A-Fa-f]+);/g, (match, hex) => {
767
- const codePoint = parseInt(hex, 16);
768
- // BUG FIX 2: Validate XML-invalid characters (NULL and control characters)
769
- const isXMLInvalid = codePoint === 0 ||
770
- (codePoint >= 0x1 && codePoint <= 0x8) ||
771
- (codePoint >= 0xB && codePoint <= 0xC) ||
772
- (codePoint >= 0xE && codePoint <= 0x1F) ||
773
- codePoint === 0xFFFE || codePoint === 0xFFFF;
774
- // BUG FIX 4: Validate code point range (0x0 to 0x10FFFF, excluding surrogates)
775
- if (isXMLInvalid || codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) {
776
- return '\uFFFD'; // Replacement character for invalid code points
777
- }
778
- return String.fromCodePoint(codePoint);
779
- })
780
- // Decode decimal entities: &#65; -> A
781
- // BUG FIX 1 & 4: Use fromCodePoint for surrogate pairs, validate code point range
782
- .replace(/&#(\d+);/g, (match, dec) => {
783
- const codePoint = parseInt(dec, 10);
784
- // BUG FIX 2: Validate XML-invalid characters (NULL and control characters)
785
- const isXMLInvalid = codePoint === 0 ||
786
- (codePoint >= 0x1 && codePoint <= 0x8) ||
787
- (codePoint >= 0xB && codePoint <= 0xC) ||
788
- (codePoint >= 0xE && codePoint <= 0x1F) ||
789
- codePoint === 0xFFFE || codePoint === 0xFFFF;
790
- // BUG FIX 4: Validate code point range (0x0 to 0x10FFFF, excluding surrogates)
791
- if (isXMLInvalid || codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) {
792
- return '\uFFFD'; // Replacement character for invalid code points
793
- }
794
- return String.fromCodePoint(codePoint);
795
- })
796
- // Named entities (& last)
797
- .replace(/&nbsp;/g, '\u00A0') // BUG FIX 3: Add support for non-breaking space entity
798
- .replace(/&lt;/g, '<')
799
- .replace(/&gt;/g, '>')
800
- .replace(/&amp;/g, '&');
965
+ return (
966
+ str
967
+ // Decode hex entities: &#x41; or &#X41; -> A (case-insensitive)
968
+ // BUG FIX 1 & 4: Use fromCodePoint for surrogate pairs, validate code point range
969
+ .replace(/&#[xX]([0-9A-Fa-f]+);/g, (match, hex) => {
970
+ const codePoint = parseInt(hex, 16);
971
+ // BUG FIX 2: Validate XML-invalid characters (NULL and control characters)
972
+ const isXMLInvalid =
973
+ codePoint === 0 ||
974
+ (codePoint >= 0x1 && codePoint <= 0x8) ||
975
+ (codePoint >= 0xb && codePoint <= 0xc) ||
976
+ (codePoint >= 0xe && codePoint <= 0x1f) ||
977
+ codePoint === 0xfffe ||
978
+ codePoint === 0xffff;
979
+ // BUG FIX 4: Validate code point range (0x0 to 0x10FFFF, excluding surrogates)
980
+ if (
981
+ isXMLInvalid ||
982
+ codePoint > 0x10ffff ||
983
+ (codePoint >= 0xd800 && codePoint <= 0xdfff)
984
+ ) {
985
+ return "\uFFFD"; // Replacement character for invalid code points
986
+ }
987
+ return String.fromCodePoint(codePoint);
988
+ })
989
+ // Decode decimal entities: &#65; -> A
990
+ // BUG FIX 1 & 4: Use fromCodePoint for surrogate pairs, validate code point range
991
+ .replace(/&#(\d+);/g, (match, dec) => {
992
+ const codePoint = parseInt(dec, 10);
993
+ // BUG FIX 2: Validate XML-invalid characters (NULL and control characters)
994
+ const isXMLInvalid =
995
+ codePoint === 0 ||
996
+ (codePoint >= 0x1 && codePoint <= 0x8) ||
997
+ (codePoint >= 0xb && codePoint <= 0xc) ||
998
+ (codePoint >= 0xe && codePoint <= 0x1f) ||
999
+ codePoint === 0xfffe ||
1000
+ codePoint === 0xffff;
1001
+ // BUG FIX 4: Validate code point range (0x0 to 0x10FFFF, excluding surrogates)
1002
+ if (
1003
+ isXMLInvalid ||
1004
+ codePoint > 0x10ffff ||
1005
+ (codePoint >= 0xd800 && codePoint <= 0xdfff)
1006
+ ) {
1007
+ return "\uFFFD"; // Replacement character for invalid code points
1008
+ }
1009
+ return String.fromCodePoint(codePoint);
1010
+ })
1011
+ // Named entities (& last)
1012
+ .replace(/&nbsp;/g, "\u00A0") // BUG FIX 3: Add support for non-breaking space entity
1013
+ .replace(/&lt;/g, "<")
1014
+ .replace(/&gt;/g, ">")
1015
+ .replace(/&amp;/g, "&")
1016
+ );
801
1017
  }
802
1018
 
803
1019
  // ============================================================================
@@ -815,7 +1031,7 @@ export function buildDefsMap(svgRoot) {
815
1031
 
816
1032
  // Find all elements with IDs
817
1033
  const addToMap = (el) => {
818
- const id = el.getAttribute('id');
1034
+ const id = el.getAttribute("id");
819
1035
  if (id) {
820
1036
  defsMap.set(id, el);
821
1037
  }
@@ -873,9 +1089,20 @@ export function parseUrlReference(urlValue) {
873
1089
  * @returns {string}
874
1090
  */
875
1091
  export function serializeSVG(root, options = {}) {
1092
+ if (!root) {
1093
+ throw new Error('serializeSVG: document is required');
1094
+ }
1095
+ // Validate that root is a proper SVGElement with serialize method
1096
+ if (typeof root.serialize !== "function") {
1097
+ // If root is already a string, return it directly
1098
+ if (typeof root === "string") return root;
1099
+ throw new Error(
1100
+ `serializeSVG: expected SVGElement with serialize method, got ${root?.constructor?.name || typeof root}`,
1101
+ );
1102
+ }
876
1103
  const minify = options.minify || false;
877
1104
  const xmlDecl = '<?xml version="1.0" encoding="UTF-8"?>';
878
- const separator = minify ? '' : '\n';
1105
+ const separator = minify ? "" : "\n";
879
1106
  return xmlDecl + separator + root.serialize(0, minify);
880
1107
  }
881
1108
 
@@ -885,5 +1112,5 @@ export default {
885
1112
  buildDefsMap,
886
1113
  findElementsWithAttribute,
887
1114
  parseUrlReference,
888
- serializeSVG
1115
+ serializeSVG,
889
1116
  };