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.
@@ -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,180 @@ class ChartWorkbookUpdater {
56
64
  }
57
65
  }
58
66
 
59
- static #generateSheetXml(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
+ }
86
+
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] || ''
95
+
96
+ const tMatch = /t="([^"]*)"/.exec(attrs)
97
+ const t = tMatch ? tMatch[1] : null
98
+
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) {
60
124
  const { categories = [], series = [] } = data
61
125
 
62
- // Column count = 1 (categories) + series.length
63
- const numCols = 1 + series.length
64
- const numRows = 1 + categories.length // Row 1 = headers
126
+ // Clear cells that are outside the new category/series grid
127
+ const maxRow = categories.length + 1
128
+ const maxCol = series.length // Column A is 0, Column B is 1, etc.
65
129
 
66
- const lastColLetter = this.getColumnLetter(numCols - 1)
67
- const dimensionRef = `A1:${lastColLetter}${numRows}`
130
+ for (const ref of Object.keys(cells)) {
131
+ const match = /^([A-Z]+)(\d+)$/.exec(ref)
132
+ if (match) {
133
+ const colLetter = match[1]
134
+ const row = parseInt(match[2], 10)
135
+ const col = this.colLetterToNum(colLetter)
136
+ if (row > maxRow || col > maxCol) {
137
+ delete cells[ref]
138
+ }
139
+ }
140
+ }
68
141
 
69
- let sheetData = '<sheetData>'
142
+ // 1. Write Header A1 as empty
143
+ cells['A1'] = ''
70
144
 
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>`
145
+ // 2. Write series titles in Row 1 (B1, C1, etc.)
74
146
  series.forEach((ser, i) => {
75
147
  const colLetter = this.getColumnLetter(i + 1)
76
- sheetData += `<c r="${colLetter}1" t="inlineStr"><is><t>${this.#escapeXml(ser.name || '')}</t></is></c>`
148
+ cells[`${colLetter}1`] = ser.name || ''
77
149
  })
78
- sheetData += '</row>'
79
150
 
80
- // Rows 2..N: Data (category name in A, then values)
151
+ // 3. Write categories in column A (A2, A3, etc.)
81
152
  categories.forEach((cat, rowIndex) => {
82
- const r = rowIndex + 2 // +1 for 1-based, +1 for header row
153
+ cells[`A${rowIndex + 2}`] = String(cat)
154
+ })
155
+
156
+ // 4. Write series values
157
+ series.forEach((ser, colIndex) => {
158
+ const colLetter = this.getColumnLetter(colIndex + 1)
159
+ if (ser.values) {
160
+ ser.values.forEach((val, rowIndex) => {
161
+ cells[`${colLetter}${rowIndex + 2}`] = val !== undefined ? val : null
162
+ })
163
+ }
164
+ })
165
+
166
+ // 5. Write custom data labels
167
+ series.forEach(ser => {
168
+ if (ser.labels && ser.labelsFromCells) {
169
+ const range = this.parseCellRange(ser.labelsFromCells)
170
+ const startColNum = this.colLetterToNum(range.startCol)
171
+ ser.labels.forEach((lbl, i) => {
172
+ let cellRef
173
+ if (range.startRow === range.endRow) {
174
+ cellRef = `${this.numToColLetter(startColNum + i)}${range.startRow}`
175
+ } else {
176
+ cellRef = `${range.startCol}${range.startRow + i}`
177
+ }
178
+ cells[cellRef] = lbl
179
+ })
180
+ }
181
+ })
182
+ }
183
+
184
+ static #serializeSheetXml(sheetXml, cells) {
185
+ // Group cells by row
186
+ const rows = {}
187
+ for (const [ref, val] of Object.entries(cells)) {
188
+ const rowMatch = /\d+$/.exec(ref)
189
+ if (!rowMatch) continue
190
+ const r = parseInt(rowMatch[0], 10)
191
+ if (!rows[r]) rows[r] = []
192
+ rows[r].push(ref)
193
+ }
194
+
195
+ // Sort rows ascending
196
+ const sortedRowKeys = Object.keys(rows)
197
+ .map(Number)
198
+ .sort((a, b) => a - b)
199
+
200
+ let maxRow = 1
201
+ let maxColNum = 0
202
+
203
+ let sheetData = '<sheetData>'
204
+ for (const r of sortedRowKeys) {
205
+ if (r > maxRow) maxRow = r
83
206
  sheetData += `<row r="${r}">`
84
- sheetData += `<c r="A${r}" t="inlineStr"><is><t>${this.#escapeXml(String(cat))}</t></is></c>`
85
207
 
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>`
208
+ // Sort cells in this row by column letters
209
+ const sortedRefs = rows[r].sort((a, b) => {
210
+ const colA = /^[A-Z]+/.exec(a)[0]
211
+ const colB = /^[A-Z]+/.exec(b)[0]
212
+ if (colA.length !== colB.length) return colA.length - colB.length
213
+ return colA.localeCompare(colB)
90
214
  })
91
- sheetData += '</row>'
92
- })
93
215
 
216
+ for (const ref of sortedRefs) {
217
+ const colLetter = /^[A-Z]+/.exec(ref)[0]
218
+ const colNum = this.colLetterToNum(colLetter)
219
+ if (colNum > maxColNum) maxColNum = colNum
220
+
221
+ const val = cells[ref]
222
+ if (val === null || val === undefined) {
223
+ sheetData += `<c r="${ref}" t="inlineStr"><is><t></t></is></c>`
224
+ } else if (typeof val === 'number') {
225
+ sheetData += `<c r="${ref}"><v>${val}</v></c>`
226
+ } else {
227
+ sheetData += `<c r="${ref}" t="inlineStr"><is><t>${this.#escapeXml(String(val))}</t></is></c>`
228
+ }
229
+ }
230
+ sheetData += '</row>'
231
+ }
94
232
  sheetData += '</sheetData>'
