Skip to content

Commit

Permalink
✨ Load images from file system
Browse files Browse the repository at this point in the history
Images need to be registered with the global `images` attribute. When
loading images from the file system, this shouldn't be necessary.

This commit allows selecting images by their file name, similar to the
`src` attribute in HTML `<img>` tags. When the name given in the `image`
attribute of an image block is not a registered name, it is now
interpreted as a file name and the image is loaded from the file system.
Relative paths are resolved relative to the current working directory.

The optional `format` attribute of an image definition is removed. The
image format is now auto-detected from the file content.

The `ImageStore` now caches the loaded images and errors from the image
loader. This prevents loading the same image again when it is used
multiple times.
  • Loading branch information
ralfstx committed Dec 11, 2023
1 parent 6b41e14 commit 2e079de
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 50 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Expand Up @@ -6,11 +6,22 @@

- Text attributes `fontStyle` and `fontWeight`.

### Changed

- The `image` attribute of an image block now supports a file name.
When file names are used, the images don't need to be registered with
the global `images` attribute anymore.

### Deprecated

- Text attributes `bold` and `italic` in favor of `fontStyle: 'italic'`
and `fontWeight: 'bold'`.

### Removed

- The optional `format` attribute of an image definition. The format is
now auto-detected from the file content.

### Fixed

