node-pptx-templater 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.
@@ -47,6 +47,95 @@ class ShapeManager {
47
47
  logger.debug(`Updated text for shape "${shapeId}" on slide ${slideIndex}`)
48
48
  }
49
49
 
50
+ /**
51
+ * Updates an existing shape's position and/or dimensions.
52
+ *
53
+ * @param {number} slideIndex
54
+ * @param {string} shapeId
55
+ * @param {Object} options Position and dimensions configuration.
56
+ * @param {number} [options.x] Absolute X offset coordinate (in EMUs).
57
+ * @param {number} [options.y] Absolute Y offset coordinate (in EMUs).
58
+ * @param {number} [options.width] Bounding box width (in EMUs).
59
+ * @param {number} [options.height] Bounding box height (in EMUs).
60
+ * @param {SlideManager} slideManager
61
+ */
62
+ updateShapePosition(slideIndex, shapeId, options = {}, slideManager) {
63
+ const slideXml = slideManager.getSlideXml(slideIndex)
64
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
65
+ const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
66
+ const res = this.findShapeRecursive(spTree, shapeId)
67
+
68
+ if (!res) {
69
+ throw new PPTXError(`Shape "${shapeId}" not found in slide ${slideIndex}`)
70
+ }
71
+
72
+ if (!res.shape['p:spPr']) {
73
+ res.shape['p:spPr'] = {}
74
+ }
75
+ if (!res.shape['p:spPr']['a:xfrm']) {
76
+ res.shape['p:spPr']['a:xfrm'] = {}
77
+ }
78
+ const xfrm = res.shape['p:spPr']['a:xfrm']
79
+
80
+ if (!xfrm['a:off']) {
81
+ xfrm['a:off'] = {}
82
+ }
83
+ if (!xfrm['a:ext']) {
84
+ xfrm['a:ext'] = {}
85
+ }
86
+
87
+ if (options.x !== undefined) {
88
+ xfrm['a:off']['@_x'] = String(Math.round(options.x))
89
+ } else if (xfrm['a:off']['@_x'] === undefined) {
90
+ xfrm['a:off']['@_x'] = '0'
91
+ }
92
+
93
+ if (options.y !== undefined) {
94
+ xfrm['a:off']['@_y'] = String(Math.round(options.y))
95
+ } else if (xfrm['a:off']['@_y'] === undefined) {
96
+ xfrm['a:off']['@_y'] = '0'
97
+ }
98
+
99
+ if (options.width !== undefined) {
100
+ xfrm['a:ext']['@_cx'] = String(Math.round(options.width))
101
+ } else if (xfrm['a:ext']['@_cx'] === undefined) {
102
+ xfrm['a:ext']['@_cx'] = '0'
103
+ }
104
+
105
+ if (options.height !== undefined) {
106
+ xfrm['a:ext']['@_cy'] = String(Math.round(options.height))
107
+ } else if (xfrm['a:ext']['@_cy'] === undefined) {
108
+ xfrm['a:ext']['@_cy'] = '0'
109
+ }
110
+
111
+ const decl = this.#xmlParser.extractDeclaration(slideXml)
112
+ slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
113
+ logger.debug(`Updated position/dimensions for shape "${shapeId}" on slide ${slideIndex}`)
114
+ }
115
+
116
+ /**
117
+ * Updates an existing textbox shape's position and/or dimensions.
118
+ *
119
+ * @param {number} slideIndex
120
+ * @param {string} textBoxId
121
+ * @param {Object} options Position and dimensions configuration.
122
+ * @param {number} [options.x] Absolute X offset coordinate (in EMUs).
123
+ * @param {number} [options.y] Absolute Y offset coordinate (in EMUs).
124
+ * @param {number} [options.width] Bounding box width (in EMUs).
125
+ * @param {number} [options.height] Bounding box height (in EMUs).
126
+ * @param {SlideManager} slideManager
127
+ */
128
+ updateTextBoxPosition(slideIndex, textBoxId, options = {}, slideManager) {
129
+ try {
130
+ this.updateShapePosition(slideIndex, textBoxId, options, slideManager)
131
+ } catch (err) {
132
+ if (err.message.includes('not found')) {
133
+ throw new PPTXError(`Textbox "${textBoxId}" not found in slide ${slideIndex}`)
134
+ }
135
+ throw err
136
+ }
137
+ }
138
+
50
139
  /**
51
140
  * Clones a shape and adds it with offsets.
52
141
  *
@@ -170,6 +170,58 @@ class ChartCacheGenerator {
170
170
  *
171
171
  * 3. If no <c:title> block exists at all: create a minimal one.
172
172
  */
