From 517bcf4889cbfd1a3f45c1b4a1d19c3dda672dd3 Mon Sep 17 00:00:00 2001 From: Ralf Sternberg Date: Sun, 15 Feb 2026 14:23:13 +0100 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=90=9B=20Always=20set=20creation=20an?= =?UTF-8?q?d=20modification=20dates=20in=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no `info` object was provided to `renderDocument`, the `setMetadata` function was skipped entirely, resulting in PDFs without `CreationDate` or `ModDate` metadata. Remove the `if (info !== undefined)` guard so that dates are always set. Use optional chaining for all other info fields, which safely passes `undefined` when info is absent. Add a test to verify dates are present even without explicit info. Co-Authored-By: Claude Opus 4.6 --- src/render/render-document.test.ts | 10 ++++++++++ src/render/render-document.ts | 24 +++++++++++------------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/render/render-document.test.ts b/src/render/render-document.test.ts index 5cb6b41..4091e50 100644 --- a/src/render/render-document.test.ts +++ b/src/render/render-document.test.ts @@ -52,6 +52,16 @@ describe('renderDocument', () => { expect(dataString).toMatch(/\/bar /); }); + it('sets dates even without info', async () => { + const def = { content: [] }; + + const pdfData = await renderDocument(def, [], noObjectStreams); + const dataString = new TextDecoder().decode(pdfData); + + expect(dataString).toMatch(/\/CreationDate \(D:20250101000000Z\)/); + expect(dataString).toMatch(/\/ModDate \(D:20250101000000Z\)/); + }); + it('renders custom data', async () => { const def = { content: [], diff --git a/src/render/render-document.ts b/src/render/render-document.ts index 2a72fcd..7a70e6e 100644 --- a/src/render/render-document.ts +++ b/src/render/render-document.ts @@ -36,19 +36,17 @@ export async function renderDocument( function setMetadata(doc: PDFDocument, info?: Metadata) { const now = new Date(); - if (info !== undefined) { - doc.setInfo({ - creationDate: info.creationDate ?? now, - modDate: now, - title: info.title, - subject: info.subject, - keywords: info.keywords?.join(', '), - author: info.author, - creator: info.creator, - producer: info.producer, - ...info.custom, - }); - } + doc.setInfo({ + creationDate: info?.creationDate ?? now, + modDate: now, + title: info?.title, + subject: info?.subject, + keywords: info?.keywords?.join(', '), + author: info?.author, + creator: info?.creator, + producer: info?.producer, + ...info?.custom, + }); } function setCustomData(data: Record, doc: PDFDocument) { From 55edae1c464ed7f306f4277022f29cfc5ac53864 Mon Sep 17 00:00:00 2001 From: Ralf Sternberg Date: Sun, 15 Feb 2026 13:15:48 +0100 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=92=A5=20Remove=20support=20for=20ima?= =?UTF-8?q?ges=20in=20document=20definition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deprecated `images` property in `DocumentDefinition` allowed to register images by name. This is no longer needed since images can be referred to by URL. This commit replaces the unnecessarily complex `ImageStore` class with a simpler `ImageLoader` that is created per `makePdf` call. Images with the same URL are loaded only once within a document. The `setResourceRoot` method is preserved for `file:/` URL support. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 12 +++- src/api/PdfMaker.ts | 24 +++---- src/api/document.ts | 31 -------- src/image-loader.test.ts | 87 ++++++++++++++++++++++ src/image-loader.ts | 41 +++++++++++ src/image-store.test.ts | 123 -------------------------------- src/image-store.ts | 84 ---------------------- src/images.test.ts | 46 ------------ src/images.ts | 26 +------ src/layout/layout-image.test.ts | 24 +++---- src/layout/layout-image.ts | 2 +- src/maker-ctx.ts | 4 +- src/read-document.ts | 4 -- 13 files changed, 161 insertions(+), 347 deletions(-) create mode 100644 src/image-loader.test.ts create mode 100644 src/image-loader.ts delete mode 100644 src/image-store.test.ts delete mode 100644 src/image-store.ts delete mode 100644 src/images.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index df38dea..1857fe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,16 @@ smaller bundle size. It also opens up new possibilities for new features such as font shaping. -### Breaking +### Removed + +- Support for font and image data as base64-encoded strings and + `ArrayBuffer`. Data must now be provided as `Uint8Array`. -- Font and image data must now be provided as `Uint8Array`. - Base64-encoded strings and `ArrayBuffer`s are no longer accepted. +- The `images` property in the document definition. Images should be + referred to by URL instead. The `ImagesDefinition` and + `ImageDefinition` types have been removed. + +### Breaking - Text height is now based on the OS/2 typographic metrics (`sTypoAscender` / `sTypoDescender`) instead of the hhea table values. diff --git a/src/api/PdfMaker.ts b/src/api/PdfMaker.ts index 19e6473..fafb01d 100644 --- a/src/api/PdfMaker.ts +++ b/src/api/PdfMaker.ts @@ -1,5 +1,5 @@ import { FontStore } from '../font-store.ts'; -import { ImageStore } from '../image-store.ts'; +import { createImageLoader } from '../image-loader.ts'; import { layoutPages } from '../layout/layout.ts'; import type { MakerCtx } from '../maker-ctx.ts'; import { readDocumentDefinition } from '../read-document.ts'; @@ -18,12 +18,11 @@ export type FontConfig = { * Generates PDF documents. */ export class PdfMaker { - #ctx: MakerCtx; + #fontStore: FontStore; + #resourceRoot?: string; constructor() { - const fontStore = new FontStore(); - const imageStore = new ImageStore(); - this.#ctx = { fontStore, imageStore }; + this.#fontStore = new FontStore(); } /** @@ -35,7 +34,7 @@ export class PdfMaker { * the meta data cannot be extracted from the font. */ registerFont(data: Uint8Array, config?: FontConfig): void { - this.#ctx.fontStore.registerFont(data, config); + this.#fontStore.registerFont(data, config); } /** @@ -45,7 +44,7 @@ export class PdfMaker { * @param root The root directory to read resources from. */ setResourceRoot(root: string): void { - this.#ctx.imageStore.setResourceRoot(root); + this.#resourceRoot = root; } /** @@ -56,19 +55,14 @@ export class PdfMaker { */ async makePdf(definition: DocumentDefinition): Promise { const def = readAs(definition, 'definition', readDocumentDefinition); - const ctx = { ...this.#ctx }; + let fontStore = this.#fontStore; if (def.fonts) { - ctx.fontStore = new FontStore(def.fonts); + fontStore = new FontStore(def.fonts); console.warn( 'Registering fonts via document definition is deprecated. Use PdfMaker.registerFont() instead.', ); } - if (def.images) { - ctx.imageStore = new ImageStore(def.images); - console.warn( - 'Registering images via document definition is deprecated. Use URLs to include images instead.', - ); - } + const ctx: MakerCtx = { fontStore, imageLoader: createImageLoader(this.#resourceRoot) }; if (def.dev?.guides != null) ctx.guides = def.dev.guides; const pages = await layoutPages(def, ctx); return await renderDocument(def, pages); diff --git a/src/api/document.ts b/src/api/document.ts index 98fc616..afc9fbb 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -59,15 +59,6 @@ export type DocumentDefinition = { */ fonts?: FontsDefinition; - /** - * 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. - * - * @deprecated Use URLs to include images. - */ - images?: ImagesDefinition; - /** * Metadata to include in the PDF's *document information dictionary*. */ @@ -256,28 +247,6 @@ export type FontDefinition = { italic?: boolean; }; -/** - * 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. - * - * @deprecated Use URLs to include images. - */ -export type ImagesDefinition = { [name: string]: ImageDefinition }; - -/** - * The definition of a single image. - */ -export type ImageDefinition = { - /** - * The image data as a Uint8Array. - * Supported image formats are PNG and JPEG. - * - * @deprecated Use URLs to include images. - */ - data: Uint8Array; -}; - /** * Information about the current page, provided to functions that create * page-specific headers, footers, and margins. diff --git a/src/image-loader.test.ts b/src/image-loader.test.ts new file mode 100644 index 0000000..9d32326 --- /dev/null +++ b/src/image-loader.test.ts @@ -0,0 +1,87 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { createImageLoader } from './image-loader.ts'; + +const baseDir = import.meta.dirname; + +describe('createImageLoader', () => { + let torusPng: Uint8Array; + + beforeAll(async () => { + torusPng = new Uint8Array(await readFile(join(baseDir, './test/resources/torus.png'))); + vi.spyOn(globalThis, 'fetch').mockImplementation((req: RequestInfo | URL) => { + const url = req instanceof URL ? req.href : (req as string); + if (url.endsWith('/torus.png')) { + return Promise.resolve(new Response(Buffer.from(torusPng))); + } + return Promise.resolve(new Response('Not found', { status: 404, statusText: 'Not Found' })); + }); + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + + it('loads image from file URL', async () => { + const loadImage = createImageLoader(baseDir); + + const image = await loadImage('file:/test/resources/torus.png'); + + expect(image).toEqual( + expect.objectContaining({ url: 'file:/test/resources/torus.png', format: 'png' }), + ); + }); + + it('loads image from data URL', async () => { + const loadImage = createImageLoader(); + const dataUrl = `data:image/png;base64,${Buffer.from(torusPng).toString('base64')}`; + + const image = await loadImage(dataUrl); + + expect(image).toEqual(expect.objectContaining({ url: dataUrl, format: 'png' })); + }); + + it('loads image from http URL', async () => { + const loadImage = createImageLoader(); + + const image = await loadImage('http://example.com/torus.png'); + + expect(image).toEqual( + expect.objectContaining({ url: 'http://example.com/torus.png', format: 'png' }), + ); + }); + + it('reads format, width and height from JPEG image', async () => { + const loadImage = createImageLoader(baseDir); + + const image = await loadImage('file:/test/resources/liberty.jpg'); + + expect(image).toEqual(expect.objectContaining({ format: 'jpeg', width: 160, height: 240 })); + }); + + it('reads format, width and height from PNG image', async () => { + const loadImage = createImageLoader(baseDir); + + const image = await loadImage('file:/test/resources/torus.png'); + + expect(image).toEqual(expect.objectContaining({ format: 'png', width: 256, height: 192 })); + }); + + it('returns same image for same URL', async () => { + const loadImage = createImageLoader(baseDir); + const url = 'file:/test/resources/liberty.jpg'; + + const [image1, image2] = await Promise.all([loadImage(url), loadImage(url)]); + + expect(image1).toBe(image2); + }); + + it('rejects for unsupported URL', async () => { + const loadImage = createImageLoader(); + + await expect(loadImage('foo')).rejects.toThrow("Invalid URL: 'foo'"); + }); +}); diff --git a/src/image-loader.ts b/src/image-loader.ts new file mode 100644 index 0000000..8a5af07 --- /dev/null +++ b/src/image-loader.ts @@ -0,0 +1,41 @@ +import { PDFImage } from '@ralfstx/pdf-core'; + +import { createDataLoader, type DataLoader } from './data-loader.ts'; +import type { Image, ImageFormat } from './images.ts'; + +export type ImageLoader = (url: string) => Promise; + +export function createImageLoader(resourceRoot?: string): ImageLoader { + const dataLoader = createDataLoader(resourceRoot ? { resourceRoot } : undefined); + const cache: Record> = {}; + return (url) => (cache[url] ??= loadImage(url, dataLoader)); +} + +async function loadImage(url: string, dataLoader: DataLoader): Promise { + const { data } = await dataLoader(url); + const format = determineImageFormat(data); + const pdfImage = format === 'jpeg' ? PDFImage.fromJpeg(data) : PDFImage.fromPng(data); + const { width, height } = pdfImage; + return { url, format, width, height, pdfImage }; +} + +function determineImageFormat(data: Uint8Array): ImageFormat { + if (isPng(data)) return 'png'; + if (isJpeg(data)) return 'jpeg'; + throw new Error('Unknown image format'); +} + +function isJpeg(data: Uint8Array): boolean { + return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff; +} + +function isPng(data: Uint8Array): boolean { + return hasBytes(data, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +} + +function hasBytes(data: Uint8Array, offset: number, bytes: number[]) { + for (let i = 0; i < bytes.length; i++) { + if (data[offset + i] !== bytes[i]) return false; + } + return true; +} diff --git a/src/image-store.test.ts b/src/image-store.test.ts deleted file mode 100644 index 8878f7c..0000000 --- a/src/image-store.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; - -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; - -import { ImageStore } from './image-store.ts'; - -const baseDir = import.meta.dirname; - -describe('ImageStore', () => { - let libertyJpg: Uint8Array; - let torusPng: Uint8Array; - let store: ImageStore; - - beforeAll(async () => { - [libertyJpg, torusPng] = await Promise.all([ - readFile(join(baseDir, './test/resources/liberty.jpg')).then((data) => new Uint8Array(data)), - readFile(join(baseDir, './test/resources/torus.png')).then((data) => new Uint8Array(data)), - ]); - vi.spyOn(globalThis, 'fetch').mockImplementation((req: RequestInfo | URL) => { - const url = req instanceof URL ? req.href : (req as string); - if (url.endsWith('/liberty.jpg')) { - return Promise.resolve(new Response(Buffer.from(libertyJpg))); - } - if (url.endsWith('/torus.png')) { - return Promise.resolve(new Response(Buffer.from(torusPng))); - } - return Promise.resolve(new Response('Not found', { status: 404, statusText: 'Not Found' })); - }); - store = new ImageStore(); - store.setResourceRoot(baseDir); - }); - - afterAll(() => { - vi.restoreAllMocks(); - }); - - it('rejects if image could not be loaded', async () => { - await expect(store.selectImage('foo')).rejects.toThrow(new Error("Could not load image 'foo'")); - }); - - it('loads registered images (deprecated)', 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({ url: 'torus', format: 'png' })); - expect(liberty).toEqual(expect.objectContaining({ url: 'liberty', format: 'jpeg' })); - }); - - it('loads image from file system (deprecated)', async () => { - const torusPath = join(baseDir, './test/resources/torus.png'); - - const image = await store.selectImage(torusPath); - - expect(image).toEqual(expect.objectContaining({ url: torusPath, format: 'png' })); - }); - - it('loads image from file URL', async () => { - const fileUrl = 'file:/test/resources/torus.png'; - - const image = await store.selectImage(fileUrl); - - expect(image).toEqual(expect.objectContaining({ url: fileUrl, format: 'png' })); - }); - - it('loads image from data URL', async () => { - const dataUrl = `data:image/png;base64,${Buffer.from(torusPng).toString('base64')}`; - - const image = await store.selectImage(dataUrl); - - expect(image).toEqual(expect.objectContaining({ url: dataUrl, format: 'png' })); - }); - - it('loads image from http URL', async () => { - const httpUrl = 'http://example.com/torus.png'; - - const image = await store.selectImage(httpUrl); - - expect(image).toEqual(expect.objectContaining({ url: httpUrl, format: 'png' })); - }); - - it('reads format, width and height from JPEG image', async () => { - const libertyUrl = 'file:/test/resources/liberty.jpg'; - - const image = await store.selectImage(libertyUrl); - - expect(image).toEqual(expect.objectContaining({ format: 'jpeg', width: 160, height: 240 })); - }); - - it('reads format, width and height from PNG image', async () => { - const torusUrl = 'file:/test/resources/torus.png'; - - const image = await store.selectImage(torusUrl); - - expect(image).toEqual(expect.objectContaining({ format: 'png', width: 256, height: 192 })); - }); - - it('loads image only once for one URL', async () => { - const store = new ImageStore(); - store.setResourceRoot(baseDir); - const url = 'file:/test/resources/liberty.jpg'; - - const [image1, image2] = await Promise.all([store.selectImage(url), store.selectImage(url)]); - - expect(image1).toBe(image2); - }); - - it('returns same image object for concurrent calls', async () => { - const libertyUrl = 'file:/test/resources/liberty.jpg'; - - const [image1, image2] = await Promise.all([ - store.selectImage(libertyUrl), - store.selectImage(libertyUrl), - ]); - - expect(image1).toBe(image2); - }); -}); diff --git a/src/image-store.ts b/src/image-store.ts deleted file mode 100644 index e7bc237..0000000 --- a/src/image-store.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { PDFImage } from '@ralfstx/pdf-core'; - -import { createDataLoader, type DataLoader } from './data-loader.ts'; -import { readRelativeFile } from './fs.ts'; -import type { Image, ImageDef, ImageFormat } from './images.ts'; - -export class ImageStore { - readonly #images: ImageDef[]; - readonly #imageCache: Record> = {}; - #dataLoader: DataLoader; - - constructor(images?: ImageDef[]) { - this.#images = images ?? []; - this.#dataLoader = createDataLoader(); - } - - setResourceRoot(root: string) { - this.#dataLoader = createDataLoader({ resourceRoot: root }); - } - - selectImage(url: string): Promise { - return (this.#imageCache[url] ??= this.loadImage(url)); - } - - async loadImage(url: string): Promise { - const data = await this.loadImageData(url); - const format = determineImageFormat(data); - if (format === 'jpeg') { - const pdfImage = PDFImage.fromJpeg(data); - const { width, height } = pdfImage; - return { url, format, width, height, pdfImage }; - } - if (format === 'png') { - const pdfImage = PDFImage.fromPng(data); - const { width, height } = pdfImage; - return { url, format, width, height, pdfImage }; - } - throw new Error(`Unsupported image format: ${format}`); - } - - async loadImageData(url: string): Promise { - const imageDef = this.#images.find((image) => image.name === url); - if (imageDef) { - return imageDef.data; - } - - const urlSchema = /^(\w+):/.exec(url)?.[1]; - try { - if (urlSchema) { - const { data } = await this.#dataLoader(url); - return data; - } - console.warn( - `Loading images from file names is deprecated ('${url}'). Use file:/ URLs instead.`, - ); - const data = await readRelativeFile('/', url.replace(/^\/+/, '')); - return new Uint8Array(data); - } catch (error) { - throw new Error(`Could not load image '${url}'`, { cause: error }); - } - } -} - -function determineImageFormat(data: Uint8Array): ImageFormat { - if (isPng(data)) return 'png'; - if (isJpeg(data)) return 'jpeg'; - throw new Error('Unknown image format'); -} - -function isJpeg(data: Uint8Array): boolean { - return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff; -} - -function isPng(data: Uint8Array): boolean { - // check PNG signature - return hasBytes(data, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); -} - -function hasBytes(data: Uint8Array, offset: number, bytes: number[]) { - for (let i = 0; i < bytes.length; i++) { - if (data[offset + i] !== bytes[i]) return false; - } - return true; -} diff --git a/src/images.test.ts b/src/images.test.ts deleted file mode 100644 index 2edd27c..0000000 --- a/src/images.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { readImages } from './images.ts'; -import { mkData } from './test/test-utils.ts'; - -describe('readImages', () => { - it('returns an empty array for missing images definition', () => { - const images = readImages({}); - - expect(images).toEqual([]); - }); - - it('returns images array', () => { - const imagesDef = { - foo: { data: mkData('Foo') }, - bar: { data: mkData('Bar'), format: 'jpeg' }, - baz: { data: mkData('Baz'), format: 'png' }, - }; - - const images = readImages(imagesDef); - - expect(images).toEqual([ - { name: 'foo', data: mkData('Foo'), format: 'jpeg' }, - { name: 'bar', data: mkData('Bar'), format: 'jpeg' }, - { name: 'baz', data: mkData('Baz'), format: 'png' }, - ]); - }); - - it('throws on invalid type', () => { - const fn = () => readImages(23); - - expect(fn).toThrow(new TypeError('Expected object, got: 23')); - }); - - it('throws on invalid image definition', () => { - const fn = () => readImages({ foo: 23 }); - - expect(fn).toThrow(new TypeError('Invalid value for "foo": Expected object, got: 23')); - }); - - it('throws on invalid image data', () => { - const fn = () => readImages({ foo: { data: 23 } }); - - expect(fn).toThrow(new TypeError('Invalid value for "foo/data": Expected Uint8Array, got: 23')); - }); -}); diff --git a/src/images.ts b/src/images.ts index 599a39a..411296c 100644 --- a/src/images.ts +++ b/src/images.ts @@ -1,16 +1,6 @@ import type { PDFImage } from '@ralfstx/pdf-core'; -import { readBinaryData } from './binary-data.ts'; -import { optional, readAs, readObject, required, types } from './types.ts'; - -const imageFormats = ['jpeg', 'png']; -export type ImageFormat = (typeof imageFormats)[number]; - -export type ImageDef = { - name: string; - data: Uint8Array; - format: ImageFormat; -}; +export type ImageFormat = 'jpeg' | 'png'; export type Image = { url: string; @@ -19,17 +9,3 @@ export type Image = { format: ImageFormat; pdfImage: PDFImage; }; - -export function readImages(input: unknown): ImageDef[] { - return Object.entries(readObject(input)).map(([name, imageDef]) => { - const { data, format } = readAs(imageDef, name, required(readImage)); - return { name, data, format: format ?? 'jpeg' }; - }); -} - -function readImage(input: unknown) { - return readObject(input, { - data: required(readBinaryData), - format: optional(types.string({ enum: imageFormats })), - }) as { data: Uint8Array; format?: ImageFormat }; -} diff --git a/src/layout/layout-image.test.ts b/src/layout/layout-image.test.ts index 083bc21..d64db6c 100644 --- a/src/layout/layout-image.test.ts +++ b/src/layout/layout-image.test.ts @@ -1,27 +1,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Box } from '../box.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'; import { layoutImageContent } from './layout-image.ts'; +const mockImageLoader = vi.fn((selector: string) => { + const match = /^img-(\d+)-(\d+)$/.exec(selector); + if (match) { + return Promise.resolve(fakeImage(selector, Number(match[1]), Number(match[2]))); + } + throw new Error(`Unknown image: ${selector}`); +}); + describe('layout-image', () => { let box: Box; let ctx: MakerCtx; beforeEach(() => { - const imageStore = new ImageStore(); - imageStore.selectImage = vi.fn((selector: string) => { - const match = /^img-(\d+)-(\d+)$/.exec(selector); - if (match) { - return Promise.resolve(fakeImage(selector, Number(match[1]), Number(match[2]))); - } - throw new Error(`Unknown image: ${selector}`); - }); box = { x: 20, y: 30, width: 400, height: 700 }; - ctx = { imageStore } as MakerCtx; + ctx = { imageLoader: mockImageLoader } as unknown as MakerCtx; }); describe('layoutImageContent', () => { @@ -41,14 +40,13 @@ describe('layout-image', () => { }); }); - it('passes width and height to ImageStore', async () => { + it('calls imageLoader with the image URL', async () => { const block = { image: 'img-720-480', width: 30, height: 40 }; box = { x: 20, y: 30, width: 200, height: 100 }; await layoutImageContent(block, box, ctx); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(ctx.imageStore.selectImage).toHaveBeenCalledWith('img-720-480'); + expect(mockImageLoader).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 bf2291f..bb76a8e 100644 --- a/src/layout/layout-image.ts +++ b/src/layout/layout-image.ts @@ -10,7 +10,7 @@ export async function layoutImageContent( box: Box, ctx: MakerCtx, ): Promise { - const image = await ctx.imageStore.selectImage(block.image); + const image = await ctx.imageLoader(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 3430f22..7dcfd48 100644 --- a/src/maker-ctx.ts +++ b/src/maker-ctx.ts @@ -1,8 +1,8 @@ import type { FontStore } from './font-store.ts'; -import type { ImageStore } from './image-store.ts'; +import type { ImageLoader } from './image-loader.ts'; export type MakerCtx = { fontStore: FontStore; - imageStore: ImageStore; + imageLoader: ImageLoader; guides?: boolean; }; diff --git a/src/read-document.ts b/src/read-document.ts index 1d9aad7..94a2a54 100644 --- a/src/read-document.ts +++ b/src/read-document.ts @@ -5,8 +5,6 @@ import type { BoxEdges, Size } from './box.ts'; import { parseEdges } from './box.ts'; import type { FontDef } from './fonts.ts'; import { readFonts } from './fonts.ts'; -import type { ImageDef } from './images.ts'; -import { readImages } from './images.ts'; import type { Block, TextAttrs } from './read-block.ts'; import { readBlock, readInheritableAttrs } from './read-block.ts'; import { parseOrientation, readPageSize } from './read-page-size.ts'; @@ -15,7 +13,6 @@ import { dynamic, optional, readAs, readObject, required, typeError, types } fro export type DocumentDefinition = { fonts?: FontDef[]; - images?: ImageDef[]; pageSize?: Size; pageOrientation?: 'portrait' | 'landscape'; info?: Metadata; @@ -58,7 +55,6 @@ export type PageInfo = { export function readDocumentDefinition(input: unknown): DocumentDefinition { const def1 = readObject(input, { fonts: optional(readFonts), - images: optional(readImages), pageSize: optional(readPageSize), pageOrientation: optional(parseOrientation), info: optional(readInfo), From 394d3c0720a3593382298aa64757244c01efac6c Mon Sep 17 00:00:00 2001 From: Ralf Sternberg Date: Sun, 15 Feb 2026 14:05:14 +0100 Subject: [PATCH 3/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Replace=20Image=20type?= =?UTF-8?q?=20with=20PDFImage=20from=20pdf-core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Image` type was a wrapper around `PDFImage` with redundant `width` and `height` fields (already on `PDFImage`) and unused `url` and `format` fields that were set but never read. Remove `images.ts` entirely and use `PDFImage` directly in all places. Make `ImageFormat` a file-local type in `image-loader.ts`. Simplify `fakeImage` test helper to return `PDFImage` directly. Co-Authored-By: Claude Opus 4.6 --- src/frame.ts | 5 +++-- src/image-loader.test.ts | 23 ++++++++++++----------- src/image-loader.ts | 13 ++++++------- src/images.ts | 11 ----------- src/layout/layout-image.test.ts | 2 +- src/layout/layout-image.ts | 5 +++-- src/render/render-image.test.ts | 7 +++---- src/render/render-image.ts | 2 +- src/test/test-utils.ts | 12 ++---------- 9 files changed, 31 insertions(+), 49 deletions(-) delete mode 100644 src/images.ts diff --git a/src/frame.ts b/src/frame.ts index 05e87fc..57c6408 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -1,5 +1,6 @@ +import type { PDFImage } from '@ralfstx/pdf-core'; + import type { Font } from './fonts.ts'; -import type { Image } from './images.ts'; import type { Color } from './read-color.ts'; import type { PathCommand } from './svg-paths.ts'; @@ -65,7 +66,7 @@ export type ImageObject = { y: number; width: number; height: number; - image: Image; + image: PDFImage; }; export type GraphicsObject = { diff --git a/src/image-loader.test.ts b/src/image-loader.test.ts index 9d32326..fecd333 100644 --- a/src/image-loader.test.ts +++ b/src/image-loader.test.ts @@ -30,9 +30,8 @@ describe('createImageLoader', () => { const image = await loadImage('file:/test/resources/torus.png'); - expect(image).toEqual( - expect.objectContaining({ url: 'file:/test/resources/torus.png', format: 'png' }), - ); + expect(image.width).toBe(256); + expect(image.height).toBe(192); }); it('loads image from data URL', async () => { @@ -41,7 +40,8 @@ describe('createImageLoader', () => { const image = await loadImage(dataUrl); - expect(image).toEqual(expect.objectContaining({ url: dataUrl, format: 'png' })); + expect(image.width).toBe(256); + expect(image.height).toBe(192); }); it('loads image from http URL', async () => { @@ -49,25 +49,26 @@ describe('createImageLoader', () => { const image = await loadImage('http://example.com/torus.png'); - expect(image).toEqual( - expect.objectContaining({ url: 'http://example.com/torus.png', format: 'png' }), - ); + expect(image.width).toBe(256); + expect(image.height).toBe(192); }); - it('reads format, width and height from JPEG image', async () => { + it('reads width and height from JPEG image', async () => { const loadImage = createImageLoader(baseDir); const image = await loadImage('file:/test/resources/liberty.jpg'); - expect(image).toEqual(expect.objectContaining({ format: 'jpeg', width: 160, height: 240 })); + expect(image.width).toBe(160); + expect(image.height).toBe(240); }); - it('reads format, width and height from PNG image', async () => { + it('reads width and height from PNG image', async () => { const loadImage = createImageLoader(baseDir); const image = await loadImage('file:/test/resources/torus.png'); - expect(image).toEqual(expect.objectContaining({ format: 'png', width: 256, height: 192 })); + expect(image.width).toBe(256); + expect(image.height).toBe(192); }); it('returns same image for same URL', async () => { diff --git a/src/image-loader.ts b/src/image-loader.ts index 8a5af07..d38b78e 100644 --- a/src/image-loader.ts +++ b/src/image-loader.ts @@ -1,22 +1,21 @@ import { PDFImage } from '@ralfstx/pdf-core'; import { createDataLoader, type DataLoader } from './data-loader.ts'; -import type { Image, ImageFormat } from './images.ts'; -export type ImageLoader = (url: string) => Promise; +type ImageFormat = 'jpeg' | 'png'; + +export type ImageLoader = (url: string) => Promise; export function createImageLoader(resourceRoot?: string): ImageLoader { const dataLoader = createDataLoader(resourceRoot ? { resourceRoot } : undefined); - const cache: Record> = {}; + const cache: Record> = {}; return (url) => (cache[url] ??= loadImage(url, dataLoader)); } -async function loadImage(url: string, dataLoader: DataLoader): Promise { +async function loadImage(url: string, dataLoader: DataLoader): Promise { const { data } = await dataLoader(url); const format = determineImageFormat(data); - const pdfImage = format === 'jpeg' ? PDFImage.fromJpeg(data) : PDFImage.fromPng(data); - const { width, height } = pdfImage; - return { url, format, width, height, pdfImage }; + return format === 'jpeg' ? PDFImage.fromJpeg(data) : PDFImage.fromPng(data); } function determineImageFormat(data: Uint8Array): ImageFormat { diff --git a/src/images.ts b/src/images.ts deleted file mode 100644 index 411296c..0000000 --- a/src/images.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { PDFImage } from '@ralfstx/pdf-core'; - -export type ImageFormat = 'jpeg' | 'png'; - -export type Image = { - url: string; - width: number; - height: number; - format: ImageFormat; - pdfImage: PDFImage; -}; diff --git a/src/layout/layout-image.test.ts b/src/layout/layout-image.test.ts index d64db6c..eab2862 100644 --- a/src/layout/layout-image.test.ts +++ b/src/layout/layout-image.test.ts @@ -9,7 +9,7 @@ import { layoutImageContent } from './layout-image.ts'; const mockImageLoader = vi.fn((selector: string) => { const match = /^img-(\d+)-(\d+)$/.exec(selector); if (match) { - return Promise.resolve(fakeImage(selector, Number(match[1]), Number(match[2]))); + return Promise.resolve(fakeImage(Number(match[1]), Number(match[2]))); } throw new Error(`Unknown image: ${selector}`); }); diff --git a/src/layout/layout-image.ts b/src/layout/layout-image.ts index bb76a8e..cc78d08 100644 --- a/src/layout/layout-image.ts +++ b/src/layout/layout-image.ts @@ -1,6 +1,7 @@ +import type { PDFImage } from '@ralfstx/pdf-core'; + import type { Box, Pos, Size } from '../box.ts'; import type { ImageObject, RenderObject } from '../frame.ts'; -import type { Image } from '../images.ts'; import type { MakerCtx } from '../maker-ctx.ts'; import type { ImageBlock } from '../read-block.ts'; import type { LayoutContent } from './layout.ts'; @@ -46,6 +47,6 @@ function align(box: Box, size: Size, alignment?: string): Pos { return { x: box.x + xShift, y: box.y + yShift }; } -function createImageObject(image: Image, pos: Pos, size: Size): ImageObject { +function createImageObject(image: PDFImage, pos: Pos, size: Size): ImageObject { return { type: 'image', image, x: pos.x, y: pos.y, width: size.width, height: size.height }; } diff --git a/src/render/render-image.test.ts b/src/render/render-image.test.ts index bff1230..b669d7a 100644 --- a/src/render/render-image.test.ts +++ b/src/render/render-image.test.ts @@ -1,9 +1,8 @@ -import { PDFPage } from '@ralfstx/pdf-core'; +import { type PDFImage, PDFPage } from '@ralfstx/pdf-core'; import { beforeEach, describe, expect, it } from 'vitest'; import type { Size } from '../box.ts'; import type { ImageObject } from '../frame.ts'; -import type { Image } from '../images.ts'; import type { Page } from '../page.ts'; import { fakeImage, getContentStream } from '../test/test-utils.ts'; import { renderImage } from './render-image.ts'; @@ -12,13 +11,13 @@ describe('renderImage', () => { const pos = { x: 10, y: 20 }; let page: Page; let size: Size; - let image: Image; + let image: PDFImage; beforeEach(() => { size = { width: 500, height: 800 }; const pdfPage = new PDFPage(size.width, size.height); page = { size, pdfPage } as Page; - image = fakeImage('test-image.jpg', 100, 150); + image = fakeImage(100, 150); }); it('renders single image object', () => { diff --git a/src/render/render-image.ts b/src/render/render-image.ts index eda24c9..1fb6b64 100644 --- a/src/render/render-image.ts +++ b/src/render/render-image.ts @@ -11,6 +11,6 @@ export function renderImage(object: ImageObject, page: Page, base: Pos) { .saveGraphicsState() .translate(x, y) .scale(width, height) - .drawImage(object.image.pdfImage) + .drawImage(object.image) .restoreGraphicsState(); } diff --git a/src/test/test-utils.ts b/src/test/test-utils.ts index 8363d34..444257d 100644 --- a/src/test/test-utils.ts +++ b/src/test/test-utils.ts @@ -4,7 +4,6 @@ import { PDFImage, PDFRef } from '@ralfstx/pdf-core'; import type { Font } from '../fonts.ts'; import { weightToNumber } from '../fonts.ts'; import type { Frame } from '../frame.ts'; -import type { Image } from '../images.ts'; import type { Page } from '../page.ts'; import type { TextAttrs, TextSpan } from '../read-block.ts'; @@ -20,16 +19,9 @@ export function fakeFont(name: string, opts?: Partial>): Font return font; } -export function fakeImage(name: string, width: number, height: number): Image { +export function fakeImage(width: number, height: number): PDFImage { const data = createTestJpeg(width, height); - return { - name, - width, - height, - format: 'jpeg', - url: 'test', - pdfImage: PDFImage.fromJpeg(data), - } as Image; + return PDFImage.fromJpeg(data); } /** From 43b762c8f8c7f439624d3acad9d97cbea9a0616b Mon Sep 17 00:00:00 2001 From: Ralf Sternberg Date: Sun, 15 Feb 2026 15:43:43 +0100 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=92=A5=20Remove=20support=20for=20fon?= =?UTF-8?q?ts=20in=20document=20definition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FontStore` parsed each font twice: once in `registerFont()` to extract metadata, and again in `_loadFont()` when actually selecting a font. Additionally, a stale TODO comment remained from the pdf-core migration, and the deprecated `fonts` property in the document definition was still supported. Store the `PDFEmbeddedFont` created during `registerFont()` in `FontDef` so that `_loadFont()` can reuse it. Remove the `fonts` property from `DocumentDefinition`, along with `readFonts`, `readFont`, `FontsDefinition`, `FontDefinition`, and the `FontStore` constructor parameter. Update font-store tests to use `registerFont()` with explicit config instead of pre-built `FontDef` arrays. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 4 +++ src/api/PdfMaker.ts | 12 +++----- src/api/document.ts | 40 -------------------------- src/font-store.test.ts | 65 +++++++++++++++++++++--------------------- src/font-store.ts | 10 +++---- src/fonts.test.ts | 58 +------------------------------------ src/fonts.ts | 26 ++--------------- src/read-document.ts | 4 --- 8 files changed, 49 insertions(+), 170 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1857fe3..370e0e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ referred to by URL instead. The `ImagesDefinition` and `ImageDefinition` types have been removed. +- The `fonts` property in the document definition. Fonts must now be + registered with `PdfMaker.registerFont()`. The `FontsDefinition` and + `FontDefinition` types have been removed. + ### Breaking - Text height is now based on the OS/2 typographic metrics diff --git a/src/api/PdfMaker.ts b/src/api/PdfMaker.ts index fafb01d..f7679dc 100644 --- a/src/api/PdfMaker.ts +++ b/src/api/PdfMaker.ts @@ -55,14 +55,10 @@ export class PdfMaker { */ async makePdf(definition: DocumentDefinition): Promise { const def = readAs(definition, 'definition', readDocumentDefinition); - let fontStore = this.#fontStore; - if (def.fonts) { - fontStore = new FontStore(def.fonts); - console.warn( - 'Registering fonts via document definition is deprecated. Use PdfMaker.registerFont() instead.', - ); - } - const ctx: MakerCtx = { fontStore, imageLoader: createImageLoader(this.#resourceRoot) }; + const ctx: MakerCtx = { + fontStore: this.#fontStore, + imageLoader: createImageLoader(this.#resourceRoot), + }; if (def.dev?.guides != null) ctx.guides = def.dev.guides; const pages = await layoutPages(def, ctx); return await renderDocument(def, pages); diff --git a/src/api/document.ts b/src/api/document.ts index afc9fbb..f01edf8 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -51,14 +51,6 @@ export type DocumentDefinition = { */ margin?: Length | BoxLengths | ((info: PageInfo) => Length | BoxLengths); - /** - * The fonts to use in the document. There is no default. Each font that is used in the document - * must be registered. Not needed for documents that contain only graphics. - * - * @deprecated Register fonts with `PdfMaker` instead. - */ - fonts?: FontsDefinition; - /** * Metadata to include in the PDF's *document information dictionary*. */ @@ -215,38 +207,6 @@ export type CustomInfoAttrs = CustomInfoProps; */ export type CustomInfoProps = Record<`XX${string}`, string>; -/** - * An object that defines the fonts to use in the document. - * - * @deprecated Register fonts with `PdfMaker` instead. - */ -export type FontsDefinition = { [name: string]: FontDefinition[] }; - -/** - * The definition of a single font. - * - * @deprecated Register fonts with `PdfMaker` instead. - */ -export type FontDefinition = { - /** - * The font data as a Uint8Array. - * - * Supports TrueType font files (`.ttf`) and OpenType (`.otf`) font - * files with TrueType outlines. - */ - data: Uint8Array; - - /** - * Whether this is a bold font. - */ - bold?: boolean; - - /** - * Whether this is an italic font. - */ - italic?: boolean; -}; - /** * Information about the current page, provided to functions that create * page-specific headers, footers, and margins. diff --git a/src/font-store.test.ts b/src/font-store.test.ts index 0f29846..78a46e9 100644 --- a/src/font-store.test.ts +++ b/src/font-store.test.ts @@ -5,7 +5,6 @@ import type { PDFEmbeddedFont } from '@ralfstx/pdf-core'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { FontStore } from './font-store.ts'; -import type { FontDef } from './fonts.ts'; import { mkData } from './test/test-utils.ts'; vi.mock('@ralfstx/pdf-core', async (importOriginal) => { @@ -39,14 +38,6 @@ vi.mock('@ralfstx/pdf-core', async (importOriginal) => { }); describe('FontStore', () => { - let normalFont: FontDef; - let italicFont: FontDef; - let obliqueFont: FontDef; - let boldFont: FontDef; - let italicBoldFont: FontDef; - let obliqueBoldFont: FontDef; - let otherFont: FontDef; - describe('registerFont', () => { let robotoRegular: Uint8Array; let robotoLightItalic: Uint8Array; @@ -93,23 +84,7 @@ describe('FontStore', () => { let store: FontStore; beforeEach(() => { - normalFont = fakeFontDef('Test'); - italicFont = fakeFontDef('Test', { style: 'italic' }); - obliqueFont = fakeFontDef('Test', { style: 'oblique' }); - boldFont = fakeFontDef('Test', { weight: 700 }); - italicBoldFont = fakeFontDef('Test', { style: 'italic', weight: 700 }); - obliqueBoldFont = fakeFontDef('Test', { style: 'oblique', weight: 700 }); - otherFont = fakeFontDef('Other'); - - store = new FontStore([ - normalFont, - italicFont, - obliqueFont, - boldFont, - italicBoldFont, - obliqueBoldFont, - otherFont, - ]); + store = createTestStore(); }); afterEach(() => { @@ -137,7 +112,9 @@ describe('FontStore', () => { }); it('rejects when no matching font style can be found', async () => { - store = new FontStore([normalFont, boldFont]); + const store = new FontStore(); + registerFakeFont(store, 'Test'); + registerFakeFont(store, 'Test', { weight: 700 }); await expect(store.selectFont({ fontFamily: 'Test', fontStyle: 'italic' })).rejects.toThrow( new Error("Could not load font for 'Test', style=italic, weight=normal", { @@ -181,7 +158,11 @@ describe('FontStore', () => { }); it('falls back to oblique when no italic font can be found', async () => { - store = new FontStore([normalFont, obliqueFont, boldFont, obliqueBoldFont]); + const store = new FontStore(); + registerFakeFont(store, 'Test'); + registerFakeFont(store, 'Test', { style: 'oblique' }); + registerFakeFont(store, 'Test', { weight: 700 }); + registerFakeFont(store, 'Test', { style: 'oblique', weight: 700 }); await expect(store.selectFont({ fontFamily: 'Test', fontStyle: 'italic' })).resolves.toEqual( expect.objectContaining({ name: 'MockFont:Test:oblique:400' }), @@ -189,7 +170,11 @@ describe('FontStore', () => { }); it('falls back to italic when no oblique font can be found', async () => { - store = new FontStore([normalFont, italicFont, boldFont, italicBoldFont]); + const store = new FontStore(); + registerFakeFont(store, 'Test'); + registerFakeFont(store, 'Test', { style: 'italic' }); + registerFakeFont(store, 'Test', { weight: 700 }); + registerFakeFont(store, 'Test', { style: 'italic', weight: 700 }); const font = await store.selectFont({ fontFamily: 'Test', fontStyle: 'italic' }); @@ -228,9 +213,25 @@ describe('FontStore', () => { }); }); -function fakeFontDef(family: string, options?: Partial): FontDef { +function registerFakeFont( + store: FontStore, + family: string, + options?: { style?: string; weight?: number }, +) { const style = options?.style ?? 'normal'; const weight = options?.weight ?? 400; - const data = options?.data ?? mkData([family, style, weight].join(':')); - return { family, style, weight, data }; + const data = mkData([family, style, weight].join(':')); + store.registerFont(data, { family, style: style as 'normal', weight }); +} + +function createTestStore(): FontStore { + const store = new FontStore(); + registerFakeFont(store, 'Test'); + registerFakeFont(store, 'Test', { style: 'italic' }); + registerFakeFont(store, 'Test', { style: 'oblique' }); + registerFakeFont(store, 'Test', { weight: 700 }); + registerFakeFont(store, 'Test', { style: 'italic', weight: 700 }); + registerFakeFont(store, 'Test', { style: 'oblique', weight: 700 }); + registerFakeFont(store, 'Other'); + return store; } diff --git a/src/font-store.ts b/src/font-store.ts index 31123da..e1b8496 100644 --- a/src/font-store.ts +++ b/src/font-store.ts @@ -10,8 +10,8 @@ export class FontStore { readonly #fontDefs: FontDef[]; #fontCache: Record> = {}; - constructor(fontDefs?: FontDef[]) { - this.#fontDefs = fontDefs ?? []; + constructor() { + this.#fontDefs = []; } registerFont(data: Uint8Array, config?: FontConfig): void { @@ -19,7 +19,7 @@ export class FontStore { const family = config?.family ?? pdfFont.familyName; const style = config?.style ?? pdfFont.style; const weight = weightToNumber(config?.weight ?? pdfFont.weight); - this.#fontDefs.push({ family, style, weight, data }); + this.#fontDefs.push({ family, style, weight, data, pdfFont }); this.#fontCache = {}; // Invalidate cache } @@ -40,11 +40,11 @@ export class FontStore { _loadFont(selector: FontSelector, key: string): Promise { const selectedFontDef = selectFontDef(this.#fontDefs, selector); - const pdfFont = new PDFEmbeddedFont(selectedFontDef.data); + const pdfFont = selectedFontDef.pdfFont ?? new PDFEmbeddedFont(selectedFontDef.data); return Promise.resolve( pickDefined({ key, - name: pdfFont.fontName ?? selectedFontDef.family, // TODO ?? pdfFont.postscriptName + name: pdfFont.fontName ?? selectedFontDef.family, style: selector.fontStyle ?? 'normal', weight: weightToNumber(selector.fontWeight ?? 400), pdfFont, diff --git a/src/fonts.test.ts b/src/fonts.test.ts index cccddb8..ab90d9e 100644 --- a/src/fonts.test.ts +++ b/src/fonts.test.ts @@ -1,59 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { FontWeight } from './api/text.ts'; -import { readFonts, weightToNumber } from './fonts.ts'; - -describe('readFonts', () => { - it('returns fonts array', () => { - const fontsDef = { - Test: [ - { data: mkData('Test_Sans_Normal') }, - { data: mkData('Test_Sans_Italic'), italic: true }, - { data: mkData('Test_Sans_Bold'), bold: true }, - { data: mkData('Test_Sans_BoldItalic'), italic: true, bold: true }, - ], - Other: [{ data: mkData('Other_Normal') }], - }; - - const fonts = readFonts(fontsDef); - - expect(fonts).toEqual([ - { family: 'Test', style: 'normal', weight: 400, data: mkData('Test_Sans_Normal') }, - { family: 'Test', style: 'italic', weight: 400, data: mkData('Test_Sans_Italic') }, - { family: 'Test', style: 'normal', weight: 700, data: mkData('Test_Sans_Bold') }, - { family: 'Test', style: 'italic', weight: 700, data: mkData('Test_Sans_BoldItalic') }, - { family: 'Other', style: 'normal', weight: 400, data: mkData('Other_Normal') }, - ]); - }); - - it('throws on missing input', () => { - expect(() => readFonts(undefined)).toThrow(new TypeError('Expected object, got: undefined')); - }); - - it('throws on invalid type', () => { - expect(() => readFonts(23)).toThrow(new TypeError('Expected object, got: 23')); - }); - - it('throws on invalid italic value', () => { - const fn = () => readFonts({ Test: [{ data: 'data', italic: 23 }] }); - - expect(fn).toThrow( - new TypeError('Invalid value for "Test/0/italic": Expected boolean, got: 23'), - ); - }); - - it('throws on invalid bold value', () => { - const fn = () => readFonts({ Test: [{ data: 'data', bold: 23 }] }); - - expect(fn).toThrow(new TypeError('Invalid value for "Test/0/bold": Expected boolean, got: 23')); - }); - - it('throws on missing data', () => { - const fn = () => readFonts({ Test: [{ italic: true }] }); - - expect(fn).toThrow(new TypeError('Invalid value for "Test/0": Missing value for "data"')); - }); -}); +import { weightToNumber } from './fonts.ts'; describe('weightToNumber', () => { it('supports keywords `normal` and `bold`', () => { @@ -79,7 +27,3 @@ describe('weightToNumber', () => { expect(() => weightToNumber(0.1)).toThrow(new Error('Invalid font weight: 0.1')); }); }); - -function mkData(value: string) { - return new Uint8Array(value.split('').map((c) => c.charCodeAt(0))); -} diff --git a/src/fonts.ts b/src/fonts.ts index a2c6f55..aafdef9 100644 --- a/src/fonts.ts +++ b/src/fonts.ts @@ -1,9 +1,7 @@ -import type { PDFFont } from '@ralfstx/pdf-core'; +import type { PDFEmbeddedFont, PDFFont } from '@ralfstx/pdf-core'; import type { FontStyle, FontWeight } from './api/text.ts'; -import { readBinaryData } from './binary-data.ts'; import { printValue } from './print-value.ts'; -import { optional, readAs, readBoolean, readObject, required, types } from './types.ts'; /** * The resolved definition of a font. @@ -13,6 +11,7 @@ export type FontDef = { style: FontStyle; weight: number; data: Uint8Array; + pdfFont?: PDFEmbeddedFont; }; export type Font = { @@ -29,27 +28,6 @@ export type FontSelector = { fontWeight?: FontWeight; }; -export function readFonts(input: unknown): FontDef[] { - return Object.entries(readObject(input)).flatMap(([name, fontDef]) => { - return readAs(fontDef, name, required(types.array(readFont))).map( - (font) => ({ family: name, ...font }) as FontDef, - ); - }); -} - -export function readFont(input: unknown): Partial { - const obj = readObject(input, { - italic: optional((value) => readBoolean(value) || undefined), - bold: optional((value) => readBoolean(value) || undefined), - data: required(readBinaryData), - }); - return { - style: obj.italic ? 'italic' : 'normal', - weight: obj.bold ? 700 : 400, - data: obj.data, - } as FontDef; -} - export function weightToNumber(weight: FontWeight): number { if (weight === 'normal') { return 400; diff --git a/src/read-document.ts b/src/read-document.ts index 94a2a54..6f2a353 100644 --- a/src/read-document.ts +++ b/src/read-document.ts @@ -3,8 +3,6 @@ import type { PDFDocument } from '@ralfstx/pdf-core'; import type { FileRelationShip } from './api/document.ts'; import type { BoxEdges, Size } from './box.ts'; import { parseEdges } from './box.ts'; -import type { FontDef } from './fonts.ts'; -import { readFonts } from './fonts.ts'; import type { Block, TextAttrs } from './read-block.ts'; import { readBlock, readInheritableAttrs } from './read-block.ts'; import { parseOrientation, readPageSize } from './read-page-size.ts'; @@ -12,7 +10,6 @@ import type { Obj } from './types.ts'; import { dynamic, optional, readAs, readObject, required, typeError, types } from './types.ts'; export type DocumentDefinition = { - fonts?: FontDef[]; pageSize?: Size; pageOrientation?: 'portrait' | 'landscape'; info?: Metadata; @@ -54,7 +51,6 @@ export type PageInfo = { export function readDocumentDefinition(input: unknown): DocumentDefinition { const def1 = readObject(input, { - fonts: optional(readFonts), pageSize: optional(readPageSize), pageOrientation: optional(parseOrientation), info: optional(readInfo), From a612fb6dde82d477a46aed0d878988f1029b2218 Mon Sep 17 00:00:00 2001 From: Ralf Sternberg Date: Sun, 15 Feb 2026 16:01:55 +0100 Subject: [PATCH 5/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Replace=20Font=20type?= =?UTF-8?q?=20with=20PDFFont=20from=20pdf-core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Font` type was a thin wrapper around `PDFFont`, duplicating properties (`key`, `style`, `weight`) that already exist on the underlying `PDFFont` class. Consumers always dereferenced `.pdfFont` to access the actual font object. Remove the `Font` type and use `PDFFont` directly throughout the codebase. `FontStore.selectFont()` now returns `PDFFont` instead of `Font`, eliminating the wrapping logic in `_loadFont`. Co-Authored-By: Claude Opus 4.6 --- src/font-store.test.ts | 36 ++++++++++++++++------------------ src/font-store.ts | 24 +++++++---------------- src/fonts.ts | 10 +--------- src/frame.ts | 5 ++--- src/layout/layout-text.test.ts | 5 +++-- src/layout/layout-text.ts | 7 ++++--- src/render/render-page.test.ts | 5 ++--- src/render/render-text.test.ts | 4 ++-- src/render/render-text.ts | 6 ++---- src/test/test-utils.ts | 15 ++++---------- src/text.test.ts | 4 ++-- src/text.ts | 9 +++++---- 12 files changed, 51 insertions(+), 79 deletions(-) diff --git a/src/font-store.test.ts b/src/font-store.test.ts index 78a46e9..5ab7005 100644 --- a/src/font-store.test.ts +++ b/src/font-store.test.ts @@ -59,8 +59,8 @@ describe('FontStore', () => { const selected1 = await store.selectFont({ fontFamily: 'Roboto' }); const selected2 = await store.selectFont({ fontFamily: 'Roboto Light', fontStyle: 'italic' }); - expect(selected1.pdfFont.fontName).toBe('Roboto'); - expect(selected2.pdfFont.fontName).toBe('Roboto Light Italic'); + expect(selected1.fontName).toBe('Roboto'); + expect(selected2.fontName).toBe('Roboto Light Italic'); }); it('registers font with custom config', async () => { @@ -75,8 +75,8 @@ describe('FontStore', () => { const selected1 = await store.selectFont({ fontFamily: 'Custom Name' }); const selected2 = await store.selectFont({ fontFamily: 'Custom Name', fontWeight: 'bold' }); - expect(selected1.pdfFont.fontName).toBe('Roboto Light Italic'); - expect(selected2.pdfFont.fontName).toBe('Roboto'); + expect(selected1.fontName).toBe('Roboto Light Italic'); + expect(selected2.fontName).toBe('Roboto'); }); }); @@ -131,29 +131,29 @@ describe('FontStore', () => { const font3 = await store.selectFont({ fontFamily, fontStyle: 'italic' }); const font4 = await store.selectFont({ fontFamily, fontStyle: 'italic', fontWeight: 'bold' }); - expect(font1.pdfFont.fontName).toBe('MockFont:Test:normal:400'); - expect(font2.pdfFont.fontName).toBe('MockFont:Test:normal:700'); - expect(font3.pdfFont.fontName).toBe('MockFont:Test:italic:400'); - expect(font4.pdfFont.fontName).toBe('MockFont:Test:italic:700'); + expect(font1.fontName).toBe('MockFont:Test:normal:400'); + expect(font2.fontName).toBe('MockFont:Test:normal:700'); + expect(font3.fontName).toBe('MockFont:Test:italic:400'); + expect(font4.fontName).toBe('MockFont:Test:italic:700'); }); it('selects first matching font if no family specified', async () => { const font1 = await store.selectFont({}); - expect(font1.pdfFont.fontName).toBe('MockFont:Test:normal:400'); + expect(font1.fontName).toBe('MockFont:Test:normal:400'); const font2 = await store.selectFont({ fontWeight: 'bold' }); - expect(font2.pdfFont.fontName).toBe('MockFont:Test:normal:700'); + expect(font2.fontName).toBe('MockFont:Test:normal:700'); const font3 = await store.selectFont({ fontStyle: 'italic' }); - expect(font3.pdfFont.fontName).toBe('MockFont:Test:italic:400'); + expect(font3.fontName).toBe('MockFont:Test:italic:400'); const font4 = await store.selectFont({ fontStyle: 'italic', fontWeight: 'bold' }); - expect(font4.pdfFont.fontName).toBe('MockFont:Test:italic:700'); + expect(font4.fontName).toBe('MockFont:Test:italic:700'); }); it('selects font with matching font family', async () => { await expect(store.selectFont({ fontFamily: 'Other' })).resolves.toEqual( - expect.objectContaining({ name: 'MockFont:Other:normal:400' }), + expect.objectContaining({ fontName: 'MockFont:Other:normal:400' }), ); }); @@ -165,7 +165,7 @@ describe('FontStore', () => { registerFakeFont(store, 'Test', { style: 'oblique', weight: 700 }); await expect(store.selectFont({ fontFamily: 'Test', fontStyle: 'italic' })).resolves.toEqual( - expect.objectContaining({ name: 'MockFont:Test:oblique:400' }), + expect.objectContaining({ fontName: 'MockFont:Test:oblique:400' }), ); }); @@ -178,17 +178,15 @@ describe('FontStore', () => { const font = await store.selectFont({ fontFamily: 'Test', fontStyle: 'italic' }); - expect(font.pdfFont).toEqual( - expect.objectContaining({ fontName: 'MockFont:Test:italic:400' }), - ); + expect(font).toEqual(expect.objectContaining({ fontName: 'MockFont:Test:italic:400' })); }); it('falls back when no matching font weight can be found', async () => { await expect(store.selectFont({ fontFamily: 'Other', fontWeight: 'bold' })).resolves.toEqual( - expect.objectContaining({ name: 'MockFont:Other:normal:400' }), + expect.objectContaining({ fontName: 'MockFont:Other:normal:400' }), ); await expect(store.selectFont({ fontFamily: 'Other', fontWeight: 200 })).resolves.toEqual( - expect.objectContaining({ name: 'MockFont:Other:normal:400' }), + expect.objectContaining({ fontName: 'MockFont:Other:normal:400' }), ); }); diff --git a/src/font-store.ts b/src/font-store.ts index e1b8496..2461066 100644 --- a/src/font-store.ts +++ b/src/font-store.ts @@ -1,14 +1,13 @@ -import { PDFEmbeddedFont } from '@ralfstx/pdf-core'; +import { PDFEmbeddedFont, type PDFFont } from '@ralfstx/pdf-core'; import type { FontConfig } from './api/PdfMaker.ts'; import type { FontWeight } from './api/text.ts'; -import type { Font, FontDef, FontSelector } from './fonts.ts'; +import type { FontDef, FontSelector } from './fonts.ts'; import { weightToNumber } from './fonts.ts'; -import { pickDefined } from './types.ts'; export class FontStore { readonly #fontDefs: FontDef[]; - #fontCache: Record> = {}; + #fontCache: Record> = {}; constructor() { this.#fontDefs = []; @@ -23,14 +22,14 @@ export class FontStore { this.#fontCache = {}; // Invalidate cache } - async selectFont(selector: FontSelector): Promise { + async selectFont(selector: FontSelector): Promise { const cacheKey = [ selector.fontFamily ?? 'any', selector.fontStyle ?? 'normal', selector.fontWeight ?? 'normal', ].join(':'); try { - return await (this.#fontCache[cacheKey] ??= this._loadFont(selector, cacheKey)); + return await (this.#fontCache[cacheKey] ??= this._loadFont(selector)); } catch (error) { const { fontFamily: family, fontStyle: style, fontWeight: weight } = selector; const selectorStr = `'${family}', style=${style ?? 'normal'}, weight=${weight ?? 'normal'}`; @@ -38,18 +37,9 @@ export class FontStore { } } - _loadFont(selector: FontSelector, key: string): Promise { + _loadFont(selector: FontSelector): Promise { const selectedFontDef = selectFontDef(this.#fontDefs, selector); - const pdfFont = selectedFontDef.pdfFont ?? new PDFEmbeddedFont(selectedFontDef.data); - return Promise.resolve( - pickDefined({ - key, - name: pdfFont.fontName ?? selectedFontDef.family, - style: selector.fontStyle ?? 'normal', - weight: weightToNumber(selector.fontWeight ?? 400), - pdfFont, - }), - ); + return Promise.resolve(selectedFontDef.pdfFont ?? new PDFEmbeddedFont(selectedFontDef.data)); } } diff --git a/src/fonts.ts b/src/fonts.ts index aafdef9..1b50503 100644 --- a/src/fonts.ts +++ b/src/fonts.ts @@ -1,4 +1,4 @@ -import type { PDFEmbeddedFont, PDFFont } from '@ralfstx/pdf-core'; +import type { PDFEmbeddedFont } from '@ralfstx/pdf-core'; import type { FontStyle, FontWeight } from './api/text.ts'; import { printValue } from './print-value.ts'; @@ -14,14 +14,6 @@ export type FontDef = { pdfFont?: PDFEmbeddedFont; }; -export type Font = { - key: string; - name: string; - style: FontStyle; - weight: number; - pdfFont: PDFFont; -}; - export type FontSelector = { fontFamily?: string; fontStyle?: FontStyle; diff --git a/src/frame.ts b/src/frame.ts index 57c6408..ae4cfb9 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -1,6 +1,5 @@ -import type { PDFImage } from '@ralfstx/pdf-core'; +import type { PDFFont, PDFImage } from '@ralfstx/pdf-core'; -import type { Font } from './fonts.ts'; import type { Color } from './read-color.ts'; import type { PathCommand } from './svg-paths.ts'; @@ -37,7 +36,7 @@ export type TextRowObject = { export type TextSegmentObject = { text: string; - font: Font; + font: PDFFont; fontSize: number; color?: Color; rise?: number; diff --git a/src/layout/layout-text.test.ts b/src/layout/layout-text.test.ts index 18a07bb..cf35f31 100644 --- a/src/layout/layout-text.test.ts +++ b/src/layout/layout-text.test.ts @@ -1,15 +1,16 @@ +import type { PDFFont } from '@ralfstx/pdf-core'; import { beforeEach, describe, expect, it } from 'vitest'; import type { Box } from '../box.ts'; import { rgb } from '../colors.ts'; import { FontStore } from '../font-store.ts'; -import type { Font, FontSelector } from '../fonts.ts'; +import type { FontSelector } from '../fonts.ts'; import type { MakerCtx } from '../maker-ctx.ts'; import { extractTextRows, fakeFont, range, span } from '../test/test-utils.ts'; import { layoutTextContent } from './layout-text.ts'; describe('layout-text', () => { - let defaultFont: Font; + let defaultFont: PDFFont; let box: Box; let ctx: MakerCtx; diff --git a/src/layout/layout-text.ts b/src/layout/layout-text.ts index 310e2b5..0c21e15 100644 --- a/src/layout/layout-text.ts +++ b/src/layout/layout-text.ts @@ -1,5 +1,6 @@ +import type { PDFFont } from '@ralfstx/pdf-core'; + import type { Box, Size } from '../box.ts'; -import type { Font } from '../fonts.ts'; import type { LinkObject, RenderObject, TextRowObject, TextSegmentObject } from '../frame.ts'; import { createRowGuides } from '../guides.ts'; import type { MakerCtx } from '../maker-ctx.ts'; @@ -136,8 +137,8 @@ function layoutTextRow(segments: TextSegment[], box: Box) { return { row, objects, remainder }; } -function getDescent(font: Font, fontSize: number) { - return Math.abs((font.pdfFont.descent * fontSize) / 1000); +function getDescent(font: PDFFont, fontSize: number) { + return Math.abs((font.descent * fontSize) / 1000); } /** diff --git a/src/render/render-page.test.ts b/src/render/render-page.test.ts index c82cc36..b0d86d7 100644 --- a/src/render/render-page.test.ts +++ b/src/render/render-page.test.ts @@ -1,9 +1,8 @@ -import type { PDFDocument } from '@ralfstx/pdf-core'; +import type { PDFDocument, PDFFont } from '@ralfstx/pdf-core'; import { PDFPage } from '@ralfstx/pdf-core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Size } from '../box.ts'; -import type { Font } from '../fonts.ts'; import type { Frame } from '../frame.ts'; import type { Page } from '../page.ts'; import { fakeFont, getContentStream } from '../test/test-utils.ts'; @@ -79,7 +78,7 @@ describe('render-page', () => { describe('renderFrame', () => { let page: Page; let size: Size; - let font: Font; + let font: PDFFont; beforeEach(() => { size = { width: 500, height: 800 }; diff --git a/src/render/render-text.test.ts b/src/render/render-text.test.ts index c5c2670..3bfed13 100644 --- a/src/render/render-text.test.ts +++ b/src/render/render-text.test.ts @@ -1,8 +1,8 @@ +import type { PDFFont } from '@ralfstx/pdf-core'; import { PDFPage } from '@ralfstx/pdf-core'; import { beforeEach, describe, expect, it } from 'vitest'; import type { Size } from '../box.ts'; -import type { Font } from '../fonts.ts'; import type { TextObject } from '../frame.ts'; import type { Page } from '../page.ts'; import { fakeFont, getContentStream } from '../test/test-utils.ts'; @@ -11,7 +11,7 @@ import { renderText } from './render-text.ts'; describe('render-text', () => { let page: Page; let size: Size; - let font: Font; + let font: PDFFont; beforeEach(() => { size = { width: 500, height: 800 }; diff --git a/src/render/render-text.ts b/src/render/render-text.ts index 50d9496..b4f7217 100644 --- a/src/render/render-text.ts +++ b/src/render/render-text.ts @@ -18,12 +18,10 @@ export function renderText(object: TextObject, page: Page, base: Pos) { object.rows?.forEach((row) => { cs.setTextMatrix(1, 0, 0, 1, x + row.x, y - row.y - row.baseline); row.segments?.forEach((seg) => { - const pdfFont = seg.font.pdfFont; - if (!pdfFont) throw new Error('PDF font not initialized'); - const glyphRun = pdfFont.shapeText(seg.text, { defaultFeatures: false }); + const glyphRun = seg.font.shapeText(seg.text, { defaultFeatures: false }); setTextColorOp(cs, state, seg.color); - setTextFontAndSizeOp(cs, state, pdfFont, seg.fontSize); + setTextFontAndSizeOp(cs, state, seg.font, seg.fontSize); setTextRiseOp(cs, state, seg.rise); setLetterSpacingOp(cs, state, seg.letterSpacing); cs.showPositionedText(glyphRun); diff --git a/src/test/test-utils.ts b/src/test/test-utils.ts index 444257d..b80cda1 100644 --- a/src/test/test-utils.ts +++ b/src/test/test-utils.ts @@ -1,22 +1,15 @@ import type { PDFFont } from '@ralfstx/pdf-core'; import { PDFImage, PDFRef } from '@ralfstx/pdf-core'; -import type { Font } from '../fonts.ts'; +import type { FontWeight } from '../api/text.ts'; import { weightToNumber } from '../fonts.ts'; import type { Frame } from '../frame.ts'; import type { Page } from '../page.ts'; import type { TextAttrs, TextSpan } from '../read-block.ts'; -export function fakeFont(name: string, opts?: Partial>): Font { - const key = `${name}-${opts?.style ?? 'normal'}-${opts?.weight ?? 400}`; - const font: Font = { - key, - name, - style: opts?.style ?? 'normal', - weight: weightToNumber(opts?.weight ?? 'normal'), - pdfFont: fakePdfFont(key), - }; - return font; +export function fakeFont(name: string, opts?: { style?: string; weight?: FontWeight }): PDFFont { + const key = `${name}-${opts?.style ?? 'normal'}-${weightToNumber(opts?.weight ?? 'normal')}`; + return fakePdfFont(key); } export function fakeImage(width: number, height: number): PDFImage { diff --git a/src/text.test.ts b/src/text.test.ts index 3feee9c..9dc31a9 100644 --- a/src/text.test.ts +++ b/src/text.test.ts @@ -1,8 +1,8 @@ +import type { PDFFont } from '@ralfstx/pdf-core'; import { beforeEach, describe, expect, it } from 'vitest'; import { rgb } from './colors.ts'; import { FontStore } from './font-store.ts'; -import type { Font } from './fonts.ts'; import { fakeFont } from './test/test-utils.ts'; import type { TextSegment } from './text.ts'; import { @@ -14,7 +14,7 @@ import { } from './text.ts'; describe('text', () => { - let normalFont: Font; + let normalFont: PDFFont; let fontStore: FontStore; beforeEach(() => { diff --git a/src/text.ts b/src/text.ts index 3a42af3..ac6ae42 100644 --- a/src/text.ts +++ b/src/text.ts @@ -1,7 +1,8 @@ +import type { PDFFont } from '@ralfstx/pdf-core'; + import type { FontStyle, FontWeight } from './api/text.ts'; import { getTextHeight, getTextWidth } from './font-metrics.ts'; import type { FontStore } from './font-store.ts'; -import type { Font } from './fonts.ts'; import type { TextSpan } from './read-block.ts'; import type { Color } from './read-color.ts'; @@ -13,7 +14,7 @@ export type TextSegment = { width: number; height: number; lineHeight: number; - font: Font; + font: PDFFont; fontSize: number; fontFamily: string; fontStyle?: FontStyle; @@ -43,13 +44,13 @@ export async function extractTextSegments( letterSpacing, } = attrs; const font = await fontStore.selectFont({ fontFamily, fontStyle, fontWeight }); - const height = getTextHeight(font.pdfFont, fontSize); + const height = getTextHeight(font, fontSize); return splitChunks(text).map( (text) => ({ text, - width: getTextWidth(text, font.pdfFont, fontSize) + text.length * (letterSpacing ?? 0), + width: getTextWidth(text, font, fontSize) + text.length * (letterSpacing ?? 0), height, lineHeight, font,