Skip to content

Commit

Permalink
✨ Support including images by URL
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ralfstx committed Dec 15, 2024
1 parent e3f72ac commit 0186543
Show file tree
Hide file tree
Showing 15 changed files with 450 additions and 56 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
name: Test
on: push
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v1
uses: actions/setup-node@v4
with:
node-version: '20.x'
node-version: lts/*
- run: npm ci
- run: npm run lint
- run: npm run test
Expand Down
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

## [0.5.5] - Unreleased

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

### Added

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

### Changed

- Fonts should now be registered with the `registerFont()` method on the
`PdfMaker` class.

- The `image` property of an image block now supports `data:`, `file:`,
and `http(s):` URLs. File names are relative to a resource root that
must be set by the `setResourceRoot()` method on the `PdfMaker` class.

### Deprecated

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

Expand Down
10 changes: 10 additions & 0 deletions src/api/PdfMaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ export class PdfMaker {
this.#ctx.fontStore.registerFont(data, config);
}

/**
* Sets the root directory to read resources from. This allows using
* `file:/` URLs with relative paths in the document definition.
*
* @param root The root directory to read resources from.
*/
setResourceRoot(root: string): void {
this.#ctx.imageStore.setResourceRoot(root);
}

/**
* Generates a PDF from the given document definition.
*
Expand Down
2 changes: 1 addition & 1 deletion src/api/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export type ImageBlock = {
/**
* Creates a block that contains an image.
*
* @param image The name or path of an image to display in this block.
* @param image The URL of the image to display in this block.
* @param props Optional properties for the block.
*/
export function image(image: string, props?: Omit<ImageBlock, 'image'>): ImageBlock {
Expand Down
39 changes: 39 additions & 0 deletions src/base64.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';

import { decodeBase64 } from './base64.ts';

describe('decodeBase64', () => {
it('decodes base64 strings', () => {
expect(decodeBase64('')).toEqual(new Uint8Array());
expect(decodeBase64('AA==')).toEqual(new Uint8Array([0]));
expect(decodeBase64('AAE=')).toEqual(new Uint8Array([0, 1]));
expect(decodeBase64('AAEC')).toEqual(new Uint8Array([0, 1, 2]));
});

it('decodes longer base64 strings', () => {
const base64 = (input: Uint8Array) => Buffer.from(input).toString('base64');
const array1 = new Uint8Array([...Array(256).keys()]);
const array2 = new Uint8Array([...Array(257).keys()]);
const array3 = new Uint8Array([...Array(258).keys()]);

expect(decodeBase64(base64(array1))).toEqual(array1);
expect(decodeBase64(base64(array2))).toEqual(array2);
expect(decodeBase64(base64(array3))).toEqual(array3);
});

it('fails if string is not a multiple of 4', () => {
expect(() => decodeBase64('A')).toThrow(
'Invalid base64 string: length must be a multiple of 4',
);
expect(() => decodeBase64('AA')).toThrow(
'Invalid base64 string: length must be a multiple of 4',
);
expect(() => decodeBase64('AAA')).toThrow(
'Invalid base64 string: length must be a multiple of 4',
);
});

it('fails if string contains invalid characters', () => {
expect(() => decodeBase64('ABØ=')).toThrow("Invalid Base64 character 'Ø' at position 2");
});
});
54 changes: 54 additions & 0 deletions src/base64.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const base64Lookup = createBase64LookupTable();

/**
* Decodes a Base64 encoded string into a Uint8Array.
*
* @param base64 - The Base64 encoded string.
* @returns The decoded bytes as a Uint8Array.
*/
export function decodeBase64(base64: string): Uint8Array {
if (base64.length % 4 !== 0) {
throw new Error('Invalid base64 string: length must be a multiple of 4');
}

const len = base64.length;
const padding = base64[len - 1] === '=' ? (base64[len - 2] === '=' ? 2 : 1) : 0;
const bufferLength = (len * 3) / 4 - padding;
const bytes = new Uint8Array(bufferLength);

let byteIndex = 0;
for (let i = 0; i < len; i += 4) {
const encoded1 = lookup(base64, i);
const encoded2 = lookup(base64, i + 1);
const encoded3 = lookup(base64, i + 2);
const encoded4 = lookup(base64, i + 3);

bytes[byteIndex++] = (encoded1 << 2) | (encoded2 >> 4);
if (base64[i + 2] !== '=') bytes[byteIndex++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
if (base64[i + 3] !== '=') bytes[byteIndex++] = ((encoded3 & 3) << 6) | encoded4;
}

return bytes;
}

function lookup(string: string, pos: number): number {
const code = string.charCodeAt(pos);
if (code === 61) return 0; // '=' padding character
if (code < base64Lookup.length) {
const value = base64Lookup[code];
if (value !== 255) {
return value;
}
}
throw new Error(`Invalid Base64 character '${string[pos]}' at position ${pos}`);
}

function createBase64LookupTable(): Uint8Array {
const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
// 255 indicates that code does not represent a valid base64 character
const table = new Uint8Array(256).fill(255);
for (let i = 0; i < base64Chars.length; i++) {
table[base64Chars.charCodeAt(i)] = i;
}
return table;
}
114 changes: 114 additions & 0 deletions src/data-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { createDataLoader } from './data-loader.ts';

const baseDir = import.meta.dirname;
const libertyJpg = await readFile(join(baseDir, 'test/resources/liberty.jpg'));

describe('createDataLoader', () => {
const loader = createDataLoader();

it('throws for invalid URLs', async () => {
await expect(loader('')).rejects.toThrow("Invalid URL: ''");
await expect(loader('http://')).rejects.toThrow("Invalid URL: 'http://'");
});

it('throws for unsupported URL scheme', async () => {
await expect(loader('foo:bar')).rejects.toThrow("URL not supported: 'foo:bar'");
});

describe('http:', () => {
beforeEach(() => {
vi.spyOn(globalThis, 'fetch').mockImplementation((req: RequestInfo | URL) => {
const url = req instanceof URL ? req.href : (req as string);
if (url.endsWith('image.jpg')) {
return Promise.resolve(new Response(new Uint8Array([1, 2, 3])));
}
return Promise.resolve(new Response('Not found', { status: 404, statusText: 'Not Found' }));
});
});

afterEach(() => {
vi.restoreAllMocks();
});

it('loads http: URL', async () => {
await expect(loader('http://example.com/image.jpg')).resolves.toEqual({
data: new Uint8Array([1, 2, 3]),
});
});

it('loads https: URL', async () => {
await expect(loader('https://example.com/image.jpg')).resolves.toEqual({
data: new Uint8Array([1, 2, 3]),
});
});

it('throws if 404 received', async () => {
await expect(loader('https://example.com/not-there')).rejects.toThrow(
'Received 404 Not Found',
);
});
});

describe('data:', () => {
it('loads data: URL', async () => {
await expect(loader('data:image/jpeg;base64,Abc=')).resolves.toEqual({
data: new Uint8Array([1, 183]),
});
});

it('throws for invalid data: URLs', async () => {
await expect(loader('data:foo')).rejects.toThrow("Invalid data URL: 'data:foo'");
});

it('throws for unsupported encoding in data: URLs', async () => {
await expect(loader('data:foo,bar')).rejects.toThrow(
"Unsupported encoding in data URL: 'data:foo,bar'",
);
});
});

describe('file:', () => {
it('loads relative file: URL', async () => {
const loader = createDataLoader({ resourceRoot: baseDir });

const result = await loader(`file:test/resources/liberty.jpg`);

expect(result).toEqual({ data: new Uint8Array(libertyJpg) });
});

it('loads file: URL without authority', async () => {
const loader = createDataLoader({ resourceRoot: baseDir });

const result = await loader(`file:/test/resources/liberty.jpg`);

expect(result).toEqual({ data: new Uint8Array(libertyJpg) });
});

it('loads absolute file: URL with empty authority', async () => {
const loader = createDataLoader({ resourceRoot: baseDir });

const result = await loader(`file:///test/resources/liberty.jpg`);

expect(result).toEqual({ data: new Uint8Array(libertyJpg) });
});

it('loads absolute file: URL with authority', async () => {
const loader = createDataLoader({ resourceRoot: baseDir });

const result = await loader(`file://localhost/test/resources/liberty.jpg`);

expect(result).toEqual({ data: new Uint8Array(libertyJpg) });
});

it('rejects when no resource root directory defined', async () => {
const url = `file:/test/resources/liberty.jpg`;

await expect(loader(url)).rejects.toThrow('No resource root defined');
});
});
});
94 changes: 94 additions & 0 deletions src/data-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { decodeBase64 } from './base64.ts';

let readRelativeFile: ((rootDir: string, relPath: string) => Promise<ArrayBuffer>) | undefined;

try {
const fs = await import('./fs.ts');
readRelativeFile = fs.readRelativeFile;
} catch {
// No file support available in this environment
}

export type DataLoaderConfig = {
resourceRoot?: string;
};

export type DataLoader = (
url: string,
config?: DataLoaderConfig,
) => DataLoaderResult | Promise<DataLoaderResult>;

export type DataLoaderResult = {
data: Uint8Array;
};

export function createDataLoader(config?: DataLoaderConfig): DataLoader {
const loaders: Record<string, DataLoader> = {
http: loadHttp,
https: loadHttp,
data: loadData,
file: loadFile,
};

return async function (url: string): Promise<DataLoaderResult> {
const schema = getUrlSchema(url).slice(0, -1);
const loader = loaders[schema];
if (!loader) {
throw new Error(`URL not supported: '${url}'`);
}
return await loader(url, config);
};
}

function getUrlSchema(url: string) {
try {
return new URL(url).protocol;
} catch {
throw new Error(`Invalid URL: '${url}'`);
}
}

function loadData(url: string) {
if (!url.startsWith('data:')) {
throw new Error(`Not a data URL: '${url}'`);
}
const endOfHeader = url.indexOf(',');
if (endOfHeader === -1) {
throw new Error(`Invalid data URL: '${url}'`);
}
const header = url.slice(5, endOfHeader);
if (!header.endsWith(';base64')) {
throw new Error(`Unsupported encoding in data URL: '${url}'`);
}
const dataPart = url.slice(endOfHeader + 1);
const data = new Uint8Array(decodeBase64(dataPart));
return { data };
}

async function loadHttp(url: string) {
if (!url.startsWith('http:') && !url.startsWith('https:')) {
throw new Error(`Not a http(s) URL: '${url}'`);
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Received ${response.status} ${response.statusText}`);
}
const data = new Uint8Array(await response.arrayBuffer());
return { data };
}

async function loadFile(url: string, config?: DataLoaderConfig) {
if (!url.startsWith('file:')) {
throw new Error(`Not a file URL: '${url}'`);
}
if (!readRelativeFile) {
throw new Error('No file support available in this environment');
}
if (!config?.resourceRoot) {
throw new Error('No resource root defined');
}
const urlPath = decodeURIComponent(new URL(url).pathname);
const relPath = urlPath.replace(/^\//g, '');
const data = new Uint8Array(await readRelativeFile(config.resourceRoot, relPath));
return { data };
}
Loading

0 comments on commit 0186543

Please sign in to comment.