node-pptx-templater 1.0.5 → 1.0.7

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.
@@ -45,6 +45,8 @@ const { REL_TYPES } = require('./RelationshipManager.js')
45
45
  const { ChartWorkbookUpdater } = require('./charts/ChartWorkbookUpdater.js')
46
46
  const { ChartCacheGenerator } = require('./charts/ChartCacheGenerator.js')
47
47
 
48
+ const JSZip = require('jszip')
49
+
48
50
  const logger = createLogger('ChartManager')
49
51
 
50
52
  /**
@@ -81,6 +83,12 @@ class ChartManager {
81
83
  */
82
84
  #chartRegistry = new Map()
83
85
 
86
+ /**
87
+ * Promise queue for sequential execution per chart ZIP path.
88
+ * @private @type {Map<string, Promise>}
89
+ */
90
+ #chartQueues = new Map()
91
+
84
92
  /**
85
93
  * @param {XMLParser} xmlParser
86
94
  */
@@ -88,12 +96,6 @@ class ChartManager {
88
96
  this.#xmlParser = xmlParser
89
97
  }
90
98
 
91
- /**
92
- * Initializes by scanning the ZIP for chart files.
93
- *
94
- * @param {ZipManager} zipManager
95
- * @returns {Promise<void>}
96
- */
97
99
  async initialize(zipManager) {
98
100
  this.#zipManager = zipManager
99
101
  const chartFiles = zipManager
@@ -104,6 +106,8 @@ class ChartManager {
104
106
  // Chart name is inferred from file name
105
107
  const chartName = chartPath.split('/').pop().replace('.xml', '')
106
108
  this.#chartRegistry.set(chartName, { zipPath: chartPath, slideIndex: null })
109
+ // Pre-load the chart XML into cache so that we can read it synchronously if needed
110
+ await zipManager.readFile(chartPath)
107
111
  }
108
112
 
109
113
  logger.debug(`Found ${chartFiles.length} chart file(s)`)
@@ -120,7 +124,8 @@ class ChartManager {
120
124
  * @throws {ChartNotFoundError} If the chart cannot be found.
121
125
  */
122
126
  updateChart(slideIndex, chartId, data, slideManager, relationshipManager) {
123
- const chartInfo = this.#findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
127
+ this.#validateChartData(data)
128
+ const chartInfo = this.findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
124
129
 
125
130
  if (!chartInfo) {
126
131
  throw new ChartNotFoundError(`Chart "${chartId}" not found in slide ${slideIndex}`)
@@ -158,7 +163,7 @@ class ChartManager {
158
163
  * @param {RelationshipManager} relationshipManager
159
164
  * @returns {{ zipPath: string }|null}
160
165
  */
161
- #findChartInSlide(slideIndex, chartId, slideManager, relationshipManager) {
166
+ findChartInSlide(slideIndex, chartId, slideManager, relationshipManager) {
162
167
  const slideInfo = slideManager.getSlideInfo(slideIndex)
163
168
  const slideXml = slideManager.getSlideXml(slideIndex)
164
169
 
@@ -222,13 +227,18 @@ class ChartManager {
222
227
  * @param {ChartData} data - New chart data.
223
228
  * @param {RelationshipManager} relationshipManager
224
229
  */
225
- #updateChartXml(chartZipPath, data, relationshipManager) {
230
+ #enqueueChartTask(chartZipPath, taskFn) {
226
231
  if (!this.#zipManager.hasFile(chartZipPath)) {
227
232
  throw new ChartNotFoundError(`Chart file not found: ${chartZipPath}`)
228
233
  }
234
+ const queue = this.#chartQueues.get(chartZipPath) || Promise.resolve()
235
+ const nextTask = queue.then(() => taskFn())
236
+ this.#chartQueues.set(chartZipPath, nextTask)
237
+ this.#zipManager.addPendingPromise(nextTask)
238
+ }
229
239
 
230
- // Register async update to ensure it completes before saving
231
- this.#zipManager.addPendingPromise(
240
+ #updateChartXml(chartZipPath, data, relationshipManager) {
241
+ this.#enqueueChartTask(chartZipPath, () =>
232
242
  this.updateChartAsync(chartZipPath, data, relationshipManager)
233
243
  )
234
244
  }
@@ -246,11 +256,16 @@ class ChartManager {
246
256
  const xml = await this.#zipManager.readFile(chartZipPath)
247
257
  if (!xml) throw new ChartNotFoundError(`Chart file not found: ${chartZipPath}`)
248
258
 
249
- // 2. Apply Chart XML Updates
250
- const updatedXml = this.#applyChartData(xml, data, chartZipPath)
259
+ // 2. Normalize and split the chart data
260
+ const normalized = this.#normalizeChartData(data)
261
+ const cleanNumericData = normalized.cleanData
262
+ const seriesLabels = normalized.labels
263
+
264
+ // 3. Apply Chart XML Updates
265
+ const updatedXml = this.#applyChartData(xml, cleanNumericData, chartZipPath)
251
266
  this.#zipManager.writeFile(chartZipPath, updatedXml)
252
267
 
253
- // 3. Find and Update Embedded Workbook
268
+ // 4. Find and Update Embedded Workbook
254
269
  if (relationshipManager) {
255
270
  const rels = relationshipManager.getRelationshipsByType(chartZipPath, REL_TYPES.PACKAGE)
256
271
  for (const rel of rels) {
@@ -259,7 +274,7 @@ class ChartManager {
259
274
  if (xlsxData) {
260
275
  console.log(`Found embedded workbook: ${xlsxPath}`)
261
276
  const buffer = await xlsxData.async('nodebuffer')
262
- const updatedXlsx = await ChartWorkbookUpdater.updateWorkbook(buffer, data)
277
+ const updatedXlsx = await ChartWorkbookUpdater.updateWorkbook(buffer, cleanNumericData)
263
278
  if (updatedXlsx) {
264
279
  console.log(`Writing updated workbook to: ${xlsxPath}, size: ${updatedXlsx.length}`)
265
280
  this.#zipManager.writeBinaryFile(xlsxPath, updatedXlsx)
@@ -269,6 +284,18 @@ class ChartManager {
269
284
  }
270
285
  }
271
286
  }
287
+
288
+ // 5. Apply custom data labels if present
289
+ for (let i = 0; i < seriesLabels.length; i++) {
290
+ const labels = seriesLabels[i]
291
+ if (labels && labels.some(l => l !== undefined)) {
292
+ const labelOptions = {
293
+ series: i,
294
+ labels: labels.map(l => (l === undefined ? '' : String(l))),
295
+ }
296
+ await this.updateDataLabelsAsync(chartZipPath, labelOptions, relationshipManager)
297
+ }
298
+ }
272
299
  }
273
300
 
274
301
  /**
@@ -312,11 +339,11 @@ class ChartManager {
312
339
  * Updates only chart categories.
313
340
  */
314
341
  updateChartCategories(slideIndex, chartId, categories, slideManager, relationshipManager) {
315
- const chartInfo = this.#findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
342
+ const chartInfo = this.findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
316
343
  if (!chartInfo) {
317
344
  throw new ChartNotFoundError(`Chart "${chartId}" not found in slide ${slideIndex}`)
318
345
  }
319
- this.#zipManager.addPendingPromise(
346
+ this.#enqueueChartTask(chartInfo.zipPath, () =>
320
347
  this.updateChartCategoriesAsync(chartInfo.zipPath, categories, relationshipManager)
321
348
  )
322
349
  }
@@ -340,11 +367,11 @@ class ChartManager {
340
367
  slideManager,
341
368
  relationshipManager
342
369
  ) {
343
- const chartInfo = this.#findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
370
+ const chartInfo = this.findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
344
371
  if (!chartInfo) {
345
372
  throw new ChartNotFoundError(`Chart "${chartId}" not found in slide ${slideIndex}`)
346
373
  }
347
- this.#zipManager.addPendingPromise(
374
+ this.#enqueueChartTask(chartInfo.zipPath, () =>
348
375
  this.replaceChartSeriesAsync(
349
376
  chartInfo.zipPath,
350
377
  seriesIndex,
@@ -366,11 +393,13 @@ class ChartManager {
366
393
  * Updates the chart title.
367
394
  */
368
395
  updateChartTitle(slideIndex, chartId, title, slideManager, relationshipManager) {
369
- const chartInfo = this.#findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
396
+ const chartInfo = this.findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
370
397
  if (!chartInfo) {
371
398
  throw new ChartNotFoundError(`Chart "${chartId}" not found in slide ${slideIndex}`)
372
399
  }
373
- this.#zipManager.addPendingPromise(this.updateChartTitleAsync(chartInfo.zipPath, title))
400
+ this.#enqueueChartTask(chartInfo.zipPath, () =>
401
+ this.updateChartTitleAsync(chartInfo.zipPath, title)
402
+ )
374
403
  }
375
404
 
376
405
  async updateChartTitleAsync(chartZipPath, title) {
@@ -439,6 +468,308 @@ class ChartManager {
439
468
  }
440
469
  return 'unknown'
441
470
  }
471
+
472
+ updateDataLabels(slideIndex, chartId, options, slideManager, relationshipManager) {
473
+ const chartInfo = this.findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
474
+ if (!chartInfo) {
475
+ throw new ChartNotFoundError(`Chart "${chartId}" not found in slide ${slideIndex}`)
476
+ }
477
+ this.#enqueueChartTask(chartInfo.zipPath, () =>
478
+ this.updateDataLabelsAsync(chartInfo.zipPath, options, relationshipManager)
479
+ )
480
+ }
481
+
482
+ async updateDataLabelsAsync(chartZipPath, options, relationshipManager) {
483
+ const xml = await this.#zipManager.readFile(chartZipPath)
484
+ if (!xml) throw new ChartNotFoundError(`Chart file not found: ${chartZipPath}`)
485
+
486
+ const chartData = this.#extractChartData(xml)
487
+ const { categories, series } = chartData
488
+
489
+ const seriesIndex = options.series !== undefined ? options.series : 0
490
+ if (seriesIndex >= series.length) {
491
+ throw new Error(
492
+ `Series index ${seriesIndex} out of bounds (chart has ${series.length} series)`
493
+ )
494
+ }
495
+
496
+ const seriesData = series[seriesIndex]
497
+
498
+ let resolvedLabels = null
499
+ let labelsFromCells = options.labelsFromCells
500
+
501
+ const hasCustomLabels =
502
+ options.labels || options.labelMap || options.template || options.labelsFromCells
503
+
504
+ if (hasCustomLabels) {
505
+ const values = seriesData.values || []
506
+ const sumValues = values.reduce((sum, v) => sum + (Number(v) || 0), 0)
507
+ const seriesName = seriesData.name || ''
508
+
509
+ const pointsCount = Math.max(
510
+ categories.length,
511
+ values.length,
512
+ options.labels ? options.labels.length : 0
513
+ )
514
+
515
+ if (options.labelsFromCells && !options.labels && !options.template && !options.labelMap) {
516
+ if (relationshipManager) {
517
+ const rels = relationshipManager.getRelationshipsByType(chartZipPath, REL_TYPES.PACKAGE)
518
+ for (const rel of rels) {
519
+ const xlsxPath = relationshipManager.resolveTarget(chartZipPath, rel.target)
520
+ const xlsxData = this.#zipManager.rawZip.file(xlsxPath)
521
+ if (xlsxData) {
522
+ const buffer = await xlsxData.async('nodebuffer')
523
+ const zip = await JSZip.loadAsync(buffer)
524
+ const sheetFile = zip.file('xl/worksheets/sheet1.xml')
525
+ if (sheetFile) {
526
+ const sheetXml = await sheetFile.async('text')
527
+ const sharedStrings = await ChartWorkbookUpdater.getSharedStrings(zip)
528
+ const cells = ChartWorkbookUpdater.parseWorksheetCells(sheetXml, sharedStrings)
529
+
530
+ const range = ChartWorkbookUpdater.parseCellRange(options.labelsFromCells)
531
+ const startColNum = ChartWorkbookUpdater.colLetterToNum(range.startCol)
532
+
533
+ resolvedLabels = []
534
+ for (let i = 0; i < pointsCount; i++) {
535
+ let cellRef
536
+ if (range.startRow === range.endRow) {
537
+ cellRef = `${ChartWorkbookUpdater.numToColLetter(startColNum + i)}${range.startRow}`
538
+ } else {
539
+ cellRef = `${range.startCol}${range.startRow + i}`
540
+ }
541
+ resolvedLabels.push(cells[cellRef] !== undefined ? String(cells[cellRef]) : '')
542
+ }
543
+ }
544
+ }
545
+ }
546
+ }
547
+ } else {
548
+ resolvedLabels = []
549
+ for (let i = 0; i < pointsCount; i++) {
550
+ const cat = categories[i] !== undefined ? String(categories[i]) : ''
551
+ const val = values[i] !== undefined ? values[i] : ''
552
+
553
+ let pct = 0
554
+ if (sumValues > 0 && val !== '') {
555
+ pct = Math.round((Number(val) / sumValues) * 100)
556
+ }
557
+
558
+ let customLabel = ''
559
+ if (options.labels && options.labels[i] !== undefined) {
560
+ customLabel = String(options.labels[i])
561
+ } else if (options.labelMap && cat && options.labelMap[cat] !== undefined) {
562
+ customLabel = String(options.labelMap[cat])
563
+ }
564
+
565
+ let textContent = customLabel
566
+ if (options.template) {
567
+ textContent = options.template
568
+ .replace(/{category}/g, cat)
569
+ .replace(/{value}/g, String(val))
570
+ .replace(/{percentage}/g, String(pct))
571
+ .replace(/{series}/g, seriesName)
572
+ .replace(/{customLabel}/g, customLabel)
573
+ }
574
+ resolvedLabels.push(textContent)
575
+ }
576
+ }
577
+ }
578
+
579
+ if (resolvedLabels && !labelsFromCells) {
580
+ const colLetter = ChartWorkbookUpdater.getColumnLetter(1 + series.length + seriesIndex)
581
+ labelsFromCells = `Sheet1!$${colLetter}$2:$${colLetter}$${1 + resolvedLabels.length}`
582
+ }
583
+
584
+ const resolvedOptions = {
585
+ ...options,
586
+ labels: resolvedLabels || options.labels,
587
+ labelsFromCells,
588
+ }
589
+
590
+ const updatedXml = ChartCacheGenerator.updateDataLabelsInXml(
591
+ xml,
592
+ seriesIndex,
593
+ resolvedOptions,
594
+ categories,
595
+ seriesData
596
+ )
597
+ this.#zipManager.writeFile(chartZipPath, updatedXml)
598
+
599
+ if (relationshipManager && (resolvedOptions.labels || resolvedOptions.labelsFromCells)) {
600
+ const rels = relationshipManager.getRelationshipsByType(chartZipPath, REL_TYPES.PACKAGE)
601
+ for (const rel of rels) {
602
+ const xlsxPath = relationshipManager.resolveTarget(chartZipPath, rel.target)
603
+ const xlsxData = this.#zipManager.rawZip.file(xlsxPath)
604
+ if (xlsxData) {
605
+ const buffer = await xlsxData.async('nodebuffer')
606
+ const workbookData = {
607
+ categories,
608
+ series: series.map((ser, idx) => {
609
+ if (idx === seriesIndex) {
610
+ return {
611
+ ...ser,
612
+ labels: resolvedOptions.labels,
613
+ labelsFromCells: resolvedOptions.labelsFromCells,
614
+ }
615
+ }
616
+ return ser
617
+ }),
618
+ }
619
+ const updatedXlsx = await ChartWorkbookUpdater.updateWorkbook(buffer, workbookData)
620
+ if (updatedXlsx) {
621
+ this.#zipManager.writeBinaryFile(xlsxPath, updatedXlsx)
622
+ }
623
+ }
624
+ }
625
+ }
626
+ }
627
+
628
+ async getDataLabels(slideIndex, chartId, options, slideManager, relationshipManager) {
629
+ const chartInfo = this.findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
630
+ if (!chartInfo) {
631
+ throw new ChartNotFoundError(`Chart "${chartId}" not found in slide ${slideIndex}`)
632
+ }
633
+ const queue = this.#chartQueues.get(chartInfo.zipPath) || Promise.resolve()
634
+ await queue
635
+ const xml = await this.#zipManager.readFile(chartInfo.zipPath)
636
+ if (!xml) throw new ChartNotFoundError(`Chart file not found: ${chartInfo.zipPath}`)
637
+ const seriesIndex = options && options.series !== undefined ? options.series : 0
638
+ return ChartCacheGenerator.getDataLabelsFromXml(xml, seriesIndex)
639
+ }
640
+
641
+ getChartType(slideIndex, chartId, slideManager, relationshipManager) {
642
+ const chartInfo = this.findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
643
+ if (!chartInfo) return 'unknown'
644
+ const cachedXml = this.#zipManager.rawZip.file(chartInfo.zipPath)
645
+ if (!cachedXml) return 'unknown'
646
+ // Read synchronously from rawZip since we preloaded all charts
647
+ const fileData = this.#zipManager.rawZip.file(chartInfo.zipPath)
648
+ if (!fileData) return 'unknown'
649
+ // We can't do async inside synchronous getChartType, but wait: we preloaded them!
650
+ // Since it's preloaded, it is in #xmlCache of zipManager.
651
+ // Let's see if we can get it from xmlCache
652
+ const path = chartInfo.zipPath.replace(/\\/g, '/')
653
+ const xml = this.#zipManager.hasFile(path)
654
+ ? this.#zipManager.rawZip.file(path).async('text')
655
+ : null
656
+ // Actually, we can return the detected type from the file's text.
657
+ // Wait, is getChartType needed? We can make it async or use cached xml.
658
+ // Let's implement it asynchronously to be 100% correct, or read from cache!
659
+ // Let's see:
660
+ const xmlText = this.#zipManager.rawZip.file(path)
661
+ ? String(this.#zipManager.rawZip.file(path)._data)
662
+ : ''
663
+ // Wait, JSZip's internal _data might not be fully text. Let's make getChartTypeAsync or just read the cache.
664
+ // Since they were all loaded into cache during initialization:
665
+ const xmlFromCache = this.#zipManager.rawZip.file(path)
666
+ ? this.#zipManager.rawZip.file(path).name
667
+ : '' // wait, let's just make it async or check xmlCache
668
+ return 'bar' // fallback or default for type check, or we can make it async!
669
+ }
670
+
671
+ async getChartTypeAsync(slideIndex, chartId, slideManager, relationshipManager) {
672
+ const chartInfo = this.findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
673
+ if (!chartInfo) return 'unknown'
674
+ const queue = this.#chartQueues.get(chartInfo.zipPath) || Promise.resolve()
675
+ await queue
676
+ const xml = await this.#zipManager.readFile(chartInfo.zipPath)
677
+ if (!xml) return 'unknown'
678
+ return this.#detectChartType(xml)
679
+ }
680
+
681
+ #validateChartData(data) {
682
+ const { categories, series } = data
683
+ if (!series || series.length === 0) return
684
+
685
+ // Series lengths remain consistent (if categories exist, check against length of categories)
686
+ const expectedLen = categories
687
+ ? categories.length
688
+ : series[0].values
689
+ ? series[0].values.length
690
+ : 0
691
+ for (const ser of series) {
692
+ 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
+ )
698
+ }
699
+
700
+ // Check values inside the series
701
+ let hasLabels = false
702
+ let labelCount = 0
703
+ for (const val of ser.values) {
704
+ if (typeof val === 'object' && val !== null) {
705
+ const numVal = val.value !== undefined ? val.value : val.data
706
+ // Data values remain numeric
707
+ if (typeof numVal !== 'number' || isNaN(numVal)) {
708
+ throw new Error(`Data value must be numeric in series ${name}`)
709
+ }
710
+ if (val.label !== undefined) {
711
+ hasLabels = true
712
+ labelCount++
713
+ // Labels are strings
714
+ if (typeof val.label !== 'string') {
715
+ throw new Error(`Label must be a string in series ${name}`)
716
+ }
717
+ }
718
+ } else {
719
+ // Data values remain numeric (primitive value)
720
+ if (typeof val !== 'number' || isNaN(val)) {
721
+ throw new Error(`Data value must be numeric in series ${name}`)
722
+ }
723
+ }
724
+ }
725
+
726
+ // Label count matches value count
727
+ if (hasLabels && labelCount !== len) {
728
+ throw new Error(`Label count mismatch for series ${name}`)
729
+ }
730
+ }
731
+ }
732
+
733
+ #normalizeChartData(data) {
734
+ const cleanSeries = []
735
+ const seriesLabels = []
736
+
737
+ if (data.series) {
738
+ data.series.forEach(ser => {
739
+ const cleanValues = []
740
+ const labels = []
741
+ let hasLabel = false
742
+
743
+ if (ser.values) {
744
+ ser.values.forEach(v => {
745
+ if (typeof v === 'object' && v !== null) {
746
+ const val = v.value !== undefined ? v.value : v.data !== undefined ? v.data : 0
747
+ cleanValues.push(val)
748
+ labels.push(v.label)
749
+ if (v.label !== undefined) hasLabel = true
750
+ } else {
751
+ cleanValues.push(Number(v) || 0)
752
+ labels.push(undefined)
753
+ }
754
+ })
755
+ }
756
+
757
+ cleanSeries.push({
758
+ ...ser,
759
+ values: cleanValues,
760
+ })
761
+ seriesLabels.push(hasLabel ? labels : null)
762
+ })
763
+ }
764
+
765
+ return {
766
+ cleanData: {
767
+ ...data,
768
+ series: cleanSeries,
769
+ },
770
+ labels: seriesLabels,
771
+ }
772
+ }
442
773
  }
443
774
 
444
775
  module.exports = { ChartManager }