-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
15 changed files
with
448 additions
and
54 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.