diff --git a/lib/object.js b/lib/object.js index ff8e0dbd..9c4c3aff 100644 --- a/lib/object.js +++ b/lib/object.js @@ -127,14 +127,25 @@ class PDFObject { return `(${string})`; } else if (Array.isArray(object)) { + // Array entries are positional, so an `undefined` hole cannot simply be + // dropped without shifting everything after it. `null` is a real PDF + // object (ISO 32000-1, 7.3.9) and is the closest valid stand-in, which + // is also what JSON.stringify does. const items = object - .map((e) => PDFObject.convert(e, encryptFn)) + .map((e) => PDFObject.convert(e === undefined ? null : e, encryptFn)) .join(' '); return `[${items}]`; } else if ({}.toString.call(object) === '[object Object]') { const out = ['<<']; for (let key in object) { const val = object[key]; + // `undefined` has no PDF representation, so serialising it produced the + // literal token `undefined` and an unparseable file. The spec treats a + // dictionary entry whose value is null as absent (ISO 32000-1, 7.3.9), + // so omitting the key is the closest valid equivalent - and it matches + // JSON.stringify. An explicit `null` is left alone: it is a real PDF + // object and callers may be relying on it. + if (val === undefined) continue; out.push(`/${key} ${PDFObject.convert(val, encryptFn)}`); } diff --git a/tests/unit/object.spec.js b/tests/unit/object.spec.js index 369c585b..c5396aa8 100644 --- a/tests/unit/object.spec.js +++ b/tests/unit/object.spec.js @@ -19,6 +19,20 @@ describe('PDFObject', () => { expect(result.length).toEqual(12); expect(result).toMatchInlineSnapshot(`"(þÿ±²³´)"`); }); + + test('dictionary omits keys whose value is undefined', () => { + expect(PDFObject.convert({ a: 1, b: undefined, c: 2 })).toEqual( + '<<\n/a 1\n/c 2\n>>', + ); + }); + + test('dictionary keeps an explicit null', () => { + expect(PDFObject.convert({ a: null })).toEqual('<<\n/a null\n>>'); + }); + + test('array converts an undefined entry to null to keep positions', () => { + expect(PDFObject.convert([1, undefined, 2])).toEqual('[1 null 2]'); + }); }); describe('escapeName', () => {