@emasoft/svg-matrix 1.0.19 → 1.0.20

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/src/svg-parser.js CHANGED
@@ -319,31 +319,49 @@ export class SVGElement {
319
319
  /**
320
320
  * Serialize to SVG string.
321
321
  * @param {number} indent - Indentation level
322
+ * @param {boolean} minify - Whether to minify output (no whitespace/newlines)
322
323
  * @returns {string}
323
324
  */
324
- serialize(indent = 0) {
325
- const pad = ' '.repeat(indent);
325
+ serialize(indent = 0, minify = false) {
326
+ const pad = minify ? '' : ' '.repeat(indent);
326
327
  const attrs = Object.entries(this._attributes)
327
328
  .map(([k, v]) => `${k}="${escapeAttr(v)}"`)
328
329
  .join(' ');
329
330
 
330
331
  const attrStr = attrs ? ' ' + attrs : '';
331
332
 
333
+ // Self-closing tag for empty elements
332
334
  if (this.children.length === 0 && !this.textContent) {
333
335
  return `${pad}<${this.tagName}${attrStr}/>`;
334
336
  }
335
337
 
336
- const childStr = this.children
337
- .map(c => c instanceof SVGElement ? c.serialize(indent + 1) : escapeText(c))
338
- .join('\n');
338
+ const separator = minify ? '' : '\n';
339
339
 
340
- const content = this.textContent ? escapeText(this.textContent) : childStr;
340
+ // Serialize children (including animation elements inside text elements)
341
+ const childStr = this.children
342
+ .map(c => c instanceof SVGElement ? c.serialize(indent + 1, minify) : escapeText(c))
343
+ .join(separator);
344
+
345
+ // CRITICAL FIX: Elements can have BOTH textContent AND children
346
+ // Example: <text>Some text<set attributeName="display" .../></text>
347
+ // We must include both, not choose one or the other
348
+ let content = '';
349
+ if (this.textContent && this.children.length > 0) {
350
+ // Both text and children - combine them
351
+ content = escapeText(this.textContent) + separator + childStr;
352
+ } else if (this.textContent) {
353
+ // Only text content
354
+ content = escapeText(this.textContent);
355
+ } else {
356
+ // Only children
357
+ content = childStr;
358
+ }
341
359
 
342
360
  if (this.children.length === 0) {
343
361
  return `${pad}<${this.tagName}${attrStr}>${content}</${this.tagName}>`;
344
362
  }
345
363
 
346
- return `${pad}<${this.tagName}${attrStr}>\n${content}\n${pad}</${this.tagName}>`;
364
+ return `${pad}<${this.tagName}${attrStr}>${separator}${content}${separator}${pad}</${this.tagName}>`;
347
365
  }
348
366
 
349
367
  /**
@@ -369,11 +387,30 @@ export class SVGElement {
369
387
  // INTERNAL PARSING FUNCTIONS
370
388
  // ============================================================================
371
389
 
390
+ /**
391
+ * Check if whitespace should be preserved based on xml:space attribute.
392
+ * BUG FIX 2: Helper function to check xml:space="preserve" on element or ancestors
393
+ * @private
394
+ */
395
+ function shouldPreserveWhitespace(element) {
396
+ let current = element;
397
+ while (current) {
398
+ const xmlSpace = current.getAttribute('xml:space');
399
+ if (xmlSpace === 'preserve') return true;
400
+ if (xmlSpace === 'default') return false;
401
+ current = current.parentNode;
402
+ }
403
+ return false;
404
+ }
405
+
372
406
  /**
373
407
  * Parse a single element from SVG string.
374
408
  * @private
409
+ * @param {string} str - SVG string to parse
410
+ * @param {number} pos - Current position in string
411
+ * @param {boolean} inheritPreserveSpace - Whether xml:space="preserve" is inherited from ancestor
375
412
  */
376
- function parseElement(str, pos) {
413
+ function parseElement(str, pos, inheritPreserveSpace = false) {
377
414
  // Skip whitespace and comments
378
415
  while (pos < str.length) {
379
416
  const ws = str.slice(pos).match(/^(\s+)/);
@@ -406,11 +443,23 @@ function parseElement(str, pos) {
406
443
  continue;
407
444
  }
408
445
 
409
- // Skip DOCTYPE
446
+ // Skip DOCTYPE (can contain internal subset with brackets)
410
447
  if (str.slice(pos, pos + 9).toUpperCase() === '<!DOCTYPE') {
411
- const endDoctype = str.indexOf('>', pos + 9);
412
- if (endDoctype === -1) break;
413
- pos = endDoctype + 1;
448
+ let depth = 0;
449
+ let i = pos + 9;
450
+ 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;
455
+ break;
456
+ }
457
+ i++;
458
+ }
459
+ if (i >= str.length) {
460
+ // Malformed DOCTYPE, skip to end
461
+ break;
462
+ }
414
463
  continue;
415
464
  }
416
465
 
@@ -506,7 +555,14 @@ function parseElement(str, pos) {
506
555
  }
507
556
  }
508
557
 
509
- const child = parseElement(str, pos);
558
+ // BUG FIX 1: Pass down xml:space inheritance to children
559
+ // 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);
510
566
  if (child.element) {
511
567
  children.push(child.element);
512
568
  pos = child.endPos;
@@ -534,7 +590,21 @@ function parseElement(str, pos) {
534
590
  }
535
591
  }
536
592
 
537
- const element = new SVGElement(tagName, attributes, children, textContent.trim());
593
+ // BUG FIX 1: Create element first to set up parent references
594
+ const element = new SVGElement(tagName, attributes, children, '');
595
+
596
+ // BUG FIX 1: Check xml:space on this element, otherwise use inherited value
597
+ // xml:space="preserve" means preserve whitespace
598
+ // xml:space="default" means collapse/trim whitespace
599
+ // 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());
606
+ element.textContent = processedText;
607
+
538
608
  return { element, endPos: pos };
539
609
  }
540
610
 
@@ -635,12 +705,96 @@ function escapeAttr(str) {
635
705
 
636
706
  /**
637
707
  * Unescape attribute value from XML.
708
+ * Handles numeric entities (decimal and hex) and named entities.
638
709
  * @private
639
710
  */
640
711
  function unescapeAttr(str) {
712
+ if (!str) return str;
641
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)
642
747
  .replace(/&quot;/g, '"')
643
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, '&');
754
+ }
755
+
756
+ /**
757
+ * Unescape text content from XML.
758
+ * Handles numeric entities (decimal and hex) and named entities.
759
+ * @private
760
+ */
761
+ function unescapeText(str) {
762
+ 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
644
798
  .replace(/&lt;/g, '<')
645
799
  .replace(/&gt;/g, '>')
646
800
  .replace(/&amp;/g, '&');
@@ -714,10 +868,15 @@ export function parseUrlReference(urlValue) {
714
868
  /**
715
869
  * Serialize SVG element tree back to string.
716
870
  * @param {SVGElement} root - Root element
871
+ * @param {Object} options - Serialization options
872
+ * @param {boolean} options.minify - Whether to minify output (no whitespace/newlines)
717
873
  * @returns {string}
718
874
  */
719
- export function serializeSVG(root) {
720
- return '<?xml version="1.0" encoding="UTF-8"?>\n' + root.serialize(0);
875
+ export function serializeSVG(root, options = {}) {
876
+ const minify = options.minify || false;
877
+ const xmlDecl = '<?xml version="1.0" encoding="UTF-8"?>';
878
+ const separator = minify ? '' : '\n';
879
+ return xmlDecl + separator + root.serialize(0, minify);
721
880
  }
722
881
 
723
882
  export default {