node-pptx-templater 1.0.17 → 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.
@@ -82,6 +82,18 @@ class ZipManager {
82
82
  */
83
83
  #removedFiles = new Set()
84
84
 
85
+ /**
86
+ * @private
87
+ * @type {Map<string, { type: string, content: string|Buffer|Uint8Array }>|null}
88
+ */
89
+ #cachedFiles = null
90
+
91
+ async loadFromCache(cachedFilesMap) {
92
+ this.#cachedFiles = cachedFilesMap
93
+ await this.#loadCoreProperties()
94
+ logger.debug(`Loaded from cache. Files: ${cachedFilesMap.size}`)
95
+ }
96
+
85
97
  async load(source) {
86
98
  try {
87
99
  const path = require('path')
@@ -221,6 +233,19 @@ class ZipManager {
221
233
  return this.#dirtyFiles.get(normalPath)
222
234
  }
223
235
 
236
+ if (this.#cachedFiles && this.#cachedFiles.has(normalPath)) {
237
+ const entry = this.#cachedFiles.get(normalPath)
238
+ let content
239
+ if (entry.type === 'text') {
240
+ content = entry.content
241
+ } else {
242
+ const { TextDecoder } = require('util')
243
+ content = new TextDecoder('utf-8').decode(entry.content)
244
+ }
245
+ this.#xmlCache.set(normalPath, content)
246
+ return content
247
+ }
248
+
224
249
  if (this.#isFolderMode) {
225
250
  const path = require('path')
226
251
  const fs = require('fs-extra')
@@ -234,6 +259,7 @@ class ZipManager {
234
259
  return content
235
260
  }
236
261
 
262
+ if (!this.#zip) return null
237
263
  const file = this.#zip.file(normalPath)
238
264
  if (!file) {
239
265
  logger.debug(`File not found in ZIP: ${normalPath}`)
@@ -256,7 +282,22 @@ class ZipManager {
256
282
  if (this.#dirtyFiles.has(normalPath)) {
257
283
  return this.#dirtyFiles.get(normalPath)
258
284
  }
259
- return this.#xmlCache.get(normalPath) || null
285
+ if (this.#xmlCache.has(normalPath)) {
286
+ return this.#xmlCache.get(normalPath)
287
+ }
288
+ if (this.#cachedFiles && this.#cachedFiles.has(normalPath)) {
289
+ const entry = this.#cachedFiles.get(normalPath)
290
+ let content
291
+ if (entry.type === 'text') {
292
+ content = entry.content
293
+ } else {
294
+ const { TextDecoder } = require('util')
295
+ content = new TextDecoder('utf-8').decode(entry.content)
296
+ }
297
+ this.#xmlCache.set(normalPath, content)
298
+ return content
299
+ }
300
+ return null
260
301
  }
261
302
 
262
303
  /**
@@ -270,6 +311,10 @@ class ZipManager {
270
311
  if (this.#dirtyBinaryFiles.has(normalPath)) {
271
312
  return this.#dirtyBinaryFiles.get(normalPath)
272
313
  }
314
+ if (this.#cachedFiles && this.#cachedFiles.has(normalPath)) {
315
+ const entry = this.#cachedFiles.get(normalPath)
316
+ return entry.content
317
+ }
273
318
  if (this.#isFolderMode) {
274
319
  const path = require('path')
275
320
  const fs = require('fs-extra')
@@ -277,6 +322,7 @@ class ZipManager {
277
322
  if (!(await fs.pathExists(diskPath))) return null
278
323
  return fs.readFile(diskPath)
279
324
  }
325
+ if (!this.#zip) return null
280
326
  const file = this.#zip.file(normalPath)
281
327
  if (!file) return null
282
328
  return file.async('uint8array')
@@ -354,10 +400,11 @@ class ZipManager {
354
400
  const normalPath = zipPath.replace(/\\/g, '/')
355
401
  if (this.#removedFiles.has(normalPath)) return false
356
402
  if (this.#dirtyFiles.has(normalPath) || this.#dirtyBinaryFiles.has(normalPath)) return true
403
+ if (this.#cachedFiles && this.#cachedFiles.has(normalPath)) return true
357
404
  if (this.#isFolderMode) {
358
405
  return this.#folderFiles.has(normalPath)
359
406
  }
360
- return this.#zip.file(normalPath) !== null
407
+ return this.#zip && this.#zip.file(normalPath) !== null
361
408
  }
362
409
 
363
410
  /**
@@ -367,6 +414,14 @@ class ZipManager {
367
414
  * @returns {string[]} Array of matching file paths.
368
415
  */
369
416
  listFiles(prefix = '') {
417
+ if (this.#cachedFiles) {
418
+ const allFiles = new Set([
419
+ ...this.#cachedFiles.keys(),
420
+ ...this.#dirtyFiles.keys(),
421
+ ...this.#dirtyBinaryFiles.keys(),
422
+ ])
423
+ return Array.from(allFiles).filter(f => !this.#removedFiles.has(f) && f.startsWith(prefix))
424
+ }
370
425
  if (this.#isFolderMode) {
371
426
  const allFiles = new Set([
372
427
  ...this.#folderFiles,
@@ -375,6 +430,7 @@ class ZipManager {
375
430
  ])
376
431
  return Array.from(allFiles).filter(f => !this.#removedFiles.has(f) && f.startsWith(prefix))
377
432
  }
433
+ if (!this.#zip) return []
378
434
  return Object.keys(this.#zip.files).filter(f => !this.#zip.files[f].dir && f.startsWith(prefix))
379
435
  }
380
436
 
@@ -384,23 +440,43 @@ class ZipManager {
384
440
  *
385
441
  * @returns {Promise<Buffer>} Compressed PPTX as a Buffer.
386
442
  */
387
- async toBuffer() {
443
+ async toBuffer(options = {}) {
388
444
  await this.#ensureZipForExport()
389
- return this.#zip.generateAsync({
390
- type: 'nodebuffer',
391
- compression: 'DEFLATE',
392
- compressionOptions: { level: 6 },
393
- })
445
+ const zipOptions = this.#getZipOptions(options)
446
+ return this.#zip.generateAsync(zipOptions)
394
447
  }
395
448
 
396
- async toStream() {
449
+ async toStream(options = {}) {
397
450
  await this.#ensureZipForExport()
398
- return this.#zip.generateNodeStream({
451
+ const zipOptions = this.#getZipOptions(options)
452
+ zipOptions.streamFiles = true
453
+ return this.#zip.generateNodeStream(zipOptions)
454
+ }
455
+
456
+ #getZipOptions(options = {}) {
457
+ const compression = options.compression || 'balanced'
458
+ let method = 'DEFLATE'
459
+ let level = 6
460
+
461
+ if (compression === 'none' || compression === 'store') {
462
+ method = 'STORE'
463
+ level = 0
464
+ } else if (compression === 'fast') {
465
+ method = 'DEFLATE'
466
+ level = 1
467
+ } else if (compression === 'balanced') {
468
+ method = 'DEFLATE'
469
+ level = 6
470
+ } else if (compression === 'maximum') {
471
+ method = 'DEFLATE'
472
+ level = 9
473
+ }
474
+
475
+ return {
399
476
  type: 'nodebuffer',
400
- compression: 'DEFLATE',
401
- compressionOptions: { level: 6 },
402
- streamFiles: true,
403
- })
477
+ compression: method,
478
+ compressionOptions: method === 'DEFLATE' ? { level } : undefined,
479
+ }
404
480
  }
405
481
 
406
482
  /**
@@ -594,6 +670,36 @@ class ZipManager {
594
670
 
595
671
  const zip = new JSZip()
596
672
 
673
+ if (this.#cachedFiles) {
674
+ // 1. Read all files from cache (that are not removed)
675
+ for (const [relPath, entry] of this.#cachedFiles.entries()) {
676
+ if (this.#removedFiles.has(relPath)) continue
677
+
678
+ if (this.#dirtyFiles.has(relPath)) {
679
+ zip.file(relPath, this.#dirtyFiles.get(relPath))
680
+ } else if (this.#dirtyBinaryFiles.has(relPath)) {
681
+ zip.file(relPath, this.#dirtyBinaryFiles.get(relPath))
682
+ } else {
683
+ zip.file(relPath, entry.content)
684
+ }
685
+ }
686
+
687
+ // 2. Write any new files that were added (and not already in cache)
688
+ for (const [relPath, content] of this.#dirtyFiles.entries()) {
689
+ if (!this.#cachedFiles.has(relPath) && !this.#removedFiles.has(relPath)) {
690
+ zip.file(relPath, content)
691
+ }
692
+ }
693
+ for (const [relPath, data] of this.#dirtyBinaryFiles.entries()) {
694
+ if (!this.#cachedFiles.has(relPath) && !this.#removedFiles.has(relPath)) {
695
+ zip.file(relPath, data)
696
+ }
697
+ }
698
+
699
+ this.#zip = zip
700
+ return zip
701
+ }
702
+
597
703
  // 1. Read all files from the original folder structure (that are not removed)
598
704
  for (const relPath of this.#folderFiles) {
599
705
  if (this.#removedFiles.has(relPath)) continue
@@ -184,7 +184,7 @@ class ChartWorkbookUpdater {
184
184
  static #serializeSheetXml(sheetXml, cells) {
185
185
  // Group cells by row
186
186
  const rows = {}
187
- for (const [ref, val] of Object.entries(cells)) {
187
+ for (const ref of Object.keys(cells)) {
188
188
  const rowMatch = /\d+$/.exec(ref)
189
189
  if (!rowMatch) continue
190
190
  const r = parseInt(rowMatch[0], 10)
@@ -70,10 +70,9 @@ class ContentTypesHelper {
70
70
  */
71
71
  addMediaDefault(zipManager, extension, mimeType) {
72
72
  this.#updateQueue = this.#updateQueue.then(async () => {
73
- const xmlFile = zipManager.rawZip.file('[Content_Types].xml')
74
- if (!xmlFile) return
73
+ const content = await zipManager.readFile('[Content_Types].xml')
74
+ if (!content) return
75
75
 
76
- const content = await xmlFile.async('text')
77
76
  const entry = `Extension="${extension}" ContentType="${mimeType}"`
78
77
  if (!content.includes(entry)) {
79
78
  const updated = content.replace('</Types>', ` <Default ${entry}/>\n</Types>`)
@@ -92,12 +91,11 @@ class ContentTypesHelper {
92
91
  */
93
92
  #addOverride(zipManager, partName, contentType) {
94
93
  this.#updateQueue = this.#updateQueue.then(async () => {
95
- const xmlFile = zipManager.rawZip.file('[Content_Types].xml')
96
- if (!xmlFile) {
94
+ const content = await zipManager.readFile('[Content_Types].xml')
95
+ if (!content) {
97
96
  logger.warn('[Content_Types].xml not found')
98
97
  return
99
98
  }
100
- const content = await xmlFile.async('text')
101
99
  const entry = `PartName="${partName}"`
102
100
  if (!content.includes(entry)) {
103
101
  const override = `<Override PartName="${partName}" ContentType="${contentType}"/>`
@@ -115,12 +113,11 @@ class ContentTypesHelper {
115
113
  */
116
114
  #removeOverride(zipManager, partName) {
117
115
  this.#updateQueue = this.#updateQueue.then(async () => {
118
- const xmlFile = zipManager.rawZip.file('[Content_Types].xml')
119
- if (!xmlFile) {
116
+ const content = await zipManager.readFile('[Content_Types].xml')
117
+ if (!content) {
120
118
  logger.warn('[Content_Types].xml not found')
121
119
  return
122
120
  }
123
- const content = await xmlFile.async('text')
124
121
  const regex = new RegExp(`<Override[^>]*PartName="${partName}"[^>]*/>\\s*`, 'g')
125
122
  if (regex.test(content)) {
126
123
  const updated = content.replace(regex, '')
@@ -0,0 +1,227 @@
1
+ /**
2
+ * @fileoverview imageMetadata - Pure JS helper to read image dimensions without full file reads.
3
+ */
4
+
5
+ const fs = require('fs')
6
+ const { promisify } = require('util')
7
+ const openAsync = promisify(fs.open)
8
+ const readAsync = promisify(fs.read)
9
+ const closeAsync = promisify(fs.close)
10
+
11
+ /**
12
+ * Reads image dimensions and aspect ratio from a file path or buffer.
13
+ *
14
+ * @param {string|Buffer} source - File path or image Buffer.
15
+ * @returns {Promise<{ width: number, height: number, aspectRatio: number, type: string }>}
16
+ */
17
+ async function getImageMetadata(source) {
18
+ let buffer
19
+ const isBuffer = Buffer.isBuffer(source) || source instanceof Uint8Array
20
+
21
+ if (isBuffer) {
22
+ buffer = Buffer.isBuffer(source) ? source : Buffer.from(source)
23
+ } else if (typeof source === 'string') {
24
+ // Read only the first 8KB of the file
25
+ let fd
26
+ try {
27
+ fd = await openAsync(source, 'r')
28
+ const tempBuffer = Buffer.alloc(8192)
29
+ const { bytesRead } = await readAsync(fd, tempBuffer, 0, 8192, 0)
30
+ buffer = tempBuffer.subarray(0, bytesRead)
31
+ } finally {
32
+ if (fd !== undefined) {
33
+ await closeAsync(fd).catch(() => {})
34
+ }
35
+ }
36
+ } else {
37
+ throw new Error('Unsupported image source type')
38
+ }
39
+
40
+ if (buffer.length < 4) {
41
+ throw new Error('Image file is too small or corrupt')
42
+ }
43
+
44
+ // Detect image type by magic bytes
45
+ if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
46
+ return parsePng(buffer)
47
+ }
48
+
49
+ if (buffer[0] === 0xff && buffer[1] === 0xd8) {
50
+ return parseJpeg(buffer)
51
+ }
52
+
53
+ if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
54
+ return parseGif(buffer)
55
+ }
56
+
57
+ if (buffer[0] === 0x42 && buffer[1] === 0x4d) {
58
+ return parseBmp(buffer)
59
+ }
60
+
61
+ // Check for SVG (starts with XML declaration or <svg)
62
+ const textContent = buffer.toString('utf8').trim()
63
+ if (
64
+ textContent.startsWith('<svg') ||
65
+ textContent.includes('<svg') ||
66
+ textContent.startsWith('<?xml')
67
+ ) {
68
+ return parseSvg(textContent)
69
+ }
70
+
71
+ throw new Error('Unsupported image format or unrecognized signature')
72
+ }
73
+
74
+ function parsePng(buffer) {
75
+ if (buffer.length < 24) {
76
+ throw new Error('PNG header too short')
77
+ }
78
+ // Width is at offset 16 (4 bytes, big endian)
79
+ const width = buffer.readUInt32BE(16)
80
+ // Height is at offset 20 (4 bytes, big endian)
81
+ const height = buffer.readUInt32BE(20)
82
+
83
+ return {
84
+ width,
85
+ height,
86
+ aspectRatio: width / height,
87
+ type: 'png',
88
+ }
89
+ }
90
+
91
+ function parseGif(buffer) {
92
+ if (buffer.length < 10) {
93
+ throw new Error('GIF header too short')
94
+ }
95
+ // Width is at offset 6 (2 bytes, little endian)
96
+ const width = buffer.readUInt16LE(6)
97
+ // Height is at offset 8 (2 bytes, little endian)
98
+ const height = buffer.readUInt16LE(8)
99
+
100
+ return {
101
+ width,
102
+ height,
103
+ aspectRatio: width / height,
104
+ type: 'gif',
105
+ }
106
+ }
107
+
108
+ function parseBmp(buffer) {
109
+ if (buffer.length < 26) {
110
+ throw new Error('BMP header too short')
111
+ }
112
+ // Width is at offset 18 (4 bytes, little endian)
113
+ const width = buffer.readInt32LE(18)
114
+ // Height is at offset 22 (4 bytes, little endian)
115
+ const height = buffer.readInt32LE(22)
116
+
117
+ return {
118
+ width: Math.abs(width),
119
+ height: Math.abs(height),
120
+ aspectRatio: Math.abs(width) / Math.abs(height),
121
+ type: 'bmp',
122
+ }
123
+ }
124
+
125
+ function parseJpeg(buffer) {
126
+ let offset = 2 // Skip SOI marker (FF D8)
127
+
128
+ while (offset < buffer.length - 8) {
129
+ // Check marker signature
130
+ if (buffer[offset] !== 0xff) {
131
+ // Not a valid marker, search next FF
132
+ offset++
133
+ continue
134
+ }
135
+
136
+ // Skip extra FF padding
137
+ while (buffer[offset] === 0xff && offset < buffer.length) {
138
+ offset++
139
+ }
140
+
141
+ if (offset >= buffer.length) break
142
+
143
+ const marker = buffer[offset]
144
+ offset++
145
+
146
+ // SOI, EOI, TEM have no length
147
+ if (marker === 0xd8 || marker === 0xd9 || marker === 0x01) {
148
+ continue
149
+ }
150
+
151
+ // Read segment length (2 bytes, big endian)
152
+ const length = buffer.readUInt16BE(offset)
153
+
154
+ // Check SOF markers: C0-C3, C5-CB, CD-CF
155
+ const isSOF =
156
+ (marker >= 0xc0 && marker <= 0xc3) ||
157
+ (marker >= 0xc5 && marker <= 0xcb) ||
158
+ (marker >= 0xcd && marker <= 0xcf)
159
+
160
+ if (isSOF) {
161
+ // SOF structure:
162
+ // Offset 0: precision (1 byte)
163
+ // Offset 1: height (2 bytes, big endian)
164
+ // Offset 3: width (2 bytes, big endian)
165
+ const height = buffer.readUInt16BE(offset + 3)
166
+ const width = buffer.readUInt16BE(offset + 5)
167
+
168
+ return {
169
+ width,
170
+ height,
171
+ aspectRatio: width / height,
172
+ type: 'jpg',
173
+ }
174
+ }
175
+
176
+ // Advance to next marker
177
+ offset += length
178
+ }
179
+
180
+ throw new Error('Could not find JPEG SOF marker')
181
+ }
182
+
183
+ function parseSvg(text) {
184
+ // Try finding <svg ...> tag
185
+ const svgMatch = /<svg([^>]+)>/i.exec(text)
186
+ if (!svgMatch) {
187
+ throw new Error('Invalid SVG: missing <svg> tag')
188
+ }
189
+
190
+ const svgAttr = svgMatch[1]
191
+
192
+ const widthMatch = /width\s*=\s*["']([^"']+)["']/i.exec(svgAttr)
193
+ const heightMatch = /height\s*=\s*["']([^"']+)["']/i.exec(svgAttr)
194
+ const viewBoxMatch = /viewBox\s*=\s*["']([^"']+)["']/i.exec(svgAttr)
195
+
196
+ let width = 0
197
+ let height = 0
198
+
199
+ if (widthMatch) width = parseFloat(widthMatch[1])
200
+ if (heightMatch) height = parseFloat(heightMatch[1])
201
+
202
+ // If width/height missing or using units, fallback to viewBox
203
+ if ((!width || !height || isNaN(width) || isNaN(height)) && viewBoxMatch) {
204
+ const parts = viewBoxMatch[1].trim().split(/\s+/)
205
+ if (parts.length === 4) {
206
+ const vbWidth = parseFloat(parts[2])
207
+ const vbHeight = parseFloat(parts[3])
208
+ if (!isNaN(vbWidth) && !isNaN(vbHeight)) {
209
+ width = width || vbWidth
210
+ height = height || vbHeight
211
+ }
212
+ }
213
+ }
214
+
215
+ // Default fallbacks if everything fails
216
+ width = width || 800
217
+ height = height || 600
218
+
219
+ return {
220
+ width,
221
+ height,
222
+ aspectRatio: width / height,
223
+ type: 'svg',
224
+ }
225
+ }
226
+
227
+ module.exports = { getImageMetadata }