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.
This commit is contained in:
Calixte Denizet 2026-03-02 19:49:03 +01:00
parent 973add845f
commit a474e81b8a
No known key found for this signature in database
GPG Key ID: 0C5442631EE0691F
4 changed files with 131 additions and 51 deletions

View File

@ -17,6 +17,7 @@ import {
BaseException, BaseException,
DrawOPS, DrawOPS,
FeatureTest, FeatureTest,
makeArr,
MathClamp, MathClamp,
shadow, shadow,
stripPath, stripPath,
@ -1360,9 +1361,7 @@ class PagesMapper {
* Gets the current page mapping suitable for saving. * Gets the current page mapping suitable for saving.
* @returns {Object} An object containing the page indices. * @returns {Object} An object containing the page indices.
*/ */
getPageMappingForSaving() { getPageMappingForSaving(idToPageNumber = this.#idToPageNumber) {
const idToPageNumber = this.#idToPageNumber;
// idToPageNumber maps used 1-based IDs to 1-based page numbers. // 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 // For example if the final pdf contains page 3 twice and they are moved at
// page 1 and 4, then it contains: // page 1 and 4, then it contains:
@ -1413,6 +1412,19 @@ class PagesMapper {
return extractParams; 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. * Gets the previous page number for a given page number.
* @param {number} pageNumber * @param {number} pageNumber

View File

@ -614,24 +614,14 @@ describe("Reorganize Pages View", () => {
10 10
); );
const handleSaveAs = await createPromise(page, resolve => { const handleSave = await createPromise(page, resolve => {
window.PDFViewerApplication.eventBus.on( window.PDFViewerApplication.onSavePages = async ({ data }) => {
"savepageseditedpdf", resolve(Array.from(data[0].pageIndices));
({ data }) => { };
resolve(Array.from(data[0].pageIndices));
},
{
once: true,
}
);
}); });
await page.click("#viewsManagerStatusActionButton"); await waitAndClick(page, "#downloadButton");
await page.waitForSelector("#viewsManagerStatusActionSaveAs", { const pageIndices = await awaitPromise(handleSave);
visible: true,
});
await page.click("#viewsManagerStatusActionSaveAs");
const pageIndices = await awaitPromise(handleSaveAs);
expect(pageIndices) expect(pageIndices)
.withContext(`In ${browserName}`) .withContext(`In ${browserName}`)
.toEqual([ .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] },
]);
})
);
});
});
}); });

View File

