node-pptx-templater 2.0.0-alpha.0 → 2.0.0-alpha.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-pptx-templater",
3
- "version": "2.0.0-alpha.0",
3
+ "version": "2.0.0-alpha.2",
4
4
  "description": "High-performance, low-level PowerPoint (PPTX) OpenXML template engine for Node.js. Dynamically replace text, insert images, update charts (with Excel workbook data caching), and merge table cells without PowerPoint corruption or Repair Mode prompts.",
5
5
  "main": "./src/index.js",
6
6
  "module": "./src/index.mjs",
@@ -209,4 +209,4 @@
209
209
  "publishConfig": {
210
210
  "access": "public"
211
211
  }
212
- }
212
+ }
@@ -25,6 +25,8 @@ class OutputWriter {
25
25
  #zipManager
26
26
  /** @private @type {ContentTypesManager} */
27
27
  #contentTypesManager
28
+ /** @private @type {RelationshipManager} */
29
+ #relationshipManager
28
30
 
29
31
  /** @type {boolean} */
30
32
  debugZip = false
@@ -32,10 +34,12 @@ class OutputWriter {
32
34
  /**
33
35
  * @param {ZipManager} zipManager
34
36
  * @param {ContentTypesManager} contentTypesManager
37
+ * @param {RelationshipManager} [relationshipManager]
35
38
  */
36
- constructor(zipManager, contentTypesManager) {
39
+ constructor(zipManager, contentTypesManager, relationshipManager = null) {
37
40
  this.#zipManager = zipManager
38
41
  this.#contentTypesManager = contentTypesManager
42
+ this.#relationshipManager = relationshipManager
39
43
  }
40
44
 
41
45
  /**
@@ -71,9 +75,18 @@ class OutputWriter {
71
75
  if (slideManager && typeof slideManager.flush === 'function') {
72
76
  slideManager.flush()
73
77
  }
78
+ // Complete all pending async mutations (slide duplication, chart writes, etc.)
79
+ // BEFORE structural normalization so sldIdLst/rels match the final slide set.
80
+ await zipManager.waitForPendingWrites()
81
+ if (
82
+ slideManager &&
83
+ typeof slideManager.normalizeStructure === 'function' &&
84
+ this.#relationshipManager
85
+ ) {
86
+ await slideManager.normalizeStructure(this.#relationshipManager, this.#contentTypesManager)
87
+ }
74
88
  await this.#flushAllSlides(slideManager, zipManager)
75
89
  this.#contentTypesManager.flush(zipManager)
76
- await zipManager.waitForPendingWrites()
77
90
  }
78
91
 
79
92
  /**
@@ -186,7 +186,11 @@ class PPTXTemplater {
186
186
  this.#textManager = new TextManager(this.#xmlParser)
187
187
  this.#templateEngine = new TemplateEngine(this.#xmlParser)
188
188
  this.#zOrderManager = new ZOrderManager(this.#xmlParser)
189
- this.#outputWriter = new OutputWriter(this.#zipManager, this.#contentTypesManager)
189
+ this.#outputWriter = new OutputWriter(
190
+ this.#zipManager,
191
+ this.#contentTypesManager,
192
+ this.#relationshipManager
193
+ )
190
194
 
191
195
  this.#profiler = {
192
196
  enabled: false,
@@ -966,8 +970,14 @@ class PPTXTemplater {
966
970
  */
967
971
  cloneSlide(sourceSlideNumber, atPosition) {
968
972
  this.#assertLoaded()
969
- this.#slideManager.cloneSlide(sourceSlideNumber, atPosition, this.#relationshipManager)
970
- return this
973
+ const promise = this.#slideManager.cloneSlide(
974
+ sourceSlideNumber,
975
+ atPosition,
976
+ this.#relationshipManager,
977
+ this.#mediaManager
978
+ )
979
+ this.#zipManager.addPendingPromise(promise)
980
+ return promise.then(() => this)
971
981
  }
972
982
 
973
983
  /**
@@ -1467,8 +1477,14 @@ class PPTXTemplater {
1467
1477
  */
1468
1478
  duplicateSlide(slideIndex, atPosition) {
1469
1479
  this.#assertLoaded()
1470
- this.#slideManager.duplicateSlide(slideIndex, atPosition, this.#relationshipManager)
1471
- return this
1480
+ const promise = this.#slideManager.duplicateSlide(
1481
+ slideIndex,
1482
+ atPosition,
1483
+ this.#relationshipManager,
1484
+ this.#mediaManager
1485
+ )
1486
+ this.#zipManager.addPendingPromise(promise)
1487
+ return promise.then(() => this)
1472
1488
  }
1473
1489
 
1474
1490
  /**
@@ -38,6 +38,11 @@ class ValidationEngine {
38
38
  errors.push(...presRelResult.errors.map(e => `Presentation relationship error: ${e}`))
39
39
  warnings.push(...presRelResult.warnings.map(w => `Presentation relationship warning: ${w}`))
40
40
 
41
+ // 3. Verify sldIdLst r:id values resolve to slide relationships in presentation.xml.rels
42
+ const sldIdResult = this.validatePresentationSlideIds(ppt)
43
+ errors.push(...sldIdResult.errors)
44
+ warnings.push(...sldIdResult.warnings)
45
+
41
46
  return {
42
47
  valid: errors.length === 0,
43
48
  errors,
@@ -206,6 +211,41 @@ class ValidationEngine {
206
211
  }
207
212
  }
208
213
 
214
+ /**
215
+ * Ensures each p:sldId entry references a valid slide relationship in presentation.xml.rels.
216
+ *
217
+ * @param {PPTXTemplater} ppt
218
+ * @returns {{ valid: boolean, errors: string[], warnings: string[] }}
219
+ */
220
+ static validatePresentationSlideIds(ppt) {
221
+ const errors = []
222
+ const warnings = []
223
+
224
+ const slides = ppt.slideManager.getAllSlideInfo()
225
+ if (slides.length === 0) {
226
+ return { valid: true, errors, warnings }
227
+ }
228
+
229
+ const presRels = ppt.relationshipManager
230
+ .getRelationships('ppt/presentation.xml')
231
+ .filter(r => r.type.endsWith('/slide'))
232
+ const slideRelIds = new Set(presRels.map(r => r.id))
233
+
234
+ for (const slide of slides) {
235
+ if (!slideRelIds.has(slide.relationshipId)) {
236
+ errors.push(
237
+ `Slide ${slide.index} relationshipId "${slide.relationshipId}" is not defined in presentation.xml.rels`
238
+ )
239
+ }
240
+ }
241
+
242
+ return {
243
+ valid: errors.length === 0,
244
+ errors,
245
+ warnings,
246
+ }
247
+ }
248
+
209
249
  /**
210
250
  * Validates relationship mappings for a specific part.
211
251
  *
@@ -150,6 +150,19 @@ class ContentTypesManager {
150
150
  return this.#contentTypesObj.Types.Override.some(o => o['@_PartName'] === normalizedPart)
151
151
  }
152
152
 
153
+ /**
154
+ * Gets the override content type for a specific part name, if registered.
155
+ *
156
+ * @param {string} partName - Absolute or relative path.
157
+ * @returns {string|null}
158
+ */
159
+ getOverrideContentType(partName) {
160
+ const normalizedPart = partName.startsWith('/') ? partName : `/${partName}`
161
+ const overrides = this.#contentTypesObj?.Types?.Override || []
162
+ const existing = overrides.find(o => o['@_PartName'] === normalizedPart)
163
+ return existing ? existing['@_ContentType'] : null
164
+ }
165
+
153
166
  /**
154
167
  * Serializes back to [Content_Types].xml and writes to ZIP.
155
168
  *
@@ -234,6 +234,44 @@ class MediaManager {
234
234
  return zipPath
235
235
  }
236
236
 
237
+ /**
238
+ * Copies an existing media part to a new unique file without deduplication.
239
+ * Used when cloning slides so the copy does not share media relationships with the source.
240
+ *
241
+ * @param {string} sourceZipPath - Absolute ZIP path to the source media file.
242
+ * @returns {Promise<string>} ZIP path of the new media file.
243
+ */
244
+ async copyMediaAsNewPart(sourceZipPath) {
245
+ const data = await this.#zipManager.readBinaryFile(sourceZipPath)
246
+ if (!data) {
247
+ throw new PPTXError(`Cannot clone slide: media not found at ${sourceZipPath}`)
248
+ }
249
+
250
+ const fileName = sourceZipPath.split('/').pop()
251
+ const ext = fileName.includes('.') ? fileName.split('.').pop().toLowerCase() : 'png'
252
+ const mimeType = EXT_TO_MIME[ext] || 'application/octet-stream'
253
+
254
+ let mediaId = this.#nextMediaId
255
+ let zipPath = `ppt/media/image${mediaId}.${ext}`
256
+ while (this.#zipManager.hasFile(zipPath)) {
257
+ mediaId++
258
+ zipPath = `ppt/media/image${mediaId}.${ext}`
259
+ }
260
+ this.#nextMediaId = mediaId + 1
261
+
262
+ this.#zipManager.writeBinaryFile(zipPath, data)
263
+ this.#mediaRegistry.set(zipPath, {
264
+ zipPath,
265
+ hash: null,
266
+ mimeType,
267
+ size: data.length,
268
+ })
269
+ this.#registerContentType(ext, mimeType)
270
+
271
+ logger.debug(`Cloned media to new part: ${zipPath}`)
272
+ return zipPath
273
+ }
274
+
237
275
  /**
238
276
  * Generates the slide XML snippet for an image element.
239
277
  *
@@ -407,6 +407,27 @@ class RelationshipManager {
407
407
  }
408
408
  }
409
409
 
410
+ /**
411
+ * Sets the relationships array for a part in memory.
412
+ *
413
+ * @param {string} partPath
414
+ * @param {Relationship[]} rels
415
+ */
416
+ setRelationships(partPath, rels) {
417
+ const key = this.#getNormalizedKey(partPath)
418
+ this.#relationships.set(key, rels)
419
+ }
420
+
421
+ /**
422
+ * Flushes the relationships of a part.
423
+ *
424
+ * @param {string} partPath
425
+ */
426
+ flushRelationships(partPath) {
427
+ const key = this.#getNormalizedKey(partPath)
428
+ this.#flushRels(key, partPath)
429
+ }
430
+
410
431
  /**
411
432
  * Scans all relationships and removes those pointing to missing internal targets.
412
433
  * This is part of the repair functionality.