Skip to content

Commit d5f9f89

Browse files
authored
Merge pull request #187 from masenov/load_media_buffers
feat: Add buffer-based media loading support for cloud storage integration
2 parents ca095b6 + 5bf15f6 commit d5f9f89

File tree

4 files changed

+355
-7
lines changed

4 files changed

+355
-7
lines changed
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import Automizer from '../src/automizer';
2+
import { ModifyImageHelper } from '../src';
3+
import * as fs from 'fs';
4+
5+
const templateDir = `${__dirname}/pptx-templates`;
6+
const outputDir = `${__dirname}/pptx-output`;
7+
const mediaDir = `${__dirname}/media`;
8+
9+
describe('loadMediaBuffer', () => {
10+
test('load single media file from buffer', async () => {
11+
const buffer = fs.readFileSync(`${mediaDir}/test.png`);
12+
const automizer = new Automizer({
13+
templateDir,
14+
outputDir,
15+
})
16+
.loadRoot('RootTemplate.pptx')
17+
.loadMediaBuffer('logo.png', buffer);
18+
19+
expect(automizer.rootTemplate.mediaFiles.length).toBe(1);
20+
expect(automizer.rootTemplate.mediaFiles[0].source).toBe('buffer');
21+
expect(automizer.rootTemplate.mediaFiles[0].file).toBe('logo.png');
22+
expect(automizer.rootTemplate.mediaFiles[0].extension).toBe('png');
23+
});
24+
25+
test('load multiple media files from buffers', async () => {
26+
const buffer1 = fs.readFileSync(`${mediaDir}/test.png`);
27+
const buffer2 = fs.readFileSync(`${mediaDir}/feather.png`);
28+
29+
const automizer = new Automizer({
30+
templateDir,
31+
outputDir,
32+
})
33+
.loadRoot('RootTemplate.pptx')
34+
.loadMediaBuffer(['logo.png', 'icon.png'], [buffer1, buffer2]);
35+
36+
expect(automizer.rootTemplate.mediaFiles.length).toBe(2);
37+
expect(automizer.rootTemplate.mediaFiles[0].source).toBe('buffer');
38+
expect(automizer.rootTemplate.mediaFiles[0].file).toBe('logo.png');
39+
expect(automizer.rootTemplate.mediaFiles[1].source).toBe('buffer');
40+
expect(automizer.rootTemplate.mediaFiles[1].file).toBe('icon.png');
41+
});
42+
43+
test('load buffer with prefix', async () => {
44+
const buffer = fs.readFileSync(`${mediaDir}/test.png`);
45+
46+
const automizer = new Automizer({
47+
templateDir,
48+
outputDir,
49+
})
50+
.loadRoot('RootTemplate.pptx')
51+
.loadMediaBuffer('logo.png', buffer, 'test_');
52+
53+
expect(automizer.rootTemplate.mediaFiles.length).toBe(1);
54+
expect(automizer.rootTemplate.mediaFiles[0].prefix).toBe('test_');
55+
});
56+
57+
test('load both file-based and buffer-based media', async () => {
58+
const buffer = fs.readFileSync(`${mediaDir}/test.png`);
59+
60+
const automizer = new Automizer({
61+
templateDir,
62+
outputDir,
63+
mediaDir,
64+
})
65+
.loadRoot('RootTemplate.pptx')
66+
.loadMedia('feather.png')
67+
.loadMediaBuffer('buffer.png', buffer);
68+
69+
expect(automizer.rootTemplate.mediaFiles.length).toBe(2);
70+
expect(automizer.rootTemplate.mediaFiles[0].source).toBe('path');
71+
expect(automizer.rootTemplate.mediaFiles[0].file).toBe('feather.png');
72+
expect(automizer.rootTemplate.mediaFiles[1].source).toBe('buffer');
73+
expect(automizer.rootTemplate.mediaFiles[1].file).toBe('buffer.png');
74+
});
75+
76+
test('throw error for filename without extension', () => {
77+
const buffer = fs.readFileSync(`${mediaDir}/test.png`);
78+
79+
const automizer = new Automizer({
80+
templateDir,
81+
outputDir,
82+
}).loadRoot('RootTemplate.pptx');
83+
84+
expect(() => {
85+
automizer.loadMediaBuffer('logo', buffer);
86+
}).toThrow('Filename must include extension');
87+
});
88+
89+
test('throw error for unsupported extension', () => {
90+
const buffer = fs.readFileSync(`${mediaDir}/test.png`);
91+
92+
const automizer = new Automizer({
93+
templateDir,
94+
outputDir,
95+
}).loadRoot('RootTemplate.pptx');
96+
97+
expect(() => {
98+
automizer.loadMediaBuffer('payload.exe', buffer);
99+
}).toThrow('Unsupported media extension');
100+
});
101+
102+
test('throw error for empty buffer', () => {
103+
const automizer = new Automizer({
104+
templateDir,
105+
outputDir,
106+
}).loadRoot('RootTemplate.pptx');
107+
108+
expect(() => {
109+
automizer.loadMediaBuffer('logo.png', Buffer.alloc(0));
110+
}).toThrow('Empty buffer provided');
111+
});
112+
113+
test('throw error for invalid buffer', () => {
114+
const automizer = new Automizer({
115+
templateDir,
116+
outputDir,
117+
}).loadRoot('RootTemplate.pptx');
118+
119+
expect(() => {
120+
automizer.loadMediaBuffer('logo.png', 'not a buffer' as any);
121+
}).toThrow('Invalid buffer for file');
122+
});
123+
124+
test('throw error for mismatched arrays', () => {
125+
const buffer1 = fs.readFileSync(`${mediaDir}/test.png`);
126+
127+
const automizer = new Automizer({
128+
templateDir,
129+
outputDir,
130+
}).loadRoot('RootTemplate.pptx');
131+
132+
expect(() => {
133+
automizer.loadMediaBuffer(['a.png', 'b.png'], [buffer1]);
134+
}).toThrow('Mismatched arrays');
135+
});
136+
137+
test('throw error when root template not loaded', () => {
138+
const buffer = fs.readFileSync(`${mediaDir}/test.png`);
139+
140+
const automizer = new Automizer({
141+
templateDir,
142+
outputDir,
143+
});
144+
145+
expect(() => {
146+
automizer.loadMediaBuffer('logo.png', buffer);
147+
}).toThrow("Can't load media, you need to load a root template first");
148+
});
149+
150+
test('reference buffer-loaded image with setRelationTarget', async () => {
151+
const buffer = fs.readFileSync(`${mediaDir}/test.png`);
152+
153+
const automizer = new Automizer({
154+
templateDir,
155+
outputDir,
156+
})
157+
.loadRoot('RootTemplate.pptx')
158+
.loadMediaBuffer('custom.png', buffer)
159+
.load('SlideWithImages.pptx', 'images');
160+
161+
const pres = automizer.addSlide('images', 1, (slide) => {
162+
slide.modifyElement('Grafik 5', [
163+
ModifyImageHelper.setRelationTarget('custom.png'),
164+
]);
165+
});
166+
167+
const result = await pres.write(`buffer-image-test.pptx`);
168+
expect(result.images).toBeGreaterThanOrEqual(1);
169+
});
170+
171+
test('use setRelationTargetCover with buffer-loaded media', async () => {
172+
const buffer = fs.readFileSync(`${mediaDir}/test.png`);
173+
174+
const automizer = new Automizer({
175+
templateDir,
176+
outputDir,
177+
})
178+
.loadRoot('RootTemplate.pptx')
179+
.loadMediaBuffer('new-image.png', buffer)
180+
.load('SlideWithImages.pptx', 'images');
181+
182+
const pres = automizer.addSlide('images', 1, (slide) => {
183+
slide.modifyElement('Grafik 5', [
184+
ModifyImageHelper.setRelationTargetCover('new-image.png', automizer),
185+
]);
186+
});
187+
188+
const result = await pres.write(`buffer-image-cover-test.pptx`);
189+
expect(result.images).toBeGreaterThanOrEqual(1);
190+
});
191+
192+
test('mixed file and buffer loading with different prefixes', async () => {
193+
const buffer = fs.readFileSync(`${mediaDir}/test.png`);
194+
195+
const automizer = new Automizer({
196+
templateDir,
197+
outputDir,
198+
mediaDir,
199+
})
200+
.loadRoot('RootTemplate.pptx')
201+
.loadMedia('feather.png', undefined, 'file_')
202+
.loadMediaBuffer('test.png', buffer, 'buffer_');
203+
204+
expect(automizer.rootTemplate.mediaFiles.length).toBe(2);
205+
expect(automizer.rootTemplate.mediaFiles[0].prefix).toBe('file_');
206+
expect(automizer.rootTemplate.mediaFiles[1].prefix).toBe('buffer_');
207+
});
208+
209+
test('write buffer-loaded media to archive', async () => {
210+
const buffer = fs.readFileSync(`${mediaDir}/test.png`);
211+
212+
const automizer = new Automizer({
213+
templateDir,
214+
outputDir,
215+
})
216+
.loadRoot('RootTemplate.pptx')
217+
.loadMediaBuffer('myimage.png', buffer)
218+
.load('SlideWithImages.pptx', 'images');
219+
220+
const pres = automizer.addSlide('images', 1, (slide) => {
221+
slide.modifyElement('Grafik 5', [
222+
ModifyImageHelper.setRelationTarget('myimage.png'),
223+
]);
224+
});
225+
226+
const result = await pres.write(`buffer-write-test.pptx`);
227+
expect(result.images).toBeGreaterThanOrEqual(1);
228+
229+
// Verify the output file exists
230+
const outputPath = `${outputDir}/buffer-write-test.pptx`;
231+
expect(fs.existsSync(outputPath)).toBe(true);
232+
});
233+
});

