node-pptx-templater 1.0.16 → 1.0.18

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.
@@ -41,10 +41,7 @@ class ImageManager {
41
41
  mediaManager,
42
42
  relationshipManager
43
43
  ) {
44
- const slideXml = slideManager.getSlideXml(slideIndex)
45
- const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
46
- const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
47
- const picRes = this.#findPicRecursive(spTree, imageIdOrName)
44
+ const picRes = slideManager.getSlidePic(slideIndex, imageIdOrName)
48
45
 
49
46
  if (!picRes) {
50
47
  throw new PPTXError(`Image shape "${imageIdOrName}" not found on slide ${slideIndex}`)
@@ -85,9 +82,11 @@ class ImageManager {
85
82
  relationshipManager
86
83
  ) {
87
84
  const slideInfo = slideManager.getSlideInfo(slideIndex)
88
- const slideXml = slideManager.getSlideXml(slideIndex)
89
- const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
90
- const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
85
+ const slideObj = slideManager.getSlideObj(slideIndex)
86
+ const spTree =
87
+ slideObj?.['p:sld']?.['p:cSld']?.['p:spTree'] ||
88
+ slideObj?.['p:sldLayout']?.['p:cSld']?.['p:spTree'] ||
89
+ slideObj?.['p:sldMaster']?.['p:cSld']?.['p:spTree']
91
90
 
92
91
  if (!spTree) {
93
92
  throw new PPTXError(`Invalid slide structure for slide ${slideIndex}`)
@@ -155,8 +154,7 @@ class ImageManager {
155
154
  }
156
155
  spTree['p:pic'].push(picObj)
157
156
 
158
- const decl = this.#xmlParser.extractDeclaration(slideXml)
159
- slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
157
+ slideManager.markSlideObjDirty(slideIndex)
160
158
  logger.debug(`Added image "${name}" with ID ${newId} and rId ${rId} to slide ${slideIndex}`)
161
159
  }
162
160
 
@@ -169,10 +167,7 @@ class ImageManager {
169
167
  * @param {RelationshipManager} relationshipManager
170
168
  */
171
169
  removeImage(slideIndex, imageIdOrName, slideManager, relationshipManager) {
172
- const slideXml = slideManager.getSlideXml(slideIndex)
173
- const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
174
- const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
175
- const picRes = this.#findPicRecursive(spTree, imageIdOrName)
170
+ const picRes = slideManager.getSlidePic(slideIndex, imageIdOrName)
176
171
 
177
172
  if (!picRes) {
178
173
  throw new PPTXError(`Image shape "${imageIdOrName}" not found on slide ${slideIndex}`)
@@ -195,8 +190,7 @@ class ImageManager {
195
190
  relationshipManager.removeRelationship(slideInfo.zipPath, rId)
196
191
  }
197
192
 
198
- const decl = this.#xmlParser.extractDeclaration(slideXml)
199
- slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
193
+ slideManager.markSlideObjDirty(slideIndex)
200
194
  logger.debug(`Removed image "${imageIdOrName}" from slide ${slideIndex}`)
201
195
  }
202
196
 
@@ -210,9 +204,11 @@ class ImageManager {
210
204
  */
211
205
  getImages(slideIndex, slideManager, relationshipManager) {
212
206
  const slideInfo = slideManager.getSlideInfo(slideIndex)
213
- const slideXml = slideManager.getSlideXml(slideIndex)
214
- const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
215
- const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
207
+ const slideObj = slideManager.getSlideObj(slideIndex)
208
+ const spTree =
209
+ slideObj?.['p:sld']?.['p:cSld']?.['p:spTree'] ||
210
+ slideObj?.['p:sldLayout']?.['p:cSld']?.['p:spTree'] ||
211
+ slideObj?.['p:sldMaster']?.['p:cSld']?.['p:spTree']
216
212
 
217
213
  const imagesInfo = []
218
214
  this.#collectImagesInfo(spTree, slideInfo.zipPath, relationshipManager, imagesInfo)
@@ -40,9 +40,29 @@ const { createHash } = require('crypto')
40
40
  const { createLogger } = require('../utils/logger.js')
41
41
  const { PPTXError } = require('../utils/errors.js')
42
42
  const fsExtra = require('fs-extra')
43
+ const fs = require('fs')
43
44
 
44
45
  const logger = createLogger('MediaManager')
45
46
 
47
+ function getFileHash(filePath) {
48
+ return new Promise((resolve, reject) => {
49
+ const hash = createHash('sha1')
50
+ const stream = fs.createReadStream(filePath)
51
+ stream.on('data', chunk => hash.update(chunk))
52
+ stream.on('end', () => resolve(hash.digest('hex')))
53
+ stream.on('error', reject)
54
+ })
55
+ }
56
+
57
+ function streamToBuffer(stream) {
58
+ return new Promise((resolve, reject) => {
59
+ const chunks = []
60
+ stream.on('data', chunk => chunks.push(chunk))
61
+ stream.on('end', () => resolve(Buffer.concat(chunks)))
62
+ stream.on('error', reject)
63
+ })
64
+ }
65
+
46
66
  /**
47
67
  * Extension to MIME type mapping.
48
68
  */
@@ -106,30 +126,37 @@ class MediaManager {
106
126
  this.#zipManager = zipManager
107
127
  const mediaFiles = zipManager.listFiles('ppt/media/')
108
128
 
109
- // Index all existing media files by content hash for deduplication
110
- await Promise.all(
111
- mediaFiles.map(async mediaPath => {
112
- const data = await zipManager.readBinaryFile(mediaPath)
129
+ // Register all existing media files without loading/hashing them yet
130
+ for (const mediaPath of mediaFiles) {
131
+ const ext = mediaPath.split('.').pop().toLowerCase()
132
+ const mimeType = EXT_TO_MIME[ext] || 'application/octet-stream'
133
+
134
+ const mediaInfo = { zipPath: mediaPath, hash: null, mimeType, size: null }
135
+ this.#mediaRegistry.set(mediaPath, mediaInfo)
136
+
137
+ // Track the highest media ID to avoid collisions
138
+ const numMatch = /\d+/.exec(mediaPath.split('/').pop())
139
+ if (numMatch) {
140
+ const num = parseInt(numMatch[0], 10)
141
+ if (num >= this.#nextMediaId) this.#nextMediaId = num + 1
142
+ }
143
+ }
144
+
145
+ logger.debug(`Registered ${this.#mediaRegistry.size} media file(s) (lazy loading enabled)`)
146
+ }
147
+
148
+ async #ensureAllMediaHashed() {
149
+ for (const [mediaPath, mediaInfo] of this.#mediaRegistry.entries()) {
150
+ if (mediaInfo.hash === null) {
151
+ const data = await this.#zipManager.readBinaryFile(mediaPath)
113
152
  if (data) {
114
153
  const hash = this.#hashBytes(data)
115
- const ext = mediaPath.split('.').pop().toLowerCase()
116
- const mimeType = EXT_TO_MIME[ext] || 'application/octet-stream'
117
-
118
- const mediaInfo = { zipPath: mediaPath, hash, mimeType, size: data.length }
154
+ mediaInfo.hash = hash
155
+ mediaInfo.size = data.length
119
156
  this.#mediaHashIndex.set(hash, mediaPath)
120
- this.#mediaRegistry.set(mediaPath, mediaInfo)
121
-
122
- // Track the highest media ID to avoid collisions
123
- const numMatch = /\d+/.exec(mediaPath.split('/').pop())
124
- if (numMatch) {
125
- const num = parseInt(numMatch[0], 10)
126
- if (num >= this.#nextMediaId) this.#nextMediaId = num + 1
127
- }
128
157
  }
129
- })
130
- )
131
-
132
- logger.debug(`Indexed ${this.#mediaRegistry.size} media file(s)`)
158
+ }
159
+ }
133
160
  }
134
161
 
135
162
  /**
@@ -152,41 +179,130 @@ class MediaManager {
152
179
  async embedImage(source, mimeType) {
153
180
  let data
154
181
  let ext
182
+ let hash
183
+ let size
184
+ const isStream = source && typeof source.on === 'function' && typeof source.pipe === 'function'
185
+ let streamForZip = null
186
+
187
+ if (isStream) {
188
+ if (source.path) {
189
+ // It's a file stream (fs.createReadStream)
190
+ const filePath = source.path
191
+ hash = await getFileHash(filePath)
192
+ ext = filePath.split('.').pop().toLowerCase()
193
+ mimeType = mimeType || EXT_TO_MIME[ext] || 'image/png'
194
+
195
+ // Check for duplicate (content-addressable dedup)
196
+ if (this.#mediaHashIndex.has(hash)) {
197
+ const existingPath = this.#mediaHashIndex.get(hash)
198
+ logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
199
+ return existingPath
200
+ }
201
+
202
+ // Ensure all media from template is hashed to check for duplicates
203
+ await this.#ensureAllMediaHashed()
204
+
205
+ if (this.#mediaHashIndex.has(hash)) {
206
+ const existingPath = this.#mediaHashIndex.get(hash)
207
+ logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
208
+ return existingPath
209
+ }
210
+
211
+ const stat = await fsExtra.stat(filePath)
212
+ size = stat.size
213
+ streamForZip = fs.createReadStream(filePath)
214
+ } else {
215
+ // Generic stream - we must buffer it to hash and reuse
216
+ data = await streamToBuffer(source)
217
+ hash = this.#hashBytes(data)
218
+ ext = this.#detectExtension(data)
219
+ mimeType = mimeType || EXT_TO_MIME[ext] || 'image/png'
220
+ size = data.length
221
+
222
+ // Check for duplicate (content-addressable dedup)
223
+ if (this.#mediaHashIndex.has(hash)) {
224
+ const existingPath = this.#mediaHashIndex.get(hash)
225
+ logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
226
+ return existingPath
227
+ }
228
+
229
+ // Ensure all media from template is hashed to check for duplicates
230
+ await this.#ensureAllMediaHashed()
155
231
 
156
- if (typeof source === 'string') {
232
+ if (this.#mediaHashIndex.has(hash)) {
233
+ const existingPath = this.#mediaHashIndex.get(hash)
234
+ logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
235
+ return existingPath
236
+ }
237
+ }
238
+ } else if (typeof source === 'string') {
157
239
  // Load from file path
158
- data = await fsExtra.readFile(source)
240
+ hash = await getFileHash(source)
159
241
  ext = source.split('.').pop().toLowerCase()
160
242
  mimeType = mimeType || EXT_TO_MIME[ext] || 'image/png'
243
+
244
+ if (this.#mediaHashIndex.has(hash)) {
245
+ const existingPath = this.#mediaHashIndex.get(hash)
246
+ logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
247
+ return existingPath
248
+ }
249
+
250
+ // Ensure all media from template is hashed to check for duplicates
251
+ await this.#ensureAllMediaHashed()
252
+
253
+ if (this.#mediaHashIndex.has(hash)) {
254
+ const existingPath = this.#mediaHashIndex.get(hash)
255
+ logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
256
+ return existingPath
257
+ }
258
+
259
+ const stat = await fsExtra.stat(source)
260
+ size = stat.size
261
+ streamForZip = fs.createReadStream(source)
161
262
  } else if (Buffer.isBuffer(source) || source instanceof Uint8Array) {
162
263
  data = source
163
- // Detect format from magic bytes
164
264
  ext = this.#detectExtension(data)
165
265
  mimeType = mimeType || EXT_TO_MIME[ext] || 'image/png'
166
- } else {
167
- throw new PPTXError('embedImage: source must be a file path string or Buffer')
168
- }
266
+ hash = this.#hashBytes(data)
267
+ size = data.length
268
+
269
+ if (this.#mediaHashIndex.has(hash)) {
270
+ const existingPath = this.#mediaHashIndex.get(hash)
271
+ logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
272
+ return existingPath
273
+ }
274
+
275
+ // Ensure all media from template is hashed to check for duplicates
276
+ await this.#ensureAllMediaHashed()
169
277
 
170
- // Check for duplicate (content-addressable dedup)
171
- const hash = this.#hashBytes(data)
172
- if (this.#mediaHashIndex.has(hash)) {
173
- const existingPath = this.#mediaHashIndex.get(hash)
174
- logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
175
- return existingPath
278
+ if (this.#mediaHashIndex.has(hash)) {
279
+ const existingPath = this.#mediaHashIndex.get(hash)
280
+ logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
281
+ return existingPath
282
+ }
283
+ } else {
284
+ throw new PPTXError(
285
+ 'embedImage: source must be a file path string, Buffer, or Readable Stream'
286
+ )
176
287
  }
177
288
 
178
289
  // Create a new media file
179
290
  const mediaId = this.#nextMediaId++
180
291
  const zipPath = `ppt/media/image${mediaId}.${ext}`
181
292
 
182
- this.#zipManager.writeBinaryFile(zipPath, data)
293
+ if (streamForZip) {
294
+ this.#zipManager.writeBinaryFile(zipPath, streamForZip)
295
+ } else {
296
+ this.#zipManager.writeBinaryFile(zipPath, data)
297
+ }
298
+
183
299
  this.#mediaHashIndex.set(hash, zipPath)
184
- this.#mediaRegistry.set(zipPath, { zipPath, hash, mimeType, size: data.length })
300
+ this.#mediaRegistry.set(zipPath, { zipPath, hash, mimeType, size })
185
301
 
186
302
  // Register content type
187
303
  this.#registerContentType(ext, mimeType)
188
304
 
189
- logger.debug(`Embedded new media: ${zipPath} (${data.length} bytes)`)
305
+ logger.debug(`Embedded new media: ${zipPath} (${size} bytes)`)
190
306
  return zipPath
191
307
  }
192
308
 
@@ -31,19 +31,14 @@ class ShapeManager {
31
31
  * @param {SlideManager} slideManager
32
32
  */
33
33
  updateShapeText(slideIndex, shapeId, text, slideManager) {
34
- const slideXml = slideManager.getSlideXml(slideIndex)
35
- const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
36
- const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
37
- const res = this.findShapeRecursive(spTree, shapeId)
34
+ const res = slideManager.getSlideShape(slideIndex, shapeId)
38
35
 
39
36
  if (!res) {
40
37
  throw new PPTXError(`Shape "${shapeId}" not found in slide ${slideIndex}`)
41
38
  }
42
39
 
43
40
  this.#setShapeTextObj(res.shape, text)
44
-
45
- const decl = this.#xmlParser.extractDeclaration(slideXml)
46
- slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
41
+ slideManager.markSlideObjDirty(slideIndex)
47
42
  logger.debug(`Updated text for shape "${shapeId}" on slide ${slideIndex}`)
48
43
  }
49
44
 
@@ -60,10 +55,7 @@ class ShapeManager {
60
55
  * @param {SlideManager} slideManager
61
56
  */
62
57
  updateShapePosition(slideIndex, shapeId, options = {}, slideManager) {
63
- const slideXml = slideManager.getSlideXml(slideIndex)
64
- const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
65
- const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
66
- const res = this.findShapeRecursive(spTree, shapeId)
58
+ const res = slideManager.getSlideShape(slideIndex, shapeId)
67
59
 
68
60
  if (!res) {
69
61
  throw new PPTXError(`Shape "${shapeId}" not found in slide ${slideIndex}`)
@@ -108,8 +100,7 @@ class ShapeManager {
108
100
  xfrm['a:ext']['@_cy'] = '0'
109
101
  }
110
102
 
111
- const decl = this.#xmlParser.extractDeclaration(slideXml)
112
- slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
103
+ slideManager.markSlideObjDirty(slideIndex)
113
104
  logger.debug(`Updated position/dimensions for shape "${shapeId}" on slide ${slideIndex}`)
114
105
  }
115
106
 
@@ -146,10 +137,8 @@ class ShapeManager {
146
137
  * @param {SlideManager} slideManager
147
138
  */
148
139
  cloneShape(slideIndex, shapeId, newShapeId, options = {}, slideManager) {
149
- const slideXml = slideManager.getSlideXml(slideIndex)
150
- const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
151
- const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
152
- const res = this.findShapeRecursive(spTree, shapeId)
140
+ const slideObj = slideManager.getSlideObj(slideIndex)
141
+ const res = slideManager.getSlideShape(slideIndex, shapeId)
153
142
 
154
143
  if (!res) {
155
144
  throw new PPTXError(`Shape "${shapeId}" not found in slide ${slideIndex}`)
@@ -158,6 +147,11 @@ class ShapeManager {
158
147
  const newShape = this.#xmlParser.deepClone(res.shape)
159
148
  const cNvPr = newShape['p:nvSpPr']?.['p:cNvPr']
160
149
 
150
+ const spTree =
151
+ slideObj?.['p:sld']?.['p:cSld']?.['p:spTree'] ||
152
+ slideObj?.['p:sldLayout']?.['p:cSld']?.['p:spTree'] ||
153
+ slideObj?.['p:sldMaster']?.['p:cSld']?.['p:spTree']
154
+
161
155
  const existingIds = this.#getAllShapeIds(spTree)
162
156
  const maxId = existingIds.length > 0 ? Math.max(...existingIds) : 1000
163
157
  const newId = maxId + 1
@@ -204,8 +198,7 @@ class ShapeManager {
204
198
  }
205
199
  parent['p:sp'].push(newShape)
206
200
 
207
- const decl = this.#xmlParser.extractDeclaration(slideXml)
208
- slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
201
+ slideManager.markSlideObjDirty(slideIndex)
209
202
  logger.debug(`Cloned shape "${shapeId}" as "${newShapeId}"`)
210
203
  }
211
204
 
@@ -217,10 +210,7 @@ class ShapeManager {
217
210
  * @param {SlideManager} slideManager
218
211
  */
219
212
  deleteShape(slideIndex, shapeId, slideManager) {
220
- const slideXml = slideManager.getSlideXml(slideIndex)
221
- const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
222
- const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
223
- const res = this.findShapeRecursive(spTree, shapeId)
213
+ const res = slideManager.getSlideShape(slideIndex, shapeId)
224
214
 
225
215
  if (!res) {
226
216
  throw new PPTXError(`Shape "${shapeId}" not found in slide ${slideIndex}`)
@@ -235,8 +225,7 @@ class ShapeManager {
235
225
  }
236
226
  }
237
227
 
238
- const decl = this.#xmlParser.extractDeclaration(slideXml)
239
- slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
228
+ slideManager.markSlideObjDirty(slideIndex)
240
229
  logger.debug(`Deleted shape "${shapeId}" from slide ${slideIndex}`)
241
230
  }
242
231
 
@@ -248,9 +237,11 @@ class ShapeManager {
248
237
  * @returns {Array<Object>}
249
238
  */
250
239
  getShapes(slideIndex, slideManager) {
251
- const slideXml = slideManager.getSlideXml(slideIndex)
252
- const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
253
- const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
240
+ const slideObj = slideManager.getSlideObj(slideIndex)
241
+ const spTree =
242
+ slideObj?.['p:sld']?.['p:cSld']?.['p:spTree'] ||
243
+ slideObj?.['p:sldLayout']?.['p:cSld']?.['p:spTree'] ||
244
+ slideObj?.['p:sldMaster']?.['p:cSld']?.['p:spTree']
254
245
 
255
246
  const shapesInfo = []
256
247
  this.#collectShapesInfo(spTree, shapesInfo)