From 37f4712f7e2c93c1f0c667d4254479733fea4f5e Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Fri, 7 Nov 2025 18:21:51 +0100 Subject: [PATCH] Update the named page destinations when some pdf are combined (bug 1997379) and remove link annotations pointing on a deleted page. --- src/core/editor/pdf_editor.js | 271 ++++++++++++++++++++++++++++++++-- src/core/primitives.js | 6 + test/pdfs/.gitignore | 1 + test/pdfs/extract_link.pdf | Bin 0 -> 10213 bytes test/unit/api_spec.js | 146 ++++++++++++++++++ test/unit/primitives_spec.js | 6 + 6 files changed, 415 insertions(+), 15 deletions(-) create mode 100755 test/pdfs/extract_link.pdf diff --git a/src/core/editor/pdf_editor.js b/src/core/editor/pdf_editor.js index 5241863a7..42769f636 100644 --- a/src/core/editor/pdf_editor.js +++ b/src/core/editor/pdf_editor.js @@ -32,6 +32,8 @@ class PageData { this.page = page; this.documentData = documentData; this.annotations = null; + // Named destinations which points to this page. + this.pointingNamedDestinations = null; documentData.pagesMap.put(page.ref, this); } @@ -40,9 +42,13 @@ class PageData { class DocumentData { constructor(document) { this.document = document; + this.destinations = null; this.pageLabels = null; this.pagesMap = new RefSetCache(); this.oldRefMapping = new RefSetCache(); + this.dedupNamedDestinations = new Map(); + this.usedNamedDestinations = new Set(); + this.postponedRefCopies = new RefSetCache(); } } @@ -64,6 +70,7 @@ class PDFEditor { this.title = title; this.author = author; this.pageLabels = null; + this.namedDestinations = new Map(); } /** @@ -114,15 +121,21 @@ class PDFEditor { if (newRef) { return newRef; } + const oldRef = obj; + obj = await xref.fetchAsync(oldRef); + if (typeof obj === "number") { + // Simple value; no need to create a new reference. + return obj; + } + newRef = this.newRef; - oldRefMapping.put(obj, newRef); - obj = await xref.fetchAsync(obj); + oldRefMapping.put(oldRef, newRef); if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { if ( obj instanceof Dict && isName(obj.get("Type"), "Page") && - !this.currentDocument.pagesMap.has(obj) + !this.currentDocument.pagesMap.has(oldRef) ) { throw new Error( "Add a deleted page to the document is not supported." @@ -134,11 +147,20 @@ class PDFEditor { return newRef; } const promises = []; + const { + currentDocument: { postponedRefCopies }, + } = this; if (Array.isArray(obj)) { if (mustClone) { obj = obj.slice(); } for (let i = 0, ii = obj.length; i < ii; i++) { + const postponedActions = postponedRefCopies.get(obj[i]); + if (postponedActions) { + // The object is a reference that needs to be copied later. + postponedActions.push(ref => (obj[i] = ref)); + continue; + } promises.push( this.#collectDependencies(obj[i], true, xref).then( newObj => (obj[i] = newObj) @@ -159,6 +181,12 @@ class PDFEditor { } if (dict) { for (const [key, rawObj] of dict.getRawEntries()) { + const postponedActions = postponedRefCopies.get(rawObj); + if (postponedActions) { + // The object is a reference that needs to be copied later. + postponedActions.push(ref => dict.set(key, ref)); + continue; + } promises.push( this.#collectDependencies(rawObj, true, xref).then(newObj => dict.set(key, newObj) @@ -189,11 +217,13 @@ class PDFEditor { const promises = []; let newIndex = 0; this.hasSingleFile = pageInfos.length === 1; + const allDocumentData = []; for (const { document, includePages, excludePages } of pageInfos) { if (!document) { continue; } const documentData = new DocumentData(document); + allDocumentData.push(documentData); promises.push(this.#collectDocumentData(documentData)); let keptIndices, keptRanges, deletedIndices, deletedRanges; for (const page of includePages || []) { @@ -256,6 +286,7 @@ class PDFEditor { await Promise.all(promises); promises.length = 0; + this.#collectValidDestinations(allDocumentData); this.#collectPageLabels(); for (const page of this.oldPages) { @@ -263,10 +294,15 @@ class PDFEditor { } await Promise.all(promises); + this.#findDuplicateNamedDestinations(); + this.#setPostponedRefCopies(allDocumentData); + for (let i = 0, ii = this.oldPages.length; i < ii; i++) { this.newPages[i] = await this.#makePageCopy(i, null); } + this.#fixPostponedRefCopies(allDocumentData); + return this.writePDF(); } @@ -276,10 +312,17 @@ class PDFEditor { * @return {Promise} */ async #collectDocumentData(documentData) { - const { document } = documentData; - await document.pdfManager - .ensureCatalog("rawPageLabels") - .then(pageLabels => (documentData.pageLabels = pageLabels)); + const { + document: { pdfManager }, + } = documentData; + await Promise.all([ + pdfManager + .ensureCatalog("destinations") + .then(destinations => (documentData.destinations = destinations)), + pdfManager + .ensureCatalog("rawPageLabels") + .then(pageLabels => (documentData.pageLabels = pageLabels)), + ]); } /** @@ -290,6 +333,7 @@ class PDFEditor { async #postCollectPageData(pageData) { const { page: { xref, annotations }, + documentData: { pagesMap, destinations, usedNamedDestinations }, } = pageData; if (!annotations) { @@ -300,22 +344,185 @@ class PDFEditor { let newAnnotations = []; let newIndex = 0; - // TODO: remove only links to deleted pages. + // Filter out annotations that are linking to deleted pages. for (const annotationRef of annotations) { const newAnnotationIndex = newIndex++; promises.push( xref.fetchIfRefAsync(annotationRef).then(async annotationDict => { if (!isName(annotationDict.get("Subtype"), "Link")) { newAnnotations[newAnnotationIndex] = annotationRef; + return; + } + const action = annotationDict.get("A"); + const dest = + action instanceof Dict + ? action.get("D") + : annotationDict.get("Dest"); + + if ( + !dest /* not a destination */ || + (Array.isArray(dest) && + (!(dest[0] instanceof Ref) || pagesMap.has(dest[0]))) + ) { + // Keep the annotation as is: it isn't linking to a deleted page. + newAnnotations[newAnnotationIndex] = annotationRef; + } else if (typeof dest === "string") { + const destString = stringToPDFString( + dest, + /* keepEscapeSequence = */ true + ); + if (destinations.has(destString)) { + // Keep the annotation as is: the named destination is valid. + // Valid named destinations have been collected previously (see + // #collectValidDestinations). + newAnnotations[newAnnotationIndex] = annotationRef; + usedNamedDestinations.add(destString); + } } }) ); } + await Promise.all(promises); newAnnotations = newAnnotations.filter(annot => !!annot); pageData.annotations = newAnnotations.length > 0 ? newAnnotations : null; } + /** + * Some references cannot be copied right away since they correspond to some + * pages that haven't been processed yet. Postpone the copy of those + * references. + * @param {Array} allDocumentData + */ + #setPostponedRefCopies(allDocumentData) { + for (const { postponedRefCopies, pagesMap } of allDocumentData) { + for (const oldPageRef of pagesMap.keys()) { + postponedRefCopies.put(oldPageRef, []); + } + } + } + + /** + * Fix all postponed reference copies. + * @param {Array} allDocumentData + */ + #fixPostponedRefCopies(allDocumentData) { + for (const { postponedRefCopies, oldRefMapping } of allDocumentData) { + for (const [oldRef, actions] of postponedRefCopies.items()) { + const newRef = oldRefMapping.get(oldRef); + for (const action of actions) { + action(newRef); + } + } + postponedRefCopies.clear(); + } + } + + /** + * Collect named destinations that are still valid (i.e. pointing to kept + * pages). + * @param {Array} allDocumentData + */ + #collectValidDestinations(allDocumentData) { + // TODO: Handle OpenAction as well. + for (const documentData of allDocumentData) { + if (!documentData.destinations) { + continue; + } + const { destinations, pagesMap } = documentData; + const newDestinations = (documentData.destinations = new Map()); + for (const [key, dest] of Object.entries(destinations)) { + const pageRef = dest[0]; + const pageData = pagesMap.get(pageRef); + if (!pageData) { + continue; + } + (pageData.pointingNamedDestinations ||= new Set()).add(key); + newDestinations.set(key, dest); + } + } + } + + /** + * Find and rename duplicate named destinations. + */ + #findDuplicateNamedDestinations() { + const { namedDestinations } = this; + for (let i = 0, ii = this.oldPages.length; i < ii; i++) { + const page = this.oldPages[i]; + const { + documentData: { + destinations, + dedupNamedDestinations, + usedNamedDestinations, + }, + } = page; + let { pointingNamedDestinations } = page; + + if (!pointingNamedDestinations) { + // No named destinations pointing to this page. + continue; + } + // Keep only the named destinations that are still used. + page.pointingNamedDestinations = pointingNamedDestinations = + pointingNamedDestinations.intersection(usedNamedDestinations); + + for (const pointingDest of pointingNamedDestinations) { + if (!usedNamedDestinations.has(pointingDest)) { + // If the named destination isn't used, we can keep it as is. + continue; + } + const dest = destinations.get(pointingDest).slice(); + if (!namedDestinations.has(pointingDest)) { + // If the named destination hasn't been used yet, we can keep it + // as is. + namedDestinations.set(pointingDest, dest); + continue; + } + // Create a new unique named destination. + const newName = `${pointingDest}_p${i + 1}`; + dedupNamedDestinations.set(pointingDest, newName); + namedDestinations.set(newName, dest); + } + } + } + + /** + * Fix named destinations in the annotations. + * @param {Array} annotations + * @param {Map} dedupNamedDestinations + */ + #fixNamedDestinations(annotations, dedupNamedDestinations) { + if (dedupNamedDestinations.size === 0) { + return; + } + const fixDestination = (dict, key, dest) => { + if (typeof dest === "string") { + dict.set( + key, + dedupNamedDestinations.get( + stringToPDFString(dest, /* keepEscapeSequence = */ true) + ) || dest + ); + } + }; + + for (const annotRef of annotations) { + const annotDict = this.xref[annotRef.num]; + if (!isName(annotDict.get("Subtype"), "Link")) { + continue; + } + const action = annotDict.get("A"); + if (action instanceof Dict && action.has("D")) { + const dest = action.get("D"); + fixDestination(action, "D", dest); + continue; + } + const dest = annotDict.get("Dest"); + fixDestination(annotDict, "Dest", dest); + } + } + async #collectPageLabels() { // We can only preserve page labels when editing a single PDF file. // This is consistent with behavior in Adobe Acrobat. @@ -372,14 +579,23 @@ class PDFEditor { * @returns {Promise} the page reference in the new PDF document. */ async #makePageCopy(pageIndex) { - const { page, documentData, annotations } = this.oldPages[pageIndex]; + const { page, documentData, annotations, pointingNamedDestinations } = + this.oldPages[pageIndex]; this.currentDocument = documentData; - const { oldRefMapping } = documentData; + const { dedupNamedDestinations, oldRefMapping } = documentData; const { xref, rotate, mediaBox, resources, ref: oldPageRef } = page; const pageRef = this.newRef; const pageDict = (this.xref[pageRef.num] = page.pageDict.clone()); oldRefMapping.put(oldPageRef, pageRef); + if (pointingNamedDestinations) { + for (const pointingDest of pointingNamedDestinations) { + const name = dedupNamedDestinations.get(pointingDest) || pointingDest; + const dest = this.namedDestinations.get(name); + dest[0] = pageRef; + } + } + // No need to keep these entries as we'll set them again later. for (const key of [ "Rotate", @@ -416,10 +632,16 @@ class PDFEditor { "Resources", await this.#collectDependencies(resources, true, xref) ); - pageDict.setIfArray( - "Annots", - await this.#collectDependencies(annotations, true, xref) - ); + + if (annotations) { + const newAnnotations = await this.#collectDependencies( + annotations, + true, + xref + ); + this.#fixNamedDestinations(newAnnotations, dedupNamedDestinations); + pageDict.setIfArray("Annots", newAnnotations); + } if (this.useObjectStreams) { const newLastRef = this.newRefCount; @@ -485,7 +707,7 @@ class PDFEditor { /** * Create a name or number tree from the given map. - * @param {Array<[string, any]>} map + * @param {Array<[string|number, any]>} map * @returns {Ref} */ #makeNameNumTree(map, areNames) { @@ -543,6 +765,24 @@ class PDFEditor { rootDict.set("PageLabels", pageLabelsRef); } + #makeDestinationsTree() { + const { namedDestinations } = this; + if (namedDestinations.size === 0) { + return; + } + if (!this.namesDict) { + [this.namesRef, this.namesDict] = this.newDict; + this.rootDict.set("Names", this.namesRef); + } + this.namesDict.set( + "Dests", + this.#makeNameNumTree( + Array.from(namedDestinations.entries()), + /* areNames = */ true + ) + ); + } + /** * Create the root dictionary. * @returns {Promise} @@ -553,6 +793,7 @@ class PDFEditor { rootDict.set("Version", this.version); this.#makePageTree(); this.#makePageLabelsTree(); + this.#makeDestinationsTree(); } /** diff --git a/src/core/primitives.js b/src/core/primitives.js index 22cdd2527..854341dbd 100644 --- a/src/core/primitives.js +++ b/src/core/primitives.js @@ -439,6 +439,12 @@ class RefSetCache { yield [Ref.fromString(ref), value]; } } + + *keys() { + for (const ref of this._map.keys()) { + yield Ref.fromString(ref); + } + } } function isName(v, name) { diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 9fc4acaf5..e2bb0f1e8 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -758,3 +758,4 @@ !doc_2_3_pages.pdf !doc_3_3_pages.pdf !labelled_pages.pdf +!extract_link.pdf diff --git a/test/pdfs/extract_link.pdf b/test/pdfs/extract_link.pdf new file mode 100755 index 0000000000000000000000000000000000000000..e9e4be43296109fcc1c629dc61fa09c809c6c261 GIT binary patch literal 10213 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 P#svjpNF?@0iP--DBXrd} literal 0 HcmV?d00001 diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index 892166732..34559c4b4 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -5337,6 +5337,15 @@ small scripts as well as for`); }); describe("PDF page editing", function () { + const getPageRefs = async pdfDoc => { + const refs = []; + for (let i = 1; i <= pdfDoc.numPages; i++) { + const page = await pdfDoc.getPage(i); + refs.push(page.ref); + } + return refs; + }; + describe("Merge pdfs", function () { it("should merge three PDFs", async function () { const loadingTask = getDocument( @@ -5576,5 +5585,142 @@ small scripts as well as for`); await loadingTask.destroy(); }); }); + + describe("Named destinations", function () { + it("extract page and check destinations", async function () { + let loadingTask = getDocument(buildGetDocumentParams("issue6204.pdf")); + let pdfDoc = await loadingTask.promise; + let pagesRef = await getPageRefs(pdfDoc); + let destinations = await pdfDoc.getDestinations(); + expect(destinations).toEqual({ + "Page.1": [pagesRef[0], { name: "XYZ" }, 0, 375, null], + "Page.2": [pagesRef[1], { name: "XYZ" }, 0, 375, null], + }); + + let data = await pdfDoc.extractPages([ + { document: null }, + { document: null }, + ]); + await loadingTask.destroy(); + + loadingTask = getDocument(data); + pdfDoc = await loadingTask.promise; + + expect(pdfDoc.numPages).toEqual(4); + + pagesRef = await getPageRefs(pdfDoc); + destinations = await pdfDoc.getDestinations(); + expect(destinations).toEqual({ + "Page.1": [pagesRef[0], { name: "XYZ" }, 0, 375, null], + "Page.2": [pagesRef[1], { name: "XYZ" }, 0, 375, null], + "Page.1_p3": [pagesRef[2], { name: "XYZ" }, 0, 375, null], + "Page.2_p4": [pagesRef[3], { name: "XYZ" }, 0, 375, null], + }); + const expectedDests = ["Page.2", "Page.1", "Page.2_p4", "Page.1_p3"]; + for (let i = 1; i <= 4; i++) { + const pdfPage = await pdfDoc.getPage(i); + const annots = await pdfPage.getAnnotations(); + expect(annots.length).toEqual(1); + expect(annots[0].dest).toEqual(expectedDests[i - 1]); + } + + data = await pdfDoc.extractPages([ + { document: null }, + { document: null }, + ]); + await loadingTask.destroy(); + + loadingTask = getDocument(data); + pdfDoc = await loadingTask.promise; + + expect(pdfDoc.numPages).toEqual(8); + + pagesRef = await getPageRefs(pdfDoc); + destinations = await pdfDoc.getDestinations(); + expect(destinations).toEqual({ + "Page.1": [pagesRef[0], { name: "XYZ" }, 0, 375, null], + "Page.2": [pagesRef[1], { name: "XYZ" }, 0, 375, null], + "Page.1_p3": [pagesRef[2], { name: "XYZ" }, 0, 375, null], + "Page.2_p4": [pagesRef[3], { name: "XYZ" }, 0, 375, null], + "Page.1_p5": [pagesRef[4], { name: "XYZ" }, 0, 375, null], + "Page.2_p6": [pagesRef[5], { name: "XYZ" }, 0, 375, null], + "Page.1_p3_p7": [pagesRef[6], { name: "XYZ" }, 0, 375, null], + "Page.2_p4_p8": [pagesRef[7], { name: "XYZ" }, 0, 375, null], + }); + expectedDests.push( + "Page.2_p6", + "Page.1_p5", + "Page.2_p4_p8", + "Page.1_p3_p7" + ); + for (let i = 1; i <= 8; i++) { + const pdfPage = await pdfDoc.getPage(i); + const annots = await pdfPage.getAnnotations(); + expect(annots.length).toEqual(1); + expect(annots[0].dest).toEqual(expectedDests[i - 1]); + } + await loadingTask.destroy(); + }); + + it("extract pages and check deleted destinations", async function () { + let loadingTask = getDocument(buildGetDocumentParams("issue6204.pdf")); + let pdfDoc = await loadingTask.promise; + const data = await pdfDoc.extractPages([ + { document: null }, + { document: null, excludePages: [0] }, + ]); + await loadingTask.destroy(); + + loadingTask = getDocument(data); + pdfDoc = await loadingTask.promise; + + expect(pdfDoc.numPages).toEqual(3); + + const pagesRef = await getPageRefs(pdfDoc); + const destinations = await pdfDoc.getDestinations(); + expect(destinations).toEqual({ + "Page.1": [pagesRef[0], { name: "XYZ" }, 0, 375, null], + "Page.2": [pagesRef[1], { name: "XYZ" }, 0, 375, null], + }); + const pdfPage = await pdfDoc.getPage(3); + const annots = await pdfPage.getAnnotations(); + expect(annots.length).toEqual(0); + }); + }); + + describe("Destinations with a page reference", function () { + it("extract page and check destinations", async function () { + let loadingTask = getDocument( + buildGetDocumentParams("extract_link.pdf") + ); + let pdfDoc = await loadingTask.promise; + let pagesRef = await getPageRefs(pdfDoc); + let pdfPage = await pdfDoc.getPage(1); + let annotations = await pdfPage.getAnnotations(); + expect(annotations.length).toEqual(1); + expect(annotations[0].dest[0]).toEqual(pagesRef[1]); + + const data = await pdfDoc.extractPages([ + { document: null }, + { document: null }, + ]); + await loadingTask.destroy(); + + loadingTask = getDocument(data); + pdfDoc = await loadingTask.promise; + + expect(pdfDoc.numPages).toEqual(4); + + pagesRef = await getPageRefs(pdfDoc); + for (let i = 1; i <= 4; i += 2) { + pdfPage = await pdfDoc.getPage(i); + annotations = await pdfPage.getAnnotations(); + expect(annotations.length).toEqual(1); + expect(annotations[0].dest[0]).toEqual(pagesRef[i]); + } + + await loadingTask.destroy(); + }); + }); }); }); diff --git a/test/unit/primitives_spec.js b/test/unit/primitives_spec.js index b71df0b83..ed9993121 100644 --- a/test/unit/primitives_spec.js +++ b/test/unit/primitives_spec.js @@ -562,6 +562,12 @@ describe("primitives", function () { [ref2, obj2], ]); }); + + it("should support iteration over keys", function () { + cache.put(ref1, obj1); + cache.put(ref2, obj2); + expect([...cache.keys()]).toEqual([ref1, ref2]); + }); }); describe("isName", function () {