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.
Files changed (35) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +415 -0
  4. package/package.json +83 -0
  5. package/src/cli/commands/build.js +79 -0
  6. package/src/cli/commands/debug.js +46 -0
  7. package/src/cli/commands/extract.js +42 -0
  8. package/src/cli/commands/inspect.js +39 -0
  9. package/src/cli/commands/validate.js +36 -0
  10. package/src/cli/index.js +132 -0
  11. package/src/core/OutputWriter.js +181 -0
  12. package/src/core/PPTXTemplater.js +961 -0
  13. package/src/core/TemplateEngine.js +321 -0
  14. package/src/index.js +43 -0
  15. package/src/managers/ChartManager.js +317 -0
  16. package/src/managers/ContentTypesManager.js +160 -0
  17. package/src/managers/HyperlinkManager.js +451 -0
  18. package/src/managers/MediaManager.js +307 -0
  19. package/src/managers/RelationshipManager.js +401 -0
  20. package/src/managers/SlideManager.js +950 -0
  21. package/src/managers/TableManager.js +416 -0
  22. package/src/managers/ZipManager.js +298 -0
  23. package/src/managers/charts/ChartCacheGenerator.js +156 -0
  24. package/src/managers/charts/ChartParser.js +43 -0
  25. package/src/managers/charts/ChartRelationshipManager.js +33 -0
  26. package/src/managers/charts/ChartWorkbookUpdater.js +130 -0
  27. package/src/parsers/XMLParser.js +291 -0
  28. package/src/templates/blankPptx.js +1 -0
  29. package/src/templates/slideTemplate.js +314 -0
  30. package/src/utils/contentTypesHelper.js +149 -0
  31. package/src/utils/errors.js +129 -0
  32. package/src/utils/idUtils.js +54 -0
  33. package/src/utils/logger.js +113 -0
  34. package/src/utils/relationshipUtils.js +89 -0
  35. 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, '&amp;')
376
+ .replace(/</g, '&lt;')
377
+ .replace(/>/g, '&gt;')
378
+ .replace(/"/g, '&quot;')
379
+ .replace(/'/g, '&apos;');
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
+ }