From 2d4ad751b46a435715e2f38cb1edb0253940055b Mon Sep 17 00:00:00 2001 From: Ralf Sternberg Date: Sun, 19 Jan 2025 13:39:47 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20error=20when=20reusing=20f?= =?UTF-8?q?onts=20or=20images?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal `Font` and `Image` objects are kept in global stores in the PdfMaker context. During the generation of a document, these objects got PDFRefs attached that outlived the document and caused errors during the generation of subsequent documents. The solution is to attach these references on the PDFDocument instead. --- src/api/PdfMaker.test.ts | 26 ++++++++++++++++++++++++-- src/font-store.test.ts | 1 + src/font-store.ts | 5 +++-- src/fonts.ts | 15 +++++++++++++-- src/images.ts | 6 ++++-- src/page.ts | 22 ++++++++-------------- src/render/render-image.test.ts | 6 +++--- src/render/render-text.ts | 7 +++---- src/test/test-utils.ts | 9 ++++++--- 9 files changed, 65 insertions(+), 32 deletions(-) diff --git a/src/api/PdfMaker.test.ts b/src/api/PdfMaker.test.ts index bfe3bc6..48fa24e 100644 --- a/src/api/PdfMaker.test.ts +++ b/src/api/PdfMaker.test.ts @@ -1,14 +1,22 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { before } from 'node:test'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { image, text } from './layout.ts'; import { PdfMaker } from './PdfMaker.ts'; describe('makePdf', () => { let pdfMaker: PdfMaker; - before(() => { + before(async () => { pdfMaker = new PdfMaker(); + pdfMaker.setResourceRoot(join(__dirname, '../test/resources')); + const fontData = await readFile( + join(__dirname, '../test/resources/fonts/roboto/Roboto-Regular.ttf'), + ); + pdfMaker.registerFont(fontData); }); it('creates data that starts with a PDF 1.7 header', async () => { @@ -31,4 +39,18 @@ describe('makePdf', () => { const string = Buffer.from(pdf.buffer).toString(); expect(string).toMatch(/\/ID \[ <[0-9A-F]{64}> <[0-9A-F]{64}> \]/); }); + + it('creates consistent results across runs', async () => { + // ensure same timestamps in generated PDF + vi.useFakeTimers(); + // include fonts and images to ensure they can be reused + const content = [text('Test'), image('file:/torus.png')]; + + const pdf1 = await pdfMaker.makePdf({ content }); + const pdf2 = await pdfMaker.makePdf({ content }); + + const pdfStr1 = Buffer.from(pdf1.buffer).toString(); + const pdfStr2 = Buffer.from(pdf2.buffer).toString(); + expect(pdfStr1).toEqual(pdfStr2); + }); }); diff --git a/src/font-store.test.ts b/src/font-store.test.ts index 829acb9..3b37db3 100644 --- a/src/font-store.test.ts +++ b/src/font-store.test.ts @@ -183,6 +183,7 @@ describe('FontStore', () => { const font = await store.selectFont({ fontFamily: 'Test' }); expect(font).toEqual({ + key: 'Test:normal:normal', name: 'Test', style: 'normal', weight: 400, diff --git a/src/font-store.ts b/src/font-store.ts index e94d828..e1d00c0 100644 --- a/src/font-store.ts +++ b/src/font-store.ts @@ -31,7 +31,7 @@ export class FontStore { selector.fontWeight ?? 'normal', ].join(':'); try { - return await (this.#fontCache[cacheKey] ??= this._loadFont(selector)); + return await (this.#fontCache[cacheKey] ??= this._loadFont(selector, cacheKey)); } catch (error) { const { fontFamily: family, fontStyle: style, fontWeight: weight } = selector; const selectorStr = `'${family}', style=${style ?? 'normal'}, weight=${weight ?? 'normal'}`; @@ -39,12 +39,13 @@ export class FontStore { } } - _loadFont(selector: FontSelector): Promise { + _loadFont(selector: FontSelector, key: string): Promise { const selectedFont = selectFont(this.#fontDefs, selector); const data = parseBinaryData(selectedFont.data); const fkFont = selectedFont.fkFont ?? fontkit.create(data); return Promise.resolve( pickDefined({ + key, name: fkFont.fullName ?? fkFont.postscriptName ?? selectedFont.family, data, style: selector.fontStyle ?? 'normal', diff --git a/src/fonts.ts b/src/fonts.ts index 0c64543..7ba2022 100644 --- a/src/fonts.ts +++ b/src/fonts.ts @@ -19,12 +19,12 @@ export type FontDef = { }; export type Font = { + key: string; name: string; style: FontStyle; weight: number; data: Uint8Array; fkFont: fontkit.Font; - pdfRef?: PDFRef; }; export type FontSelector = { @@ -54,14 +54,25 @@ export function readFont(input: unknown): Partial { } as FontDef; } -export function registerFont(font: Font, pdfDoc: PDFDocument) { +export function registerFont(font: Font, pdfDoc: PDFDocument): PDFRef { + const registeredFonts = ((pdfDoc as any)._pdfmkr_registeredFonts ??= {}); + if (font.key in registeredFonts) return registeredFonts[font.key]; const ref = pdfDoc.context.nextRef(); const embedder = new (CustomFontSubsetEmbedder as any)(font.fkFont, font.data); const pdfFont = PDFFont.of(ref, pdfDoc, embedder); (pdfDoc as any).fonts.push(pdfFont); + registeredFonts[font.key] = ref; return ref; } +export function findRegisteredFont(font: Font, pdfDoc: PDFDocument): PDFFont | undefined { + const registeredFonts = ((pdfDoc as any)._pdfmkr_registeredFonts ??= {}); + const ref = registeredFonts[font.key]; + if (ref) { + return (pdfDoc as any).fonts?.find((font: PDFFont) => font.ref === ref); + } +} + export function weightToNumber(weight: FontWeight): number { if (weight === 'normal') { return 400; diff --git a/src/images.ts b/src/images.ts index 1b906dd..f395020 100644 --- a/src/images.ts +++ b/src/images.ts @@ -19,7 +19,6 @@ export type Image = { height: number; data: Uint8Array; format: ImageFormat; - pdfRef?: PDFRef; }; export function readImages(input: unknown): ImageDef[] { @@ -36,7 +35,9 @@ function readImage(input: unknown) { }) as { data: Uint8Array; format?: ImageFormat }; } -export function registerImage(image: Image, pdfDoc: PDFDocument) { +export function registerImage(image: Image, pdfDoc: PDFDocument): PDFRef { + const registeredImages = ((pdfDoc as any)._pdfmkr_registeredImages ??= {}); + if (image.url in registeredImages) return registeredImages[image.url]; const ref = pdfDoc.context.nextRef(); (pdfDoc as any).images.push({ async embed() { @@ -50,5 +51,6 @@ export function registerImage(image: Image, pdfDoc: PDFDocument) { } }, }); + registeredImages[image.url] = ref; return ref; } diff --git a/src/page.ts b/src/page.ts index 110a9b9..a65b414 100644 --- a/src/page.ts +++ b/src/page.ts @@ -29,28 +29,22 @@ export type Page = { export function addPageFont(page: Page, font: Font): PDFName { if (!page.pdfPage) throw new Error('Page not initialized'); - if (!font.pdfRef) { - font.pdfRef = registerFont(font, page.pdfPage.doc); - } page.fonts ??= {}; - const key = font.pdfRef.toString(); - if (!(key in page.fonts)) { - page.fonts[key] = (page.pdfPage as any).node.newFontDictionary(font.name, font.pdfRef); + if (!(font.key in page.fonts)) { + const pdfRef = registerFont(font, page.pdfPage.doc); + page.fonts[font.key] = (page.pdfPage as any).node.newFontDictionary(font.name, pdfRef); } - return page.fonts[key]; + return page.fonts[font.key]; } export function addPageImage(page: Page, image: Image): PDFName { if (!page.pdfPage) throw new Error('Page not initialized'); - if (!image.pdfRef) { - image.pdfRef = registerImage(image, page.pdfPage.doc); - } page.images ??= {}; - const key = image.pdfRef.toString(); - if (!(key in page.images)) { - page.images[key] = (page.pdfPage as any).node.newXObject('Image', image.pdfRef); + if (!(image.url in page.images)) { + const pdfRef = registerImage(image, page.pdfPage.doc); + page.images[image.url] = (page.pdfPage as any).node.newXObject('Image', pdfRef); } - return page.images[key]; + return page.images[image.url]; } type ExtGraphicsParams = { ca: number; CA: number }; diff --git a/src/render/render-image.test.ts b/src/render/render-image.test.ts index a7bd63b..aa31a99 100644 --- a/src/render/render-image.test.ts +++ b/src/render/render-image.test.ts @@ -17,10 +17,10 @@ describe('renderImage', () => { size = { width: 500, height: 800 }; const pdfPage = fakePDFPage(); page = { size, pdfPage } as Page; - image = { pdfRef: 23 } as unknown as Image; + image = { url: 'test-url' } as unknown as Image; }); - it('renders single text object', () => { + it('renders single image object', () => { const obj: ImageObject = { type: 'image', image, x: 1, y: 2, width: 30, height: 40 }; renderImage(obj, page, pos); @@ -29,7 +29,7 @@ describe('renderImage', () => { 'q', '1 0 0 1 11 738 cm', '30 0 0 40 0 0 cm', - '/Image-23-1 Do', + '/Image-1-0-1 Do', 'Q', ]); }); diff --git a/src/render/render-text.ts b/src/render/render-text.ts index bd9cb7b..0f7aa7d 100644 --- a/src/render/render-text.ts +++ b/src/render/render-text.ts @@ -1,4 +1,4 @@ -import type { Color, PDFContentStream, PDFFont, PDFName, PDFOperator } from 'pdf-lib'; +import type { Color, PDFContentStream, PDFName, PDFOperator } from 'pdf-lib'; import { beginText, endText, @@ -10,6 +10,7 @@ import { setTextRise, showText, } from 'pdf-lib'; +import { findRegisteredFont } from 'src/fonts.ts'; import type { Pos } from '../box.ts'; import type { TextObject } from '../frame.ts'; @@ -27,9 +28,7 @@ export function renderText(object: TextObject, page: Page, base: Pos) { contentStream.push(setTextMatrix(1, 0, 0, 1, x + row.x, y - row.y - row.baseline)); row.segments?.forEach((seg) => { const fontKey = addPageFont(page, seg.font); - const pdfFont = (page.pdfPage as any)?.doc?.fonts?.find( - (font: PDFFont) => font.ref === seg.font.pdfRef, - ); + const pdfFont = findRegisteredFont(seg.font, page.pdfPage!.doc)!; const encodedText = pdfFont.encodeText(seg.text); const operators = compact([ setTextColorOp(state, seg.color), diff --git a/src/test/test-utils.ts b/src/test/test-utils.ts index 456e540..a7f62ca 100644 --- a/src/test/test-utils.ts +++ b/src/test/test-utils.ts @@ -14,6 +14,7 @@ export function fakeFont( ): Font { const key = `${name}-${opts?.style ?? 'normal'}-${opts?.weight ?? 400}`; const font: Font = { + key, name, style: opts?.style ?? 'normal', weight: weightToNumber(opts?.weight ?? 'normal'), @@ -23,7 +24,8 @@ export function fakeFont( if (opts.doc) { const pdfFont = fakePdfFont(name, font.fkFont); (opts.doc as any).fonts.push(pdfFont); - font.pdfRef = pdfFont.ref; + (opts.doc as any)._pdfmkr_registeredFonts ??= {}; + (opts.doc as any)._pdfmkr_registeredFonts[font.key] = pdfFont.ref; } return font; } @@ -79,8 +81,9 @@ export function fakePDFPage(document?: PDFDocument): PDFPage { const contentStream: any[] = []; let counter = 1; (node as any).newFontDictionary = (name: string) => PDFName.of(`${name}-${counter++}`); - (node as any).newXObject = (type: string, ref: string) => - PDFName.of(`${type}-${ref}-${counter++}`); + let xObjectCounter = 1; + (node as any).newXObject = (tag: string, ref: PDFRef) => + PDFName.of(`${tag}-${ref.objectNumber}-${ref.generationNumber}-${xObjectCounter++}`); (node as any).newExtGState = (type: string) => PDFName.of(`${type}-${counter++}`); return { doc,