node-pptx-templater 1.0.13 → 1.0.15

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.13",
3
+ "version": "1.0.15",
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",
@@ -181,4 +181,4 @@
181
181
  "LICENSE",
182
182
  "CHANGELOG.md"
183
183
  ]
184
- }
184
+ }
@@ -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.
@@ -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)
@@ -232,8 +232,8 @@ class ZipManager {
232
232
  async toBuffer() {
233
233
  return this.#zip.generateAsync({
234
234
  type: 'nodebuffer',
235
- compression: 'DEFLATE',
236
- compressionOptions: { level: 6 },
235
+ compression: 'STORE',
236
+ compressionOptions: { level: 0 },
237
237
  })
238
238
  }
239
239
 
@@ -245,8 +245,8 @@ class ZipManager {
245
245
  async toStream() {
246
246
  return this.#zip.generateNodeStream({
247
247
  type: 'nodebuffer',
248
- compression: 'DEFLATE',
249
- compressionOptions: { level: 6 },
248
+ compression: 'STORE',
249
+ compressionOptions: { level: 0 },
250
250
  streamFiles: true,
251
251
  })
252
252
  }
@@ -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
  }