@emasoft/svg-matrix 1.0.10 → 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.
@@ -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, '&amp;')
618
+ .replace(/</g, '&lt;')
619
+ .replace(/>/g, '&gt;');
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, '&amp;')
630
+ .replace(/</g, '&lt;')
631
+ .replace(/>/g, '&gt;')
632
+ .replace(/"/g, '&quot;')
633
+ .replace(/'/g, '&#39;');
634
+ }
635
+
636
+ /**
637
+ * Unescape attribute value from XML.
638
+ * @private
639
+ */
640
+ function unescapeAttr(str) {
641
+ return str
642
+ .replace(/&quot;/g, '"')
643
+ .replace(/&#39;/g, "'")
644
+ .replace(/&lt;/g, '<')
645
+ .replace(/&gt;/g, '>')
646
+ .replace(/&amp;/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
+ };