node-pptx-templater 1.0.6 → 1.0.8
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 +258 -2
- package/package.json +1 -1
- package/src/core/PPTXTemplater.js +92 -1
- package/src/core/TemplateEngine.js +126 -0
- package/src/core/ValidationEngine.js +179 -0
- package/src/managers/ChartManager.js +383 -21
- package/src/managers/TextManager.js +271 -0
- package/src/managers/charts/ChartCacheGenerator.js +427 -1
- package/src/managers/charts/ChartWorkbookUpdater.js +204 -33
|
@@ -192,6 +192,277 @@ class TextManager {
|
|
|
192
192
|
this.#collectTextElements(g, results)
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Updates shape text or list content by placeholder tag or shape name/ID.
|
|
198
|
+
*
|
|
199
|
+
* @param {number} slideIndex
|
|
200
|
+
* @param {string} tag - Placeholder tag (e.g. '{{name}}') or shape name/ID.
|
|
201
|
+
* @param {string|Object} data - String value or list configuration object.
|
|
202
|
+
* @param {SlideManager} slideManager
|
|
203
|
+
* @param {TemplateEngine} templateEngine
|
|
204
|
+
*/
|
|
205
|
+
updateText(slideIndex, tag, data, slideManager, templateEngine) {
|
|
206
|
+
const slideXml = slideManager.getSlideXml(slideIndex)
|
|
207
|
+
const normalizedTag = tag.startsWith('{{') && tag.endsWith('}}') ? tag : `{{${tag}}}`
|
|
208
|
+
|
|
209
|
+
// Option A: Tag exists as a placeholder in the slide XML
|
|
210
|
+
if (slideXml.includes(normalizedTag)) {
|
|
211
|
+
const replacements = { [normalizedTag]: data }
|
|
212
|
+
const updatedXml = templateEngine.replaceTextInXml(slideXml, replacements)
|
|
213
|
+
slideManager.setSlideXml(slideIndex, updatedXml)
|
|
214
|
+
logger.debug(`Updated text tag "${normalizedTag}" on slide ${slideIndex}`)
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Option B: Search for a shape whose name or ID matches the tag
|
|
219
|
+
const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
|
|
220
|
+
const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
|
|
221
|
+
const res = this.findShapeRecursive(spTree, tag)
|
|
222
|
+
|
|
223
|
+
if (!res) {
|
|
224
|
+
const { PPTXError } = require('../utils/errors.js')
|
|
225
|
+
throw new PPTXError(`Text placeholder or shape "${tag}" not found in slide ${slideIndex}`)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Replace the text body of the shape
|
|
229
|
+
const shape = res.shape
|
|
230
|
+
const listConfig =
|
|
231
|
+
typeof data === 'object' && data !== null
|
|
232
|
+
? data.list !== undefined
|
|
233
|
+
? data
|
|
234
|
+
: { list: [data] }
|
|
235
|
+
: { list: [String(data)] }
|
|
236
|
+
|
|
237
|
+
const { ValidationEngine } = require('../core/ValidationEngine.js')
|
|
238
|
+
const validation = ValidationEngine.validateList(listConfig)
|
|
239
|
+
if (!validation.valid) {
|
|
240
|
+
throw new Error(`List validation failed: ${validation.errors.join(', ')}`)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!shape['p:txBody']) {
|
|
244
|
+
shape['p:txBody'] = {
|
|
245
|
+
'a:bodyPr': {},
|
|
246
|
+
'a:lstStyle': {},
|
|
247
|
+
'a:p': [],
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const txBody = shape['p:txBody']
|
|
252
|
+
const originalParas = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
|
|
253
|
+
const templatePara = originalParas.length > 0 ? originalParas[0] : {}
|
|
254
|
+
const templateRuns = templatePara['a:r']
|
|
255
|
+
? Array.isArray(templatePara['a:r'])
|
|
256
|
+
? templatePara['a:r']
|
|
257
|
+
: [templatePara['a:r']]
|
|
258
|
+
: []
|
|
259
|
+
const firstRun = templateRuns.length > 0 ? templateRuns[0] : { 'a:rPr': {} }
|
|
260
|
+
|
|
261
|
+
const firstRunXml = this.#xmlParser.build({ 'a:r': firstRun })
|
|
262
|
+
const dummyParaXml = `<a:p>${firstRunXml}</a:p>`
|
|
263
|
+
const generatedXml = templateEngine.generateListParagraphs(
|
|
264
|
+
dummyParaXml,
|
|
265
|
+
{ xml: firstRunXml },
|
|
266
|
+
listConfig
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
// Parse the generated XML paragraphs back to objects
|
|
270
|
+
const wrappedXml = `<root xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">${generatedXml}</root>`
|
|
271
|
+
const parsedObj = this.#xmlParser.parse(wrappedXml)
|
|
272
|
+
let newParas = parsedObj?.root?.['a:p'] || []
|
|
273
|
+
if (!Array.isArray(newParas)) {
|
|
274
|
+
newParas = [newParas]
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
txBody['a:p'] = newParas
|
|
278
|
+
|
|
279
|
+
const decl = this.#xmlParser.extractDeclaration(slideXml)
|
|
280
|
+
slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
|
|
281
|
+
logger.debug(`Updated text content for shape "${tag}" on slide ${slideIndex}`)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Retrieves list items from a shape or text box by name or placeholder tag.
|
|
286
|
+
*
|
|
287
|
+
* @param {number} slideIndex
|
|
288
|
+
* @param {string} tag - Shape name/ID or placeholder tag.
|
|
289
|
+
* @param {SlideManager} slideManager
|
|
290
|
+
* @returns {Array} Nested list structure of items.
|
|
291
|
+
*/
|
|
292
|
+
getList(slideIndex, tag, slideManager) {
|
|
293
|
+
const slideXml = slideManager.getSlideXml(slideIndex)
|
|
294
|
+
const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
|
|
295
|
+
const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
|
|
296
|
+
|
|
297
|
+
// Step 1: Find shape by name or ID matching tag
|
|
298
|
+
let res = this.findShapeRecursive(spTree, tag)
|
|
299
|
+
|
|
300
|
+
// Step 2: If not found, look for any shape containing the placeholder string
|
|
301
|
+
if (!res) {
|
|
302
|
+
const collectMatchingShape = container => {
|
|
303
|
+
if (!container) return null
|
|
304
|
+
|
|
305
|
+
let shapes = container['p:sp'] || []
|
|
306
|
+
if (!Array.isArray(shapes)) shapes = [shapes]
|
|
307
|
+
|
|
308
|
+
for (const shape of shapes) {
|
|
309
|
+
const txBody = shape['p:txBody']
|
|
310
|
+
if (txBody && txBody['a:p']) {
|
|
311
|
+
const paras = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
|
|
312
|
+
for (const p of paras) {
|
|
313
|
+
let pText = ''
|
|
314
|
+
if (p['a:r']) {
|
|
315
|
+
const runs = Array.isArray(p['a:r']) ? p['a:r'] : [p['a:r']]
|
|
316
|
+
for (const r of runs) {
|
|
317
|
+
if (r['a:t']) pText += String(r['a:t'])
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (pText.includes(tag)) {
|
|
321
|
+
return { shape, parent: container, type: 'sp' }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Search in tables inside graphicFrames
|
|
328
|
+
let frames = container['p:graphicFrame'] || []
|
|
329
|
+
if (!Array.isArray(frames)) frames = [frames]
|
|
330
|
+
|
|
331
|
+
for (const frame of frames) {
|
|
332
|
+
const tbl = frame?.['a:graphic']?.['a:graphicData']?.['a:tbl']
|
|
333
|
+
if (tbl && tbl['a:tr']) {
|
|
334
|
+
const rows = Array.isArray(tbl['a:tr']) ? tbl['a:tr'] : [tbl['a:tr']]
|
|
335
|
+
for (const row of rows) {
|
|
336
|
+
const cells = Array.isArray(row['a:tc']) ? row['a:tc'] : [row['a:tc']]
|
|
337
|
+
for (const cell of cells) {
|
|
338
|
+
const txBody = cell['a:txBody']
|
|
339
|
+
if (txBody && txBody['a:p']) {
|
|
340
|
+
const paras = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
|
|
341
|
+
for (const p of paras) {
|
|
342
|
+
let pText = ''
|
|
343
|
+
if (p['a:r']) {
|
|
344
|
+
const runs = Array.isArray(p['a:r']) ? p['a:r'] : [p['a:r']]
|
|
345
|
+
for (const r of runs) {
|
|
346
|
+
if (r['a:t']) pText += String(r['a:t'])
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (pText.includes(tag)) {
|
|
350
|
+
return { shape: cell, parent: row, type: 'cell' }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let groups = container['p:grpSp'] || []
|
|
360
|
+
if (!Array.isArray(groups)) groups = [groups]
|
|
361
|
+
|
|
362
|
+
for (const group of groups) {
|
|
363
|
+
const matched = collectMatchingShape(group)
|
|
364
|
+
if (matched) return matched
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return null
|
|
368
|
+
}
|
|
369
|
+
res = collectMatchingShape(spTree)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!res || !res.shape || !(res.shape['p:txBody'] || res.shape['a:txBody'])) {
|
|
373
|
+
return []
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const txBody = res.shape['p:txBody'] || res.shape['a:txBody']
|
|
377
|
+
const paras = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
|
|
378
|
+
|
|
379
|
+
const flatItems = []
|
|
380
|
+
for (const p of paras) {
|
|
381
|
+
let lvl = 0
|
|
382
|
+
if (p['a:pPr'] && p['a:pPr']['@_lvl'] !== undefined) {
|
|
383
|
+
lvl = parseInt(p['a:pPr']['@_lvl'], 10) || 0
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let text = ''
|
|
387
|
+
if (p['a:r']) {
|
|
388
|
+
const runs = Array.isArray(p['a:r']) ? p['a:r'] : [p['a:r']]
|
|
389
|
+
const textParts = []
|
|
390
|
+
for (const r of runs) {
|
|
391
|
+
if (r['a:t']) textParts.push(String(r['a:t']))
|
|
392
|
+
}
|
|
393
|
+
text = textParts.join('')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (text.trim() !== '') {
|
|
397
|
+
flatItems.push({ text: text.trim(), lvl })
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (flatItems.length === 0) {
|
|
402
|
+
return []
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const result = []
|
|
406
|
+
const stack = []
|
|
407
|
+
|
|
408
|
+
for (const item of flatItems) {
|
|
409
|
+
const node = { text: item.text, children: [] }
|
|
410
|
+
|
|
411
|
+
while (stack.length > 0 && stack[stack.length - 1].lvl >= item.lvl) {
|
|
412
|
+
stack.pop()
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (stack.length === 0) {
|
|
416
|
+
result.push(node)
|
|
417
|
+
} else {
|
|
418
|
+
const parent = stack[stack.length - 1].node
|
|
419
|
+
parent.children.push(node)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
stack.push({ lvl: item.lvl, node })
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const cleanNode = n => {
|
|
426
|
+
if (n.children.length === 0) {
|
|
427
|
+
return n.text
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
text: n.text,
|
|
431
|
+
children: n.children.map(cleanNode),
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return result.map(cleanNode)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Helper to recursively scan a container for shapes.
|
|
440
|
+
*/
|
|
441
|
+
findShapeRecursive(container, shapeId) {
|
|
442
|
+
if (!container) return null
|
|
443
|
+
|
|
444
|
+
let shapes = container['p:sp'] || []
|
|
445
|
+
if (!Array.isArray(shapes)) shapes = [shapes]
|
|
446
|
+
|
|
447
|
+
for (const shape of shapes) {
|
|
448
|
+
const cNvPr = shape?.['p:nvSpPr']?.['p:cNvPr']
|
|
449
|
+
if (cNvPr) {
|
|
450
|
+
if (cNvPr['@_name'] === shapeId || String(cNvPr['@_id']) === shapeId) {
|
|
451
|
+
return { shape, parent: container, type: 'sp' }
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
let groups = container['p:grpSp'] || []
|
|
457
|
+
if (!Array.isArray(groups)) groups = [groups]
|
|
458
|
+
|
|
459
|
+
for (const group of groups) {
|
|
460
|
+
const res = this.findShapeRecursive(group, shapeId)
|
|
461
|
+
if (res) return res
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return null
|
|
465
|
+
}
|
|
195
466
|
}
|
|
196
467
|
|
|
197
468
|
module.exports = { TextManager }
|