diff --git a/CHANGELOG.md b/CHANGELOG.md index 8155b4e35..90180f30e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/table/normalize.js b/lib/table/normalize.js index 6ff534920..c937591fd 100644 --- a/lib/table/normalize.js +++ b/lib/table/normalize.js @@ -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; diff --git a/lib/table/style.js b/lib/table/style.js index 17ac24d1a..9439de506 100644 --- a/lib/table/style.js +++ b/lib/table/style.js @@ -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; @@ -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, + }; + // Merge defaults - colStyle = deepMerge(defaultColStyle, colStyle); + colStyle = deepMerge(restDefaultStyle, restStyle); + colStyle.font = mergedFont; if (colStyle.width == null || colStyle.width === '*') { colStyle.width = '*'; diff --git a/lib/table/utils.js b/lib/table/utils.js index 7762d6bc7..9c9fcdf21 100644 --- a/lib/table/utils.js +++ b/lib/table/utils.js @@ -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. * @@ -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) { @@ -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]); } diff --git a/tests/unit/table.spec.js b/tests/unit/table.spec.js index 9f84938d5..a765aabd2 100644 --- a/tests/unit/table.spec.js +++ b/tests/unit/table.spec.js @@ -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', () => { @@ -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', () => { @@ -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];