diff --git a/src/api/make-pdf.ts b/src/api/make-pdf.ts index 994b98a..4e25203 100644 --- a/src/api/make-pdf.ts +++ b/src/api/make-pdf.ts @@ -1,5 +1,5 @@ import { FontStore } from '../font-store.ts'; -import { ImageLoader, ImageStore } from '../image-loader.ts'; +import { ImageStore } from '../image-store.ts'; import { layoutPages } from '../layout/layout.ts'; import { readDocumentDefinition } from '../read-document.ts'; import { renderDocument } from '../render/render-document.ts'; @@ -15,8 +15,7 @@ import type { DocumentDefinition } from './document.ts'; export async function makePdf(definition: DocumentDefinition): Promise { const def = readAs(definition, 'definition', readDocumentDefinition); const fontStore = new FontStore(def.fonts ?? []); - const imageLoader = new ImageLoader(def.images ?? []); - const imageStore = new ImageStore(imageLoader); + const imageStore = new ImageStore(def.images ?? []); const guides = !!def.dev?.guides; const ctx = { fontStore, imageStore, guides }; const pages = await layoutPages(def, ctx); diff --git a/src/image-loader.test.ts b/src/image-loader.test.ts deleted file mode 100644 index 21acc69..0000000 --- a/src/image-loader.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; - -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { ImageLoader, ImageStore } from './image-loader.ts'; -import type { ImageSelector } from './images.ts'; - -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('new ImageLoader', () => { - it('rejects if image cannot be loaded', async () => { - const loader = new ImageLoader([]); - - await expect(loader.loadImage({ name: 'foo' })).rejects.toThrow( - expect.objectContaining({ - message: "Could not load image 'foo'", - cause: new Error("ENOENT: no such file or directory, open 'foo'"), - }), - ); - }); - - 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 = new ImageLoader([image1, image2]); - - const result1 = await loader.loadImage({ name: 'image1' }); - const result2 = await loader.loadImage({ name: 'image2' }); - - expect(result1).toEqual({ data: libertyJpg }); - expect(result2).toEqual({ data: torusPng }); - }); - - it('loads images from file system and returns data and metadata', async () => { - const loader = new ImageLoader([]); - - 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 = new ImageLoader([]); - imageLoader.loadImage = vi.fn((selector: ImageSelector) => { - if (selector.name === 'liberty') return Promise.resolve({ data: libertyJpg }); - if (selector.name === 'torus') return Promise.resolve({ data: torusPng }); - throw new Error('No such image'); - }); - }); - - it('rejects if image could not be loaded', async () => { - const store = new ImageStore(imageLoader); - - await expect(store.selectImage({ name: 'foo' })).rejects.toThrow( - expect.objectContaining({ - message: "Could not load image 'foo'", - cause: new Error('No such image'), - }), - ); - }); - - it('reads format, width and height from JPEG image', async () => { - const store = new ImageStore(imageLoader); - - const image = await store.selectImage({ name: 'liberty' }); - - expect(image).toEqual({ - name: 'liberty', - format: 'jpeg', - width: 160, - height: 240, - data: libertyJpg, - }); - }); - - it('reads format, width and height from PNG image', async () => { - const store = new ImageStore(imageLoader); - - const image = await store.selectImage({ name: 'torus' }); - - expect(image).toEqual({ - name: 'torus', - format: 'png', - width: 256, - height: 192, - data: torusPng, - }); - }); - - it('calls image loader only once per selector', async () => { - const store = new ImageStore(imageLoader); - - await store.selectImage({ name: 'liberty' }); - await store.selectImage({ name: 'liberty' }); - - expect(imageLoader.loadImage).toHaveBeenCalledTimes(1); - }); - - it('returns same image object for concurrent calls', async () => { - const store = new ImageStore(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 = new ImageStore(imageLoader); - - await expect(store.selectImage({ name: 'foo' })).rejects.toThrow( - expect.objectContaining({ - message: "Could not load image 'foo'", - cause: new Error('No such image'), - }), - ); - }); - }); -}); diff --git a/src/image-loader.ts b/src/image-loader.ts deleted file mode 100644 index 78c641a..0000000 --- a/src/image-loader.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { readFile } from 'node:fs/promises'; - -import { toUint8Array } from 'pdf-lib'; - -import type { Image, ImageDef, ImageFormat, ImageSelector } from './images.ts'; -import { isJpeg, readJpegInfo } from './images/jpeg.ts'; -import { isPng, readPngInfo } from './images/png.ts'; - -export type LoadedImage = { - data: Uint8Array; -}; - -export class ImageLoader { - readonly #images: ImageDef[]; - - constructor(images: ImageDef[]) { - this.#images = images; - } - - async loadImage(selector: ImageSelector): Promise { - const imageDef = this.#images.find((image) => image.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}'`, { cause: error }); - } - } -} - -export class ImageStore { - readonly #imageLoader: ImageLoader; - readonly #imageCache: Record> = {}; - - constructor(imageLoader: ImageLoader) { - this.#imageLoader = imageLoader; - } - - selectImage(selector: ImageSelector): Promise { - const cacheKey = selector.name; - return (this.#imageCache[cacheKey] ??= this.loadImage(selector)); - } - - async loadImage(selector: ImageSelector): Promise { - let loadedImage: LoadedImage; - try { - loadedImage = await this.#imageLoader.loadImage(selector); - } catch (error) { - throw new Error(`Could not load image '${selector.name}'`, { cause: error }); - } - 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'); -} diff --git a/src/image-store.test.ts b/src/image-store.test.ts new file mode 100644 index 0000000..4a97b43 --- /dev/null +++ b/src/image-store.test.ts @@ -0,0 +1,89 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { beforeAll, describe, expect, it } from 'vitest'; + +import { ImageStore } from './image-store.ts'; + +const baseDir = import.meta.dirname; + +describe('image-loader', () => { + let libertyJpg: Uint8Array; + let torusPng: Uint8Array; + + beforeAll(async () => { + [libertyJpg, torusPng] = await Promise.all([ + readFile(join(baseDir, './test/resources/liberty.jpg')), + readFile(join(baseDir, './test/resources/torus.png')), + ]); + }); + + describe('ImageStore', () => { + it('rejects if image could not be loaded', async () => { + const store = new ImageStore([]); + + await expect(store.selectImage('foo')).rejects.toThrow("Could not load image 'foo'"); + }); + + it('loads registered images', async () => { + const store = new ImageStore([ + { name: 'liberty', data: libertyJpg, format: 'jpeg' }, + { name: 'torus', data: torusPng, format: 'png' }, + ]); + + const torus = await store.selectImage('torus'); + const liberty = await store.selectImage('liberty'); + + expect(torus).toEqual(expect.objectContaining({ name: 'torus', data: torusPng })); + expect(liberty).toEqual(expect.objectContaining({ name: 'liberty', data: libertyJpg })); + }); + + it('loads image from file system', async () => { + const store = new ImageStore([]); + + const torusPath = join(baseDir, './test/resources/torus.png'); + const image = await store.selectImage(torusPath); + + expect(image).toEqual(expect.objectContaining({ name: torusPath, data: torusPng })); + }); + + it('reads format, width and height from JPEG image', async () => { + const store = new ImageStore([{ name: 'liberty', data: libertyJpg, format: 'jpeg' }]); + + const image = await store.selectImage('liberty'); + + expect(image).toEqual({ + name: 'liberty', + format: 'jpeg', + width: 160, + height: 240, + data: libertyJpg, + }); + }); + + it('reads format, width and height from PNG image', async () => { + const store = new ImageStore([{ name: 'torus', data: torusPng, format: 'png' }]); + + const image = await store.selectImage('torus'); + + expect(image).toEqual({ + name: 'torus', + format: 'png', + width: 256, + height: 192, + data: torusPng, + }); + }); + + it('returns same image object for concurrent calls', async () => { + const store = new ImageStore([{ name: 'liberty', data: libertyJpg, format: 'jpeg' }]); + + const [image1, image2] = await Promise.all([ + store.selectImage('liberty'), + store.selectImage('liberty'), + ]); + + expect(image1).toBe(image2); + }); + }); +}); diff --git a/src/image-store.ts b/src/image-store.ts new file mode 100644 index 0000000..1ab9213 --- /dev/null +++ b/src/image-store.ts @@ -0,0 +1,45 @@ +import { readFile } from 'node:fs/promises'; + +import { toUint8Array } from 'pdf-lib'; + +import type { Image, ImageDef, ImageFormat } from './images.ts'; +import { isJpeg, readJpegInfo } from './images/jpeg.ts'; +import { isPng, readPngInfo } from './images/png.ts'; + +export class ImageStore { + readonly #images: ImageDef[]; + readonly #imageCache: Record> = {}; + + constructor(images: ImageDef[]) { + this.#images = images; + } + + selectImage(selector: string): Promise { + return (this.#imageCache[selector] ??= this.loadImage(selector)); + } + + async loadImage(selector: string): Promise { + const data = await this.loadImageData(selector); + const format = determineImageFormat(data); + const { width, height } = format === 'png' ? readPngInfo(data) : readJpegInfo(data); + return { name: selector, format, data, width, height }; + } + + async loadImageData(selector: string): Promise { + const imageDef = this.#images.find((image) => image.name === selector); + if (imageDef) { + return toUint8Array(imageDef.data); + } + try { + return await readFile(selector); + } catch (error) { + throw new Error(`Could not load image '${selector}'`, { cause: error }); + } + } +} + +function determineImageFormat(data: Uint8Array): ImageFormat { + if (isPng(data)) return 'png'; + if (isJpeg(data)) return 'jpeg'; + throw new Error('Unknown image format'); +} diff --git a/src/images.ts b/src/images.ts index 9dc3e4b..0991653 100644 --- a/src/images.ts +++ b/src/images.ts @@ -22,10 +22,6 @@ export type Image = { pdfRef?: PDFRef; }; -export type ImageSelector = { - name: string; -}; - export function readImages(input: unknown): ImageDef[] { return Object.entries(readObject(input)).map(([name, imageDef]) => { const { data, format } = readAs(imageDef, name, required(readImage)); diff --git a/src/layout/layout-image.test.ts b/src/layout/layout-image.test.ts index e6261c1..7c6e759 100644 --- a/src/layout/layout-image.test.ts +++ b/src/layout/layout-image.test.ts @@ -1,8 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Box } from '../box.ts'; -import { ImageLoader, ImageStore } from '../image-loader.ts'; -import type { ImageSelector } from '../images.ts'; +import { ImageStore } from '../image-store.ts'; import type { MakerCtx } from '../maker-ctx.ts'; import type { ImageBlock } from '../read-block.ts'; import { fakeImage } from '../test/test-utils.ts'; @@ -13,13 +12,13 @@ describe('layout-image', () => { let ctx: MakerCtx; beforeEach(() => { - const imageStore = new ImageStore(new ImageLoader([])); - imageStore.selectImage = vi.fn((selector: ImageSelector) => { - const match = /^img-(\d+)-(\d+)$/.exec(selector.name); + const imageStore = new ImageStore([]); + imageStore.selectImage = vi.fn((selector: string) => { + const match = /^img-(\d+)-(\d+)$/.exec(selector); if (match) { - return Promise.resolve(fakeImage(selector.name, Number(match[1]), Number(match[2]))); + return Promise.resolve(fakeImage(selector, Number(match[1]), Number(match[2]))); } - throw new Error(`Unknown image: ${selector.name}`); + throw new Error(`Unknown image: ${selector}`); }); box = { x: 20, y: 30, width: 400, height: 700 }; ctx = { imageStore } as MakerCtx; @@ -48,8 +47,7 @@ describe('layout-image', () => { await layoutImageContent(block, box, ctx); - const selector = { name: 'img-720-480', width: 30, height: 40 }; - expect(ctx.imageStore.selectImage).toHaveBeenCalledWith(selector); + expect(ctx.imageStore.selectImage).toHaveBeenCalledWith('img-720-480'); }); ['img-720-480', 'img-72-48'].forEach((image) => { diff --git a/src/layout/layout-image.ts b/src/layout/layout-image.ts index 382ab9d..bf2291f 100644 --- a/src/layout/layout-image.ts +++ b/src/layout/layout-image.ts @@ -10,8 +10,7 @@ export async function layoutImageContent( box: Box, ctx: MakerCtx, ): Promise { - const selector = { name: block.image, width: block.width, height: block.height }; - const image = await ctx.imageStore.selectImage(selector); + const image = await ctx.imageStore.selectImage(block.image); const hasFixedWidth = block.width != null; const hasFixedHeight = block.height != null; const scale = getScale(image, box, hasFixedWidth, hasFixedHeight); diff --git a/src/maker-ctx.ts b/src/maker-ctx.ts index e62c7f4..3430f22 100644 --- a/src/maker-ctx.ts +++ b/src/maker-ctx.ts @@ -1,5 +1,5 @@ import type { FontStore } from './font-store.ts'; -import type { ImageStore } from './image-loader.ts'; +import type { ImageStore } from './image-store.ts'; export type MakerCtx = { fontStore: FontStore;