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.
- package/README.md +178 -4
- package/package.json +1 -1
- package/src/core/OutputWriter.js +12 -14
- package/src/core/PPTXTemplater.js +205 -5
- package/src/managers/ChartManager.js +0 -3
- package/src/managers/ImageManager.js +14 -18
- package/src/managers/MediaManager.js +151 -35
- package/src/managers/ShapeManager.js +19 -28
- package/src/managers/SlideManager.js +247 -4
- package/src/managers/TableManager.js +56 -87
- package/src/managers/ZOrderManager.js +0 -5
- package/src/managers/ZipManager.js +120 -14
- package/src/managers/charts/ChartWorkbookUpdater.js +1 -1
- package/src/utils/contentTypesHelper.js +6 -9
- package/src/utils/imageMetadata.js +227 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
compression: 'STORE',
|
|
392
|
-
compressionOptions: { level: 0 },
|
|
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
|
-
|
|
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:
|
|
401
|
-
compressionOptions: { level:
|
|
402
|
-
|
|
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
|
|
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
|
|
74
|
-
if (!
|
|
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
|
|
96
|
-
if (!
|
|
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
|
|
119
|
-
if (!
|
|
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 }
|