From 6b92ad59240f387e852e92af7167cf577c94aee3 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Mon, 25 May 2026 20:27:50 +0200 Subject: [PATCH] Allow merging several PDFs at once via the picker or drag-and-drop --- test/integration/reorganize_pages_spec.mjs | 148 +++++++++++++++++++-- web/pdf_thumbnail_viewer.js | 49 ++++--- web/viewer.html | 2 +- 3 files changed, 164 insertions(+), 35 deletions(-) diff --git a/test/integration/reorganize_pages_spec.mjs b/test/integration/reorganize_pages_spec.mjs index f5bdb6412..3622826f0 100644 --- a/test/integration/reorganize_pages_spec.mjs +++ b/test/integration/reorganize_pages_spec.mjs @@ -45,20 +45,24 @@ import path from "path"; const __dirname = import.meta.dirname; -async function createPDFDataTransfer(page, filename) { - const pdfPath = path.join(__dirname, "../pdfs", filename); - const pdfData = fs.readFileSync(pdfPath).toString("base64"); - return page.evaluateHandle( - (data, name) => { - const transfer = new DataTransfer(); - const view = Uint8Array.fromBase64(data); - const file = new File([view], name, { type: "application/pdf" }); - transfer.items.add(file); - return transfer; - }, - pdfData, - filename - ); +async function createPDFDataTransfer(page, ...filenames) { + const pdfData = filenames.map(filename => { + const pdfPath = path.join(__dirname, "../pdfs", filename); + return { + data: fs.readFileSync(pdfPath).toString("base64"), + filename, + }; + }); + return page.evaluateHandle(data => { + const transfer = new DataTransfer(); + for (const { data: base64, filename } of data) { + const view = Uint8Array.fromBase64(base64); + transfer.items.add( + new File([view], filename, { type: "application/pdf" }) + ); + } + return transfer; + }, pdfData); } async function waitForThumbnailVisible(page, pageNums) { @@ -3243,6 +3247,62 @@ describe("Reorganize Pages View", () => { }) ); }); + + it("should merge several PDFs selected at once", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForThumbnailVisible(page, 1); + + // Navigate to page 2 so the merged PDFs are inserted after it. + await page.evaluate(() => { + window.PDFViewerApplication.page = 2; + }); + await page.waitForFunction( + () => window.PDFViewerApplication.page === 2 + ); + await waitAndClick(page, getThumbnailSelector(2)); + + const handleMerged = await createPromise(page, resolve => { + window.PDFViewerApplication.eventBus.on( + "thumbnailsloaded", + resolve, + { once: true } + ); + }); + + const picker = await page.$("#viewsManagerAddFilePicker"); + const pdfPath = path.join( + __dirname, + "../pdfs/three_pages_with_number.pdf" + ); + // Upload two PDFs in a single picker selection. + await picker.uploadFile(pdfPath, pdfPath); + await awaitPromise(handleMerged); + + // Original 3 pages + 2 * 3 merged pages = 9 pages total. + await page.waitForFunction( + () => parseInt(document.getElementById("pageNumber").max, 10) === 9 + ); + + // Focus must move to the first newly inserted page (page 3, since + // we merged after page 2). + await page.waitForFunction( + () => window.PDFViewerApplication.page === 3 + ); + + // Pages 1–2 of the original, then both merged copies (in selection + // order), then page 3 of the original shifted to the end. + await waitForHavingContents(page, [1, 2, 1, 2, 3, 1, 2, 3, 3]); + + // All 6 newly inserted pages must be selected. + await waitForTextToBe( + page, + "#viewsManagerStatusActionLabel", + `${FSI}6${PDI} selected` + ); + }) + ); + }); }); describe("Drag-and-drop PDF merge", () => { @@ -3375,5 +3435,65 @@ describe("Reorganize Pages View", () => { }) ); }); + + it("should merge several dropped PDFs at once", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForThumbnailVisible(page, [1, 2, 3]); + + const dataTransfer = await createPDFDataTransfer( + page, + "three_pages_with_number.pdf", + "three_pages_with_number.pdf" + ); + + const handleMerged = await createPromise(page, resolve => { + window.PDFViewerApplication.eventBus.on( + "thumbnailsloaded", + resolve, + { once: true } + ); + }); + const filesLength = await page.evaluate( + (transfer, selector) => { + const target = document.querySelector(selector); + const { left, top, width, height } = + target.getBoundingClientRect(); + const clientX = left + width / 4; + const clientY = top + (3 * height) / 4; + for (const type of ["dragenter", "dragover", "drop"]) { + target.dispatchEvent( + new DragEvent(type, { + bubbles: true, + cancelable: true, + clientX, + clientY, + dataTransfer: transfer, + }) + ); + } + return transfer.files.length; + }, + dataTransfer, + getThumbnailSelector(2) + ); + expect(filesLength).withContext(`In ${browserName}`).toBe(2); + await awaitPromise(handleMerged); + + await page.waitForFunction( + () => parseInt(document.getElementById("pageNumber").max, 10) === 9 + ); + await page.waitForFunction( + () => window.PDFViewerApplication.page === 3 + ); + await waitForHavingContents(page, [1, 2, 1, 2, 3, 1, 2, 3, 3]); + await waitForTextToBe( + page, + "#viewsManagerStatusActionLabel", + `${FSI}6${PDI} selected` + ); + }) + ); + }); }); }); diff --git a/web/pdf_thumbnail_viewer.js b/web/pdf_thumbnail_viewer.js index 443c199be..9e0c3e18d 100644 --- a/web/pdf_thumbnail_viewer.js +++ b/web/pdf_thumbnail_viewer.js @@ -79,7 +79,7 @@ const SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT = 15; * @property {Object} [waitingBar] - The waiting bar elements shown during * long-running operations. * @property {Object} [addFileComponent] - The file picker and button used to - * add a PDF file to merge with the current one. + * add one or more PDF files to merge with the current one. */ /** @@ -321,9 +321,9 @@ class PDFThumbnailViewer { if (this.#enableMerge && addFileComponent) { const { picker, button } = addFileComponent; picker.addEventListener("change", () => { - const file = picker.files?.[0]; - if (file) { - this.#mergeFile(file, this._currentPageNumber - 1); + const files = Array.from(picker.files ?? []); + if (files.length) { + this.#mergeFiles(files, this._currentPageNumber - 1); } }); button.addEventListener("click", () => { @@ -351,23 +351,32 @@ class PDFThumbnailViewer { this.renderingQueue.renderHighestPriority(); } - async #mergeFile(file, insertAfter) { - if (file.type !== "application/pdf") { - const magic = await file.slice(0, 5).text(); - if (magic !== "%PDF-") { - return; - } - } + async #mergeFiles(files, insertAfter) { this.#toggleBar("waiting", "pdfjs-views-manager-waiting-for-file"); - const buffer = await file.bytes(); + const buffers = []; + for (const file of files) { + if (file.type !== "application/pdf") { + const magic = await file.slice(0, 5).text(); + if (magic !== "%PDF-") { + continue; + } + } + buffers.push(await file.bytes()); + } + if (buffers.length === 0) { + this.#toggleBar("status"); + return; + } const pagesCount = this.#pagesMapper.pagesNumber; const data = this.hasStructuralChanges() ? this.getStructuralChanges() : [{ document: null }]; - data.push({ - document: buffer, - insertAfter, - }); + for (const buffer of buffers) { + data.push({ + document: buffer, + insertAfter, + }); + } this.eventBus._on( "pagesloaded", () => { @@ -1579,7 +1588,7 @@ class PDFThumbnailViewer { // the only available signal. Matches the existing global drop handler // in app.js. Files with no MIME (e.g. some macOS sources) are rejected // here to keep the "copy" cursor honest; if needed, drop-time magic-byte - // validation in #mergeFile would still catch a permissive variant. + // validation in #mergeFiles would still catch a permissive variant. for (const item of dataTransfer.items) { if (item.kind === "file" && item.type === "application/pdf") { return true; @@ -1668,7 +1677,7 @@ class PDFThumbnailViewer { } e.preventDefault(); e.stopPropagation(); - const file = e.dataTransfer.files?.[0]; + const files = Array.from(e.dataTransfer.files ?? []); // If no dragover ever ran (e.g. instant drop), compute the index from // the drop event itself so we don't fall through to a stale fallback. if (isNaN(this.#lastDraggedOverIndex) && this.#thumbnailsPositions) { @@ -1682,8 +1691,8 @@ class PDFThumbnailViewer { ? -1 : this.#lastDraggedOverIndex; this.#endExternalFileDrag(); - if (file) { - this.#mergeFile(file, insertAfter); + if (files.length) { + this.#mergeFiles(files, insertAfter); } }, { signal } diff --git a/web/viewer.html b/web/viewer.html index e4d24d7e4..6ac7f7cc7 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -191,7 +191,7 @@ See https://github.com/adobe-type-tools/cmap-resources hidden="true" > - +