Skip to content

Commit fb6d142

Browse files
committed
✨ Support including images by URL
Images used to be registered in the document definition. In v0.5.4, support for loading images directly from the file system was added. However, this feature gives the library access to all files on the system, which may be a security concern. This commit adds support for including images by a URL. The `image` property of an image block now supports `data:`, `file:`, and `http(s):` URLs. File names are interpreted as relative to a resource root that must be set by the `setResourceRoot()` method on the `PdfMaker` class. Compatibility with the previous API is maintained, but the `images` property in a document definition is deprecated. An `fs` module is introduced to read files from the file system. Since this module is not available in browser environments, it is loaded using dynamic imports to prevent runtime errors. This required to raise the minimal ES version to 2020.
1 parent e3f72ac commit fb6d142

File tree

14 files changed

+447
-53
lines changed

14 files changed

+447
-53
lines changed

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
## [0.5.5] - Unreleased
44

5-
Minimum requirements bumped to Node 20 and npm 10.
5+
The minimum EcmaScript version has been raised to ES2020.
6+
Minimum build requirements have been raised to Node 20 and npm 10.
67

78
### Added
89

@@ -26,6 +27,15 @@ Minimum requirements bumped to Node 20 and npm 10.
2627
const pdf2 = await pdfMaker.makePdf(doc2);
2728
```
2829

30+
### Changed
31+
32+
- Fonts should now be registered with the `registerFont()` method on the
33+
`PdfMaker` class.
34+
35+
- The `image` property of an image block now supports `data:`, `file:`,
36+
and `http(s):` URLs. File names are relative to a resource root that
37+
must be set by the `setResourceRoot()` method on the `PdfMaker` class.
38+
2939
### Deprecated
3040

3141
- `TextAttrs` in favor of `TextProps`.
@@ -38,6 +48,7 @@ Minimum requirements bumped to Node 20 and npm 10.
3848
- `CircleOpts` in favor of `CircleProps`.
3949
- `PathOpts` in favor of `PathProps`.
4050
- The `fonts` property in a document definition.
51+
- The `images` property in a document definition.
4152
- The `makePdf` function in favor of the `makePdf` method on the
4253
`PdfMaker` class.
4354

src/api/PdfMaker.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ export class PdfMaker {
3838
this.#ctx.fontStore.registerFont(data, config);
3939
}
4040

41+
/**
42+
* Sets the root directory to read resources from. This allows using
43+
* `file:/` URLs with relative paths in the document definition.
44+
*
45+
* @param root The root directory to read resources from.
46+
*/
47+
setResourceRoot(root: string): void {
48+
this.#ctx.imageStore.setResourceRoot(root);
49+
}
50+
4151
/**
4252
* Generates a PDF from the given document definition.
4353
*

src/api/layout.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export type ImageBlock = {
6767
/**
6868
* Creates a block that contains an image.
6969
*
70-
* @param image The name or path of an image to display in this block.
70+
* @param image The URL of the image to display in this block.
7171
* @param props Optional properties for the block.
7272
*/
7373
export function image(image: string, props?: Omit<ImageBlock, 'image'>): ImageBlock {

src/base64.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { decodeBase64 } from './base64.ts';
4+
5+
describe('decodeBase64', () => {
6+
it('decodes base64 strings', () => {
7+
expect(decodeBase64('')).toEqual(new Uint8Array());
8+
expect(decodeBase64('AA==')).toEqual(new Uint8Array([0]));
9+
expect(decodeBase64('AAE=')).toEqual(new Uint8Array([0, 1]));
10+
expect(decodeBase64('AAEC')).toEqual(new Uint8Array([0, 1, 2]));
11+
});
12+
13+
it('decodes longer base64 strings', () => {
14+
const base64 = (input: Uint8Array) => Buffer.from(input).toString('base64');
15+
const array1 = new Uint8Array([...Array(256).keys()]);
16+
const array2 = new Uint8Array([...Array(257).keys()]);
17+
const array3 = new Uint8Array([...Array(258).keys()]);
18+
19+
expect(decodeBase64(base64(array1))).toEqual(array1);
20+
expect(decodeBase64(base64(array2))).toEqual(array2);
21+
expect(decodeBase64(base64(array3))).toEqual(array3);
22+
});
23+
24+
it('fails if string is not a multiple of 4', () => {
25+
expect(() => decodeBase64('A')).toThrow(
26+
'Invalid base64 string: length must be a multiple of 4',
27+
);
28+
expect(() => decodeBase64('AA')).toThrow(
29+
'Invalid base64 string: length must be a multiple of 4',
30+
);
31+
expect(() => decodeBase64('AAA')).toThrow(
32+
'Invalid base64 string: length must be a multiple of 4',
33+
);
34+
});
35+
36+
it('fails if string contains invalid characters', () => {
37+
expect(() => decodeBase64('ABØ=')).toThrow("Invalid Base64 character 'Ø' at position 2");
38+
});
39+
});

src/base64.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
const base64Lookup = createBase64LookupTable();
2+
3+
/**
4+
* Decodes a Base64 encoded string into a Uint8Array.
5+
*
6+
* @param base64 - The Base64 encoded string.
7+
* @returns The decoded bytes as a Uint8Array.
8+
*/
9+
export function decodeBase64(base64: string): Uint8Array {
10+
if (base64.length % 4 !== 0) {
11+
throw new Error('Invalid base64 string: length must be a multiple of 4');
12+
}
13+
14+
const len = base64.length;
15+
const padding = base64[len - 1] === '=' ? (base64[len - 2] === '=' ? 2 : 1) : 0;
16+
const bufferLength = (len * 3) / 4 - padding;
17+
const bytes = new Uint8Array(bufferLength);
18+
19+
let byteIndex = 0;
20+
for (let i = 0; i < len; i += 4) {
21+
const encoded1 = lookup(base64, i);
22+
const encoded2 = lookup(base64, i + 1);
23+
const encoded3 = lookup(base64, i + 2);
24+
const encoded4 = lookup(base64, i + 3);
25+
26+
bytes[byteIndex++] = (encoded1 << 2) | (encoded2 >> 4);
27+
if (base64[i + 2] !== '=') bytes[byteIndex++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
28+
if (base64[i + 3] !== '=') bytes[byteIndex++] = ((encoded3 & 3) << 6) | encoded4;
29+
}
30+
31+
return bytes;
32+
}
33+
34+
function lookup(string: string, pos: number): number {
35+
const code = string.charCodeAt(pos);
36+
if (code === 61) return 0; // '=' padding character
37+
if (code < base64Lookup.length) {
38+
const value = base64Lookup[code];
39+
if (value !== 255) {
40+
return value;
41+
}
42+
}
43+
throw new Error(`Invalid Base64 character '${string[pos]}' at position ${pos}`);
44+
}
45+
46+
function createBase64LookupTable(): Uint8Array {
47+
const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
48+
// 255 indicates that code does not represent a valid base64 character
49+
const table = new Uint8Array(256).fill(255);
50+
for (let i = 0; i < base64Chars.length; i++) {
51+
table[base64Chars.charCodeAt(i)] = i;
52+
}
53+
return table;
54+
}

src/data-loader.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { readFile } from 'node:fs/promises';
2+
import { join } from 'node:path';
3+
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
import { createDataLoader } from './data-loader.ts';
7+
8+
const baseDir = import.meta.dirname;
9+
const libertyJpg = await readFile(join(baseDir, 'test/resources/liberty.jpg'));
10+
11+
describe('createDataLoader', () => {
12+
const loader = createDataLoader();
13+
14+
it('throws for invalid URLs', async () => {
15+
await expect(loader('')).rejects.toThrow("Invalid URL: ''");
16+
await expect(loader('http://')).rejects.toThrow("Invalid URL: 'http://'");
17+
});
18+
19+
it('throws for unsupported URL scheme', async () => {
20+
await expect(loader('foo:bar')).rejects.toThrow("URL not supported: 'foo:bar'");
21+
});
22+
23+
describe('http:', () => {
24+
beforeEach(() => {
25+
vi.spyOn(globalThis, 'fetch').mockImplementation((req: RequestInfo | URL) => {
26+
const url = req instanceof URL ? req.href : (req as string);
27+
if (url.endsWith('image.jpg')) {
28+
return Promise.resolve(new Response(new Uint8Array([1, 2, 3])));
29+
}
30+
return Promise.resolve(new Response('Not found', { status: 404, statusText: 'Not Found' }));
31+
});
32+
});
33+
34+
afterEach(() => {
35+
vi.restoreAllMocks();
36+
});
37+
38+
it('loads http: URL', async () => {
39+
await expect(loader('http://example.com/image.jpg')).resolves.toEqual({
40+
data: new Uint8Array([1, 2, 3]),
41+
});
42+
});
43+
44+
it('loads https: URL', async () => {
45+
await expect(loader('https://example.com/image.jpg')).resolves.toEqual({
46+
data: new Uint8Array([1, 2, 3]),
47+
});
48+
});
49+
50+
it('throws if 404 received', async () => {
51+
await expect(loader('https://example.com/not-there')).rejects.toThrow(
52+
'Received 404 Not Found',
53+
);
54+
});
55+
});
56+
57+
describe('data:', () => {
58+
it('loads data: URL', async () => {
59+
await expect(loader('data:image/jpeg;base64,Abc=')).resolves.toEqual({
60+
data: new Uint8Array([1, 183]),
61+
});
62+
});
63+
64+
it('throws for invalid data: URLs', async () => {
65+
await expect(loader('data:foo')).rejects.toThrow("Invalid data URL: 'data:foo'");
66+
});
67+
68+
it('throws for unsupported encoding in data: URLs', async () => {
69+
await expect(loader('data:foo,bar')).rejects.toThrow(
70+
"Unsupported encoding in data URL: 'data:foo,bar'",
71+
);
72+
});
73+
});
74+
75+
describe('file:', () => {
76+
it('loads relative file: URL', async () => {
77+
const loader = createDataLoader({ resourceRoot: baseDir });
78+
79+
const result = await loader(`file:test/resources/liberty.jpg`);
80+
81+
expect(result).toEqual({ data: new Uint8Array(libertyJpg) });
82+
});
83+
84+
it('loads file: URL without authority', async () => {
85+
const loader = createDataLoader({ resourceRoot: baseDir });
86+
87+
const result = await loader(`file:/test/resources/liberty.jpg`);
88+
89+
expect(result).toEqual({ data: new Uint8Array(libertyJpg) });
90+
});
91+
92+
it('loads absolute file: URL with empty authority', async () => {
93+
const loader = createDataLoader({ resourceRoot: baseDir });
94+
95+
const result = await loader(`file:///test/resources/liberty.jpg`);
96+
97+
expect(result).toEqual({ data: new Uint8Array(libertyJpg) });
98+
});
99+
100+
it('loads absolute file: URL with authority', async () => {
101+
const loader = createDataLoader({ resourceRoot: baseDir });
102+
103+
const result = await loader(`file://localhost/test/resources/liberty.jpg`);
104+
105+
expect(result).toEqual({ data: new Uint8Array(libertyJpg) });
106+
});
107+
108+
it('rejects when no resource root directory defined', async () => {
109+
const url = `file:/test/resources/liberty.jpg`;
110+
111+
await expect(loader(url)).rejects.toThrow('No resource root defined');
112+
});
113+
});
114+
});

