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.
- package/README.md +192 -0
- package/package.json +1 -1
- package/src/core/PPTXTemplater.js +157 -0
- package/src/core/ValidationEngine.js +170 -0
- package/src/managers/ChartManager.js +1431 -7
- package/src/managers/ShapeManager.js +70 -0
- package/src/managers/charts/ChartCacheGenerator.js +148 -53
|
@@ -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
|
|
158
|
-
* styling (spPr, txPr, overlay, layout) from the template.
|
|
157
|
+
* Updates the chart title text while fully preserving all existing styling.
|
|
159
158
|
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
254
|
+
return `<c:title>${updatedContent}</c:title>`
|
|
178
255
|
}
|
|
256
|
+
}
|
|
179
257
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 {
|
|
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
|
-
|
|
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 +=
|
|
530
|
+
xml += bodyPr
|
|
436
531
|
xml += `<a:lstStyle/>`
|
|
437
|
-
xml +=
|
|
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 (
|
|
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 (
|
|
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"/>`
|