173
+ /**
174
+ * Extracts parts of <c:txPr> block (bodyPr, pPr, and converts defRPr to rPr)
175
+ * to use when creating custom rich text components.
176
+ *
177
+ * @param {string} txPrXml - The <c:txPr> XML string or block content.
178
+ * @returns {Object} { bodyPr, pPrXml, rPrXml }
179
+ */
180
+ static extractTxPrParts(txPrXml) {
181
+ let bodyPr = '<a:bodyPr/>'
182
+ let pPrXml = ''
183
+ let rPrXml = ''
184
+
185
+ if (!txPrXml) {
186
+ return { bodyPr, pPrXml, rPrXml }
187
+ }
188
+
189
+ let txPrContent = txPrXml
190
+ const txPrInnerMatch = /<c:txPr>([\s\S]*?)<\/c:txPr>/.exec(txPrXml)
191
+ if (txPrInnerMatch) {
192
+ txPrContent = txPrInnerMatch[1]
193
+ }
194
+
195
+ // Extract <a:bodyPr>
196
+ const bodyPrMatch = /(<a:bodyPr[^>]*\/>|<a:bodyPr[^>]*>[\s\S]*?<\/a:bodyPr>)/.exec(txPrContent)
197
+ if (bodyPrMatch) bodyPr = bodyPrMatch[1]
198
+
199
+ // Extract <a:defRPr> → convert to <a:rPr> for the run
200
+ const defRPrMatch = /(<a:defRPr[\s\S]*?<\/a:defRPr>|<a:defRPr[^>]*\/>)/.exec(txPrContent)
201
+ if (defRPrMatch) {
202
+ rPrXml = defRPrMatch[1].replace(/^<a:defRPr/, '<a:rPr').replace(/<\/a:defRPr>$/, '</a:rPr>')
203
+ }
204
+
205
+ // Extract <a:pPr> (keeps algn, indent, etc.) but strip <a:defRPr> from it
206
+ const pPrBlockMatch = /(<a:pPr[^>]*>)([\s\S]*?)(<\/a:pPr>)/.exec(txPrContent)
207
+ if (pPrBlockMatch) {
208
+ const innerContent = pPrBlockMatch[2]
209
+ .replace(/<a:defRPr(?:[^>]*\/>|[\s\S]*?<\/a:defRPr>)/g, '')
210
+ .trim()
211
+ const attrs = pPrBlockMatch[1].slice(7, -1).trim()
212
+ if (attrs || innerContent) {
213
+ pPrXml = innerContent
214
+ ? `${pPrBlockMatch[1]}${innerContent}${pPrBlockMatch[3]}`
215
+ : `<a:pPr ${attrs}/>`
216
+ }
217
+ } else {
218
+ const scPPrMatch = /(<a:pPr[^>]*\/>)/.exec(txPrContent)
219
+ if (scPPrMatch) pPrXml = scPPrMatch[1]
220
+ }
221
+
222
+ return { bodyPr, pPrXml, rPrXml }
223
+ }
224
+
173
225
  static updateTitle(xml, title) {
174
226
  // Split by \n so callers can drive multi-paragraph titles
175
227
  const titleLines = title.split('\n')
@@ -205,48 +257,7 @@ class ChartCacheGenerator {
205
257
 
206
258
  // ── Strategy 2 ──────────────────────────────────────────────────────────────
207
259
  // No <c:tx> yet – build one from <c:txPr> styles.
208
- let bodyPr = '<a:bodyPr/>'
209
- let pPrXml = '' // paragraph properties (alignment etc.) without defRPr
210
- let rPr = '' // run properties from defRPr
211
-
212
- const txPrMatch = /<c:txPr>([\s\S]*?)<\/c:txPr>/.exec(titleContent)
213
- if (txPrMatch) {
214
- const txPrContent = txPrMatch[1]
215
-
216
- // Extract <a:bodyPr>
217
- const bodyPrMatch = /(<a:bodyPr[^>]*\/>|<a:bodyPr[^>]*>[\s\S]*?<\/a:bodyPr>)/.exec(
218
- txPrContent
219
- )
220
- if (bodyPrMatch) bodyPr = bodyPrMatch[1]
221
-
222
- // Extract <a:defRPr> → convert to <a:rPr> for the run
223
- const defRPrMatch = /(<a:defRPr[\s\S]*?<\/a:defRPr>|<a:defRPr[^>]*\/>)/.exec(txPrContent)
224
- if (defRPrMatch) {
225
- rPr = defRPrMatch[1].replace(/^<a:defRPr/, '<a:rPr').replace(/<\/a:defRPr>$/, '</a:rPr>')
226
- }
227
-
228
- // Extract <a:pPr> (keeps algn, indent, etc.) but strip <a:defRPr> from it
229
- // since that is now expressed as <a:rPr> in the run.
230
- // We must handle both <a:defRPr .../> (self-closing) and
231
- // <a:defRPr ...>...</a:defRPr> (element with children).
232
- const pPrBlockMatch = /(<a:pPr[^>]*>)([\s\S]*?)(<\/a:pPr>)/.exec(txPrContent)
233
- if (pPrBlockMatch) {
234
- const innerContent = pPrBlockMatch[2]
235
- .replace(/<a:defRPr(?:[^>]*\/>|[\s\S]*?<\/a:defRPr>)/g, '')
236
- .trim()
237
- // Only emit pPr tag if it has attributes or remaining child content
238
- const attrs = pPrBlockMatch[1].slice(7, -1).trim() // strip '<a:pPr' and '>'
239
- if (attrs || innerContent) {
240
- pPrXml = innerContent
241
- ? `${pPrBlockMatch[1]}${innerContent}${pPrBlockMatch[3]}`
242
- : `<a:pPr ${attrs}/>`
243
- }
244
- } else {
245
- // Self-closing <a:pPr .../> (no children)
246
- const scPPrMatch = /(<a:pPr[^>]*\/>)/.exec(txPrContent)
247
- if (scPPrMatch) pPrXml = scPPrMatch[1]
248
- }
249
- }
260
+ const { bodyPr, pPrXml, rPrXml: rPr } = this.extractTxPrParts(titleContent)
250
261
 
251
262
  // Build one <a:p> per title line, each with the same pPr + rPr
252
263
  const paragraphs = titleLines
@@ -282,16 +293,17 @@ class ChartCacheGenerator {
282
293
  if (countMatch) pointsCount = parseInt(countMatch[1], 10)
283
294
  }
284
295
  }
285
- if (pointsCount === 0 && options.labels) {
286
- pointsCount = options.labels.length
287
- }
288
296
 
289
- // Parse existing styling and flags from the current <c:dLbls> block
290
297
  let existingTxPr = ''
291
298
  let existingDLblPos = ''
292
299
  let existingNumFmt = ''
293
300
  let existingSpPr = ''
301
+ let existingExtLst = ''
302
+ let existingShowLeaderLines = ''
294
303
  const existingDLblSpPrs = {}
304
+ const existingDLblTxPrs = {}
305
+ const existingDLblLayouts = {}
306
+ const existingDLblPositions = {}
295
307
  const existingShowTags = {}
296
308
 
297
309
  const dLblsMatch = /<c:dLbls>([\s\S]*?)<\/c:dLbls>/.exec(content)
@@ -313,8 +325,16 @@ class ChartCacheGenerator {
313
325
  if (spPrMatch) {
314
326
  existingSpPr = spPrMatch[1]
315
327
  }
328
+ const extLstMatch = /(<c:extLst>[\s\S]*?<\/c:extLst>)/.exec(dLblsContent)
329
+ if (extLstMatch) {
330
+ existingExtLst = extLstMatch[1]
331
+ }
332
+ const showLeaderMatch = /(<c:showLeaderLines\s+[^>]*\/>)/.exec(dLblsContent)
333
+ if (showLeaderMatch) {
334
+ existingShowLeaderLines = showLeaderMatch[1]
335
+ }
316
336
 
317
- // Parse individual <c:dLbl> shape properties to map their background fills
337
+ // Parse individual <c:dLbl> properties: fills, text styling, layouts, and positions
318
338
  const dLblPattern = /<c:dLbl>([\s\S]*?)<\/c:dLbl>/g
319
339
  let dLblMatch
320
340
  while ((dLblMatch = dLblPattern.exec(dLblsContent)) !== null) {
@@ -326,6 +346,18 @@ class ChartCacheGenerator {
326
346
  if (dLblSpPrMatch) {
327
347
  existingDLblSpPrs[idx] = dLblSpPrMatch[1]
328
348
  }
349
+ const dLblTxPrMatch = /(<c:txPr>[\s\S]*?<\/c:txPr>)/.exec(dLblContent)
350
+ if (dLblTxPrMatch) {
351
+ existingDLblTxPrs[idx] = dLblTxPrMatch[1]
352
+ }
353
+ const dLblLayoutMatch = /(<c:layout>[\s\S]*?<\/c:layout>|<c:layout\/>)/.exec(dLblContent)
354
+ if (dLblLayoutMatch) {
355
+ existingDLblLayouts[idx] = dLblLayoutMatch[1]
356
+ }
357
+ const dLblPosMatch = /(<c:dLblPos\s+[^>]*\/>)/.exec(dLblContent)
358
+ if (dLblPosMatch) {
359
+ existingDLblPositions[idx] = dLblPosMatch[1]
360
+ }
329
361
  }
330
362
  }
331
363
 
@@ -356,7 +388,12 @@ class ChartCacheGenerator {
356
388
  existingNumFmt,
357
389
  existingShowTags,
358
390
  existingSpPr,
359
- existingDLblSpPrs
391
+ existingDLblSpPrs,
392
+ existingDLblTxPrs,
393
+ existingDLblLayouts,
394
+ existingDLblPositions,
395
+ existingExtLst,
396
+ existingShowLeaderLines
360
397
  )
361
398
 
362
399
  let updatedContent = content
@@ -386,9 +423,22 @@ class ChartCacheGenerator {
386
423
  existingNumFmt = '',
387
424
  existingShowTags = {},
388
425
  existingSpPr = '',
389
- existingDLblSpPrs = {}
426
+ existingDLblSpPrs = {},
427
+ existingDLblTxPrs = {},
428
+ existingDLblLayouts = {},
429
+ existingDLblPositions = {},
430
+ existingExtLst = '',
431
+ existingShowLeaderLines = ''
390
432
  ) {
391
- const { labels, labelsFromCells, template, position, labelStyle, labelMap } = options
433
+ const {
434
+ labels,
435
+ labelsFromCells,
436
+ template,
437
+ position,
438
+ labelStyle,
439
+ labelMap,
440
+ showSeriesNameInBar,
441
+ } = options
392
442
 
393
443
  let xml = '<c:dLbls>'
394
444
 
@@ -403,7 +453,10 @@ class ChartCacheGenerator {
403
453
  top: 't',
404
454
  bottom: 'b',
405
455
  }
406
- const openxmlPos = position ? posMap[position] : null
456
+ let openxmlPos = position ? posMap[position] : null
457
+ if (!openxmlPos && showSeriesNameInBar) {
458
+ openxmlPos = 'ctr'
459
+ }
407
460
 
408
461
  const values = seriesData.values || []
409
462
  const sumValues = values.reduce((sum, v) => sum + (Number(v) || 0), 0)
@@ -441,6 +494,11 @@ class ChartCacheGenerator {
441
494
  xml += `<c:dLbl>`
442
495
  xml += `<c:idx val="${i}"/>`
443
496
 
497
+ // Restore per-point layout (manual position offsets) from template
498
+ if (existingDLblLayouts && existingDLblLayouts[i]) {
499
+ xml += existingDLblLayouts[i]
500
+ }
501
+
444
502
  if (labelsFromCells && !template) {
445
503
  const range = ChartWorkbookUpdater.parseCellRange(labelsFromCells)
446
504
  const startColNum = ChartWorkbookUpdater.colLetterToNum(range.startCol)
@@ -485,15 +543,30 @@ class ChartCacheGenerator {
485
543
  xml += `</c:strRef>`
486
544
  xml += `</c:tx>`
487
545
  } else {
546
+ let bodyPr = '<a:bodyPr/>'
547
+ let pPrXml = ''
548
+ let rPrXml = ''
549
+ const dLblTxPr = (existingDLblTxPrs && existingDLblTxPrs[i]) || existingTxPr
550
+ if (dLblTxPr) {
551
+ const extracted = this.extractTxPrParts(dLblTxPr)
552
+ bodyPr = extracted.bodyPr
553
+ pPrXml = extracted.pPrXml
554
+ rPrXml = extracted.rPrXml
555
+ }
556
+
557
+ const labelLines = String(textContent).split('\n')
558
+ const paragraphs = labelLines
559
+ .map(line => {
560
+ const escapedLine = this.#escapeXml(line)
561
+ return `<a:p>${pPrXml}<a:r>${rPrXml}<a:t>${escapedLine}</a:t></a:r></a:p>`
562
+ })
563
+ .join('')
564
+
488
565
  xml += `<c:tx>`
489
566
  xml += `<c:rich>`
490
- xml += `<a:bodyPr/>`
567
+ xml += bodyPr
491
568
  xml += `<a:lstStyle/>`
492
- xml += `<a:p>`
493
- xml += `<a:r>`
494
- xml += `<a:t>${this.#escapeXml(textContent)}</a:t>`
495
- xml += `</a:r>`
496
- xml += `</a:p>`
569
+ xml += paragraphs
497
570
  xml += `</c:rich>`
498
571
  xml += `</c:tx>`
499
572
  }
@@ -520,12 +593,16 @@ class ChartCacheGenerator {
520
593
 
521
594
  if (labelStyle) {
522
595
  xml += this.generateTxPrXml(labelStyle)
596
+ } else if (existingDLblTxPrs && existingDLblTxPrs[i]) {
597
+ xml += existingDLblTxPrs[i]
523
598
  } else if (existingTxPr) {
524
599
  xml += existingTxPr
525
600
  }
526
601
 
527
602
  if (openxmlPos) {
528
603
  xml += `<c:dLblPos val="${openxmlPos}"/>`
604
+ } else if (existingDLblPositions && existingDLblPositions[i]) {
605
+ xml += existingDLblPositions[i]
529
606
  } else if (existingDLblPos) {
530
607
  xml += existingDLblPos
531
608
  }
@@ -571,7 +648,9 @@ class ChartCacheGenerator {
571
648
 
572
649
  // showVal
573
650
  const defaultShowVal = hasCustomLabels ? '0' : '1'
574
- if (existingShowTags['showVal'] && !hasCustomLabels) {
651
+ if (showSeriesNameInBar) {
652
+ xml += `<c:showVal val="0"/>`
653
+ } else if (existingShowTags['showVal'] && !hasCustomLabels) {
575
654
  xml += existingShowTags['showVal']
576
655
  } else {
577
656
  xml += `<c:showVal val="${defaultShowVal}"/>`
@@ -585,7 +664,9 @@ class ChartCacheGenerator {
585
664
  }
586
665
 
587
666
  // showSerName
588
- if (existingShowTags['showSerName']) {
667
+ if (showSeriesNameInBar) {
668
+ xml += `<c:showSerName val="1"/>`
669
+ } else if (existingShowTags['showSerName']) {
589
670
  xml += existingShowTags['showSerName']
590
671
  } else {
591
672
  xml += `<c:showSerName val="0"/>`
@@ -608,6 +689,14 @@ class ChartCacheGenerator {
608
689
  xml += `<c:showBubbleSize val="0"/>`
609
690
  }
610
691
 
692
+ // Restore showLeaderLines and extension list from template
693
+ if (existingShowLeaderLines) {
694
+ xml += existingShowLeaderLines
695
+ }
696
+ if (existingExtLst) {
697
+ xml += existingExtLst
698
+ }
699
+
611
700
  xml += '</c:dLbls>'
612
701
  return xml
613
702
  }