node-pptx-templater 1.0.0
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/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/package.json +83 -0
- package/src/cli/commands/build.js +79 -0
- package/src/cli/commands/debug.js +46 -0
- package/src/cli/commands/extract.js +42 -0
- package/src/cli/commands/inspect.js +39 -0
- package/src/cli/commands/validate.js +36 -0
- package/src/cli/index.js +132 -0
- package/src/core/OutputWriter.js +181 -0
- package/src/core/PPTXTemplater.js +961 -0
- package/src/core/TemplateEngine.js +321 -0
- package/src/index.js +43 -0
- package/src/managers/ChartManager.js +317 -0
- package/src/managers/ContentTypesManager.js +160 -0
- package/src/managers/HyperlinkManager.js +451 -0
- package/src/managers/MediaManager.js +307 -0
- package/src/managers/RelationshipManager.js +401 -0
- package/src/managers/SlideManager.js +950 -0
- package/src/managers/TableManager.js +416 -0
- package/src/managers/ZipManager.js +298 -0
- package/src/managers/charts/ChartCacheGenerator.js +156 -0
- package/src/managers/charts/ChartParser.js +43 -0
- package/src/managers/charts/ChartRelationshipManager.js +33 -0
- package/src/managers/charts/ChartWorkbookUpdater.js +130 -0
- package/src/parsers/XMLParser.js +291 -0
- package/src/templates/blankPptx.js +1 -0
- package/src/templates/slideTemplate.js +314 -0
- package/src/utils/contentTypesHelper.js +149 -0
- package/src/utils/errors.js +129 -0
- package/src/utils/idUtils.js +54 -0
- package/src/utils/logger.js +113 -0
- package/src/utils/relationshipUtils.js +89 -0
- package/src/utils/xmlUtils.js +115 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview TableManager - Updates table data in PPTX slides.
|
|
3
|
+
*
|
|
4
|
+
* Tables in OpenXML PPTX:
|
|
5
|
+
* ─────────────────────────────────────────────────────────────────
|
|
6
|
+
* Tables are stored as a:tbl inside a p:graphicFrame shape.
|
|
7
|
+
*
|
|
8
|
+
* <p:graphicFrame>
|
|
9
|
+
* <p:nvGraphicFramePr>
|
|
10
|
+
* <p:cNvPr id="6" name="employees-table"/> ← table name
|
|
11
|
+
* </p:nvGraphicFramePr>
|
|
12
|
+
* <a:graphic>
|
|
13
|
+
* <a:graphicData uri="...table">
|
|
14
|
+
* <a:tbl>
|
|
15
|
+
* <a:tblGrid>
|
|
16
|
+
* <a:gridCol w="2286000"/> ← column widths
|
|
17
|
+
* </a:tblGrid>
|
|
18
|
+
* <a:tr h="370840"> ← row (height in EMUs)
|
|
19
|
+
* <a:tc> ← table cell
|
|
20
|
+
* <a:txBody>
|
|
21
|
+
* <a:p>
|
|
22
|
+
* <a:r><a:t>Cell text</a:t></a:r>
|
|
23
|
+
* </a:p>
|
|
24
|
+
* </a:txBody>
|
|
25
|
+
* <a:tcPr/> ← cell properties (borders, fills)
|
|
26
|
+
* </a:tc>
|
|
27
|
+
* </a:tr>
|
|
28
|
+
* </a:tbl>
|
|
29
|
+
* </a:graphicData>
|
|
30
|
+
* </a:graphic>
|
|
31
|
+
* </p:graphicFrame>
|
|
32
|
+
*
|
|
33
|
+
* EMU (English Metric Units):
|
|
34
|
+
* - 1 inch = 914400 EMU
|
|
35
|
+
* - 1 pt = 12700 EMU
|
|
36
|
+
* - 1 cm = 360000 EMU
|
|
37
|
+
*
|
|
38
|
+
* Key challenge: Preserving cell formatting while replacing text.
|
|
39
|
+
* 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
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { createLogger } from '../utils/logger.js';
|
|
49
|
+
import { TableNotFoundError } from '../utils/errors.js';
|
|
50
|
+
|
|
51
|
+
const logger = createLogger('TableManager');
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @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.
|
|
59
|
+
*/
|
|
60
|
+
export class TableManager {
|
|
61
|
+
/** @private @type {XMLParser} */
|
|
62
|
+
#xmlParser;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {XMLParser} xmlParser
|
|
66
|
+
*/
|
|
67
|
+
constructor(xmlParser) {
|
|
68
|
+
this.#xmlParser = xmlParser;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Updates a table with new row data.
|
|
73
|
+
* Finds the table by name/ID and replaces its row content.
|
|
74
|
+
* Preserves all formatting properties.
|
|
75
|
+
*
|
|
76
|
+
* @param {number} slideIndex - 1-based slide index.
|
|
77
|
+
* @param {string} tableId - Table name (from shape's cNvPr name attribute).
|
|
78
|
+
* @param {string[][]} rows - 2D array of cell values [row][col].
|
|
79
|
+
* @param {SlideManager} slideManager
|
|
80
|
+
* @throws {TableNotFoundError} If the table is not found.
|
|
81
|
+
*/
|
|
82
|
+
updateTable(slideIndex, tableId, rows, slideManager) {
|
|
83
|
+
const slideXml = slideManager.getSlideXml(slideIndex);
|
|
84
|
+
const updatedXml = this.#updateTableInXml(slideXml, tableId, rows);
|
|
85
|
+
|
|
86
|
+
if (updatedXml === null) {
|
|
87
|
+
throw new TableNotFoundError(`Table "${tableId}" not found in slide ${slideIndex}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
slideManager.setSlideXml(slideIndex, updatedXml);
|
|
91
|
+
logger.debug(`Updated table "${tableId}" with ${rows.length} rows`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Updates a table within raw XML.
|
|
96
|
+
* Returns null if the table was not found.
|
|
97
|
+
*
|
|
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.
|
|
103
|
+
*/
|
|
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
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
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);
|
|
158
|
+
|
|
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
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return found ? updatedXml : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Replaces the table rows within a graphicFrame XML snippet.
|
|
173
|
+
* Preserves the first row's styling as a template for new rows.
|
|
174
|
+
*
|
|
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.
|
|
179
|
+
*/
|
|
180
|
+
#updateTableRows(frameXml, rows) {
|
|
181
|
+
// Extract all existing rows
|
|
182
|
+
const existingRows = this.#extractAllRows(frameXml);
|
|
183
|
+
|
|
184
|
+
if (existingRows.length === 0) {
|
|
185
|
+
logger.warn('No rows found in table');
|
|
186
|
+
return frameXml;
|
|
187
|
+
}
|
|
188
|
+
|
|
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];
|
|
192
|
+
|
|
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('');
|
|
198
|
+
|
|
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);
|
|
203
|
+
|
|
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;
|
|
209
|
+
|
|
210
|
+
if (firstRowStart === -1 || lastRowEnd === -1) {
|
|
211
|
+
return frameXml;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const newTblXml =
|
|
215
|
+
tblXml.substring(0, firstRowStart) +
|
|
216
|
+
newRowsXml +
|
|
217
|
+
tblXml.substring(lastRowEnd);
|
|
218
|
+
|
|
219
|
+
return frameXml.substring(0, tblStart) + newTblXml + frameXml.substring(tblEnd);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Extracts all <a:tr> row XML strings from a table.
|
|
224
|
+
* @private
|
|
225
|
+
* @param {string} tableXml
|
|
226
|
+
* @returns {string[]}
|
|
227
|
+
*/
|
|
228
|
+
#extractAllRows(tableXml) {
|
|
229
|
+
const rows = [];
|
|
230
|
+
let searchFrom = 0;
|
|
231
|
+
|
|
232
|
+
while (true) {
|
|
233
|
+
const rowStart = tableXml.indexOf('<a:tr', searchFrom);
|
|
234
|
+
if (rowStart === -1) break;
|
|
235
|
+
|
|
236
|
+
const rowEnd = tableXml.indexOf('</a:tr>', rowStart);
|
|
237
|
+
if (rowEnd === -1) break;
|
|
238
|
+
|
|
239
|
+
rows.push(tableXml.substring(rowStart, rowEnd + '</a:tr>'.length));
|
|
240
|
+
searchFrom = rowEnd + '</a:tr>'.length;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return rows;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Builds a new row XML by cloning a template row and replacing cell text.
|
|
248
|
+
*
|
|
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.
|
|
253
|
+
*/
|
|
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
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Extracts all <a:tc> cell XML strings from a row.
|
|
283
|
+
* @private
|
|
284
|
+
* @param {string} rowXml
|
|
285
|
+
* @returns {string[]}
|
|
286
|
+
*/
|
|
287
|
+
#extractCells(rowXml) {
|
|
288
|
+
const cells = [];
|
|
289
|
+
let searchFrom = 0;
|
|
290
|
+
|
|
291
|
+
while (true) {
|
|
292
|
+
let cellStart = rowXml.indexOf('<a:tc>', searchFrom);
|
|
293
|
+
if (cellStart === -1) {
|
|
294
|
+
cellStart = rowXml.indexOf('<a:tc ', searchFrom);
|
|
295
|
+
}
|
|
296
|
+
if (cellStart === -1) break;
|
|
297
|
+
|
|
298
|
+
const cellEnd = rowXml.indexOf('</a:tc>', cellStart);
|
|
299
|
+
if (cellEnd === -1) break;
|
|
300
|
+
|
|
301
|
+
cells.push(rowXml.substring(cellStart, cellEnd + '</a:tc>'.length));
|
|
302
|
+
searchFrom = cellEnd + '</a:tc>'.length;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return cells;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Replaces the text content of a table cell.
|
|
310
|
+
* Preserves all formatting (borders, colors, fonts) and only changes <a:t> content.
|
|
311
|
+
*
|
|
312
|
+
* @private
|
|
313
|
+
* @param {string} cellXml - Cell XML template.
|
|
314
|
+
* @param {string} text - New text content.
|
|
315
|
+
* @returns {string} Updated cell XML.
|
|
316
|
+
*/
|
|
317
|
+
#setCellText(cellXml, text) {
|
|
318
|
+
const escapedText = this.#escapeXml(String(text));
|
|
319
|
+
|
|
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`);
|
|
325
|
+
}
|
|
326
|
+
|
|
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`);
|
|
331
|
+
}
|
|
332
|
+
|
|
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
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return cellXml;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
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.
|
|
352
|
+
*/
|
|
353
|
+
#findClosingTag(xml, closingTag, searchFrom) {
|
|
354
|
+
return xml.indexOf(closingTag, searchFrom);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Escapes regex special characters.
|
|
359
|
+
* @private
|
|
360
|
+
* @param {string} str
|
|
361
|
+
* @returns {string}
|
|
362
|
+
*/
|
|
363
|
+
#escapeRegex(str) {
|
|
364
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Escapes XML special characters.
|
|
369
|
+
* @private
|
|
370
|
+
* @param {string} str
|
|
371
|
+
* @returns {string}
|
|
372
|
+
*/
|
|
373
|
+
#escapeXml(str) {
|
|
374
|
+
return str
|
|
375
|
+
.replace(/&/g, '&')
|
|
376
|
+
.replace(/</g, '<')
|
|
377
|
+
.replace(/>/g, '>')
|
|
378
|
+
.replace(/"/g, '"')
|
|
379
|
+
.replace(/'/g, ''');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Inspects a slide's table structure (for debugging).
|
|
384
|
+
*
|
|
385
|
+
* @param {number} slideIndex
|
|
386
|
+
* @param {SlideManager} slideManager
|
|
387
|
+
* @returns {Array<{name: string, id: string, rows: number, cols: number}>}
|
|
388
|
+
*/
|
|
389
|
+
inspectTables(slideIndex, slideManager) {
|
|
390
|
+
const slideXml = slideManager.getSlideXml(slideIndex);
|
|
391
|
+
const tables = [];
|
|
392
|
+
|
|
393
|
+
// Find all graphicFrames with table data
|
|
394
|
+
const framePattern = /<p:graphicFrame>([\s\S]*?)<\/p:graphicFrame>/g;
|
|
395
|
+
let match;
|
|
396
|
+
|
|
397
|
+
while ((match = framePattern.exec(slideXml)) !== null) {
|
|
398
|
+
const frameXml = match[1];
|
|
399
|
+
if (!frameXml.includes('<a:tbl>')) continue;
|
|
400
|
+
|
|
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;
|
|
405
|
+
|
|
406
|
+
tables.push({
|
|
407
|
+
name: nameMatch ? nameMatch[1] : 'unnamed',
|
|
408
|
+
id: idMatch ? idMatch[1] : 'unknown',
|
|
409
|
+
rows: rows.length,
|
|
410
|
+
cols,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return tables;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview ZipManager - Handles PPTX ZIP archive operations.
|
|
3
|
+
*
|
|
4
|
+
* A PPTX file is a ZIP archive following the Open Packaging Convention (OPC).
|
|
5
|
+
* This manager wraps JSZip to provide:
|
|
6
|
+
* - Loading and parsing PPTX ZIP archives
|
|
7
|
+
* - Reading individual XML parts
|
|
8
|
+
* - Writing/replacing individual XML parts
|
|
9
|
+
* - Re-packaging the modified archive
|
|
10
|
+
* - Media file deduplication
|
|
11
|
+
*
|
|
12
|
+
* ZipManager is the lowest layer — all other managers use it to read/write
|
|
13
|
+
* raw file content within the ZIP.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import JSZip from 'jszip';
|
|
17
|
+
import fsExtra from 'fs-extra';
|
|
18
|
+
import { createLogger } from '../utils/logger.js';
|
|
19
|
+
import { PPTXError } from '../utils/errors.js';
|
|
20
|
+
import { BLANK_PPTX_BASE64 } from '../templates/blankPptx.js';
|
|
21
|
+
|
|
22
|
+
const logger = createLogger('ZipManager');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @class ZipManager
|
|
26
|
+
* @description Manages the PPTX ZIP archive — reading, modifying, and re-packaging it.
|
|
27
|
+
*
|
|
28
|
+
* All file paths within the ZIP use forward slashes (e.g., 'ppt/slides/slide1.xml').
|
|
29
|
+
*/
|
|
30
|
+
export class ZipManager {
|
|
31
|
+
/**
|
|
32
|
+
* @private
|
|
33
|
+
* @type {JSZip}
|
|
34
|
+
*/
|
|
35
|
+
#zip = null;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @private
|
|
39
|
+
* @type {Map<string, string>} Cache of decoded XML strings for fast repeated access.
|
|
40
|
+
*/
|
|
41
|
+
#xmlCache = new Map();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @private
|
|
45
|
+
* @type {Map<string, string>} Dirty (modified) files that need to be re-written.
|
|
46
|
+
*/
|
|
47
|
+
#dirtyFiles = new Map();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @private
|
|
51
|
+
* @type {Map<string, string>} Core properties (dc:title, dc:creator, etc.)
|
|
52
|
+
*/
|
|
53
|
+
#coreProperties = new Map();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Loads a PPTX file from a path or Buffer.
|
|
57
|
+
*
|
|
58
|
+
* @param {string|Buffer} source - File path or Buffer.
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
* @throws {PPTXError} If the file cannot be read or parsed as a ZIP.
|
|
61
|
+
*/
|
|
62
|
+
async load(source) {
|
|
63
|
+
try {
|
|
64
|
+
let data;
|
|
65
|
+
if (typeof source === 'string') {
|
|
66
|
+
logger.debug(`Reading file: ${source}`);
|
|
67
|
+
data = await fsExtra.readFile(source);
|
|
68
|
+
} else if (Buffer.isBuffer(source) || source instanceof Uint8Array) {
|
|
69
|
+
data = source;
|
|
70
|
+
} else {
|
|
71
|
+
throw new PPTXError(`Invalid source type: ${typeof source}. Expected string path or Buffer.`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.#zip = await JSZip.loadAsync(data);
|
|
75
|
+
await this.#loadCoreProperties();
|
|
76
|
+
logger.debug(`ZIP loaded successfully. Files: ${Object.keys(this.#zip.files).length}`);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err instanceof PPTXError) throw err;
|
|
79
|
+
throw new PPTXError(`Failed to load PPTX: ${err.message}`, err);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Creates a blank PPTX structure from the embedded minimal template.
|
|
85
|
+
* @returns {Promise<void>}
|
|
86
|
+
*/
|
|
87
|
+
async createBlank() {
|
|
88
|
+
const buffer = Buffer.from(BLANK_PPTX_BASE64, 'base64');
|
|
89
|
+
this.#zip = await JSZip.loadAsync(buffer);
|
|
90
|
+
await this.#loadCoreProperties();
|
|
91
|
+
logger.debug('Created blank PPTX structure');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reads and caches a text file from the ZIP archive.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} zipPath - Path within the ZIP (e.g., 'ppt/slides/slide1.xml').
|
|
98
|
+
* @returns {Promise<string|null>} File content as UTF-8 string, or null if not found.
|
|
99
|
+
*/
|
|
100
|
+
async readFile(zipPath) {
|
|
101
|
+
// Normalize path separators
|
|
102
|
+
const normalPath = zipPath.replace(/\\/g, '/');
|
|
103
|
+
|
|
104
|
+
// Return cached version if available and not dirty
|
|
105
|
+
if (this.#xmlCache.has(normalPath) && !this.#dirtyFiles.has(normalPath)) {
|
|
106
|
+
return this.#xmlCache.get(normalPath);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check dirty files (pending writes)
|
|
110
|
+
if (this.#dirtyFiles.has(normalPath)) {
|
|
111
|
+
return this.#dirtyFiles.get(normalPath);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const file = this.#zip.file(normalPath);
|
|
115
|
+
if (!file) {
|
|
116
|
+
logger.debug(`File not found in ZIP: ${normalPath}`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const content = await file.async('text');
|
|
121
|
+
this.#xmlCache.set(normalPath, content);
|
|
122
|
+
return content;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Reads a binary file from the ZIP archive.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} zipPath - Path within the ZIP.
|
|
129
|
+
* @returns {Promise<Uint8Array|null>} Binary content or null if not found.
|
|
130
|
+
*/
|
|
131
|
+
async readBinaryFile(zipPath) {
|
|
132
|
+
const normalPath = zipPath.replace(/\\/g, '/');
|
|
133
|
+
const file = this.#zip.file(normalPath);
|
|
134
|
+
if (!file) return null;
|
|
135
|
+
return file.async('uint8array');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Writes (or overwrites) a text file in the ZIP archive.
|
|
140
|
+
* Changes are buffered and applied when generating the output ZIP.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} zipPath - Path within the ZIP.
|
|
143
|
+
* @param {string} content - UTF-8 string content.
|
|
144
|
+
*/
|
|
145
|
+
writeFile(zipPath, content) {
|
|
146
|
+
const normalPath = zipPath.replace(/\\/g, '/');
|
|
147
|
+
this.#dirtyFiles.set(normalPath, content);
|
|
148
|
+
this.#xmlCache.set(normalPath, content);
|
|
149
|
+
// Also write to the underlying JSZip object
|
|
150
|
+
this.#zip.file(normalPath, content);
|
|
151
|
+
logger.debug(`Queued write: ${normalPath}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Writes a binary file to the ZIP archive.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} zipPath - Path within the ZIP.
|
|
158
|
+
* @param {Buffer|Uint8Array} data - Binary data.
|
|
159
|
+
*/
|
|
160
|
+
writeBinaryFile(zipPath, data) {
|
|
161
|
+
const normalPath = zipPath.replace(/\\/g, '/');
|
|
162
|
+
this.#zip.file(normalPath, data);
|
|
163
|
+
logger.debug(`Queued binary write: ${normalPath}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @private
|
|
168
|
+
* @type {Promise[]}
|
|
169
|
+
*/
|
|
170
|
+
#pendingPromises = [];
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Adds a promise to the pending queue to be awaited before saving.
|
|
174
|
+
* @param {Promise} promise
|
|
175
|
+
*/
|
|
176
|
+
addPendingPromise(promise) {
|
|
177
|
+
this.#pendingPromises.push(promise);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Waits for all pending asynchronous operations (like async ZIP reads/writes) to complete.
|
|
182
|
+
* @returns {Promise<void>}
|
|
183
|
+
*/
|
|
184
|
+
async waitForPendingWrites() {
|
|
185
|
+
if (this.#pendingPromises.length > 0) {
|
|
186
|
+
await Promise.all(this.#pendingPromises);
|
|
187
|
+
this.#pendingPromises = [];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Removes a file from the ZIP archive.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} zipPath - Path to remove.
|
|
195
|
+
*/
|
|
196
|
+
removeFile(zipPath) {
|
|
197
|
+
const normalPath = zipPath.replace(/\\/g, '/');
|
|
198
|
+
this.#zip.remove(normalPath);
|
|
199
|
+
this.#xmlCache.delete(normalPath);
|
|
200
|
+
this.#dirtyFiles.delete(normalPath);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Checks if a file exists in the ZIP archive.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} zipPath - Path to check.
|
|
207
|
+
* @returns {boolean}
|
|
208
|
+
*/
|
|
209
|
+
hasFile(zipPath) {
|
|
210
|
+
const normalPath = zipPath.replace(/\\/g, '/');
|
|
211
|
+
return this.#zip.file(normalPath) !== null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Lists all files in the ZIP archive matching an optional prefix.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} [prefix] - Optional path prefix filter.
|
|
218
|
+
* @returns {string[]} Array of matching file paths.
|
|
219
|
+
*/
|
|
220
|
+
listFiles(prefix = '') {
|
|
221
|
+
return Object.keys(this.#zip.files).filter(
|
|
222
|
+
f => !this.#zip.files[f].dir && f.startsWith(prefix)
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Generates the final ZIP archive as a Buffer.
|
|
228
|
+
* All pending changes are applied before compressing.
|
|
229
|
+
*
|
|
230
|
+
* @returns {Promise<Buffer>} Compressed PPTX as a Buffer.
|
|
231
|
+
*/
|
|
232
|
+
async toBuffer() {
|
|
233
|
+
return this.#zip.generateAsync({
|
|
234
|
+
type: 'nodebuffer',
|
|
235
|
+
compression: 'DEFLATE',
|
|
236
|
+
compressionOptions: { level: 6 },
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Generates the final ZIP archive as a readable Stream.
|
|
242
|
+
*
|
|
243
|
+
* @returns {Promise<NodeJS.ReadableStream>}
|
|
244
|
+
*/
|
|
245
|
+
async toStream() {
|
|
246
|
+
return this.#zip.generateNodeStream({
|
|
247
|
+
type: 'nodebuffer',
|
|
248
|
+
compression: 'DEFLATE',
|
|
249
|
+
compressionOptions: { level: 6 },
|
|
250
|
+
streamFiles: true,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Returns a core document property (from docProps/core.xml).
|
|
256
|
+
*
|
|
257
|
+
* @param {string} key - Property key (e.g., 'dc:title', 'dc:creator').
|
|
258
|
+
* @returns {string|undefined}
|
|
259
|
+
*/
|
|
260
|
+
getCoreProperty(key) {
|
|
261
|
+
return this.#coreProperties.get(key);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Sets a core document property.
|
|
266
|
+
* Updates docProps/core.xml in the ZIP.
|
|
267
|
+
*
|
|
268
|
+
* @param {string} key - Property key.
|
|
269
|
+
* @param {string} value - Property value.
|
|
270
|
+
*/
|
|
271
|
+
setCoreProperty(key, value) {
|
|
272
|
+
this.#coreProperties.set(key, value);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Parses and loads core properties from docProps/core.xml.
|
|
277
|
+
* @private
|
|
278
|
+
*/
|
|
279
|
+
async #loadCoreProperties() {
|
|
280
|
+
const coreXml = await this.readFile('docProps/core.xml');
|
|
281
|
+
if (!coreXml) return;
|
|
282
|
+
|
|
283
|
+
// Simple regex extraction for core properties (lightweight vs full parse)
|
|
284
|
+
const propPattern = /<(dc:[a-zA-Z]+|dcterms:[a-zA-Z]+)[^>]*>([^<]*)<\/\1>/g;
|
|
285
|
+
let match;
|
|
286
|
+
while ((match = propPattern.exec(coreXml)) !== null) {
|
|
287
|
+
this.#coreProperties.set(match[1], match[2]);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Returns the raw JSZip instance (for advanced use cases).
|
|
293
|
+
* @returns {JSZip}
|
|
294
|
+
*/
|
|
295
|
+
get rawZip() {
|
|
296
|
+
return this.#zip;
|
|
297
|
+
}
|
|
298
|
+
}
|