Allow merging several PDFs at once via the picker or drag-and-drop

This commit is contained in:
Calixte Denizet 2026-05-25 20:27:50 +02:00
parent e7661983f7
commit 6b92ad5924
3 changed files with 164 additions and 35 deletions

View File

@ -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 12 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`
);
})
);
});
});
});

View File

@ -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 }

View File

@ -191,7 +191,7 @@ See https://github.com/adobe-type-tools/cmap-resources
hidden="true"
>
<span data-l10n-id="pdfjs-views-manager-add-file-button-label"></span>
<input id="viewsManagerAddFilePicker" type="file" accept="application/pdf" />
<input id="viewsManagerAddFilePicker" type="file" accept="application/pdf" multiple />
</button>
<button
id="viewsManagerCurrentOutlineButton"