node-pptx-templater 1.0.1 → 1.0.3

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.
Files changed (37) hide show
  1. package/README.md +336 -281
  2. package/package.json +6 -6
  3. package/src/cli/commands/build.js +32 -31
  4. package/src/cli/commands/debug.js +25 -24
  5. package/src/cli/commands/extract.js +23 -21
  6. package/src/cli/commands/inspect.js +25 -23
  7. package/src/cli/commands/validate.js +19 -17
  8. package/src/cli/index.js +45 -43
  9. package/src/core/OutputWriter.js +81 -78
  10. package/src/core/PPTXTemplater.js +859 -274
  11. package/src/core/TemplateEngine.js +69 -71
  12. package/src/core/ValidationEngine.js +246 -0
  13. package/src/index.js +51 -15
  14. package/src/managers/ChartManager.js +197 -70
  15. package/src/managers/ContentTypesManager.js +51 -45
  16. package/src/managers/HyperlinkManager.js +148 -142
  17. package/src/managers/ImageManager.js +336 -0
  18. package/src/managers/MediaManager.js +64 -81
  19. package/src/managers/RelationshipManager.js +102 -96
  20. package/src/managers/ShapeManager.js +340 -0
  21. package/src/managers/SlideManager.js +410 -311
  22. package/src/managers/TableManager.js +981 -262
  23. package/src/managers/TextManager.js +197 -0
  24. package/src/managers/ZipManager.js +71 -69
  25. package/src/managers/charts/ChartCacheGenerator.js +77 -58
  26. package/src/managers/charts/ChartParser.js +11 -13
  27. package/src/managers/charts/ChartRelationshipManager.js +14 -10
  28. package/src/managers/charts/ChartWorkbookUpdater.js +61 -56
  29. package/src/parsers/XMLParser.js +50 -49
  30. package/src/templates/blankPptx.js +3 -1
  31. package/src/templates/slideTemplate.js +31 -32
  32. package/src/utils/contentTypesHelper.js +41 -53
  33. package/src/utils/errors.js +33 -23
  34. package/src/utils/idUtils.js +23 -15
  35. package/src/utils/logger.js +21 -15
  36. package/src/utils/relationshipUtils.js +28 -22
  37. package/src/utils/xmlUtils.js +37 -29
@@ -30,42 +30,29 @@
30
30
  * </a:graphic>
31
31
  * </p:graphicFrame>
32
32
  *
33
- * EMU (English Metric Units):
34
- * - 1 inch = 914400 EMU
35
- * - 1 pt = 12700 EMU
36
- * - 1 cm = 360000 EMU
37
- *
38
33
  * Key challenge: Preserving cell formatting while replacing text.
39
34
  * We ONLY modify the <a:t> text nodes, keeping all <a:tcPr>, <a:rPr>, etc.
40
- *
41
- * Merged cells use:
42
- * - <a:tc gridSpan="2"> → horizontal merge (spans 2 columns)
43
- * - <a:tc rowSpan="2"> → vertical merge (spans 2 rows)
44
- * - <a:tc hMerge="1"> → continuation of horizontal merge
45
- * - <a:tc vMerge="1"> → continuation of vertical merge
35
+ * And critical to avoid PPT corruption: update the val of <a16:rowId> in each cloned row's <a:extLst>.
46
36
  */
47
37
 
48
- import { createLogger } from '../utils/logger.js';
49
- import { TableNotFoundError } from '../utils/errors.js';
38
+ const { createLogger } = require('../utils/logger.js')
39
+ const { TableNotFoundError, PPTXError } = require('../utils/errors.js')
50
40
 
51
- const logger = createLogger('TableManager');
41
+ const logger = createLogger('TableManager')
52
42
 
53
43
  /**
54
44
  * @class TableManager
55
- * @description Handles table data replacement in PPTX slides.
56
- *
57
- * The key design principle is "preserve formatting, replace content".
58
- * We never touch table styles, borders, or fonts — only the text.
45
+ * @description Handles table operations in PPTX templates.
59
46
  */
