node-pptx-templater 1.0.6 → 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.
- package/README.md +133 -2
- package/package.json +1 -1
- package/src/core/PPTXTemplater.js +52 -1
- package/src/core/ValidationEngine.js +93 -0
- package/src/managers/ChartManager.js +352 -21
- package/src/managers/charts/ChartCacheGenerator.js +371 -0
- package/src/managers/charts/ChartWorkbookUpdater.js +187 -34
|
@@ -162,6 +162,377 @@ class ChartCacheGenerator {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
static updateDataLabelsInXml(xml, seriesIndex, options, categories = [], seriesData = {}) {
|
|
166
|
+
let serIndex = 0
|
|
167
|
+
const serPattern = /(<c:ser>)([\s\S]*?)(<\/c:ser>)/g
|
|
168
|
+
|
|
169
|
+
return xml.replace(serPattern, (match, open, content, close) => {
|
|
170
|
+
if (serIndex !== seriesIndex) {
|
|
171
|
+
serIndex++
|
|
172
|
+
return match
|
|
173
|
+
}
|
|
174
|
+
serIndex++
|
|
175
|
+
|
|
176
|
+
let pointsCount = categories.length
|
|
177
|
+
if (pointsCount === 0) {
|
|
178
|
+
const valMatch = /<c:val>([\s\S]*?)<\/c:val>/.exec(content)
|
|
179
|
+
if (valMatch) {
|
|
180
|
+
const countMatch = /<c:ptCount val="(\d+)"\/>/.exec(valMatch[1])
|
|
181
|
+
if (countMatch) pointsCount = parseInt(countMatch[1], 10)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (pointsCount === 0 && options.labels) {
|
|
185
|
+
pointsCount = options.labels.length
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Parse existing styling and flags from the current <c:dLbls> block
|
|
189
|
+
let existingTxPr = ''
|
|
190
|
+
let existingDLblPos = ''
|
|
191
|
+
let existingNumFmt = ''
|
|
192
|
+
const existingShowTags = {}
|
|
193
|
+
|
|
194
|
+
const dLblsMatch = /<c:dLbls>([\s\S]*?)<\/c:dLbls>/.exec(content)
|
|
195
|
+
if (dLblsMatch) {
|
|
196
|
+
const dLblsContent = dLblsMatch[1]
|
|
197
|
+
const txPrMatch = /(<c:txPr>[\s\S]*?<\/c:txPr>)/.exec(dLblsContent)
|
|
198
|
+
if (txPrMatch) {
|
|
199
|
+
existingTxPr = txPrMatch[1]
|
|
200
|
+
}
|
|
201
|
+
const dLblPosMatch = /(<c:dLblPos\s+[^>]*\/>)/.exec(dLblsContent)
|
|
202
|
+
if (dLblPosMatch) {
|
|
203
|
+
existingDLblPos = dLblPosMatch[1]
|
|
204
|
+
}
|
|
205
|
+
const numFmtMatch = /(<c:numFmt\s+[^>]*\/>)/.exec(dLblsContent)
|
|
206
|
+
if (numFmtMatch) {
|
|
207
|
+
existingNumFmt = numFmtMatch[1]
|
|
208
|
+
}
|
|
209
|
+
const showTagsList = [
|
|
210
|
+
'showLegendKey',
|
|
211
|
+
'showVal',
|
|
212
|
+
'showCatName',
|
|
213
|
+
'showSerName',
|
|
214
|
+
'showPercent',
|
|
215
|
+
'showBubbleSize',
|
|
216
|
+
]
|
|
217
|
+
showTagsList.forEach(tag => {
|
|
218
|
+
const tagPattern = new RegExp(`(<c:${tag}\\s+val="([^"]*)"\\s*\\/>)`)
|
|
219
|
+
const tagMatch = tagPattern.exec(dLblsContent)
|
|
220
|
+
if (tagMatch) {
|
|
221
|
+
existingShowTags[tag] = tagMatch[1]
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const dLblsXml = this.generateDLblsXml(
|
|
227
|
+
pointsCount,
|
|
228
|
+
options,
|
|
229
|
+
categories,
|
|
230
|
+
seriesData,
|
|
231
|
+
existingTxPr,
|
|
232
|
+
existingDLblPos,
|
|
233
|
+
existingNumFmt,
|
|
234
|
+
existingShowTags
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
let updatedContent = content
|
|
238
|
+
const dLblsPattern = /(<c:dLbls>[\s\S]*?<\/c:dLbls>)/
|
|
239
|
+
if (dLblsPattern.test(updatedContent)) {
|
|
240
|
+
updatedContent = updatedContent.replace(dLblsPattern, dLblsXml)
|
|
241
|
+
} else {
|
|
242
|
+
const insertBefore = /(<c:cat>|<c:val>|<c:extLst>)/
|
|
243
|
+
if (insertBefore.test(updatedContent)) {
|
|
244
|
+
updatedContent = updatedContent.replace(insertBefore, `${dLblsXml}$1`)
|
|
245
|
+
} else {
|
|
246
|
+
updatedContent += dLblsXml
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return `${open}${updatedContent}${close}`
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
static generateDLblsXml(
|
|
255
|
+
pointsCount,
|
|
256
|
+
options,
|
|
257
|
+
categories = [],
|
|
258
|
+
seriesData = {},
|
|
259
|
+
existingTxPr = '',
|
|
260
|
+
existingDLblPos = '',
|
|
261
|
+
existingNumFmt = '',
|
|
262
|
+
existingShowTags = {}
|
|
263
|
+
) {
|
|
264
|
+
const { labels, labelsFromCells, template, position, labelStyle, labelMap } = options
|
|
265
|
+
|
|
266
|
+
let xml = '<c:dLbls>'
|
|
267
|
+
|
|
268
|
+
const posMap = {
|
|
269
|
+
center: 'ctr',
|
|
270
|
+
insideEnd: 'inEnd',
|
|
271
|
+
insideBase: 'inBase',
|
|
272
|
+
outsideEnd: 'outEnd',
|
|
273
|
+
bestFit: 'bestFit',
|
|
274
|
+
left: 'l',
|
|
275
|
+
right: 'r',
|
|
276
|
+
top: 't',
|
|
277
|
+
bottom: 'b',
|
|
278
|
+
}
|
|
279
|
+
const openxmlPos = position ? posMap[position] : null
|
|
280
|
+
|
|
281
|
+
const values = seriesData.values || []
|
|
282
|
+
const sumValues = values.reduce((sum, v) => sum + (Number(v) || 0), 0)
|
|
283
|
+
const seriesName = seriesData.name || ''
|
|
284
|
+
|
|
285
|
+
const hasCustomLabels = labels || labelsFromCells || template || labelMap
|
|
286
|
+
|
|
287
|
+
if (hasCustomLabels && pointsCount > 0) {
|
|
288
|
+
for (let i = 0; i < pointsCount; i++) {
|
|
289
|
+
const cat = categories[i] !== undefined ? String(categories[i]) : ''
|
|
290
|
+
const val = values[i] !== undefined ? values[i] : ''
|
|
291
|
+
|
|
292
|
+
let pct = 0
|
|
293
|
+
if (sumValues > 0 && val !== '') {
|
|
294
|
+
pct = Math.round((Number(val) / sumValues) * 100)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let customLabel = ''
|
|
298
|
+
if (labels && labels[i] !== undefined) {
|
|
299
|
+
customLabel = String(labels[i])
|
|
300
|
+
} else if (labelMap && cat && labelMap[cat] !== undefined) {
|
|
301
|
+
customLabel = String(labelMap[cat])
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let textContent = customLabel
|
|
305
|
+
if (template) {
|
|
306
|
+
textContent = template
|
|
307
|
+
.replace(/{category}/g, cat)
|
|
308
|
+
.replace(/{value}/g, String(val))
|
|
309
|
+
.replace(/{percentage}/g, String(pct))
|
|
310
|
+
.replace(/{series}/g, seriesName)
|
|
311
|
+
.replace(/{customLabel}/g, customLabel)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
xml += `<c:dLbl>`
|
|
315
|
+
xml += `<c:idx val="${i}"/>`
|
|
316
|
+
|
|
317
|
+
if (labelsFromCells && !template) {
|
|
318
|
+
const range = ChartWorkbookUpdater.parseCellRange(labelsFromCells)
|
|
319
|
+
const startColNum = ChartWorkbookUpdater.colLetterToNum(range.startCol)
|
|
320
|
+
|
|
321
|
+
let cellRef
|
|
322
|
+
if (range.startRow === range.endRow) {
|
|
323
|
+
cellRef = `${ChartWorkbookUpdater.numToColLetter(startColNum + i)}${range.startRow}`
|
|
324
|
+
} else {
|
|
325
|
+
cellRef = `${range.startCol}${range.startRow + i}`
|
|
326
|
+
}
|
|
327
|
+
const fullCellRef = `${range.sheetName}!$${cellRef.replace(/(\d+)/, '$$$1')}`
|
|
328
|
+
const displayVal = textContent || customLabel || ''
|
|
329
|
+
|
|
330
|
+
xml += `<c:tx>`
|
|
331
|
+
xml += `<c:strRef>`
|
|
332
|
+
xml += `<c:f>${fullCellRef}</c:f>`
|
|
333
|
+
xml += `<c:strCache>`
|
|
334
|
+
xml += `<c:ptCount val="1"/>`
|
|
335
|
+
xml += `<c:pt idx="0"><c:v>${this.#escapeXml(displayVal)}</c:v></c:pt>`
|
|
336
|
+
xml += `</c:strCache>`
|
|
337
|
+
xml += `</c:strRef>`
|
|
338
|
+
xml += `</c:tx>`
|
|
339
|
+
} else if (textContent) {
|
|
340
|
+
if (labelsFromCells) {
|
|
341
|
+
const range = ChartWorkbookUpdater.parseCellRange(labelsFromCells)
|
|
342
|
+
const startColNum = ChartWorkbookUpdater.colLetterToNum(range.startCol)
|
|
343
|
+
let cellRef
|
|
344
|
+
if (range.startRow === range.endRow) {
|
|
345
|
+
cellRef = `${ChartWorkbookUpdater.numToColLetter(startColNum + i)}${range.startRow}`
|
|
346
|
+
} else {
|
|
347
|
+
cellRef = `${range.startCol}${range.startRow + i}`
|
|
348
|
+
}
|
|
349
|
+
const fullCellRef = `${range.sheetName}!$${cellRef.replace(/(\d+)/, '$$$1')}`
|
|
350
|
+
|
|
351
|
+
xml += `<c:tx>`
|
|
352
|
+
xml += `<c:strRef>`
|
|
353
|
+
xml += `<c:f>${fullCellRef}</c:f>`
|
|
354
|
+
xml += `<c:strCache>`
|
|
355
|
+
xml += `<c:ptCount val="1"/>`
|
|
356
|
+
xml += `<c:pt idx="0"><c:v>${this.#escapeXml(textContent)}</c:v></c:pt>`
|
|
357
|
+
xml += `</c:strCache>`
|
|
358
|
+
xml += `</c:strRef>`
|
|
359
|
+
xml += `</c:tx>`
|
|
360
|
+
} else {
|
|
361
|
+
xml += `<c:tx>`
|
|
362
|
+
xml += `<c:rich>`
|
|
363
|
+
xml += `<a:bodyPr/>`
|
|
364
|
+
xml += `<a:lstStyle/>`
|
|
365
|
+
xml += `<a:p>`
|
|
366
|
+
xml += `<a:r>`
|
|
367
|
+
xml += `<a:t>${this.#escapeXml(textContent)}</a:t>`
|
|
368
|
+
xml += `</a:r>`
|
|
369
|
+
xml += `</a:p>`
|
|
370
|
+
xml += `</c:rich>`
|
|
371
|
+
xml += `</c:tx>`
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (labelStyle) {
|
|
376
|
+
xml += this.generateTxPrXml(labelStyle)
|
|
377
|
+
} else if (existingTxPr) {
|
|
378
|
+
xml += existingTxPr
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (openxmlPos) {
|
|
382
|
+
xml += `<c:dLblPos val="${openxmlPos}"/>`
|
|
383
|
+
} else if (existingDLblPos) {
|
|
384
|
+
xml += existingDLblPos
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
xml += `<c:showLegendKey val="0"/>`
|
|
388
|
+
xml += `<c:showVal val="0"/>`
|
|
389
|
+
xml += `<c:showCatName val="0"/>`
|
|
390
|
+
xml += `<c:showSerName val="0"/>`
|
|
391
|
+
xml += `<c:showPercent val="0"/>`
|
|
392
|
+
xml += `<c:showBubbleSize val="0"/>`
|
|
393
|
+
|
|
394
|
+
xml += `</c:dLbl>`
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (existingNumFmt) {
|
|
399
|
+
xml += existingNumFmt
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (labelStyle) {
|
|
403
|
+
xml += this.generateTxPrXml(labelStyle)
|
|
404
|
+
} else if (existingTxPr) {
|
|
405
|
+
xml += existingTxPr
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (openxmlPos) {
|
|
409
|
+
xml += `<c:dLblPos val="${openxmlPos}"/>`
|
|
410
|
+
} else if (existingDLblPos) {
|
|
411
|
+
xml += existingDLblPos
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// showLegendKey
|
|
415
|
+
if (existingShowTags['showLegendKey']) {
|
|
416
|
+
xml += existingShowTags['showLegendKey']
|
|
417
|
+
} else {
|
|
418
|
+
xml += `<c:showLegendKey val="0"/>`
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// showVal
|
|
422
|
+
const defaultShowVal = hasCustomLabels ? '0' : '1'
|
|
423
|
+
if (existingShowTags['showVal'] && !hasCustomLabels) {
|
|
424
|
+
xml += existingShowTags['showVal']
|
|
425
|
+
} else {
|
|
426
|
+
xml += `<c:showVal val="${defaultShowVal}"/>`
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// showCatName
|
|
430
|
+
if (existingShowTags['showCatName']) {
|
|
431
|
+
xml += existingShowTags['showCatName']
|
|
432
|
+
} else {
|
|
433
|
+
xml += `<c:showCatName val="0"/>`
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// showSerName
|
|
437
|
+
if (existingShowTags['showSerName']) {
|
|
438
|
+
xml += existingShowTags['showSerName']
|
|
439
|
+
} else {
|
|
440
|
+
xml += `<c:showSerName val="0"/>`
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// showPercent
|
|
444
|
+
const defaultShowPercent = hasCustomLabels || !options.showPercent ? '0' : '1'
|
|
445
|
+
if (options.showPercent !== undefined) {
|
|
446
|
+
xml += `<c:showPercent val="${options.showPercent ? '1' : '0'}"/>`
|
|
447
|
+
} else if (existingShowTags['showPercent'] && !hasCustomLabels) {
|
|
448
|
+
xml += existingShowTags['showPercent']
|
|
449
|
+
} else {
|
|
450
|
+
xml += `<c:showPercent val="${defaultShowPercent}"/>`
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// showBubbleSize
|
|
454
|
+
if (existingShowTags['showBubbleSize']) {
|
|
455
|
+
xml += existingShowTags['showBubbleSize']
|
|
456
|
+
} else {
|
|
457
|
+
xml += `<c:showBubbleSize val="0"/>`
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
xml += '</c:dLbls>'
|
|
461
|
+
return xml
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
static generateTxPrXml(style) {
|
|
465
|
+
const { fontFamily, fontSize, bold, italic, underline, color } = style
|
|
466
|
+
|
|
467
|
+
const sz = fontSize ? ` sz="${fontSize * 100}"` : ''
|
|
468
|
+
const b = bold !== undefined ? ` b="${bold ? '1' : '0'}"` : ''
|
|
469
|
+
const i = italic !== undefined ? ` i="${italic ? '1' : '0'}"` : ''
|
|
470
|
+
const u = underline !== undefined ? ` u="${underline ? 'sng' : 'none'}"` : ''
|
|
471
|
+
|
|
472
|
+
let fillXml = ''
|
|
473
|
+
if (color) {
|
|
474
|
+
const cleanColor = color.replace('#', '')
|
|
475
|
+
fillXml = `<a:solidFill><a:srgbClr val="${cleanColor}"/></a:solidFill>`
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let latinXml = ''
|
|
479
|
+
if (fontFamily) {
|
|
480
|
+
latinXml = `<a:latin typeface="${fontFamily}"/><a:cs typeface="${fontFamily}"/>`
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return `<c:txPr>
|
|
484
|
+
<a:bodyPr/>
|
|
485
|
+
<a:lstStyle/>
|
|
486
|
+
<a:p>
|
|
487
|
+
<a:pPr>
|
|
488
|
+
<a:defRPr${sz}${b}${i}${u}>
|
|
489
|
+
${fillXml}
|
|
490
|
+
${latinXml}
|
|
491
|
+
</a:defRPr>
|
|
492
|
+
</a:pPr>
|
|
493
|
+
<a:endParaRPr lang="en-US"/>
|
|
494
|
+
</a:p>
|
|
495
|
+
</c:txPr>`
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
static getDataLabelsFromXml(xml, seriesIndex) {
|
|
499
|
+
const serPattern = /<c:ser>([\s\S]*?)<\/c:ser>/g
|
|
500
|
+
const matches = [...xml.matchAll(serPattern)]
|
|
501
|
+
if (seriesIndex >= matches.length) return []
|
|
502
|
+
|
|
503
|
+
const serXml = matches[seriesIndex][1]
|
|
504
|
+
const dLblsMatch = /<c:dLbls>([\s\S]*?)<\/c:dLbls>/.exec(serXml)
|
|
505
|
+
if (!dLblsMatch) return []
|
|
506
|
+
|
|
507
|
+
const dLblsXml = dLblsMatch[1]
|
|
508
|
+
const dLblPattern = /<c:dLbl>([\s\S]*?)<\/c:dLbl>/g
|
|
509
|
+
const result = []
|
|
510
|
+
|
|
511
|
+
let dLblMatch
|
|
512
|
+
while ((dLblMatch = dLblPattern.exec(dLblsXml)) !== null) {
|
|
513
|
+
const dLblXml = dLblMatch[1]
|
|
514
|
+
const idxMatch = /<c:idx val="(\d+)"\/>/.exec(dLblXml)
|
|
515
|
+
if (!idxMatch) continue
|
|
516
|
+
const point = parseInt(idxMatch[1], 10)
|
|
517
|
+
|
|
518
|
+
let value = ''
|
|
519
|
+
const strCacheMatch = /<c:strCache>([\s\S]*?)<\/c:strCache>/.exec(dLblXml)
|
|
520
|
+
if (strCacheMatch) {
|
|
521
|
+
const vMatch = /<c:v>([^<]*)<\/c:v>/.exec(strCacheMatch[1])
|
|
522
|
+
if (vMatch) value = vMatch[1]
|
|
523
|
+
} else {
|
|
524
|
+
const tPattern = /<a:t>([^<]*)<\/a:t>/g
|
|
525
|
+
let tMatch
|
|
526
|
+
while ((tMatch = tPattern.exec(dLblXml)) !== null) {
|
|
527
|
+
value += tMatch[1]
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
result.push({ point, value })
|
|
532
|
+
}
|
|
533
|
+
return result
|
|
534
|
+
}
|
|
535
|
+
|
|
165
536
|
static #escapeXml(str) {
|
|
166
537
|
return str
|
|
167
538
|
.replace(/&/g, '&')
|
|
@@ -20,14 +20,22 @@ class ChartWorkbookUpdater {
|
|
|
20
20
|
// Look for sheet1.xml
|
|
21
21
|
const sheetPath = 'xl/worksheets/sheet1.xml'
|
|
22
22
|
if (!zip.file(sheetPath)) {
|
|
23
|
-
logger.warn('sheet1.xml not found in embedded workbook
|
|
24
|
-
|
|
23
|
+
logger.warn('sheet1.xml not found in embedded workbook')
|
|
24
|
+
return workbookData
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
const
|
|
28
|
-
|
|
27
|
+
const sheetXml = await zip.file(sheetPath).async('text')
|
|
28
|
+
const sharedStrings = await this.getSharedStrings(zip)
|
|
29
|
+
const cells = this.parseWorksheetCells(sheetXml, sharedStrings)
|
|
29
30
|
|
|
30
|
-
//
|
|
31
|
+
// Update categories, series values, and custom labels in the cell grid
|
|
32
|
+
this.#updateCellGrid(cells, data)
|
|
33
|
+
|
|
34
|
+
// Serialize cells to sheet XML
|
|
35
|
+
const updatedSheetXml = this.#serializeSheetXml(sheetXml, cells)
|
|
36
|
+
zip.file(sheetPath, updatedSheetXml)
|
|
37
|
+
|
|
38
|
+
// Clean up any existing Excel tables
|
|
31
39
|
const tableFiles = Object.keys(zip.files).filter(f => f.startsWith('xl/tables/'))
|
|
32
40
|
tableFiles.forEach(f => zip.remove(f))
|
|
33
41
|
|
|
@@ -56,50 +64,162 @@ class ChartWorkbookUpdater {
|
|
|
56
64
|
}
|
|
57
65
|
}
|
|
58
66
|
|
|
59
|
-
static
|
|
60
|
-
const
|
|
67
|
+
static async getSharedStrings(zip) {
|
|
68
|
+
const sstFile = zip.file('xl/sharedStrings.xml')
|
|
69
|
+
if (!sstFile) return []
|
|
70
|
+
const xml = await sstFile.async('text')
|
|
71
|
+
const strings = []
|
|
72
|
+
const pattern = /<si>([\s\S]*?)<\/si>/g
|
|
73
|
+
let match
|
|
74
|
+
while ((match = pattern.exec(xml)) !== null) {
|
|
75
|
+
const siContent = match[1]
|
|
76
|
+
const tPattern = /<t\b[^>]*>([^<]*)<\/t>/g
|
|
77
|
+
let tMatch
|
|
78
|
+
let textVal = ''
|
|
79
|
+
while ((tMatch = tPattern.exec(siContent)) !== null) {
|
|
80
|
+
textVal += tMatch[1]
|
|
81
|
+
}
|
|
82
|
+
strings.push(textVal)
|
|
83
|
+
}
|
|
84
|
+
return strings
|
|
85
|
+
}
|
|
61
86
|
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
const
|
|
87
|
+
static parseWorksheetCells(sheetXml, sharedStrings) {
|
|
88
|
+
const cells = {}
|
|
89
|
+
const cellPattern = /<c r="([A-Z]+\d+)"([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g
|
|
90
|
+
let match
|
|
91
|
+
while ((match = cellPattern.exec(sheetXml)) !== null) {
|
|
92
|
+
const ref = match[1]
|
|
93
|
+
const attrs = match[2]
|
|
94
|
+
const content = match[3] || ''
|
|
65
95
|
|
|
66
|
-
|
|
67
|
-
|
|
96
|
+
const tMatch = /t="([^"]*)"/.exec(attrs)
|
|
97
|
+
const t = tMatch ? tMatch[1] : null
|
|
68
98
|
|
|
69
|
-
|
|
99
|
+
let val = ''
|
|
100
|
+
if (t === 'inlineStr') {
|
|
101
|
+
const tValMatch = /<t\b[^>]*>([^<]*)<\/t>/.exec(content)
|
|
102
|
+
val = tValMatch ? tValMatch[1] : ''
|
|
103
|
+
} else if (t === 's') {
|
|
104
|
+
const vMatch = /<v>(\d+)<\/v>/.exec(content)
|
|
105
|
+
if (vMatch) {
|
|
106
|
+
const idx = parseInt(vMatch[1], 10)
|
|
107
|
+
val = sharedStrings[idx] !== undefined ? sharedStrings[idx] : ''
|
|
108
|
+
}
|
|
109
|
+
} else if (content) {
|
|
110
|
+
const vMatch = /<v>([^<]*)<\/v>/.exec(content)
|
|
111
|
+
if (vMatch) {
|
|
112
|
+
val = vMatch[1]
|
|
113
|
+
if (val !== '' && !isNaN(val)) {
|
|
114
|
+
val = Number(val)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
cells[ref] = val
|
|
119
|
+
}
|
|
120
|
+
return cells
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
static #updateCellGrid(cells, data) {
|
|
124
|
+
const { categories = [], series = [] } = data
|
|
70
125
|
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
126
|
+
// 1. Write Header A1 as empty
|
|
127
|
+
cells['A1'] = ''
|
|
128
|
+
|
|
129
|
+
// 2. Write series titles in Row 1 (B1, C1, etc.)
|
|
74
130
|
series.forEach((ser, i) => {
|
|
75
131
|
const colLetter = this.getColumnLetter(i + 1)
|
|
76
|
-
|
|
132
|
+
cells[`${colLetter}1`] = ser.name || ''
|
|
77
133
|
})
|
|
78
|
-
sheetData += '</row>'
|
|
79
134
|
|
|
80
|
-
//
|
|
135
|
+
// 3. Write categories in column A (A2, A3, etc.)
|
|
81
136
|
categories.forEach((cat, rowIndex) => {
|
|
82
|
-
|
|
137
|
+
cells[`A${rowIndex + 2}`] = String(cat)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// 4. Write series values
|
|
141
|
+
series.forEach((ser, colIndex) => {
|
|
142
|
+
const colLetter = this.getColumnLetter(colIndex + 1)
|
|
143
|
+
if (ser.values) {
|
|
144
|
+
ser.values.forEach((val, rowIndex) => {
|
|
145
|
+
cells[`${colLetter}${rowIndex + 2}`] = val !== undefined ? val : 0
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// 5. Write custom data labels
|
|
151
|
+
series.forEach(ser => {
|
|
152
|
+
if (ser.labels && ser.labelsFromCells) {
|
|
153
|
+
const range = this.parseCellRange(ser.labelsFromCells)
|
|
154
|
+
const startColNum = this.colLetterToNum(range.startCol)
|
|
155
|
+
ser.labels.forEach((lbl, i) => {
|
|
156
|
+
let cellRef
|
|
157
|
+
if (range.startRow === range.endRow) {
|
|
158
|
+
cellRef = `${this.numToColLetter(startColNum + i)}${range.startRow}`
|
|
159
|
+
} else {
|
|
160
|
+
cellRef = `${range.startCol}${range.startRow + i}`
|
|
161
|
+
}
|
|
162
|
+
cells[cellRef] = lbl
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
static #serializeSheetXml(sheetXml, cells) {
|
|
169
|
+
// Group cells by row
|
|
170
|
+
const rows = {}
|
|
171
|
+
for (const [ref, val] of Object.entries(cells)) {
|
|
172
|
+
const rowMatch = /\d+$/.exec(ref)
|
|
173
|
+
if (!rowMatch) continue
|
|
174
|
+
const r = parseInt(rowMatch[0], 10)
|
|
175
|
+
if (!rows[r]) rows[r] = []
|
|
176
|
+
rows[r].push(ref)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Sort rows ascending
|
|
180
|
+
const sortedRowKeys = Object.keys(rows)
|
|
181
|
+
.map(Number)
|
|
182
|
+
.sort((a, b) => a - b)
|
|
183
|
+
|
|
184
|
+
let maxRow = 1
|
|
185
|
+
let maxColNum = 0
|
|
186
|
+
|
|
187
|
+
let sheetData = '<sheetData>'
|
|
188
|
+
for (const r of sortedRowKeys) {
|
|
189
|
+
if (r > maxRow) maxRow = r
|
|
83
190
|
sheetData += `<row r="${r}">`
|
|
84
|
-
sheetData += `<c r="A${r}" t="inlineStr"><is><t>${this.#escapeXml(String(cat))}</t></is></c>`
|
|
85
191
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
192
|
+
// Sort cells in this row by column letters
|
|
193
|
+
const sortedRefs = rows[r].sort((a, b) => {
|
|
194
|
+
const colA = /^[A-Z]+/.exec(a)[0]
|
|
195
|
+
const colB = /^[A-Z]+/.exec(b)[0]
|
|
196
|
+
if (colA.length !== colB.length) return colA.length - colB.length
|
|
197
|
+
return colA.localeCompare(colB)
|
|
90
198
|
})
|
|
91
|
-
sheetData += '</row>'
|
|
92
|
-
})
|
|
93
199
|
|
|
200
|
+
for (const ref of sortedRefs) {
|
|
201
|
+
const colLetter = /^[A-Z]+/.exec(ref)[0]
|
|
202
|
+
const colNum = this.colLetterToNum(colLetter)
|
|
203
|
+
if (colNum > maxColNum) maxColNum = colNum
|
|
204
|
+
|
|
205
|
+
const val = cells[ref]
|
|
206
|
+
if (typeof val === 'number') {
|
|
207
|
+
sheetData += `<c r="${ref}"><v>${val}</v></c>`
|
|
208
|
+
} else {
|
|
209
|
+
sheetData += `<c r="${ref}" t="inlineStr"><is><t>${this.#escapeXml(String(val))}</t></is></c>`
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
sheetData += '</row>'
|
|
213
|
+
}
|
|
94
214
|
sheetData += '</sheetData>'
|
|
95
215
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
216
|
+
const maxColLetter = this.numToColLetter(maxColNum)
|
|
217
|
+
const newDimension = `<dimension ref="A1:${maxColLetter}${maxRow}"/>`
|
|
218
|
+
|
|
219
|
+
let updatedXml = sheetXml.replace(/<sheetData>[\s\S]*?<\/sheetData>/, sheetData)
|
|
220
|
+
updatedXml = updatedXml.replace(/<dimension ref="[^"]*"\/>/, newDimension)
|
|
221
|
+
|
|
222
|
+
return updatedXml
|
|
103
223
|
}
|
|
104
224
|
|
|
105
225
|
static getColumnLetter(colIndex) {
|
|
@@ -111,6 +231,39 @@ class ChartWorkbookUpdater {
|
|
|
111
231
|
return letter
|
|
112
232
|
}
|
|
113
233
|
|
|
234
|
+
static colLetterToNum(letter) {
|
|
235
|
+
let num = 0
|
|
236
|
+
for (let i = 0; i < letter.length; i++) {
|
|
237
|
+
num = num * 26 + (letter.charCodeAt(i) - 64)
|
|
238
|
+
}
|
|
239
|
+
return num - 1
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
static numToColLetter(num) {
|
|
243
|
+
return this.getColumnLetter(num)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
static parseCellRange(rangeStr) {
|
|
247
|
+
const parts = rangeStr.split('!')
|
|
248
|
+
const range = parts.length > 1 ? parts[1] : parts[0]
|
|
249
|
+
const cleanRange = range.replace(/\$/g, '')
|
|
250
|
+
const [start, end] = cleanRange.split(':')
|
|
251
|
+
|
|
252
|
+
const startCol = /^[A-Z]+/.exec(start)[0]
|
|
253
|
+
const startRow = parseInt(/\d+$/.exec(start)[0], 10)
|
|
254
|
+
|
|
255
|
+
const endCol = end ? /^[A-Z]+/.exec(end)[0] : startCol
|
|
256
|
+
const endRow = end ? parseInt(/\d+$/.exec(end)[0], 10) : startRow
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
sheetName: parts.length > 1 ? parts[0] : 'Sheet1',
|
|
260
|
+
startCol,
|
|
261
|
+
startRow,
|
|
262
|
+
endCol,
|
|
263
|
+
endRow,
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
114
267
|
static getFormulaRange(sheetName, startRow, startCol, endRow, endCol) {
|
|
115
268
|
const startLetter = this.getColumnLetter(startCol)
|
|
116
269
|
const endLetter = this.getColumnLetter(endCol)
|