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.
@@ -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, '&amp;')
@@ -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, trying to find first sheet')
24
- // fallback to finding the first sheet
23
+ logger.warn('sheet1.xml not found in embedded workbook')
24
+ return workbookData
25
25
  }
26
26
 
27
- const newSheetXml = this.#generateSheetXml(data)
28
- zip.file(sheetPath, newSheetXml)
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
- // Clean up any existing Excel tables, as our new sheet data might not align with them
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 #generateSheetXml(data) {
60
- const { categories = [], series = [] } = data
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
- // Column count = 1 (categories) + series.length
63
- const numCols = 1 + series.length
64
- const numRows = 1 + categories.length // Row 1 = headers
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
- const lastColLetter = this.getColumnLetter(numCols - 1)
67
- const dimensionRef = `A1:${lastColLetter}${numRows}`
96
+ const tMatch = /t="([^"]*)"/.exec(attrs)
97
+ const t = tMatch ? tMatch[1] : null
68
98
 
69
- let sheetData = '<sheetData>'
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
- // Row 1: Headers (empty cell A1, then series names)
72
- sheetData += '<row r="1">'
73
- sheetData += `<c r="A1" t="inlineStr"><is><t></t></is></c>`
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
- sheetData += `<c r="${colLetter}1" t="inlineStr"><is><t>${this.#escapeXml(ser.name || '')}</t></is></c>`
132
+ cells[`${colLetter}1`] = ser.name || ''
77
133
  })
78
- sheetData += '</row>'
79
134
 
80
- // Rows 2..N: Data (category name in A, then values)
135
+ // 3. Write categories in column A (A2, A3, etc.)
81
136
  categories.forEach((cat, rowIndex) => {
82
- const r = rowIndex + 2 // +1 for 1-based, +1 for header row
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
- series.forEach((ser, colIndex) => {
87
- const colLetter = this.getColumnLetter(colIndex + 1)
88
- const val = ser.values && ser.values[rowIndex] !== undefined ? ser.values[rowIndex] : 0
89
- sheetData += `<c r="${colLetter}${r}"><v>${Number(val)}</v></c>`
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
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
97
- <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
98
- <dimension ref="${dimensionRef}"/>
99
- <sheetViews><sheetView workbookViewId="0"/></sheetViews>
100
- <sheetFormatPr defaultRowHeight="15"/>
101
- ${sheetData}
102
- </worksheet>`
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)
@@ -124,6 +124,38 @@ function attachZOrder(normalContainer, containerId, containerMap) {
124
124
  }
125
125
  }
126
126
 
127
+ /**
128
+ * Unescapes XML entities safely and non-recursively.
129
+ * Supports standard XML entities and numeric decimal/hex code points.
130
+ *
131
+ * @param {string} str - XML value to unescape.
132
+ * @returns {string} Unescaped string.
133
+ */
134
+ function unescapeXml(str) {
135
+ if (typeof str !== 'string') return str
136
+ if (str.indexOf('&') === -1) return str
137
+ return str
138
+ .replace(/&amp;/g, '&')
139
+ .replace(/&lt;/g, '<')
140
+ .replace(/&gt;/g, '>')
141
+ .replace(/&quot;/g, '"')
142
+ .replace(/&apos;/g, "'")
143
+ .replace(/&#(\d+);/g, (match, dec) => {
144
+ try {
145
+ return String.fromCodePoint(parseInt(dec, 10))
146
+ } catch (e) {
147
+ return match
148
+ }
149
+ })
150
+ .replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {
151
+ try {
152
+ return String.fromCodePoint(parseInt(hex, 16))
153
+ } catch (e) {
154
+ return match
155
+ }
156
+ })
157
+ }
158
+
127
159
  /**
128
160
  * Parser configuration for fast-xml-parser.
129
161
  * These settings ensure lossless round-trip XML parsing.
@@ -139,7 +171,9 @@ const PARSER_OPTIONS = {
139
171
  commentPropName: '__comment',
140
172
  preserveOrder: false,
141
173
  trimValues: false,
142
- processEntities: true,
174
+ processEntities: false,
175
+ tagValueProcessor: (tagName, val) => unescapeXml(val),
176
+ attributeValueProcessor: (attrName, val) => unescapeXml(val),
143
177
  htmlEntities: false,
144
178
  isArray: (name, jpath) => {
145
179
  // Elements that should ALWAYS be arrays (even when there's only one)
@@ -217,6 +251,9 @@ class XMLParser {
217
251
  attributeNamePrefix: '@_',
218
252
  parseAttributeValue: false,
219
253
  parseTagValue: false,
254
+ processEntities: false,
255
+ tagValueProcessor: (tagName, val) => unescapeXml(val),
256
+ attributeValueProcessor: (attrName, val) => unescapeXml(val),
220
257
  })
221
258
  }
222
259