node-pptx-templater 1.0.12 → 1.0.14

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": "1.0.12",
3
+ "version": "1.0.14",
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
  "type": "commonjs",
@@ -26,6 +26,9 @@ class OutputWriter {
26
26
  /** @private @type {ContentTypesManager} */
27
27
  #contentTypesManager
28
28
 
29
+ /** @type {boolean} */
30
+ debugZip = false
31
+
29
32
  /**
30
33
  * @param {ZipManager} zipManager
31
34
  * @param {ContentTypesManager} contentTypesManager
@@ -76,6 +79,11 @@ class OutputWriter {
76
79
 
77
80
  const buffer = await zipManager.toBuffer()
78
81
  logger.debug(`Generated buffer: ${(buffer.length / 1024).toFixed(1)} KB`)
82
+
83
+ if (this.debugZip) {
84
+ this.printDebugZip(buffer)
85
+ }
86
+
79
87
  return buffer
80
88
  }
81
89
 
@@ -94,9 +102,68 @@ class OutputWriter {
94
102
 
95
103
  await zipManager.waitForPendingWrites()
96
104
  const nodeStream = await zipManager.toStream()
105
+
106
+ if (this.debugZip) {
107
+ const buffer = await zipManager.toBuffer()
108
+ this.printDebugZip(buffer)
109
+ }
110
+
97
111
  return nodeStream
98
112
  }
99
113
 
114
+ /**
115
+ * Parses the Central Directory of a ZIP buffer and logs debug info for every entry.
116
+ *
117
+ * @param {Buffer} buffer
118
+ */
119
+ printDebugZip(buffer) {
120
+ let offset = 0
121
+ const entries = []
122
+
123
+ while (offset < buffer.length - 46) {
124
+ const sig = buffer.readUInt32LE(offset)
125
+ if (sig === 0x02014b50) {
126
+ const compressionMethod = buffer.readUInt16LE(offset + 10)
127
+ const crc32 = buffer.readUInt32LE(offset + 16)
128
+ const compressedSize = buffer.readUInt32LE(offset + 20)
129
+ const uncompressedSize = buffer.readUInt32LE(offset + 24)
130
+ const fileNameLength = buffer.readUInt16LE(offset + 28)
131
+ const extraFieldLength = buffer.readUInt16LE(offset + 30)
132
+ const fileCommentLength = buffer.readUInt16LE(offset + 32)
133
+
134
+ const fileName = buffer.toString('utf8', offset + 46, offset + 46 + fileNameLength)
135
+
136
+ entries.push({
137
+ name: fileName,
138
+ compressionMethod,
139
+ crc32: crc32.toString(16).toLowerCase(),
140
+ compressedSize,
141
+ uncompressedSize,
142
+ })
143
+
144
+ offset += 46 + fileNameLength + extraFieldLength + fileCommentLength
145
+ } else {
146
+ offset++
147
+ }
148
+ }
149
+
150
+ logger.info(`--- ZIP debug output (${entries.length} entries) ---`)
151
+ entries.forEach(e => {
152
+ const methodStr =
153
+ e.compressionMethod === 8
154
+ ? 'DEFLATE'
155
+ : e.compressionMethod === 0
156
+ ? 'STORE'
157
+ : `UNKNOWN(${e.compressionMethod})`
158
+ console.log(e.name)
159
+ console.log(`compressed: ${e.compressedSize}`)
160
+ console.log(`uncompressed: ${e.uncompressedSize}`)
161
+ console.log(`crc: ${e.crc32}`)
162
+ console.log(`method: ${methodStr}`)
163
+ })
164
+ logger.info('--- End of ZIP debug output ---')
165
+ }
166
+
100
167
  /**
101
168
  * Ensures all dirty slide XML is committed to the ZipManager.
102
169
  * This is called before any output operation.
@@ -107,8 +174,6 @@ class OutputWriter {
107
174
  * @returns {Promise<void>}
108
175
  */
109
176
  async #flushAllSlides(slideManager, zipManager) {
110
- // SlideManager already writes to zipManager via setSlideXml,
111
- // so this is mostly a no-op with a validation step.
112
177
  const info = slideManager.getAllSlideInfo()
113
178
 
114
179
  for (const slide of info) {
@@ -117,64 +182,58 @@ class OutputWriter {
117
182
  }
118
183
  }
119
184
 
120
- // Update the slide count and titles in docProps/app.xml to prevent repair mode issues
185
+ // Change this block to await the process completely
121
186
  if (zipManager.hasFile('docProps/app.xml')) {
122
- zipManager.addPendingPromise(
123
- zipManager.rawZip
124
- .file('docProps/app.xml')
125
- .async('text')
126
- .then(content => {
127
- const parser = new XMLParser()
128
- const appObj = parser.parse(content, 'app.xml')
129
- const properties = appObj.Properties
130
-
131
- if (properties) {
132
- // 1. Update Slides count
133
- properties.Slides = info.length
134
-
135
- // 2. Find old slide titles count and update HeadingPairs
136
- let oldSlideTitlesCount = 0
137
- const variants = properties.HeadingPairs?.['vt:vector']?.['vt:variant']
138
- if (Array.isArray(variants)) {
139
- for (let i = 0; i < variants.length; i++) {
140
- if (variants[i]['vt:lpstr'] === 'Slide Titles') {
141
- const countVar = variants[i + 1]
142
- if (countVar) {
143
- oldSlideTitlesCount = parseInt(countVar['vt:i4'], 10) || 0
144
- countVar['vt:i4'] = info.length
145
- }
146
- break
187
+ // AWAIT this process entirely before exiting the function!
188
+ await zipManager.rawZip
189
+ .file('docProps/app.xml')
190
+ .async('text')
191
+ .then(content => {
192
+ const parser = new XMLParser()
193
+ const appObj = parser.parse(content, 'app.xml')
194
+ const properties = appObj.Properties
195
+
196
+ if (properties) {
197
+ properties.Slides = info.length
198
+
199
+ let oldSlideTitlesCount = 0
200
+ const variants = properties.HeadingPairs?.['vt:vector']?.['vt:variant']
201
+ if (Array.isArray(variants)) {
202
+ for (let i = 0; i < variants.length; i++) {
203
+ if (variants[i]['vt:lpstr'] === 'Slide Titles') {
204
+ const countVar = variants[i + 1]
205
+ if (countVar) {
206
+ oldSlideTitlesCount = parseInt(countVar['vt:i4'], 10) || 0
207
+ countVar['vt:i4'] = info.length
147
208
  }
209
+ break
148
210
  }
149
211
  }
212
+ }
150
213
 
151
- // 3. Update TitlesOfParts
152
- const titlesVector = properties.TitlesOfParts?.['vt:vector']
153
- if (titlesVector) {
154
- let lpstrs = titlesVector['vt:lpstr']
155
- if (lpstrs) {
156
- if (!Array.isArray(lpstrs)) lpstrs = [lpstrs]
157
-
158
- // Remove the old slide titles (which are at the end)
159
- if (oldSlideTitlesCount > 0 && lpstrs.length >= oldSlideTitlesCount) {
160
- lpstrs = lpstrs.slice(0, lpstrs.length - oldSlideTitlesCount)
161
- }
162
-
163
- // Append new slide titles
164
- const newSlideTitles = info.map(slide => slide.title || `Slide ${slide.index}`)
165
- lpstrs.push(...newSlideTitles)
166
-
167
- titlesVector['vt:lpstr'] = lpstrs
168
- titlesVector['@_size'] = String(lpstrs.length)
214
+ const titlesVector = properties.TitlesOfParts?.['vt:vector']
215
+ if (titlesVector) {
216
+ let lpstrs = titlesVector['vt:lpstr']
217
+ if (lpstrs) {
218
+ if (!Array.isArray(lpstrs)) lpstrs = [lpstrs]
219
+ if (oldSlideTitlesCount > 0 && lpstrs.length >= oldSlideTitlesCount) {
220
+ lpstrs = lpstrs.slice(0, lpstrs.length - oldSlideTitlesCount)
169
221
  }
170
- }
222
+ const newSlideTitles = info.map(slide => slide.title || `Slide ${slide.index}`)
223
+ lpstrs.push(...newSlideTitles)
171
224
 
172
- const declaration = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
173
- const updatedXml = parser.build(appObj, declaration)
174
- zipManager.writeFile('docProps/app.xml', updatedXml)
225
+ titlesVector['vt:lpstr'] = lpstrs
226
+ titlesVector['@_size'] = String(lpstrs.length)
227
+ }
175
228
  }
176
- })
177
- )
229
+
230
+ const declaration = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
231
+ const updatedXml = parser.build(appObj, declaration)
232
+
233
+ // Writing it safely here now that the function block is strictly sequential
234
+ zipManager.writeFile('docProps/app.xml', updatedXml)
235
+ }
236
+ })
178
237
  }
179
238
 
180
239
  logger.debug(`Flushed ${info.length} slide(s) to ZIP`)
@@ -987,6 +987,7 @@ class PPTXTemplater {
987
987
  )
988
988
  }
989
989
  }
990
+ await this.validateArchive()
990
991
  await this.#outputWriter.saveToFile(filePath, this.#slideManager, this.#zipManager)
991
992
  logger.info(`Saved PPTX to ${filePath}`)
992
993
  }
@@ -998,6 +999,7 @@ class PPTXTemplater {
998
999
  */
999
1000
  async toBuffer() {
1000
1001
  this.#assertLoaded()
1002
+ await this.validateArchive()
1001
1003
  return this.#outputWriter.toBuffer(this.#slideManager, this.#zipManager)
1002
1004
  }
1003
1005
 
@@ -1008,6 +1010,7 @@ class PPTXTemplater {
1008
1010
  */
1009
1011
  async toStream() {
1010
1012
  this.#assertLoaded()
1013
+ await this.validateArchive()
1011
1014
  return this.#outputWriter.toStream(this.#slideManager, this.#zipManager)
1012
1015
  }
1013
1016
 
@@ -1749,6 +1752,17 @@ class PPTXTemplater {
1749
1752
  )
1750
1753
  }
1751
1754
 
1755
+ async validateArchive() {
1756
+ this.#assertLoaded()
1757
+ await this.#zipManager.validateArchive()
1758
+ return this
1759
+ }
1760
+
1761
+ enableDebugZip() {
1762
+ this.#outputWriter.debugZip = true
1763
+ return this
1764
+ }
1765
+
1752
1766
  validateRelationships(partPath) {
1753
1767
  this.#assertLoaded()
1754
1768
  return ValidationEngine.validateRelationships(this, partPath)
@@ -288,6 +288,97 @@ class ZipManager {
288
288
  }
289
289
  }
290
290
 
291
+ /**
292
+ * Validates the integrity of the ZIP archive.
293
+ * Checks for CRC integrity, entry sizes, duplicate entries, missing critical entries, and invalid binary data.
294
+ *
295
+ * @returns {Promise<void>}
296
+ * @throws {PPTXError} If any validation issue is found.
297
+ */
298
+ async validateArchive() {
299
+ const files = this.#zip.files
300
+ const errors = []
301
+ const seenPaths = new Set()
302
+ const { XMLParser } = require('../parsers/XMLParser.js')
303
+ const parser = new XMLParser()
304
+
305
+ for (const [name, file] of Object.entries(files)) {
306
+ if (file.dir) continue
307
+
308
+ const lowerPath = name.toLowerCase()
309
+ if (seenPaths.has(lowerPath)) {
310
+ errors.push(`Duplicate entry found (case-insensitive): ${name}`)
311
+ }
312
+ seenPaths.add(lowerPath)
313
+
314
+ try {
315
+ // file.async('uint8array') forces decompression and checks CRC32 & uncompressed size
316
+ const content = await file.async('uint8array')
317
+
318
+ // If XML/rels file, verify it's not empty and is valid XML
319
+ if (name.endsWith('.xml') || name.endsWith('.rels')) {
320
+ const { TextDecoder } = require('util')
321
+ const text = new TextDecoder('utf-8').decode(content)
322
+ if (!text.trim()) {
323
+ errors.push(`XML/rels entry is empty: ${name}`)
324
+ } else {
325
+ try {
326
+ parser.parse(text, name)
327
+ } catch (xmlErr) {
328
+ errors.push(`Invalid XML/rels structure in ${name}: ${xmlErr.message}`)
329
+ }
330
+ }
331
+ }
332
+
333
+ // Verify media files (like images) are not empty and have correct magic numbers
334
+ if (name.startsWith('ppt/media/')) {
335
+ if (content.length === 0) {
336
+ errors.push(`Media entry is empty: ${name}`)
337
+ }
338
+ const ext = name.split('.').pop().toLowerCase()
339
+ if (ext === 'png') {
340
+ if (
341
+ content[0] !== 0x89 ||
342
+ content[1] !== 0x50 ||
343
+ content[2] !== 0x4e ||
344
+ content[3] !== 0x47
345
+ ) {
346
+ errors.push(`Invalid PNG signature in media file: ${name}`)
347
+ }
348
+ } else if (ext === 'jpg' || ext === 'jpeg') {
349
+ if (content[0] !== 0xff || content[1] !== 0xd8) {
350
+ errors.push(`Invalid JPEG signature in media file: ${name}`)
351
+ }
352
+ }
353
+ }
354
+ } catch (err) {
355
+ errors.push(`CRC32 or uncompressed size integrity failure on entry ${name}: ${err.message}`)
356
+ }
357
+ }
358
+
359
+ // Check for critical missing OpenXML files
360
+ const criticalFiles = [
361
+ '[Content_Types].xml',
362
+ '_rels/.rels',
363
+ 'ppt/presentation.xml',
364
+ 'ppt/_rels/presentation.xml.rels',
365
+ ]
366
+
367
+ for (const critical of criticalFiles) {
368
+ if (!this.hasFile(critical)) {
369
+ errors.push(`Critical OpenXML package file is missing: ${critical}`)
370
+ }
371
+ }
372
+
373
+ if (errors.length > 0) {
374
+ throw new PPTXError(
375
+ `ZIP archive validation failed:\n${errors.map(e => ` • ${e}`).join('\n')}`
376
+ )
377
+ }
378
+
379
+ logger.info('ZIP archive integrity validation passed.')
380
+ }
381
+
291
382
  /**
292
383
  * Returns the raw JSZip instance (for advanced use cases).
293
384
  * @returns {JSZip}
@@ -350,7 +350,9 @@ class ChartCacheGenerator {
350
350
  if (dLblTxPrMatch) {
351
351
  existingDLblTxPrs[idx] = dLblTxPrMatch[1]
352
352
  }
353
- const dLblLayoutMatch = /(<c:layout>[\s\S]*?<\/c:layout>|<c:layout\/>)/.exec(dLblContent)
353
+ const dLblLayoutMatch = /(<c:layout>[\s\S]*?<\/c:layout>|<c:layout\/>)/.exec(
354
+ dLblContent
355
+ )
354
356
  if (dLblLayoutMatch) {
355
357
  existingDLblLayouts[idx] = dLblLayoutMatch[1]
356
358
  }