- Text in a text block will no longer break before the first row, which
Expand Down
7 changes: 3 additions & 4 deletions README.md
Expand Up @@ -85,9 +85,8 @@ const documentDefinition = {

### Images

JPG and PNG images are supported. All images must be registered with the
`images` attribute. Images can be used multiple times in the document,
but the image data is only included once in the PDF. The size of an
JPG and PNG images are supported. When the same image is used more than
once, the image data is only included once in the PDF. The size of an
image can be confined using the `width` and `height` attributes.

```js
Expand All @@ -98,7 +97,7 @@ const documentDefinition = {
},
content: [
// An image block
{ image: 'logo', width: 200, height: 100 },
{ image: 'images/logo.png', width: 200, height: 100 },
]
};
Expand Down
27 changes: 14 additions & 13 deletions src/api/content.ts
Expand Up @@ -58,8 +58,9 @@ export type DocumentDefinition = {
fonts?: FontsDefinition;

/**
* The images to use in the document. Each image in the document needs to be registered.
* Once registered, an image can be reused without multiplying its footprint.
* Pre-defined image data. These images can be used by their name in
* the document. This is only needed if images cannot be loaded
* directly from the file system.
*/
images?: ImagesDefinition;

Expand Down Expand Up @@ -163,11 +164,6 @@ export type ImageDefinition = {
* Supported image formats are PNG and JPEG.
*/
data: string | Uint8Array | ArrayBuffer;

/**
* The image format. Defaults to `jpeg`.
*/
format?: 'jpeg' | 'png';
};

export type Block = TextBlock | ImageBlock | ColumnsBlock | RowsBlock | EmptyBlock;
Expand All @@ -189,13 +185,18 @@ export type TextBlock = {

export type ImageBlock = {
/**
* The name of the JPG image to display in this block. The image must have been registered with
* the global `images` attribute.
* The name of an image to display in this block. If the given image
* name has been registered with the global `images` attribute, the
* registered image will be used. Otherwise, the image name is
* interpreted as a file name and the image is loaded from the file
* system. Relative paths are resolved relative to the current working
* directory.
*
* When any of the attributes `width` and `height` are specified, the image will be scaled
* proportionally to be contained in the given bounds.
* When neither `width` nor `height` is given, the image is not scaled unless it exceeds the
* maximum available width. In this case, it is scaled down to fit onto the page.
* When any of the attributes `width` and `height` are specified, the
* image will be scaled proportionally to be contained in the given
* bounds. When neither `width` nor `height` is given, the image is
* not scaled unless it exceeds the maximum available width. In this
* case, it is scaled down to fit onto the page.
*/
image: string;

Expand Down
115 changes: 94 additions & 21 deletions src/image-loader.test.ts
Expand Up @@ -2,64 +2,137 @@ import crypto from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';

import { describe, expect, it } from '@jest/globals';
import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals';

import { createImageLoader, createImageStore } from './image-loader.js';
import { mkData } from './test/test-utils.js';
import { createImageLoader, createImageStore, ImageLoader } from './image-loader.js';
import { ImageSelector } from './images.js';

global.crypto ??= (crypto as any).webcrypto;

describe('image-loader', () => {
let libertyJpg: Uint8Array;
let torusPng: Uint8Array;

beforeAll(async () => {
[libertyJpg, torusPng] = await Promise.all([
readFile(join(__dirname, './test/resources/liberty.jpg')),
readFile(join(__dirname, './test/resources/torus.png')),
]);
});

describe('createImageLoader', () => {
it('rejects for unknown image name', async () => {
it('rejects if image cannot be loaded', async () => {
const loader = createImageLoader([]);

await expect(loader.loadImage({ name: 'foo' })).rejects.toThrowError(
"No image defined with name 'foo'"
"Could not load image 'foo': ENOENT: no such file or directory, open 'foo'"
);
});

it('returns data and format', async () => {
const image1 = { name: 'image1', data: mkData('Foo'), format: 'jpeg' as const };
const image2 = { name: 'image2', data: mkData('Foo'), format: 'png' as const };
it('returns data and metadata for registered images', async () => {
const image1 = { name: 'image1', data: libertyJpg, format: 'jpeg' as const };
const image2 = { name: 'image2', data: torusPng, format: 'png' as const };
const loader = createImageLoader([image1, image2]);

const result1 = await loader.loadImage({ name: 'image1' });
const result2 = await loader.loadImage({ name: 'image2' });

expect(result1).toEqual({ data: mkData('Foo'), format: 'jpeg' });
expect(result2).toEqual({ data: mkData('Foo'), format: 'png' });
expect(result1).toEqual({ data: libertyJpg });
expect(result2).toEqual({ data: torusPng });
});

it('loads images from file system and returns data and metadata', async () => {
const loader = createImageLoader([]);

const result1 = await loader.loadImage({ name: 'src/test/resources/liberty.jpg' });
const result2 = await loader.loadImage({ name: 'src/test/resources/torus.png' });

expect(result1).toEqual({ data: libertyJpg });
expect(result2).toEqual({ data: torusPng });
});
});

describe('ImageStore', () => {
let imageLoader: ImageLoader;

beforeEach(() => {
imageLoader = {
loadImage: jest.fn(async (selector: ImageSelector) => {
if (selector.name === 'liberty') return { data: libertyJpg };
if (selector.name === 'torus') return { data: torusPng };
throw new Error('No such image');
}),
};
});

it('rejects if image could not be loaded', async () => {
const loader = createImageLoader([]);
const store = createImageStore(loader);
const store = createImageStore(imageLoader);

await expect(store.selectImage({ name: 'foo' })).rejects.toThrowError(
"Could not load image 'foo': No image defined with name 'foo'"
"Could not load image 'foo': No such image"
);
});

it('reads width and height from JPEG image', async () => {
const data = await readFile(join(__dirname, './test/resources/liberty.jpg'));
const loader = createImageLoader([{ name: 'liberty', data, format: 'jpeg' }]);
const store = createImageStore(imageLoader);

const store = createImageStore(loader);
const image = await store.selectImage({ name: 'liberty' });

expect(image).toEqual({ name: 'liberty', data, format: 'jpeg', width: 160, height: 240 });
expect(image).toEqual({
name: 'liberty',
format: 'jpeg',
width: 160,
height: 240,
data: libertyJpg,
});
});

it('reads width and height from PNG image', async () => {
const data = await readFile(join(__dirname, './test/resources/torus.png'));
const loader = createImageLoader([{ name: 'torus', data, format: 'png' }]);
const store = createImageStore(imageLoader);

const store = createImageStore(loader);
const image = await store.selectImage({ name: 'torus' });

expect(image).toEqual({ name: 'torus', data, format: 'png', width: 256, height: 192 });
expect(image).toEqual({
name: 'torus',
format: 'png',
width: 256,
height: 192,
data: torusPng,
});
});

it('calls image loader only once per selector', async () => {
const store = createImageStore(imageLoader);

await store.selectImage({ name: 'liberty' });
await store.selectImage({ name: 'liberty', width: 300 });
await store.selectImage({ name: 'liberty' });
await store.selectImage({ name: 'liberty', width: 300 });

expect(imageLoader.loadImage).toHaveBeenCalledTimes(2);
});

it('returns same image object for concurrent calls', async () => {
const store = createImageStore(imageLoader);

const [image1, image2] = await Promise.all([
store.selectImage({ name: 'liberty' }),
store.selectImage({ name: 'liberty' }),
]);

expect(image1).toBe(image2);
});

it('caches errors from image loader', async () => {
const store = createImageStore(imageLoader);

await expect(store.selectImage({ name: 'foo' })).rejects.toThrowError(
"Could not load image 'foo': No such image"
);
await expect(store.selectImage({ name: 'foo' })).rejects.toThrowError(
"Could not load image 'foo': No such image"
);
expect(imageLoader.loadImage).toHaveBeenCalledTimes(1);
});
});
});
43 changes: 31 additions & 12 deletions src/image-loader.ts
@@ -1,11 +1,12 @@
import { readFile } from 'node:fs/promises';

import { toUint8Array } from 'pdf-lib';

import { Image, ImageDef, ImageFormat, ImageSelector } from './images.js';
import { readJpegInfo } from './images/jpeg.js';
import { readPngInfo } from './images/png.js';
import { isJpeg, readJpegInfo } from './images/jpeg.js';
import { isPng, readPngInfo } from './images/png.js';

export type LoadedImage = {
format: ImageFormat;
data: Uint8Array;
};

Expand All @@ -20,14 +21,19 @@ export function createImageLoader(images: ImageDef[]): ImageLoader {

async function loadImage(selector: ImageSelector): Promise<LoadedImage> {
const imageDef = images.find((image) => image.name === selector.name);
if (!imageDef) {
throw new Error(`No image defined with name '${selector.name}'`);
let data: Uint8Array;
if (imageDef) {
data = toUint8Array(imageDef.data);
return { data };
}
try {
data = await readFile(selector.name);
return { data };
} catch (error) {
throw new Error(
`Could not load image '${selector.name}': ${(error as Error)?.message ?? error}`
);
}
const data = toUint8Array(imageDef.data);
return {
format: imageDef.format,
data,
};
}
}

Expand All @@ -36,11 +42,18 @@ export type ImageStore = {
};

export function createImageStore(imageLoader: ImageLoader): ImageStore {
const imageCache: Record<string, Promise<Image>> = {};

return {
selectImage,
};

async function selectImage(selector: ImageSelector): Promise<Image> {
const cacheKey = [selector.name, selector.height ?? 'any', selector.width ?? 'any'].join(':');
return (imageCache[cacheKey] ??= loadImage(selector));
}

async function loadImage(selector: ImageSelector): Promise<Image> {
let loadedImage: LoadedImage;
try {
loadedImage = await imageLoader.loadImage(selector);
Expand All @@ -51,9 +64,15 @@ export function createImageStore(imageLoader: ImageLoader): ImageStore {
(selector.height != null ? `, height=${selector.height}` : '');
throw new Error(`Could not load image ${selectorStr}: ${(error as Error)?.message ?? error}`);
}

const { format, data } = loadedImage;
const { data } = loadedImage;
const format = determineImageFormat(data);
const { width, height } = format === 'png' ? readPngInfo(data) : readJpegInfo(data);
return { name: selector.name, format, data, width, height };
}
}

function determineImageFormat(data: Uint8Array): ImageFormat {
if (isPng(data)) return 'png';
if (isJpeg(data)) return 'jpeg';
throw new Error('Unknown image format');
}

0 comments on commit 2e079de

Please sign in to comment.