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 10, 2023
1 parent 6b41e14 commit febd2d0
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 44 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
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
22 changes: 11 additions & 11 deletions src/api/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,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 +184,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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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 febd2d0

Please sign in to comment.