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,950 @@
1
+ /**
2
+ * @fileoverview SlideManager - Manages individual slide operations.
3
+ *
4
+ * Slides in OpenXML PPTX:
5
+ * ─────────────────────────────────────────────────────────────────
6
+ * Each slide is an XML file stored at ppt/slides/slideN.xml.
7
+ * The slide order is defined in ppt/presentation.xml under:
8
+ * <p:sldIdLst>
9
+ * <p:sldId id="256" r:id="rId2"/> ← slide 1
10
+ * <p:sldId id="257" r:id="rId3"/> ← slide 2
11
+ * </p:sldIdLst>
12
+ *
13
+ * Each slide has:
14
+ * - A content tree: p:sld > p:cSld > p:spTree > [shapes/text/images]
15
+ * - A layout reference via its .rels file (points to slideLayouts/slideLayoutN.xml)
16
+ * - Optional notes slide: ppt/notesSlides/notesSlideN.xml
17
+ * - Optional animation data: embedded in the slide XML itself
18
+ *
19
+ * Shape types in spTree:
20
+ * - p:sp → Text/shape placeholders
21
+ * - p:pic → Images
22
+ * - p:graphicFrame → Charts, tables, SmartArt
23
+ * - p:grpSp → Grouped shapes
24
+ * - p:cxnSp → Connectors
25
+ */
26
+
27
+ import { createLogger } from '../utils/logger.js';
28
+ import { PPTXError, SlideNotFoundError } from '../utils/errors.js';
29
+ import { REL_TYPES } from './RelationshipManager.js';
30
+ import { buildNewSlideXml } from '../templates/slideTemplate.js';
31
+ import { generateUniqueId } from '../utils/idUtils.js';
32
+ import { remapRelationshipIds } from '../utils/relationshipUtils.js';
33
+
34
+ const logger = createLogger('SlideManager');
35
+
36
+ /** MIME type for PPTX slide parts. */
37
+ const SLIDE_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml';
38
+
39
+ /**
40
+ * @typedef {Object} SlideInfo
41
+ * @property {number} index - 1-based slide number.
42
+ * @property {string} zipPath - Path within the ZIP.
43
+ * @property {string} relationshipId - rId in presentation.xml.rels.
44
+ * @property {string} slideId - Unique slide ID from presentation.xml.
45
+ * @property {string[]} tags - Custom tags assigned via tagSlide().
46
+ * @property {string} title - Slide title (extracted from title placeholder).
47
+ */
48
+
49
+ /**
50
+ * @class SlideManager
51
+ * @description Manages slide loading, ordering, modification, and creation.
52
+ */
53
+ export class SlideManager {
54
+ /** @private @type {XMLParser} */
55
+ #xmlParser;
56
+ /** @private @type {RelationshipManager} */
57
+ #relationshipManager;
58
+ /** @private @type {ContentTypesManager} */
59
+ #contentTypesManager;
60
+ /** @private @type {ZipManager} */
61
+ #zipManager;
62
+
63
+ /**
64
+ * Slide registry: maps 1-based index → SlideInfo.
65
+ * @private @type {Map<number, SlideInfo>}
66
+ */
67
+ #slides = new Map();
68
+
69
+ /**
70
+ * Raw XML cache: maps zipPath → XML string.
71
+ * @private @type {Map<string, string>}
72
+ */
73
+ #slideXmlCache = new Map();
74
+
75
+ /**
76
+ * Custom tags: maps tag → array of 1-based indices.
77
+ * @private @type {Map<string, number[]>}
78
+ */
79
+ #tags = new Map();
80
+
81
+ /**
82
+ * Parsed presentation.xml object (cached for modification).
83
+ * @private @type {Object}
84
+ */
85
+ #presentationObj = null;
86
+
87
+ /**
88
+ * @param {XMLParser} xmlParser
89
+ * @param {RelationshipManager} relationshipManager
90
+ * @param {ContentTypesManager} contentTypesManager
91
+ */
92
+ constructor(xmlParser, relationshipManager, contentTypesManager) {
93
+ this.#xmlParser = xmlParser;
94
+ this.#relationshipManager = relationshipManager;
95
+ this.#contentTypesManager = contentTypesManager;
96
+ }
97
+
98
+ /**
99
+ * Initializes by reading presentation.xml and discovering all slides.
100
+ *
101
+ * @param {ZipManager} zipManager
102
+ * @returns {Promise<void>}
103
+ */
104
+ async initialize(zipManager) {
105
+ this.#zipManager = zipManager;
106
+ const presentationXml = await zipManager.readFile('ppt/presentation.xml');
107
+
108
+ if (!presentationXml) {
109
+ // If no presentation.xml, create a blank one
110
+ await this.#initializeBlankPresentation();
111
+ return;
112
+ }
113
+
114
+ this.#presentationObj = this.#xmlParser.parse(presentationXml, 'presentation.xml');
115
+ await this.#discoverSlides(zipManager);
116
+ }
117
+
118
+ /**
119
+ * Discovers all slides from presentation.xml and caches their info.
120
+ * @private
121
+ */
122
+ async #discoverSlides(zipManager) {
123
+ const rels = this.#relationshipManager.getRelationships('ppt/presentation.xml');
124
+ const slideRels = rels.filter(r => r.type === REL_TYPES.SLIDE);
125
+
126
+ // Get slide order from presentation.xml sldIdLst
127
+ const sldIdList = this.#xmlParser.findAll(this.#presentationObj, 'p:presentation.p:sldIdLst.p:sldId');
128
+
129
+ // Map rId → slide info from sldIdLst
130
+ const rIdToSlideId = new Map();
131
+ for (const sldId of sldIdList) {
132
+ const rId = sldId['@_r:id'];
133
+ const slideId = sldId['@_id'];
134
+ if (rId) rIdToSlideId.set(rId, slideId);
135
+ }
136
+
137
+ // Attempt to read slide titles from docProps/app.xml to preserve them
138
+ let slideTitles = [];
139
+ try {
140
+ const appXml = await zipManager.readFile('docProps/app.xml');
141
+ if (appXml) {
142
+ const appObj = this.#xmlParser.parse(appXml, 'app.xml');
143
+ const lpstrs = appObj?.Properties?.TitlesOfParts?.['vt:vector']?.['vt:lpstr'];
144
+ if (lpstrs) {
145
+ const allLpstrs = Array.isArray(lpstrs) ? lpstrs : [lpstrs];
146
+ // Slide titles are usually the last N items where N = slide count
147
+ // We take the last slideRels.length items
148
+ slideTitles = allLpstrs.slice(-slideRels.length);
149
+ }
150
+ }
151
+ } catch (e) {
152
+ logger.warn('Failed to parse app.xml for slide titles', e);
153
+ }
154
+
155
+ // Build ordered slide list
156
+ let slideIndex = 1;
157
+ for (const sldId of sldIdList) {
158
+ const rId = sldId['@_r:id'];
159
+ const slideRel = slideRels.find(r => r.id === rId);
160
+ if (!slideRel) continue;
161
+
162
+ // Resolve absolute path from relative target
163
+ const zipPath = this.#relationshipManager.resolveTarget('ppt/presentation.xml', slideRel.target);
164
+
165
+ const slideInfo = {
166
+ index: slideIndex,
167
+ zipPath,
168
+ relationshipId: rId,
169
+ slideId: rIdToSlideId.get(rId) || String(256 + slideIndex),
170
+ tags: [],
171
+ title: slideTitles[slideIndex - 1] || '',
172
+ };
173
+
174
+ this.#slides.set(slideIndex, slideInfo);
175
+ slideIndex++;
176
+ }
177
+
178
+ logger.debug(`Discovered ${this.#slides.size} slides`);
179
+ }
180
+
181
+ /**
182
+ * Returns the total number of slides.
183
+ * @returns {number}
184
+ */
185
+ get slideCount() {
186
+ return this.#slides.size;
187
+ }
188
+
189
+ /**
190
+ * Returns all 1-based slide indices.
191
+ * @returns {number[]}
192
+ */
193
+ getAllSlideIndices() {
194
+ return Array.from(this.#slides.keys()).sort((a, b) => a - b);
195
+ }
196
+
197
+ /**
198
+ * Returns info objects for all slides.
199
+ * @returns {SlideInfo[]}
200
+ */
201
+ getAllSlideInfo() {
202
+ return this.getAllSlideIndices().map(i => this.#slides.get(i));
203
+ }
204
+
205
+ /**
206
+ * Resolves a string/number ref to an array of 1-based slide indices.
207
+ * - If number: returns [number]
208
+ * - If string: looks up tag registry
209
+ *
210
+ * @param {number|string} ref
211
+ * @returns {number[]}
212
+ */
213
+ resolveSlideRef(ref) {
214
+ if (typeof ref === 'number') {
215
+ this.#assertSlideExists(ref);
216
+ return [ref];
217
+ }
218
+ const taggedIndices = this.#tags.get(ref);
219
+ if (!taggedIndices || taggedIndices.length === 0) {
220
+ throw new SlideNotFoundError(`No slides found with tag: "${ref}"`);
221
+ }
222
+ return taggedIndices;
223
+ }
224
+
225
+ /**
226
+ * Gets the raw XML string for a slide.
227
+ * Loads from ZIP on first access, then returns from cache.
228
+ *
229
+ * @param {number} slideIndex - 1-based index.
230
+ * @returns {string} Slide XML content.
231
+ */
232
+ getSlideXml(slideIndex) {
233
+ this.#assertSlideExists(slideIndex);
234
+ const info = this.#slides.get(slideIndex);
235
+
236
+ if (this.#slideXmlCache.has(info.zipPath)) {
237
+ return this.#slideXmlCache.get(info.zipPath);
238
+ }
239
+
240
+ // This is sync because we pre-load; async callers should use getSlideXmlAsync
241
+ throw new PPTXError(`Slide ${slideIndex} XML not pre-loaded. Use getSlideXmlAsync().`);
242
+ }
243
+
244
+ /**
245
+ * Async version of getSlideXml — loads from ZIP if not cached.
246
+ *
247
+ * @param {number} slideIndex - 1-based index.
248
+ * @returns {Promise<string>}
249
+ */
250
+ async getSlideXmlAsync(slideIndex) {
251
+ this.#assertSlideExists(slideIndex);
252
+ const info = this.#slides.get(slideIndex);
253
+
254
+ if (!this.#slideXmlCache.has(info.zipPath)) {
255
+ const xml = await this.#zipManager.readFile(info.zipPath);
256
+ if (!xml) throw new SlideNotFoundError(`Slide ${slideIndex} XML not found at ${info.zipPath}`);
257
+ this.#slideXmlCache.set(info.zipPath, xml);
258
+ }
259
+
260
+ return this.#slideXmlCache.get(info.zipPath);
261
+ }
262
+
263
+ /**
264
+ * Sets (replaces) the XML for a slide and marks it as dirty.
265
+ *
266
+ * @param {number} slideIndex - 1-based index.
267
+ * @param {string} xml - New XML content.
268
+ */
269
+ setSlideXml(slideIndex, xml) {
270
+ this.#assertSlideExists(slideIndex);
271
+ const info = this.#slides.get(slideIndex);
272
+ this.#slideXmlCache.set(info.zipPath, xml);
273
+ this.#zipManager.writeFile(info.zipPath, xml);
274
+ }
275
+
276
+ /**
277
+ * Tags a slide with a custom string identifier.
278
+ *
279
+ * @param {number} slideIndex - 1-based index.
280
+ * @param {string} tag - Tag string.
281
+ */
282
+ tagSlide(slideIndex, tag) {
283
+ this.#assertSlideExists(slideIndex);
284
+ const info = this.#slides.get(slideIndex);
285
+ if (!info.tags.includes(tag)) info.tags.push(tag);
286
+
287
+ if (!this.#tags.has(tag)) this.#tags.set(tag, []);
288
+ const tagList = this.#tags.get(tag);
289
+ if (!tagList.includes(slideIndex)) tagList.push(slideIndex);
290
+ }
291
+
292
+ /**
293
+ * Gets the SlideInfo for a slide.
294
+ *
295
+ * @param {number} slideIndex - 1-based index.
296
+ * @returns {SlideInfo}
297
+ */
298
+ getSlideInfo(slideIndex) {
299
+ this.#assertSlideExists(slideIndex);
300
+ return this.#slides.get(slideIndex);
301
+ }
302
+
303
+ /**
304
+ * Adds a completely new slide to the presentation.
305
+ * Creates the XML file, adds it to presentation.xml, and registers relationships.
306
+ *
307
+ * @param {NewSlideOptions} options
308
+ * @param {RelationshipManager} relationshipManager
309
+ * @param {MediaManager} mediaManager
310
+ */
311
+ addNewSlide(options, relationshipManager, mediaManager) {
312
+ const newIndex = this.#slides.size + 1;
313
+ let nextFileIndex = 1;
314
+ while (this.#zipManager.hasFile(`ppt/slides/slide${nextFileIndex}.xml`)) {
315
+ nextFileIndex++;
316
+ }
317
+ const slideFileName = `slide${nextFileIndex}.xml`;
318
+ const slideZipPath = `ppt/slides/${slideFileName}`;
319
+
320
+ // Find the first available layout to reference
321
+ const layoutRels = relationshipManager.getRelationshipsByType('ppt/presentation.xml', REL_TYPES.SLIDE_MASTER);
322
+ const masterTarget = layoutRels[0]?.target || '../slideMasters/slideMaster1.xml';
323
+
324
+ // Generate the slide XML
325
+ const slideXml = buildNewSlideXml(options, newIndex);
326
+
327
+ // Write the slide XML to the ZIP
328
+ this.#zipManager.writeFile(slideZipPath, slideXml);
329
+ this.#slideXmlCache.set(slideZipPath, slideXml);
330
+
331
+ // Add slide relationship to presentation.xml.rels
332
+ const rId = relationshipManager.addRelationship(
333
+ 'ppt/presentation.xml',
334
+ REL_TYPES.SLIDE,
335
+ `slides/${slideFileName}`
336
+ );
337
+
338
+ // Add slide layout relationship to the new slide's .rels
339
+ // Reference the first available layout
340
+ const layoutRelsAll = this.#zipManager.listFiles('ppt/slideLayouts/').filter(f => f.endsWith('.xml'));
341
+ const firstLayout = layoutRelsAll[0] || 'ppt/slideLayouts/slideLayout1.xml';
342
+ const relativeLayoutPath = `../slideLayouts/${firstLayout.split('/').pop()}`;
343
+ relationshipManager.addRelationship(slideZipPath, REL_TYPES.SLIDE_LAYOUT, relativeLayoutPath);
344
+
345
+ // Generate a unique slide ID
346
+ const existingIds = Array.from(this.#slides.values()).map(s => parseInt(s.slideId, 10));
347
+ const maxId = existingIds.length > 0 ? Math.max(...existingIds) : 255;
348
+ const newSlideId = String(maxId + 1);
349
+
350
+ const slideInfo = {
351
+ index: newIndex,
352
+ zipPath: slideZipPath,
353
+ relationshipId: rId,
354
+ slideId: newSlideId,
355
+ tags: [],
356
+ title: options.title || '',
357
+ };
358
+
359
+ this.#slides.set(newIndex, slideInfo);
360
+
361
+ // Update presentation.xml sldIdLst
362
+ this.#addSlideToPresentation(rId, newSlideId);
363
+
364
+ // Update [Content_Types].xml
365
+ this.#registerSlideContentType(slideFileName);
366
+
367
+ logger.debug(`Added new slide ${newIndex} at ${slideZipPath}`);
368
+ }
369
+
370
+ /**
371
+ * Clones an existing slide, duplicating its XML and relationships.
372
+ *
373
+ * @param {number} sourceIndex - 1-based source slide number.
374
+ * @param {number} [atPosition] - Insert position (1-based). Default: append.
375
+ * @param {RelationshipManager} relationshipManager
376
+ */
377
+ cloneSlide(sourceIndex, atPosition, relationshipManager) {
378
+ this.#assertSlideExists(sourceIndex);
379
+ const sourceInfo = this.#slides.get(sourceIndex);
380
+
381
+ const newIndex = this.#slides.size + 1;
382
+ let nextFileIndex = 1;
383
+ while (this.#zipManager.hasFile(`ppt/slides/slide${nextFileIndex}.xml`)) {
384
+ nextFileIndex++;
385
+ }
386
+ const slideFileName = `slide${nextFileIndex}.xml`;
387
+ const slideZipPath = `ppt/slides/${slideFileName}`;
388
+
389
+ // Copy the source XML
390
+ let sourceXml = this.getSlideXml(sourceIndex);
391
+
392
+ // Copy relationships from source slide (excluding notes, which are slide-specific)
393
+ const idMap = relationshipManager.copyRelationships(
394
+ sourceInfo.zipPath,
395
+ slideZipPath,
396
+ [REL_TYPES.NOTES_SLIDE]
397
+ );
398
+
399
+ // Remap relationship IDs in the cloned XML to match the new targets
400
+ sourceXml = remapRelationshipIds(sourceXml, idMap);
401
+
402
+ this.#zipManager.writeFile(slideZipPath, sourceXml);
403
+ this.#slideXmlCache.set(slideZipPath, sourceXml);
404
+
405
+ // Add to presentation.xml
406
+ const rId = relationshipManager.addRelationship(
407
+ 'ppt/presentation.xml',
408
+ REL_TYPES.SLIDE,
409
+ `slides/${slideFileName}`
410
+ );
411
+
412
+ const existingIds = Array.from(this.#slides.values()).map(s => parseInt(s.slideId, 10));
413
+ const maxId = Math.max(...existingIds);
414
+ const newSlideId = String(maxId + 1);
415
+
416
+ const slideInfo = {
417
+ index: newIndex,
418
+ zipPath: slideZipPath,
419
+ relationshipId: rId,
420
+ slideId: newSlideId,
421
+ tags: [...sourceInfo.tags],
422
+ title: sourceInfo.title,
423
+ };
424
+
425
+ this.#slides.set(newIndex, slideInfo);
426
+ this.#addSlideToPresentation(rId, newSlideId);
427
+ this.#registerSlideContentType(slideFileName);
428
+
429
+ logger.debug(`Cloned slide ${sourceIndex} to new slide ${newIndex}`);
430
+ }
431
+
432
+ /**
433
+ * Removes a slide from the presentation.
434
+ *
435
+ * @param {number} slideIndex - 1-based index.
436
+ */
437
+ removeSlide(slideIndex) {
438
+ this.#assertSlideExists(slideIndex);
439
+ const info = this.#slides.get(slideIndex);
440
+
441
+ // Remove from ZIP
442
+ this.#zipManager.removeFile(info.zipPath);
443
+
444
+ // Remove its relationships file
445
+ const relsFileName = info.zipPath.split('/').pop() + '.rels';
446
+ this.#zipManager.removeFile(`ppt/slides/_rels/${relsFileName}`);
447
+
448
+ // Remove from cache
449
+ this.#slideXmlCache.delete(info.zipPath);
450
+
451
+ // Remove relationship from presentation.xml
452
+ this.#relationshipManager.removeRelationship('ppt/presentation.xml', info.relationshipId);
453
+
454
+ // Remove content type from [Content_Types].xml
455
+ this.#contentTypesManager.removeOverride(info.zipPath);
456
+
457
+ // Remove from slides map and reindex
458
+ this.#slides.delete(slideIndex);
459
+ this.#reindexSlides();
460
+
461
+ // Update presentation.xml
462
+ this.#removeSlideFromPresentation(info.slideId);
463
+
464
+ logger.debug(`Removed slide ${slideIndex}`);
465
+ }
466
+
467
+ /**
468
+ * Reorders slides to match the given order array.
469
+ *
470
+ * @param {number[]} order - Array of 1-based slide numbers in desired order.
471
+ */
472
+ reorderSlides(order) {
473
+ const current = this.getAllSlideIndices();
474
+ if (order.length !== current.length) {
475
+ throw new PPTXError(`reorderSlides: order array length (${order.length}) must match slide count (${current.length})`);
476
+ }
477
+
478
+ const slidesCopy = new Map(this.#slides);
479
+ this.#slides.clear();
480
+
481
+ order.forEach((oldIndex, newPos) => {
482
+ const info = slidesCopy.get(oldIndex);
483
+ if (!info) throw new SlideNotFoundError(`Slide ${oldIndex} not found`);
484
+ info.index = newPos + 1;
485
+ this.#slides.set(newPos + 1, info);
486
+ });
487
+
488
+ // Rebuild presentation sldIdLst
489
+ this.rebuildPresentationSlideOrder();
490
+ logger.debug(`Reordered slides: [${order.join(', ')}]`);
491
+ }
492
+
493
+ /**
494
+ * Resolves a slide reference (index, slideId string, or tag string) to SlideInfo.
495
+ *
496
+ * @param {number|string} slideRef
497
+ * @returns {SlideInfo|null}
498
+ */
499
+ resolveSlideInfo(slideRef) {
500
+ let index;
501
+ if (typeof slideRef === 'number') {
502
+ index = slideRef;
503
+ } else {
504
+ // 1. Try finding by slideId string
505
+ for (const info of this.#slides.values()) {
506
+ if (info.slideId === String(slideRef)) {
507
+ return info;
508
+ }
509
+ }
510
+ // 2. Try finding by tag
511
+ try {
512
+ const indices = this.resolveSlideRef(slideRef);
513
+ if (indices && indices.length > 0) {
514
+ index = indices[0];
515
+ }
516
+ } catch (e) {
517
+ // Fallback: parse as slide index
518
+ const parsedNum = parseInt(slideRef, 10);
519
+ if (!isNaN(parsedNum)) {
520
+ index = parsedNum;
521
+ }
522
+ }
523
+ }
524
+
525
+ if (index !== undefined) {
526
+ return this.#slides.get(index) || null;
527
+ }
528
+ return null;
529
+ }
530
+
531
+ /**
532
+ * Imports a slide from another PPTX template (PPTXTemplater instance).
533
+ * Preserves all relationships: layouts, media, charts, workbooks, etc.
534
+ *
535
+ * @param {PPTXTemplater} sourceEngine - Source presentation.
536
+ * @param {number|string} slideRef - Slide index (1-based), slide ID, or custom tag.
537
+ * @param {MediaManager} mediaManager - Destination media manager.
538
+ * @returns {Promise<number>} Index of the imported slide.
539
+ */
540
+ async importSlide(sourceEngine, slideRef, mediaManager) {
541
+ const sourceSlideManager = sourceEngine.slideManager;
542
+ const sourceRelManager = sourceEngine.relationshipManager;
543
+ const sourceZip = sourceEngine.zipManager;
544
+
545
+ const sourceSlideInfo = sourceSlideManager.resolveSlideInfo(slideRef);
546
+ if (!sourceSlideInfo) {
547
+ throw new SlideNotFoundError(`Source slide "${slideRef}" not found`);
548
+ }
549
+
550
+ const newIndex = this.#slides.size + 1;
551
+ let nextFileIndex = 1;
552
+ while (this.#zipManager.hasFile(`ppt/slides/slide${nextFileIndex}.xml`)) {
553
+ nextFileIndex++;
554
+ }
555
+ const slideFileName = `slide${nextFileIndex}.xml`;
556
+ const slideZipPath = `ppt/slides/${slideFileName}`;
557
+
558
+ // Read the source slide's XML
559
+ let slideXml = await sourceSlideManager.getSlideXmlAsync(sourceSlideInfo.index);
560
+
561
+ // Get relationships from the source slide
562
+ const sourceRels = sourceRelManager.getRelationships(sourceSlideInfo.zipPath);
563
+
564
+ // Map to track old rId -> new rId in the destination slide's .rels file
565
+ const idMap = new Map();
566
+
567
+ const EXT_TO_MIME_LOCAL = {
568
+ png: 'image/png',
569
+ jpg: 'image/jpeg',
570
+ jpeg: 'image/jpeg',
571
+ gif: 'image/gif',
572
+ webp: 'image/webp',
573
+ bmp: 'image/bmp',
574
+ xml: 'application/xml',
575
+ rels: 'application/vnd.openxmlformats-package.relationships+xml',
576
+ };
577
+
578
+ for (const rel of sourceRels) {
579
+ const resolvedTarget = sourceRelManager.resolveTarget(sourceSlideInfo.zipPath, rel.target);
580
+
581
+ if (rel.type === REL_TYPES.SLIDE_LAYOUT) {
582
+ // Map to destination's slide layout.
583
+ const layoutFileName = rel.target.split('/').pop();
584
+ const destLayoutPath = `ppt/slideLayouts/${layoutFileName}`;
585
+
586
+ let targetLayout = `../slideLayouts/${layoutFileName}`;
587
+ if (!this.#zipManager.hasFile(destLayoutPath)) {
588
+ // Find first available layout
589
+ const layoutFiles = this.#zipManager.listFiles('ppt/slideLayouts/').filter(f => f.endsWith('.xml'));
590
+ if (layoutFiles.length > 0) {
591
+ targetLayout = `../slideLayouts/${layoutFiles[0].split('/').pop()}`;
592
+ } else {
593
+ targetLayout = '../slideLayouts/slideLayout1.xml';
594
+ }
595
+ }
596
+
597
+ const newRId = this.#relationshipManager.addRelationship(slideZipPath, rel.type, targetLayout);
598
+ idMap.set(rel.id, newRId);
599
+
600
+ } else if (rel.type === REL_TYPES.IMAGE) {
601
+ // Copy media file
602
+ const mediaBytes = await sourceZip.readBinaryFile(resolvedTarget);
603
+ if (mediaBytes) {
604
+ const destMediaZipPath = await mediaManager.embedImage(mediaBytes);
605
+ const relativeMediaTarget = `../media/${destMediaZipPath.split('/').pop()}`;
606
+ const newRId = this.#relationshipManager.addRelationship(slideZipPath, rel.type, relativeMediaTarget);
607
+ idMap.set(rel.id, newRId);
608
+ }
609
+
610
+ } else if (rel.type === REL_TYPES.CHART) {
611
+ // Copy chart XML and its relationships
612
+ const chartXml = await sourceZip.readFile(resolvedTarget);
613
+ if (chartXml) {
614
+ const chartRels = sourceRelManager.getRelationships(resolvedTarget);
615
+
616
+ let nextChartId = 1;
617
+ while (this.#zipManager.hasFile(`ppt/charts/chart${nextChartId}.xml`)) {
618
+ nextChartId++;
619
+ }
620
+ const destChartZipPath = `ppt/charts/chart${nextChartId}.xml`;
621
+ const chartFileName = `chart${nextChartId}.xml`;
622
+
623
+ // Handle workbook packages within charts
624
+ for (const chartRel of chartRels) {
625
+ const resolvedChartTarget = sourceRelManager.resolveTarget(resolvedTarget, chartRel.target);
626
+ const workbookBytes = await sourceZip.readBinaryFile(resolvedChartTarget);
627
+
628
+ if (workbookBytes) {
629
+ const workbookFileName = resolvedChartTarget.split('/').pop();
630
+ let nextEmbedId = 1;
631
+ let destWorkbookZipPath = `ppt/embeddings/Microsoft_Excel_Worksheet${nextEmbedId}.xlsx`;
632
+ if (workbookFileName.endsWith('.bin')) {
633
+ destWorkbookZipPath = `ppt/embeddings/oleObject${nextEmbedId}.bin`;
634
+ }
635
+ while (this.#zipManager.hasFile(destWorkbookZipPath)) {
636
+ nextEmbedId++;
637
+ destWorkbookZipPath = workbookFileName.endsWith('.bin')
638
+ ? `ppt/embeddings/oleObject${nextEmbedId}.bin`
639
+ : `ppt/embeddings/Microsoft_Excel_Worksheet${nextEmbedId}.xlsx`;
640
+ }
641
+
642
+ this.#zipManager.writeBinaryFile(destWorkbookZipPath, workbookBytes);
643
+
644
+ const workbookContentType = workbookFileName.endsWith('.bin')
645
+ ? 'application/vnd.ms-office.activeX'
646
+ : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
647
+ this.#contentTypesManager.addOverride(destWorkbookZipPath, workbookContentType);
648
+
649
+ const relativeWorkbookPath = `../embeddings/${destWorkbookZipPath.split('/').pop()}`;
650
+ this.#relationshipManager.addRelationship(destChartZipPath, chartRel.type, relativeWorkbookPath);
651
+ }
652
+ }
653
+
654
+ this.#zipManager.writeFile(destChartZipPath, chartXml);
655
+ this.#contentTypesManager.addOverride(destChartZipPath, 'application/vnd.openxmlformats-officedocument.drawingml.chart+xml');
656
+
657
+ const relativeChartPath = `../charts/${chartFileName}`;
658
+ const newRId = this.#relationshipManager.addRelationship(slideZipPath, rel.type, relativeChartPath);
659
+ idMap.set(rel.id, newRId);
660
+ }
661
+
662
+ } else if (rel.type === REL_TYPES.HYPERLINK) {
663
+ const newRId = this.#relationshipManager.addRelationship(
664
+ slideZipPath,
665
+ rel.type,
666
+ rel.target,
667
+ rel.targetMode
668
+ );
669
+ idMap.set(rel.id, newRId);
670
+
671
+ } else {
672
+ // Fallback for notes, themes, styles or custom XML
673
+ if (rel.target && !rel.target.startsWith('http')) {
674
+ const targetBytes = await sourceZip.readBinaryFile(resolvedTarget);
675
+ if (targetBytes && !this.#zipManager.hasFile(resolvedTarget)) {
676
+ this.#zipManager.writeBinaryFile(resolvedTarget, targetBytes);
677
+ const ext = resolvedTarget.split('.').pop().toLowerCase();
678
+ const mime = EXT_TO_MIME_LOCAL[ext] || 'application/octet-stream';
679
+ this.#contentTypesManager.addDefault(ext, mime);
680
+ }
681
+ }
682
+ const newRId = this.#relationshipManager.addRelationship(slideZipPath, rel.type, rel.target, rel.targetMode);
683
+ idMap.set(rel.id, newRId);
684
+ }
685
+ }
686
+
687
+ // Remap all relationship IDs inside the imported slide XML
688
+ slideXml = remapRelationshipIds(slideXml, idMap);
689
+
690
+ // Save the remapped slide XML to ZIP
691
+ this.#zipManager.writeFile(slideZipPath, slideXml);
692
+ this.#slideXmlCache.set(slideZipPath, slideXml);
693
+
694
+ // Generate unique Slide ID
695
+ const existingIds = Array.from(this.#slides.values()).map(s => parseInt(s.slideId, 10));
696
+ const maxId = existingIds.length > 0 ? Math.max(...existingIds) : 255;
697
+ const newSlideId = String(maxId + 1);
698
+
699
+ // Add relationship from presentation.xml
700
+ const rId = this.#relationshipManager.addRelationship(
701
+ 'ppt/presentation.xml',
702
+ REL_TYPES.SLIDE,
703
+ `slides/${slideFileName}`
704
+ );
705
+
706
+ const slideInfo = {
707
+ index: newIndex,
708
+ zipPath: slideZipPath,
709
+ relationshipId: rId,
710
+ slideId: newSlideId,
711
+ tags: [...sourceSlideInfo.tags],
712
+ title: sourceSlideInfo.title || '',
713
+ };
714
+
715
+ this.#slides.set(newIndex, slideInfo);
716
+
717
+ // Add entry in presentation.xml sldIdLst
718
+ this.#addSlideToPresentation(rId, newSlideId);
719
+
720
+ // Register slide in content types
721
+ this.#registerSlideContentType(slideFileName);
722
+
723
+ logger.debug(`Successfully imported slide "${slideRef}" to index ${newIndex}`);
724
+ return newIndex;
725
+ }
726
+
727
+ /**
728
+ * Exports a subset of slides to a new PPTXTemplater.
729
+ *
730
+ * @param {number[]} slideIndices - 1-based slide indices to export.
731
+ * @param {PPTXTemplater} sourceEngine - Current PPTXTemplater instance.
732
+ * @returns {Promise<PPTXTemplater>}
733
+ */
734
+ async exportSlides(slideIndices, sourceEngine) {
735
+ // Lazy import to avoid circular dep
736
+ const { PPTXTemplater } = await import('../core/PPTXTemplater.js');
737
+
738
+ // Create a blank new PPTX
739
+ const newEngine = await PPTXTemplater.create();
740
+
741
+ // Remove the default slides from the blank template to avoid orphans
742
+ const defaultSlides = newEngine.slideManager.getAllSlideIndices();
743
+ for (const dIdx of defaultSlides.reverse()) {
744
+ newEngine.slideManager.removeSlide(dIdx);
745
+ }
746
+
747
+ // Copy selected slides into the new engine
748
+ for (const idx of slideIndices) {
749
+ this.#assertSlideExists(idx);
750
+ await newEngine.slideManager.importSlide(sourceEngine, idx, newEngine.mediaManager);
751
+ }
752
+
753
+ return newEngine;
754
+ }
755
+
756
+ /**
757
+ * Validates the slide structure.
758
+ *
759
+ * @param {RelationshipManager} relationshipManager
760
+ * @param {ZipManager} zipManager
761
+ * @returns {ValidationResult}
762
+ */
763
+ validateStructure(relationshipManager, zipManager) {
764
+ const errors = [];
765
+ const warnings = [];
766
+
767
+ for (const [index, info] of this.#slides) {
768
+ if (!zipManager.hasFile(info.zipPath)) {
769
+ errors.push(`Slide ${index}: XML file missing at ${info.zipPath}`);
770
+ }
771
+
772
+ const rels = relationshipManager.getRelationships(info.zipPath);
773
+ const layoutRel = rels.find(r => r.type === REL_TYPES.SLIDE_LAYOUT);
774
+ if (!layoutRel) {
775
+ warnings.push(`Slide ${index}: No slide layout relationship found`);
776
+ }
777
+ }
778
+
779
+ return {
780
+ valid: errors.length === 0,
781
+ errors,
782
+ warnings,
783
+ };
784
+ }
785
+
786
+ /**
787
+ * Pre-loads all slide XML into cache (for bulk operations).
788
+ * @returns {Promise<void>}
789
+ */
790
+ async preloadAll() {
791
+ await Promise.all(
792
+ this.getAllSlideIndices().map(i => this.getSlideXmlAsync(i))
793
+ );
794
+ }
795
+
796
+ /**
797
+ * Updates the presentation.xml sldIdLst with a new slide entry.
798
+ * @private
799
+ */
800
+ #addSlideToPresentation(rId, slideId) {
801
+ if (!this.#presentationObj) return;
802
+
803
+ let sldIdLst = this.#xmlParser.getNode(this.#presentationObj, 'p:presentation.p:sldIdLst');
804
+ if (!sldIdLst) {
805
+ this.#xmlParser.setNode(this.#presentationObj, 'p:presentation.p:sldIdLst', { 'p:sldId': [] });
806
+ sldIdLst = this.#xmlParser.getNode(this.#presentationObj, 'p:presentation.p:sldIdLst');
807
+ }
808
+
809
+ if (!sldIdLst['p:sldId']) sldIdLst['p:sldId'] = [];
810
+ if (!Array.isArray(sldIdLst['p:sldId'])) {
811
+ sldIdLst['p:sldId'] = [sldIdLst['p:sldId']];
812
+ }
813
+
814
+ sldIdLst['p:sldId'].push({ '@_id': slideId, '@_r:id': rId });
815
+ this.#flushPresentation();
816
+ }
817
+
818
+ /**
819
+ * Removes a slide from presentation.xml sldIdLst.
820
+ * @private
821
+ */
822
+ #removeSlideFromPresentation(slideId) {
823
+ if (!this.#presentationObj) return;
824
+ const sldIdLst = this.#xmlParser.getNode(this.#presentationObj, 'p:presentation.p:sldIdLst');
825
+ if (!sldIdLst?.['p:sldId']) return;
826
+
827
+ sldIdLst['p:sldId'] = (Array.isArray(sldIdLst['p:sldId'])
828
+ ? sldIdLst['p:sldId']
829
+ : [sldIdLst['p:sldId']]
830
+ ).filter(s => s['@_id'] !== slideId);
831
+
832
+ // Also remove from any PowerPoint sections
833
+ this.#removeSlideFromSections(slideId);
834
+
835
+ this.#flushPresentation();
836
+ }
837
+
838
+ /**
839
+ * Removes a slide ID from all sections in presentation.xml.
840
+ * @private
841
+ * @param {string} slideId - Unique slide ID.
842
+ */
843
+ #removeSlideFromSections(slideId) {
844
+ if (!this.#presentationObj) return;
845
+ const extLst = this.#xmlParser.getNode(this.#presentationObj, 'p:presentation.p:extLst');
846
+ if (!extLst?.['p:ext']) return;
847
+
848
+ const exts = Array.isArray(extLst['p:ext']) ? extLst['p:ext'] : [extLst['p:ext']];
849
+ for (const ext of exts) {
850
+ const sectionLst = ext['p14:sectionLst'];
851
+ if (!sectionLst?.['p14:section']) continue;
852
+
853
+ const sections = sectionLst['p14:section']; // Guaranteed to be array by XMLParser config
854
+
855
+ for (const section of sections) {
856
+ const sldIdLst = section['p14:sldIdLst'];
857
+ if (!sldIdLst?.['p14:sldId']) continue;
858
+
859
+ const sldIds = sldIdLst['p14:sldId']; // Guaranteed to be array by XMLParser config
860
+ const targetIdStr = String(slideId);
861
+ const filtered = sldIds.filter(s => String(s['@_id']) !== targetIdStr);
862
+
863
+ if (filtered.length !== sldIds.length) {
864
+ logger.debug(`Removing slide ${targetIdStr} from section "${section['@_name']}"`);
865
+ section['p14:sldIdLst']['p14:sldId'] = filtered;
866
+ }
867
+ }
868
+ }
869
+ }
870
+
871
+ /**
872
+ * Rebuilds presentation.xml sldIdLst in the current slide order.
873
+ */
874
+ rebuildPresentationSlideOrder() {
875
+ if (!this.#presentationObj) return;
876
+ const sldIdLst = this.#xmlParser.getNode(this.#presentationObj, 'p:presentation.p:sldIdLst');
877
+ if (!sldIdLst) return;
878
+
879
+ const ordered = this.getAllSlideIndices().map(i => {
880
+ const info = this.#slides.get(i);
881
+ return { '@_id': info.slideId, '@_r:id': info.relationshipId };
882
+ });
883
+
884
+ sldIdLst['p:sldId'] = ordered;
885
+ this.#flushPresentation();
886
+ }
887
+
888
+ /**
889
+ * Re-indexes slide map after a removal.
890
+ * @private
891
+ */
892
+ #reindexSlides() {
893
+ const sorted = Array.from(this.#slides.entries()).sort(([a], [b]) => a - b);
894
+ this.#slides.clear();
895
+ sorted.forEach(([, info], i) => {
896
+ info.index = i + 1;
897
+ this.#slides.set(i + 1, info);
898
+ });
899
+ }
900
+
901
+ /**
902
+ * Registers a new slide in [Content_Types].xml.
903
+ * @private
904
+ */
905
+ #registerSlideContentType(slideFileName) {
906
+ this.#contentTypesManager.addOverride(`ppt/slides/${slideFileName}`, SLIDE_CONTENT_TYPE);
907
+ }
908
+
909
+ /**
910
+ * Writes the updated presentation.xml back to the ZIP.
911
+ * @private
912
+ */
913
+ #flushPresentation() {
914
+ if (!this.#presentationObj || !this.#zipManager) return;
915
+ const declaration = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
916
+ const xml = this.#xmlParser.build(this.#presentationObj, declaration);
917
+ this.#zipManager.writeFile('ppt/presentation.xml', xml);
918
+ }
919
+
920
+ /**
921
+ * Initializes a blank presentation.xml structure.
922
+ * @private
923
+ */
924
+ async #initializeBlankPresentation() {
925
+ // Used when creating from scratch
926
+ this.#presentationObj = {
927
+ 'p:presentation': {
928
+ '@_xmlns:a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
929
+ '@_xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
930
+ '@_xmlns:p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
931
+ 'p:sldMasterIdLst': {},
932
+ 'p:sldIdLst': {},
933
+ 'p:sldSz': { '@_cx': '9144000', '@_cy': '5143500' },
934
+ 'p:notesSz': { '@_cx': '6858000', '@_cy': '9144000' },
935
+ },
936
+ };
937
+ this.#flushPresentation();
938
+ }
939
+
940
+ /**
941
+ * Asserts a slide index is valid.
942
+ * @private
943
+ * @param {number} index - 1-based slide index.
944
+ */
945
+ #assertSlideExists(index) {
946
+ if (!this.#slides.has(index)) {
947
+ throw new SlideNotFoundError(`Slide ${index} does not exist. Total slides: ${this.#slides.size}`);
948
+ }
949
+ }
950
+ }