Skip to content
Merged
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 @@ -6,6 +6,7 @@
- Fix Link accessibility issues
- Fix Table Accessibility Issue: Operator CS/cs not allowed in this current state
- Preserve existing PageMode instead of overwriting when adding outlines
- Support outlines that jump to specific page positions with custom zoom level

### [v0.17.2] - 2025-08-30

Expand Down
53 changes: 50 additions & 3 deletions docs/outline.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,59 @@ Here is an example of adding a bookmark with a single child bookmark.

## Options

The `options` parameter currently only has one property: `expanded`. If this value is set to `true` then all of that section's children will be visible by default. This value defaults to `false`.
The `options` parameter supports the following properties:

In this example the 'Top Level' section will be expanded to show 'Sub-section'.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `expanded` | boolean | `false` | Show children by default |
| `pageNumber` | number | `null` | Link to specific page by index (0-based). If `null`, links to current page |
| `fit` | boolean | `true` | Use `Fit` destination (fit entire page in window) |
| `top` | number | `0` | Top position for `XYZ` destination (requires `fit: false`) |
| `left` | number | `0` | Left position for `XYZ` destination (requires `fit: false`) |
| `zoom` | number | `0` | Zoom level for `XYZ` destination (0 = current zoom, requires `fit: false`) |

### Expanded sections

If `expanded` is set to `true`, all of that section's children will be visible by default.

// Add a top-level bookmark
const top = outline.addItem('Top Level', { expanded: true });

// Add a sub-section
top.addItem('Sub-section');
top.addItem('Sub-section');

### Link to a specific page

Use `pageNumber` to link to a specific page by its index (0-based).

doc.text('Page 1');
doc.addPage();
doc.text('Page 2');
doc.addPage();
doc.text('Page 3');

// Link to page 1 (index 0)
outline.addItem('Chapter 1', { pageNumber: 0 });
// Link to page 2 (index 1)
outline.addItem('Chapter 2', { pageNumber: 1 });

### Custom position and zoom (XYZ destination)

Set `fit: false` to use XYZ destination with custom position and zoom level.

// Jump to top-left corner of the page with 150% zoom
outline.addItem('Section 1', {
fit: false,
top: 0,
left: 0,
zoom: 1.5
});

// Jump to a specific position (100pt from top)
outline.addItem('Section 2', {
pageNumber: 1,
fit: false,
top: 100,
left: 0,
zoom: 0 // 0 means keep current zoom level
});
31 changes: 27 additions & 4 deletions lib/outline.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
const DEFAULT_OPTIONS = {
top: 0,
left: 0,
zoom: 0,
fit: true, // Default to Fit for backward compatibility
pageNumber: null,
expanded: false,
};

