Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Bump node version requirement to 20+
- Bump minimum supported browsers to Firefox 115, iOS/Safari 16
- Fix text with input x as null
- Fix corrupted PDF when mixing standard and embedded fonts that share postscript name

### [v0.18.0] - 2026-03-14

Expand Down
13 changes: 12 additions & 1 deletion lib/mixins/fonts.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,21 @@ export default {
this._fontFamilies[cacheKey] = this._font;
}

if (this._font.name) {
if (this._font.name && !this._fontFamilies[this._font.name]) {
this._fontFamilies[this._font.name] = this._font;
}

// if the font wasn't registered under any key (e.g. loaded via raw buffer
// with no cacheKey and a postscript name that collides with an
// already-registered font), register it under its id so it always gets
// finalized and doesn't leave a dangling references
if (
!cacheKey &&
(!this._font.name || this._fontFamilies[this._font.name] !== this._font)
) {
this._fontFamilies[this._font.id] = this._font;
}

return this;
},

Expand Down
80 changes: 79 additions & 1 deletion tests/unit/font.spec.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { vi } from 'vitest';
import { readFileSync } from 'fs';
import PDFDocument from '../../lib/document';
import PDFFontFactory from '../../lib/font_factory';
import { logData } from './helpers';
import { logData, collectPdf, missingObjects } from './helpers';

describe('EmbeddedFont', () => {
test('no fontLayoutCache option', () => {
Expand Down Expand Up @@ -244,3 +245,80 @@ describe('sizeToPoint', () => {
expect(doc.sizeToPoint('1rem')).toEqual(12);
});
});

describe('font name collision', () => {
// fake font with a bogus checksum so isEqualFont always returns false
const makeCachedFont = (name, id) => ({
name,
id,
font: { _tables: { head: { checkSumAdjustment: -1 } } },
ref: vi.fn(),
finalize: vi.fn(),
embedded: false,
});

afterEach(() => vi.restoreAllMocks());

describe('name-alias slot is not overwritten when already occupied', () => {
test('does not overwrite existing font on postscript name collision', () => {
const doc = new PDFDocument({ font: null, compress: false });
const existingFont = makeCachedFont('Roboto-Regular', 'F0');
doc._fontFamilies['Roboto-Regular'] = existingFont;

doc.registerFont('Roboto', 'tests/fonts/Roboto-Regular.ttf');
doc.font('Roboto');

expect(doc._fontFamilies['Roboto-Regular']).toBe(existingFont);
expect(doc._fontFamilies['Roboto']).toBeDefined();
expect(doc._fontFamilies['Roboto']).not.toBe(existingFont);
});
});

describe('buffer-loaded font with name collision is registered under its id', () => {
test('registers under id when postscript name slot is taken', () => {
const doc = new PDFDocument({ font: null, compress: false });
const existingFont = makeCachedFont('Roboto-Regular', 'F0');
doc._fontFamilies['Roboto-Regular'] = existingFont;

const buf = readFileSync('tests/fonts/Roboto-Regular.ttf');
doc.font(buf);

const loaded = doc._font;
expect(doc._fontFamilies[loaded.id]).toBe(loaded);
expect(doc._fontFamilies['Roboto-Regular']).toBe(existingFont);
});
});

describe('document completes without dangling references', () => {
test('standard + registered embedded font', () => {
const doc = new PDFDocument({ compress: false });

doc.text('standard helvetica');
doc.registerFont('Roboto', 'tests/fonts/Roboto-Regular.ttf');
doc.font('Roboto').text('embedded roboto');

const pdf = collectPdf(doc);

expect(pdf).toContain('startxref');
expect(pdf).toContain('%%EOF');
expect(missingObjects(pdf)).toHaveLength(0);
});

test('buffer font with name collision', () => {
const doc = new PDFDocument({ compress: false });
doc._fontFamilies['Roboto-Regular'] = makeCachedFont(
'Roboto-Regular',
'F0',
);

const buf = readFileSync('tests/fonts/Roboto-Regular.ttf');
doc.font(buf).text('buffer roboto');

const pdf = collectPdf(doc);

expect(pdf).toContain('startxref');
expect(pdf).toContain('%%EOF');
expect(missingObjects(pdf)).toHaveLength(0);
});
});
});
32 changes: 31 additions & 1 deletion tests/unit/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,34 @@ function parseTextStreams(decodedStream) {
return results;
}

export { logData, joinTokens, parseTextStreams, getObjects };
/**
* Collect all PDF output from a document into a single binary string.
* @param {PDFDocument} doc
* @return {string}
*/
function collectPdf(doc) {
const data = logData(doc);
doc.end();
return data.map((d) => d.toString('binary')).join('');
}

/**
* Return object ids that are referenced but never defined in a PDF string.
* An empty result means no dangling references.
* @param {string} pdf
* @return {string[]}
*/
function missingObjects(pdf) {
const defined = [...pdf.matchAll(/(\d+) 0 obj/g)].map((m) => m[1]);
const refs = [...new Set([...pdf.matchAll(/(\d+) 0 R/g)].map((m) => m[1]))];
return refs.filter((r) => !defined.includes(r));
}

export {
logData,
joinTokens,
parseTextStreams,
getObjects,
collectPdf,
missingObjects,
};
Loading