Skip to content

Commit 04922fe

Browse files
authored
feat(image): various improvements (#3264)
1 parent f034491 commit 04922fe

File tree

6 files changed

+164
-57
lines changed

6 files changed

+164
-57
lines changed

.changeset/quick-camels-grin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-pdf/image": patch
3+
---
4+
5+
feat(image): various improvements

packages/image/src/cache.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
11
const createCache = <T>({ limit = 100 } = {}) => {
2-
let cache: Record<string, T> = {};
3-
let keys: string[] = [];
2+
let cache = new Map<string, T>();
43

54
return {
6-
get: (key: string | null): T | null => (key ? cache[key] : null),
5+
get: (key: string | null): T | undefined | null =>
6+
key ? cache.get(key) ?? undefined : null,
77
set: (key: string, value: T) => {
8-
keys.push(key);
9-
if (keys.length > limit) {
10-
delete cache[keys.shift()!];
8+
cache.delete(key);
9+
10+
if (cache.size >= limit) {
11+
const firstKey = cache.keys().next().value as string;
12+
cache.delete(firstKey);
1113
}
12-
cache[key] = value;
14+
15+
cache.set(key, value);
1316
},
1417
reset: () => {
15-
cache = {};
16-
keys = [];
18+
cache = new Map();
1719
},
18-
length: () => keys.length,
20+
length: () => cache.size,
1921
};
2022
};
2123

packages/image/src/png.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,18 @@ class PNG implements Image {
1818
}
1919

2020
static isValid(data: Buffer): boolean {
21-
try {
22-
return !!new PNG(data);
23-
} catch {
24-
return false;
25-
}
21+
return (
22+
data &&
23+
Buffer.isBuffer(data) &&
24+
data[0] === 137 &&
25+
data[1] === 80 &&
26+
data[2] === 78 &&
27+
data[3] === 71 &&
28+
data[4] === 13 &&
29+
data[5] === 10 &&
30+
data[6] === 26 &&
31+
data[7] === 10
32+
);
2633
}
2734
}
2835

packages/image/src/resolve.ts

Lines changed: 14 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,8 @@ const isDataImageSrc = (src: ImageSrc): src is DataImageSrc => {
2727
return 'data' in src;
2828
};
2929

30-
const isBase64Src = (imageSrc: ImageSrc): imageSrc is Base64ImageSrc =>
31-
'uri' in imageSrc &&
32-
/^data:image\/[a-zA-Z]*;base64,[^"]*/g.test(imageSrc.uri);
30+
const isDataUri = (imageSrc: ImageSrc): imageSrc is Base64ImageSrc =>
31+
'uri' in imageSrc && imageSrc.uri.startsWith('data:');
3332

3433
const getAbsoluteLocalPath = (src: string) => {
3534
if (BROWSER) {
@@ -58,14 +57,14 @@ const fetchLocalFile = (src: LocalImageSrc): Promise<Buffer> =>
5857
new Promise((resolve, reject) => {
5958
try {
6059
if (BROWSER) {
61-
reject(new Error('Cannot fetch local file in this environemnt'));
60+
reject(new Error('Cannot fetch local file in this environment'));
6261
return;
6362
}
6463

6564
const absolutePath = getAbsoluteLocalPath(src.uri);
6665

6766
if (!absolutePath) {
68-
reject(new Error(`Cannot fetch non-local path: ${src}`));
67+
reject(new Error(`Cannot fetch non-local path: ${src.uri}`));
6968
return;
7069
}
7170

@@ -97,13 +96,13 @@ const isValidFormat = (format: string): format is ImageFormat => {
9796
return lower === 'jpg' || lower === 'jpeg' || lower === 'png';
9897
};
9998

100-
const guessFormat = (buffer: Buffer) => {
99+
const getImageFormat = (buffer: Buffer) => {
101100
let format;
102101

103102
if (JPEG.isValid(buffer)) {
104-
format = 'jpg';
103+
format = 'jpg' as const;
105104
} else if (PNG.isValid(buffer)) {
106-
format = 'png';
105+
format = 'png' as const;
107106
}
108107

109108
return format;
@@ -144,7 +143,7 @@ const resolveImageFromData = async (src: DataImageSrc) => {
144143
};
145144

146145
const resolveBufferImage = async (buffer: Buffer) => {
147-
const format = guessFormat(buffer);
146+
const format = getImageFormat(buffer);
148147

149148
if (format) {
150149
return getImage(buffer, format);
@@ -176,31 +175,6 @@ const resolveBlobImage = async (blob: Blob) => {
176175
return getImage(Buffer.from(buffer), format);
177176
};
178177

179-
const getImageFormat = (body: Buffer) => {
180-
const isPng =
181-
body[0] === 137 &&
182-
body[1] === 80 &&
183-
body[2] === 78 &&
184-
body[3] === 71 &&
185-
body[4] === 13 &&
186-
body[5] === 10 &&
187-
body[6] === 26 &&
188-
body[7] === 10;
189-
190-
const isJpg = body[0] === 255 && body[1] === 216 && body[2] === 255;
191-
192-
let extension = '';
193-
if (isPng) {
194-
extension = 'png';
195-
} else if (isJpg) {
196-
extension = 'jpg';
197-
} else {
198-
throw new Error('Not valid image extension');
199-
}
200-
201-
return extension;
202-
};
203-
204178
const resolveImageFromUrl = async (src: LocalImageSrc | RemoteImageSrc) => {
205179
const data =
206180
!BROWSER && getAbsoluteLocalPath(src.uri)
@@ -209,13 +183,17 @@ const resolveImageFromUrl = async (src: LocalImageSrc | RemoteImageSrc) => {
209183

210184
const format = getImageFormat(data);
211185

186+
if (!format) {
187+
throw new Error('Not valid image extension');
188+
}
189+
212190
return getImage(data, format);
213191
};
214192

215193
const getCacheKey = (src: ImageSrc): string | null => {
216194
if (isBlob(src) || isBuffer(src)) return null;
217195

218-
if (isDataImageSrc(src)) return src.data.toString();
196+
if (isDataImageSrc(src)) return src.data?.toString('base64') ?? null;
219197

220198
return src.uri;
221199
};
@@ -231,18 +209,14 @@ const resolveImage = (src: ImageSrc, { cache = true } = {}) => {
231209
image = resolveBufferImage(src);
232210
} else if (cache && IMAGE_CACHE.get(cacheKey)) {
233211
return IMAGE_CACHE.get(cacheKey);
234-
} else if (isBase64Src(src)) {
212+
} else if (isDataUri(src)) {
235213
image = resolveBase64Image(src);
236214
} else if (isDataImageSrc(src)) {
237215
image = resolveImageFromData(src);
238216
} else {
239217
image = resolveImageFromUrl(src);
240218
}
241219

242-
if (!image) {
243-
throw new Error('Cannot resolve image');
244-
}
245-
246220
if (cache && cacheKey) {
247221
IMAGE_CACHE.set(cacheKey, image);
248222
}

packages/image/tests/cache.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,48 @@ describe('Background', () => {
5252
expect(cache.get('4')).toBe('4');
5353
expect(cache.get('5')).toBe('5');
5454
});
55+
56+
test('should return null when getting with null key', () => {
57+
const cache = createCache();
58+
cache.set('somekey', 'somevalue');
59+
expect(cache.get(null)).toBeNull();
60+
});
61+
62+
test('should use default limit of 100', () => {
63+
const cache = createCache();
64+
65+
for (let i = 0; i < 105; i++) {
66+
cache.set(`key${i}`, `value${i}`);
67+
}
68+
69+
expect(cache.length()).toBe(100);
70+
expect(cache.get('key0')).toBe(undefined);
71+
expect(cache.get('key4')).toBe(undefined);
72+
expect(cache.get('key5')).toBe('value5');
73+
expect(cache.get('key104')).toBe('value104');
74+
});
75+
76+
test('should overwrite existing key without increasing length', () => {
77+
const cache = createCache({ limit: 3 });
78+
79+
cache.set('key1', 'value1');
80+
cache.set('key2', 'value2');
81+
cache.set('key1', 'updatedValue1');
82+
83+
expect(cache.get('key1')).toBe('updatedValue1');
84+
expect(cache.length()).toBe(2);
85+
});
86+
87+
test('should handle limit of 1', () => {
88+
const cache = createCache({ limit: 1 });
89+
90+
cache.set('key1', 'value1');
91+
expect(cache.get('key1')).toBe('value1');
92+
expect(cache.length()).toBe(1);
93+
94+
cache.set('key2', 'value2');
95+
expect(cache.get('key1')).toBe(undefined);
96+
expect(cache.get('key2')).toBe('value2');
97+
expect(cache.length()).toBe(1);
98+
});
5599
});

packages/image/tests/resolve.test.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ describe('image resolveImage', () => {
101101
expect(image?.height).toBeGreaterThan(0);
102102
});
103103

104-
105104
test('Should render a local image from relative path', async () => {
106105
const image = await resolveImage({
107106
uri: 'packages/layout/tests/assets/test.jpg',
@@ -112,7 +111,6 @@ describe('image resolveImage', () => {
112111
expect(image?.height).toBeGreaterThan(0);
113112
});
114113

115-
116114
test('Should render a local image from src object', async () => {
117115
const image = await resolveImage({
118116
uri: './packages/layout/tests/assets/test.jpg',
@@ -232,4 +230,81 @@ describe('image resolveImage', () => {
232230
expect(image?.width).toBeGreaterThan(0);
233231
expect(image?.height).toBeGreaterThan(0);
234232
});
233+
234+
test('Should throw error for unsupported base64 format', async () => {
235+
await expect(
236+
resolveImage({
237+
uri: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
238+
}),
239+
).rejects.toThrow('Base64 image invalid format: gif');
240+
});
241+
242+
test('Should throw error for invalid base64 URI', async () => {
243+
await expect(
244+
resolveImage({ uri: 'data:image/pngbase64,invalid' } as any),
245+
).rejects.toThrow('Invalid base64 image');
246+
});
247+
248+
test('Should throw error for invalid blob type', async () => {
249+
const blob = new Blob([localJPGImage], { type: 'text/plain' });
250+
await expect(resolveImage(blob)).rejects.toThrow(
251+
'Invalid blob type: text/plain',
252+
);
253+
});
254+
255+
test('Should throw error for unsupported blob image type', async () => {
256+
const blob = new Blob([localJPGImage], { type: 'image/gif' });
257+
await expect(resolveImage(blob)).rejects.toThrow(
258+
'Invalid blob type: image/gif',
259+
);
260+
});
261+
262+
test('Should throw error for invalid data source', async () => {
263+
await expect(
264+
resolveImage({ data: undefined as any, format: 'jpg' }),
265+
).rejects.toThrow('Invalid data given for local file');
266+
});
267+
268+
test('Should throw error for non-existent local file', async () => {
269+
await expect(
270+
resolveImage({ uri: '/nonexistent/path/image.jpg' }),
271+
).rejects.toThrow();
272+
});
273+
274+
test('Should return null for invalid buffer', async () => {
275+
const invalidBuffer = Buffer.from('not an image');
276+
const image = await resolveImage(invalidBuffer);
277+
expect(image).toBeNull();
278+
});
279+
280+
test('Should throw on network fetch failure', async () => {
281+
fetchMock.once(() => {
282+
throw new Error('Network error');
283+
});
284+
285+
await expect(resolveImage({ uri: jpgImageUrl })).rejects.toThrow(
286+
'Network error',
287+
);
288+
});
289+
290+
test('Should throw for unsupported remote image format', async () => {
291+
// GIF89a magic bytes - neither JPEG nor PNG
292+
const gifBuffer = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
293+
fetchMock.once(gifBuffer);
294+
295+
await expect(
296+
resolveImage({ uri: 'https://example.com/image.gif' }),
297+
).rejects.toThrow('Not valid image extension');
298+
});
299+
300+
test('Should cache DataImageSrc images using base64 key', async () => {
301+
const image1 = await resolveImage({ data: localJPGImage, format: 'jpg' });
302+
const image2 = await resolveImage({ data: localJPGImage, format: 'jpg' });
303+
304+
expect(image1).toBe(image2);
305+
});
306+
307+
test('Should return null when getting cache with null key', () => {
308+
expect(IMAGE_CACHE.get(null)).toBeNull();
309+
});
235310
});

0 commit comments

Comments
 (0)