From a474e81b8a66e639c5fecbed2731f7edd98aca1e Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Mon, 2 Mar 2026 19:49:03 +0100 Subject: [PATCH] Add a way to extract some pages from a pdf (bug 2019682) The user has to select some pages and then click on the "Save As" menu item in the Manage menu. If they modify the structure of the pdf (deleted, moved, copied pages), they have to use the usual save button. --- src/display/display_utils.js | 18 ++++- test/integration/reorganize_pages_spec.mjs | 77 +++++++++++++++++----- web/app.js | 59 ++++++++++------- web/pdf_thumbnail_viewer.js | 28 +++++--- 4 files changed, 131 insertions(+), 51 deletions(-) diff --git a/src/display/display_utils.js b/src/display/display_utils.js index 0950b806e..5abf8c8dc 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -17,6 +17,7 @@ import { BaseException, DrawOPS, FeatureTest, + makeArr, MathClamp, shadow, stripPath, @@ -1360,9 +1361,7 @@ class PagesMapper { * Gets the current page mapping suitable for saving. * @returns {Object} An object containing the page indices. */ - getPageMappingForSaving() { - const idToPageNumber = this.#idToPageNumber; - + getPageMappingForSaving(idToPageNumber = this.#idToPageNumber) { // idToPageNumber maps used 1-based IDs to 1-based page numbers. // For example if the final pdf contains page 3 twice and they are moved at // page 1 and 4, then it contains: @@ -1413,6 +1412,19 @@ class PagesMapper { return extractParams; } + extractPages(extractedPageNumbers) { + extractedPageNumbers = Array.from(extractedPageNumbers).sort( + (a, b) => a - b + ); + const usedIds = new Map(); + for (let i = 0, ii = extractedPageNumbers.length; i < ii; i++) { + const id = this.getPageId(extractedPageNumbers[i]); + const usedPageNumbers = usedIds.getOrInsertComputed(id, makeArr); + usedPageNumbers.push(i + 1); + } + return this.getPageMappingForSaving(usedIds); + } + /** * Gets the previous page number for a given page number. * @param {number} pageNumber diff --git a/test/integration/reorganize_pages_spec.mjs b/test/integration/reorganize_pages_spec.mjs index 8d7572e77..6c664cd74 100644 --- a/test/integration/reorganize_pages_spec.mjs +++ b/test/integration/reorganize_pages_spec.mjs @@ -614,24 +614,14 @@ describe("Reorganize Pages View", () => { 10 ); - const handleSaveAs = await createPromise(page, resolve => { - window.PDFViewerApplication.eventBus.on( - "savepageseditedpdf", - ({ data }) => { - resolve(Array.from(data[0].pageIndices)); - }, - { - once: true, - } - ); + const handleSave = await createPromise(page, resolve => { + window.PDFViewerApplication.onSavePages = async ({ data }) => { + resolve(Array.from(data[0].pageIndices)); + }; }); - await page.click("#viewsManagerStatusActionButton"); - await page.waitForSelector("#viewsManagerStatusActionSaveAs", { - visible: true, - }); - await page.click("#viewsManagerStatusActionSaveAs"); - const pageIndices = await awaitPromise(handleSaveAs); + await waitAndClick(page, "#downloadButton"); + const pageIndices = await awaitPromise(handleSave); expect(pageIndices) .withContext(`In ${browserName}`) .toEqual([ @@ -1041,4 +1031,59 @@ describe("Reorganize Pages View", () => { ); }); }); + + describe("Extract some pages from a pdf", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "page_with_number.pdf", + "#viewsManagerToggleButton", + "page-fit", + null, + { enableSplitMerge: true } + ); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("should check that the pages are correctly extracted", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForThumbnailVisible(page, 1); + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(1)}) input` + ); + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(3)}) input` + ); + + const handleSaveAs = await createPromise(page, resolve => { + window.PDFViewerApplication.eventBus.on( + "saveextractedpages", + ({ data }) => { + resolve(data); + }, + { + once: true, + } + ); + }); + + await page.click("#viewsManagerStatusActionButton"); + await waitAndClick(page, "#viewsManagerStatusActionSaveAs"); + const pagesData = await awaitPromise(handleSaveAs); + expect(pagesData) + .withContext(`In ${browserName}`) + .toEqual([ + { document: null, pageIndices: [0, 1], includePages: [0, 2] }, + ]); + }) + ); + }); + }); }); diff --git a/web/app.js b/web/app.js index a22ac8313..8d77e8fb0 100644 --- a/web/app.js +++ b/web/app.js @@ -1072,9 +1072,9 @@ const PDFViewerApplication = { // Embedded PDF viewers should not be changing their parent page's title. return; } - const editorIndicator = - this._hasAnnotationEditors && !this.pdfRenderingQueue.printing; - document.title = `${editorIndicator ? "* " : ""}${title}`; + const hasChangesIndicator = + this._hasChanges() && !this.pdfRenderingQueue.printing; + document.title = `${hasChangesIndicator ? "* " : ""}${title}`; }, get _docFilename() { @@ -1129,12 +1129,12 @@ const PDFViewerApplication = { if ( (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC && !TESTING")) && - this.pdfDocument?.annotationStorage.size > 0 && + this._hasChanges() && this._annotationStorageModified ) { try { // Trigger saving, to prevent data loss in forms; see issue 12257. - await this.save(); + await this.downloadOrSave(); } catch { // Ignoring errors, to ensure that document closing won't break. } @@ -1315,9 +1315,15 @@ const PDFViewerApplication = { // a message and change PdfjsChild.sys.mjs to take it into account. const { classList } = this.appConfig.appContainer; classList.add("wait"); - await (this.pdfDocument?.annotationStorage.size > 0 - ? this.save() - : this.download()); + + const structuralChanges = this.pdfThumbnailViewer?.getStructuralChanges(); + if (structuralChanges) { + await this.onSavePages({ data: structuralChanges }); + } else { + await (this.pdfDocument?.annotationStorage.size > 0 + ? this.save() + : this.download()); + } classList.remove("wait"); }, @@ -1862,6 +1868,13 @@ const PDFViewerApplication = { } }, + _hasChanges() { + return ( + this.pdfDocument?.annotationStorage.size > 0 || + this.pdfThumbnailViewer?.hasStructuralChanges() + ); + }, + /** * @private */ @@ -1872,15 +1885,11 @@ const PDFViewerApplication = { const { annotationStorage } = pdfDocument; annotationStorage.onSetModified = () => { - window.addEventListener("beforeunload", beforeUnload); - if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { this._annotationStorageModified = true; } }; annotationStorage.onResetModified = () => { - window.removeEventListener("beforeunload", beforeUnload); - if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { delete this._annotationStorageModified; } @@ -2185,11 +2194,7 @@ const PDFViewerApplication = { ); } eventBus._on("pagesedited", this.onPagesEdited.bind(this), opts); - eventBus._on( - "savepageseditedpdf", - this.onSavePagesEditedPDF.bind(this), - opts - ); + eventBus._on("saveextractedpages", this.onSavePages.bind(this), opts); }, bindWindowEvents() { @@ -2270,6 +2275,9 @@ const PDFViewerApplication = { }, { signal } ); + window.addEventListener("beforeunload", onBeforeUnload.bind(this), { + signal, + }); if ( (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) && @@ -2368,7 +2376,7 @@ const PDFViewerApplication = { this.pdfViewer.onPagesEdited(data); }, - async onSavePagesEditedPDF({ data: extractParams }) { + async onSavePages({ data: extractParams }) { if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) { return; } @@ -2876,6 +2884,15 @@ function closeEditorUndoBar(evt) { } } +function onBeforeUnload(evt) { + if (this._hasChanges()) { + evt.preventDefault(); + evt.returnValue = ""; + return false; + } + return true; +} + function onClick(evt) { closeSecondaryToolbar.call(this, evt); closeEditorUndoBar.call(this, evt); @@ -3230,10 +3247,4 @@ function onKeyDown(evt) { } } -function beforeUnload(evt) { - evt.preventDefault(); - evt.returnValue = ""; - return false; -} - export { PDFViewerApplication }; diff --git a/web/pdf_thumbnail_viewer.js b/web/pdf_thumbnail_viewer.js index 621065d45..3b1c3912d 100644 --- a/web/pdf_thumbnail_viewer.js +++ b/web/pdf_thumbnail_viewer.js @@ -175,12 +175,7 @@ class PDFThumbnailViewer { this._manageMenu = new Menu(menu, button, [copy, cut, del, saveAs]); this.#manageSaveAsButton = saveAs; - saveAs.addEventListener("click", () => { - this.eventBus.dispatch("savepageseditedpdf", { - source: this, - data: this.#pagesMapper.getPageMappingForSaving(), - }); - }); + saveAs.addEventListener("click", this.#saveExtractedPages.bind(this)); this.#manageDeleteButton = del; del.addEventListener("click", this.#deletePages.bind(this)); this.#manageCopyButton = copy; @@ -432,6 +427,14 @@ class PDFThumbnailViewer { return false; } + hasStructuralChanges() { + return this.#pagesMapper?.hasBeenAltered() || false; + } + + getStructuralChanges() { + return this.#pagesMapper?.getPageMappingForSaving() || null; + } + static #getScaleFactor(image) { return (PDFThumbnailViewer.#draggingScaleFactor ||= parseFloat( getComputedStyle(image).getPropertyValue("--thumbnail-dragging-scale") @@ -617,6 +620,15 @@ class PDFThumbnailViewer { this.#selectedPages.clear(); } + #saveExtractedPages() { + this.eventBus.dispatch("saveextractedpages", { + source: this, + data: this.#pagesMapper.extractPages(this.#selectedPages), + }); + this.#clearSelection(); + this.#toggleMenuEntries(false); + } + #copyPages(clearSelection = true) { const pageNumbersToCopy = (this.#copiedPageNumbers = Uint32Array.from( this.#selectedPages @@ -710,8 +722,8 @@ class PDFThumbnailViewer { } #updateMenuEntries() { - this.#manageSaveAsButton.disabled = !this.#pagesMapper.hasBeenAltered(); - this.#manageDeleteButton.disabled = + this.#manageSaveAsButton.disabled = + this.#manageDeleteButton.disabled = this.#manageCopyButton.disabled = this.#manageCutButton.disabled = !this.#selectedPages?.size;