Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions docs/destinations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand Down
28 changes: 26 additions & 2 deletions lib/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
87 changes: 87 additions & 0 deletions tests/unit/trailer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
]
>>
>>`,
]);
});
Expand Down
Loading