node-pptx-templater 1.0.18 → 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.
@@ -4,6 +4,7 @@
4
4
 
5
5
  const { createLogger } = require('../utils/logger.js')
6
6
  const { PPTXError } = require('../utils/errors.js')
7
+ const { Z_ORDER_SYMBOL } = require('../parsers/XMLParser.js')
7
8
 
8
9
  const logger = createLogger('ShapeManager')
9
10
 
@@ -225,6 +226,12 @@ class ShapeManager {
225
226
  }
226
227
  }
227
228
 
229
+ const zOrder = parent[Z_ORDER_SYMBOL]
230
+ if (zOrder && res.shape['p:nvSpPr']?.['p:cNvPr']?.['@_id']) {
231
+ const elementId = String(res.shape['p:nvSpPr']['p:cNvPr']['@_id'])
232
+ parent[Z_ORDER_SYMBOL] = zOrder.filter(id => String(id) !== elementId)
233
+ }
234
+
228
235
  slideManager.markSlideObjDirty(slideIndex)
229
236
  logger.debug(`Deleted shape "${shapeId}" from slide ${slideIndex}`)
230
237
  }
@@ -415,6 +422,699 @@ class ShapeManager {
415
422
  this.#collectShapesInfo(g, results)
416
423
  }
417
424
  }
