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.
- package/README.md +121 -0
- package/package.json +1 -1
- package/src/core/PPTXTemplater.js +186 -1
- package/src/managers/MediaManager.js +26 -98
- package/src/managers/ShapeManager.js +700 -0
- package/src/managers/TableManager.js +759 -69
|
@@ -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, '&')
|
|
1098
|
+
.replace(/</g, '<')
|
|
1099
|
+
.replace(/>/g, '>')
|
|
1100
|
+
.replace(/"/g, '"')
|
|
1101
|
+
.replace(/'/g, ''')
|
|
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 }
|