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 @@
- [BREAKING CHANGE] Restrict AcroForm options to documented mappings and explicit escape hatches.
- [BREAKING CHANGE] Stop automatically uppercasing annotation option keys.
- Do not mutate options passed to `doc.annotate()` and its convenience methods (link, note, strike, lineAnnotation, rectAnnotation, ellipseAnnotation, textAnnotation, fileAnnotation)
- Fix Table style merging crashes when passing fonts as buffer (#1743)

### [v0.19.1] - 2026-06-10

Expand Down
7 changes: 6 additions & 1 deletion lib/table/normalize.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ export function normalizeCell(cell, rowIndex, colIndex) {
const colStyle = this._colStyle(colIndex);
let rowStyle = this._rowStyle(rowIndex);

const font = deepMerge({}, colStyle.font, rowStyle.font, cell.font);
const font = {
...((cell.font?.src && cell.font) ||
(rowStyle.font?.src && rowStyle.font) ||
(colStyle.font?.src && colStyle.font)),
size: cell.font?.size || rowStyle.font?.size || colStyle.font?.size,
};
const customFont = Object.values(font).filter((v) => v != null).length > 0;
const doc = this.document;

Expand Down
22 changes: 20 additions & 2 deletions lib/table/style.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,17 @@ export function normalizedRowStyle(defaultRowStyle, rowStyleInternal, i) {
rowStyle.borderColor = normalizeSides(rowStyle.borderColor);
rowStyle.align = normalizeAlignment(rowStyle.align);

// extract fonts
const { font: defaultFont, ...restDefaultStyle } = defaultRowStyle || {};
const { font: font, ...restStyle } = rowStyle || {};
const mergedFont = {
...((font?.src && font) || (defaultFont?.src && defaultFont)),
size: font?.size || defaultFont?.size,
};

// Merge defaults
rowStyle = deepMerge(defaultRowStyle, rowStyle);
rowStyle = deepMerge(restDefaultStyle, restStyle);
rowStyle.font = mergedFont;

const document = this.document;
const page = document.page;
Expand Down Expand Up @@ -114,8 +123,17 @@ export function normalizedColumnStyle(defaultColStyle, colStyleInternal, i) {
colStyle.borderColor = normalizeSides(colStyle.borderColor);
colStyle.align = normalizeAlignment(colStyle.align);

// extract fonts
const { font: defaultFont, ...restDefaultStyle } = defaultColStyle || {};
const { font: font, ...restStyle } = colStyle || {};
const mergedFont = {
...((font?.src && font) || (defaultFont?.src && defaultFont)),
size: font?.size || defaultFont?.size,
};
Comment on lines +126 to +132

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that deepMerge was modified to handle binary data, why not use it?

@ykw9263 ykw9263 Jun 28, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just found a bug where merging fonts with font collections would cause problem

  doc.table({
    rowStyles: [{
        font: { src: SOME_TTC_FONT, family: FONT_FAMILY },
    }]
  })
  .row([
    { text: 'Hello World', font: { src: SOME_TTF_FONT } }
  ]);
// throws `Variations require a font with the fvar, gvar and glyf, or CFF2 tables`

I think font src should be binded with its family and not be merged with other family names across styles


// Merge defaults
colStyle = deepMerge(defaultColStyle, colStyle);
colStyle = deepMerge(restDefaultStyle, restStyle);
colStyle.font = mergedFont;

if (colStyle.width == null || colStyle.width === '*') {
colStyle.width = '*';
Expand Down
15 changes: 13 additions & 2 deletions lib/table/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,15 @@ function isObject(item) {
return item && typeof item === 'object' && !Array.isArray(item);
}

/**
* buffer check.
* @param item
* @returns {boolean}
*/
function isBinaryData(item) {
return item instanceof Uint8Array || item instanceof ArrayBuffer;
}

/**
* Deep merge two objects.
*
Expand All @@ -345,7 +354,7 @@ export function deepMerge(target, ...sources) {
for (const source of sources) {
if (isObject(source)) {
for (const key in source) {
if (isObject(source[key])) {
if (isObject(source[key]) && !isBinaryData(source[key])) {
if (!(key in target)) target[key] = {};
target[key] = deepMerge(target[key], source[key]);
} else if (source[key] !== undefined) {
Expand All @@ -360,7 +369,9 @@ export function deepMerge(target, ...sources) {

function deepClone(obj) {
let result = obj;
if (obj && typeof obj == 'object') {
if (isBinaryData(obj)) {
result = obj;
} else if (obj && typeof obj == 'object') {
result = Array.isArray(obj) ? [] : {};
for (const key in obj) result[key] = deepClone(obj[key]);
}
Expand Down
196 changes: 196 additions & 0 deletions tests/unit/table.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { vi } from 'vitest';
import PDFDocument from '../../lib/document';
import PDFTable from '../../lib/table';
import { deepMerge } from '../../lib/table/utils';
import fs from 'fs';

describe('table', () => {
test('created', () => {
Expand All @@ -14,6 +16,190 @@ describe('table', () => {
table.row(['A', 'B', 'C']);
expect(table._columnWidths.length).toBe(3);
});

describe('font', () => {
test('column font', () => {
const standardFont = 'Courier';
const fontPath = 'tests/fonts/Roboto-Regular.ttf';
const fontBuffer = fs.readFileSync('tests/fonts/Roboto-Regular.ttf');
const document = new PDFDocument();
const fontSpy = vi.spyOn(document, 'font');

const table = document.table({
columnStyles: [
{ font: { src: standardFont } },
{ font: { src: fontPath } },
{ font: { src: fontBuffer } },
],
});
table.row(['A', 'B', 'C']);
expect(fontSpy).toHaveBeenCalledWith(
standardFont,
expect.toSatisfy(() => true),
);
expect(fontSpy).toHaveBeenCalledWith(
fontPath,
expect.toSatisfy(() => true),
);
expect(fontSpy).toHaveBeenCalledWith(
fontBuffer,
expect.toSatisfy(() => true),
);
});

test('row font', () => {
const standardFont = 'Courier';
const fontPath = 'tests/fonts/Roboto-Regular.ttf';
const fontBuffer = fs.readFileSync('tests/fonts/Roboto-Regular.ttf');
const document = new PDFDocument();
const fontSpy = vi.spyOn(document, 'font');

const table = document.table({
rowStyles: [
{ font: { src: standardFont } },
{ font: { src: fontPath } },
{ font: { src: fontBuffer } },
],
});
table.row(['A']);
table.row(['B']);
table.row(['C']);
expect(fontSpy).toHaveBeenCalledWith(
standardFont,
expect.toSatisfy(() => true),
);
expect(fontSpy).toHaveBeenCalledWith(
fontPath,
expect.toSatisfy(() => true),
);
expect(fontSpy).toHaveBeenCalledWith(
fontBuffer,
expect.toSatisfy(() => true),
);
});

test('cell font', () => {
const standardFont = 'Courier';
const fontPath = 'tests/fonts/Roboto-Regular.ttf';
const fontBuffer = fs.readFileSync('tests/fonts/Roboto-Regular.ttf');
const document = new PDFDocument();
const fontSpy = vi.spyOn(document, 'font');

const table = document.table();
table.row([
{ text: 'A', font: { src: standardFont } },
{ text: 'B', font: { src: fontPath } },
{ text: 'C', font: { src: fontBuffer } },
]);
expect(fontSpy).toHaveBeenCalledWith(
standardFont,
expect.toSatisfy(() => true),
);
expect(fontSpy).toHaveBeenCalledWith(
fontPath,
expect.toSatisfy(() => true),
);
expect(fontSpy).toHaveBeenCalledWith(
fontBuffer,
expect.toSatisfy(() => true),
);
});

test('merge table font', () => {
const fontSrcs = {
colStandardFont: 'Courier',
colFontPath: 'tests/fonts/Roboto-Regular.ttf',
colFontBuffer: fs.readFileSync('tests/fonts/Roboto-Regular.ttf'),
rowStandardFont: 'Courier-Bold',
rowFontPath: 'tests/fonts/Roboto-Medium.ttf',
rowFontBuffer: fs.readFileSync('tests/fonts/Roboto-Medium.ttf'),
cellStandardFont: 'Courier-Oblique',
cellFontPath: 'tests/fonts/Roboto-MediumItalic.ttf',
cellFontBuffer: fs.readFileSync('tests/fonts/Roboto-MediumItalic.ttf'),
};
const fontSrcSet = Object.values(fontSrcs);

/**
* Check whether given spy has been called with specified allowed fonts
* and not other fonts within concerned font set
* @param {*} fontSpy
* @param {import('../../lib/table/utils').Font[]} allowedFonts
*/
function expectFonts(
fontSpy,
allowedFonts = [],
testedFonts = fontSrcSet,
) {
const allowedFontSrc = allowedFonts.map((font) => {
expect(fontSpy).toHaveBeenCalledWith(font.src, font.family);
return font.src;
});
testedFonts.forEach((fontSrc) => {
if (!allowedFontSrc.includes(fontSrc)) {
expect(fontSpy).not.toHaveBeenCalledWith(
fontSrc,
expect.toSatisfy(() => true),
);
}
});
}
const document = new PDFDocument();
const fontSpy = vi.spyOn(document, 'font');

const table = document.table({
columnStyles: [
{ font: { src: fontSrcs.colStandardFont } },
{ font: { src: fontSrcs.colFontPath } },
{ font: { src: fontSrcs.colFontBuffer } },
],
rowStyles: [
{},
{ font: { src: fontSrcs.rowStandardFont } },
{ font: { src: fontSrcs.rowFontPath } },
{ font: { src: fontSrcs.rowFontBuffer } },
{ font: { src: fontSrcs.rowFontBuffer } },
],
});
// fonts in column styles
fontSpy.mockClear();
table.row([{ text: 'A' }, { text: 'B' }, { text: 'C' }]);
expectFonts(fontSpy, [
{ src: fontSrcs.colStandardFont },
{ src: fontSrcs.colFontPath },
{ src: fontSrcs.colFontBuffer },
]);

// fonts in column + row styles
fontSpy.mockClear();
table.row([{ text: 'A' }, { text: 'B' }, { text: 'C' }]);
expectFonts(fontSpy, [{ src: fontSrcs.rowStandardFont }]);
fontSpy.mockClear();
table.row([{ text: 'A' }, { text: 'B' }, { text: 'C' }]);
expectFonts(fontSpy, [{ src: fontSrcs.rowFontPath }]);
fontSpy.mockClear();
table.row([{ text: 'A' }, { text: 'B' }, { text: 'C' }]);
expectFonts(fontSpy, [{ src: fontSrcs.rowFontBuffer }]);

// fonts in column + row + cell style
fontSpy.mockClear();
table.row([
{
text: 'A',
font: { src: fontSrcs.cellStandardFont },
},
{ text: 'B', font: { src: fontSrcs.cellFontPath } },
{
text: 'C',
font: { src: fontSrcs.cellFontBuffer },
},
]);
expectFonts(fontSpy, [
{ src: fontSrcs.cellStandardFont },
{ src: fontSrcs.cellFontPath },
{ src: fontSrcs.cellFontBuffer },
]);
});
});
});

describe('utils', () => {
Expand All @@ -28,6 +214,16 @@ describe('utils', () => {
[1, {}, 1],
[{ a: 'hello' }, { a: {} }, { a: 'hello' }],
[{ a: { b: 'hello' } }, { a: { b: 'world' } }, { a: { b: 'world' } }],
[
{ a: Buffer.from([1, 2, 3]) },
{ b: Buffer.from([4, 5, 6]) },
{ a: Buffer.from([1, 2, 3]), b: Buffer.from([4, 5, 6]) },
],
[
{ a: new Uint8Array([1, 2, 3]) },
{ b: new Uint8Array([4, 5, 6]) },
{ a: new Uint8Array([1, 2, 3]), b: new Uint8Array([4, 5, 6]) },
],
])('%o -> %o', function () {
const opts = Array.from(arguments);
const expected = opts.splice(-1, 1)[0];
Expand Down