From 8613b4405732cc21ee0a482ea95f89cbc25cf6a1 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Tue, 2 Jun 2026 22:32:51 +0200 Subject: [PATCH] Make the viewer's handling of scripting commands more robust --- src/scripting_api/event.js | 5 + test/integration/jasmine-boot.js | 1 + test/integration/scripting_commands_spec.mjs | 368 +++++++++++++++++++ web/app.js | 58 ++- web/generic_scripting.js | 7 +- web/pdf_scripting_manager.js | 80 +++- 6 files changed, 491 insertions(+), 28 deletions(-) create mode 100644 test/integration/scripting_commands_spec.mjs diff --git a/src/scripting_api/event.js b/src/scripting_api/event.js index f84d76688934a..b24b54bb0ecf6 100644 --- a/src/scripting_api/event.js +++ b/src/scripting_api/event.js @@ -100,7 +100,9 @@ class EventDispatcher { } if (id === "doc") { const eventName = event.name; + let nonce; if (eventName === "Open") { + nonce = baseEvent.nonce; // The user has decided to open this pdf, hence we enable // userActivation. this.userActivation(); @@ -119,6 +121,9 @@ class EventDispatcher { this.userActivation(); } this._document.obj._dispatchDocEvent(event.name); + if (nonce) { + this._externalCall("send", [{ command: "OpenFinished", nonce }]); + } } else if (id === "page") { this.userActivation(); this._document.obj._dispatchPageEvent( diff --git a/test/integration/jasmine-boot.js b/test/integration/jasmine-boot.js index f61cab215be1e..48590ce0ccf31 100644 --- a/test/integration/jasmine-boot.js +++ b/test/integration/jasmine-boot.js @@ -40,6 +40,7 @@ async function runTests(results) { "ink_editor_spec.mjs", "presentation_mode_spec.mjs", "reorganize_pages_spec.mjs", + "scripting_commands_spec.mjs", "scripting_spec.mjs", "signature_editor_spec.mjs", "simple_viewer_spec.mjs", diff --git a/test/integration/scripting_commands_spec.mjs b/test/integration/scripting_commands_spec.mjs new file mode 100644 index 0000000000000..7bc93ee424188 --- /dev/null +++ b/test/integration/scripting_commands_spec.mjs @@ -0,0 +1,368 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + closePages, + getSelector, + loadAndWait, + waitForSandboxTrip, +} from "./test_utils.mjs"; + +async function waitForScripting(page) { + await page.waitForFunction( + "window.PDFViewerApplication.scriptingReady === true" + ); +} + +describe("Scripting commands", () => { + describe("in doc_actions.pdf", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait("doc_actions.pdf", getSelector("47R")); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("coalesces repeated SaveAs requests into a single download", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForScripting(page); + await waitForSandboxTrip(page); + + const result = await page.evaluate(async () => { + const app = window.PDFViewerApplication; + const original = app._downloadOrSave; + let underlying = 0; + let resolveDownload; + app._downloadOrSave = function () { + underlying++; + return new Promise(resolve => { + resolveDownload = resolve; + }); + }; + try { + const promises = []; + for (let i = 0; i < 50; i++) { + promises.push(app.downloadOrSave()); + } + const sharedPromise = promises.every(p => p === promises[0]); + const pending = app._downloadOrSavePromise !== null; + resolveDownload(); + await Promise.all(promises); + const cleared = app._downloadOrSavePromise === null; + return { underlying, sharedPromise, pending, cleared }; + } finally { + app._downloadOrSave = original; + } + }); + + expect(result.underlying).withContext(`In ${browserName}`).toEqual(1); + expect(result.sharedPromise) + .withContext(`In ${browserName}`) + .toBeTrue(); + expect(result.pending).withContext(`In ${browserName}`).toBeTrue(); + expect(result.cleared).withContext(`In ${browserName}`).toBeTrue(); + }) + ); + }); + + it("does not run SaveAs without user activation", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForScripting(page); + + const result = await page.evaluate(async () => { + const app = window.PDFViewerApplication; + const { eventBus, downloadManager } = app; + const originalUserActivationDescriptor = + Object.getOwnPropertyDescriptor(navigator, "userActivation"); + Object.defineProperty(navigator, "userActivation", { + configurable: true, + get: () => ({ isActive: false, hasBeenActive: true }), + }); + const originalDownload = downloadManager.download; + downloadManager.download = () => {}; + let downloads = 0; + const countDownload = () => { + downloads++; + }; + eventBus.on("download", countDownload); + const dispatchSaveAs = () => { + eventBus.dispatch("updatefromsandbox", { + source: window, + detail: { command: "SaveAs" }, + }); + }; + try { + const inactive = navigator.userActivation.isActive === false; + for (let i = 0; i < 20; i++) { + dispatchSaveAs(); + } + const inactiveDownloads = downloads; + + Object.defineProperty(navigator, "userActivation", { + configurable: true, + get: () => undefined, + }); + const unavailable = navigator.userActivation === undefined; + dispatchSaveAs(); + const unavailableDownloads = downloads - inactiveDownloads; + + return { + inactive, + inactiveDownloads, + unavailable, + unavailableDownloads, + }; + } finally { + eventBus.off("download", countDownload); + downloadManager.download = originalDownload; + if (originalUserActivationDescriptor) { + Object.defineProperty( + navigator, + "userActivation", + originalUserActivationDescriptor + ); + } else { + delete navigator.userActivation; + } + } + }); + + expect(result.inactive).withContext(`In ${browserName}`).toBeTrue(); + expect(result.inactiveDownloads) + .withContext(`In ${browserName}`) + .toEqual(0); + expect(result.unavailable) + .withContext(`In ${browserName}`) + .toBeTrue(); + expect(result.unavailableDownloads) + .withContext(`In ${browserName}`) + .toEqual(0); + }) + ); + }); + + it("starts a single print for each batch of repeated print requests", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForScripting(page); + await waitForSandboxTrip(page); + + const result = await page.evaluate(async () => { + const app = window.PDFViewerApplication; + const { eventBus } = app; + const originalUserActivationDescriptor = + Object.getOwnPropertyDescriptor(navigator, "userActivation"); + Object.defineProperty(navigator, "userActivation", { + configurable: true, + get: () => ({ isActive: true, hasBeenActive: true }), + }); + const realPrint = window.print; + let count = 0; + window.print = () => {}; + const waiters = new Set(); + const resolveWaiters = () => { + for (const waiter of waiters) { + if (count >= waiter.target) { + waiters.delete(waiter); + waiter.resolve(); + } + } + }; + const waitForPrintCount = target => { + if (count >= target) { + return Promise.resolve(); + } + return new Promise(resolve => { + waiters.add({ target, resolve }); + }); + }; + const countPrint = () => { + count++; + resolveWaiters(); + }; + eventBus.on("print", countPrint); + const dispatchPrint = () => { + eventBus.dispatch("updatefromsandbox", { + source: window, + detail: { command: "print" }, + }); + }; + try { + await app.pdfViewer.pagesPromise; + eventBus.dispatch("afterprint", { source: window }); + for (let i = 0; i < 20; i++) { + dispatchPrint(); + } + await waitForPrintCount(1); + const firstBatch = count; + eventBus.dispatch("afterprint", { source: window }); + for (let i = 0; i < 20; i++) { + dispatchPrint(); + } + await waitForPrintCount(2); + return { count, firstBatch }; + } finally { + eventBus.off("print", countPrint); + window.print = realPrint; + if (originalUserActivationDescriptor) { + Object.defineProperty( + navigator, + "userActivation", + originalUserActivationDescriptor + ); + } else { + delete navigator.userActivation; + } + } + }); + + expect(result.firstBatch).withContext(`In ${browserName}`).toEqual(1); + expect(result.count).withContext(`In ${browserName}`).toEqual(2); + }) + ); + }); + + it("does not run print without user activation", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForScripting(page); + await waitForSandboxTrip(page); + + const result = await page.evaluate(async () => { + const app = window.PDFViewerApplication; + const { eventBus } = app; + const originalUserActivationDescriptor = + Object.getOwnPropertyDescriptor(navigator, "userActivation"); + Object.defineProperty(navigator, "userActivation", { + configurable: true, + get: () => ({ isActive: false, hasBeenActive: true }), + }); + const realPrint = window.print; + window.print = () => {}; + let prints = 0; + const countPrint = () => { + prints++; + }; + eventBus.on("print", countPrint); + const dispatchPrint = () => { + eventBus.dispatch("updatefromsandbox", { + source: window, + detail: { command: "print" }, + }); + }; + try { + await app.pdfViewer.pagesPromise; + const inactive = navigator.userActivation.isActive === false; + for (let i = 0; i < 20; i++) { + dispatchPrint(); + } + const inactivePrints = prints; + + Object.defineProperty(navigator, "userActivation", { + configurable: true, + get: () => undefined, + }); + const unavailable = navigator.userActivation === undefined; + dispatchPrint(); + const unavailablePrints = prints - inactivePrints; + + return { + inactive, + inactivePrints, + unavailable, + unavailablePrints, + }; + } finally { + eventBus.off("print", countPrint); + window.print = realPrint; + if (originalUserActivationDescriptor) { + Object.defineProperty( + navigator, + "userActivation", + originalUserActivationDescriptor + ); + } else { + delete navigator.userActivation; + } + } + }); + + expect(result.inactive).withContext(`In ${browserName}`).toBeTrue(); + expect(result.inactivePrints) + .withContext(`In ${browserName}`) + .toEqual(0); + expect(result.unavailable) + .withContext(`In ${browserName}`) + .toBeTrue(); + expect(result.unavailablePrints) + .withContext(`In ${browserName}`) + .toEqual(0); + }) + ); + }); + + it("only applies field updates for known form fields", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForScripting(page); + + const result = await page.evaluate(async () => { + const app = window.PDFViewerApplication; + const { eventBus } = app; + const knownId = document + .querySelector("[data-element-id]") + ?.getAttribute("data-element-id"); + let delivered = 0; + const handler = () => { + delivered++; + }; + document.addEventListener("updatefromsandbox", handler, true); + try { + const before = delivered; + if (knownId) { + eventBus.dispatch("updatefromsandbox", { + source: window, + detail: { id: knownId, value: "TEST123" }, + }); + } + const knownDelivered = delivered - before; + eventBus.dispatch("updatefromsandbox", { + source: window, + detail: { id: "unknown-field-id", value: "ignored" }, + }); + const unknownDelivered = delivered - before - knownDelivered; + return { knownId, knownDelivered, unknownDelivered }; + } finally { + document.removeEventListener("updatefromsandbox", handler, true); + } + }); + + expect(result.knownId).withContext(`In ${browserName}`).toBeTruthy(); + expect(result.knownDelivered) + .withContext(`In ${browserName}`) + .toBeGreaterThanOrEqual(1); + expect(result.unknownDelivered) + .withContext(`In ${browserName}`) + .toEqual(0); + }) + ); + }); + }); +}); diff --git a/web/app.js b/web/app.js index 52afe2be8b7fe..01e225c4dcca6 100644 --- a/web/app.js +++ b/web/app.js @@ -180,6 +180,7 @@ const PDFViewerApplication = { _contentDispositionFilename: null, _contentLength: null, _saveInProgress: false, + _downloadOrSavePromise: null, _wheelUnusedTicks: 0, _wheelUnusedFactor: 1, _touchManager: null, @@ -1336,7 +1337,14 @@ const PDFViewerApplication = { } }, - async downloadOrSave() { + downloadOrSave() { + this._downloadOrSavePromise ??= this._downloadOrSave().finally(() => { + this._downloadOrSavePromise = null; + }); + return this._downloadOrSavePromise; + }, + + async _downloadOrSave() { // In the Firefox case, this method MUST always trigger a download. // When the user is closing a modified and unsaved document, we display a // prompt asking for saving or not. In case they save, we must wait for @@ -1346,22 +1354,25 @@ const PDFViewerApplication = { const { classList } = this.appConfig.appContainer; classList.add("wait"); - if (this.pdfThumbnailViewer?.hasStructuralChanges()) { - this.externalServices.reportTelemetry({ - type: "pageOrganization", - data: { action: "save" }, - }); - await this.onSavePages({ - data: this.pdfThumbnailViewer.getStructuralChanges(), - }); - } else { - await (this.pdfDocument?.annotationStorage.size > 0 - ? this.save() - : this.download()); + try { + if (this.pdfThumbnailViewer?.hasStructuralChanges()) { + this.externalServices.reportTelemetry({ + type: "pageOrganization", + data: { action: "save" }, + }); + await this.onSavePages({ + data: this.pdfThumbnailViewer.getStructuralChanges(), + }); + } else { + await (this.pdfDocument?.annotationStorage.size > 0 + ? this.save() + : this.download()); + } + delete this._mergedDocumentNeedsSaving; + this.setTitle(); + } finally { + classList.remove("wait"); } - delete this._mergedDocumentNeedsSaving; - this.setTitle(); - classList.remove("wait"); }, /** @@ -2098,9 +2109,20 @@ const PDFViewerApplication = { this.pdfPresentationMode?.request(); }, - async triggerPrinting() { - if (this.supportsPrinting && (await this._printPermissionPromise)) { + async triggerPrinting({ onPrintCancelled } = {}) { + try { + if (!this.supportsPrinting || !(await this._printPermissionPromise)) { + onPrintCancelled?.(); + return; + } + window.print(); + if (!this.printService) { + onPrintCancelled?.(); + } + } catch (ex) { + onPrintCancelled?.(); + throw ex; } }, diff --git a/web/generic_scripting.js b/web/generic_scripting.js index 27f805b8e805c..6381ddaebc871 100644 --- a/web/generic_scripting.js +++ b/web/generic_scripting.js @@ -57,7 +57,12 @@ class GenericScripting { async dispatchEventInSandbox(event) { const sandbox = await this._ready; - setTimeout(() => sandbox.dispatchEvent(event), 0); + return new Promise(resolve => { + setTimeout(() => { + sandbox.dispatchEvent(event); + resolve(); + }, 0); + }); } async destroySandbox() { diff --git a/web/pdf_scripting_manager.js b/web/pdf_scripting_manager.js index 9da66880d5ec2..ac71ef8e7ad19 100644 --- a/web/pdf_scripting_manager.js +++ b/web/pdf_scripting_manager.js @@ -15,10 +15,10 @@ /** @typedef {import("./event_utils").EventBus} EventBus */ +import { getUuid, shadow } from "pdfjs-lib"; import { apiPageLayoutToViewerModes } from "./ui_utils.js"; import { internalOpt } from "./internal_evt.js"; import { RenderingStates } from "./renderable_view.js"; -import { shadow } from "pdfjs-lib"; /** * @typedef {Object} PDFScriptingManagerOptions @@ -45,12 +45,18 @@ class PDFScriptingManager { #externalServices = null; + #fieldIds = null; + #pdfDocument = null; #pdfViewer = null; + #printing = false; + #ready = false; + #runningOpenAction = ""; + #scripting = null; #willPrintCapability = null; @@ -104,6 +110,14 @@ class PDFScriptingManager { if (pdfDocument !== this.#pdfDocument) { return; // The document was closed while the data resolved. } + this.#fieldIds = new Set( + objects + ? Object.values(objects) + .flat() + .map(obj => obj?.id) + .filter(Boolean) + : undefined + ); try { this.#scripting = this.#initScripting(); } catch (error) { @@ -172,6 +186,13 @@ class PDFScriptingManager { }, evtOpts ); + eventBus.on( + "afterprint", + () => { + this.#printing = false; + }, + evtOpts + ); try { const docProperties = await this.#docProperties(pdfDocument); @@ -200,10 +221,16 @@ class PDFScriptingManager { return; } - await this.#scripting?.dispatchEventInSandbox({ - id: "doc", - name: "Open", - }); + this.#runningOpenAction = getUuid(); + try { + await this.#scripting?.dispatchEventInSandbox({ + id: "doc", + name: "Open", + nonce: this.#runningOpenAction, + }); + } catch { + this.#runningOpenAction = ""; + } await this.#dispatchPageOpen( this.#pdfViewer.currentPageNumber, /* initialize = */ true @@ -248,7 +275,7 @@ class PDFScriptingManager { throw ex; } - await this.#willPrintCapability.promise; + await this.#willPrintCapability?.promise; } async dispatchDidPrint() { @@ -316,8 +343,26 @@ class PDFScriptingManager { pdfViewer.currentPageNumber = value + 1; break; case "print": - await pdfViewer.pagesPromise; - this.#eventBus.dispatch("print", { source: this }); + if ( + this.#printing || + (!this.#runningOpenAction && + navigator.userActivation?.isActive !== true) + ) { + break; + } + this.#printing = true; + try { + await pdfViewer.pagesPromise; + } catch { + this.#printing = false; + break; + } + this.#eventBus.dispatch("print", { + source: this, + onPrintCancelled: () => { + this.#printing = false; + }, + }); break; case "println": console.log(value); @@ -328,6 +373,12 @@ class PDFScriptingManager { } break; case "SaveAs": + if ( + this.#runningOpenAction || + navigator.userActivation?.isActive !== true + ) { + break; + } this.#eventBus.dispatch("download", { source: this }); break; case "FirstPage": @@ -356,6 +407,11 @@ class PDFScriptingManager { this.#willPrintCapability?.resolve(); this.#willPrintCapability = null; break; + case "OpenFinished": + if (detail.nonce === this.#runningOpenAction) { + this.#runningOpenAction = ""; + } + break; } return; } @@ -368,8 +424,11 @@ class PDFScriptingManager { const ids = siblings ? [id, ...siblings] : [id]; for (const elementId of ids) { + if (!this.#fieldIds?.has(elementId)) { + continue; // Ignore ids that don't match a known field object. + } const element = document.querySelector( - `[data-element-id="${elementId}"]` + `[data-element-id="${CSS.escape(elementId)}"]` ); if (element) { element.dispatchEvent(new CustomEvent("updatefromsandbox", { detail })); @@ -489,6 +548,9 @@ class PDFScriptingManager { this._pageOpenPending.clear(); this._visitedPages.clear(); + this.#fieldIds = null; + this.#printing = false; + this.#runningOpenAction = ""; this.#scripting = null; this.#ready = false;