node-pptx-templater 1.0.10 → 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 +2 -2
- 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 +92 -52
|
@@ -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
|
*
|
|
@@ -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
|
-
|
|
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
|
|
@@ -388,7 +399,15 @@ class ChartCacheGenerator {
|
|
|
388
399
|
existingSpPr = '',
|
|
389
400
|
existingDLblSpPrs = {}
|
|
390
401
|
) {
|
|
391
|
-
const {
|
|
402
|
+
const {
|
|
403
|
+
labels,
|
|
404
|
+
labelsFromCells,
|
|
405
|
+
template,
|
|
406
|
+
position,
|
|
407
|
+
labelStyle,
|
|
408
|
+
labelMap,
|
|
409
|
+
showSeriesNameInBar,
|
|
410
|
+
} = options
|
|
392
411
|
|
|
393
412
|
let xml = '<c:dLbls>'
|
|
394
413
|
|
|
@@ -403,7 +422,10 @@ class ChartCacheGenerator {
|
|
|
403
422
|
top: 't',
|
|
404
423
|
bottom: 'b',
|
|
405
424
|
}
|
|
406
|
-
|
|
425
|
+
let openxmlPos = position ? posMap[position] : null
|
|
426
|
+
if (!openxmlPos && showSeriesNameInBar) {
|
|
427
|
+
openxmlPos = 'ctr'
|
|
428
|
+
}
|
|
407
429
|
|
|
408
430
|
const values = seriesData.values || []
|
|
409
431
|
const sumValues = values.reduce((sum, v) => sum + (Number(v) || 0), 0)
|
|
@@ -485,15 +507,29 @@ class ChartCacheGenerator {
|
|
|
485
507
|
xml += `</c:strRef>`
|
|
486
508
|
xml += `</c:tx>`
|
|
487
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
|
+
|
|
488
528
|
xml += `<c:tx>`
|
|
489
529
|
xml += `<c:rich>`
|
|
490
|
-
xml +=
|
|
530
|
+
xml += bodyPr
|
|
491
531
|
xml += `<a:lstStyle/>`
|
|
492
|
-
xml +=
|
|
493
|
-
xml += `<a:r>`
|
|
494
|
-
xml += `<a:t>${this.#escapeXml(textContent)}</a:t>`
|
|
495
|
-
xml += `</a:r>`
|
|
496
|
-
xml += `</a:p>`
|
|
532
|
+
xml += paragraphs
|
|
497
533
|
xml += `</c:rich>`
|
|
498
534
|
xml += `</c:tx>`
|
|
499
535
|
}
|
|
@@ -571,7 +607,9 @@ class ChartCacheGenerator {
|
|
|
571
607
|
|
|
572
608
|
// showVal
|
|
573
609
|
const defaultShowVal = hasCustomLabels ? '0' : '1'
|
|
574
|
-
if (
|
|
610
|
+
if (showSeriesNameInBar) {
|
|
611
|
+
xml += `<c:showVal val="0"/>`
|
|
612
|
+
} else if (existingShowTags['showVal'] && !hasCustomLabels) {
|
|
575
613
|
xml += existingShowTags['showVal']
|
|
576
614
|
} else {
|
|
577
615
|
xml += `<c:showVal val="${defaultShowVal}"/>`
|
|
@@ -585,7 +623,9 @@ class ChartCacheGenerator {
|
|
|
585
623
|
}
|
|
586
624
|
|
|
587
625
|
// showSerName
|
|
588
|
-
if (
|
|
626
|
+
if (showSeriesNameInBar) {
|
|
627
|
+
xml += `<c:showSerName val="1"/>`
|
|
628
|
+
} else if (existingShowTags['showSerName']) {
|
|
589
629
|
xml += existingShowTags['showSerName']
|
|
590
630
|
} else {
|
|
591
631
|
xml += `<c:showSerName val="0"/>`
|