node-pptx-templater 1.0.9 → 1.0.11

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,76 @@ 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
+ const xfrm = res.shape['p:spPr']?.['a:xfrm']
73
+ if (xfrm) {
74
+ if (options.x !== undefined) {
75
+ if (!xfrm['a:off']) xfrm['a:off'] = {}
76
+ xfrm['a:off']['@_x'] = String(Math.round(options.x))
77
+ }
78
+ if (options.y !== undefined) {
79
+ if (!xfrm['a:off']) xfrm['a:off'] = {}
80
+ xfrm['a:off']['@_y'] = String(Math.round(options.y))
81
+ }
82
+ if (options.width !== undefined) {
83
+ if (!xfrm['a:ext']) xfrm['a:ext'] = {}
84
+ xfrm['a:ext']['@_cx'] = String(Math.round(options.width))
85
+ }
86
+ if (options.height !== undefined) {
87
+ if (!xfrm['a:ext']) xfrm['a:ext'] = {}
88
+ xfrm['a:ext']['@_cy'] = String(Math.round(options.height))
89
+ }
90
+ }
91
+
92
+ const decl = this.#xmlParser.extractDeclaration(slideXml)
93
+ slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
94
+ logger.debug(`Updated position/dimensions for shape "${shapeId}" on slide ${slideIndex}`)
95
+ }
96
+
97
+ /**
98
+ * Updates an existing textbox shape's position and/or dimensions.
99
+ *
100
+ * @param {number} slideIndex
101
+ * @param {string} textBoxId
102
+ * @param {Object} options Position and dimensions configuration.
103
+ * @param {number} [options.x] Absolute X offset coordinate (in EMUs).
104
+ * @param {number} [options.y] Absolute Y offset coordinate (in EMUs).
105
+ * @param {number} [options.width] Bounding box width (in EMUs).
106
+ * @param {number} [options.height] Bounding box height (in EMUs).
107
+ * @param {SlideManager} slideManager
108
+ */
109
+ updateTextBoxPosition(slideIndex, textBoxId, options = {}, slideManager) {
110
+ try {
111
+ this.updateShapePosition(slideIndex, textBoxId, options, slideManager)
112
+ } catch (err) {
113
+ if (err.message.includes('not found')) {
114
+ throw new PPTXError(`Textbox "${textBoxId}" not found in slide ${slideIndex}`)
115
+ }
116
+ throw err
117
+ }
118
+ }
119
+
50
120
  /**
51
121
  * Clones a shape and adds it with offsets.
52
122
  *
@@ -154,58 +154,124 @@ class ChartCacheGenerator {
154
154
  }
155
155
 
156
156
  /**
157
- * Updates the chart title text in chart XML while preserving all existing
158
- * styling (spPr, txPr, overlay, layout) from the template.
157
+ * Updates the chart title text while fully preserving all existing styling.
159
158
  *
160
- * Because <c:txPr> is ignored by PowerPoint once <c:tx><c:rich> is present,
161
- * this method extracts <a:defRPr> from <c:txPr> and injects it as <a:rPr>
162
- * into the run, and uses <a:bodyPr> from <c:txPr> inside <c:rich>, so that
163
- * the template's font, size, and color are faithfully applied to the title text.
159
+ * Three strategies in priority order:
160
+ *
161
+ * 1. If <c:tx><c:rich> already exists (title was previously set via PowerPoint or this
162
+ * library): ONLY replace <a:t> text values in-place, mapped by \n-split lines to
163
+ * existing runs in document order. This preserves alignment, bold/italic/underline,
164
+ * font sizes, paragraph structure, and any other per-run or per-paragraph properties.
165
+ *
166
+ * 2. If <c:title> exists but has no <c:tx> (title text comes from <c:txPr> default
167
+ * properties): Build a new <c:tx><c:rich> by extracting <a:bodyPr>, <a:pPr> (including
168
+ * algn), and <a:defRPr>→<a:rPr> from <c:txPr>. Supports multi-line via \n splitting
169
+ * into separate <a:p> paragraphs, each inheriting the same pPr and rPr.
170
+ *
171
+ * 3. If no <c:title> block exists at all: create a minimal one.
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 }
164
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
+
165
225
  static updateTitle(xml, title) {
166
- const escapedTitle = this.#escapeXml(title)
226
+ // Split by \n so callers can drive multi-paragraph titles
227
+ const titleLines = title.split('\n')
228
+
229
+ if (!xml.includes('<c:title>')) {
230
+ // Strategy 3 – no title block yet, create minimal
231
+ const escapedText = this.#escapeXml(titleLines[0])
232
+ const titleBlock = `<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>${escapedText}</a:t></a:r></a:p></c:rich></c:tx><c:layout/></c:title>`
233
+ return xml.replace(/(<c:chart>)/, `$1${titleBlock}`)
234
+ }
167
235
 
168
- if (xml.includes('<c:title>')) {
169
- return xml.replace(/<c:title>([\s\S]*?)<\/c:title>/, (match, titleContent) => {
170
- // Extract <a:bodyPr .../> from existing <c:txPr> to use in <c:rich>
171
- let bodyPr = '<a:bodyPr/>'
172
- const txPrMatch = /<c:txPr>([\s\S]*?)<\/c:txPr>/.exec(titleContent)
173
- if (txPrMatch) {
174
- const bodyPrMatch = /(<a:bodyPr[^>]*\/>|<a:bodyPr[^>]*>[\s\S]*?<\/a:bodyPr>)/.exec(
175
- txPrMatch[1]
236
+ return xml.replace(/<c:title>([\s\S]*?)<\/c:title>/, (match, titleContent) => {
237
+ // ── Strategy 1 ──────────────────────────────────────────────────────────────
238
+ // Existing <c:tx><c:rich> is present: preserve every element, just swap text.
239
+ if (titleContent.includes('<c:tx>')) {
240
+ const txMatch = /<c:tx>([\s\S]*?)<\/c:tx>/.exec(titleContent)
241
+ if (txMatch && txMatch[1].includes('<c:rich>')) {
242
+ let lineIndex = 0
243
+ // Replace each <a:t>…</a:t> in document order with the next line.
244
+ // Any run that maps beyond the supplied lines gets an empty string.
245
+ const updatedTx = txMatch[1].replace(/<a:t>[^<]*<\/a:t>/g, () => {
246
+ const text = lineIndex < titleLines.length ? this.#escapeXml(titleLines[lineIndex]) : ''
247
+ lineIndex++
248
+ return `<a:t>${text}</a:t>`
249
+ })
250
+ const updatedContent = titleContent.replace(
251
+ /<c:tx>[\s\S]*?<\/c:tx>/,
252
+ `<c:tx>${updatedTx}</c:tx>`
176
253
  )
177
- if (bodyPrMatch) bodyPr = bodyPrMatch[1]
254
+ return `<c:title>${updatedContent}</c:title>`
178
255
  }
256
+ }
179
257
 
180
- // Extract <a:defRPr .../> from <c:txPr><a:p><a:pPr> to use as <a:rPr> in the run
181
- let rPr = ''
182
- if (txPrMatch) {
183
- const defRPrMatch = /(<a:defRPr[\s\S]*?<\/a:defRPr>|<a:defRPr[^>]*\/>)/.exec(txPrMatch[1])
184
- if (defRPrMatch) {
185
- // Convert <a:defRPr ...> to <a:rPr ...> (same attributes, different tag name)
186
- rPr = defRPrMatch[1]
187
- .replace(/^<a:defRPr/, '<a:rPr')
188
- .replace(/<\/a:defRPr>$/, '</a:rPr>')
189
- }
190
- }
258
+ // ── Strategy 2 ──────────────────────────────────────────────────────────────
259
+ // No <c:tx> yet – build one from <c:txPr> styles.
260
+ const { bodyPr, pPrXml, rPrXml: rPr } = this.extractTxPrParts(titleContent)
191
261
 
192
- const newTxBlock = `<c:tx><c:rich>${bodyPr}<a:lstStyle/><a:p><a:r>${rPr}<a:t>${escapedTitle}</a:t></a:r></a:p></c:rich></c:tx>`
262
+ // Build one <a:p> per title line, each with the same pPr + rPr
263
+ const paragraphs = titleLines
264
+ .map(line => {
265
+ const escapedLine = this.#escapeXml(line)
266
+ return `<a:p>${pPrXml}<a:r>${rPr}<a:t>${escapedLine}</a:t></a:r></a:p>`
267
+ })
268
+ .join('')
193
269
 
194
- if (titleContent.includes('<c:tx>')) {
195
- // Replace existing c:tx, keep all other siblings intact
196
- const updatedContent = titleContent.replace(/<c:tx>[\s\S]*?<\/c:tx>/, newTxBlock)
197
- return `<c:title>${updatedContent}</c:title>`
198
- } else {
199
- // No c:tx yet – prepend before first existing sibling
200
- return `<c:title>${newTxBlock}${titleContent}</c:title>`
201
- }
202
- })
203
- } else {
204
- // No title block exists yet – create a minimal one
205
- const titleBlock = `<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>${escapedTitle}</a:t></a:r></a:p></c:rich></c:tx><c:layout/></c:title>`
206
- const chartPattern = /(<c:chart>)/
207
- return xml.replace(chartPattern, `$1${titleBlock}`)
208
- }
270
+ const newTxBlock = `<c:tx><c:rich>${bodyPr}<a:lstStyle/>${paragraphs}</c:rich></c:tx>`
271
+
272
+ // Prepend <c:tx> before the first existing sibling (overlay, spPr, txPr, etc.)
273
+ return `<c:title>${newTxBlock}${titleContent}</c:title>`
274
+ })
209
275
  }
210
276
 
211
277
  static updateDataLabelsInXml(xml, seriesIndex, options, categories = [], seriesData = {}) {
@@ -333,7 +399,15 @@ class ChartCacheGenerator {
333
399
  existingSpPr = '',
334
400
  existingDLblSpPrs = {}
335
401
  ) {
336
- const { labels, labelsFromCells, template, position, labelStyle, labelMap } = options
402
+ const {
403
+ labels,
404
+ labelsFromCells,
405
+ template,
406
+ position,
407
+ labelStyle,
408
+ labelMap,
409
+ showSeriesNameInBar,
410
+ } = options
337
411
 
338
412
  let xml = '<c:dLbls>'
339
413
 
@@ -348,7 +422,10 @@ class ChartCacheGenerator {
348
422
  top: 't',
349
423
  bottom: 'b',
350
424
  }
351
- const openxmlPos = position ? posMap[position] : null
425
+ let openxmlPos = position ? posMap[position] : null
426
+ if (!openxmlPos && showSeriesNameInBar) {
427
+ openxmlPos = 'ctr'
428
+ }
352
429
 
353
430
  const values = seriesData.values || []
354
431
  const sumValues = values.reduce((sum, v) => sum + (Number(v) || 0), 0)
@@ -430,15 +507,29 @@ class ChartCacheGenerator {
430
507
  xml += `</c:strRef>`
431
508
  xml += `</c:tx>`
432
509
  } else {
510
+ let bodyPr = '<a:bodyPr/>'
511
+ let pPrXml = ''
512
+ let rPrXml = ''
513
+ if (existingTxPr) {
514
+ const extracted = this.extractTxPrParts(existingTxPr)
515
+ bodyPr = extracted.bodyPr
516
+ pPrXml = extracted.pPrXml
517
+ rPrXml = extracted.rPrXml
518
+ }
519
+
520
+ const labelLines = String(textContent).split('\n')
521
+ const paragraphs = labelLines
522
+ .map(line => {
523
+ const escapedLine = this.#escapeXml(line)
524
+ return `<a:p>${pPrXml}<a:r>${rPrXml}<a:t>${escapedLine}</a:t></a:r></a:p>`
525
+ })
526
+ .join('')
527
+
433
528
  xml += `<c:tx>`
434
529
  xml += `<c:rich>`
435
- xml += `<a:bodyPr/>`
530
+ xml += bodyPr
436
531
  xml += `<a:lstStyle/>`
437
- xml += `<a:p>`
438
- xml += `<a:r>`
439
- xml += `<a:t>${this.#escapeXml(textContent)}</a:t>`
440
- xml += `</a:r>`
441
- xml += `</a:p>`
532
+ xml += paragraphs
442
533
  xml += `</c:rich>`
443
534
  xml += `</c:tx>`
444
535
  }
@@ -516,7 +607,9 @@ class ChartCacheGenerator {
516
607
 
517
608
  // showVal
518
609
  const defaultShowVal = hasCustomLabels ? '0' : '1'
519
- if (existingShowTags['showVal'] && !hasCustomLabels) {
610
+ if (showSeriesNameInBar) {
611
+ xml += `<c:showVal val="0"/>`
612
+ } else if (existingShowTags['showVal'] && !hasCustomLabels) {
520
613
  xml += existingShowTags['showVal']
521
614
  } else {
522
615
  xml += `<c:showVal val="${defaultShowVal}"/>`
@@ -530,7 +623,9 @@ class ChartCacheGenerator {
530
623
  }
531
624
 
532
625
  // showSerName
533
- if (existingShowTags['showSerName']) {
626
+ if (showSeriesNameInBar) {
627
+ xml += `<c:showSerName val="1"/>`
628
+ } else if (existingShowTags['showSerName']) {
534
629
  xml += existingShowTags['showSerName']
535
630
  } else {
536
631
  xml += `<c:showSerName val="0"/>`