@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.
- package/README.md +325 -0
- package/bin/svg-matrix.js +985 -378
- package/bin/svglinter.cjs +4172 -433
- package/bin/svgm.js +723 -180
- package/package.json +16 -4
- package/src/animation-references.js +71 -52
- package/src/arc-length.js +160 -96
- package/src/bezier-analysis.js +257 -117
- package/src/bezier-intersections.js +411 -148
- package/src/browser-verify.js +240 -100
- package/src/clip-path-resolver.js +350 -142
- package/src/convert-path-data.js +279 -134
- package/src/css-specificity.js +78 -70
- package/src/flatten-pipeline.js +751 -263
- package/src/geometry-to-path.js +511 -182
- package/src/index.js +191 -46
- package/src/inkscape-support.js +18 -7
- package/src/marker-resolver.js +278 -164
- package/src/mask-resolver.js +209 -98
- package/src/matrix.js +147 -67
- package/src/mesh-gradient.js +187 -96
- package/src/off-canvas-detection.js +201 -104
- package/src/path-analysis.js +187 -107
- package/src/path-data-plugins.js +628 -167
- package/src/path-simplification.js +0 -1
- package/src/pattern-resolver.js +125 -88
- package/src/polygon-clip.js +111 -66
- package/src/svg-boolean-ops.js +194 -118
- package/src/svg-collections.js +22 -18
- package/src/svg-flatten.js +282 -164
- package/src/svg-parser.js +427 -200
- package/src/svg-rendering-context.js +147 -104
- package/src/svg-toolbox.js +16381 -3370
- package/src/svg2-polyfills.js +93 -224
- package/src/transform-decomposition.js +46 -41
- package/src/transform-optimization.js +89 -68
- package/src/transforms2d.js +49 -16
- package/src/transforms3d.js +58 -22
- package/src/use-symbol-resolver.js +150 -110
- package/src/vector.js +67 -15
- package/src/vendor/README.md +110 -0
- package/src/vendor/inkscape-hatch-polyfill.js +401 -0
- package/src/vendor/inkscape-hatch-polyfill.min.js +8 -0
- package/src/vendor/inkscape-mesh-polyfill.js +843 -0
- package/src/vendor/inkscape-mesh-polyfill.min.js +8 -0
- 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
|
|
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(
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
?
|
|
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
|
-
|
|
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(
|
|
398
|
+
const styleAttr = this.getAttribute("style") || "";
|
|
271
399
|
const styles = {};
|
|
272
|
-
styleAttr.split(
|
|
273
|
-
const [key, val] = pair.split(
|
|
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 ?
|
|
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 ?
|
|
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 ?
|
|
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 =>
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
547
|
+
function _shouldPreserveWhitespace(element) {
|
|
396
548
|
let current = element;
|
|
397
549
|
while (current) {
|
|
398
|
-
const xmlSpace = current.getAttribute(
|
|
399
|
-
if (xmlSpace ===
|
|
400
|
-
if (xmlSpace ===
|
|
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 (
|
|
416
|
-
const ws = str.slice(
|
|
571
|
+
while (position < str.length) {
|
|
572
|
+
const ws = str.slice(position).match(/^(\s+)/);
|
|
417
573
|
if (ws) {
|
|
418
|
-
|
|
574
|
+
position += ws[1].length;
|
|
419
575
|
continue;
|
|
420
576
|
}
|
|
421
577
|
|
|
422
578
|
// Skip comments
|
|
423
|
-
if (str.slice(
|
|
424
|
-
const endComment = str.indexOf(
|
|
579
|
+
if (str.slice(position, position + 4) === "<!--") {
|
|
580
|
+
const endComment = str.indexOf("-->", position + 4);
|
|
425
581
|
if (endComment === -1) break;
|
|
426
|
-
|
|
582
|
+
position = endComment + 3;
|
|
427
583
|
continue;
|
|
428
584
|
}
|
|
429
585
|
|
|
430
586
|
// Skip CDATA
|
|
431
|
-
if (str.slice(
|
|
432
|
-
const endCdata = str.indexOf(
|
|
587
|
+
if (str.slice(position, position + 9) === "<![CDATA[") {
|
|
588
|
+
const endCdata = str.indexOf("]]>", position + 9);
|
|
433
589
|
if (endCdata === -1) break;
|
|
434
|
-
|
|
590
|
+
position = endCdata + 3;
|
|
435
591
|
continue;
|
|
436
592
|
}
|
|
437
593
|
|
|
438
594
|
// Skip processing instructions (<?xml ...?>)
|
|
439
|
-
if (str.slice(
|
|
440
|
-
const endPI = str.indexOf(
|
|
595
|
+
if (str.slice(position, position + 2) === "<?") {
|
|
596
|
+
const endPI = str.indexOf("?>", position + 2);
|
|
441
597
|
if (endPI === -1) break;
|
|
442
|
-
|
|
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(
|
|
603
|
+
if (str.slice(position, position + 9).toUpperCase() === "<!DOCTYPE") {
|
|
448
604
|
let depth = 0;
|
|
449
|
-
let i =
|
|
605
|
+
let i = position + 9;
|
|
450
606
|
while (i < str.length) {
|
|
451
|
-
if (str[i] ===
|
|
452
|
-
else if (str[i] ===
|
|
453
|
-
else if (str[i] ===
|
|
454
|
-
|
|
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 (
|
|
470
|
-
return { element: null, endPos:
|
|
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(
|
|
630
|
+
const tagMatch = str.slice(position).match(/^<([a-zA-Z][a-zA-Z0-9_:-]*)/);
|
|
475
631
|
if (!tagMatch) {
|
|
476
|
-
return { element: null, endPos:
|
|
632
|
+
return { element: null, endPos: position };
|
|
477
633
|
}
|
|
478
634
|
|
|
479
635
|
const tagName = tagMatch[1];
|
|
480
|
-
|
|
636
|
+
position += tagMatch[0].length;
|
|
481
637
|
|
|
482
638
|
// Parse attributes
|
|
483
639
|
const attributes = {};
|
|
484
|
-
while (
|
|
640
|
+
while (position < str.length) {
|
|
485
641
|
// Skip whitespace
|
|
486
|
-
const ws = str.slice(
|
|
642
|
+
const ws = str.slice(position).match(/^(\s+)/);
|
|
487
643
|
if (ws) {
|
|
488
|
-
|
|
644
|
+
position += ws[1].length;
|
|
489
645
|
}
|
|
490
646
|
|
|
491
647
|
// Check for end of tag
|
|
492
|
-
if (str[
|
|
493
|
-
|
|
648
|
+
if (str[position] === ">") {
|
|
649
|
+
position++;
|
|
494
650
|
break;
|
|
495
651
|
}
|
|
496
652
|
|
|
497
|
-
if (str.slice(
|
|
498
|
-
|
|
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:
|
|
658
|
+
endPos: position,
|
|
503
659
|
};
|
|
504
660
|
}
|
|
505
661
|
|
|
506
662
|
// Parse attribute
|
|
507
|
-
const attrMatch = str
|
|
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 =
|
|
668
|
+
const attrValue =
|
|
669
|
+
attrMatch[2] !== undefined ? attrMatch[2] : attrMatch[3];
|
|
511
670
|
attributes[attrName] = unescapeAttr(attrValue);
|
|
512
|
-
|
|
671
|
+
position += attrMatch[0].length;
|
|
513
672
|
} else {
|
|
514
673
|
// Boolean attribute or malformed - skip
|
|
515
|
-
const boolAttr = str.slice(
|
|
674
|
+
const boolAttr = str.slice(position).match(/^([a-zA-Z][a-zA-Z0-9_:-]*)/);
|
|
516
675
|
if (boolAttr) {
|
|
517
|
-
attributes[boolAttr[1]] =
|
|
518
|
-
|
|
676
|
+
attributes[boolAttr[1]] = "";
|
|
677
|
+
position += boolAttr[0].length;
|
|
519
678
|
} else {
|
|
520
|
-
|
|
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 (
|
|
689
|
+
while (position < str.length) {
|
|
531
690
|
// Check for closing tag
|
|
532
|
-
if (
|
|
533
|
-
|
|
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[
|
|
700
|
+
if (str[position] === "<" && str[position + 1] !== "/") {
|
|
539
701
|
// Check for CDATA
|
|
540
|
-
if (str.slice(
|
|
541
|
-
const endCdata = str.indexOf(
|
|
702
|
+
if (str.slice(position, position + 9) === "<![CDATA[") {
|
|
703
|
+
const endCdata = str.indexOf("]]>", position + 9);
|
|
542
704
|
if (endCdata !== -1) {
|
|
543
|
-
textContent += str.slice(
|
|
544
|
-
|
|
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(
|
|
551
|
-
const endComment = str.indexOf(
|
|
712
|
+
if (str.slice(position, position + 4) === "<!--") {
|
|
713
|
+
const endComment = str.indexOf("-->", position + 4);
|
|
552
714
|
if (endComment !== -1) {
|
|
553
|
-
|
|
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[
|
|
561
|
-
const childInheritPreserve =
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
733
|
+
position = child.endPos;
|
|
569
734
|
} else {
|
|
570
|
-
|
|
735
|
+
position++;
|
|
571
736
|
}
|
|
572
|
-
} else if (str[
|
|
737
|
+
} else if (str[position] === "<" && str[position + 1] === "/") {
|
|
573
738
|
// Closing tag for this element
|
|
574
|
-
const closeMatch = str
|
|
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
|
-
|
|
743
|
+
position += closeMatch[0].length;
|
|
577
744
|
break;
|
|
578
745
|
}
|
|
579
|
-
|
|
746
|
+
position++;
|
|
580
747
|
} else {
|
|
581
748
|
// Text content
|
|
582
|
-
const nextTag = str.indexOf(
|
|
749
|
+
const nextTag = str.indexOf("<", position);
|
|
583
750
|
if (nextTag === -1) {
|
|
584
|
-
textContent += str.slice(
|
|
585
|
-
|
|
751
|
+
textContent += str.slice(position);
|
|
752
|
+
position = str.length;
|
|
586
753
|
} else {
|
|
587
|
-
textContent += str.slice(
|
|
588
|
-
|
|
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[
|
|
601
|
-
const preserveWhitespace =
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
664
|
-
if (matcher.
|
|
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(
|
|
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)
|
|
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
|
-
|
|
686
|
-
return
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
.replace(/>/g, '>');
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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 !==
|
|
881
|
+
if (typeof str !== "string") return String(str);
|
|
698
882
|
return str
|
|
699
|
-
.replace(/&/g,
|
|
700
|
-
.replace(/</g,
|
|
701
|
-
.replace(/>/g,
|
|
702
|
-
.replace(/"/g,
|
|
703
|
-
.replace(/'/g,
|
|
883
|
+
.replace(/&/g, "&")
|
|
884
|
+
.replace(/</g, "<")
|
|
885
|
+
.replace(/>/g, ">")
|
|
886
|
+
.replace(/"/g, """)
|
|
887
|
+
.replace(/'/g, "'");
|
|
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
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
899
|
+
return (
|
|
900
|
+
str
|
|
901
|
+
// Decode hex entities first: A or A -> 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: A -> 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(/"/g, '"')
|
|
947
|
+
.replace(/'/g, "'")
|
|
948
|
+
.replace(/'/g, "'")
|
|
949
|
+
.replace(/ /g, "\u00A0") // BUG FIX 3: Add support for non-breaking space entity
|
|
950
|
+
.replace(/</g, "<")
|
|
951
|
+
.replace(/>/g, ">")
|
|
952
|
+
.replace(/&/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
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
965
|
+
return (
|
|
966
|
+
str
|
|
967
|
+
// Decode hex entities: A or A -> 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: A -> 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(/ /g, "\u00A0") // BUG FIX 3: Add support for non-breaking space entity
|
|
1013
|
+
.replace(/</g, "<")
|
|
1014
|
+
.replace(/>/g, ">")
|
|
1015
|
+
.replace(/&/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(
|
|
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 ?
|
|
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
|
};
|