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),