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.
|
|
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
|
+
}
|
package/src/core/OutputWriter.js
CHANGED
|
@@ -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: '
|
|
236
|
-
compressionOptions: { level:
|
|
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: '
|
|
249
|
-
compressionOptions: { level:
|
|
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(
|
|
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
|
}
|