@ -1072,9 +1072,9 @@ const PDFViewerApplication = {
// Embedded PDF viewers should not be changing their parent page's title. // Embedded PDF viewers should not be changing their parent page's title.
return; return;
} }
const editorIndicator = const hasChangesIndicator =
this._hasAnnotationEditors && !this.pdfRenderingQueue.printing; this._hasChanges() && !this.pdfRenderingQueue.printing;
document.title = `${editorIndicator ? "* " : ""}${title}`; document.title = `${hasChangesIndicator ? "* " : ""}${title}`;
}, },
get _docFilename() { get _docFilename() {
@ -1129,12 +1129,12 @@ const PDFViewerApplication = {
if ( if (
(typeof PDFJSDev === "undefined" || (typeof PDFJSDev === "undefined" ||
PDFJSDev.test("GENERIC && !TESTING")) && PDFJSDev.test("GENERIC && !TESTING")) &&
this.pdfDocument?.annotationStorage.size > 0 && this._hasChanges() &&
this._annotationStorageModified this._annotationStorageModified
) { ) {
try { try {
// Trigger saving, to prevent data loss in forms; see issue 12257. // Trigger saving, to prevent data loss in forms; see issue 12257.
await this.save(); await this.downloadOrSave();
} catch { } catch {
// Ignoring errors, to ensure that document closing won't break. // 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. // a message and change PdfjsChild.sys.mjs to take it into account.
const { classList } = this.appConfig.appContainer; const { classList } = this.appConfig.appContainer;
classList.add("wait"); classList.add("wait");
await (this.pdfDocument?.annotationStorage.size > 0
? this.save() const structuralChanges = this.pdfThumbnailViewer?.getStructuralChanges();
: this.download()); if (structuralChanges) {
await this.onSavePages({ data: structuralChanges });
} else {
await (this.pdfDocument?.annotationStorage.size > 0
? this.save()
: this.download());
}
classList.remove("wait"); classList.remove("wait");
}, },
@ -1862,6 +1868,13 @@ const PDFViewerApplication = {
} }
}, },
_hasChanges() {
return (
this.pdfDocument?.annotationStorage.size > 0 ||
this.pdfThumbnailViewer?.hasStructuralChanges()
);
},
/** /**
* @private * @private
*/ */
@ -1872,15 +1885,11 @@ const PDFViewerApplication = {
const { annotationStorage } = pdfDocument; const { annotationStorage } = pdfDocument;
annotationStorage.onSetModified = () => { annotationStorage.onSetModified = () => {
window.addEventListener("beforeunload", beforeUnload);
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
this._annotationStorageModified = true; this._annotationStorageModified = true;
} }
}; };
annotationStorage.onResetModified = () => { annotationStorage.onResetModified = () => {
window.removeEventListener("beforeunload", beforeUnload);
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
delete this._annotationStorageModified; delete this._annotationStorageModified;
} }
@ -2185,11 +2194,7 @@ const PDFViewerApplication = {
); );
} }
eventBus._on("pagesedited", this.onPagesEdited.bind(this), opts); eventBus._on("pagesedited", this.onPagesEdited.bind(this), opts);
eventBus._on( eventBus._on("saveextractedpages", this.onSavePages.bind(this), opts);
"savepageseditedpdf",
this.onSavePagesEditedPDF.bind(this),
opts
);
}, },
bindWindowEvents() { bindWindowEvents() {
@ -2270,6 +2275,9 @@ const PDFViewerApplication = {
}, },
{ signal } { signal }
); );
window.addEventListener("beforeunload", onBeforeUnload.bind(this), {
signal,
});
if ( if (
(typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) && (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) &&
@ -2368,7 +2376,7 @@ const PDFViewerApplication = {
this.pdfViewer.onPagesEdited(data); this.pdfViewer.onPagesEdited(data);
}, },
async onSavePagesEditedPDF({ data: extractParams }) { async onSavePages({ data: extractParams }) {
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) { if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) {
return; 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) { function onClick(evt) {
closeSecondaryToolbar.call(this, evt); closeSecondaryToolbar.call(this, evt);
closeEditorUndoBar.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 }; export { PDFViewerApplication };

View File

@ -175,12 +175,7 @@ class PDFThumbnailViewer {
this._manageMenu = new Menu(menu, button, [copy, cut, del, saveAs]); this._manageMenu = new Menu(menu, button, [copy, cut, del, saveAs]);
this.#manageSaveAsButton = saveAs; this.#manageSaveAsButton = saveAs;
saveAs.addEventListener("click", () => { saveAs.addEventListener("click", this.#saveExtractedPages.bind(this));
this.eventBus.dispatch("savepageseditedpdf", {
source: this,
data: this.#pagesMapper.getPageMappingForSaving(),
});
});
this.#manageDeleteButton = del; this.#manageDeleteButton = del;
del.addEventListener("click", this.#deletePages.bind(this)); del.addEventListener("click", this.#deletePages.bind(this));
this.#manageCopyButton = copy; this.#manageCopyButton = copy;
@ -432,6 +427,14 @@ class PDFThumbnailViewer {
return false; return false;
} }
hasStructuralChanges() {
return this.#pagesMapper?.hasBeenAltered() || false;
}
getStructuralChanges() {
return this.#pagesMapper?.getPageMappingForSaving() || null;
}
static #getScaleFactor(image) { static #getScaleFactor(image) {
return (PDFThumbnailViewer.#draggingScaleFactor ||= parseFloat( return (PDFThumbnailViewer.#draggingScaleFactor ||= parseFloat(
getComputedStyle(image).getPropertyValue("--thumbnail-dragging-scale") getComputedStyle(image).getPropertyValue("--thumbnail-dragging-scale")
@ -617,6 +620,15 @@ class PDFThumbnailViewer {
this.#selectedPages.clear(); this.#selectedPages.clear();
} }
#saveExtractedPages() {
this.eventBus.dispatch("saveextractedpages", {
source: this,
data: this.#pagesMapper.extractPages(this.#selectedPages),
});
this.#clearSelection();
this.#toggleMenuEntries(false);
}
#copyPages(clearSelection = true) { #copyPages(clearSelection = true) {
const pageNumbersToCopy = (this.#copiedPageNumbers = Uint32Array.from( const pageNumbersToCopy = (this.#copiedPageNumbers = Uint32Array.from(
this.#selectedPages this.#selectedPages
@ -710,8 +722,8 @@ class PDFThumbnailViewer {
} }
#updateMenuEntries() { #updateMenuEntries() {
this.#manageSaveAsButton.disabled = !this.#pagesMapper.hasBeenAltered(); this.#manageSaveAsButton.disabled =
this.#manageDeleteButton.disabled = this.#manageDeleteButton.disabled =
this.#manageCopyButton.disabled = this.#manageCopyButton.disabled =
this.#manageCutButton.disabled = this.#manageCutButton.disabled =
!this.#selectedPages?.size; !this.#selectedPages?.size;