Skip to content

Commit 253ce6e

Browse files
committed
Handle outline with Structure Element (SE) destination
1 parent 2ffd2e6 commit 253ce6e

4 files changed

Lines changed: 170 additions & 0 deletions

File tree

src/core/catalog.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1542,6 +1542,105 @@ class Catalog {
15421542
* exist in most PDF documents).
15431543
*/
15441544

1545+
/**
1546+
* Derive a destination array from a Structure Element reference.
1547+
* Walks the SE dict to find its page (Pg) and optional bounding box (A.BBox),
1548+
* then returns an XYZ destination array that can be used for navigation.
1549+
* @param {XRef} xref
1550+
* @param {Ref} seRef
1551+
* @returns {Array|null}
1552+
*/
1553+
static #getDestFromStructElement(xref, seRef) {
1554+
const seDict = xref.fetchIfRef(seRef);
1555+
if (!(seDict instanceof Dict)) {
1556+
return null;
1557+
}
1558+
1559+
// Try to find the page reference for this structure element.
1560+
// Search order: the element itself, its descendants down to leaf nodes,
1561+
// then ancestor elements via the P entry (up).
1562+
let pageRef = null;
1563+
1564+
// Check the element directly.
1565+
const directPg = seDict.getRaw("Pg");
1566+
if (directPg instanceof Ref) {
1567+
pageRef = directPg;
1568+
}
1569+
1570+
// Walk down into descendants (BFS) until a Pg is found or leaves are
1571+
// reached (e.g. integer MCIDs or MCR/OBJR dicts without further K).
1572+
if (!pageRef) {
1573+
const queue = [seDict];
1574+
while (queue.length > 0 && !pageRef) {
1575+
const node = queue.shift();
1576+
const kids = node.get("K");
1577+
let kidsArr;
1578+
if (Array.isArray(kids)) {
1579+
kidsArr = kids;
1580+
} else if (kids) {
1581+
kidsArr = [kids];
1582+
} else {
1583+
kidsArr = [];
1584+
}
1585+
for (const kid of kidsArr) {
1586+
const kidObj = xref.fetchIfRef(kid);
1587+
if (!(kidObj instanceof Dict)) {
1588+
continue; // integer MCID – leaf node, no Pg here
1589+
}
1590+
const pg = kidObj.getRaw("Pg");
1591+
if (pg instanceof Ref) {
1592+
pageRef = pg;
1593+
break;
1594+
}
1595+
queue.push(kidObj);
1596+
}
1597+
}
1598+
}
1599+
1600+
// Walk up the parent chain if still not found.
1601+
if (!pageRef) {
1602+
const MAX_DEPTH = 40;
1603+
let current = seDict;
1604+
for (let depth = 0; depth < MAX_DEPTH; depth++) {
1605+
const parentRaw = current.getRaw("P");
1606+
if (!(parentRaw instanceof Ref)) {
1607+
break;
1608+
}
1609+
const parentDict = xref.fetchIfRef(parentRaw);
1610+
if (!(parentDict instanceof Dict)) {
1611+
break;
1612+
}
1613+
if (isName(parentDict.get("Type"), "StructTreeRoot")) {
1614+
break;
1615+
}
1616+
const pg = parentDict.getRaw("Pg");
1617+
if (pg instanceof Ref) {
1618+
pageRef = pg;
1619+
break;
1620+
}
1621+
current = parentDict;
1622+
}
1623+
}
1624+
1625+
if (!pageRef) {
1626+
return null;
1627+
}
1628+
1629+
// Try to obtain precise coordinates from the element's attribute BBox.
1630+
let x = null,
1631+
y = null;
1632+
const attrs = seDict.get("A");
1633+
if (attrs instanceof Dict) {
1634+
const bboxArr = attrs.getArray("BBox");
1635+
if (isNumberArray(bboxArr, 4)) {
1636+
x = bboxArr[0];
1637+
y = bboxArr[3]; // top of the bbox in PDF page coordinates
1638+
}
1639+
}
1640+
1641+
return [pageRef, { name: "XYZ" }, x, y, null];
1642+
}
1643+
15451644
/**
15461645
* Helper function used to parse the contents of destination dictionaries.
15471646
* @param {ParseDestDictionaryParameters} params
@@ -1773,6 +1872,35 @@ class Catalog {
17731872
resultObj.dest = dest;
17741873
}
17751874
}
1875+
1876+
// Handle SE (Structure Element) entry: when no other destination has been
1877+
// found, derive one from the structure element's page and optional bbox.
1878+
if (
1879+
!resultObj.dest &&
1880+
!resultObj.url &&
1881+
!resultObj.action &&
1882+
!resultObj.attachment &&
1883+
!resultObj.setOCGState &&
1884+
!resultObj.resetForm
1885+
) {
1886+
const seRef = destDict.getRaw("SE");
1887+
if (seRef instanceof Ref) {
1888+
try {
1889+
const seDest = Catalog.#getDestFromStructElement(
1890+
destDict.xref,
1891+
seRef
1892+
);
1893+
if (seDest) {
1894+
resultObj.dest = seDest;
1895+
}
1896+
} catch (ex) {
1897+
if (ex instanceof MissingDataException) {
1898+
throw ex;
1899+
}
1900+
info("SE parsing failed.");
1901+
}
1902+
}
1903+
}
17761904
}
17771905
}
17781906

test/integration/viewer_spec.mjs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1646,4 +1646,45 @@ describe("PDF viewer", () => {
16461646
);
16471647
});
16481648
});
1649+
1650+
describe("Outline with SE (Structure Element) entries", () => {
1651+
let pages;
1652+
1653+
beforeEach(async () => {
1654+
pages = await loadAndWait(
1655+
"outlines_se.pdf",
1656+
`.page[data-page-number="1"] .endOfContent`
1657+
);
1658+
});
1659+
1660+
afterEach(async () => {
1661+
await closePages(pages);
1662+
});
1663+
1664+
it("should navigate to the correct page when clicking an outline item with an SE entry", async () => {
1665+
await Promise.all(
1666+
pages.map(async ([browserName, page]) => {
1667+
// Open the sidebar.
1668+
await showViewsManager(page);
1669+
1670+
// Switch to the outline view.
1671+
await page.click("#viewsManagerSelectorButton");
1672+
await page.waitForSelector("#outlinesViewMenu", { visible: true });
1673+
await page.click("#outlinesViewMenu");
1674+
1675+
for (let i = 2; i >= 1; i--) {
1676+
await waitAndClick(
1677+
page,
1678+
`#outlinesView .treeItem .treeItem:nth-child(${i}) a`
1679+
);
1680+
await page.waitForFunction(
1681+
pageNum => window.PDFViewerApplication.page === pageNum,
1682+
{},
1683+
i
1684+
);
1685+
}
1686+
})
1687+
);
1688+
});
1689+
});
16491690
});

test/pdfs/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,3 +879,4 @@
879879
!sci-notation.pdf
880880
!nested_outline.pdf
881881
!form_two_pages.pdf
882+
!outlines_se.pdf

test/pdfs/outlines_se.pdf

29.7 KB
Binary file not shown.

0 commit comments

Comments
 (0)