95
233
 
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>`
234
+ const maxColLetter = this.numToColLetter(maxColNum)
235
+ const newDimension = `<dimension ref="A1:${maxColLetter}${maxRow}"/>`
236
+
237
+ let updatedXml = sheetXml.replace(/<sheetData>[\s\S]*?<\/sheetData>/, sheetData)
238
+ updatedXml = updatedXml.replace(/<dimension ref="[^"]*"\/>/, newDimension)
239
+
240
+ return updatedXml
103
241
  }
104
242
 
105
243
  static getColumnLetter(colIndex) {
@@ -111,6 +249,39 @@ class ChartWorkbookUpdater {
111
249
  return letter
112
250
  }
113
251
 
252
+ static colLetterToNum(letter) {
253
+ let num = 0
254
+ for (let i = 0; i < letter.length; i++) {
255
+ num = num * 26 + (letter.charCodeAt(i) - 64)
256
+ }
257
+ return num - 1
258
+ }
259
+
260
+ static numToColLetter(num) {
261
+ return this.getColumnLetter(num)
262
+ }
263
+
264
+ static parseCellRange(rangeStr) {
265
+ const parts = rangeStr.split('!')
266
+ const range = parts.length > 1 ? parts[1] : parts[0]
267
+ const cleanRange = range.replace(/\$/g, '')
268
+ const [start, end] = cleanRange.split(':')
269
+
270
+ const startCol = /^[A-Z]+/.exec(start)[0]
271
+ const startRow = parseInt(/\d+$/.exec(start)[0], 10)
272
+
273
+ const endCol = end ? /^[A-Z]+/.exec(end)[0] : startCol
274
+ const endRow = end ? parseInt(/\d+$/.exec(end)[0], 10) : startRow
275
+
276
+ return {
277
+ sheetName: parts.length > 1 ? parts[0] : 'Sheet1',
278
+ startCol,
279
+ startRow,
280
+ endCol,
281
+ endRow,
282
+ }
283
+ }
284
+
114
285
  static getFormulaRange(sheetName, startRow, startCol, endRow, endCol) {
115
286
  const startLetter = this.getColumnLetter(startCol)
116
287
  const endLetter = this.getColumnLetter(endCol)