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.
@@ -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 }