src/automizer.ts

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
AutomizerFile,
55
AutomizerParams,
66
AutomizerSummary,
7+
getMediaBuffer,
78
PresentationInfo,
89
SourceIdentifier,
910
StatusTracker,
@@ -26,9 +27,49 @@ import {
2627
import JSZip from 'jszip';
2728
import { ISlide } from './interfaces/islide';
2829
import { IMaster } from './interfaces/imaster';
29-
import { ContentTypeExtension } from './enums/content-type-map';
30+
import { ContentTypeExtension, ContentTypeMap } from './enums/content-type-map';
3031
import slugify from 'slugify';
3132

33+
/**
34+
* Valid media file extensions based on ContentTypeMap.
35+
* Used to validate file extensions before processing.
36+
*/
37+
const VALID_MEDIA_EXTENSIONS: readonly string[] = Object.keys(ContentTypeMap) as readonly ContentTypeExtension[];
38+
39+
/**
40+
* Validates that a file extension is a supported media type.
41+
* @param extension - The file extension to validate (without leading dot)
42+
* @param filename - The original filename (for error message)
43+
* @throws {Error} If the extension is not supported
44+
*/
45+
function validateMediaExtension(extension: string, filename: string): asserts extension is ContentTypeExtension {
46+
if (!VALID_MEDIA_EXTENSIONS.includes(extension.toLowerCase())) {
47+
throw (
48+
`Unsupported media extension '${extension}' for file '${filename}'. ` +
49+
`Supported extensions: ${VALID_MEDIA_EXTENSIONS.join(', ')}`
50+
);
51+
}
52+
}
53+
54+
/**
55+
* Extracts and validates file extension.
56+
* @param filename - The filename to extract extension from
57+
* @returns The validated extension
58+
* @throws {Error} If filename has no extension or extension is not supported
59+
*/
60+
function getValidatedExtension(filename: string): ContentTypeExtension {
61+
const extension = path
62+
.extname(filename)
63+
.replace('.', '') as ContentTypeExtension;
64+
65+
if (!extension) {
66+
throw `Filename must include extension: ${filename}. Example: 'logo.png'`;
67+
}
68+
69+
validateMediaExtension(extension, filename);
70+
return extension;
71+
}
72+
3273
/**
3374
* Automizer
3475
*
@@ -237,22 +278,68 @@ export default class Automizer implements IPresentationProps {
237278
files.forEach((file) => {
238279
const directory = dir || this.params.mediaDir;
239280
const filepath = path.join(directory, file);
240-
const extension = path
241-
.extname(file)
242-
.replace('.', '') as ContentTypeExtension;
281+
const extension = getValidatedExtension(file);
243282
try {
244283
fs.accessSync(filepath, fs.constants.F_OK);
245284
} catch (e) {
246285
throw `Can't load media: ${filepath} does not exist.`;
247286
}
248287
this.rootTemplate.mediaFiles.push({
288+
source: 'path',
249289
file,
250290
directory,
251291
filepath,
252292
extension,
253293
prefix,
254294
});
255295
});
296+
297+
return this;
298+
}
299+
300+
/**
301+
* Load media files from buffers to output presentation.
302+
* @returns Instance of Automizer
303+
* @param filename Filename(s) to use (must include extension).
304+
* @param buffer Buffer(s) containing the media data.
305+
* @param prefix Optional prefix to prepend to filename in archive.
306+
*/
307+
public loadMediaBuffer(
308+
filename: string | string[],
309+
buffer: Buffer | Buffer[],
310+
prefix?: string,
311+
): this {
312+
const files = GeneralHelper.arrayify(filename);
313+
const buffers = GeneralHelper.arrayify(buffer);
314+
if (!this.rootTemplate) {
315+
throw "Can't load media, you need to load a root template first";
316+
}
317+
318+
if (files.length !== buffers.length) {
319+
throw `Mismatched arrays: ${files.length} filename(s) but ${buffers.length} buffer(s)`;
320+
}
321+
322+
files.forEach((file, index) => {
323+
const buf = buffers[index];
324+
325+
if (!Buffer.isBuffer(buf)) {
326+
throw `Invalid buffer for file: ${file}`;
327+
}
328+
if (buf.length === 0) {
329+
throw `Empty buffer provided for file: ${file}`;
330+
}
331+
332+
const extension = getValidatedExtension(file);
333+
334+
this.rootTemplate.mediaFiles.push({
335+
source: 'buffer',
336+
file,
337+
buffer: buf,
338+
extension,
339+
prefix,
340+
});
341+
});
342+
256343
return this;
257344
}
258345

@@ -510,7 +597,7 @@ export default class Automizer implements IPresentationProps {
510597
public async writeMediaFiles(): Promise<void> {
511598
const mediaDir = 'ppt/media/';
512599
for (const file of this.rootTemplate.mediaFiles) {
513-
const data = fs.readFileSync(file.filepath);
600+
const data = getMediaBuffer(file, fs.readFileSync);
514601
let archiveFilename = file.file;
515602
if (file.prefix) {
516603
archiveFilename = file.prefix + file.file;

src/helper/modify-image-helper.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { XmlElement } from '../types/xml-types';
22
import { ImageStyle } from '../types/modify-types';
3+
import { getMediaBuffer } from '../types/types';
34
import slugify from 'slugify';
45
import { imageSize } from 'image-size';
56
import fs from 'fs';
@@ -58,7 +59,7 @@ export default class ModifyImageHelper {
5859
'Media file not found in template archive in path: ' + filename,
5960
);
6061
}
61-
const buffer = fs.readFileSync(mediaFile.filepath);
62+
const buffer = getMediaBuffer(mediaFile, fs.readFileSync);
6263
const _dimensions = imageSize(buffer);
6364
newImageDimensions.width = _dimensions.width;
6465
newImageDimensions.height = _dimensions.height;

0 commit comments

Comments
 (0)