diff --git a/src/core/catalog.js b/src/core/catalog.js index 9ab10f281a0f5..fcb5964987288 100644 --- a/src/core/catalog.js +++ b/src/core/catalog.js @@ -1542,6 +1542,105 @@ class Catalog { * exist in most PDF documents). */ + /** + * Derive a destination array from a Structure Element reference. + * Walks the SE dict to find its page (Pg) and optional bounding box (A.BBox), + * then returns an XYZ destination array that can be used for navigation. + * @param {XRef} xref + * @param {Ref} seRef + * @returns {Array|null} + */ + static #getDestFromStructElement(xref, seRef) { + const seDict = xref.fetchIfRef(seRef); + if (!(seDict instanceof Dict)) { + return null; + } + + // Try to find the page reference for this structure element. + // Search order: the element itself, its descendants down to leaf nodes, + // then ancestor elements via the P entry (up). + let pageRef = null; + + // Check the element directly. + const directPg = seDict.getRaw("Pg"); + if (directPg instanceof Ref) { + pageRef = directPg; + } + + // Walk down into descendants (BFS) until a Pg is found or leaves are + // reached (e.g. integer MCIDs or MCR/OBJR dicts without further K). + if (!pageRef) { + const queue = [seDict]; + while (queue.length > 0 && !pageRef) { + const node = queue.shift(); + const kids = node.get("K"); + let kidsArr; + if (Array.isArray(kids)) { + kidsArr = kids; + } else if (kids) { + kidsArr = [kids]; + } else { + kidsArr = []; + } + for (const kid of kidsArr) { + const kidObj = xref.fetchIfRef(kid); + if (!(kidObj instanceof Dict)) { + continue; // integer MCID – leaf node, no Pg here + } + const pg = kidObj.getRaw("Pg"); + if (pg instanceof Ref) { + pageRef = pg; + break; + } + queue.push(kidObj); + } + } + } + + // Walk up the parent chain if still not found. + if (!pageRef) { + const MAX_DEPTH = 40; + let current = seDict; + for (let depth = 0; depth < MAX_DEPTH; depth++) { + const parentRaw = current.getRaw("P"); + if (!(parentRaw instanceof Ref)) { + break; + } + const parentDict = xref.fetchIfRef(parentRaw); + if (!(parentDict instanceof Dict)) { + break; + } + if (isName(parentDict.get("Type"), "StructTreeRoot")) { + break; + } + const pg = parentDict.getRaw("Pg"); + if (pg instanceof Ref) { + pageRef = pg; + break; + } + current = parentDict; + } + } + + if (!pageRef) { + return null; + } + + // Try to obtain precise coordinates from the element's attribute BBox. + let x = null, + y = null; + const attrs = seDict.get("A"); + if (attrs instanceof Dict) { + const bboxArr = attrs.getArray("BBox"); + if (isNumberArray(bboxArr, 4)) { + x = bboxArr[0]; + y = bboxArr[3]; // top of the bbox in PDF page coordinates + } + } + + return [pageRef, { name: "XYZ" }, x, y, null]; + } + /** * Helper function used to parse the contents of destination dictionaries. * @param {ParseDestDictionaryParameters} params @@ -1773,6 +1872,35 @@ class Catalog { resultObj.dest = dest; } } + + // Handle SE (Structure Element) entry: when no other destination has been + // found, derive one from the structure element's page and optional bbox. + if ( + !resultObj.dest && + !resultObj.url && + !resultObj.action && + !resultObj.attachment && + !resultObj.setOCGState && + !resultObj.resetForm + ) { + const seRef = destDict.getRaw("SE"); + if (seRef instanceof Ref) { + try { + const seDest = Catalog.#getDestFromStructElement( + destDict.xref, + seRef + ); + if (seDest) { + resultObj.dest = seDest; + } + } catch (ex) { + if (ex instanceof MissingDataException) { + throw ex; + } + info("SE parsing failed."); + } + } + } } } diff --git a/test/integration/viewer_spec.mjs b/test/integration/viewer_spec.mjs index 9d05102ccd337..d65e81ee91a74 100644 --- a/test/integration/viewer_spec.mjs +++ b/test/integration/viewer_spec.mjs @@ -1646,4 +1646,45 @@ describe("PDF viewer", () => { ); }); }); + + describe("Outline with SE (Structure Element) entries", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "outlines_se.pdf", + `.page[data-page-number="1"] .endOfContent` + ); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("should navigate to the correct page when clicking an outline item with an SE entry", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + // Open the sidebar. + await showViewsManager(page); + + // Switch to the outline view. + await page.click("#viewsManagerSelectorButton"); + await page.waitForSelector("#outlinesViewMenu", { visible: true }); + await page.click("#outlinesViewMenu"); + + for (let i = 2; i >= 1; i--) { + await waitAndClick( + page, + `#outlinesView .treeItem .treeItem:nth-child(${i}) a` + ); + await page.waitForFunction( + pageNum => window.PDFViewerApplication.page === pageNum, + {}, + i + ); + } + }) + ); + }); + }); }); diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 01472be4974a2..771e71c68339b 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -879,3 +879,4 @@ !sci-notation.pdf !nested_outline.pdf !form_two_pages.pdf +!outlines_se.pdf diff --git a/test/pdfs/outlines_se.pdf b/test/pdfs/outlines_se.pdf new file mode 100644 index 0000000000000..bb6725142bb87 Binary files /dev/null and b/test/pdfs/outlines_se.pdf differ