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,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
|
+
}
|