src/data-loader.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { decodeBase64 } from './base64.ts';
2+
3+
let readRelativeFile: ((rootDir: string, relPath: string) => Promise<ArrayBuffer>) | undefined;
4+
5+
try {
6+
const fs = await import('./fs.ts');
7+
readRelativeFile = fs.readRelativeFile;
8+
} catch {
9+
// No file support available in this environment
10+
}
11+
12+
export type DataLoaderConfig = {
13+
resourceRoot?: string;
14+
};
15+
16+
export type DataLoader = (
17+
url: string,
18+
config?: DataLoaderConfig,
19+
) => DataLoaderResult | Promise<DataLoaderResult>;
20+
21+
export type DataLoaderResult = {
22+
data: Uint8Array;
23+
};
24+
25+
export function createDataLoader(config?: DataLoaderConfig): DataLoader {
26+
const loaders: Record<string, DataLoader> = {
27+
http: loadHttp,
28+
https: loadHttp,
29+
data: loadData,
30+
file: loadFile,
31+
};
32+
33+
return async function (url: string): Promise<DataLoaderResult> {
34+
const schema = getUrlSchema(url).slice(0, -1);
35+
const loader = loaders[schema];
36+
if (!loader) {
37+
throw new Error(`URL not supported: '${url}'`);
38+
}
39+
return await loader(url, config);
40+
};
41+
}
42+
43+
function getUrlSchema(url: string) {
44+
try {
45+
return new URL(url).protocol;
46+
} catch {
47+
throw new Error(`Invalid URL: '${url}'`);
48+
}
49+
}
50+
51+
function loadData(url: string) {
52+
if (!url.startsWith('data:')) {
53+
throw new Error(`Not a data URL: '${url}'`);
54+
}
55+
const endOfHeader = url.indexOf(',');
56+
if (endOfHeader === -1) {
57+
throw new Error(`Invalid data URL: '${url}'`);
58+
}
59+
const header = url.slice(5, endOfHeader);
60+
if (!header.endsWith(';base64')) {
61+
throw new Error(`Unsupported encoding in data URL: '${url}'`);
62+
}
63+
const dataPart = url.slice(endOfHeader + 1);
64+
const data = new Uint8Array(decodeBase64(dataPart));
65+
return { data };
66+
}
67+
68+
async function loadHttp(url: string) {
69+
if (!url.startsWith('http:') && !url.startsWith('https:')) {
70+
throw new Error(`Not a http(s) URL: '${url}'`);
71+
}
72+
const response = await fetch(url);
73+
if (!response.ok) {
74+
throw new Error(`Received ${response.status} ${response.statusText}`);
75+
}
76+
const data = new Uint8Array(await response.arrayBuffer());
77+
return { data };
78+
}
79+
80+
async function loadFile(url: string, config?: DataLoaderConfig) {
81+
if (!url.startsWith('file:')) {
82+
throw new Error(`Not a file URL: '${url}'`);
83+
}
84+
if (!readRelativeFile) {
85+
throw new Error('No file support available in this environment');
86+
}
87+
if (!config?.resourceRoot) {
88+
throw new Error('No resource root defined');
89+
}
90+
const urlPath = decodeURIComponent(new URL(url).pathname);
91+
const relPath = urlPath.replace(/^\//g, '');
92+
const data = new Uint8Array(await readRelativeFile(config.resourceRoot, relPath));
93+
return { data };
94+
}

src/font-store.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import fontkit from '@pdf-lib/fontkit';
2-
import { toUint8Array } from 'pdf-lib';
32

43
import type { FontConfig } from './api/PdfMaker.ts';
54
import type { FontStyle, FontWeight } from './api/text.ts';
5+
import { parseBinaryData } from './binary-data.ts';
66
import type { Font, FontDef, FontSelector } from './fonts.ts';
77
import { weightToNumber } from './fonts.ts';
88
import { pickDefined } from './types.ts';
@@ -41,7 +41,7 @@ export class FontStore {
4141

4242
_loadFont(selector: FontSelector): Promise<Font> {
4343
const selectedFont = selectFont(this.#fontDefs, selector);
44-
const data = toUint8Array(selectedFont.data);
44+
const data = parseBinaryData(selectedFont.data);
4545
const fkFont = selectedFont.fkFont ?? fontkit.create(data);
4646
return Promise.resolve(
4747
pickDefined({

src/fs.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { readFile, realpath } from 'node:fs/promises';
2+
import { isAbsolute, resolve } from 'node:path';
3+
4+
export const readRelativeFile = async (rootDir: string, relPath: string) => {
5+
if (isAbsolute(relPath)) {
6+
throw new Error(`Path is not relative: '${relPath}'`);
7+
}
8+
const resolvedPath = resolve(rootDir, relPath);
9+
const realPath = await realpath(resolvedPath);
10+
try {
11+
return await readFile(realPath);
12+
} catch (error) {
13+
throw new Error(`Failed to load file '${realPath}'`, { cause: error });
14+
}
15+
};

0 commit comments

Comments
 (0)