diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 9093ded7c..1220ec4ea 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -505,6 +505,7 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { element.setAttribute("value", textContent); } + element.userValue = textContent; element.setAttribute("id", id); element.addEventListener("input", function (event) { @@ -516,26 +517,76 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { }); if (this.enableScripting && this.hasJSActions) { - element.addEventListener("updateFromSandbox", function (event) { - const data = event.detail; - if ("value" in data) { - event.target.value = event.detail.value; - } else if ("focus" in data) { - event.target.focus({ preventScroll: false }); + element.addEventListener("focus", event => { + if (event.target.userValue) { + event.target.value = event.target.userValue; } }); - if (this.data.actions !== null) { + if (this.data.actions) { + element.addEventListener("updateFromSandbox", function (event) { + const detail = event.detail; + const actions = { + value() { + const value = detail.value; + if (value === undefined || value === null) { + // remove data + event.target.userValue = ""; + } else { + event.target.userValue = value; + } + }, + valueAsString() { + const value = detail.valueAsString; + if (value === undefined || value === null) { + // remove data + event.target.value = ""; + } else { + event.target.value = value; + } + storage.setValue(id, event.target.value); + }, + focus() { + event.target.focus({ preventScroll: false }); + }, + userName() { + const tooltip = detail.userName; + event.target.title = tooltip; + }, + hidden() { + event.target.style.display = detail.hidden ? "none" : "block"; + }, + editable() { + event.target.disabled = !detail.editable; + }, + selRange() { + const [selStart, selEnd] = detail.selRange; + if (selStart >= 0 && selEnd < event.target.value.length) { + event.target.setSelectionRange(selStart, selEnd); + } + }, + }; + for (const name of Object.keys(detail)) { + if (name in actions) { + actions[name](); + } + } + }); + for (const eventType of Object.keys(this.data.actions)) { switch (eventType) { case "Format": - element.addEventListener("blur", function (event) { + element.addEventListener("change", function (event) { window.dispatchEvent( new CustomEvent("dispatchEventInSandbox", { detail: { id, - name: "Format", + name: "Keystroke", value: event.target.value, + willCommit: true, + commitKey: 1, + selStart: event.target.selectionStart, + selEnd: event.target.selectionEnd, }, }) ); diff --git a/src/scripting_api/doc.js b/src/scripting_api/doc.js index 0a7e1f4ef..715a852ea 100644 --- a/src/scripting_api/doc.js +++ b/src/scripting_api/doc.js @@ -30,6 +30,7 @@ class InfoProxyHandler { class Doc extends PDFObject { constructor(data) { super(data); + this.calculate = true; this.baseURL = data.baseURL || ""; this.calculate = true; diff --git a/src/scripting_api/event.js b/src/scripting_api/event.js index a919fdd91..f96b0f757 100644 --- a/src/scripting_api/event.js +++ b/src/scripting_api/event.js @@ -26,14 +26,14 @@ class Event { this.richChange = data.richChange || []; this.richChangeEx = data.richChangeEx || []; this.richValue = data.richValue || []; - this.selEnd = data.selEnd || 0; - this.selStart = data.selStart || 0; + this.selEnd = data.selEnd || -1; + this.selStart = data.selStart || -1; this.shift = data.shift || false; this.source = data.source || null; this.target = data.target || null; - this.targetName = data.targetName || ""; + this.targetName = ""; this.type = "Field"; - this.value = data.value || null; + this.value = data.value || ""; this.willCommit = data.willCommit || false; } } @@ -47,6 +47,21 @@ class EventDispatcher { this._document.obj._eventDispatcher = this; } + mergeChange(event) { + let value = event.value; + if (typeof value !== "string") { + value = value.toString(); + } + const prefix = + event.selStart >= 0 ? value.substring(0, event.selStart) : ""; + const postfix = + event.selEnd >= 0 && event.selEnd <= value.length + ? value.substring(event.selEnd) + : ""; + + return `${prefix}${event.change}${postfix}`; + } + dispatch(baseEvent) { const id = baseEvent.id; if (!(id in this._objects)) { @@ -56,11 +71,71 @@ class EventDispatcher { const name = baseEvent.name.replace(" ", ""); const source = this._objects[id]; const event = (this._document.obj._event = new Event(baseEvent)); - const oldValue = source.obj.value; + let savedChange; + + if (source.obj._isButton()) { + source.obj._id = id; + event.value = source.obj._getExportValue(event.value); + } + + if (name === "Keystroke") { + savedChange = { + value: event.value, + change: event.change, + selStart: event.selStart, + selEnd: event.selEnd, + }; + } else if (name === "Blur" || name === "Focus") { + Object.defineProperty(event, "value", { + configurable: false, + writable: false, + enumerable: true, + value: event.value, + }); + } else if (name === "Validate") { + this.runValidation(source, event); + return; + } this.runActions(source, source, event, name); - if (event.rc && oldValue !== event.value) { - source.wrapped.value = event.value; + + if (name === "Keystroke") { + if (event.rc) { + if (event.willCommit) { + this.runValidation(source, event); + } else if ( + event.change !== savedChange.change || + event.selStart !== savedChange.selStart || + event.selEnd !== savedChange.selEnd + ) { + source.wrapped.value = this.mergeChange(event); + } + } else if (!event.willCommit) { + source.obj._send({ + id: source.obj._id, + value: savedChange.value, + selRange: [savedChange.selStart, savedChange.selEnd], + }); + } + } + } + + runValidation(source, event) { + const hasRan = this.runActions(source, source, event, "Validate"); + if (event.rc) { + if (hasRan) { + source.wrapped.value = event.value; + } else { + source.obj.value = event.value; + } + + if (this._document.obj.calculate) { + this.runCalculate(source, event); + } + + event.value = source.obj.value; + this.runActions(source, source, event, "Format"); + source.wrapped.valueAsString = event.value; } } @@ -68,11 +143,43 @@ class EventDispatcher { event.source = source.wrapped; event.target = target.wrapped; event.name = eventName; + event.targetName = target.obj.name; event.rc = true; - if (!target.obj._runActions(event)) { - return true; + + return target.obj._runActions(event); + } + + calculateNow() { + if (this._calculationOrder.length === 0) { + return; + } + const first = this._calculationOrder[0]; + const source = this._objects[first]; + const event = (this._document.obj._event = new Event({})); + this.runCalculate(source, event); + } + + runCalculate(source, event) { + if (this._calculationOrder.length === 0) { + return; + } + + for (const targetId of this._calculationOrder) { + if (!(targetId in this._objects)) { + continue; + } + + const target = this._objects[targetId]; + this.runActions(source, target, event, "Calculate"); + this.runActions(target, target, event, "Validate"); + if (!event.rc) { + continue; + } + + target.wrapped.value = event.value; + this.runActions(target, target, event, "Format"); + target.wrapped.valueAsString = event.value; } - return event.rc; } } diff --git a/src/scripting_api/field.js b/src/scripting_api/field.js index b6d4d93c8..1eb0304c9 100644 --- a/src/scripting_api/field.js +++ b/src/scripting_api/field.js @@ -71,17 +71,8 @@ class Field extends PDFObject { this.valueAsString = data.valueAsString; // Private - this._actions = Object.create(null); - const doc = (this._document = data.doc); - if (data.actions !== null) { - for (const [eventType, actions] of Object.entries(data.actions)) { - // This code is running in a sandbox so it's safe to use Function - this._actions[eventType] = actions.map(action => - // eslint-disable-next-line no-new-func - Function("event", `with (this) {${action}}`).bind(doc) - ); - } - } + this._document = data.doc; + this._actions = this._createActionsMap(data.actions); } setAction(cTrigger, cScript) { @@ -91,20 +82,45 @@ class Field extends PDFObject { if (!(cTrigger in this._actions)) { this._actions[cTrigger] = []; } - this._actions[cTrigger].push(cScript); + this._actions[cTrigger].push( + // eslint-disable-next-line no-new-func + Function("event", `with (this) {${cScript}}`).bind(this._document) + ); } setFocus() { this._send({ id: this._id, focus: true }); } + _createActionsMap(actions) { + const actionsMap = new Map(); + if (actions) { + const doc = this._document; + for (const [eventType, actionsForEvent] of Object.entries(actions)) { + // This stuff is running in a sandbox so it's safe to use Function + actionsMap.set( + eventType, + actionsForEvent.map(action => + // eslint-disable-next-line no-new-func + Function("event", `with (this) {${action}}`).bind(doc) + ) + ); + } + } + return actionsMap; + } + + _isButton() { + return false; + } + _runActions(event) { const eventName = event.name; - if (!(eventName in this._actions)) { + if (!this._actions.has(eventName)) { return false; } - const actions = this._actions[eventName]; + const actions = this._actions.get(eventName); try { for (const action of actions) { action(event); @@ -115,7 +131,7 @@ class Field extends PDFObject { `"${error.toString()}" for event ` + `"${eventName}" in object ${this._id}.` + `\n${error.stack}`; - this._send({ command: "error", value }); + this._send({ id: "error", value }); } return true; diff --git a/src/scripting_api/initialization.js b/src/scripting_api/initialization.js index 55e805413..9fc65d8e0 100644 --- a/src/scripting_api/initialization.js +++ b/src/scripting_api/initialization.js @@ -22,7 +22,7 @@ import { ProxyHandler } from "./proxy.js"; import { Util } from "./util.js"; import { ZoomType } from "./constants.js"; -function initSandbox(data, extra, out) { +function initSandbox({ data, extra, out, testMode = false }) { const proxyHandler = new ProxyHandler(data.dispatchEventName); const { send, crackURL } = extra; const doc = new Doc({ @@ -58,6 +58,14 @@ function initSandbox(data, extra, out) { out[name] = aform[name].bind(aform); } } + + if ( + (typeof PDFJSDev === "undefined" || + PDFJSDev.test("!PRODUCTION || TESTING")) && + testMode + ) { + out._app = app; + } } export { initSandbox }; diff --git a/test/unit/scripting_spec.js b/test/unit/scripting_spec.js index 2e97b3360..c804d9ecc 100644 --- a/test/unit/scripting_spec.js +++ b/test/unit/scripting_spec.js @@ -15,83 +15,255 @@ import { initSandbox } from "../../src/scripting_api/initialization.js"; -describe("Util", function () { - let sandbox, util; +describe("Scripting", function () { + describe("Util", function () { + let sandbox, util; - beforeAll(function (done) { - sandbox = Object.create(null); - const extra = { send: null, crackURL: null }; - const data = { objects: {}, calculationOrder: [] }; - initSandbox(data, extra, sandbox); - util = sandbox.util; - done(); - }); - - afterAll(function () { - sandbox = util = null; - }); - - describe("printd", function () { - it("should print a date according to a format", function (done) { - const date = new Date("April 15, 1707 3:14:15"); - expect(util.printd(0, date)).toEqual("D:17070415031415"); - expect(util.printd(1, date)).toEqual("1707.04.15 03:14:15"); - expect(util.printd(2, date)).toEqual("4/15/07 3:14:15 am"); - expect(util.printd("mmmm mmm mm m", date)).toEqual("April Apr 04 4"); - expect(util.printd("dddd ddd dd d", date)).toEqual("Friday Fri 15 15"); - done(); - }); - }); - - describe("scand", function () { - it("should parse a date according to a format", function (done) { - const date = new Date("April 15, 1707 3:14:15"); - expect(util.scand(0, "D:17070415031415")).toEqual(date); - expect(util.scand(1, "1707.04.15 03:14:15")).toEqual(date); - expect(util.scand(2, "4/15/07 3:14:15 am")).toEqual( - new Date("April 15, 2007 3:14:15") - ); - done(); - }); - }); - - describe("printf", function () { - it("should print some data according to a format", function (done) { - expect(util.printf("Integer numbers: %d, %d,...", 1.234, 56.789)).toEqual( - "Integer numbers: 1, 56,..." - ); - expect(util.printf("Hex numbers: %x, %x,...", 1234, 56789)).toEqual( - "Hex numbers: 4D2, DDD5,..." - ); - expect( - util.printf("Hex numbers with 0x: %#x, %#x,...", 1234, 56789) - ).toEqual("Hex numbers with 0x: 0x4D2, 0xDDD5,..."); - expect(util.printf("Decimal number: %,0+.3f", 1234567.89123)).toEqual( - "Decimal number: +1,234,567.891" - ); - expect(util.printf("Decimal number: %,0+8.3f", 1.234567)).toEqual( - "Decimal number: + 1.235" - ); + beforeAll(function (done) { + sandbox = Object.create(null); + const extra = { send: null, crackURL: null }; + const data = { objects: {}, calculationOrder: [] }; + initSandbox({ data, extra, out: sandbox }); + util = sandbox.util; done(); }); - it("should print a string with no argument", function (done) { - expect(util.printf("hello world")).toEqual("hello world"); - done(); + afterAll(function () { + sandbox = util = null; }); - it("should print a string with a percent", function (done) { - expect(util.printf("%%s")).toEqual("%%s"); - expect(util.printf("%%s", "hello")).toEqual("%%s"); - done(); + describe("printd", function () { + it("should print a date according to a format", function (done) { + const date = new Date("April 15, 1707 3:14:15"); + expect(util.printd(0, date)).toEqual("D:17070415031415"); + expect(util.printd(1, date)).toEqual("1707.04.15 03:14:15"); + expect(util.printd(2, date)).toEqual("4/15/07 3:14:15 am"); + expect(util.printd("mmmm mmm mm m", date)).toEqual("April Apr 04 4"); + expect(util.printd("dddd ddd dd d", date)).toEqual("Friday Fri 15 15"); + done(); + }); + }); + + describe("scand", function () { + it("should parse a date according to a format", function (done) { + const date = new Date("April 15, 1707 3:14:15"); + expect(util.scand(0, "D:17070415031415")).toEqual(date); + expect(util.scand(1, "1707.04.15 03:14:15")).toEqual(date); + expect(util.scand(2, "4/15/07 3:14:15 am")).toEqual( + new Date("April 15, 2007 3:14:15") + ); + done(); + }); + }); + + describe("printf", function () { + it("should print some data according to a format", function (done) { + expect( + util.printf("Integer numbers: %d, %d,...", 1.234, 56.789) + ).toEqual("Integer numbers: 1, 56,..."); + expect(util.printf("Hex numbers: %x, %x,...", 1234, 56789)).toEqual( + "Hex numbers: 4D2, DDD5,..." + ); + expect( + util.printf("Hex numbers with 0x: %#x, %#x,...", 1234, 56789) + ).toEqual("Hex numbers with 0x: 0x4D2, 0xDDD5,..."); + expect(util.printf("Decimal number: %,0+.3f", 1234567.89123)).toEqual( + "Decimal number: +1,234,567.891" + ); + expect(util.printf("Decimal number: %,0+8.3f", 1.234567)).toEqual( + "Decimal number: + 1.235" + ); + done(); + }); + + it("should print a string with no argument", function (done) { + expect(util.printf("hello world")).toEqual("hello world"); + done(); + }); + + it("should print a string with a percent", function (done) { + expect(util.printf("%%s")).toEqual("%%s"); + expect(util.printf("%%s", "hello")).toEqual("%%s"); + done(); + }); + }); + + describe("printx", function () { + it("should print some data according to a format", function (done) { + expect(util.printx("9 (999) 999-9999", "aaa14159697489zzz")).toEqual( + "1 (415) 969-7489" + ); + done(); + }); }); }); - describe("printx", function () { - it("should print some data according to a format", function (done) { - expect(util.printx("9 (999) 999-9999", "aaa14159697489zzz")).toEqual( - "1 (415) 969-7489" - ); + describe("Events", function () { + let sandbox, send_queue, _app; + + beforeEach(function (done) { + send_queue = []; + sandbox = Object.create(null); + const extra = { + send(data) { + send_queue.push(data); + }, + crackURL: null, + }; + const data = { + objects: { + field314R: [ + { + id: "314R", + value: "", + actions: {}, + type: "text", + }, + ], + field271R: [ + { + id: "271R", + value: "", + actions: {}, + type: "text", + }, + ], + }, + calculationOrder: ["271R"], + dispatchEventName: "_dispatchMe", + }; + + initSandbox({ + data, + extra, + out: sandbox, + testMode: true, + }); + + _app = sandbox._app; + send_queue = []; + done(); + }); + + afterAll(function () { + sandbox = send_queue = _app = null; + }); + + it("should trigger an event and modify the source", function (done) { + _app._objects["314R"].obj._actions.set("test", [ + event => { + event.source.value = "123"; + }, + ]); + + sandbox.app._dispatchMe({ + id: "314R", + value: "", + name: "test", + willCommit: true, + }); + + expect(send_queue.length).toEqual(1); + expect(send_queue[0]).toEqual({ id: "314R", value: "123" }); + + done(); + }); + + it("should trigger a Keystroke event and invalidate it", function (done) { + _app._objects["314R"].obj._actions.set("Keystroke", [ + event => { + event.rc = false; + }, + ]); + + sandbox.app._dispatchMe({ + id: "314R", + value: "hell", + name: "Keystroke", + willCommit: false, + change: "o", + selStart: 4, + selEnd: 4, + }); + expect(send_queue.length).toEqual(1); + expect(send_queue[0]).toEqual({ + id: "314R", + value: "hell", + selRange: [4, 4], + }); + + done(); + }); + + it("should trigger a Keystroke event and change it", function (done) { + _app._objects["314R"].obj._actions.set("Keystroke", [ + event => { + event.change = "a"; + }, + ]); + + sandbox.app._dispatchMe({ + id: "314R", + value: "hell", + name: "Keystroke", + willCommit: false, + change: "o", + selStart: 4, + selEnd: 4, + }); + expect(send_queue.length).toEqual(1); + expect(send_queue[0]).toEqual({ id: "314R", value: "hella" }); + + done(); + }); + + it("should trigger an invalid commit Keystroke event", function (done) { + _app._objects["314R"].obj._actions.set("Validate", [ + event => { + event.rc = false; + }, + ]); + + sandbox.app._dispatchMe({ + id: "314R", + value: "hello", + name: "Keystroke", + willCommit: true, + }); + expect(send_queue.length).toEqual(0); + + done(); + }); + + it("should trigger a valid commit Keystroke event", function (done) { + let output = ""; + _app._objects["314R"].obj._actions.set("Validate", [ + event => { + event.value = "world"; + output += "foo"; + }, + ]); + _app._objects["271R"].obj._actions.set("Calculate", [ + event => { + event.value = "hello"; + output += "bar"; + }, + ]); + + sandbox.app._dispatchMe({ + id: "314R", + value: "hello", + name: "Keystroke", + willCommit: true, + }); + + expect(send_queue.length).toEqual(4); + expect(send_queue[0]).toEqual({ id: "314R", value: "world" }); + expect(send_queue[1]).toEqual({ id: "271R", value: "hello" }); + expect(send_queue[2]).toEqual({ id: "271R", valueAsString: "hello" }); + expect(send_queue[3]).toEqual({ id: "314R", valueAsString: "world" }); + expect(output).toEqual("foobar"); + done(); }); }); diff --git a/web/app.js b/web/app.js index 0a2fa2532..aa7ebd825 100644 --- a/web/app.js +++ b/web/app.js @@ -1369,6 +1369,7 @@ const PDFViewerApplication = { if (!objects || !AppOptions.get("enableScripting")) { return; } + const calculationOrder = await pdfDocument.getCalculationOrderIds(); const scripting = this.externalServices.scripting; const { info, @@ -1431,7 +1432,6 @@ const PDFViewerApplication = { }); const dispatchEventName = generateRandomStringForSandbox(objects); - const calculationOrder = []; const { length } = await pdfDocument.getDownloadInfo(); const filename = contentDispositionFilename || getPDFFileNameFromURL(this.url);