node-pptx-templater 1.0.7 → 1.0.9

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 CHANGED
@@ -718,6 +718,44 @@ Adds a special navigation link (next, previous, first, last slide) to a shape or
718
718
  ppt.useSlide(1).addShapeNavigationLink(options);
719
719
  ```
720
720
 
721
+ #### `updateText(tag, data)`
722
+ Updates shape text or list content by placeholder tag or shape name/ID. Supports bullet lists, numbered lists, nested lists, and custom styling.
723
+
724
+ * **Arguments**:
725
+ * `tag` (`string`): Placeholder tag (e.g. '{{name}}' or 'name') or shape name/ID.
726
+ * `data` (`string|Object`): String value or list configuration object.
727
+ * **Returns**: `PPTXTemplater` - this (chainable)
728
+
729
+ ```javascript
730
+ ppt.useSlide(1).updateText('Features', {
731
+ list: ['Point A', 'Point B', 'Point C']
732
+ });
733
+ ```
734
+
735
+ #### `getList(tag)`
736
+ Retrieves list items from a shape or text box by name or placeholder tag.
737
+
738
+ * **Arguments**:
739
+ * `tag` (`string`): Shape name/ID or placeholder tag.
740
+ * **Returns**: `Array` - Nested list structure of items.
741
+
742
+ ```javascript
743
+ const items = ppt.useSlide(1).getList('Features');
744
+ console.log(items); // ['A', { text: 'B', children: [...] }]
745
+ ```
746
+
747
+ #### `validateList(data)`
748
+ Validates a list structure and values.
749
+
750
+ * **Arguments**:
751
+ * `data` (`Object|Array`): List config object or array of items.
752
+ * **Returns**: `Object` - Report containing validation result.
753
+
754
+ ```javascript
755
+ const result = ppt.validateList(['Valid string', 'Another item']);
756
+ console.log(result.valid);
757
+ ```
758
+
721
759
  #### `replaceTextByTag(())`
722
760
  Delegates core actions to slide element sub-managers.
723
761
 
@@ -1380,6 +1418,93 @@ To preserve PowerPoint integrity, the engine ensures that if one value contains
1380
1418
 
1381
1419
  ---
1382
1420
 
1421
+ ## 📋 Native Lists (Bullet & Numbered Lists)
1422
+
1423
+ PPTXForge supports native PowerPoint bullet lists and numbered lists across text placeholders, shapes, text boxes, table cells, and grouped shapes. When generating lists, the engine preserves run styles, custom bullet characters, indentation, and color overlays, generating valid OpenXML/DrawingML without repair alerts.
1424
+
1425
+ ### 1. Basic Bullet List
1426
+ Update a shape or text placeholder to be a bullet list:
1427
+
1428
+ ```javascript
1429
+ ppt.useSlide(1).updateText('Features', {
1430
+ list: [
1431
+ 'Fast PPTX generation',
1432
+ 'OpenXML based',
1433
+ 'Chart updates',
1434
+ 'Table updates'
1435
+ ]
1436
+ });
1437
+ ```
1438
+
1439
+ ### 2. Numbered / Ordered List
1440
+ Use the `ordered` flag to convert the list to a numbered format:
1441
+
1442
+ ```javascript
1443
+ ppt.useSlide(1).updateText('Steps', {
1444
+ ordered: true,
1445
+ list: [
1446
+ 'Import template',
1447
+ 'Update data',
1448
+ 'Generate PPTX'
1449
+ ]
1450
+ });
1451
+ ```
1452
+ * Custom numbering systems can be specified with `style.numberType` (e.g., `arabicPeriod`, `alphaLcParen`, `romanUcPeriod`).
1453
+
1454
+ ### 3. Nested / Multi-Level Lists
1455
+ Construct hierarchy by passing objects containing a `text` string and a `children` array:
1456
+
1457
+ ```javascript
1458
+ ppt.useSlide(1).updateText('Requirements', {
1459
+ list: [
1460
+ 'Frontend',
1461
+ {
1462
+ text: 'Backend Development',
1463
+ children: [
1464
+ 'Node.js',
1465
+ {
1466
+ text: 'Databases',
1467
+ children: ['MS SQL', 'PostgreSQL']
1468
+ }
1469
+ ]
1470
+ }
1471
+ ]
1472
+ });
1473
+ ```
1474
+
1475
+ ### 4. Custom List Styling
1476
+ Customize bullet characters, colors, sizes, and font properties:
1477
+
1478
+ ```javascript
1479
+ ppt.useSlide(1).updateText('KPIs', {
1480
+ list: ['Revenue Up', 'Margins Normal'],
1481
+ style: {
1482
+ fontFamily: 'Arial',
1483
+ fontSize: 18,
1484
+ color: '#0055AA',
1485
+ bulletColor: '#FF5500',
1486
+ bulletChar: '✦',
1487
+ bulletSize: 120 // Percentage relative to text (e.g. 120%)
1488
+ }
1489
+ });
1490
+ ```
1491
+
1492
+ ### 5. Table Cell Lists
1493
+ You can also generate list hierarchies directly inside a cell of a DrawingML table:
1494
+
1495
+ ```javascript
1496
+ ppt.useSlide(3).updateTable('sales-table', [
1497
+ ['Category', 'Performance details'],
1498
+ ['North Region', '{{CellPlaceholder}}']
1499
+ ]);
1500
+
1501
+ ppt.updateText('CellPlaceholder', {
1502
+ list: ['Table Bullet 1', 'Table Bullet 2']
1503
+ });
1504
+ ```
1505
+
1506
+ ---
1507
+
1383
1508
  ## 🔒 Advanced XML Security
1384
1509
 
1385
1510
  PPTXForge uses a secure XML parser that defends your application servers against common XML vulnerabilities.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-pptx-templater",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "High-performance, low-level PowerPoint (PPTX) OpenXML template engine for Node.js. Dynamically replace text, insert images, update charts (with Excel workbook data caching), and merge table cells without PowerPoint corruption or Repair Mode prompts.",
5
5
  "main": "./src/index.js",
6
6
  "type": "commonjs",
@@ -1383,6 +1383,46 @@ class PPTXTemplater {
1383
1383
  return charts
1384
1384
  }
1385
1385
 
1386
+ /**
1387
+ * Updates shape text or list content by placeholder tag or shape name/ID.
1388
+ * Supports bullet lists, numbered lists, nested lists, and custom styling.
1389
+ *
1390
+ * @param {string} tag - Placeholder tag (e.g. '{{name}}' or 'name') or shape name/ID.
1391
+ * @param {string|Object} data - String value or list configuration object.
1392
+ * @returns {PPTXTemplater} this (chainable)
1393
+ */
1394
+ updateText(tag, data) {
1395
+ this.#assertLoaded()
1396
+ const targetIndices = this.#getTargetSlideIndices()
1397
+ for (const idx of targetIndices) {
1398
+ this.#textManager.updateText(idx, tag, data, this.#slideManager, this.#templateEngine)
1399
+ }
1400
+ return this
1401
+ }
1402
+
1403
+ /**
1404
+ * Retrieves list items from a shape or text box by name or placeholder tag.
1405
+ *
1406
+ * @param {string} tag - Shape name/ID or placeholder tag.
1407
+ * @returns {Array} Nested list structure of items.
1408
+ */
1409
+ getList(tag) {
1410
+ this.#assertLoaded()
1411
+ const targetIndices = this.#getTargetSlideIndices()
1412
+ const idx = targetIndices.length > 0 ? targetIndices[0] : 1
1413
+ return this.#textManager.getList(idx, tag, this.#slideManager)
1414
+ }
1415
+
1416
+ /**
1417
+ * Validates a list structure and values.
1418
+ *
1419
+ * @param {Object|Array} data - List config object or array of items.
1420
+ * @returns {Object} Report containing validation result.
1421
+ */
1422
+ validateList(data) {
1423
+ return ValidationEngine.validateList(data)
1424
+ }
1425
+
1386
1426
  // === Text Features ===
1387
1427
  replaceTextByTag(tag, value, options = {}) {
1388
1428
  this.#assertLoaded()
@@ -80,6 +80,13 @@ class TemplateEngine {
80
80
 
81
81
  // Step 2: Simple direct replacement for any remaining unfragmented placeholders
82
82
  for (const [placeholder, value] of Object.entries(replacements)) {
83
+ if (
84
+ value &&
85
+ typeof value === 'object' &&
86
+ (Array.isArray(value) || value.list !== undefined)
87
+ ) {
88
+ continue
89
+ }
83
90
  const escaped = this.#escapeXml(String(value))
84
91
  const placeholderEscaped = this.#escapeXml(placeholder)
85
92
 
@@ -146,15 +153,35 @@ class TemplateEngine {
146
153
 
147
154
  // Check if any placeholder appears in the combined text
148
155
  let hasPlaceholder = false
156
+ let matchedPlaceholder = null
149
157
  for (const placeholder of Object.keys(replacements)) {
150
158
  if (combinedText.includes(placeholder)) {
151
159
  hasPlaceholder = true
160
+ matchedPlaceholder = placeholder
152
161
  break
153
162
  }
154
163
  }
155
164
 
156
165
  if (!hasPlaceholder) return paragraphXml
157
166
 
167
+ const replacementValue = replacements[matchedPlaceholder]
168
+ const isList =
169
+ replacementValue &&
170
+ (Array.isArray(replacementValue) ||
171
+ (typeof replacementValue === 'object' && replacementValue.list !== undefined))
172
+
173
+ if (isList) {
174
+ const listConfig = Array.isArray(replacementValue)
175
+ ? { list: replacementValue }
176
+ : replacementValue
177
+ const { ValidationEngine } = require('./ValidationEngine.js')
178
+ const validation = ValidationEngine.validateList(listConfig)
179
+ if (!validation.valid) {
180
+ throw new Error(`List validation failed: ${validation.errors.join(', ')}`)
181
+ }
182
+ return this.generateListParagraphs(paragraphXml, runs[0], listConfig)
183
+ }
184
+
158
185
  // Perform replacement on combined text
159
186
  let replacedText = combinedText
160
187
  for (const [placeholder, value] of Object.entries(replacements)) {
@@ -285,6 +312,105 @@ class TemplateEngine {
285
312
  return Array.from(placeholders)
286
313
  }
287
314
 
315
+ /**
316
+ * Generates a block of list paragraph XML elements from a template paragraph,
317
+ * a baseline run, and list options.
318
+ *
319
+ * @param {string} paragraphXml
320
+ * @param {Object} firstRun - Run XML info.
321
+ * @param {Object} listConfig - List styling and items.
322
+ * @returns {string} XML string of multiple paragraphs.
323
+ */
324
+ generateListParagraphs(paragraphXml, firstRun, listConfig) {
325
+ const list = listConfig.list || []
326
+ const ordered = !!listConfig.ordered
327
+ const style = listConfig.style || {}
328
+
329
+ const flattenList = (items, currentLvl = 0) => {
330
+ let flat = []
331
+ for (const item of items) {
332
+ if (typeof item === 'string') {
333
+ flat.push({ text: item, lvl: currentLvl })
334
+ } else if (typeof item === 'object' && item !== null) {
335
+ const text = item.text || ''
336
+ flat.push({ text, lvl: currentLvl })
337
+ if (Array.isArray(item.children)) {
338
+ flat = flat.concat(flattenList(item.children, currentLvl + 1))
339
+ }
340
+ }
341
+ }
342
+ return flat
343
+ }
344
+
345
+ const flatItems = flattenList(list)
346
+
347
+ const rPrMatch = /(<a:rPr>[\s\S]*?<\/a:rPr>)/.exec(firstRun.xml)
348
+ let baseRPr = rPrMatch ? rPrMatch[1] : '<a:rPr/>'
349
+
350
+ if (style.fontSize) {
351
+ const szVal = Math.round(style.fontSize * 100)
352
+ if (/sz="\d+"/.test(baseRPr)) {
353
+ baseRPr = baseRPr.replace(/sz="\d+"/, `sz="${szVal}"`)
354
+ } else {
355
+ baseRPr = baseRPr.replace('<a:rPr', `<a:rPr sz="${szVal}"`)
356
+ }
357
+ }
358
+ if (style.color) {
359
+ const cleanColor = style.color.replace('#', '')
360
+ const newFill = `<a:solidFill><a:srgbClr val="${cleanColor}"/></a:solidFill>`
361
+ if (/<a:solidFill>[\s\S]*?<\/a:solidFill>/.test(baseRPr)) {
362
+ baseRPr = baseRPr.replace(/<a:solidFill>[\s\S]*?<\/a:solidFill>/, newFill)
363
+ } else {
364
+ if (baseRPr.endsWith('/>')) {
365
+ baseRPr = baseRPr.replace('/>', `>${newFill}</a:rPr>`)
366
+ } else {
367
+ baseRPr = baseRPr.replace('</a:rPr>', `${newFill}</a:rPr>`)
368
+ }
369
+ }
370
+ }
371
+ if (style.fontFamily) {
372
+ const latinXml = `<a:latin typeface="${style.fontFamily}"/><a:cs typeface="${style.fontFamily}"/>`
373
+ baseRPr = baseRPr.replace(/<a:latin\s+[^>]*\/>/g, '').replace(/<a:cs\s+[^>]*\/>/g, '')
374
+ if (baseRPr.endsWith('/>')) {
375
+ baseRPr = baseRPr.replace('/>', `>${latinXml}</a:rPr>`)
376
+ } else {
377
+ baseRPr = baseRPr.replace('</a:rPr>', `${latinXml}</a:rPr>`)
378
+ }
379
+ }
380
+
381
+ let paragraphsXml = ''
382
+ for (const item of flatItems) {
383
+ const lvl = item.lvl
384
+ const marL = style.marL !== undefined ? style.marL : 381000 + lvl * 457200
385
+ const indent = style.indent !== undefined ? style.indent : -228600
386
+
387
+ let pPr = `<a:pPr lvl="${lvl}" marL="${marL}" indent="${indent}">`
388
+ if (ordered) {
389
+ const numType = style.numberType || 'arabicPeriod'
390
+ pPr += `<a:buAutoNum type="${numType}"/>`
391
+ } else {
392
+ const bulletChar = style.bulletChar || '•'
393
+ pPr += `<a:buChar char="${bulletChar}"/>`
394
+ }
395
+
396
+ if (style.bulletColor) {
397
+ const cleanBClr = style.bulletColor.replace('#', '')
398
+ pPr += `<a:buClr><a:srgbClr val="${cleanBClr}"/></a:buClr>`
399
+ }
400
+
401
+ if (style.bulletSize) {
402
+ pPr += `<a:buSzPct val="${Math.round(style.bulletSize * 1000)}"/>`
403
+ }
404
+
405
+ pPr += `</a:pPr>`
406
+
407
+ const runXml = `<a:r>${baseRPr}<a:t>${this.#escapeXml(item.text)}</a:t></a:r>`
408
+ paragraphsXml += `<a:p>${pPr}${runXml}</a:p>`
409
+ }
410
+
411
+ return paragraphsXml
412
+ }
413
+
288
414
  /**
289
415
  * Escapes XML special characters.
290
416
  * @private
@@ -411,6 +411,92 @@ class ValidationEngine {
411
411
  warnings,
412
412
  }
413
413
  }
414
+
415
+ /**
416
+ * Validates a list structure and values.
417
+ *
418
+ * @param {Object|Array} data - List config object or array of items.
419
+ * @returns {Object} Report containing errors and warnings.
420
+ */
421
+ static validateList(data) {
422
+ const errors = []
423
+ const warnings = []
424
+
425
+ if (!data) {
426
+ errors.push('List data must be provided')
427
+ return { valid: false, errors, warnings }
428
+ }
429
+
430
+ const listArray = Array.isArray(data) ? data : data.list
431
+ if (!listArray) {
432
+ errors.push('List data must contain an array under the "list" property or be an array')
433
+ return { valid: false, errors, warnings }
434
+ }
435
+
436
+ const checkItem = (item, level) => {
437
+ if (level < 0 || level > 8) {
438
+ errors.push(`Level ${level} is out of supported range (0 to 8)`)
439
+ }
440
+
441
+ if (typeof item === 'string') {
442
+ if (item.trim() === '') {
443
+ errors.push('Empty list item text is not allowed')
444
+ }
445
+ } else if (typeof item === 'object' && item !== null) {
446
+ if (item.text === undefined || item.text === null || String(item.text).trim() === '') {
447
+ errors.push('Empty list item text is not allowed')
448
+ }
449
+ if (item.children) {
450
+ if (!Array.isArray(item.children)) {
451
+ errors.push('Children property must be an array of items')
452
+ } else {
453
+ item.children.forEach(child => {
454
+ checkItem(child, level + 1)
455
+ })
456
+ }
457
+ }
458
+ } else {
459
+ errors.push(`Invalid list item type: "${typeof item}"`)
460
+ }
461
+ }
462
+
463
+ listArray.forEach(item => {
464
+ checkItem(item, 0)
465
+ })
466
+
467
+ if (data.style) {
468
+ const style = data.style
469
+ if (style.fontSize !== undefined) {
470
+ if (typeof style.fontSize !== 'number' || style.fontSize <= 0) {
471
+ errors.push('fontSize must be a positive number')
472
+ }
473
+ }
474
+ if (style.color !== undefined) {
475
+ if (typeof style.color !== 'string' || !/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(style.color)) {
476
+ errors.push(`Invalid color format: "${style.color}" (expected hex e.g. #FF0000)`)
477
+ }
478
+ }
479
+ if (style.bulletColor !== undefined) {
480
+ if (
481
+ typeof style.bulletColor !== 'string' ||
482
+ !/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(style.bulletColor)
483
+ ) {
484
+ errors.push(`Invalid bulletColor format: "${style.bulletColor}"`)
485
+ }
486
+ }
487
+ if (style.bulletSize !== undefined) {
488
+ if (typeof style.bulletSize !== 'number' || style.bulletSize <= 0) {
489
+ errors.push('bulletSize must be a positive number')
490
+ }
491
+ }
492
+ }
493
+
494
+ return {
495
+ valid: errors.length === 0,
496
+ errors,
497
+ warnings,
498
+ }
499
+ }
414
500
  }
