diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b27cdb0..70c374aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### 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 +- Fix `doc.addNamedDestination()` writing a destination that carries more parameters than its type takes, and throwing `unsupported number: NaN` when the top of an `XYZ` destination was left off or passed as `undefined`. Parameters beyond the type's list are now dropped, and a short list is filled out with null for the types whose parameters may be null (`XYZ`, `FitH`, `FitV`, `FitBH` and `FitBV`) ### [v0.20.1] - 2026-08-23 diff --git a/docs/destinations.md b/docs/destinations.md index 30becbe6..eefcc9e3 100644 --- a/docs/destinations.md +++ b/docs/destinations.md @@ -13,6 +13,9 @@ Examples of creating anchor: // Insert anchor to display a portion of the current page, 1/2 inch in from the top and left and zoomed 50% doc.addNamedDestination('LINK', 'XYZ', 36, 36, 50); + // Insert anchor 1/2 inch in from the top and left, leaving the zoom as the reader has it + doc.addNamedDestination('LINK', 'XYZ', 36, 36); + // Insert anchor for this text doc.text('End of paragraph', { destination: 'ENDP' }); diff --git a/lib/document.js b/lib/document.js index 4e76a0a5..2b9588e7 100644 --- a/lib/document.js +++ b/lib/document.js @@ -24,6 +24,20 @@ import TableMixin from './mixins/table'; import MetadataMixin from './mixins/metadata'; import { fromBinaryString } from './binary'; +// The parameters each destination type takes (ISO 32000-1, Table 151). `nullable` +// marks the types whose parameters may each be null, which tells the reader to keep +// that aspect of its current view. FitR has no such allowance. +const DESTINATION_PARAMETERS = new Map([ + ['XYZ', { count: 3, nullable: true }], + ['Fit', { count: 0, nullable: false }], + ['FitH', { count: 1, nullable: true }], + ['FitV', { count: 1, nullable: true }], + ['FitR', { count: 4, nullable: false }], + ['FitB', { count: 0, nullable: false }], + ['FitBH', { count: 1, nullable: true }], + ['FitBV', { count: 1, nullable: true }], +]); + class PDFDocument extends Readable { constructor(options = {}) { super(options); @@ -221,9 +235,19 @@ class PDFDocument extends Readable { addNamedDestination(name, ...args) { if (args.length === 0) { - args = ['XYZ', null, null, null]; + args = ['XYZ']; + } + const parameters = DESTINATION_PARAMETERS.get(args[0]); + if (parameters !== undefined) { + // Drop anything past the parameters the type takes, and fill out a short + // list only where a null parameter is allowed. The holes this leaves are + // written as null by PDFObject.convert. + const length = parameters.count + 1; + if (args.length > length || parameters.nullable) { + args.length = length; + } } - if (args[0] === 'XYZ' && args[2] !== null) { + if (args[0] === 'XYZ' && args[2] != null) { args[2] = this.page.height - args[2]; } args.unshift(this.page.dictionary); diff --git a/tests/unit/trailer.spec.js b/tests/unit/trailer.spec.js index 95c7566b..25f1a767 100644 --- a/tests/unit/trailer.spec.js +++ b/tests/unit/trailer.spec.js @@ -72,6 +72,93 @@ describe('Document trailer', () => { /Resources 6 0 R /UserUnit 1 /Annots [9 0 R] +>>`, + ]); + }); + + test('writes null for XYZ parameters left off, rather than a NaN top', () => { + const docData = logData(document); + document.addNamedDestination('LINK1', 'XYZ', 36); + document.end(); + + expect(docData).toContainChunk([ + '2 0 obj', + `<< +/Dests << + /Names [ + (LINK1) [7 0 R /XYZ 36 null null] +] +>> +>>`, + ]); + }); + + test('fills out a short parameter list for the types that allow null', () => { + const docData = logData(document); + document.addNamedDestination('LINK1', 'XYZ', 36, 36); + document.addNamedDestination('LINK2', 'FitH'); + document.addNamedDestination('LINK3', 'FitV'); + document.addNamedDestination('LINK4', 'FitBH'); + document.addNamedDestination('LINK5', 'FitBV'); + document.end(); + + expect(docData).toContainChunk([ + '2 0 obj', + `<< +/Dests << + /Limits [(LINK1) (LINK5)] + /Names [ + (LINK1) [7 0 R /XYZ 36 756 null] + (LINK2) [7 0 R /FitH null] + (LINK3) [7 0 R /FitV null] + (LINK4) [7 0 R /FitBH null] + (LINK5) [7 0 R /FitBV null] +] +>> +>>`, + ]); + }); + + test('drops parameters beyond the list a destination type takes', () => { + const docData = logData(document); + document.addNamedDestination('LINK1', 'Fit', 99); + document.addNamedDestination('LINK2', 'FitB', 99); + document.addNamedDestination('LINK3', 'XYZ', 1, 2, 3, 4); + document.addNamedDestination('LINK4', 'FitR', 1, 2, 3, 4, 5); + document.end(); + + expect(docData).toContainChunk([ + '2 0 obj', + `<< +/Dests << + /Limits [(LINK1) (LINK4)] + /Names [ + (LINK1) [7 0 R /Fit] + (LINK2) [7 0 R /FitB] + (LINK3) [7 0 R /XYZ 1 790 3] + (LINK4) [7 0 R /FitR 1 2 3 4] +] +>> +>>`, + ]); + }); + + // FitR is the one type whose parameters may not be null, so a short one is left as it + // was given rather than filled out. It is still not a valid destination; resizing it is + // out of scope here. + test('leaves a short FitR destination as it was given', () => { + const docData = logData(document); + document.addNamedDestination('LINK1', 'FitR', 1, 2, 3); + document.end(); + + expect(docData).toContainChunk([ + '2 0 obj', + `<< +/Dests << + /Names [ + (LINK1) [7 0 R /FitR 1 2 3] +] +>> >>`, ]); });