60
- export class TableManager {
47
+ class TableManager {
61
48
  /** @private @type {XMLParser} */
62
- #xmlParser;
49
+ #xmlParser
63
50
 
64
51
  /**
65
52
  * @param {XMLParser} xmlParser
66
53
  */
67
54
  constructor(xmlParser) {
68
- this.#xmlParser = xmlParser;
55
+ this.#xmlParser = xmlParser
69
56
  }
70
57
 
71
58
  /**
@@ -74,343 +61,1075 @@ export class TableManager {
74
61
  * Preserves all formatting properties.
75
62
  *
76
63
  * @param {number} slideIndex - 1-based slide index.
77
- * @param {string} tableId - Table name (from shape's cNvPr name attribute).
64
+ * @param {string} tableId - Table name or shape ID.
78
65
  * @param {string[][]} rows - 2D array of cell values [row][col].
79
66
  * @param {SlideManager} slideManager
80
67
  * @throws {TableNotFoundError} If the table is not found.
81
68
  */
82
- updateTable(slideIndex, tableId, rows, slideManager) {
83
- const slideXml = slideManager.getSlideXml(slideIndex);
84
- const updatedXml = this.#updateTableInXml(slideXml, tableId, rows);
69
+ updateTable(slideIndex, tableId, data, slideManager) {
70
+ const slideXml = slideManager.getSlideXml(slideIndex)
71
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
72
+ const tblObj = this.#findTableObj(slideObj, tableId)
73
+ if (!tblObj) {
74
+ throw new TableNotFoundError(`Table "${tableId}" not found in slide ${slideIndex}`)
75
+ }
85
76
 
86
- if (updatedXml === null) {
87
- throw new TableNotFoundError(`Table "${tableId}" not found in slide ${slideIndex}`);
77
+ const trs = tblObj['a:tr'] || []
78
+ if (trs.length === 0) {
79
+ logger.warn('No rows found in table XML template')
80
+ return
88
81
  }
89
82
 
90
- slideManager.setSlideXml(slideIndex, updatedXml);
91
- logger.debug(`Updated table "${tableId}" with ${rows.length} rows`);
83
+ let rowsData = []
84
+ let templateMerges = []
85
+
86
+ if (Array.isArray(data)) {
87
+ rowsData = data
88
+ } else if (data && typeof data === 'object') {
89
+ rowsData = data.rows || []
90
+ templateMerges = data.merge || []
91
+ }
92
+
93
+ const headerTemplate = trs[0]
94
+ const dataTemplate = trs[1] || trs[0]
95
+
96
+ const newRows = []
97
+ const generatedMerges = []
98
+
99
+ for (let i = 0; i < rowsData.length; i++) {
100
+ const template = i === 0 ? headerTemplate : trs[i] || dataTemplate
101
+ const newRow = this.#xmlParser.deepClone(template)
102
+ this.#updateRowId(newRow)
103
+
104
+ const tcs = newRow['a:tc'] || []
105
+ const rowData = rowsData[i]
106
+
107
+ for (let j = 0; j < tcs.length; j++) {
108
+ const rawCell = rowData && rowData[j] !== undefined ? rowData[j] : ''
109
+ let val = ''
110
+ let cellOptions = {}
111
+
112
+ if (tcs[j]['@_hMerge']) delete tcs[j]['@_hMerge']
113
+ if (tcs[j]['@_vMerge']) delete tcs[j]['@_vMerge']
114
+ if (tcs[j]['@_gridSpan']) delete tcs[j]['@_gridSpan']
115
+ if (tcs[j]['@_rowSpan']) delete tcs[j]['@_rowSpan']
116
+
117
+ if (rawCell && typeof rawCell === 'object') {
118
+ val = rawCell.value !== undefined ? rawCell.value : ''
119
+ const rowSpan = parseInt(rawCell.rowSpan || 1, 10)
120
+ const colSpan = parseInt(rawCell.colSpan || rawCell.gridSpan || 1, 10)
121
+ if (rowSpan > 1 || colSpan > 1) {
122
+ generatedMerges.push({
123
+ startRow: i,
124
+ startCol: j,
125
+ endRow: i + rowSpan - 1,
126
+ endCol: j + colSpan - 1,
127
+ })
128
+ }
129
+ cellOptions = rawCell
130
+ } else {
131
+ val = String(rawCell)
132
+ }
133
+
134
+ this.#setCellTextObj(tcs[j], val)
135
+
136
+ // Apply style properties if specified on the cell object
137
+ if (cellOptions.fill) {
138
+ if (!tcs[j]['a:tcPr']) tcs[j]['a:tcPr'] = {}
139
+ tcs[j]['a:tcPr']['a:solidFill'] = {
140
+ 'a:srgbClr': { '@_val': cellOptions.fill },
141
+ }
142
+ }
143
+
144
+ if (cellOptions.align) {
145
+ const txBody = tcs[j]['a:txBody']
146
+ if (txBody && txBody['a:p']) {
147
+ const paras = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
148
+ for (const p of paras) {
149
+ if (!p['a:pPr']) p['a:pPr'] = {}
150
+ p['a:pPr']['@_algn'] = cellOptions.align
151
+ }
152
+ }
153
+ }
154
+
155
+ if (cellOptions.fontSize) {
156
+ const sizeVal = cellOptions.fontSize * 100
157
+ const txBody = tcs[j]['a:txBody']
158
+ if (txBody && txBody['a:p']) {
159
+ const paras = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
160
+ for (const p of paras) {
161
+ if (p['a:r']) {
162
+ const runs = Array.isArray(p['a:r']) ? p['a:r'] : [p['a:r']]
163
+ for (const r of runs) {
164
+ if (!r['a:rPr']) r['a:rPr'] = {}
165
+ r['a:rPr']['@_sz'] = String(sizeVal)
166
+ }
167
+ }
168
+ }
169
+ }
170
+ }
171
+ }
172
+ newRows.push(newRow)
173
+ }
174
+
175
+ tblObj['a:tr'] = newRows
176
+
177
+ const decl = this.#xmlParser.extractDeclaration(slideXml)
178
+ slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
179
+
180
+ const finalMerges = [...templateMerges, ...generatedMerges]
181
+ for (const merge of finalMerges) {
182
+ this.mergeCells(
183
+ slideIndex,
184
+ tableId,
185
+ merge.startRow,
186
+ merge.startCol,
187
+ merge.endRow,
188
+ merge.endCol,
189
+ slideManager
190
+ )
191
+ }
192
+
193
+ logger.debug(
194
+ `Updated table "${tableId}" with ${rowsData.length} rows and ${finalMerges.length} merges`
195
+ )
92
196
  }
93
197
 
94
198
  /**
95
- * Updates a table within raw XML.
96
- * Returns null if the table was not found.
199
+ * Adds a row at the end of the table.
97
200
  *
98
- * @private
99
- * @param {string} slideXml - Slide XML content.
100
- * @param {string} tableId - Table name to find.
101
- * @param {string[][]} rows - New row data.
102
- * @returns {string|null} Updated XML or null if not found.
201
+ * @param {number} slideIndex
202
+ * @param {string} tableId
203
+ * @param {string[]} rowData
204
+ * @param {SlideManager} slideManager
103
205
  */
104
- #updateTableInXml(slideXml, tableId, rows) {
105
- // Step 1: Find the graphicFrame containing our table
106
- // We look for the cNvPr name attribute matching tableId
107
- const framePattern = new RegExp(
108
- `(<p:graphicFrame>(?:(?!<\\/p:graphicFrame>)[\\s\\S])*?<p:cNvPr[^>]*name="${this.#escapeRegex(tableId)}"[^>]*>[\\s\\S]*?<\\/p:graphicFrame>)`,
109
- 'g'
110
- );
111
-
112
- // Alternative: more robust approach — find graphicFrames with table data
113
- let found = false;
114
- let updatedXml = slideXml;
115
-
116
- // Strategy 1: Find by name attribute
117
- const namePattern = new RegExp(`name="${this.#escapeRegex(tableId)}"`, 'g');
118
- const nameMatch = namePattern.exec(slideXml);
119
-
120
- if (nameMatch) {
121
- // Find the graphicFrame containing this name
122
- const frameStart = slideXml.lastIndexOf('<p:graphicFrame>', nameMatch.index);
123
- const frameEnd = this.#findClosingTag(slideXml, '</p:graphicFrame>', nameMatch.index);
124
-
125
- if (frameStart !== -1 && frameEnd !== -1) {
126
- const frameXml = slideXml.substring(frameStart, frameEnd + '</p:graphicFrame>'.length);
127
- const updatedFrame = this.#updateTableRows(frameXml, rows);
128
-
129
- updatedXml = slideXml.substring(0, frameStart) + updatedFrame +
130
- slideXml.substring(frameEnd + '</p:graphicFrame>'.length);
131
- found = true;
132
- }
133
- }
134
-
135
- // Strategy 2: Find by shape ID (tableId is numeric)
136
- if (!found && /^\d+$/.test(tableId)) {
137
- const idPattern = new RegExp(`id="${tableId}"`, 'g');
138
- const idMatch = idPattern.exec(slideXml);
139
- if (idMatch) {
140
- const frameStart = slideXml.lastIndexOf('<p:graphicFrame>', idMatch.index);
141
- const frameEnd = this.#findClosingTag(slideXml, '</p:graphicFrame>', idMatch.index);
142
-
143
- if (frameStart !== -1 && frameEnd !== -1) {
144
- const frameXml = slideXml.substring(frameStart, frameEnd + '</p:graphicFrame>'.length);
145
- const updatedFrame = this.#updateTableRows(frameXml, rows);
146
- updatedXml = slideXml.substring(0, frameStart) + updatedFrame +
147
- slideXml.substring(frameEnd + '</p:graphicFrame>'.length);
148
- found = true;
149
- }
150
- }
206
+ addTableRow(slideIndex, tableId, rowData, slideManager) {
207
+ const slideXml = slideManager.getSlideXml(slideIndex)
208
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
209
+ const tblObj = this.#findTableObj(slideObj, tableId)
210
+ if (!tblObj) {
211
+ throw new TableNotFoundError(`Table "${tableId}" not found in slide ${slideIndex}`)
212
+ }
213
+
214
+ const trs = tblObj['a:tr'] || []
215
+ if (trs.length === 0) {
216
+ throw new PPTXError('No rows to clone from')
151
217
  }
152
218
 
153
- // Strategy 3: Find first table in slide
154
- if (!found && tableId === 'first') {
155
- const tableStart = slideXml.indexOf('<a:tbl>');
156
- const frameStart = slideXml.lastIndexOf('<p:graphicFrame>', tableStart);
157
- const frameEnd = this.#findClosingTag(slideXml, '</p:graphicFrame>', tableStart);
219
+ const lastRow = trs[trs.length - 1]
220
+ const newRow = this.#xmlParser.deepClone(lastRow)
221
+ this.#updateRowId(newRow)
158
222
 
159
- if (frameStart !== -1 && frameEnd !== -1) {
160
- const frameXml = slideXml.substring(frameStart, frameEnd + '</p:graphicFrame>'.length);
161
- const updatedFrame = this.#updateTableRows(frameXml, rows);
162
- updatedXml = slideXml.substring(0, frameStart) + updatedFrame +
163
- slideXml.substring(frameEnd + '</p:graphicFrame>'.length);
164
- found = true;
165
- }
223
+ const tcs = newRow['a:tc'] || []
224
+ for (let j = 0; j < tcs.length; j++) {
225
+ this.#setCellTextObj(tcs[j], rowData[j] !== undefined ? rowData[j] : '')
226
+ // Clear any merged indicators for the new row by default
227
+ if (tcs[j]['@_hMerge']) delete tcs[j]['@_hMerge']
228
+ if (tcs[j]['@_vMerge']) delete tcs[j]['@_vMerge']
166
229
  }
167
230
 
168
- return found ? updatedXml : null;
231
+ trs.push(newRow)
232
+
233
+ const decl = this.#xmlParser.extractDeclaration(slideXml)
234
+ slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
169
235
  }
170
236
 
171
237
  /**
172
- * Replaces the table rows within a graphicFrame XML snippet.
173
- * Preserves the first row's styling as a template for new rows.
238
+ * Removes a table row by index.
174
239
  *
175
- * @private
176
- * @param {string} frameXml - XML of the graphicFrame containing the table.
177
- * @param {string[][]} rows - New data rows.
178
- * @returns {string} Updated frame XML.
240
+ * @param {number} slideIndex
241
+ * @param {string} tableId
242
+ * @param {number} rowIndex - 0-based row index.
243
+ * @param {SlideManager} slideManager
179
244
  */
180
- #updateTableRows(frameXml, rows) {
181
- // Extract all existing rows
182
- const existingRows = this.#extractAllRows(frameXml);
245
+ removeTableRow(slideIndex, tableId, rowIndex, slideManager) {
246
+ const slideXml = slideManager.getSlideXml(slideIndex)
247
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
248
+ const tblObj = this.#findTableObj(slideObj, tableId)
249
+ if (!tblObj) {
250
+ throw new TableNotFoundError(`Table "${tableId}" not found in slide ${slideIndex}`)
251
+ }
183
252
 
184
- if (existingRows.length === 0) {
185
- logger.warn('No rows found in table');
186
- return frameXml;
253
+ const trs = tblObj['a:tr'] || []
254
+ if (rowIndex < 0 || rowIndex >= trs.length) {
255
+ throw new PPTXError(`Row index ${rowIndex} out of bounds`)
187
256
  }
188
257
 
189
- // Use first row as header template, second as data row template (if available)
190
- const headerTemplate = existingRows[0];
191
- const dataTemplate = existingRows[1] || existingRows[0];
258
+ trs.splice(rowIndex, 1)
192
259
 
193
- // Build new rows
194
- const newRowsXml = rows.map((rowData, rowIdx) => {
195
- const template = rowIdx === 0 ? headerTemplate : dataTemplate;
196
- return this.#buildRow(template, rowData);
197
- }).join('');
260
+ const decl = this.#xmlParser.extractDeclaration(slideXml)
261
+ slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
262
+ }
198
263
 
199
- // Replace all existing rows with new rows
200
- const tblStart = frameXml.indexOf('<a:tbl>');
201
- const tblEnd = frameXml.lastIndexOf('</a:tbl>') + '</a:tbl>'.length;
202
- const tblXml = frameXml.substring(tblStart, tblEnd);
264
+ /**
265
+ * Inserts a table row at a specific index.
266
+ *
267
+ * @param {number} slideIndex
268
+ * @param {string} tableId
269
+ * @param {number} rowIndex
270
+ * @param {string[]} rowData
271
+ * @param {SlideManager} slideManager
272
+ */
273
+ insertTableRow(slideIndex, tableId, rowIndex, rowData, slideManager) {
274
+ const slideXml = slideManager.getSlideXml(slideIndex)
275
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
276
+ const tblObj = this.#findTableObj(slideObj, tableId)
277
+ if (!tblObj) {
278
+ throw new TableNotFoundError(`Table "${tableId}" not found in slide ${slideIndex}`)
279
+ }
280
+
281
+ const trs = tblObj['a:tr'] || []
282
+ if (rowIndex < 0 || rowIndex > trs.length) {
283
+ throw new PPTXError(`Row index ${rowIndex} out of bounds`)
284
+ }
203
285
 
204
- // Find where rows begin (after tblGrid) and end (before </a:tbl>)
205
- const firstRowStart = tblXml.indexOf('<a:tr ') !== -1
206
- ? tblXml.indexOf('<a:tr ')
207
- : tblXml.indexOf('<a:tr>');
208
- const lastRowEnd = tblXml.lastIndexOf('</a:tr>') + '</a:tr>'.length;
286
+ // Use row above or first row as template
287
+ const templateIndex = Math.max(0, rowIndex - 1)
288
+ const template = trs[templateIndex] || trs[0]
289
+ if (!template) {
290
+ throw new PPTXError('No rows to insert and copy from')
291
+ }
292
+
293
+ const newRow = this.#xmlParser.deepClone(template)
294
+ this.#updateRowId(newRow)
209
295
 
210
- if (firstRowStart === -1 || lastRowEnd === -1) {
211
- return frameXml;
296
+ const tcs = newRow['a:tc'] || []
297
+ for (let j = 0; j < tcs.length; j++) {
298
+ this.#setCellTextObj(tcs[j], rowData[j] !== undefined ? rowData[j] : '')
299
+ if (tcs[j]['@_hMerge']) delete tcs[j]['@_hMerge']
300
+ if (tcs[j]['@_vMerge']) delete tcs[j]['@_vMerge']
212
301
  }
213
302
 
214
- const newTblXml =
215
- tblXml.substring(0, firstRowStart) +
216
- newRowsXml +
217
- tblXml.substring(lastRowEnd);
303
+ trs.splice(rowIndex, 0, newRow)
218
304
 
219
- return frameXml.substring(0, tblStart) + newTblXml + frameXml.substring(tblEnd);
305
+ const decl = this.#xmlParser.extractDeclaration(slideXml)
306
+ slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
220
307
  }
221
308
 
222
309
  /**
223
- * Extracts all <a:tr> row XML strings from a table.
224
- * @private
225
- * @param {string} tableXml
226
- * @returns {string[]}
310
+ * Clones a row and inserts it at another index.
311
+ *
312
+ * @param {number} slideIndex
313
+ * @param {string} tableId
314
+ * @param {number} sourceRowIndex
315
+ * @param {number} targetRowIndex
316
+ * @param {SlideManager} slideManager
227
317
  */
228
- #extractAllRows(tableXml) {
229
- const rows = [];
230
- let searchFrom = 0;
318
+ cloneTableRow(slideIndex, tableId, sourceRowIndex, targetRowIndex, slideManager) {
319
+ const slideXml = slideManager.getSlideXml(slideIndex)
320
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
321
+ const tblObj = this.#findTableObj(slideObj, tableId)
322
+ if (!tblObj) {
323
+ throw new TableNotFoundError(`Table "${tableId}" not found in slide ${slideIndex}`)
324
+ }
325
+
326
+ const trs = tblObj['a:tr'] || []
327
+ if (sourceRowIndex < 0 || sourceRowIndex >= trs.length) {
328
+ throw new PPTXError(`Source row index ${sourceRowIndex} out of bounds`)
329
+ }
330
+ if (targetRowIndex < 0 || targetRowIndex > trs.length) {
331
+ throw new PPTXError(`Target row index ${targetRowIndex} out of bounds`)
332
+ }
231
333
 
232
- while (true) {
233
- const rowStart = tableXml.indexOf('<a:tr', searchFrom);
234
- if (rowStart === -1) break;
334
+ const template = trs[sourceRowIndex]
335
+ const newRow = this.#xmlParser.deepClone(template)
336
+ this.#updateRowId(newRow)
235
337
 
236
- const rowEnd = tableXml.indexOf('</a:tr>', rowStart);
237
- if (rowEnd === -1) break;
338
+ trs.splice(targetRowIndex, 0, newRow)
238
339
 
239
- rows.push(tableXml.substring(rowStart, rowEnd + '</a:tr>'.length));
240
- searchFrom = rowEnd + '</a:tr>'.length;
340
+ const decl = this.#xmlParser.extractDeclaration(slideXml)
341
+ slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
342
+ }
343
+
344
+ /**
345
+ * Updates a single cell text and formatting.
346
+ *
347
+ * @param {number} slideIndex
348
+ * @param {string} tableId
349
+ * @param {number} rowIndex
350
+ * @param {number} colIndex
351
+ * @param {string} value
352
+ * @param {Object} options
353
+ * @param {SlideManager} slideManager
354
+ */
355
+ updateCell(slideIndex, tableId, rowIndex, colIndex, value, options = {}, slideManager) {
356
+ const slideXml = slideManager.getSlideXml(slideIndex)
357
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
358
+ const tblObj = this.#findTableObj(slideObj, tableId)
359
+ if (!tblObj) {
360
+ throw new TableNotFoundError(`Table "${tableId}" not found in slide ${slideIndex}`)
361
+ }
362
+
363
+ const row = tblObj['a:tr']?.[rowIndex]
364
+ if (!row) {
365
+ throw new PPTXError(`Row index ${rowIndex} out of bounds`)
366
+ }
367
+
368
+ const cell = row['a:tc']?.[colIndex]
369
+ if (!cell) {
370
+ throw new PPTXError(`Column index ${colIndex} out of bounds`)
241
371
  }
242
372
 
243
- return rows;
373
+ this.#setCellTextObj(cell, value)
374
+
375
+ if (options.fill) {
376
+ if (!cell['a:tcPr']) cell['a:tcPr'] = {}
377
+ cell['a:tcPr']['a:solidFill'] = {
378
+ 'a:srgbClr': { '@_val': options.fill },
379
+ }
380
+ }
381
+
382
+ if (options.align) {
383
+ const txBody = cell['a:txBody']
384
+ if (txBody && txBody['a:p']) {
385
+ const paras = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
386
+ for (const p of paras) {
387
+ if (!p['a:pPr']) p['a:pPr'] = {}
388
+ p['a:pPr']['@_algn'] = options.align
389
+ }
390
+ }
391
+ }
392
+
393
+ if (options.fontSize) {
394
+ const sizeVal = options.fontSize * 100
395
+ const txBody = cell['a:txBody']
396
+ if (txBody && txBody['a:p']) {
397
+ const paras = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
398
+ for (const p of paras) {
399
+ if (p['a:r']) {
400
+ const runs = Array.isArray(p['a:r']) ? p['a:r'] : [p['a:r']]
401
+ for (const r of runs) {
402
+ if (!r['a:rPr']) r['a:rPr'] = {}
403
+ r['a:rPr']['@_sz'] = String(sizeVal)
404
+ }
405
+ }
406
+ }
407
+ }
408
+ }
409
+
410
+ const decl = this.#xmlParser.extractDeclaration(slideXml)
411
+ slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
244
412
  }
245
413
 
246
414
  /**
247
- * Builds a new row XML by cloning a template row and replacing cell text.
415
+ * Validates if a merge region can be applied to the table.
248
416
  *
249
- * @private
250
- * @param {string} templateRow - Template row XML to clone.
251
- * @param {string[]} cellValues - Text values for each cell.
252
- * @returns {string} New row XML.
417
+ * @param {number} slideIndex
418
+ * @param {string} tableId
419
+ * @param {number} startRow
420
+ * @param {number} startCol
421
+ * @param {number} endRow
422
+ * @param {number} endCol
423
+ * @param {SlideManager} slideManager
424
+ * @returns {Object} Validation report { valid: boolean, errors: string[] }
253
425
  */
254
- #buildRow(templateRow, cellValues) {
255
- // Extract template cells
256
- const cells = this.#extractCells(templateRow);
257
-
258
- // Build new cells by replacing text in templates
259
- const newCells = cellValues.map((value, colIdx) => {
260
- const template = cells[colIdx] || cells[cells.length - 1] || '<a:tc><a:txBody><a:p><a:r><a:t/></a:r></a:p></a:txBody><a:tcPr/></a:tc>';
261
- return this.#setCellText(template, value);
262
- });
263
-
264
- // Replace cells in template row
265
- const firstCellStart = templateRow.indexOf('<a:tc>') !== -1
266
- ? templateRow.indexOf('<a:tc>')
267
- : templateRow.indexOf('<a:tc ');
268
- const lastCellEnd = templateRow.lastIndexOf('</a:tc>') + '</a:tc>'.length;
269
-
270
- if (firstCellStart === -1 || lastCellEnd === -1) {
271
- return templateRow;
272
- }
273
-
274
- return (
275
- templateRow.substring(0, firstCellStart) +
276
- newCells.join('') +
277
- templateRow.substring(lastCellEnd)
278
- );
426
+ validateMergeRegion(slideIndex, tableId, startRow, startCol, endRow, endCol, slideManager) {
427
+ const errors = []
428
+ const slideXml = slideManager.getSlideXml(slideIndex)
429
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
430
+ const tblObj = this.#findTableObj(slideObj, tableId)
431
+ if (!tblObj) {
432
+ errors.push(`Table "${tableId}" not found in slide ${slideIndex}`)
433
+ return { valid: false, errors }
434
+ }
435
+
436
+ const trs = tblObj['a:tr'] || []
437
+ const numRows = trs.length
438
+ const numCols = trs[0]?.['a:tc']?.length || 0
439
+
440
+ if (startRow < 0 || startRow >= numRows) {
441
+ errors.push(`startRow ${startRow} is out of bounds (table has ${numRows} rows)`)
442
+ }
443
+ if (endRow < 0 || endRow >= numRows) {
444
+ errors.push(`endRow ${endRow} is out of bounds (table has ${numRows} rows)`)
445
+ }
446
+ if (startCol < 0 || startCol >= numCols) {
447
+ errors.push(`startCol ${startCol} is out of bounds (table has ${numCols} cols)`)
448
+ }
449
+ if (endCol < 0 || endCol >= numCols) {
450
+ errors.push(`endCol ${endCol} is out of bounds (table has ${numCols} cols)`)
451
+ }
452
+
453
+ if (errors.length > 0) {
454
+ return { valid: false, errors }
455
+ }
456
+
457
+ if (endRow < startRow) {
458
+ errors.push(`endRow (${endRow}) cannot be less than startRow (${startRow})`)
459
+ }
460
+ if (endCol < startCol) {
461
+ errors.push(`endCol (${endCol}) cannot be less than startCol (${startCol})`)
462
+ }
463
+
464
+ if (errors.length > 0) {
465
+ return { valid: false, errors }
466
+ }
467
+
468
+ const existingMerges = this.getMergedCells(slideIndex, tableId, slideManager)
469
+ for (const R of existingMerges) {
470
+ const overlap =
471
+ startRow <= R.endRow && endRow >= R.startRow && startCol <= R.endCol && endCol >= R.startCol
472
+ if (overlap) {
473
+ errors.push(
474
+ `Requested merge region (${startRow}, ${startCol}) to (${endRow}, ${endCol}) overlaps with existing merged region (${R.startRow}, ${R.startCol}) to (${R.endRow}, ${R.endCol})`
475
+ )
476
+ }
477
+ }
478
+
479
+ return {
480
+ valid: errors.length === 0,
481
+ errors,
482
+ }
279
483
  }
280
484
 
281
485
  /**
282
- * Extracts all <a:tc> cell XML strings from a row.
283
- * @private
284
- * @param {string} rowXml
285
- * @returns {string[]}
486
+ * Merges cells in a table.
487
+ *
488
+ * @param {number} slideIndex
489
+ * @param {string} tableId
490
+ * @param {number} startRow
491
+ * @param {number} startCol
492
+ * @param {number} endRow
493
+ * @param {number} endCol
494
+ * @param {SlideManager} slideManager
286
495
  */
287
- #extractCells(rowXml) {
288
- const cells = [];
289
- let searchFrom = 0;
496
+ mergeCells(slideIndex, tableId, startRow, startCol, endRow, endCol, slideManager) {
497
+ const validation = this.validateMergeRegion(
498
+ slideIndex,
499
+ tableId,
500
+ startRow,
501
+ startCol,
502
+ endRow,
503
+ endCol,
504
+ slideManager
505
+ )
506
+ if (!validation.valid) {
507
+ throw new PPTXError(`Invalid merge region: ${validation.errors.join('; ')}`)
508
+ }
509
+
510
+ const slideXml = slideManager.getSlideXml(slideIndex)
511
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
512
+ const tblObj = this.#findTableObj(slideObj, tableId)
513
+ const trs = tblObj['a:tr'] || []
514
+
515
+ const allTexts = []
516
+
517
+ for (let r = startRow; r <= endRow; r++) {
518
+ const row = trs[r]
519
+ if (!row) continue
520
+ const tcs = row['a:tc'] || []
290
521
 
291
- while (true) {
292
- let cellStart = rowXml.indexOf('<a:tc>', searchFrom);
293
- if (cellStart === -1) {
294
- cellStart = rowXml.indexOf('<a:tc ', searchFrom);
522
+ for (let c = startCol; c <= endCol; c++) {
523
+ const cell = tcs[c]
524
+ if (!cell) continue
525
+
526
+ if (r === startRow && c === startCol) {
527
+ const text = this.#getCellText(cell)
528
+ if (text) allTexts.push(text)
529
+ if (cell['@_hMerge'] !== undefined) delete cell['@_hMerge']
530
+ if (cell['@_vMerge'] !== undefined) delete cell['@_vMerge']
531
+ } else {
532
+ const text = this.#getCellText(cell)
533
+ if (text) allTexts.push(text)
534
+
535
+ if (cell['@_gridSpan'] !== undefined) delete cell['@_gridSpan']
536
+ if (cell['@_rowSpan'] !== undefined) delete cell['@_rowSpan']
537
+
538
+ if (r === startRow) {
539
+ cell['@_hMerge'] = '1'
540
+ if (cell['@_vMerge'] !== undefined) delete cell['@_vMerge']
541
+ } else if (c === startCol) {
542
+ cell['@_vMerge'] = '1'
543
+ if (cell['@_hMerge'] !== undefined) delete cell['@_hMerge']
544
+ } else {
545
+ cell['@_hMerge'] = '1'
546
+ cell['@_vMerge'] = '1'
547
+ }
548
+ this.#setCellTextObj(cell, '')
549
+ }
295
550
  }
296
- if (cellStart === -1) break;
551
+ }
552
+
553
+ const originCell = trs[startRow]['a:tc'][startCol]
554
+ if (endCol > startCol) {
555
+ originCell['@_gridSpan'] = String(endCol - startCol + 1)
556
+ }
557
+ if (endRow > startRow) {
558
+ originCell['@_rowSpan'] = String(endRow - startRow + 1)
559
+ }
560
+
561
+ const combinedText = allTexts.filter(t => t.trim() !== '').join('\n')
562
+ this.#setCellTextObj(originCell, combinedText)
563
+
564
+ const decl = this.#xmlParser.extractDeclaration(slideXml)
565
+ slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
566
+ }
567
+
568
+ /**
569
+ * Unmerges cells in a table.
570
+ *
571
+ * @param {number} slideIndex
572
+ * @param {string} tableId
573
+ * @param {number} startRow
574
+ * @param {number} startCol
575
+ * @param {number} endRow
576
+ * @param {number} endCol
577
+ * @param {SlideManager} slideManager
578
+ */
579
+ unmergeCells(slideIndex, tableId, startRow, startCol, endRow, endCol, slideManager) {
580
+ let actualSlideManager = slideManager
581
+ let actualEndRow = endRow
582
+ let actualEndCol = endCol
297
583
 
298
- const cellEnd = rowXml.indexOf('</a:tc>', cellStart);
299
- if (cellEnd === -1) break;
584
+ if (typeof endRow === 'object' && endRow.getSlideXml) {
585
+ actualSlideManager = endRow
586
+ actualEndRow = undefined
587
+ actualEndCol = undefined
588
+ }
300
589
 
301
- cells.push(rowXml.substring(cellStart, cellEnd + '</a:tc>'.length));
302
- searchFrom = cellEnd + '</a:tc>'.length;
590
+ const slideXml = actualSlideManager.getSlideXml(slideIndex)
591
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
592
+ const tblObj = this.#findTableObj(slideObj, tableId)
593
+ if (!tblObj) {
594
+ throw new TableNotFoundError(`Table "${tableId}" not found in slide ${slideIndex}`)
303
595
  }
304
596
 
305
- return cells;
597
+ const trs = tblObj['a:tr'] || []
598
+
599
+ if (actualEndRow === undefined || actualEndCol === undefined) {
600
+ const R = this.getMergeRegion(slideIndex, tableId, startRow, startCol, actualSlideManager)
601
+ if (!R) return
602
+
603
+ for (let r = R.startRow; r <= R.endRow; r++) {
604
+ const rowObj = trs[r]
605
+ if (!rowObj) continue
606
+ const tcs = rowObj['a:tc'] || []
607
+ for (let c = R.startCol; c <= R.endCol; c++) {
608
+ const cell = tcs[c]
609
+ if (!cell) continue
610
+ if (cell['@_hMerge'] !== undefined) delete cell['@_hMerge']
611
+ if (cell['@_vMerge'] !== undefined) delete cell['@_vMerge']
612
+ if (cell['@_gridSpan'] !== undefined) delete cell['@_gridSpan']
613
+ if (cell['@_rowSpan'] !== undefined) delete cell['@_rowSpan']
614
+ }
615
+ }
616
+ } else {
617
+ for (let r = startRow; r <= actualEndRow; r++) {
618
+ const rowObj = trs[r]
619
+ if (!rowObj) continue
620
+ const tcs = rowObj['a:tc'] || []
621
+ for (let c = startCol; c <= actualEndCol; c++) {
622
+ const cell = tcs[c]
623
+ if (!cell) continue
624
+ if (cell['@_hMerge'] !== undefined) delete cell['@_hMerge']
625
+ if (cell['@_vMerge'] !== undefined) delete cell['@_vMerge']
626
+ if (cell['@_gridSpan'] !== undefined) delete cell['@_gridSpan']
627
+ if (cell['@_rowSpan'] !== undefined) delete cell['@_rowSpan']
628
+ }
629
+ }
630
+ }
631
+
632
+ const decl = this.#xmlParser.extractDeclaration(slideXml)
633
+ actualSlideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
306
634
  }
307
635
 
308
636
  /**
309
- * Replaces the text content of a table cell.
310
- * Preserves all formatting (borders, colors, fonts) and only changes <a:t> content.
637
+ * Scans the table grid and returns all merged regions.
311
638
  *
312
- * @private
313
- * @param {string} cellXml - Cell XML template.
314
- * @param {string} text - New text content.
315
- * @returns {string} Updated cell XML.
639
+ * @param {number} slideIndex
640
+ * @param {string} tableId
641
+ * @param {SlideManager} slideManager
642
+ * @returns {Array<Object>} List of merged region coordinates
316
643
  */
317
- #setCellText(cellXml, text) {
318
- const escapedText = this.#escapeXml(String(text));
644
+ getMergedCells(slideIndex, tableId, slideManager) {
645
+ const slideXml = slideManager.getSlideXml(slideIndex)
646
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
647
+ const tblObj = this.#findTableObj(slideObj, tableId)
648
+ if (!tblObj) {
649
+ throw new TableNotFoundError(`Table "${tableId}" not found in slide ${slideIndex}`)
650
+ }
319
651
 
320
- // If there's an existing <a:t> element, replace its content
321
- const tPattern = /(<a:t>)(.*?)(<\/a:t>)/;
322
- if (tPattern.test(cellXml)) {
323
- // Only replace the first <a:t> to avoid touching other text runs
324
- return cellXml.replace(tPattern, `$1${escapedText}$3`);
652
+ const trs = tblObj['a:tr'] || []
653
+ const merged = []
654
+
655
+ for (let r = 0; r < trs.length; r++) {
656
+ const row = trs[r]
657
+ const tcs = row['a:tc'] || []
658
+ for (let c = 0; c < tcs.length; c++) {
659
+ const cell = tcs[c]
660
+ if (!cell) continue
661
+
662
+ const gridSpan = parseInt(cell['@_gridSpan'] || 1, 10)
663
+ const rowSpan = parseInt(cell['@_rowSpan'] || 1, 10)
664
+
665
+ if (gridSpan > 1 || rowSpan > 1) {
666
+ merged.push({
667
+ startRow: r,
668
+ startCol: c,
669
+ endRow: r + rowSpan - 1,
670
+ endCol: c + gridSpan - 1,
671
+ })
672
+ }
673
+ }
325
674
  }
675
+ return merged
676
+ }
326
677
 
327
- // If no <a:t> exists, inject one inside the first text run
328
- const rPattern = /(<a:r[^>]*>)([\s\S]*?)(<\/a:r>)/;
329
- if (rPattern.test(cellXml)) {
330
- return cellXml.replace(rPattern, `$1$2<a:t>${escapedText}</a:t>$3`);
678
+ /**
679
+ * Helper to get clean string content of a cell.
680
+ */
681
+ #getCellText(cellObj) {
682
+ const txBody = cellObj?.['a:txBody']
683
+ if (!txBody) return ''
684
+ const paras = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
685
+ const text = []
686
+ for (const p of paras) {
687
+ let pText = ''
688
+ if (p['a:r']) {
689
+ const runs = Array.isArray(p['a:r']) ? p['a:r'] : [p['a:r']]
690
+ for (const r of runs) {
691
+ if (r['a:t']) {
692
+ pText += String(r['a:t'])
693
+ }
694
+ }
695
+ }
696
+ text.push(pText)
331
697
  }
698
+ return text.join('\n')
699
+ }
332
700
 
333
- // Fallback: inject a basic text paragraph
334
- const txBodyPattern = /(<a:txBody>)([\s\S]*?)(<\/a:txBody>)/;
335
- if (txBodyPattern.test(cellXml)) {
336
- return cellXml.replace(
337
- txBodyPattern,
338
- `$1<a:p><a:r><a:t>${escapedText}</a:t></a:r></a:p>$3`
339
- );
701
+ /**
702
+ * Returns true if the cell is part of any merged region.
703
+ */
704
+ isMergedCell(slideIndex, tableId, row, col, slideManager) {
705
+ return this.getMergeRegion(slideIndex, tableId, row, col, slideManager) !== null
706
+ }
707
+
708
+ /**
709
+ * Returns the region coordinate containing cell (row, col).
710
+ */
711
+ getMergeRegion(slideIndex, tableId, row, col, slideManager) {
712
+ const merges = this.getMergedCells(slideIndex, tableId, slideManager)
713
+ for (const R of merges) {
714
+ if (row >= R.startRow && row <= R.endRow && col >= R.startCol && col <= R.endCol) {
715
+ return R
716
+ }
340
717
  }
718
+ return null
719
+ }
341
720
 
342
- return cellXml;
721
+ /**
722
+ * Returns origin cell coordinates.
723
+ */
724
+ getMergeParent(slideIndex, tableId, row, col, slideManager) {
725
+ const R = this.getMergeRegion(slideIndex, tableId, row, col, slideManager)
726
+ if (R) {
727
+ return { row: R.startRow, col: R.startCol }
728
+ }
729
+ return { row, col }
343
730
  }
344
731
 
345
732
  /**
346
- * Finds the position of the closing tag that matches an opening at searchFrom.
347
- * @private
348
- * @param {string} xml
349
- * @param {string} closingTag
350
- * @param {number} searchFrom
351
- * @returns {number} Index of closing tag or -1.
733
+ * Splits a merged region containing cell (row, col).
352
734
  */
353
- #findClosingTag(xml, closingTag, searchFrom) {
354
- return xml.indexOf(closingTag, searchFrom);
735
+ splitMergedRegion(slideIndex, tableId, row, col, slideManager) {
736
+ this.unmergeCells(slideIndex, tableId, row, col, slideManager)
355
737
  }
356
738
 
357
739
  /**
358
- * Escapes regex special characters.
359
- * @private
360
- * @param {string} str
361
- * @returns {string}
740
+ * Clones a merged region.
362
741
  */
363
- #escapeRegex(str) {
364
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
742
+ cloneMergedRegion(slideIndex, tableId, row, col, targetRow, targetCol, slideManager) {
743
+ const R = this.getMergeRegion(slideIndex, tableId, row, col, slideManager)
744
+ if (!R) return
745
+
746
+ const rowSpan = R.endRow - R.startRow + 1
747
+ const colSpan = R.endCol - R.startCol + 1
748
+
749
+ const targetEndRow = targetRow + rowSpan - 1
750
+ const targetEndCol = targetCol + colSpan - 1
751
+
752
+ this.mergeCells(
753
+ slideIndex,
754
+ tableId,
755
+ targetRow,
756
+ targetCol,
757
+ targetEndRow,
758
+ targetEndCol,
759
+ slideManager
760
+ )
761
+
762
+ const slideXml = slideManager.getSlideXml(slideIndex)
763
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
764
+ const tblObj = this.#findTableObj(slideObj, tableId)
765
+ if (!tblObj) return
766
+
767
+ const trs = tblObj['a:tr'] || []
768
+ const srcCell = trs[R.startRow]?.['a:tc']?.[R.startCol]
769
+ const destCell = trs[targetRow]?.['a:tc']?.[targetCol]
770
+
771
+ if (srcCell && destCell) {
772
+ if (srcCell['a:tcPr']) {
773
+ destCell['a:tcPr'] = this.#xmlParser.deepClone(srcCell['a:tcPr'])
774
+ }
775
+ if (srcCell['a:txBody']) {
776
+ destCell['a:txBody'] = this.#xmlParser.deepClone(srcCell['a:txBody'])
777
+ }
778
+ }
779
+
780
+ const decl = this.#xmlParser.extractDeclaration(slideXml)
781
+ slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
365
782
  }
366
783
 
367
784
  /**
368
- * Escapes XML special characters.
369
- * @private
370
- * @param {string} str
371
- * @returns {string}
785
+ * Auto-fits table columns based on text length.
786
+ *
787
+ * @param {number} slideIndex
788
+ * @param {string} tableId
789
+ * @param {SlideManager} slideManager
372
790
  */
373
- #escapeXml(str) {
374
- return str
375
- .replace(/&/g, '&amp;')
376
- .replace(/</g, '&lt;')
377
- .replace(/>/g, '&gt;')
378
- .replace(/"/g, '&quot;')
379
- .replace(/'/g, '&apos;');
791
+ autoFitTable(slideIndex, tableId, slideManager) {
792
+ const slideXml = slideManager.getSlideXml(slideIndex)
793
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
794
+ const tblObj = this.#findTableObj(slideObj, tableId)
795
+ if (!tblObj) {
796
+ throw new TableNotFoundError(`Table "${tableId}" not found in slide ${slideIndex}`)
797
+ }
798
+
799
+ const trs = tblObj['a:tr'] || []
800
+ const gridCols = tblObj['a:tblGrid']?.['a:gridCol']
801
+ if (!gridCols || trs.length === 0) return
802
+
803
+ const numCols = gridCols.length
804
+ const maxLens = new Array(numCols).fill(0)
805
+
806
+ for (const row of trs) {
807
+ const tcs = row['a:tc'] || []
808
+ for (let c = 0; c < numCols; c++) {
809
+ const cell = tcs[c]
810
+ if (!cell || cell['@_hMerge'] || cell['@_vMerge']) continue
811
+
812
+ const txBody = cell['a:txBody']
813
+ if (txBody) {
814
+ const paras = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
815
+ let len = 0
816
+ for (const p of paras) {
817
+ if (p['a:r']) {
818
+ const runs = Array.isArray(p['a:r']) ? p['a:r'] : [p['a:r']]
819
+ for (const r of runs) {
820
+ if (r['a:t']) {
821
+ len += String(r['a:t']).length
822
+ }
823
+ }
824
+ }
825
+ }
826
+ if (len > maxLens[c]) {
827
+ maxLens[c] = len
828
+ }
829
+ }
830
+ }
831
+ }
832
+
833
+ for (let c = 0; c < numCols; c++) {
834
+ const width = Math.max(1000000, maxLens[c] * 120000)
835
+ gridCols[c]['@_w'] = String(width)
836
+ }
837
+
838
+ const decl = this.#xmlParser.extractDeclaration(slideXml)
839
+ slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
380
840
  }
381
841
 
382
842
  /**
383
- * Inspects a slide's table structure (for debugging).
843
+ * Resizes the table width and height.
844
+ *
845
+ * @param {number} slideIndex
846
+ * @param {string} tableId
847
+ * @param {number} width - New width in EMUs or inches.
848
+ * @param {number} height - New height in EMUs or inches.
849
+ * @param {SlideManager} slideManager
850
+ */
851
+ resizeTable(slideIndex, tableId, width, height, slideManager) {
852
+ const slideXml = slideManager.getSlideXml(slideIndex)
853
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
854
+
855
+ const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
856
+ if (!spTree) return
857
+
858
+ let frames = spTree['p:graphicFrame'] || []
859
+ if (!Array.isArray(frames)) frames = [frames]
860
+
861
+ let targetFrame = null
862
+ let tbl = null
863
+
864
+ for (const frame of frames) {
865
+ tbl = frame?.['a:graphic']?.['a:graphicData']?.['a:tbl']
866
+ if (!tbl) continue
867
+
868
+ const cNvPr = frame?.['p:nvGraphicFramePr']?.['p:cNvPr']
869
+ if (!cNvPr) continue
870
+
871
+ const name = cNvPr['@_name']
872
+ const id = String(cNvPr['@_id'])
873
+
874
+ if (tableId === 'first' || name === tableId || id === tableId) {
875
+ targetFrame = frame
876
+ break
877
+ }
878
+ }
879
+
880
+ if (!targetFrame || !tbl) {
881
+ throw new TableNotFoundError(`Table "${tableId}" not found in slide ${slideIndex}`)
882
+ }
883
+
884
+ const emuWidth = width < 100 ? Math.round(width * 914400) : Math.round(width)
885
+ const emuHeight = height < 100 ? Math.round(height * 914400) : Math.round(height)
886
+
887
+ if (!targetFrame['p:xfrm']) targetFrame['p:xfrm'] = {}
888
+ if (!targetFrame['p:xfrm']['a:ext']) targetFrame['p:xfrm']['a:ext'] = {}
889
+ targetFrame['p:xfrm']['a:ext']['@_cx'] = String(emuWidth)
890
+ targetFrame['p:xfrm']['a:ext']['@_cy'] = String(emuHeight)
891
+
892
+ const gridCols = tbl['a:tblGrid']?.['a:gridCol']
893
+ if (gridCols && gridCols.length > 0) {
894
+ let currentTotalWidth = 0
895
+ for (const col of gridCols) {
896
+ currentTotalWidth += parseInt(col['@_w'] || 0, 10)
897
+ }
898
+
899
+ if (currentTotalWidth > 0) {
900
+ const ratio = emuWidth / currentTotalWidth
901
+ for (const col of gridCols) {
902
+ const w = parseInt(col['@_w'] || 0, 10)
903
+ col['@_w'] = String(Math.round(w * ratio))
904
+ }
905
+ } else {
906
+ const evenWidth = Math.round(emuWidth / gridCols.length)
907
+ for (const col of gridCols) {
908
+ col['@_w'] = String(evenWidth)
909
+ }
910
+ }
911
+ }
912
+
913
+ const trs = tbl['a:tr'] || []
914
+ if (trs.length > 0) {
915
+ let currentTotalHeight = 0
916
+ for (const row of trs) {
917
+ currentTotalHeight += parseInt(row['@_h'] || 0, 10)
918
+ }
919
+
920
+ if (currentTotalHeight > 0) {
921
+ const ratio = emuHeight / currentTotalHeight
922
+ for (const row of trs) {
923
+ const h = parseInt(row['@_h'] || 0, 10)
924
+ row['@_h'] = String(Math.round(h * ratio))
925
+ }
926
+ } else {
927
+ const evenHeight = Math.round(emuHeight / trs.length)
928
+ for (const row of trs) {
929
+ row['@_h'] = String(evenHeight)
930
+ }
931
+ }
932
+ }
933
+
934
+ const decl = this.#xmlParser.extractDeclaration(slideXml)
935
+ slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
936
+ }
937
+
938
+ /**
939
+ * Inspects all tables in a slide.
384
940
  *
385
941
  * @param {number} slideIndex
386
942
  * @param {SlideManager} slideManager
387
943
  * @returns {Array<{name: string, id: string, rows: number, cols: number}>}
388
944
  */
389
945
  inspectTables(slideIndex, slideManager) {
390
- const slideXml = slideManager.getSlideXml(slideIndex);
391
- const tables = [];
946
+ const slideXml = slideManager.getSlideXml(slideIndex)
947
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
948
+ const tables = []
949
+
950
+ const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
951
+ if (!spTree) return []
392
952
 
393
- // Find all graphicFrames with table data
394
- const framePattern = /<p:graphicFrame>([\s\S]*?)<\/p:graphicFrame>/g;
395
- let match;
953
+ let frames = spTree['p:graphicFrame'] || []
954
+ if (!Array.isArray(frames)) frames = [frames]
396
955
 
397
- while ((match = framePattern.exec(slideXml)) !== null) {
398
- const frameXml = match[1];
399
- if (!frameXml.includes('<a:tbl>')) continue;
956
+ for (const frame of frames) {
957
+ const tbl = frame?.['a:graphic']?.['a:graphicData']?.['a:tbl']
958
+ if (!tbl) continue
400
959
 
401
- const nameMatch = /name="([^"]*)"/.exec(frameXml);
402
- const idMatch = /id="([^"]*)"/.exec(frameXml);
403
- const rows = this.#extractAllRows(frameXml);
404
- const cols = rows[0] ? this.#extractCells(rows[0]).length : 0;
960
+ const cNvPr = frame?.['p:nvGraphicFramePr']?.['p:cNvPr']
961
+ const name = cNvPr ? cNvPr['@_name'] : 'unnamed'
962
+ const id = cNvPr ? String(cNvPr['@_id']) : 'unknown'
963
+
964
+ const trs = tbl['a:tr'] || []
965
+ const cols = trs[0]?.['a:tc']?.length || 0
405
966
 
406
967
  tables.push({
407
- name: nameMatch ? nameMatch[1] : 'unnamed',
408
- id: idMatch ? idMatch[1] : 'unknown',
409
- rows: rows.length,
968
+ name,
969
+ id,
970
+ rows: trs.length,
410
971
  cols,
411
- });
972
+ })
973
+ }
974
+
975
+ return tables
976
+ }
977
+
978
+ /**
979
+ * Helper to set cell text in parsed object structure.
980
+ */
981
+ #setCellTextObj(cellObj, text) {
982
+ const val = text === undefined || text === null ? '' : String(text)
983
+
984
+ if (!cellObj['a:txBody']) {
985
+ cellObj['a:txBody'] = {
986
+ 'a:bodyPr': {},
987
+ 'a:lstStyle': {},
988
+ 'a:p': [],
989
+ }
990
+ }
991
+
992
+ const txBody = cellObj['a:txBody']
993
+
994
+ if (!txBody['a:p']) {
995
+ txBody['a:p'] = []
996
+ }
997
+ if (!Array.isArray(txBody['a:p'])) {
998
+ txBody['a:p'] = [txBody['a:p']]
999
+ }
1000
+ if (txBody['a:p'].length === 0) {
1001
+ txBody['a:p'].push({})
1002
+ }
1003
+
1004
+ const lines = val.split(/\r?\n/)
1005
+ const templatePara = txBody['a:p'][0]
1006
+ const newParas = []
1007
+
1008
+ for (const line of lines) {
1009
+ const p = this.#xmlParser.deepClone(templatePara)
1010
+
1011
+ if (!p['a:r']) {
1012
+ p['a:r'] = []
1013
+ }
1014
+ if (!Array.isArray(p['a:r'])) {
1015
+ p['a:r'] = [p['a:r']]
1016
+ }
1017
+
1018
+ if (p['a:r'].length === 0) {
1019
+ p['a:r'].push({ 'a:t': line })
1020
+ } else {
1021
+ const firstRun = p['a:r'][0]
1022
+ firstRun['a:t'] = line
1023
+ p['a:r'] = [firstRun]
1024
+ }
1025
+
1026
+ // Ensure strict schema ordering for DrawingML paragraph elements:
1027
+ // 1. a:pPr (paragraph properties)
1028
+ // 2. a:r / a:br / a:fld (text runs, breaks, fields)
1029
+ // 3. a:endParaRPr (end paragraph run properties)
1030
+ const pOrdered = {}
1031
+ if (p['a:pPr'] !== undefined) {
1032
+ pOrdered['a:pPr'] = p['a:pPr']
1033
+ }
1034
+ if (p['a:r'] !== undefined) {
1035
+ pOrdered['a:r'] = p['a:r']
1036
+ }
1037
+ if (p['a:br'] !== undefined) {
1038
+ pOrdered['a:br'] = p['a:br']
1039
+ }
1040
+ if (p['a:fld'] !== undefined) {
1041
+ pOrdered['a:fld'] = p['a:fld']
1042
+ }
1043
+ for (const key of Object.keys(p)) {
1044
+ if (
1045
+ key !== 'a:pPr' &&
1046
+ key !== 'a:r' &&
1047
+ key !== 'a:br' &&
1048
+ key !== 'a:fld' &&
1049
+ key !== 'a:endParaRPr'
1050
+ ) {
1051
+ pOrdered[key] = p[key]
1052
+ }
1053
+ }
1054
+ if (p['a:endParaRPr'] !== undefined) {
1055
+ pOrdered['a:endParaRPr'] = p['a:endParaRPr']
1056
+ }
1057
+
1058
+ newParas.push(pOrdered)
1059
+ }
1060
+
1061
+ txBody['a:p'] = newParas
1062
+ }
1063
+
1064
+ /**
1065
+ * Helper to find a table element inside a slide parsed object.
1066
+ */
1067
+ #findTableObj(slideObj, tableId) {
1068
+ const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
1069
+ if (!spTree) return null
1070
+
1071
+ let frames = spTree['p:graphicFrame'] || []
1072
+ if (!Array.isArray(frames)) frames = [frames]
1073
+
1074
+ for (const frame of frames) {
1075
+ const tbl = frame?.['a:graphic']?.['a:graphicData']?.['a:tbl']
1076
+ if (!tbl) continue
1077
+
1078
+ const cNvPr = frame?.['p:nvGraphicFramePr']?.['p:cNvPr']
1079
+ if (!cNvPr) continue
1080
+
1081
+ const name = cNvPr['@_name']
1082
+ const id = String(cNvPr['@_id'])
1083
+
1084
+ if (tableId === 'first' || name === tableId || id === tableId) {
1085
+ return tbl
1086
+ }
412
1087
  }
413
1088
 
414
- return tables;
1089
+ return null
1090
+ }
1091
+
1092
+ /**
1093
+ * Generates a new rowId for the given row object.
1094
+ */
1095
+ #updateRowId(rowObj) {
1096
+ const randomVal = String(this.#generateRandomUint32())
1097
+ if (rowObj['a:extLst']?.['a:ext']) {
1098
+ const ext = rowObj['a:extLst']['a:ext']
1099
+ const exts = Array.isArray(ext) ? ext : [ext]
1100
+ let updated = false
1101
+ for (const e of exts) {
1102
+ if (e['a16:rowId']) {
1103
+ e['a16:rowId']['@_val'] = randomVal
1104
+ updated = true
1105
+ }
1106
+ }
1107
+ if (!updated) {
1108
+ exts.push({
1109
+ '@_uri': '{0D108BD9-81ED-4DB2-BD59-A6C34878D82A}',
1110
+ 'a16:rowId': {
1111
+ '@_xmlns:a16': 'http://schemas.microsoft.com/office/drawing/2014/main',
1112
+ '@_val': randomVal,
1113
+ },
1114
+ })
1115
+ rowObj['a:extLst']['a:ext'] = exts
1116
+ }
1117
+ } else {
1118
+ rowObj['a:extLst'] = {
1119
+ 'a:ext': {
1120
+ '@_uri': '{0D108BD9-81ED-4DB2-BD59-A6C34878D82A}',
1121
+ 'a16:rowId': {
1122
+ '@_xmlns:a16': 'http://schemas.microsoft.com/office/drawing/2014/main',
1123
+ '@_val': randomVal,
1124
+ },
1125
+ },
1126
+ }
1127
+ }
1128
+ }
1129
+
1130
+ #generateRandomUint32() {
1131
+ return Math.floor(Math.random() * 4294967296)
415
1132
  }
416
1133
  }
1134
+
1135
+ module.exports = { TableManager }