Skip to content

Commit 2d4ad75

Browse files
committed
🐛 Fix error when reusing fonts or images
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.
1 parent eec2a03 commit 2d4ad75

9 files changed

+65
-32
lines changed

Diff for: src/api/PdfMaker.test.ts

+24-2
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1+
import { readFile } from 'node:fs/promises';
2+
import { join } from 'node:path';
13
import { before } from 'node:test';
24

3-
import { describe, expect, it } from 'vitest';
5+
import { describe, expect, it, vi } from 'vitest';
46

7+
import { image, text } from './layout.ts';
58
import { PdfMaker } from './PdfMaker.ts';
69

710
describe('makePdf', () => {
811
let pdfMaker: PdfMaker;
912

10-
before(() => {
13+
before(async () => {
1114
pdfMaker = new PdfMaker();
15+
pdfMaker.setResourceRoot(join(__dirname, '../test/resources'));
16+
const fontData = await readFile(
17+
join(__dirname, '../test/resources/fonts/roboto/Roboto-Regular.ttf'),
18+
);
19+
pdfMaker.registerFont(fontData);
1220
});
1321

1422
it('creates data that starts with a PDF 1.7 header', async () => {
@@ -31,4 +39,18 @@ describe('makePdf', () => {
3139
const string = Buffer.from(pdf.buffer).toString();
3240
expect(string).toMatch(/\/ID \[ <[0-9A-F]{64}> <[0-9A-F]{64}> \]/);
3341
});
42+
43+
it('creates consistent results across runs', async () => {
44+
// ensure same timestamps in generated PDF
45+
vi.useFakeTimers();
46+
// include fonts and images to ensure they can be reused
47+
const content = [text('Test'), image('file:/torus.png')];
48+
49+
const pdf1 = await pdfMaker.makePdf({ content });
50+
const pdf2 = await pdfMaker.makePdf({ content });
51+
52+
const pdfStr1 = Buffer.from(pdf1.buffer).toString();
53+
const pdfStr2 = Buffer.from(pdf2.buffer).toString();
54+
expect(pdfStr1).toEqual(pdfStr2);
55+
});
3456
});

Diff for: src/font-store.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ describe('FontStore', () => {
183183
const font = await store.selectFont({ fontFamily: 'Test' });
184184

185185
expect(font).toEqual({
186+
key: 'Test:normal:normal',
186187
name: 'Test',
187188
style: 'normal',
188189
weight: 400,

Diff for: src/font-store.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,21 @@ export class FontStore {
3131
selector.fontWeight ?? 'normal',
3232
].join(':');
3333
try {
34-
return await (this.#fontCache[cacheKey] ??= this._loadFont(selector));
34+
return await (this.#fontCache[cacheKey] ??= this._loadFont(selector, cacheKey));
3535
} catch (error) {
3636
const { fontFamily: family, fontStyle: style, fontWeight: weight } = selector;
3737
const selectorStr = `'${family}', style=${style ?? 'normal'}, weight=${weight ?? 'normal'}`;
3838
throw new Error(`Could not load font for ${selectorStr}`, { cause: error });
3939
}
4040
}
4141

42-
_loadFont(selector: FontSelector): Promise<Font> {
42+
_loadFont(selector: FontSelector, key: string): Promise<Font> {
4343
const selectedFont = selectFont(this.#fontDefs, selector);
4444
const data = parseBinaryData(selectedFont.data);
4545
const fkFont = selectedFont.fkFont ?? fontkit.create(data);
4646
return Promise.resolve(
4747
pickDefined({
48+
key,
4849
name: fkFont.fullName ?? fkFont.postscriptName ?? selectedFont.family,
4950
data,
5051
style: selector.fontStyle ?? 'normal',

Diff for: src/fonts.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ export type FontDef = {
1919
};
2020

2121
export type Font = {
22+
key: string;
2223
name: string;
2324
style: FontStyle;
2425
weight: number;
2526
data: Uint8Array;
2627
fkFont: fontkit.Font;
27-
pdfRef?: PDFRef;
2828
};
2929

3030
export type FontSelector = {
@@ -54,14 +54,25 @@ export function readFont(input: unknown): Partial<FontDef> {
5454
} as FontDef;
5555
}
5656

57-
export function registerFont(font: Font, pdfDoc: PDFDocument) {
57+
export function registerFont(font: Font, pdfDoc: PDFDocument): PDFRef {
58+
const registeredFonts = ((pdfDoc as any)._pdfmkr_registeredFonts ??= {});
59+
if (font.key in registeredFonts) return registeredFonts[font.key];
5860
const ref = pdfDoc.context.nextRef();
5961
const embedder = new (CustomFontSubsetEmbedder as any)(font.fkFont, font.data);
6062
const pdfFont = PDFFont.of(ref, pdfDoc, embedder);
6163
(pdfDoc as any).fonts.push(pdfFont);
64+
registeredFonts[font.key] = ref;
6265
return ref;
6366
}
6467

68+
export function findRegisteredFont(font: Font, pdfDoc: PDFDocument): PDFFont | undefined {
69+
const registeredFonts = ((pdfDoc as any)._pdfmkr_registeredFonts ??= {});
70+
const ref = registeredFonts[font.key];
71+
if (ref) {
72+
return (pdfDoc as any).fonts?.find((font: PDFFont) => font.ref === ref);
73+
}
74+
}
75+
6576
export function weightToNumber(weight: FontWeight): number {
6677
if (weight === 'normal') {
6778
return 400;

Diff for: src/images.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ export type Image = {
1919
height: number;
2020
data: Uint8Array;
2121
format: ImageFormat;
22-
pdfRef?: PDFRef;
2322
};
2423

2524
export function readImages(input: unknown): ImageDef[] {
@@ -36,7 +35,9 @@ function readImage(input: unknown) {
3635
}) as { data: Uint8Array; format?: ImageFormat };
3736
}
3837

39-
export function registerImage(image: Image, pdfDoc: PDFDocument) {
38+
export function registerImage(image: Image, pdfDoc: PDFDocument): PDFRef {
39+
const registeredImages = ((pdfDoc as any)._pdfmkr_registeredImages ??= {});
40+
if (image.url in registeredImages) return registeredImages[image.url];
4041
const ref = pdfDoc.context.nextRef();
4142
(pdfDoc as any).images.push({
4243
async embed() {
@@ -50,5 +51,6 @@ export function registerImage(image: Image, pdfDoc: PDFDocument) {
5051
}
5152
},
5253
});
54+
registeredImages[image.url] = ref;
5355
return ref;
5456
}

Diff for: src/page.ts

+8-14
Original file line numberDiff line numberDiff line change
@@ -29,28 +29,22 @@ export type Page = {
2929

3030
export function addPageFont(page: Page, font: Font): PDFName {
3131
if (!page.pdfPage) throw new Error('Page not initialized');
32-
if (!font.pdfRef) {
33-
font.pdfRef = registerFont(font, page.pdfPage.doc);
34-
}
3532
page.fonts ??= {};
36-
const key = font.pdfRef.toString();
37-
if (!(key in page.fonts)) {
38-
page.fonts[key] = (page.pdfPage as any).node.newFontDictionary(font.name, font.pdfRef);
33+
if (!(font.key in page.fonts)) {
34+
const pdfRef = registerFont(font, page.pdfPage.doc);
35+
page.fonts[font.key] = (page.pdfPage as any).node.newFontDictionary(font.name, pdfRef);
3936
}
40-
return page.fonts[key];
37+
return page.fonts[font.key];
4138
}
4239

4340
export function addPageImage(page: Page, image: Image): PDFName {
4441
if (!page.pdfPage) throw new Error('Page not initialized');
45-
if (!image.pdfRef) {
46-
image.pdfRef = registerImage(image, page.pdfPage.doc);
47-
}
4842
page.images ??= {};
49-
const key = image.pdfRef.toString();
50-
if (!(key in page.images)) {
51-
page.images[key] = (page.pdfPage as any).node.newXObject('Image', image.pdfRef);
43+
if (!(image.url in page.images)) {
44+
const pdfRef = registerImage(image, page.pdfPage.doc);
45+
page.images[image.url] = (page.pdfPage as any).node.newXObject('Image', pdfRef);
5246
}
53-
return page.images[key];
47+
return page.images[image.url];
5448
}
5549

5650
type ExtGraphicsParams = { ca: number; CA: number };

Diff for: src/render/render-image.test.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ describe('renderImage', () => {
1717
size = { width: 500, height: 800 };
1818
const pdfPage = fakePDFPage();
1919
page = { size, pdfPage } as Page;
20-
image = { pdfRef: 23 } as unknown as Image;
20+
image = { url: 'test-url' } as unknown as Image;
2121
});
2222

23-
it('renders single text object', () => {
23+
it('renders single image object', () => {
2424
const obj: ImageObject = { type: 'image', image, x: 1, y: 2, width: 30, height: 40 };
2525

2626
renderImage(obj, page, pos);
@@ -29,7 +29,7 @@ describe('renderImage', () => {
2929
'q',
3030
'1 0 0 1 11 738 cm',
3131
'30 0 0 40 0 0 cm',
32-
'/Image-23-1 Do',
32+
'/Image-1-0-1 Do',
3333
'Q',
3434
]);
3535
});

Diff for: src/render/render-text.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Color, PDFContentStream, PDFFont, PDFName, PDFOperator } from 'pdf-lib';
1+
import type { Color, PDFContentStream, PDFName, PDFOperator } from 'pdf-lib';
22
import {
33
beginText,
44
endText,
@@ -10,6 +10,7 @@ import {
1010
setTextRise,
1111
showText,
1212
} from 'pdf-lib';
13+
import { findRegisteredFont } from 'src/fonts.ts';
1314

1415
import type { Pos } from '../box.ts';
1516
import type { TextObject } from '../frame.ts';
@@ -27,9 +28,7 @@ export function renderText(object: TextObject, page: Page, base: Pos) {
2728
contentStream.push(setTextMatrix(1, 0, 0, 1, x + row.x, y - row.y - row.baseline));
2829
row.segments?.forEach((seg) => {
2930
const fontKey = addPageFont(page, seg.font);
30-
const pdfFont = (page.pdfPage as any)?.doc?.fonts?.find(
31-
(font: PDFFont) => font.ref === seg.font.pdfRef,
32-
);
31+
const pdfFont = findRegisteredFont(seg.font, page.pdfPage!.doc)!;
3332
const encodedText = pdfFont.encodeText(seg.text);
3433
const operators = compact([
3534
setTextColorOp(state, seg.color),

Diff for: src/test/test-utils.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export function fakeFont(
1414
): Font {
1515
const key = `${name}-${opts?.style ?? 'normal'}-${opts?.weight ?? 400}`;
1616
const font: Font = {
17+
key,
1718
name,
1819
style: opts?.style ?? 'normal',
1920
weight: weightToNumber(opts?.weight ?? 'normal'),
@@ -23,7 +24,8 @@ export function fakeFont(
2324
if (opts.doc) {
2425
const pdfFont = fakePdfFont(name, font.fkFont);
2526
(opts.doc as any).fonts.push(pdfFont);
26-
font.pdfRef = pdfFont.ref;
27+
(opts.doc as any)._pdfmkr_registeredFonts ??= {};
28+
(opts.doc as any)._pdfmkr_registeredFonts[font.key] = pdfFont.ref;
2729
}
2830
return font;
2931
}
@@ -79,8 +81,9 @@ export function fakePDFPage(document?: PDFDocument): PDFPage {
7981
const contentStream: any[] = [];
8082
let counter = 1;
8183
(node as any).newFontDictionary = (name: string) => PDFName.of(`${name}-${counter++}`);
82-
(node as any).newXObject = (type: string, ref: string) =>
83-
PDFName.of(`${type}-${ref}-${counter++}`);
84+
let xObjectCounter = 1;
85+
(node as any).newXObject = (tag: string, ref: PDFRef) =>
86+
PDFName.of(`${tag}-${ref.objectNumber}-${ref.generationNumber}-${xObjectCounter++}`);
8487
(node as any).newExtGState = (type: string) => PDFName.of(`${type}-${counter++}`);
8588
return {
8689
doc,

0 commit comments

Comments
 (0)