From a7f9749943b14d118c404aec501cc1e15fc0d46a Mon Sep 17 00:00:00 2001 From: Mahathir Mohammad Shuvo Date: Tue, 25 Aug 2026 14:08:40 +0600 Subject: [PATCH] Fix doc.file() throwing when the same in-memory attachment is added twice The deduplication check dereferenced Params.CreationDate unguarded, and the ModDate comparison below it covered only the case where both sides were absent, so an asymmetric absence threw there as well. A source that is not read from disk - an ArrayBuffer, a Uint8Array or a data URL - carries no file system timestamps, so embedding one twice under the same name threw TypeError instead of reusing the existing reference. --- CHANGELOG.md | 2 + lib/mixins/attachments.js | 18 ++++++-- tests/unit/attachments.spec.js | 84 ++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b931da0..9b27cdb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Unreleased +- Fix `doc.file()` throwing when the same in-memory attachment is embedded twice under one name, because the creation and modified dates the deduplication check compares are absent for sources that are not read from disk + ### [v0.20.1] - 2026-08-23 - Add a Node ESM build so `import 'pdfkit'` in Node resolves to the Node build (real file system, native zlib, Node streams, self-registering standard fonts) instead of the browser bundle diff --git a/lib/mixins/attachments.js b/lib/mixins/attachments.js index 77114f30..e9f7e6eb 100644 --- a/lib/mixins/attachments.js +++ b/lib/mixins/attachments.js @@ -119,8 +119,20 @@ function isEqual(a, b) { a.Subtype === b.Subtype && a.Params.CheckSum.toString() === b.Params.CheckSum.toString() && a.Params.Size === b.Params.Size && - a.Params.CreationDate.getTime() === b.Params.CreationDate.getTime() && - ((a.Params.ModDate === undefined && b.Params.ModDate === undefined) || - a.Params.ModDate.getTime() === b.Params.ModDate.getTime()) + isSameDate(a.Params.CreationDate, b.Params.CreationDate) && + isSameDate(a.Params.ModDate, b.Params.ModDate) ); } + +/** + * Compare two dates, either of which may be absent. + * + * An in-memory source carries no file system timestamps, so `CreationDate` and + * `ModDate` are only present when the file was read from disk or the caller + * passed `creationDate` / `modifiedDate`. Two absent dates describe the same + * metadata; an absent date never matches a present one. + */ +function isSameDate(a, b) { + if (a === undefined || b === undefined) return a === b; + return a.getTime() === b.getTime(); +} diff --git a/tests/unit/attachments.spec.js b/tests/unit/attachments.spec.js index 6e49129b..d911526a 100644 --- a/tests/unit/attachments.spec.js +++ b/tests/unit/attachments.spec.js @@ -245,6 +245,90 @@ describe('file', () => { ]); }); + test('attach the same file multiple times without dates', () => { + const docData = logData(document); + + document.file(Buffer.from('example text'), { name: 'file1.txt' }); + document.file(Buffer.from('example text'), { name: 'file1.txt' }); + document.end(); + + const numFiles = docData.filter( + (str) => + typeof str === 'string' && str.startsWith('<<\n/Type /EmbeddedFile\n'), + ); + + expect(numFiles.length).toEqual(1); + + // both filespecs point at the single embedded file + expect(docData).toContainChunk([ + `9 0 obj`, + `<< +/Type /Filespec +/AFRelationship /Unspecified +/F (file1.txt) +/EF << +/F 8 0 R +>> +/UF (file1.txt) +>>`, + ]); + + expect(docData).toContainChunk([ + `10 0 obj`, + `<< +/Type /Filespec +/AFRelationship /Unspecified +/F (file1.txt) +/EF << +/F 8 0 R +>> +/UF (file1.txt) +>>`, + ]); + }); + + test('attach the same file twice, only one with a creation date', () => { + const docData = logData(document); + + document.file(Buffer.from('example text'), { name: 'file1.txt' }); + document.file(Buffer.from('example text'), { + name: 'file1.txt', + creationDate: date, + }); + document.end(); + + const numFiles = docData.filter( + (str) => + typeof str === 'string' && str.startsWith('<<\n/Type /EmbeddedFile\n'), + ); + + // the metadata differs, so the reference is not reused + expect(numFiles.length).toEqual(2); + }); + + test('attach the same file twice, only one with a modified date', () => { + const docData = logData(document); + + document.file(Buffer.from('example text'), { + name: 'file1.txt', + creationDate: date, + }); + document.file(Buffer.from('example text'), { + name: 'file1.txt', + creationDate: date, + modifiedDate: date, + }); + document.end(); + + const numFiles = docData.filter( + (str) => + typeof str === 'string' && str.startsWith('<<\n/Type /EmbeddedFile\n'), + ); + + // the metadata differs, so the reference is not reused + expect(numFiles.length).toEqual(2); + }); + test('throws when the ref is missing', () => { expect(() => document.addNamedEmbeddedFile('phantom.txt', undefined),