From 4977246adf50cdc9d5ddf4b42abaec65ac2e8523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?TATSUNO=20=E2=80=9CTaz=E2=80=9D=20Yasuhiro?= Date: Sun, 25 Jan 2026 15:33:03 +0900 Subject: [PATCH 1/2] feat(outline): support pageNumber and XYZ destination options --- CHANGELOG.md | 1 + lib/outline.js | 31 +++++++-- tests/unit/outline.spec.js | 138 ++++++++++++++++++++++++++++++++++++- 3 files changed, 165 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c11c5c..2f28ebca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/outline.js b/lib/outline.js index 02a32aa5..1f5b05d2 100644 --- a/lib/outline.js +++ b/lib/outline.js @@ -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) { @@ -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); diff --git a/tests/unit/outline.spec.js b/tests/unit/outline.spec.js index 8a788a1d..53f4078b 100644 --- a/tests/unit/outline.spec.js +++ b/tests/unit/outline.spec.js @@ -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(); @@ -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 + }); + }); }); From 760c31a94632d1e762aa912ce695f41bc9715f36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?TATSUNO=20=E2=80=9CTaz=E2=80=9D=20Yasuhiro?= Date: Sun, 25 Jan 2026 16:23:45 +0900 Subject: [PATCH 2/2] Update document --- docs/outline.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/docs/outline.md b/docs/outline.md index 7ee46da2..1b0f1fae 100644 --- a/docs/outline.md +++ b/docs/outline.md @@ -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'); \ No newline at end of file + 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 + });