415
501
 
416
502
  module.exports = { ValidationEngine }
@@ -262,7 +262,13 @@ class ChartManager {
262
262
  const seriesLabels = normalized.labels
263
263
 
264
264
  // 3. Apply Chart XML Updates
265
- const updatedXml = this.#applyChartData(xml, cleanNumericData, chartZipPath)
265
+ let updatedXml = this.#applyChartData(xml, cleanNumericData, chartZipPath)
266
+ if (data.title !== undefined) {
267
+ updatedXml = require('./charts/ChartCacheGenerator.js').ChartCacheGenerator.updateTitle(
268
+ updatedXml,
269
+ data.title
270
+ )
271
+ }
266
272
  this.#zipManager.writeFile(chartZipPath, updatedXml)
267
273
 
268
274
  // 4. Find and Update Embedded Workbook
@@ -680,6 +686,9 @@ class ChartManager {
680
686
 
681
687
  #validateChartData(data) {
682
688
  const { categories, series } = data
689
+ if (data.title !== undefined && typeof data.title !== 'string') {
690
+ throw new Error('Chart title must be a string')
691
+ }
683
692
  if (!series || series.length === 0) return
684
693
 
685
694
  // Series lengths remain consistent (if categories exist, check against length of categories)
@@ -688,23 +697,45 @@ class ChartManager {
688
697
  : series[0].values
689
698
  ? series[0].values.length
690
699
  : 0
700
+
691
701
  for (const ser of series) {
692
702
  const name = ser.name || 'Unknown'
693
- const len = ser.values ? ser.values.length : 0
694
- if (len !== expectedLen) {
695
- throw new Error(
696
- `Series lengths mismatch: expected ${expectedLen} values, got ${len} in series ${name}`
697
- )
703
+ if (!ser.values) {
704
+ ser.values = []
705
+ }
706
+
707
+ // Check if there is any label in the series to determine padding style
708
+ let seriesHasLabels = false
709
+ for (const val of ser.values) {
710
+ if (typeof val === 'object' && val !== null && val.label !== undefined) {
711
+ seriesHasLabels = true
712
+ break
713
+ }
698
714
  }
699
715
 
716
+ // Pad or truncate values to match expectedLen
717
+ if (ser.values.length < expectedLen) {
718
+ while (ser.values.length < expectedLen) {
719
+ if (seriesHasLabels) {
720
+ ser.values.push({ value: null, label: '' })
721
+ } else {
722
+ ser.values.push(null)
723
+ }
724
+ }
725
+ } else if (ser.values.length > expectedLen) {
726
+ ser.values = ser.values.slice(0, expectedLen)
727
+ }
728
+
729
+ const len = ser.values.length
730
+
700
731
  // Check values inside the series
701
732
  let hasLabels = false
702
733
  let labelCount = 0
703
734
  for (const val of ser.values) {
704
735
  if (typeof val === 'object' && val !== null) {
705
736
  const numVal = val.value !== undefined ? val.value : val.data
706
- // Data values remain numeric
707
- if (typeof numVal !== 'number' || isNaN(numVal)) {
737
+ // Data values remain numeric or null
738
+ if (numVal !== null && (typeof numVal !== 'number' || isNaN(numVal))) {
708
739
  throw new Error(`Data value must be numeric in series ${name}`)
709
740
  }
710
741
  if (val.label !== undefined) {
@@ -716,8 +747,8 @@ class ChartManager {
716
747
  }
717
748
  }
718
749
  } else {
719
- // Data values remain numeric (primitive value)
720
- if (typeof val !== 'number' || isNaN(val)) {
750
+ // Data values remain numeric (primitive value) or null
751
+ if (val !== null && (typeof val !== 'number' || isNaN(val))) {
721
752
  throw new Error(`Data value must be numeric in series ${name}`)
722
753
  }
723
754
  }
@@ -743,12 +774,12 @@ class ChartManager {
743
774
  if (ser.values) {
744
775
  ser.values.forEach(v => {
745
776
  if (typeof v === 'object' && v !== null) {
746
- const val = v.value !== undefined ? v.value : v.data !== undefined ? v.data : 0
777
+ const val = v.value !== undefined ? v.value : v.data !== undefined ? v.data : null
747
778
  cleanValues.push(val)
748
779
  labels.push(v.label)
749
780
  if (v.label !== undefined) hasLabel = true
750
781
  } else {
751
- cleanValues.push(Number(v) || 0)
782
+ cleanValues.push(v === null || v === undefined ? null : Number(v) || 0)
752
783
  labels.push(undefined)
753
784
  }
754
785
  })
@@ -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 }
@@ -16,7 +16,12 @@ class ChartCacheGenerator {
16
16
  */
17
17
  static generateNumCache(values) {
18
18
  const ptEntries = values
19
- .map((val, i) => `<c:pt idx="${i}"><c:v>${Number(val) || 0}</c:v></c:pt>`)
19
+ .map((val, i) => {
20
+ if (val === null || val === undefined) {
21
+ return `<c:pt idx="${i}"/>`
22
+ }
23
+ return `<c:pt idx="${i}"><c:v>${val}</c:v></c:pt>`
24
+ })
20
25
  .join('')
21
26
  return `<c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="${values.length}"/>${ptEntries}</c:numCache>`
22
27
  }
@@ -149,14 +154,55 @@ class ChartCacheGenerator {
149
154
  }
150
155
 
151
156
  /**
152
- * Updates the chart title in chart XML.
157
+ * Updates the chart title text in chart XML while preserving all existing
158
+ * styling (spPr, txPr, overlay, layout) from the template.
159
+ *
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.
153
164
  */
154
165
  static updateTitle(xml, title) {
155
- const titleBlock = `<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>${this.#escapeXml(title)}</a:t></a:r></a:p></c:rich></c:tx><c:layout/></c:title>`
166
+ const escapedTitle = this.#escapeXml(title)
167
+
156
168
  if (xml.includes('<c:title>')) {
157
- const fullTitlePattern = /(<c:title>[\s\S]*?<\/c:title>)/
158
- return xml.replace(fullTitlePattern, titleBlock)
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]
176
+ )
177
+ if (bodyPrMatch) bodyPr = bodyPrMatch[1]
178
+ }
179
+
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
+ }
191
+
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>`
193
+
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
+ })
159
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>`
160
206
  const chartPattern = /(<c:chart>)/
161
207
  return xml.replace(chartPattern, `$1${titleBlock}`)
162
208
  }
@@ -189,6 +235,8 @@ class ChartCacheGenerator {
189
235
  let existingTxPr = ''
190
236
  let existingDLblPos = ''
191
237
  let existingNumFmt = ''
238
+ let existingSpPr = ''
239
+ const existingDLblSpPrs = {}
192
240
  const existingShowTags = {}
193
241
 
194
242
  const dLblsMatch = /<c:dLbls>([\s\S]*?)<\/c:dLbls>/.exec(content)
@@ -206,6 +254,26 @@ class ChartCacheGenerator {
206
254
  if (numFmtMatch) {
207
255
  existingNumFmt = numFmtMatch[1]
208
256
  }
257
+ const spPrMatch = /(<c:spPr>[\s\S]*?<\/c:spPr>)/.exec(dLblsContent)
258
+ if (spPrMatch) {
259
+ existingSpPr = spPrMatch[1]
260
+ }
261
+
262
+ // Parse individual <c:dLbl> shape properties to map their background fills
263
+ const dLblPattern = /<c:dLbl>([\s\S]*?)<\/c:dLbl>/g
264
+ let dLblMatch
265
+ while ((dLblMatch = dLblPattern.exec(dLblsContent)) !== null) {
266
+ const dLblContent = dLblMatch[1]
267
+ const idxMatch = /<c:idx val="(\d+)"\/>/.exec(dLblContent)
268
+ if (idxMatch) {
269
+ const idx = parseInt(idxMatch[1], 10)
270
+ const dLblSpPrMatch = /(<c:spPr>[\s\S]*?<\/c:spPr>)/.exec(dLblContent)
271
+ if (dLblSpPrMatch) {
272
+ existingDLblSpPrs[idx] = dLblSpPrMatch[1]
273
+ }
274
+ }
275
+ }
276
+
209
277
  const showTagsList = [
210
278
  'showLegendKey',
211
279
  'showVal',
@@ -231,7 +299,9 @@ class ChartCacheGenerator {
231
299
  existingTxPr,
232
300
  existingDLblPos,
233
301
  existingNumFmt,
234
- existingShowTags
302
+ existingShowTags,
303
+ existingSpPr,
304
+ existingDLblSpPrs
235
305
  )
236
306
 
237
307
  let updatedContent = content
@@ -259,7 +329,9 @@ class ChartCacheGenerator {
259
329
  existingTxPr = '',
260
330
  existingDLblPos = '',
261
331
  existingNumFmt = '',
262
- existingShowTags = {}
332
+ existingShowTags = {},
333
+ existingSpPr = '',
334
+ existingDLblSpPrs = {}
263
335
  ) {
264
336
  const { labels, labelsFromCells, template, position, labelStyle, labelMap } = options
265
337
 
@@ -372,6 +444,25 @@ class ChartCacheGenerator {
372
444
  }
373
445
  }
374
446
 
447
+ // Restore individual point-level spPr (background fill, border, line) from template.
448
+ // Priority: individual dLbl spPr → first available individual dLbl spPr → series-level spPr.
449
+ // The series-level spPr fallback ensures fills are preserved even when the template stores
450
+ // styling only at the dLbls level (not per individual dLbl override).
451
+ if (existingDLblSpPrs && existingDLblSpPrs[i]) {
452
+ xml += existingDLblSpPrs[i]
453
+ } else {
454
+ const firstIdx =
455
+ existingDLblSpPrs && Object.keys(existingDLblSpPrs).length > 0
456
+ ? Object.keys(existingDLblSpPrs)[0]
457
+ : undefined
458
+ if (firstIdx !== undefined && existingDLblSpPrs[firstIdx]) {
459
+ xml += existingDLblSpPrs[firstIdx]
460
+ } else if (existingSpPr) {
461
+ // Fall back to series-level spPr so fills are preserved on each label
462
+ xml += existingSpPr
463
+ }
464
+ }
465
+
375
466
  if (labelStyle) {
376
467
  xml += this.generateTxPrXml(labelStyle)
377
468
  } else if (existingTxPr) {
@@ -399,6 +490,11 @@ class ChartCacheGenerator {
399
490
  xml += existingNumFmt
400
491
  }
401
492
 
493
+ // Restore series-level spPr (background fill, border, line) from template
494
+ if (existingSpPr) {
495
+ xml += existingSpPr
496
+ }
497
+
402
498
  if (labelStyle) {
403
499
  xml += this.generateTxPrXml(labelStyle)
404
500
  } else if (existingTxPr) {
@@ -123,6 +123,22 @@ class ChartWorkbookUpdater {
123
123
  static #updateCellGrid(cells, data) {
124
124
  const { categories = [], series = [] } = data
125
125
 
126
+ // Clear cells that are outside the new category/series grid
127
+ const maxRow = categories.length + 1
128
+ const maxCol = series.length // Column A is 0, Column B is 1, etc.
129
+
130
+ for (const ref of Object.keys(cells)) {
131
+ const match = /^([A-Z]+)(\d+)$/.exec(ref)
132
+ if (match) {
133
+ const colLetter = match[1]
134
+ const row = parseInt(match[2], 10)
135
+ const col = this.colLetterToNum(colLetter)
136
+ if (row > maxRow || col > maxCol) {
137
+ delete cells[ref]
138
+ }
139
+ }
140
+ }
141
+
126
142
  // 1. Write Header A1 as empty
127
143
  cells['A1'] = ''
128
144
 
@@ -142,7 +158,7 @@ class ChartWorkbookUpdater {
142
158
  const colLetter = this.getColumnLetter(colIndex + 1)
143
159
  if (ser.values) {
144
160
  ser.values.forEach((val, rowIndex) => {
145
- cells[`${colLetter}${rowIndex + 2}`] = val !== undefined ? val : 0
161
+ cells[`${colLetter}${rowIndex + 2}`] = val !== undefined ? val : null
146
162
  })
147
163
  }
148
164
  })
@@ -203,7 +219,9 @@ class ChartWorkbookUpdater {
203
219
  if (colNum > maxColNum) maxColNum = colNum
204
220
 
205
221
  const val = cells[ref]
206
- if (typeof val === 'number') {
222
+ if (val === null || val === undefined) {
223
+ sheetData += `<c r="${ref}" t="inlineStr"><is><t></t></is></c>`
224
+ } else if (typeof val === 'number') {
207
225
  sheetData += `<c r="${ref}"><v>${val}</v></c>`
208
226
  } else {
209
227
  sheetData += `<c r="${ref}" t="inlineStr"><is><t>${this.#escapeXml(String(val))}</t></is></c>`