From 2dfac4cb41c7820557dd47df2938aaaa45856f6a Mon Sep 17 00:00:00 2001
From: Calixte Denizet <calixte.denizet@gmail.com>
Date: Tue, 3 Nov 2020 19:24:07 +0100
Subject: [PATCH] JS -- Fix events dispatchment and add tests  * dispatch event
 to take into account calculation order  * use a map for actions in Field

---
 src/display/annotation_layer.js     |  69 ++++++-
 src/scripting_api/doc.js            |   1 +
 src/scripting_api/event.js          | 127 +++++++++++-
 src/scripting_api/field.js          |  46 +++--
 src/scripting_api/initialization.js |  10 +-
 test/unit/scripting_spec.js         | 308 ++++++++++++++++++++++------
 web/app.js                          |   2 +-
 7 files changed, 459 insertions(+), 104 deletions(-)

diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js
index b8e1c90fe..e3e810eba 100644
--- a/src/display/annotation_layer.js
+++ b/src/display/annotation_layer.js
@@ -501,6 +501,7 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement {
         element.setAttribute("value", textContent);
       }
 
+      element.userValue = textContent;
       element.setAttribute("id", id);
 
       element.addEventListener("input", function (event) {
@@ -512,26 +513,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);