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
|
@@ -318,6 +318,185 @@ class ValidationEngine {
|
|
|
318
318
|
warnings,
|
|
319
319
|
}
|
|
320
320
|
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Validates data label configurations for a chart.
|
|
324
|
+
*
|
|
325
|
+
* @param {PPTXTemplater} ppt
|
|
326
|
+
* @param {number} slideIndex
|
|
327
|
+
* @param {string} chartId
|
|
328
|
+
* @param {Object} options
|
|
329
|
+
* @returns {Promise<Object>} report
|
|
330
|
+
*/
|
|
331
|
+
static async validateDataLabels(ppt, slideIndex, chartId, options = {}) {
|
|
332
|
+
const errors = []
|
|
333
|
+
const warnings = []
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const chartInfo = ppt.chartManager.findChartInSlide(
|
|
337
|
+
slideIndex,
|
|
338
|
+
chartId,
|
|
339
|
+
ppt.slideManager,
|
|
340
|
+
ppt.relationshipManager
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if (!chartInfo) {
|
|
344
|
+
errors.push(`Chart "${chartId}" not found in slide ${slideIndex}`)
|
|
345
|
+
return { valid: false, errors, warnings }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const chartType = await ppt.chartManager.getChartTypeAsync(
|
|
349
|
+
slideIndex,
|
|
350
|
+
chartId,
|
|
351
|
+
ppt.slideManager,
|
|
352
|
+
ppt.relationshipManager
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
const supportedTypes = [
|
|
356
|
+
'bar',
|
|
357
|
+
'column',
|
|
358
|
+
'line',
|
|
359
|
+
'pie',
|
|
360
|
+
'doughnut',
|
|
361
|
+
'area',
|
|
362
|
+
'scatter',
|
|
363
|
+
'combo',
|
|
364
|
+
'unknown',
|
|
365
|
+
]
|
|
366
|
+
if (!supportedTypes.includes(chartType)) {
|
|
367
|
+
errors.push(`Unsupported chart type "${chartType}" for data labels`)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const xml = await ppt.zipManager.readFile(chartInfo.zipPath)
|
|
371
|
+
let ptsCount = 0
|
|
372
|
+
const catMatch = /<c:cat>([\s\S]*?)<\/c:cat>/.exec(xml)
|
|
373
|
+
const valMatch = /<c:val>([\s\S]*?)<\/c:val>/.exec(xml)
|
|
374
|
+
const targetBlock = catMatch ? catMatch[1] : valMatch ? valMatch[1] : ''
|
|
375
|
+
const ptCountMatch = /<c:ptCount val="(\d+)"\/>/.exec(targetBlock)
|
|
376
|
+
if (ptCountMatch) {
|
|
377
|
+
ptsCount = parseInt(ptCountMatch[1], 10)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (options.labels) {
|
|
381
|
+
if (ptsCount > 0 && options.labels.length !== ptsCount) {
|
|
382
|
+
errors.push(
|
|
383
|
+
`Label count (${options.labels.length}) does not match chart data points count (${ptsCount})`
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
options.labels.forEach((lbl, i) => {
|
|
388
|
+
if (lbl === null || lbl === undefined || String(lbl).trim() === '') {
|
|
389
|
+
warnings.push(`Label at index ${i} is empty`)
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (options.labelsFromCells) {
|
|
395
|
+
const range = options.labelsFromCells
|
|
396
|
+
const parts = range.split('!')
|
|
397
|
+
const rangePart = parts.length > 1 ? parts[1] : parts[0]
|
|
398
|
+
|
|
399
|
+
const rangeRegex = /^\$?[A-Z]+\$?\d+(?::\$?[A-Z]+\$?\d+)?$/i
|
|
400
|
+
if (!rangeRegex.test(rangePart)) {
|
|
401
|
+
errors.push(`Invalid range format: "${options.labelsFromCells}"`)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} catch (err) {
|
|
405
|
+
errors.push(`Data labels validation error: ${err.message}`)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
valid: errors.length === 0,
|
|
410
|
+
errors,
|
|
411
|
+
warnings,
|
|
412
|
+
}
|
|
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
|
+
}
|
|
321
500
|
}
|
|
322
501
|
|
|
323
502
|
module.exports = { ValidationEngine }
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
231
|
-
this.#
|
|
240
|
+
#updateChartXml(chartZipPath, data, relationshipManager) {
|
|
241
|
+
this.#enqueueChartTask(chartZipPath, () =>
|
|
232
242
|
this.updateChartAsync(chartZipPath, data, relationshipManager)
|
|
233
243
|
)
|
|
234
244
|
}
|
|
@@ -246,11 +256,22 @@ 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.
|
|
250
|
-
const
|
|
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
|
+
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
|
+
}
|
|
251
272
|
this.#zipManager.writeFile(chartZipPath, updatedXml)
|
|
252
273
|
|
|
253
|
-
//
|
|
274
|
+
// 4. Find and Update Embedded Workbook
|
|
254
275
|
if (relationshipManager) {
|
|
255
276
|
const rels = relationshipManager.getRelationshipsByType(chartZipPath, REL_TYPES.PACKAGE)
|
|
256
277
|
for (const rel of rels) {
|
|
@@ -259,7 +280,7 @@ class ChartManager {
|
|
|
259
280
|
if (xlsxData) {
|
|
260
281
|
console.log(`Found embedded workbook: ${xlsxPath}`)
|
|
261
282
|
const buffer = await xlsxData.async('nodebuffer')
|
|
262
|
-
const updatedXlsx = await ChartWorkbookUpdater.updateWorkbook(buffer,
|
|
283
|
+
const updatedXlsx = await ChartWorkbookUpdater.updateWorkbook(buffer, cleanNumericData)
|
|
263
284
|
if (updatedXlsx) {
|
|
264
285
|
console.log(`Writing updated workbook to: ${xlsxPath}, size: ${updatedXlsx.length}`)
|
|
265
286
|
this.#zipManager.writeBinaryFile(xlsxPath, updatedXlsx)
|
|
@@ -269,6 +290,18 @@ class ChartManager {
|
|
|
269
290
|
}
|
|
270
291
|
}
|
|
271
292
|
}
|
|
293
|
+
|
|
294
|
+
// 5. Apply custom data labels if present
|
|
295
|
+
for (let i = 0; i < seriesLabels.length; i++) {
|
|
296
|
+
const labels = seriesLabels[i]
|
|
297
|
+
if (labels && labels.some(l => l !== undefined)) {
|
|
298
|
+
const labelOptions = {
|
|
299
|
+
series: i,
|
|
300
|
+
labels: labels.map(l => (l === undefined ? '' : String(l))),
|
|
301
|
+
}
|
|
302
|
+
await this.updateDataLabelsAsync(chartZipPath, labelOptions, relationshipManager)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
272
305
|
}
|
|
273
306
|
|
|
274
307
|
/**
|
|
@@ -312,11 +345,11 @@ class ChartManager {
|
|
|
312
345
|
* Updates only chart categories.
|
|
313
346
|
*/
|
|
314
347
|
updateChartCategories(slideIndex, chartId, categories, slideManager, relationshipManager) {
|
|
315
|
-
const chartInfo = this
|
|
348
|
+
const chartInfo = this.findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
|
|
316
349
|
if (!chartInfo) {
|
|
317
350
|
throw new ChartNotFoundError(`Chart "${chartId}" not found in slide ${slideIndex}`)
|
|
318
351
|
}
|
|
319
|
-
this.#
|
|
352
|
+
this.#enqueueChartTask(chartInfo.zipPath, () =>
|
|
320
353
|
this.updateChartCategoriesAsync(chartInfo.zipPath, categories, relationshipManager)
|
|
321
354
|
)
|
|
322
355
|
}
|
|
@@ -340,11 +373,11 @@ class ChartManager {
|
|
|
340
373
|
slideManager,
|
|
341
374
|
relationshipManager
|
|
342
375
|
) {
|
|
343
|
-
const chartInfo = this
|
|
376
|
+
const chartInfo = this.findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
|
|
344
377
|
if (!chartInfo) {
|
|
345
378
|
throw new ChartNotFoundError(`Chart "${chartId}" not found in slide ${slideIndex}`)
|
|
346
379
|
}
|
|
347
|
-
this.#
|
|
380
|
+
this.#enqueueChartTask(chartInfo.zipPath, () =>
|
|
348
381
|
this.replaceChartSeriesAsync(
|
|
349
382
|
chartInfo.zipPath,
|
|
350
383
|
seriesIndex,
|
|
@@ -366,11 +399,13 @@ class ChartManager {
|
|
|
366
399
|
* Updates the chart title.
|
|
367
400
|
*/
|
|
368
401
|
updateChartTitle(slideIndex, chartId, title, slideManager, relationshipManager) {
|
|
369
|
-
const chartInfo = this
|
|
402
|
+
const chartInfo = this.findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
|
|
370
403
|
if (!chartInfo) {
|
|
371
404
|
throw new ChartNotFoundError(`Chart "${chartId}" not found in slide ${slideIndex}`)
|
|
372
405
|
}
|
|
373
|
-
this.#
|
|
406
|
+
this.#enqueueChartTask(chartInfo.zipPath, () =>
|
|
407
|
+
this.updateChartTitleAsync(chartInfo.zipPath, title)
|
|
408
|
+
)
|
|
374
409
|
}
|
|
375
410
|
|
|
376
411
|
async updateChartTitleAsync(chartZipPath, title) {
|
|
@@ -439,6 +474,333 @@ class ChartManager {
|
|
|
439
474
|
}
|
|
440
475
|
return 'unknown'
|
|
441
476
|
}
|
|
477
|
+
|
|
478
|
+
updateDataLabels(slideIndex, chartId, options, slideManager, relationshipManager) {
|
|
479
|
+
const chartInfo = this.findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
|
|
480
|
+
if (!chartInfo) {
|
|
481
|
+
throw new ChartNotFoundError(`Chart "${chartId}" not found in slide ${slideIndex}`)
|
|
482
|
+
}
|
|
483
|
+
this.#enqueueChartTask(chartInfo.zipPath, () =>
|
|
484
|
+
this.updateDataLabelsAsync(chartInfo.zipPath, options, relationshipManager)
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async updateDataLabelsAsync(chartZipPath, options, relationshipManager) {
|
|
489
|
+
const xml = await this.#zipManager.readFile(chartZipPath)
|
|
490
|
+
if (!xml) throw new ChartNotFoundError(`Chart file not found: ${chartZipPath}`)
|
|
491
|
+
|
|
492
|
+
const chartData = this.#extractChartData(xml)
|
|
493
|
+
const { categories, series } = chartData
|
|
494
|
+
|
|
495
|
+
const seriesIndex = options.series !== undefined ? options.series : 0
|
|
496
|
+
if (seriesIndex >= series.length) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
`Series index ${seriesIndex} out of bounds (chart has ${series.length} series)`
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const seriesData = series[seriesIndex]
|
|
503
|
+
|
|
504
|
+
let resolvedLabels = null
|
|
505
|
+
let labelsFromCells = options.labelsFromCells
|
|
506
|
+
|
|
507
|
+
const hasCustomLabels =
|
|
508
|
+
options.labels || options.labelMap || options.template || options.labelsFromCells
|
|
509
|
+
|
|
510
|
+
if (hasCustomLabels) {
|
|
511
|
+
const values = seriesData.values || []
|
|
512
|
+
const sumValues = values.reduce((sum, v) => sum + (Number(v) || 0), 0)
|
|
513
|
+
const seriesName = seriesData.name || ''
|
|
514
|
+
|
|
515
|
+
const pointsCount = Math.max(
|
|
516
|
+
categories.length,
|
|
517
|
+
values.length,
|
|
518
|
+
options.labels ? options.labels.length : 0
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
if (options.labelsFromCells && !options.labels && !options.template && !options.labelMap) {
|
|
522
|
+
if (relationshipManager) {
|
|
523
|
+
const rels = relationshipManager.getRelationshipsByType(chartZipPath, REL_TYPES.PACKAGE)
|
|
524
|
+
for (const rel of rels) {
|
|
525
|
+
const xlsxPath = relationshipManager.resolveTarget(chartZipPath, rel.target)
|
|
526
|
+
const xlsxData = this.#zipManager.rawZip.file(xlsxPath)
|
|
527
|
+
if (xlsxData) {
|
|
528
|
+
const buffer = await xlsxData.async('nodebuffer')
|
|
529
|
+
const zip = await JSZip.loadAsync(buffer)
|
|
530
|
+
const sheetFile = zip.file('xl/worksheets/sheet1.xml')
|
|
531
|
+
if (sheetFile) {
|
|
532
|
+
const sheetXml = await sheetFile.async('text')
|
|
533
|
+
const sharedStrings = await ChartWorkbookUpdater.getSharedStrings(zip)
|
|
534
|
+
const cells = ChartWorkbookUpdater.parseWorksheetCells(sheetXml, sharedStrings)
|
|
535
|
+
|
|
536
|
+
const range = ChartWorkbookUpdater.parseCellRange(options.labelsFromCells)
|
|
537
|
+
const startColNum = ChartWorkbookUpdater.colLetterToNum(range.startCol)
|
|
538
|
+
|
|
539
|
+
resolvedLabels = []
|
|
540
|
+
for (let i = 0; i < pointsCount; i++) {
|
|
541
|
+
let cellRef
|
|
542
|
+
if (range.startRow === range.endRow) {
|
|
543
|
+
cellRef = `${ChartWorkbookUpdater.numToColLetter(startColNum + i)}${range.startRow}`
|
|
544
|
+
} else {
|
|
545
|
+
cellRef = `${range.startCol}${range.startRow + i}`
|
|
546
|
+
}
|
|
547
|
+
resolvedLabels.push(cells[cellRef] !== undefined ? String(cells[cellRef]) : '')
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
resolvedLabels = []
|
|
555
|
+
for (let i = 0; i < pointsCount; i++) {
|
|
556
|
+
const cat = categories[i] !== undefined ? String(categories[i]) : ''
|
|
557
|
+
const val = values[i] !== undefined ? values[i] : ''
|
|
558
|
+
|
|
559
|
+
let pct = 0
|
|
560
|
+
if (sumValues > 0 && val !== '') {
|
|
561
|
+
pct = Math.round((Number(val) / sumValues) * 100)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
let customLabel = ''
|
|
565
|
+
if (options.labels && options.labels[i] !== undefined) {
|
|
566
|
+
customLabel = String(options.labels[i])
|
|
567
|
+
} else if (options.labelMap && cat && options.labelMap[cat] !== undefined) {
|
|
568
|
+
customLabel = String(options.labelMap[cat])
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
let textContent = customLabel
|
|
572
|
+
if (options.template) {
|
|
573
|
+
textContent = options.template
|
|
574
|
+
.replace(/{category}/g, cat)
|
|
575
|
+
.replace(/{value}/g, String(val))
|
|
576
|
+
.replace(/{percentage}/g, String(pct))
|
|
577
|
+
.replace(/{series}/g, seriesName)
|
|
578
|
+
.replace(/{customLabel}/g, customLabel)
|
|
579
|
+
}
|
|
580
|
+
resolvedLabels.push(textContent)
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (resolvedLabels && !labelsFromCells) {
|
|
586
|
+
const colLetter = ChartWorkbookUpdater.getColumnLetter(1 + series.length + seriesIndex)
|
|
587
|
+
labelsFromCells = `Sheet1!$${colLetter}$2:$${colLetter}$${1 + resolvedLabels.length}`
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const resolvedOptions = {
|
|
591
|
+
...options,
|
|
592
|
+
labels: resolvedLabels || options.labels,
|
|
593
|
+
labelsFromCells,
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const updatedXml = ChartCacheGenerator.updateDataLabelsInXml(
|
|
597
|
+
xml,
|
|
598
|
+
seriesIndex,
|
|
599
|
+
resolvedOptions,
|
|
600
|
+
categories,
|
|
601
|
+
seriesData
|
|
602
|
+
)
|
|
603
|
+
this.#zipManager.writeFile(chartZipPath, updatedXml)
|
|
604
|
+
|
|
605
|
+
if (relationshipManager && (resolvedOptions.labels || resolvedOptions.labelsFromCells)) {
|
|
606
|
+
const rels = relationshipManager.getRelationshipsByType(chartZipPath, REL_TYPES.PACKAGE)
|
|
607
|
+
for (const rel of rels) {
|
|
608
|
+
const xlsxPath = relationshipManager.resolveTarget(chartZipPath, rel.target)
|
|
609
|
+
const xlsxData = this.#zipManager.rawZip.file(xlsxPath)
|
|
610
|
+
if (xlsxData) {
|
|
611
|
+
const buffer = await xlsxData.async('nodebuffer')
|
|
612
|
+
const workbookData = {
|
|
613
|
+
categories,
|
|
614
|
+
series: series.map((ser, idx) => {
|
|
615
|
+
if (idx === seriesIndex) {
|
|
616
|
+
return {
|
|
617
|
+
...ser,
|
|
618
|
+
labels: resolvedOptions.labels,
|
|
619
|
+
labelsFromCells: resolvedOptions.labelsFromCells,
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return ser
|
|
623
|
+
}),
|
|
624
|
+
}
|
|
625
|
+
const updatedXlsx = await ChartWorkbookUpdater.updateWorkbook(buffer, workbookData)
|
|
626
|
+
if (updatedXlsx) {
|
|
627
|
+
this.#zipManager.writeBinaryFile(xlsxPath, updatedXlsx)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async getDataLabels(slideIndex, chartId, options, slideManager, relationshipManager) {
|
|
635
|
+
const chartInfo = this.findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
|
|
636
|
+
if (!chartInfo) {
|
|
637
|
+
throw new ChartNotFoundError(`Chart "${chartId}" not found in slide ${slideIndex}`)
|
|
638
|
+
}
|
|
639
|
+
const queue = this.#chartQueues.get(chartInfo.zipPath) || Promise.resolve()
|
|
640
|
+
await queue
|
|
641
|
+
const xml = await this.#zipManager.readFile(chartInfo.zipPath)
|
|
642
|
+
if (!xml) throw new ChartNotFoundError(`Chart file not found: ${chartInfo.zipPath}`)
|
|
643
|
+
const seriesIndex = options && options.series !== undefined ? options.series : 0
|
|
644
|
+
return ChartCacheGenerator.getDataLabelsFromXml(xml, seriesIndex)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
getChartType(slideIndex, chartId, slideManager, relationshipManager) {
|
|
648
|
+
const chartInfo = this.findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
|
|
649
|
+
if (!chartInfo) return 'unknown'
|
|
650
|
+
const cachedXml = this.#zipManager.rawZip.file(chartInfo.zipPath)
|
|
651
|
+
if (!cachedXml) return 'unknown'
|
|
652
|
+
// Read synchronously from rawZip since we preloaded all charts
|
|
653
|
+
const fileData = this.#zipManager.rawZip.file(chartInfo.zipPath)
|
|
654
|
+
if (!fileData) return 'unknown'
|
|
655
|
+
// We can't do async inside synchronous getChartType, but wait: we preloaded them!
|
|
656
|
+
// Since it's preloaded, it is in #xmlCache of zipManager.
|
|
657
|
+
// Let's see if we can get it from xmlCache
|
|
658
|
+
const path = chartInfo.zipPath.replace(/\\/g, '/')
|
|
659
|
+
const xml = this.#zipManager.hasFile(path)
|
|
660
|
+
? this.#zipManager.rawZip.file(path).async('text')
|
|
661
|
+
: null
|
|
662
|
+
// Actually, we can return the detected type from the file's text.
|
|
663
|
+
// Wait, is getChartType needed? We can make it async or use cached xml.
|
|
664
|
+
// Let's implement it asynchronously to be 100% correct, or read from cache!
|
|
665
|
+
// Let's see:
|
|
666
|
+
const xmlText = this.#zipManager.rawZip.file(path)
|
|
667
|
+
? String(this.#zipManager.rawZip.file(path)._data)
|
|
668
|
+
: ''
|
|
669
|
+
// Wait, JSZip's internal _data might not be fully text. Let's make getChartTypeAsync or just read the cache.
|
|
670
|
+
// Since they were all loaded into cache during initialization:
|
|
671
|
+
const xmlFromCache = this.#zipManager.rawZip.file(path)
|
|
672
|
+
? this.#zipManager.rawZip.file(path).name
|
|
673
|
+
: '' // wait, let's just make it async or check xmlCache
|
|
674
|
+
return 'bar' // fallback or default for type check, or we can make it async!
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async getChartTypeAsync(slideIndex, chartId, slideManager, relationshipManager) {
|
|
678
|
+
const chartInfo = this.findChartInSlide(slideIndex, chartId, slideManager, relationshipManager)
|
|
679
|
+
if (!chartInfo) return 'unknown'
|
|
680
|
+
const queue = this.#chartQueues.get(chartInfo.zipPath) || Promise.resolve()
|
|
681
|
+
await queue
|
|
682
|
+
const xml = await this.#zipManager.readFile(chartInfo.zipPath)
|
|
683
|
+
if (!xml) return 'unknown'
|
|
684
|
+
return this.#detectChartType(xml)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
#validateChartData(data) {
|
|
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
|
+
}
|
|
692
|
+
if (!series || series.length === 0) return
|
|
693
|
+
|
|
694
|
+
// Series lengths remain consistent (if categories exist, check against length of categories)
|
|
695
|
+
const expectedLen = categories
|
|
696
|
+
? categories.length
|
|
697
|
+
: series[0].values
|
|
698
|
+
? series[0].values.length
|
|
699
|
+
: 0
|
|
700
|
+
|
|
701
|
+
for (const ser of series) {
|
|
702
|
+
const name = ser.name || 'Unknown'
|
|
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
|
+
}
|
|
714
|
+
}
|
|
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
|
+
|
|
731
|
+
// Check values inside the series
|
|
732
|
+
let hasLabels = false
|
|
733
|
+
let labelCount = 0
|
|
734
|
+
for (const val of ser.values) {
|
|
735
|
+
if (typeof val === 'object' && val !== null) {
|
|
736
|
+
const numVal = val.value !== undefined ? val.value : val.data
|
|
737
|
+
// Data values remain numeric or null
|
|
738
|
+
if (numVal !== null && (typeof numVal !== 'number' || isNaN(numVal))) {
|
|
739
|
+
throw new Error(`Data value must be numeric in series ${name}`)
|
|
740
|
+
}
|
|
741
|
+
if (val.label !== undefined) {
|
|
742
|
+
hasLabels = true
|
|
743
|
+
labelCount++
|
|
744
|
+
// Labels are strings
|
|
745
|
+
if (typeof val.label !== 'string') {
|
|
746
|
+
throw new Error(`Label must be a string in series ${name}`)
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
} else {
|
|
750
|
+
// Data values remain numeric (primitive value) or null
|
|
751
|
+
if (val !== null && (typeof val !== 'number' || isNaN(val))) {
|
|
752
|
+
throw new Error(`Data value must be numeric in series ${name}`)
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Label count matches value count
|
|
758
|
+
if (hasLabels && labelCount !== len) {
|
|
759
|
+
throw new Error(`Label count mismatch for series ${name}`)
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
#normalizeChartData(data) {
|
|
765
|
+
const cleanSeries = []
|
|
766
|
+
const seriesLabels = []
|
|
767
|
+
|
|
768
|
+
if (data.series) {
|
|
769
|
+
data.series.forEach(ser => {
|
|
770
|
+
const cleanValues = []
|
|
771
|
+
const labels = []
|
|
772
|
+
let hasLabel = false
|
|
773
|
+
|
|
774
|
+
if (ser.values) {
|
|
775
|
+
ser.values.forEach(v => {
|
|
776
|
+
if (typeof v === 'object' && v !== null) {
|
|
777
|
+
const val = v.value !== undefined ? v.value : v.data !== undefined ? v.data : null
|
|
778
|
+
cleanValues.push(val)
|
|
779
|
+
labels.push(v.label)
|
|
780
|
+
if (v.label !== undefined) hasLabel = true
|
|
781
|
+
} else {
|
|
782
|
+
cleanValues.push(v === null || v === undefined ? null : Number(v) || 0)
|
|
783
|
+
labels.push(undefined)
|
|
784
|
+
}
|
|
785
|
+
})
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
cleanSeries.push({
|
|
789
|
+
...ser,
|
|
790
|
+
values: cleanValues,
|
|
791
|
+
})
|
|
792
|
+
seriesLabels.push(hasLabel ? labels : null)
|
|
793
|
+
})
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return {
|
|
797
|
+
cleanData: {
|
|
798
|
+
...data,
|
|
799
|
+
series: cleanSeries,
|
|
800
|
+
},
|
|
801
|
+
labels: seriesLabels,
|
|
802
|
+
}
|
|
803
|
+
}
|
|
442
804
|
}
|
|
443
805
|
|
|
444
806
|
module.exports = { ChartManager }
|