class PDFOutline {
constructor(document, parent, title, dest, options = { expanded: false }) {
constructor(document, parent, title, dest, options = DEFAULT_OPTIONS) {
this.document = document;
this.options = options;
this.outlineData = {};

if (dest !== null) {
this.outlineData['Dest'] = [dest.dictionary, 'Fit'];
const destWidth = dest.data.MediaBox[2];
const destHeight = dest.data.MediaBox[3];
const top = destHeight - (options.top || 0);
const left = destWidth - (options.left || 0);
const zoom = options.zoom || 0;

this.outlineData['Dest'] = options.fit
? [dest, 'Fit']
: [dest, 'XYZ', left, top, zoom];
}

if (parent !== null) {
Expand All @@ -20,12 +37,18 @@ class PDFOutline {
this.children = [];
}

addItem(title, options = { expanded: false }) {
addItem(title, options = DEFAULT_OPTIONS) {
const pages = this.document._root.data.Pages.data.Kids;
const dest =
options.pageNumber != null
? pages[options.pageNumber]
: this.document.page.dictionary;

const result = new PDFOutline(
this.document,
this.dictionary,
title,
this.document.page,
dest,
options,
);
this.children.push(result);
Expand Down
138 changes: 137 additions & 1 deletion tests/unit/outline.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import PDFDocument from '../../lib/document';

describe('outline', () => {
describe('PDFOutline', () => {
describe('PageMode', () => {
test('sets PageMode to UseOutlines when not already set', () => {
const doc = new PDFDocument();
Expand All @@ -21,4 +21,140 @@ describe('outline', () => {
expect(doc._root.data.PageMode).toBe('FullScreen');
});
});

describe('addItem', () => {
test('creates outline item for current page by default', () => {
const doc = new PDFDocument();
const outline = doc.outline;

// Add first page content
doc.text('Page 1');

// Add outline item (should point to page 1)
const item = outline.addItem('Chapter 1');

expect(item.outlineData.Dest[0]).toBe(doc.page.dictionary);
});

test('creates outline item for specific page when pageNumber is provided', () => {
const doc = new PDFDocument();
const outline = doc.outline;

// Create multiple pages
doc.text('Page 1');
doc.addPage();
doc.text('Page 2');
doc.addPage();
doc.text('Page 3');

// Add outline item pointing to page 1 (index 0)
const item1 = outline.addItem('Chapter 1', { pageNumber: 0 });
// Add outline item pointing to page 2 (index 1)
const item2 = outline.addItem('Chapter 2', { pageNumber: 1 });

// Get page references
const pages = doc._root.data.Pages.data.Kids;

expect(item1.outlineData.Dest[0]).toBe(pages[0]);
expect(item2.outlineData.Dest[0]).toBe(pages[1]);
});

test('falls back to current page when pageNumber is null', () => {
const doc = new PDFDocument();
const outline = doc.outline;

doc.text('Page 1');
doc.addPage();
doc.text('Page 2');

// pageNumber: null should use current page (page 2)
const item = outline.addItem('Current Page', { pageNumber: null });

expect(item.outlineData.Dest[0]).toBe(doc.page.dictionary);
});

test('supports nested outline items with page numbers', () => {
const doc = new PDFDocument();
const outline = doc.outline;

doc.text('Page 1');
doc.addPage();
doc.text('Page 2');
doc.addPage();
doc.text('Page 3');

const pages = doc._root.data.Pages.data.Kids;

// Create parent pointing to page 1
const parent = outline.addItem('Part 1', { pageNumber: 0 });
// Create child pointing to page 2
const child = parent.addItem('Chapter 1', { pageNumber: 1 });

expect(parent.outlineData.Dest[0]).toBe(pages[0]);
expect(child.outlineData.Dest[0]).toBe(pages[1]);
});
});

describe('destination type', () => {
test('uses Fit destination by default', () => {
const doc = new PDFDocument();
const outline = doc.outline;

doc.text('Page 1');
const item = outline.addItem('Chapter 1');

expect(item.outlineData.Dest[1]).toBe('Fit');
});

test('uses XYZ destination with position and zoom options', () => {
const doc = new PDFDocument();
const outline = doc.outline;

doc.text('Page 1');
const item = outline.addItem('Chapter 1', {
fit: false,
top: 100,
left: 50,
zoom: 1.5,
});

expect(item.outlineData.Dest[1]).toBe('XYZ');
// XYZ format: [page, 'XYZ', left, top, zoom]
expect(item.outlineData.Dest[4]).toBe(1.5); // zoom
});

test('uses Fit destination when fit option is true', () => {
const doc = new PDFDocument();
const outline = doc.outline;

doc.text('Page 1');
const item = outline.addItem('Chapter 1', {
fit: true,
top: 100, // should be ignored when fit is true
});

expect(item.outlineData.Dest[1]).toBe('Fit');
expect(item.outlineData.Dest.length).toBe(2);
});

test('calculates XYZ coordinates correctly', () => {
const doc = new PDFDocument({ size: [612, 792] }); // Letter size
const outline = doc.outline;

doc.text('Page 1');
const item = outline.addItem('Chapter 1', {
fit: false,
top: 100,
left: 50,
zoom: 0,
});

// XYZ format: [page, 'XYZ', left, top, zoom]
// left = pageWidth - options.left = 612 - 50 = 562
// top = pageHeight - options.top = 792 - 100 = 692
expect(item.outlineData.Dest[2]).toBe(562); // left
expect(item.outlineData.Dest[3]).toBe(692); // top
expect(item.outlineData.Dest[4]).toBe(0); // zoom
});
});
});