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.
@@ -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
  }
@@ -162,6 +167,427 @@ class ChartCacheGenerator {
162
167
  }
163
168
  }
164
169
 
170
+ static updateDataLabelsInXml(xml, seriesIndex, options, categories = [], seriesData = {}) {
171
+ let serIndex = 0
172
+ const serPattern = /(<c:ser>)([\s\S]*?)(<\/c:ser>)/g
173
+
174
+ return xml.replace(serPattern, (match, open, content, close) => {
175
+ if (serIndex !== seriesIndex) {
176
+ serIndex++
177
+ return match
178
+ }
179
+ serIndex++
180
+
181
+ let pointsCount = categories.length
182
+ if (pointsCount === 0) {
183
+ const valMatch = /<c:val>([\s\S]*?)<\/c:val>/.exec(content)
184
+ if (valMatch) {
185
+ const countMatch = /<c:ptCount val="(\d+)"\/>/.exec(valMatch[1])
186
+ if (countMatch) pointsCount = parseInt(countMatch[1], 10)
187
+ }
188
+ }
189
+ if (pointsCount === 0 && options.labels) {
190
+ pointsCount = options.labels.length
191
+ }
192
+
193
+ // Parse existing styling and flags from the current <c:dLbls> block
194
+ let existingTxPr = ''
195
+ let existingDLblPos = ''
196
+ let existingNumFmt = ''
197
+ let existingSpPr = ''
198
+ const existingDLblSpPrs = {}
199
+ const existingShowTags = {}
200
+
201
+ const dLblsMatch = /<c:dLbls>([\s\S]*?)<\/c:dLbls>/.exec(content)
202
+ if (dLblsMatch) {
203
+ const dLblsContent = dLblsMatch[1]
204
+ const txPrMatch = /(<c:txPr>[\s\S]*?<\/c:txPr>)/.exec(dLblsContent)
205
+ if (txPrMatch) {
206
+ existingTxPr = txPrMatch[1]
207
+ }
208
+ const dLblPosMatch = /(<c:dLblPos\s+[^>]*\/>)/.exec(dLblsContent)
209
+ if (dLblPosMatch) {
210
+ existingDLblPos = dLblPosMatch[1]
211
+ }
212
+ const numFmtMatch = /(<c:numFmt\s+[^>]*\/>)/.exec(dLblsContent)
213
+ if (numFmtMatch) {
214
+ existingNumFmt = numFmtMatch[1]
215
+ }
216
+ const spPrMatch = /(<c:spPr>[\s\S]*?<\/c:spPr>)/.exec(dLblsContent)
217
+ if (spPrMatch) {
218
+ existingSpPr = spPrMatch[1]
219
+ }
220
+
221
+ // Parse individual <c:dLbl> shape properties to map their background fills
222
+ const dLblPattern = /<c:dLbl>([\s\S]*?)<\/c:dLbl>/g
223
+ let dLblMatch
224
+ while ((dLblMatch = dLblPattern.exec(dLblsContent)) !== null) {
225
+ const dLblContent = dLblMatch[1]
226
+ const idxMatch = /<c:idx val="(\d+)"\/>/.exec(dLblContent)
227
+ if (idxMatch) {
228
+ const idx = parseInt(idxMatch[1], 10)
229
+ const dLblSpPrMatch = /(<c:spPr>[\s\S]*?<\/c:spPr>)/.exec(dLblContent)
230
+ if (dLblSpPrMatch) {
231
+ existingDLblSpPrs[idx] = dLblSpPrMatch[1]
232
+ }
233
+ }
234
+ }
235
+
236
+ const showTagsList = [
237
+ 'showLegendKey',
238
+ 'showVal',
239
+ 'showCatName',
240
+ 'showSerName',
241
+ 'showPercent',
242
+ 'showBubbleSize',
243
+ ]
244
+ showTagsList.forEach(tag => {
245
+ const tagPattern = new RegExp(`(<c:${tag}\\s+val="([^"]*)"\\s*\\/>)`)
246
+ const tagMatch = tagPattern.exec(dLblsContent)
247
+ if (tagMatch) {
248
+ existingShowTags[tag] = tagMatch[1]
249
+ }
250
+ })
251
+ }
252
+
253
+ const dLblsXml = this.generateDLblsXml(
254
+ pointsCount,
255
+ options,
256
+ categories,
257
+ seriesData,
258
+ existingTxPr,
259
+ existingDLblPos,
260
+ existingNumFmt,
261
+ existingShowTags,
262
+ existingSpPr,
263
+ existingDLblSpPrs
264
+ )
265
+
266
+ let updatedContent = content
267
+ const dLblsPattern = /(<c:dLbls>[\s\S]*?<\/c:dLbls>)/
268
+ if (dLblsPattern.test(updatedContent)) {
269
+ updatedContent = updatedContent.replace(dLblsPattern, dLblsXml)
270
+ } else {
271
+ const insertBefore = /(<c:cat>|<c:val>|<c:extLst>)/
272
+ if (insertBefore.test(updatedContent)) {
273
+ updatedContent = updatedContent.replace(insertBefore, `${dLblsXml}$1`)
274
+ } else {
275
+ updatedContent += dLblsXml
276
+ }
277
+ }
278
+
279
+ return `${open}${updatedContent}${close}`
280
+ })
281
+ }
282
+
283
+ static generateDLblsXml(
284
+ pointsCount,
285
+ options,
286
+ categories = [],
287
+ seriesData = {},
288
+ existingTxPr = '',
289
+ existingDLblPos = '',
290
+ existingNumFmt = '',
291
+ existingShowTags = {},
292
+ existingSpPr = '',
293
+ existingDLblSpPrs = {}
294
+ ) {
295
+ const { labels, labelsFromCells, template, position, labelStyle, labelMap } = options
296
+
297
+ let xml = '<c:dLbls>'
298
+
299
+ const posMap = {
300
+ center: 'ctr',
301
+ insideEnd: 'inEnd',
302
+ insideBase: 'inBase',
303
+ outsideEnd: 'outEnd',
304
+ bestFit: 'bestFit',
305
+ left: 'l',
306
+ right: 'r',
307
+ top: 't',
308
+ bottom: 'b',
309
+ }
310
+ const openxmlPos = position ? posMap[position] : null
311
+
312
+ const values = seriesData.values || []
313
+ const sumValues = values.reduce((sum, v) => sum + (Number(v) || 0), 0)
314
+ const seriesName = seriesData.name || ''
315
+
316
+ const hasCustomLabels = labels || labelsFromCells || template || labelMap
317
+
318
+ if (hasCustomLabels && pointsCount > 0) {
319
+ for (let i = 0; i < pointsCount; i++) {
320
+ const cat = categories[i] !== undefined ? String(categories[i]) : ''
321
+ const val = values[i] !== undefined ? values[i] : ''
322
+
323
+ let pct = 0
324
+ if (sumValues > 0 && val !== '') {
325
+ pct = Math.round((Number(val) / sumValues) * 100)
326
+ }
327
+
328
+ let customLabel = ''
329
+ if (labels && labels[i] !== undefined) {
330
+ customLabel = String(labels[i])
331
+ } else if (labelMap && cat && labelMap[cat] !== undefined) {
332
+ customLabel = String(labelMap[cat])
333
+ }
334
+
335
+ let textContent = customLabel
336
+ if (template) {
337
+ textContent = template
338
+ .replace(/{category}/g, cat)
339
+ .replace(/{value}/g, String(val))
340
+ .replace(/{percentage}/g, String(pct))
341
+ .replace(/{series}/g, seriesName)
342
+ .replace(/{customLabel}/g, customLabel)
343
+ }
344
+
345
+ xml += `<c:dLbl>`
346
+ xml += `<c:idx val="${i}"/>`
347
+
348
+ if (labelsFromCells && !template) {
349
+ const range = ChartWorkbookUpdater.parseCellRange(labelsFromCells)
350
+ const startColNum = ChartWorkbookUpdater.colLetterToNum(range.startCol)
351
+
352
+ let cellRef
353
+ if (range.startRow === range.endRow) {
354
+ cellRef = `${ChartWorkbookUpdater.numToColLetter(startColNum + i)}${range.startRow}`
355
+ } else {
356
+ cellRef = `${range.startCol}${range.startRow + i}`
357
+ }
358
+ const fullCellRef = `${range.sheetName}!$${cellRef.replace(/(\d+)/, '$$$1')}`
359
+ const displayVal = textContent || customLabel || ''
360
+
361
+ xml += `<c:tx>`
362
+ xml += `<c:strRef>`
363
+ xml += `<c:f>${fullCellRef}</c:f>`
364
+ xml += `<c:strCache>`
365
+ xml += `<c:ptCount val="1"/>`
366
+ xml += `<c:pt idx="0"><c:v>${this.#escapeXml(displayVal)}</c:v></c:pt>`
367
+ xml += `</c:strCache>`
368
+ xml += `</c:strRef>`
369
+ xml += `</c:tx>`
370
+ } else if (textContent) {
371
+ if (labelsFromCells) {
372
+ const range = ChartWorkbookUpdater.parseCellRange(labelsFromCells)
373
+ const startColNum = ChartWorkbookUpdater.colLetterToNum(range.startCol)
374
+ let cellRef
375
+ if (range.startRow === range.endRow) {
376
+ cellRef = `${ChartWorkbookUpdater.numToColLetter(startColNum + i)}${range.startRow}`
377
+ } else {
378
+ cellRef = `${range.startCol}${range.startRow + i}`
379
+ }
380
+ const fullCellRef = `${range.sheetName}!$${cellRef.replace(/(\d+)/, '$$$1')}`
381
+
382
+ xml += `<c:tx>`
383
+ xml += `<c:strRef>`
384
+ xml += `<c:f>${fullCellRef}</c:f>`
385
+ xml += `<c:strCache>`
386
+ xml += `<c:ptCount val="1"/>`
387
+ xml += `<c:pt idx="0"><c:v>${this.#escapeXml(textContent)}</c:v></c:pt>`
388
+ xml += `</c:strCache>`
389
+ xml += `</c:strRef>`
390
+ xml += `</c:tx>`
391
+ } else {
392
+ xml += `<c:tx>`
393
+ xml += `<c:rich>`
394
+ xml += `<a:bodyPr/>`
395
+ xml += `<a:lstStyle/>`
396
+ xml += `<a:p>`
397
+ xml += `<a:r>`
398
+ xml += `<a:t>${this.#escapeXml(textContent)}</a:t>`
399
+ xml += `</a:r>`
400
+ xml += `</a:p>`
401
+ xml += `</c:rich>`
402
+ xml += `</c:tx>`
403
+ }
404
+ }
405
+
406
+ // Restore individual point-level spPr (background fill, border, line) from template.
407
+ // Priority: individual dLbl spPr → first available individual dLbl spPr → series-level spPr.
408
+ // The series-level spPr fallback ensures fills are preserved even when the template stores
409
+ // styling only at the dLbls level (not per individual dLbl override).
410
+ if (existingDLblSpPrs && existingDLblSpPrs[i]) {
411
+ xml += existingDLblSpPrs[i]
412
+ } else {
413
+ const firstIdx =
414
+ existingDLblSpPrs && Object.keys(existingDLblSpPrs).length > 0
415
+ ? Object.keys(existingDLblSpPrs)[0]
416
+ : undefined
417
+ if (firstIdx !== undefined && existingDLblSpPrs[firstIdx]) {
418
+ xml += existingDLblSpPrs[firstIdx]
419
+ } else if (existingSpPr) {
420
+ // Fall back to series-level spPr so fills are preserved on each label
421
+ xml += existingSpPr
422
+ }
423
+ }
424
+
425
+ if (labelStyle) {
426
+ xml += this.generateTxPrXml(labelStyle)
427
+ } else if (existingTxPr) {
428
+ xml += existingTxPr
429
+ }
430
+
431
+ if (openxmlPos) {
432
+ xml += `<c:dLblPos val="${openxmlPos}"/>`
433
+ } else if (existingDLblPos) {
434
+ xml += existingDLblPos
435
+ }
436
+
437
+ xml += `<c:showLegendKey val="0"/>`
438
+ xml += `<c:showVal val="0"/>`
439
+ xml += `<c:showCatName val="0"/>`
440
+ xml += `<c:showSerName val="0"/>`
441
+ xml += `<c:showPercent val="0"/>`
442
+ xml += `<c:showBubbleSize val="0"/>`
443
+
444
+ xml += `</c:dLbl>`
445
+ }
446
+ }
447
+
448
+ if (existingNumFmt) {
449
+ xml += existingNumFmt
450
+ }
451
+
452
+ // Restore series-level spPr (background fill, border, line) from template
453
+ if (existingSpPr) {
454
+ xml += existingSpPr
455
+ }
456
+
457
+ if (labelStyle) {
458
+ xml += this.generateTxPrXml(labelStyle)
459
+ } else if (existingTxPr) {
460
+ xml += existingTxPr
461
+ }
462
+
463
+ if (openxmlPos) {
464
+ xml += `<c:dLblPos val="${openxmlPos}"/>`
465
+ } else if (existingDLblPos) {
466
+ xml += existingDLblPos
467
+ }
468
+
469
+ // showLegendKey
470
+ if (existingShowTags['showLegendKey']) {
471
+ xml += existingShowTags['showLegendKey']
472
+ } else {
473
+ xml += `<c:showLegendKey val="0"/>`
474
+ }
475
+
476
+ // showVal
477
+ const defaultShowVal = hasCustomLabels ? '0' : '1'
478
+ if (existingShowTags['showVal'] && !hasCustomLabels) {
479
+ xml += existingShowTags['showVal']
480
+ } else {
481
+ xml += `<c:showVal val="${defaultShowVal}"/>`
482
+ }
483
+
484
+ // showCatName
485
+ if (existingShowTags['showCatName']) {
486
+ xml += existingShowTags['showCatName']
487
+ } else {
488
+ xml += `<c:showCatName val="0"/>`
489
+ }
490
+
491
+ // showSerName
492
+ if (existingShowTags['showSerName']) {
493
+ xml += existingShowTags['showSerName']
494
+ } else {
495
+ xml += `<c:showSerName val="0"/>`
496
+ }
497
+
498
+ // showPercent
499
+ const defaultShowPercent = hasCustomLabels || !options.showPercent ? '0' : '1'
500
+ if (options.showPercent !== undefined) {
501
+ xml += `<c:showPercent val="${options.showPercent ? '1' : '0'}"/>`
502
+ } else if (existingShowTags['showPercent'] && !hasCustomLabels) {
503
+ xml += existingShowTags['showPercent']
504
+ } else {
505
+ xml += `<c:showPercent val="${defaultShowPercent}"/>`
506
+ }
507
+
508
+ // showBubbleSize
509
+ if (existingShowTags['showBubbleSize']) {
510
+ xml += existingShowTags['showBubbleSize']
511
+ } else {
512
+ xml += `<c:showBubbleSize val="0"/>`
513
+ }
514
+
515
+ xml += '</c:dLbls>'
516
+ return xml
517
+ }
518
+
519
+ static generateTxPrXml(style) {
520
+ const { fontFamily, fontSize, bold, italic, underline, color } = style
521
+
522
+ const sz = fontSize ? ` sz="${fontSize * 100}"` : ''
523
+ const b = bold !== undefined ? ` b="${bold ? '1' : '0'}"` : ''
524
+ const i = italic !== undefined ? ` i="${italic ? '1' : '0'}"` : ''
525
+ const u = underline !== undefined ? ` u="${underline ? 'sng' : 'none'}"` : ''
526
+
527
+ let fillXml = ''
528
+ if (color) {
529
+ const cleanColor = color.replace('#', '')
530
+ fillXml = `<a:solidFill><a:srgbClr val="${cleanColor}"/></a:solidFill>`
531
+ }
532
+
533
+ let latinXml = ''
534
+ if (fontFamily) {
535
+ latinXml = `<a:latin typeface="${fontFamily}"/><a:cs typeface="${fontFamily}"/>`
536
+ }
537
+
538
+ return `<c:txPr>
539
+ <a:bodyPr/>
540
+ <a:lstStyle/>
541
+ <a:p>
542
+ <a:pPr>
543
+ <a:defRPr${sz}${b}${i}${u}>
544
+ ${fillXml}
545
+ ${latinXml}
546
+ </a:defRPr>
547
+ </a:pPr>
548
+ <a:endParaRPr lang="en-US"/>
549
+ </a:p>
550
+ </c:txPr>`
551
+ }
552
+
553
+ static getDataLabelsFromXml(xml, seriesIndex) {
554
+ const serPattern = /<c:ser>([\s\S]*?)<\/c:ser>/g
555
+ const matches = [...xml.matchAll(serPattern)]
556
+ if (seriesIndex >= matches.length) return []
557
+
558
+ const serXml = matches[seriesIndex][1]
559
+ const dLblsMatch = /<c:dLbls>([\s\S]*?)<\/c:dLbls>/.exec(serXml)
560
+ if (!dLblsMatch) return []
561
+
562
+ const dLblsXml = dLblsMatch[1]
563
+ const dLblPattern = /<c:dLbl>([\s\S]*?)<\/c:dLbl>/g
564
+ const result = []
565
+
566
+ let dLblMatch
567
+ while ((dLblMatch = dLblPattern.exec(dLblsXml)) !== null) {
568
+ const dLblXml = dLblMatch[1]
569
+ const idxMatch = /<c:idx val="(\d+)"\/>/.exec(dLblXml)
570
+ if (!idxMatch) continue
571
+ const point = parseInt(idxMatch[1], 10)
572
+
573
+ let value = ''
574
+ const strCacheMatch = /<c:strCache>([\s\S]*?)<\/c:strCache>/.exec(dLblXml)
575
+ if (strCacheMatch) {
576
+ const vMatch = /<c:v>([^<]*)<\/c:v>/.exec(strCacheMatch[1])
577
+ if (vMatch) value = vMatch[1]
578
+ } else {
579
+ const tPattern = /<a:t>([^<]*)<\/a:t>/g
580
+ let tMatch
581
+ while ((tMatch = tPattern.exec(dLblXml)) !== null) {
582
+ value += tMatch[1]
583
+ }
584
+ }
585
+
586
+ result.push({ point, value })
587
+ }
588
+ return result
589
+ }
590
+
165
591
  static #escapeXml(str) {
166
592
  return str
167
593
  .replace(/&/g, '&amp;')