425
+
426
+ /**
427
+ * Validates shape configuration options.
428
+ *
429
+ * @param {Object} options Shape configuration options.
430
+ * @returns {Array<string>} Array of validation error messages.
431
+ */
432
+ validateShape(options) {
433
+ const errors = []
434
+
435
+ if (!options) {
436
+ errors.push('Shape options object is missing.')
437
+ return errors
438
+ }
439
+
440
+ // Missing IDs
441
+ if (options.id === undefined || options.id === null || String(options.id).trim() === '') {
442
+ errors.push('Shape ID is missing or empty.')
443
+ }
444
+
445
+ // Unsupported shape types
446
+ const supportedTypes = [
447
+ 'rectangle',
448
+ 'square',
449
+ 'circle',
450
+ 'ellipse',
451
+ 'roundedRectangle',
452
+ 'triangle',
453
+ 'star5',
454
+ 'upArrow',
455
+ 'downArrow',
456
+ 'leftArrow',
457
+ 'rightArrow',
458
+ ]
459
+ if (!options.type) {
460
+ errors.push('Shape type is missing.')
461
+ } else if (!supportedTypes.includes(options.type)) {
462
+ errors.push(
463
+ `Unsupported shape type: "${options.type}". Supported types are: ${supportedTypes.join(
464
+ ', '
465
+ )}.`
466
+ )
467
+ }
468
+
469
+ // Invalid dimensions
470
+ if (options.type === 'square') {
471
+ if (options.size === undefined || typeof options.size !== 'number' || options.size <= 0) {
472
+ errors.push('Square "size" must be a positive number.')
473
+ }
474
+ } else if (options.type === 'circle') {
475
+ if (
476
+ options.radius === undefined ||
477
+ typeof options.radius !== 'number' ||
478
+ options.radius <= 0
479
+ ) {
480
+ errors.push('Circle "radius" must be a positive number.')
481
+ }
482
+ } else if (options.type) {
483
+ if (options.width === undefined || typeof options.width !== 'number' || options.width <= 0) {
484
+ errors.push(`${options.type} "width" must be a positive number.`)
485
+ }
486
+ if (
487
+ options.height === undefined ||
488
+ typeof options.height !== 'number' ||
489
+ options.height <= 0
490
+ ) {
491
+ errors.push(`${options.type} "height" must be a positive number.`)
492
+ }
493
+ }
494
+
495
+ if (options.x !== undefined && typeof options.x !== 'number') {
496
+ errors.push('Shape coordinate "x" must be a number.')
497
+ }
498
+ if (options.y !== undefined && typeof options.y !== 'number') {
499
+ errors.push('Shape coordinate "y" must be a number.')
500
+ }
501
+
502
+ const isValidHexColor = color => {
503
+ if (typeof color !== 'string') return false
504
+ return /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(color)
505
+ }
506
+
507
+ // Invalid colors
508
+ if (options.fill) {
509
+ if (typeof options.fill === 'string') {
510
+ if (!isValidHexColor(options.fill)) {
511
+ errors.push(`Invalid fill color: "${options.fill}". Must be a valid hex color.`)
512
+ }
513
+ } else if (typeof options.fill === 'object') {
514
+ if (options.fill.type === 'gradient') {
515
+ if (!Array.isArray(options.fill.colors) || options.fill.colors.length < 2) {
516
+ errors.push('Gradient fill must contain an array of at least 2 colors.')
517
+ } else {
518
+ options.fill.colors.forEach((color, idx) => {
519
+ if (!isValidHexColor(color)) {
520
+ errors.push(
521
+ `Invalid gradient fill color at index ${idx}: "${color}". Must be a valid hex color.`
522
+ )
523
+ }
524
+ })
525
+ }
526
+ } else {
527
+ errors.push('Invalid fill configuration.')
528
+ }
529
+ }
530
+ }
531
+
532
+ if (options.border) {
533
+ if (typeof options.border !== 'object') {
534
+ errors.push('Border option must be an object.')
535
+ } else {
536
+ if (options.border.color && !isValidHexColor(options.border.color)) {
537
+ errors.push(`Invalid border color: "${options.border.color}". Must be a valid hex color.`)
538
+ }
539
+ if (
540
+ options.border.width !== undefined &&
541
+ (typeof options.border.width !== 'number' || options.border.width <= 0)
542
+ ) {
543
+ errors.push('Border width must be a positive number.')
544
+ }
545
+ }
546
+ }
547
+
548
+ if (options.textStyle) {
549
+ if (typeof options.textStyle !== 'object') {
550
+ errors.push('TextStyle option must be an object.')
551
+ } else {
552
+ if (options.textStyle.color && !isValidHexColor(options.textStyle.color)) {
553
+ errors.push(
554
+ `Invalid text style color: "${options.textStyle.color}". Must be a valid hex color.`
555
+ )
556
+ }
557
+ if (
558
+ options.textStyle.fontSize !== undefined &&
559
+ (typeof options.textStyle.fontSize !== 'number' || options.textStyle.fontSize <= 0)
560
+ ) {
561
+ errors.push('Text style fontSize must be a positive number.')
562
+ }
563
+ }
564
+ }
565
+
566
+ // Invalid border radius
567
+ if (options.borderRadius !== undefined) {
568
+ if (options.type !== 'roundedRectangle') {
569
+ errors.push(`Shape type "${options.type}" does not support borderRadius.`)
570
+ } else if (typeof options.borderRadius !== 'number' || options.borderRadius <= 0) {
571
+ errors.push('Border radius must be a positive number.')
572
+ }
573
+ }
574
+
575
+ return errors
576
+ }
577
+
578
+ /**
579
+ * Adds a new shape to a slide.
580
+ */
581
+ addShape(slideIndex, options, slideManager) {
582
+ const errors = this.validateShape(options)
583
+ if (errors.length > 0) {
584
+ throw new PPTXError(`Shape validation failed:\n- ${errors.join('\n- ')}`)
585
+ }
586
+
587
+ const slideObj = slideManager.getSlideObj(slideIndex)
588
+ const spTree =
589
+ slideObj?.['p:sld']?.['p:cSld']?.['p:spTree'] ||
590
+ slideObj?.['p:sldLayout']?.['p:cSld']?.['p:spTree'] ||
591
+ slideObj?.['p:sldMaster']?.['p:cSld']?.['p:spTree']
592
+
593
+ if (!spTree) {
594
+ throw new PPTXError(`Invalid slide structure for slide ${slideIndex}`)
595
+ }
596
+
597
+ // Generate unique shape ID
598
+ const existingIds = this.#getAllShapeIds(spTree)
599
+ const maxId = existingIds.length > 0 ? Math.max(...existingIds) : 1000
600
+ const newId = maxId + 1
601
+
602
+ // Build shape XML
603
+ const type = options.type
604
+ let preset = 'rect'
605
+ let width = options.width || 100
606
+ let height = options.height || 100
607
+
608
+ if (type === 'square') {
609
+ preset = 'rect'
610
+ width = options.size || 100
611
+ height = options.size || 100
612
+ } else if (type === 'circle') {
613
+ preset = 'ellipse'
614
+ width = (options.radius || 50) * 2
615
+ height = (options.radius || 50) * 2
616
+ } else if (type === 'ellipse') {
617
+ preset = 'ellipse'
618
+ } else if (type === 'roundedRectangle') {
619
+ preset = 'roundRect'
620
+ } else if (
621
+ ['triangle', 'star5', 'upArrow', 'downArrow', 'leftArrow', 'rightArrow'].includes(type)
622
+ ) {
623
+ preset = type
624
+ }
625
+
626
+ const xEmu = Math.round((options.x || 0) * 9525)
627
+ const yEmu = Math.round((options.y || 0) * 9525)
628
+ const wEmu = Math.round(width * 9525)
629
+ const hEmu = Math.round(height * 9525)
630
+
631
+ const name = options.id || `${type.charAt(0).toUpperCase() + type.slice(1)} ${newId}`
632
+
633
+ // Fill properties
634
+ let fillXml = '<a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill>' // default
635
+ if (options.fill) {
636
+ if (typeof options.fill === 'string') {
637
+ const hex = options.fill.replace('#', '')
638
+ const alphaXml =
639
+ options.transparency !== undefined
640
+ ? `<a:alpha val="${Math.round((100 - options.transparency) * 1000)}"/>`
641
+ : ''
642
+ fillXml = `<a:solidFill><a:srgbClr val="${hex}">${alphaXml}</a:srgbClr></a:solidFill>`
643
+ } else if (typeof options.fill === 'object' && options.fill.type === 'gradient') {
644
+ const colors = options.fill.colors || []
645
+ const transparency = options.transparency
646
+ fillXml = `<a:gradFill flip="none" rotWithShape="1">
647
+ <a:gsLst>
648
+ ${colors
649
+ .map((color, i) => {
650
+ const pos = Math.round((i / (colors.length - 1)) * 100000)
651
+ const hexColor = color.replace('#', '')
652
+ const alphaXml =
653
+ transparency !== undefined
654
+ ? `<a:alpha val="${Math.round((100 - transparency) * 1000)}"/>`
655
+ : ''
656
+ return `<a:gs pos="${pos}"><a:srgbClr val="${hexColor}">${alphaXml}</a:srgbClr></a:gs>`
657
+ })
658
+ .join('')}
659
+ </a:gsLst>
660
+ <a:lin ang="5400000" scaled="1"/>
661
+ </a:gradFill>`
662
+ }
663
+ }
664
+
665
+ // Border properties
666
+ let borderXml = ''
667
+ if (options.border) {
668
+ const bColor = (options.border.color || '#000000').replace('#', '')
669
+ const bWidth = Math.round((options.border.width || 1) * 9525)
670
+ borderXml = `<a:ln w="${bWidth}">
671
+ <a:solidFill><a:srgbClr val="${bColor}"/></a:solidFill>
672
+ </a:ln>`
673
+ }
674
+
675
+ // Adjustments (border radius)
676
+ let avLstXml = '<a:avLst/>'
677
+ if (preset === 'roundRect') {
678
+ let adjVal = 16667 // PPT default
679
+ if (options.borderRadius !== undefined) {
680
+ const shorterSide = Math.min(width, height)
681
+ adjVal = Math.min(
682
+ 50000,
683
+ Math.max(0, Math.round((options.borderRadius / shorterSide) * 100000))
684
+ )
685
+ }
686
+ avLstXml = `<a:avLst><a:gd name="adj" fmla="val ${adjVal}"/></a:avLst>`
687
+ }
688
+
689
+ // Shadow properties
690
+ let shadowXml = ''
691
+ if (options.shadow) {
692
+ let blur = 5
693
+ let distance = 3
694
+ let opacity = 50
695
+ if (typeof options.shadow === 'object') {
696
+ if (options.shadow.blur !== undefined) blur = options.shadow.blur
697
+ if (options.shadow.distance !== undefined) distance = options.shadow.distance
698
+ if (options.shadow.opacity !== undefined) opacity = options.shadow.opacity
699
+ }
700
+ const blurEmu = Math.round(blur * 9525)
701
+ const distEmu = Math.round(distance * 9525)
702
+ const alphaVal = Math.round(opacity * 1000)
703
+ shadowXml = `<a:effectLst>
704
+ <a:outerShdw blurRad="${blurEmu}" dist="${distEmu}" dir="5400000" algn="tl" rotWithShape="0">
705
+ <a:srgbClr val="000000">
706
+ <a:alpha val="${alphaVal}"/>
707
+ </a:srgbClr>
708
+ </a:outerShdw>
709
+ </a:effectLst>`
710
+ }
711
+
712
+ // Rotation attribute
713
+ const rotAttr =
714
+ options.rotation !== undefined ? ` rot="${Math.round(options.rotation * 60000)}"` : ''
715
+
716
+ // Text box body properties
717
+ let txBodyXml = ''
718
+ if (options.text !== undefined && options.text !== null) {
719
+ const textStyle = options.textStyle || {}
720
+ const fontSizeVal = (textStyle.fontSize || 14) * 100
721
+ const boldAttr = textStyle.bold ? ' b="1"' : ''
722
+ const italicAttr = textStyle.italic ? ' i="1"' : ''
723
+
724
+ let alignAttr = ''
725
+ if (textStyle.align) {
726
+ const alignMap = { center: 'ctr', right: 'r', left: 'l', justify: 'just' }
727
+ const algn = alignMap[textStyle.align] || 'l'
728
+ alignAttr = `<a:pPr algn="${algn}"/>`
729
+ }
730
+
731
+ let colorFill = ''
732
+ if (textStyle.color) {
733
+ const colorHex = textStyle.color.replace('#', '')
734
+ colorFill = `<a:solidFill><a:srgbClr val="${colorHex}"/></a:solidFill>`
735
+ }
736
+
737
+ const lines = String(options.text).split(/\r?\n/)
738
+ const paragraphsXml = lines
739
+ .map(line => {
740
+ return `<a:p>
741
+ ${alignAttr}
742
+ <a:r>
743
+ <a:rPr lang="en-US" sz="${fontSizeVal}"${boldAttr}${italicAttr}>
744
+ ${colorFill}
745
+ </a:rPr>
746
+ <a:t>${escapeXml(line)}</a:t>
747
+ </a:r>
748
+ </a:p>`
749
+ })
750
+ .join('')
751
+
752
+ txBodyXml = `<p:txBody>
753
+ <a:bodyPr wrap="square" rtlCol="0">
754
+ <a:normAutofit/>
755
+ </a:bodyPr>
756
+ <a:lstStyle/>
757
+ ${paragraphsXml}
758
+ </p:txBody>`
759
+ }
760
+
761
+ // Build shape XML block
762
+ const shapeXml = `<p:sp>
763
+ <p:nvSpPr>
764
+ <p:cNvPr id="${newId}" name="${escapeXml(name)}"/>
765
+ <p:cNvSpPr/>
766
+ <p:nvPr/>
767
+ </p:nvSpPr>
768
+ <p:spPr>
769
+ <a:xfrm${rotAttr}>
770
+ <a:off x="${xEmu}" y="${yEmu}"/>
771
+ <a:ext cx="${wEmu}" cy="${hEmu}"/>
772
+ </a:xfrm>
773
+ <a:prstGeom prst="${preset}">${avLstXml}</a:prstGeom>
774
+ ${fillXml}
775
+ ${borderXml}
776
+ ${shadowXml}
777
+ </p:spPr>
778
+ ${txBodyXml}
779
+ </p:sp>`
780
+
781
+ const parsed = this.#xmlParser.parse(shapeXml, 'shape.xml')['p:sp']
782
+ const shapeObj = Array.isArray(parsed) ? parsed[0] : parsed
783
+
784
+ if (!spTree['p:sp']) {
785
+ spTree['p:sp'] = []
786
+ }
787
+ if (!Array.isArray(spTree['p:sp'])) {
788
+ spTree['p:sp'] = [spTree['p:sp']]
789
+ }
790
+ spTree['p:sp'].push(shapeObj)
791
+
792
+ if (spTree[Z_ORDER_SYMBOL] && !spTree[Z_ORDER_SYMBOL].includes(String(newId))) {
793
+ spTree[Z_ORDER_SYMBOL].push(String(newId))
794
+ }
795
+
796
+ slideManager.markSlideObjDirty(slideIndex)
797
+ logger.debug(`Added shape "${name}" with ID ${newId} to slide ${slideIndex}`)
798
+ }
799
+
800
+ /**
801
+ * Updates an existing shape in-place.
802
+ */
803
+ updateShape(slideIndex, shapeId, options, slideManager) {
804
+ const res = slideManager.getSlideShape(slideIndex, shapeId)
805
+
806
+ if (!res) {
807
+ throw new PPTXError(`Shape "${shapeId}" not found in slide ${slideIndex}`)
808
+ }
809
+
810
+ const shape = res.shape
811
+ const spPr = shape['p:spPr']
812
+
813
+ if (!spPr) {
814
+ throw new PPTXError(`Invalid shape structure for "${shapeId}" on slide ${slideIndex}`)
815
+ }
816
+
817
+ // Update coordinates & dimensions
818
+ let w = options.width
819
+ let h = options.height
820
+ if (options.size !== undefined) {
821
+ w = options.size
822
+ h = options.size
823
+ } else if (options.radius !== undefined) {
824
+ w = options.radius * 2
825
+ h = options.radius * 2
826
+ }
827
+
828
+ const xfrm = spPr['a:xfrm']
829
+ if (xfrm) {
830
+ if (options.x !== undefined) xfrm['a:off']['@_x'] = String(Math.round(options.x * 9525))
831
+ if (options.y !== undefined) xfrm['a:off']['@_y'] = String(Math.round(options.y * 9525))
832
+ if (w !== undefined) xfrm['a:ext']['@_cx'] = String(Math.round(w * 9525))
833
+ if (h !== undefined) xfrm['a:ext']['@_cy'] = String(Math.round(h * 9525))
834
+
835
+ // Update rotation
836
+ if (options.rotation !== undefined) {
837
+ if (options.rotation === null) {
838
+ delete xfrm['@_rot']
839
+ } else {
840
+ xfrm['@_rot'] = String(Math.round(options.rotation * 60000))
841
+ }
842
+ }
843
+ }
844
+
845
+ // Update preset geometry type if specified
846
+ if (options.type) {
847
+ let preset = 'rect'
848
+ if (options.type === 'square') preset = 'rect'
849
+ else if (options.type === 'circle' || options.type === 'ellipse') preset = 'ellipse'
850
+ else if (options.type === 'roundedRectangle') preset = 'roundRect'
851
+ else if (
852
+ ['triangle', 'star5', 'upArrow', 'downArrow', 'leftArrow', 'rightArrow'].includes(
853
+ options.type
854
+ )
855
+ ) {
856
+ preset = options.type
857
+ }
858
+ if (spPr['a:prstGeom']) {
859
+ spPr['a:prstGeom']['@_prst'] = preset
860
+ }
861
+ }
862
+
863
+ // Update fill properties
864
+ let fillXml = ''
865
+ if (options.fill) {
866
+ if (typeof options.fill === 'string') {
867
+ const hex = options.fill.replace('#', '')
868
+ const alphaXml =
869
+ options.transparency !== undefined
870
+ ? `<a:alpha val="${Math.round((100 - options.transparency) * 1000)}"/>`
871
+ : ''
872
+ fillXml = `<a:solidFill><a:srgbClr val="${hex}">${alphaXml}</a:srgbClr></a:solidFill>`
873
+ } else if (typeof options.fill === 'object' && options.fill.type === 'gradient') {
874
+ const colors = options.fill.colors || []
875
+ const transparency = options.transparency
876
+ fillXml = `<a:gradFill flip="none" rotWithShape="1">
877
+ <a:gsLst>
878
+ ${colors
879
+ .map((color, i) => {
880
+ const pos = Math.round((i / (colors.length - 1)) * 100000)
881
+ const hexColor = color.replace('#', '')
882
+ const alphaXml =
883
+ transparency !== undefined
884
+ ? `<a:alpha val="${Math.round((100 - transparency) * 1000)}"/>`
885
+ : ''
886
+ return `<a:gs pos="${pos}"><a:srgbClr val="${hexColor}">${alphaXml}</a:srgbClr></a:gs>`
887
+ })
888
+ .join('')}
889
+ </a:gsLst>
890
+ <a:lin ang="5400000" scaled="1"/>
891
+ </a:gradFill>`
892
+ }
893
+
894
+ if (fillXml) {
895
+ delete spPr['a:noFill']
896
+ delete spPr['a:solidFill']
897
+ delete spPr['a:gradFill']
898
+ delete spPr['a:pattFill']
899
+ delete spPr['a:grpFill']
900
+ const parsedFill = this.#xmlParser.parse(fillXml, 'fill.xml')
901
+ const fillKey = Object.keys(parsedFill)[0]
902
+ const fillVal = parsedFill[fillKey]
903
+ spPr[fillKey] = Array.isArray(fillVal) ? fillVal[0] : fillVal
904
+ }
905
+ } else if (options.transparency !== undefined) {
906
+ // Transparency only
907
+ const solidFill = spPr['a:solidFill']
908
+ if (solidFill && solidFill['a:srgbClr']) {
909
+ const srgbClr = solidFill['a:srgbClr']
910
+ srgbClr['a:alpha'] = { '@_val': String(Math.round((100 - options.transparency) * 1000)) }
911
+ }
912
+ }
913
+
914
+ // Update border properties
915
+ if (options.border) {
916
+ delete spPr['a:ln']
917
+ const bColor = (options.border.color || '#000000').replace('#', '')
918
+ const bWidth = Math.round((options.border.width || 1) * 9525)
919
+ const borderXml = `<a:ln w="${bWidth}">
920
+ <a:solidFill><a:srgbClr val="${bColor}"/></a:solidFill>
921
+ </a:ln>`
922
+ const parsedBorder = this.#xmlParser.parse(borderXml, 'border.xml')['a:ln']
923
+ spPr['a:ln'] = Array.isArray(parsedBorder) ? parsedBorder[0] : parsedBorder
924
+ }
925
+
926
+ // Update shadow properties
927
+ if (options.shadow !== undefined) {
928
+ delete spPr['a:effectLst']
929
+ if (options.shadow) {
930
+ let blur = 5
931
+ let distance = 3
932
+ let opacity = 50
933
+ if (typeof options.shadow === 'object') {
934
+ if (options.shadow.blur !== undefined) blur = options.shadow.blur
935
+ if (options.shadow.distance !== undefined) distance = options.shadow.distance
936
+ if (options.shadow.opacity !== undefined) opacity = options.shadow.opacity
937
+ }
938
+ const blurEmu = Math.round(blur * 9525)
939
+ const distEmu = Math.round(distance * 9525)
940
+ const alphaVal = Math.round(opacity * 1000)
941
+ const shadowXml = `<a:effectLst>
942
+ <a:outerShdw blurRad="${blurEmu}" dist="${distEmu}" dir="5400000" algn="tl" rotWithShape="0">
943
+ <a:srgbClr val="000000">
944
+ <a:alpha val="${alphaVal}"/>
945
+ </a:srgbClr>
946
+ </a:outerShdw>
947
+ </a:effectLst>`
948
+ const parsedShadow = this.#xmlParser.parse(shadowXml, 'shadow.xml')['a:effectLst']
949
+ spPr['a:effectLst'] = Array.isArray(parsedShadow) ? parsedShadow[0] : parsedShadow
950
+ }
951
+ }
952
+
953
+ // Update border radius
954
+ if (options.borderRadius !== undefined) {
955
+ const prstGeom = spPr['a:prstGeom']
956
+ if (prstGeom && prstGeom['@_prst'] === 'roundRect') {
957
+ let curW = 100
958
+ let curH = 100
959
+ if (xfrm) {
960
+ curW = parseInt(xfrm['a:ext']?.['@_cx'] || 0, 10) / 9525 || 100
961
+ curH = parseInt(xfrm['a:ext']?.['@_cy'] || 0, 10) / 9525 || 100
962
+ }
963
+ const shorterSide = Math.min(curW, curH)
964
+ const adjVal = Math.min(
965
+ 50000,
966
+ Math.max(0, Math.round((options.borderRadius / shorterSide) * 100000))
967
+ )
968
+ const avLstXml = `<a:avLst><a:gd name="adj" fmla="val ${adjVal}"/></a:avLst>`
969
+ const parsedAvLst = this.#xmlParser.parse(avLstXml, 'avLst.xml')['a:avLst']
970
+ prstGeom['a:avLst'] = Array.isArray(parsedAvLst) ? parsedAvLst[0] : parsedAvLst
971
+ }
972
+ }
973
+
974
+ // Update text
975
+ if (options.text !== undefined || options.textStyle !== undefined) {
976
+ let textVal = options.text
977
+ if (textVal === undefined) {
978
+ textVal = this.getShapeText(shape) || ''
979
+ }
980
+
981
+ const textStyle = options.textStyle || {}
982
+ const fontSizeVal = (textStyle.fontSize || 14) * 100
983
+ const boldAttr = textStyle.bold ? ' b="1"' : ''
984
+ const italicAttr = textStyle.italic ? ' i="1"' : ''
985
+
986
+ let alignAttr = ''
987
+ if (textStyle.align) {
988
+ const alignMap = { center: 'ctr', right: 'r', left: 'l', justify: 'just' }
989
+ const algn = alignMap[textStyle.align] || 'l'
990
+ alignAttr = `<a:pPr algn="${algn}"/>`
991
+ }
992
+
993
+ let colorFill = ''
994
+ if (textStyle.color) {
995
+ const colorHex = textStyle.color.replace('#', '')
996
+ colorFill = `<a:solidFill><a:srgbClr val="${colorHex}"/></a:solidFill>`
997
+ }
998
+
999
+ const lines = String(textVal).split(/\r?\n/)
1000
+ const paragraphsXml = lines
1001
+ .map(line => {
1002
+ return `<a:p>
1003
+ ${alignAttr}
1004
+ <a:r>
1005
+ <a:rPr lang="en-US" sz="${fontSizeVal}"${boldAttr}${italicAttr}>
1006
+ ${colorFill}
1007
+ </a:rPr>
1008
+ <a:t>${escapeXml(line)}</a:t>
1009
+ </a:r>
1010
+ </a:p>`
1011
+ })
1012
+ .join('')
1013
+
1014
+ const txBodyXml = `<p:txBody>
1015
+ <a:bodyPr wrap="square" rtlCol="0">
1016
+ <a:normAutofit/>
1017
+ </a:bodyPr>
1018
+ <a:lstStyle/>
1019
+ ${paragraphsXml}
1020
+ </p:txBody>`
1021
+
1022
+ const parsedTxBody = this.#xmlParser.parse(txBodyXml, 'txBody.xml')['p:txBody']
1023
+ shape['p:txBody'] = Array.isArray(parsedTxBody) ? parsedTxBody[0] : parsedTxBody
1024
+ }
1025
+
1026
+ slideManager.markSlideObjDirty(slideIndex)
1027
+ logger.debug(`Updated shape "${shapeId}" on slide ${slideIndex}`)
1028
+ }
1029
+
1030
+ /**
1031
+ * Removes a shape from a slide (alias for deleteShape).
1032
+ */
1033
+ removeShape(slideIndex, shapeId, slideManager) {
1034
+ this.deleteShape(slideIndex, shapeId, slideManager)
1035
+ }
1036
+
1037
+ /**
1038
+ * Helper to get existing text of a shape.
1039
+ */
1040
+ getShapeText(shape) {
1041
+ const txBody = shape['p:txBody']
1042
+ if (txBody && txBody['a:p']) {
1043
+ const paras = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
1044
+ const textParts = []
1045
+ for (const p of paras) {
1046
+ if (p['a:r']) {
1047
+ const runs = Array.isArray(p['a:r']) ? p['a:r'] : [p['a:r']]
1048
+ for (const r of runs) {
1049
+ if (r['a:t']) textParts.push(String(r['a:t']))
1050
+ }
1051
+ }
1052
+ }
1053
+ return textParts.join('\n')
1054
+ }
1055
+ return ''
1056
+ }
1057
+
1058
+ /**
1059
+ * Gets details of a shape.
1060
+ */
1061
+ getShape(slideIndex, shapeId, slideManager) {
1062
+ const res = slideManager.getSlideShape(slideIndex, shapeId)
1063
+ if (!res) return null
1064
+
1065
+ const cNvPr = res.shape['p:nvSpPr']?.['p:cNvPr']
1066
+ const xfrm = res.shape['p:spPr']?.['a:xfrm']
1067
+ const prstGeom = res.shape['p:spPr']?.['a:prstGeom']
1068
+
1069
+ const id = cNvPr ? cNvPr['@_name'] || String(cNvPr['@_id']) : shapeId
1070
+ const preset = prstGeom ? prstGeom['@_prst'] : 'rect'
1071
+
1072
+ const xEmu = xfrm?.['a:off']?.['@_x'] ? parseInt(xfrm['a:off']['@_x'], 10) : 0
1073
+ const yEmu = xfrm?.['a:off']?.['@_y'] ? parseInt(xfrm['a:off']['@_y'], 10) : 0
1074
+ const wEmu = xfrm?.['a:ext']?.['@_cx'] ? parseInt(xfrm['a:ext']['@_cx'], 10) : 0
1075
+ const hEmu = xfrm?.['a:ext']?.['@_cy'] ? parseInt(xfrm['a:ext']['@_cy'], 10) : 0
1076
+
1077
+ const x = Math.round(xEmu / 9525)
1078
+ const y = Math.round(yEmu / 9525)
1079
+ const width = Math.round(wEmu / 9525)
1080
+ const height = Math.round(hEmu / 9525)
1081
+
1082
+ const type = mapPresetToType(preset, width, height)
1083
+
1084
+ return {
1085
+ id,
1086
+ type,
1087
+ x,
1088
+ y,
1089
+ width,
1090
+ height,
1091
+ }
1092
+ }
1093
+ }
1094
+
1095
+ function escapeXml(str) {
1096
+ return String(str)
1097
+ .replace(/&/g, '&amp;')
1098
+ .replace(/</g, '&lt;')
1099
+ .replace(/>/g, '&gt;')
1100
+ .replace(/"/g, '&quot;')
1101
+ .replace(/'/g, '&apos;')
1102
+ }
1103
+
1104
+ function mapPresetToType(preset, w, h) {
1105
+ if (preset === 'rect') {
1106
+ return w === h ? 'square' : 'rectangle'
1107
+ }
1108
+ if (preset === 'ellipse') {
1109
+ return w === h ? 'circle' : 'ellipse'
1110
+ }
1111
+ if (preset === 'roundRect') {
1112
+ return 'roundedRectangle'
1113
+ }
1114
+ if (['triangle', 'star5', 'upArrow', 'downArrow', 'leftArrow', 'rightArrow'].includes(preset)) {
1115
+ return preset
1116
+ }
1117
+ return 'rectangle'
418
1118
  }
419
1119
 
420
1120
  module.exports = { ShapeManager }