@emasoft/svg-matrix 1.0.11 → 1.0.12
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/bin/svg-matrix.js +222 -123
- package/package.json +1 -1
- package/src/flatten-pipeline.js +992 -0
- package/src/index.js +7 -2
- package/src/svg-parser.js +730 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG Parser - Lightweight DOM-like SVG parsing for Node.js
|
|
3
|
+
*
|
|
4
|
+
* Creates element objects with DOM-like interface (getAttribute, children, tagName)
|
|
5
|
+
* without requiring external dependencies. Designed for svg-matrix resolver modules.
|
|
6
|
+
*
|
|
7
|
+
* @module svg-parser
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import Decimal from 'decimal.js';
|
|
11
|
+
|
|
12
|
+
Decimal.set({ precision: 80 });
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse an SVG string into a DOM-like element tree.
|
|
16
|
+
* @param {string} svgString - Raw SVG content
|
|
17
|
+
* @returns {SVGElement} Root element with DOM-like interface
|
|
18
|
+
*/
|
|
19
|
+
export function parseSVG(svgString) {
|
|
20
|
+
// Normalize whitespace but preserve content
|
|
21
|
+
const normalized = svgString.trim();
|
|
22
|
+
|
|
23
|
+
// Parse into element tree
|
|
24
|
+
const root = parseElement(normalized, 0);
|
|
25
|
+
|
|
26
|
+
if (!root.element) {
|
|
27
|
+
throw new Error('Failed to parse SVG: no root element found');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return root.element;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* SVG Element class with DOM-like interface.
|
|
35
|
+
* Provides getAttribute, querySelectorAll, children, etc.
|
|
36
|
+
*/
|
|
37
|
+
export class SVGElement {
|
|
38
|
+
constructor(tagName, attributes = {}, children = [], textContent = '') {
|
|
39
|
+
this.tagName = tagName.toLowerCase();
|
|
40
|
+
this.nodeName = tagName.toUpperCase();
|
|
41
|
+
this._attributes = { ...attributes };
|
|
42
|
+
this.children = children;
|
|
43
|
+
this.childNodes = children;
|
|
44
|
+
this.textContent = textContent;
|
|
45
|
+
this.parentNode = null;
|
|
46
|
+
|
|
47
|
+
// Set parent references
|
|
48
|
+
for (const child of children) {
|
|
49
|
+
if (child instanceof SVGElement) {
|
|
50
|
+
child.parentNode = this;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get attribute value by name.
|
|
57
|
+
* @param {string} name - Attribute name
|
|
58
|
+
* @returns {string|null} Attribute value or null
|
|
59
|
+
*/
|
|
60
|
+
getAttribute(name) {
|
|
61
|
+
return this._attributes[name] ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Set attribute value.
|
|
66
|
+
* @param {string} name - Attribute name
|
|
67
|
+
* @param {string} value - Attribute value
|
|
68
|
+
*/
|
|
69
|
+
setAttribute(name, value) {
|
|
70
|
+
this._attributes[name] = value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if attribute exists.
|
|
75
|
+
* @param {string} name - Attribute name
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
hasAttribute(name) {
|
|
79
|
+
return name in this._attributes;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Remove attribute.
|
|
84
|
+
* @param {string} name - Attribute name
|
|
85
|
+
*/
|
|
86
|
+
removeAttribute(name) {
|
|
87
|
+
delete this._attributes[name];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get all attribute names.
|
|
92
|
+
* @returns {string[]}
|
|
93
|
+
*/
|
|
94
|
+
getAttributeNames() {
|
|
95
|
+
return Object.keys(this._attributes);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Find all descendants matching a tag name.
|
|
100
|
+
* @param {string} tagName - Tag name to match (case-insensitive)
|
|
101
|
+
* @returns {SVGElement[]}
|
|
102
|
+
*/
|
|
103
|
+
getElementsByTagName(tagName) {
|
|
104
|
+
const tag = tagName.toLowerCase();
|
|
105
|
+
const results = [];
|
|
106
|
+
|
|
107
|
+
const search = (el) => {
|
|
108
|
+
for (const child of el.children) {
|
|
109
|
+
if (child instanceof SVGElement) {
|
|
110
|
+
if (child.tagName === tag || tag === '*') {
|
|
111
|
+
results.push(child);
|
|
112
|
+
}
|
|
113
|
+
search(child);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
search(this);
|
|
119
|
+
return results;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Find element by ID.
|
|
124
|
+
* @param {string} id - Element ID
|
|
125
|
+
* @returns {SVGElement|null}
|
|
126
|
+
*/
|
|
127
|
+
getElementById(id) {
|
|
128
|
+
const search = (el) => {
|
|
129
|
+
if (el.getAttribute('id') === id) {
|
|
130
|
+
return el;
|
|
131
|
+
}
|
|
132
|
+
for (const child of el.children) {
|
|
133
|
+
if (child instanceof SVGElement) {
|
|
134
|
+
const found = search(child);
|
|
135
|
+
if (found) return found;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return search(this);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Find first matching descendant (simple selector support).
|
|
146
|
+
* Supports: tagName, #id, .class, [attr], [attr=value]
|
|
147
|
+
* @param {string} selector - CSS-like selector
|
|
148
|
+
* @returns {SVGElement|null}
|
|
149
|
+
*/
|
|
150
|
+
querySelector(selector) {
|
|
151
|
+
const results = this.querySelectorAll(selector);
|
|
152
|
+
return results[0] || null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Find all matching descendants.
|
|
157
|
+
* @param {string} selector - CSS-like selector
|
|
158
|
+
* @returns {SVGElement[]}
|
|
159
|
+
*/
|
|
160
|
+
querySelectorAll(selector) {
|
|
161
|
+
const results = [];
|
|
162
|
+
const matchers = parseSelector(selector);
|
|
163
|
+
|
|
164
|
+
const search = (el) => {
|
|
165
|
+
for (const child of el.children) {
|
|
166
|
+
if (child instanceof SVGElement) {
|
|
167
|
+
if (matchesAllSelectors(child, matchers)) {
|
|
168
|
+
results.push(child);
|
|
169
|
+
}
|
|
170
|
+
search(child);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
search(this);
|
|
176
|
+
return results;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Check if element matches selector.
|
|
181
|
+
* @param {string} selector - CSS-like selector
|
|
182
|
+
* @returns {boolean}
|
|
183
|
+
*/
|
|
184
|
+
matches(selector) {
|
|
185
|
+
const matchers = parseSelector(selector);
|
|
186
|
+
return matchesAllSelectors(this, matchers);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Clone element (deep by default).
|
|
191
|
+
* @param {boolean} deep - Clone children too
|
|
192
|
+
* @returns {SVGElement}
|
|
193
|
+
*/
|
|
194
|
+
cloneNode(deep = true) {
|
|
195
|
+
const clonedChildren = deep
|
|
196
|
+
? this.children.map(c => c instanceof SVGElement ? c.cloneNode(true) : c)
|
|
197
|
+
: [];
|
|
198
|
+
return new SVGElement(this.tagName, { ...this._attributes }, clonedChildren, this.textContent);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Append child element.
|
|
203
|
+
* @param {SVGElement} child
|
|
204
|
+
*/
|
|
205
|
+
appendChild(child) {
|
|
206
|
+
if (child instanceof SVGElement) {
|
|
207
|
+
child.parentNode = this;
|
|
208
|
+
}
|
|
209
|
+
this.children.push(child);
|
|
210
|
+
this.childNodes = this.children;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Remove child element.
|
|
215
|
+
* @param {SVGElement} child
|
|
216
|
+
*/
|
|
217
|
+
removeChild(child) {
|
|
218
|
+
const idx = this.children.indexOf(child);
|
|
219
|
+
if (idx >= 0) {
|
|
220
|
+
this.children.splice(idx, 1);
|
|
221
|
+
this.childNodes = this.children;
|
|
222
|
+
if (child instanceof SVGElement) {
|
|
223
|
+
child.parentNode = null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Insert child before reference node.
|
|
230
|
+
* @param {SVGElement} newChild
|
|
231
|
+
* @param {SVGElement} refChild
|
|
232
|
+
*/
|
|
233
|
+
insertBefore(newChild, refChild) {
|
|
234
|
+
const idx = this.children.indexOf(refChild);
|
|
235
|
+
if (idx >= 0) {
|
|
236
|
+
if (newChild instanceof SVGElement) {
|
|
237
|
+
newChild.parentNode = this;
|
|
238
|
+
}
|
|
239
|
+
this.children.splice(idx, 0, newChild);
|
|
240
|
+
this.childNodes = this.children;
|
|
241
|
+
} else {
|
|
242
|
+
this.appendChild(newChild);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Replace child element.
|
|
248
|
+
* @param {SVGElement} newChild
|
|
249
|
+
* @param {SVGElement} oldChild
|
|
250
|
+
*/
|
|
251
|
+
replaceChild(newChild, oldChild) {
|
|
252
|
+
const idx = this.children.indexOf(oldChild);
|
|
253
|
+
if (idx >= 0) {
|
|
254
|
+
if (oldChild instanceof SVGElement) {
|
|
255
|
+
oldChild.parentNode = null;
|
|
256
|
+
}
|
|
257
|
+
if (newChild instanceof SVGElement) {
|
|
258
|
+
newChild.parentNode = this;
|
|
259
|
+
}
|
|
260
|
+
this.children[idx] = newChild;
|
|
261
|
+
this.childNodes = this.children;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Get computed style (stub - returns inline styles only).
|
|
267
|
+
* @returns {Object}
|
|
268
|
+
*/
|
|
269
|
+
get style() {
|
|
270
|
+
const styleAttr = this.getAttribute('style') || '';
|
|
271
|
+
const styles = {};
|
|
272
|
+
styleAttr.split(';').forEach(pair => {
|
|
273
|
+
const [key, val] = pair.split(':').map(s => s.trim());
|
|
274
|
+
if (key && val) {
|
|
275
|
+
styles[key] = val;
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
return styles;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Get first child element.
|
|
283
|
+
* @returns {SVGElement|null}
|
|
284
|
+
*/
|
|
285
|
+
get firstChild() {
|
|
286
|
+
return this.children[0] || null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Get last child element.
|
|
291
|
+
* @returns {SVGElement|null}
|
|
292
|
+
*/
|
|
293
|
+
get lastChild() {
|
|
294
|
+
return this.children[this.children.length - 1] || null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Get next sibling.
|
|
299
|
+
* @returns {SVGElement|null}
|
|
300
|
+
*/
|
|
301
|
+
get nextSibling() {
|
|
302
|
+
if (!this.parentNode) return null;
|
|
303
|
+
const siblings = this.parentNode.children;
|
|
304
|
+
const idx = siblings.indexOf(this);
|
|
305
|
+
return siblings[idx + 1] || null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Get previous sibling.
|
|
310
|
+
* @returns {SVGElement|null}
|
|
311
|
+
*/
|
|
312
|
+
get previousSibling() {
|
|
313
|
+
if (!this.parentNode) return null;
|
|
314
|
+
const siblings = this.parentNode.children;
|
|
315
|
+
const idx = siblings.indexOf(this);
|
|
316
|
+
return idx > 0 ? siblings[idx - 1] : null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Serialize to SVG string.
|
|
321
|
+
* @param {number} indent - Indentation level
|
|
322
|
+
* @returns {string}
|
|
323
|
+
*/
|
|
324
|
+
serialize(indent = 0) {
|
|
325
|
+
const pad = ' '.repeat(indent);
|
|
326
|
+
const attrs = Object.entries(this._attributes)
|
|
327
|
+
.map(([k, v]) => `${k}="${escapeAttr(v)}"`)
|
|
328
|
+
.join(' ');
|
|
329
|
+
|
|
330
|
+
const attrStr = attrs ? ' ' + attrs : '';
|
|
331
|
+
|
|
332
|
+
if (this.children.length === 0 && !this.textContent) {
|
|
333
|
+
return `${pad}<${this.tagName}${attrStr}/>`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const childStr = this.children
|
|
337
|
+
.map(c => c instanceof SVGElement ? c.serialize(indent + 1) : escapeText(c))
|
|
338
|
+
.join('\n');
|
|
339
|
+
|
|
340
|
+
const content = this.textContent ? escapeText(this.textContent) : childStr;
|
|
341
|
+
|
|
342
|
+
if (this.children.length === 0) {
|
|
343
|
+
return `${pad}<${this.tagName}${attrStr}>${content}</${this.tagName}>`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return `${pad}<${this.tagName}${attrStr}>\n${content}\n${pad}</${this.tagName}>`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Get outerHTML-like representation.
|
|
351
|
+
* @returns {string}
|
|
352
|
+
*/
|
|
353
|
+
get outerHTML() {
|
|
354
|
+
return this.serialize(0);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Get innerHTML-like representation.
|
|
359
|
+
* @returns {string}
|
|
360
|
+
*/
|
|
361
|
+
get innerHTML() {
|
|
362
|
+
return this.children
|
|
363
|
+
.map(c => c instanceof SVGElement ? c.serialize(0) : escapeText(c))
|
|
364
|
+
.join('\n');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ============================================================================
|
|
369
|
+
// INTERNAL PARSING FUNCTIONS
|
|
370
|
+
// ============================================================================
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Parse a single element from SVG string.
|
|
374
|
+
* @private
|
|
375
|
+
*/
|
|
376
|
+
function parseElement(str, pos) {
|
|
377
|
+
// Skip whitespace and comments
|
|
378
|
+
while (pos < str.length) {
|
|
379
|
+
const ws = str.slice(pos).match(/^(\s+)/);
|
|
380
|
+
if (ws) {
|
|
381
|
+
pos += ws[1].length;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Skip comments
|
|
386
|
+
if (str.slice(pos, pos + 4) === '<!--') {
|
|
387
|
+
const endComment = str.indexOf('-->', pos + 4);
|
|
388
|
+
if (endComment === -1) break;
|
|
389
|
+
pos = endComment + 3;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Skip CDATA
|
|
394
|
+
if (str.slice(pos, pos + 9) === '<![CDATA[') {
|
|
395
|
+
const endCdata = str.indexOf(']]>', pos + 9);
|
|
396
|
+
if (endCdata === -1) break;
|
|
397
|
+
pos = endCdata + 3;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Skip processing instructions (<?xml ...?>)
|
|
402
|
+
if (str.slice(pos, pos + 2) === '<?') {
|
|
403
|
+
const endPI = str.indexOf('?>', pos + 2);
|
|
404
|
+
if (endPI === -1) break;
|
|
405
|
+
pos = endPI + 2;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Skip DOCTYPE
|
|
410
|
+
if (str.slice(pos, pos + 9).toUpperCase() === '<!DOCTYPE') {
|
|
411
|
+
const endDoctype = str.indexOf('>', pos + 9);
|
|
412
|
+
if (endDoctype === -1) break;
|
|
413
|
+
pos = endDoctype + 1;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (pos >= str.length || str[pos] !== '<') {
|
|
421
|
+
return { element: null, endPos: pos };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Parse opening tag
|
|
425
|
+
const tagMatch = str.slice(pos).match(/^<([a-zA-Z][a-zA-Z0-9_:-]*)/);
|
|
426
|
+
if (!tagMatch) {
|
|
427
|
+
return { element: null, endPos: pos };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const tagName = tagMatch[1];
|
|
431
|
+
pos += tagMatch[0].length;
|
|
432
|
+
|
|
433
|
+
// Parse attributes
|
|
434
|
+
const attributes = {};
|
|
435
|
+
while (pos < str.length) {
|
|
436
|
+
// Skip whitespace
|
|
437
|
+
const ws = str.slice(pos).match(/^(\s+)/);
|
|
438
|
+
if (ws) {
|
|
439
|
+
pos += ws[1].length;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Check for end of tag
|
|
443
|
+
if (str[pos] === '>') {
|
|
444
|
+
pos++;
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (str.slice(pos, pos + 2) === '/>') {
|
|
449
|
+
pos += 2;
|
|
450
|
+
// Self-closing tag
|
|
451
|
+
return {
|
|
452
|
+
element: new SVGElement(tagName, attributes, []),
|
|
453
|
+
endPos: pos
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Parse attribute
|
|
458
|
+
const attrMatch = str.slice(pos).match(/^([a-zA-Z][a-zA-Z0-9_:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/);
|
|
459
|
+
if (attrMatch) {
|
|
460
|
+
const attrName = attrMatch[1];
|
|
461
|
+
const attrValue = attrMatch[2] !== undefined ? attrMatch[2] : attrMatch[3];
|
|
462
|
+
attributes[attrName] = unescapeAttr(attrValue);
|
|
463
|
+
pos += attrMatch[0].length;
|
|
464
|
+
} else {
|
|
465
|
+
// Boolean attribute or malformed - skip
|
|
466
|
+
const boolAttr = str.slice(pos).match(/^([a-zA-Z][a-zA-Z0-9_:-]*)/);
|
|
467
|
+
if (boolAttr) {
|
|
468
|
+
attributes[boolAttr[1]] = '';
|
|
469
|
+
pos += boolAttr[0].length;
|
|
470
|
+
} else {
|
|
471
|
+
pos++;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Parse children
|
|
477
|
+
const children = [];
|
|
478
|
+
let textContent = '';
|
|
479
|
+
const closingTag = `</${tagName}>`;
|
|
480
|
+
|
|
481
|
+
while (pos < str.length) {
|
|
482
|
+
// Check for closing tag
|
|
483
|
+
if (str.slice(pos, pos + closingTag.length).toLowerCase() === closingTag.toLowerCase()) {
|
|
484
|
+
pos += closingTag.length;
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Check for child element
|
|
489
|
+
if (str[pos] === '<' && str[pos + 1] !== '/') {
|
|
490
|
+
// Check for CDATA
|
|
491
|
+
if (str.slice(pos, pos + 9) === '<![CDATA[') {
|
|
492
|
+
const endCdata = str.indexOf(']]>', pos + 9);
|
|
493
|
+
if (endCdata !== -1) {
|
|
494
|
+
textContent += str.slice(pos + 9, endCdata);
|
|
495
|
+
pos = endCdata + 3;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Check for comment
|
|
501
|
+
if (str.slice(pos, pos + 4) === '<!--') {
|
|
502
|
+
const endComment = str.indexOf('-->', pos + 4);
|
|
503
|
+
if (endComment !== -1) {
|
|
504
|
+
pos = endComment + 3;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const child = parseElement(str, pos);
|
|
510
|
+
if (child.element) {
|
|
511
|
+
children.push(child.element);
|
|
512
|
+
pos = child.endPos;
|
|
513
|
+
} else {
|
|
514
|
+
pos++;
|
|
515
|
+
}
|
|
516
|
+
} else if (str[pos] === '<' && str[pos + 1] === '/') {
|
|
517
|
+
// Closing tag for this element
|
|
518
|
+
const closeMatch = str.slice(pos).match(/^<\/([a-zA-Z][a-zA-Z0-9_:-]*)>/);
|
|
519
|
+
if (closeMatch && closeMatch[1].toLowerCase() === tagName.toLowerCase()) {
|
|
520
|
+
pos += closeMatch[0].length;
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
pos++;
|
|
524
|
+
} else {
|
|
525
|
+
// Text content
|
|
526
|
+
const nextTag = str.indexOf('<', pos);
|
|
527
|
+
if (nextTag === -1) {
|
|
528
|
+
textContent += str.slice(pos);
|
|
529
|
+
pos = str.length;
|
|
530
|
+
} else {
|
|
531
|
+
textContent += str.slice(pos, nextTag);
|
|
532
|
+
pos = nextTag;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const element = new SVGElement(tagName, attributes, children, textContent.trim());
|
|
538
|
+
return { element, endPos: pos };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Parse CSS-like selector into matchers.
|
|
543
|
+
* @private
|
|
544
|
+
*/
|
|
545
|
+
function parseSelector(selector) {
|
|
546
|
+
const matchers = [];
|
|
547
|
+
const parts = selector.trim().split(/\s*,\s*/);
|
|
548
|
+
|
|
549
|
+
for (const part of parts) {
|
|
550
|
+
const matcher = { tag: null, id: null, classes: [], attrs: [] };
|
|
551
|
+
|
|
552
|
+
// Parse selector parts
|
|
553
|
+
let remaining = part;
|
|
554
|
+
|
|
555
|
+
// Tag name
|
|
556
|
+
const tagMatch = remaining.match(/^([a-zA-Z][a-zA-Z0-9_-]*)/);
|
|
557
|
+
if (tagMatch) {
|
|
558
|
+
matcher.tag = tagMatch[1].toLowerCase();
|
|
559
|
+
remaining = remaining.slice(tagMatch[0].length);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ID
|
|
563
|
+
const idMatch = remaining.match(/#([a-zA-Z][a-zA-Z0-9_-]*)/);
|
|
564
|
+
if (idMatch) {
|
|
565
|
+
matcher.id = idMatch[1];
|
|
566
|
+
remaining = remaining.replace(idMatch[0], '');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Classes
|
|
570
|
+
const classMatches = remaining.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)/g);
|
|
571
|
+
for (const m of classMatches) {
|
|
572
|
+
matcher.classes.push(m[1]);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Attributes [attr] or [attr=value]
|
|
576
|
+
const attrMatches = remaining.matchAll(/\[([a-zA-Z][a-zA-Z0-9_:-]*)(?:=["']?([^"'\]]+)["']?)?\]/g);
|
|
577
|
+
for (const m of attrMatches) {
|
|
578
|
+
matcher.attrs.push({ name: m[1], value: m[2] });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
matchers.push(matcher);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return matchers;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Check if element matches all selector matchers.
|
|
589
|
+
* @private
|
|
590
|
+
*/
|
|
591
|
+
function matchesAllSelectors(el, matchers) {
|
|
592
|
+
return matchers.some(matcher => {
|
|
593
|
+
if (matcher.tag && el.tagName !== matcher.tag) return false;
|
|
594
|
+
if (matcher.id && el.getAttribute('id') !== matcher.id) return false;
|
|
595
|
+
|
|
596
|
+
const elClasses = (el.getAttribute('class') || '').split(/\s+/);
|
|
597
|
+
for (const cls of matcher.classes) {
|
|
598
|
+
if (!elClasses.includes(cls)) return false;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
for (const attr of matcher.attrs) {
|
|
602
|
+
if (!el.hasAttribute(attr.name)) return false;
|
|
603
|
+
if (attr.value !== undefined && el.getAttribute(attr.name) !== attr.value) return false;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return true;
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Escape text content for XML.
|
|
612
|
+
* @private
|
|
613
|
+
*/
|
|
614
|
+
function escapeText(str) {
|
|
615
|
+
if (typeof str !== 'string') return String(str);
|
|
616
|
+
return str
|
|
617
|
+
.replace(/&/g, '&')
|
|
618
|
+
.replace(/</g, '<')
|
|
619
|
+
.replace(/>/g, '>');
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Escape attribute value for XML.
|
|
624
|
+
* @private
|
|
625
|
+
*/
|
|
626
|
+
function escapeAttr(str) {
|
|
627
|
+
if (typeof str !== 'string') return String(str);
|
|
628
|
+
return str
|
|
629
|
+
.replace(/&/g, '&')
|
|
630
|
+
.replace(/</g, '<')
|
|
631
|
+
.replace(/>/g, '>')
|
|
632
|
+
.replace(/"/g, '"')
|
|
633
|
+
.replace(/'/g, ''');
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Unescape attribute value from XML.
|
|
638
|
+
* @private
|
|
639
|
+
*/
|
|
640
|
+
function unescapeAttr(str) {
|
|
641
|
+
return str
|
|
642
|
+
.replace(/"/g, '"')
|
|
643
|
+
.replace(/'/g, "'")
|
|
644
|
+
.replace(/</g, '<')
|
|
645
|
+
.replace(/>/g, '>')
|
|
646
|
+
.replace(/&/g, '&');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ============================================================================
|
|
650
|
+
// UTILITY FUNCTIONS
|
|
651
|
+
// ============================================================================
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Build a defs map from SVG root element.
|
|
655
|
+
* Maps IDs to their definition elements.
|
|
656
|
+
* @param {SVGElement} svgRoot - Parsed SVG root
|
|
657
|
+
* @returns {Map<string, SVGElement>}
|
|
658
|
+
*/
|
|
659
|
+
export function buildDefsMap(svgRoot) {
|
|
660
|
+
const defsMap = new Map();
|
|
661
|
+
|
|
662
|
+
// Find all elements with IDs
|
|
663
|
+
const addToMap = (el) => {
|
|
664
|
+
const id = el.getAttribute('id');
|
|
665
|
+
if (id) {
|
|
666
|
+
defsMap.set(id, el);
|
|
667
|
+
}
|
|
668
|
+
for (const child of el.children) {
|
|
669
|
+
if (child instanceof SVGElement) {
|
|
670
|
+
addToMap(child);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
addToMap(svgRoot);
|
|
676
|
+
return defsMap;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Find all elements with a specific attribute.
|
|
681
|
+
* @param {SVGElement} root - Root element
|
|
682
|
+
* @param {string} attrName - Attribute to search for
|
|
683
|
+
* @returns {SVGElement[]}
|
|
684
|
+
*/
|
|
685
|
+
export function findElementsWithAttribute(root, attrName) {
|
|
686
|
+
const results = [];
|
|
687
|
+
|
|
688
|
+
const search = (el) => {
|
|
689
|
+
if (el.hasAttribute(attrName)) {
|
|
690
|
+
results.push(el);
|
|
691
|
+
}
|
|
692
|
+
for (const child of el.children) {
|
|
693
|
+
if (child instanceof SVGElement) {
|
|
694
|
+
search(child);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
search(root);
|
|
700
|
+
return results;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Get the URL reference from a url() value.
|
|
705
|
+
* @param {string} urlValue - Value like "url(#foo)" or "url(foo.svg#bar)"
|
|
706
|
+
* @returns {string|null} The reference ID or null
|
|
707
|
+
*/
|
|
708
|
+
export function parseUrlReference(urlValue) {
|
|
709
|
+
if (!urlValue) return null;
|
|
710
|
+
const match = urlValue.match(/url\(\s*["']?#?([^"')]+)["']?\s*\)/i);
|
|
711
|
+
return match ? match[1] : null;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Serialize SVG element tree back to string.
|
|
716
|
+
* @param {SVGElement} root - Root element
|
|
717
|
+
* @returns {string}
|
|
718
|
+
*/
|
|
719
|
+
export function serializeSVG(root) {
|
|
720
|
+
return '<?xml version="1.0" encoding="UTF-8"?>\n' + root.serialize(0);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export default {
|
|
724
|
+
parseSVG,
|
|
725
|
+
SVGElement,
|
|
726
|
+
buildDefsMap,
|
|
727
|
+
findElementsWithAttribute,
|
|
728
|
+
parseUrlReference,
|
|
729
|
+
serializeSVG
|
|
730
|
+
};
|