From 283aac4c53ffaf9e7114745a634c41fcebb75d82 Mon Sep 17 00:00:00 2001
From: Calixte Denizet <calixte.denizet@gmail.com>
Date: Mon, 16 Nov 2020 14:27:27 +0100
Subject: [PATCH] JS -- Implement app object  *
 https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/AcrobatDC_js_api_reference.pdf
  * Add color, fullscreen objects + few constants.

---
 src/display/annotation_layer.js      |   7 +
 src/display/display_utils.js         |  54 +++
 src/scripting_api/app.js             | 515 ++++++++++++++++++++++++++-
 src/scripting_api/color.js           | 129 +++++++
 src/scripting_api/constants.js       | 113 +++++-
 src/scripting_api/field.js           |  40 ++-
 src/scripting_api/fullscreen.js      | 145 ++++++++
 src/scripting_api/initialization.js  |  42 ++-
 src/scripting_api/quickjs-sandbox.js |   6 +-
 src/scripting_api/thermometer.js     |  69 ++++
 test/unit/scripting_spec.js          | 154 +++++++-
 web/app.js                           |   4 +
 12 files changed, 1259 insertions(+), 19 deletions(-)
 create mode 100644 src/scripting_api/color.js
 create mode 100644 src/scripting_api/fullscreen.js
 create mode 100644 src/scripting_api/thermometer.js

diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js
index 26271cbe6..86ede98eb 100644
--- a/src/display/annotation_layer.js
+++ b/src/display/annotation_layer.js
@@ -15,6 +15,7 @@
 
 import {
   addLinkAttributes,
+  ColorConverters,
   DOMSVGFactory,
   getFilenameFromUrl,
   LinkTarget,
@@ -567,6 +568,12 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement {
                   event.target.setSelectionRange(selStart, selEnd);
                 }
               },
+              strokeColor() {
+                const color = detail.strokeColor;
+                event.target.style.color = ColorConverters[`${color[0]}_HTML`](
+                  color.slice(1)
+                );
+              },
             };
             for (const name of Object.keys(detail)) {
               if (name in actions) {
diff --git a/src/display/display_utils.js b/src/display/display_utils.js
index 302dd0244..6fb601752 100644
--- a/src/display/display_utils.js
+++ b/src/display/display_utils.js
@@ -635,6 +635,59 @@ class PDFDateString {
   }
 }
 
+function makeColorComp(n) {
+  return Math.floor(Math.max(0, Math.min(1, n)) * 255)
+    .toString(16)
+    .padStart(2, "0");
+}
+
+const ColorConverters = {
+  // PDF specifications section 10.3
+  CMYK_G([c, y, m, k]) {
+    return ["G", 1 - Math.min(1, 0.3 * c + 0.59 * m + 0.11 * y + k)];
+  },
+  G_CMYK([g]) {
+    return ["CMYK", 0, 0, 0, 1 - g];
+  },
+  G_RGB([g]) {
+    return ["RGB", g, g, g];
+  },
+  G_HTML([g]) {
+    const G = makeColorComp(g);
+    return `#${G}${G}${G}`;
+  },
+  RGB_G([r, g, b]) {
+    return ["G", 0.3 * r + 0.59 * g + 0.11 * b];
+  },
+  RGB_HTML([r, g, b]) {
+    const R = makeColorComp(r);
+    const G = makeColorComp(g);
+    const B = makeColorComp(b);
+    return `#${R}${G}${B}`;
+  },
+  T_HTML() {
+    return "#00000000";
+  },
+  CMYK_RGB([c, y, m, k]) {
+    return [
+      "RGB",
+      1 - Math.min(1, c + k),
+      1 - Math.min(1, m + k),
+      1 - Math.min(1, y + k),
+    ];
+  },
+  CMYK_HTML(components) {
+    return ColorConverters.RGB_HTML(ColorConverters.CMYK_RGB(components));
+  },
+  RGB_CMYK([r, g, b]) {
+    const c = 1 - r;
+    const m = 1 - g;
+    const y = 1 - b;
+    const k = Math.min(c, m, y);
+    return ["CMYK", c, m, y, k];
+  },
+};
+
 export {
   PageViewport,
   RenderingCancelledException,
@@ -645,6 +698,7 @@ export {
   BaseCanvasFactory,
   DOMCanvasFactory,
   BaseCMapReaderFactory,
+  ColorConverters,
   DOMCMapReaderFactory,
   DOMSVGFactory,
   StatTimer,
diff --git a/src/scripting_api/app.js b/src/scripting_api/app.js
index 2a823b168..2cd09bf31 100644
--- a/src/scripting_api/app.js
+++ b/src/scripting_api/app.js
@@ -13,22 +13,50 @@
  * limitations under the License.
  */
 
+import { Color } from "./color.js";
 import { EventDispatcher } from "./event.js";
-import { NotSupportedError } from "./error.js";
+import { FullScreen } from "./fullscreen.js";
 import { PDFObject } from "./pdf_object.js";
+import { Thermometer } from "./thermometer.js";
+
+const VIEWER_TYPE = "PDF.js";
+const VIEWER_VARIATION = "Full";
+const VIEWER_VERSION = "10.0";
+const FORMS_VERSION = undefined;
 
 class App extends PDFObject {
   constructor(data) {
     super(data);
+
+    this.calculate = true;
+
+    this._constants = null;
+    this._focusRect = true;
+    this._fs = null;
+    this._language = App._getLanguage(data.language);
+    this._openInPlace = false;
+    this._platform = App._getPlatform(data.platform);
+    this._runtimeHighlight = false;
+    this._runtimeHighlightColor = ["T"];
+    this._thermometer = null;
+    this._toolbar = false;
+
     this._document = data._document;
+    this._proxyHandler = data.proxyHandler;
     this._objects = Object.create(null);
     this._eventDispatcher = new EventDispatcher(
       this._document,
       data.calculationOrder,
       this._objects
     );
+    this._setTimeout = data.setTimeout;
+    this._clearTimeout = data.clearTimeout;
+    this._setInterval = data.setInterval;
+    this._clearInterval = data.clearInterval;
+    this._timeoutIds = null;
+    this._timeoutIdsRegistry = null;
 
-    // used in proxy.js to check that this the object with the backdoor
+    // used in proxy.js to check that this is the object with the backdoor
     this._isApp = true;
   }
 
@@ -38,12 +66,339 @@ class App extends PDFObject {
     this._eventDispatcher.dispatch(pdfEvent);
   }
 
+  _registerTimeout(timeout, id, interval) {
+    if (!this._timeoutIds) {
+      this._timeoutIds = new WeakMap();
+      // FinalizationRegistry isn't implemented in QuickJS
+      // eslint-disable-next-line no-undef
+      if (typeof FinalizationRegistry !== "undefined") {
+        // About setTimeOut/setInterval return values (specs):
+        //   The return value of this method must be held in a
+        //   JavaScript variable.
+        //   Otherwise, the timeout object is subject to garbage-collection,
+        //   which would cause the clock to stop.
+
+        // eslint-disable-next-line no-undef
+        this._timeoutIdsRegistry = new FinalizationRegistry(
+          ([timeoutId, isInterval]) => {
+            if (isInterval) {
+              this._clearInterval(timeoutId);
+            } else {
+              this._clearTimeout(timeoutId);
+            }
+          }
+        );
+      }
+    }
+    this._timeoutIds.set(timeout, [id, interval]);
+    if (this._timeoutIdsRegistry) {
+      this._timeoutIdsRegistry.register(timeout, [id, interval]);
+    }
+  }
+
+  _unregisterTimeout(timeout) {
+    if (!this._timeoutIds || !this._timeoutIds.has(timeout)) {
+      return;
+    }
+    const [id, interval] = this._timeoutIds.get(timeout);
+    if (this._timeoutIdsRegistry) {
+      this._timeoutIdsRegistry.unregister(timeout);
+    }
+    this._timeoutIds.delete(timeout);
+
+    if (interval) {
+      this._clearInterval(id);
+    } else {
+      this._clearTimeout(id);
+    }
+  }
+
+  static _getPlatform(platform) {
+    if (typeof platform === "string") {
+      platform = platform.toLowerCase();
+      if (platform.includes("win")) {
+        return "WIN";
+      } else if (platform.includes("mac")) {
+        return "MAC";
+      }
+    }
+    return "UNIX";
+  }
+
+  static _getLanguage(language) {
+    const [main, sub] = language.toLowerCase().split(/[-_]/);
+    switch (main) {
+      case "zh":
+        if (sub === "cn" || sub === "sg") {
+          return "CHS";
+        }
+        return "CHT";
+      case "da":
+        return "DAN";
+      case "de":
+        return "DEU";
+      case "es":
+        return "ESP";
+      case "fr":
+        return "FRA";
+      case "it":
+        return "ITA";
+      case "ko":
+        return "KOR";
+      case "ja":
+        return "JPN";
+      case "nl":
+        return "NLD";
+      case "no":
+        return "NOR";
+      case "pt":
+        if (sub === "br") {
+          return "PTB";
+        }
+        return "ENU";
+      case "fi":
+        return "SUO";
+      case "SV":
+        return "SVE";
+      default:
+        return "ENU";
+    }
+  }
+
   get activeDocs() {
     return [this._document.wrapped];
   }
 
   set activeDocs(_) {
-    throw new NotSupportedError("app.activeDocs");
+    throw new Error("app.activeDocs is read-only");
+  }
+
+  get constants() {
+    if (!this._constants) {
+      this._constants = Object.freeze({
+        align: Object.freeze({
+          left: 0,
+          center: 1,
+          right: 2,
+          top: 3,
+          bottom: 4,
+        }),
+      });
+    }
+    return this._constants;
+  }
+
+  set constants(_) {
+    throw new Error("app.constants is read-only");
+  }
+
+  get focusRect() {
+    return this._focusRect;
+  }
+
+  set focusRect(val) {
+    /* TODO or not */
+    this._focusRect = val;
+  }
+
+  get formsVersion() {
+    return FORMS_VERSION;
+  }
+
+  set formsVersion(_) {
+    throw new Error("app.formsVersion is read-only");
+  }
+
+  get fromPDFConverters() {
+    return [];
+  }
+
+  set fromPDFConverters(_) {
+    throw new Error("app.fromPDFConverters is read-only");
+  }
+
+  get fs() {
+    if (this._fs === null) {
+      this._fs = new Proxy(
+        new FullScreen({ send: this._send }),
+        this._proxyHandler
+      );
+    }
+    return this._fs;
+  }
+
+  set fs(_) {
+    throw new Error("app.fs is read-only");
+  }
+
+  get language() {
+    return this._language;
+  }
+
+  set language(_) {
+    throw new Error("app.language is read-only");
+  }
+
+  get media() {
+    return undefined;
+  }
+
+  set media(_) {
+    throw new Error("app.media is read-only");
+  }
+
+  get monitors() {
+    return [];
+  }
+
+  set monitors(_) {
+    throw new Error("app.monitors is read-only");
+  }
+
+  get numPlugins() {
+    return 0;
+  }
+
+  set numPlugins(_) {
+    throw new Error("app.numPlugins is read-only");
+  }
+
+  get openInPlace() {
+    return this._openInPlace;
+  }
+
+  set openInPlace(val) {
+    this._openInPlace = val;
+    /* TODO */
+  }
+
+  get platform() {
+    return this._platform;
+  }
+
+  set platform(_) {
+    throw new Error("app.platform is read-only");
+  }
+
+  get plugins() {
+    return [];
+  }
+
+  set plugins(_) {
+    throw new Error("app.plugins is read-only");
+  }
+
+  get printColorProfiles() {
+    return [];
+  }
+
+  set printColorProfiles(_) {
+    throw new Error("app.printColorProfiles is read-only");
+  }
+
+  get printerNames() {
+    return [];
+  }
+
+  set printerNames(_) {
+    throw new Error("app.printerNames is read-only");
+  }
+
+  get runtimeHighlight() {
+    return this._runtimeHighlight;
+  }
+
+  set runtimeHighlight(val) {
+    this._runtimeHighlight = val;
+    /* TODO */
+  }
+
+  get runtimeHighlightColor() {
+    return this._runtimeHighlightColor;
+  }
+
+  set runtimeHighlightColor(val) {
+    if (Color._isValidColor(val)) {
+      this._runtimeHighlightColor = val;
+      /* TODO */
+    }
+  }
+
+  get thermometer() {
+    if (this._thermometer === null) {
+      this._thermometer = new Proxy(
+        new Thermometer({ send: this._send }),
+        this._proxyHandler
+      );
+    }
+    return this._thermometer;
+  }
+
+  set thermometer(_) {
+    throw new Error("app.thermometer is read-only");
+  }
+
+  get toolbar() {
+    return this._toolbar;
+  }
+
+  set toolbar(val) {
+    this._toolbar = val;
+    /* TODO */
+  }
+
+  get toolbarHorizontal() {
+    return this.toolbar;
+  }
+
+  set toolbarHorizontal(value) {
+    /* has been deprecated and it's now equivalent to toolbar */
+    this.toolbar = value;
+  }
+
+  get toolbarVertical() {
+    return this.toolbar;
+  }
+
+  set toolbarVertical(value) {
+    /* has been deprecated and it's now equivalent to toolbar */
+    this.toolbar = value;
+  }
+
+  get viewerType() {
+    return VIEWER_TYPE;
+  }
+
+  set viewerType(_) {
+    throw new Error("app.viewerType is read-only");
+  }
+
+  get viewerVariation() {
+    return VIEWER_VARIATION;
+  }
+
+  set viewerVariation(_) {
+    throw new Error("app.viewerVariation is read-only");
+  }
+
+  get viewerVersion() {
+    return VIEWER_VERSION;
+  }
+
+  set viewerVersion(_) {
+    throw new Error("app.viewerVersion is read-only");
+  }
+
+  addMenuItem() {
+    /* Not implemented */
+  }
+
+  addSubMenu() {
+    /* Not implemented */
+  }
+
+  addToolButton() {
+    /* Not implemented */
   }
 
   alert(
@@ -56,6 +411,160 @@ class App extends PDFObject {
   ) {
     this._send({ command: "alert", value: cMsg });
   }
+
+  beep() {
+    /* Not implemented */
+  }
+
+  beginPriv() {
+    /* Not implemented */
+  }
+
+  browseForDoc() {
+    /* Not implemented */
+  }
+
+  clearInterval(oInterval) {
+    this.unregisterTimeout(oInterval);
+  }
+
+  clearTimeOut(oTime) {
+    this.unregisterTimeout(oTime);
+  }
+
+  endPriv() {
+    /* Not implemented */
+  }
+
+  execDialog() {
+    /* Not implemented */
+  }
+
+  execMenuItem() {
+    /* Not implemented */
+  }
+
+  getNthPlugInName() {
+    /* Not implemented */
+  }
+
+  getPath() {
+    /* Not implemented */
+  }
+
+  goBack() {
+    /* TODO */
+  }
+
+  goForward() {
+    /* TODO */
+  }
+
+  hideMenuItem() {
+    /* Not implemented */
+  }
+
+  hideToolbarButton() {
+    /* Not implemented */
+  }
+
+  launchURL() {
+    /* Unsafe */
+  }
+
+  listMenuItems() {
+    /* Not implemented */
+  }
+
+  listToolbarButtons() {
+    /* Not implemented */
+  }
+
+  loadPolicyFile() {
+    /* Not implemented */
+  }
+
+  mailGetAddrs() {
+    /* Not implemented */
+  }
+
+  mailMsg() {
+    /* TODO or not ? */
+  }
+
+  newDoc() {
+    /* Not implemented */
+  }
+
+  newCollection() {
+    /* Not implemented */
+  }
+
+  newFDF() {
+    /* Not implemented */
+  }
+
+  openDoc() {
+    /* Not implemented */
+  }
+
+  openFDF() {
+    /* Not implemented */
+  }
+
+  popUpMenu() {
+    /* Not implemented */
+  }
+
+  popUpMenuEx() {
+    /* Not implemented */
+  }
+
+  removeToolButton() {
+    /* Not implemented */
+  }
+
+  response() {
+    /* TODO or not */
+  }
+
+  setInterval(cExpr, nMilliseconds) {
+    if (typeof cExpr !== "string") {
+      throw new TypeError("First argument of app.setInterval must be a string");
+    }
+    if (typeof nMilliseconds !== "number") {
+      throw new TypeError(
+        "Second argument of app.setInterval must be a number"
+      );
+    }
+
+    const id = this._setInterval(cExpr, nMilliseconds);
+    const timeout = Object.create(null);
+    this._registerTimeout(timeout, id, true);
+    return timeout;
+  }
+
+  setTimeOut(cExpr, nMilliseconds) {
+    if (typeof cExpr !== "string") {
+      throw new TypeError("First argument of app.setTimeOut must be a string");
+    }
+    if (typeof nMilliseconds !== "number") {
+      throw new TypeError("Second argument of app.setTimeOut must be a number");
+    }
+
+    const id = this._setTimeout(cExpr, nMilliseconds);
+    const timeout = Object.create(null);
+    this._registerTimeout(timeout, id, false);
+    return timeout;
+  }
+
+  trustedFunction() {
+    /* Not implemented */
+  }
+
+  trustPropagatorFunction() {
+    /* Not implemented */
+  }
 }
 
 export { App };
diff --git a/src/scripting_api/color.js b/src/scripting_api/color.js
new file mode 100644
index 000000000..1f163d6eb
--- /dev/null
+++ b/src/scripting_api/color.js
@@ -0,0 +1,129 @@
+/* Copyright 2020 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 { ColorConverters } from "../display/display_utils.js";
+import { PDFObject } from "./pdf_object.js";
+
+class Color extends PDFObject {
+  constructor() {
+    super({});
+
+    this.transparent = ["T"];
+    this.black = ["G", 0];
+    this.white = ["G", 1];
+    this.red = ["RGB", 1, 0, 0];
+    this.green = ["RGB", 0, 1, 0];
+    this.blue = ["RGB", 0, 0, 1];
+    this.cyan = ["CMYK", 1, 0, 0, 0];
+    this.magenta = ["CMYK", 0, 1, 0, 0];
+    this.yellow = ["CMYK", 0, 0, 1, 0];
+    this.dkGray = ["G", 0.25];
+    this.gray = ["G", 0.5];
+    this.ltGray = ["G", 0.75];
+  }
+
+  static _isValidSpace(cColorSpace) {
+    return (
+      typeof cColorSpace === "string" &&
+      (cColorSpace === "T" ||
+        cColorSpace === "G" ||
+        cColorSpace === "RGB" ||
+        cColorSpace === "CMYK")
+    );
+  }
+
+  static _isValidColor(colorArray) {
+    if (!Array.isArray(colorArray) || colorArray.length === 0) {
+      return false;
+    }
+    const space = colorArray[0];
+    if (!Color._isValidSpace(space)) {
+      return false;
+    }
+
+    switch (space) {
+      case "T":
+        if (colorArray.length !== 1) {
+          return false;
+        }
+        break;
+      case "G":
+        if (colorArray.length !== 2) {
+          return false;
+        }
+        break;
+      case "RGB":
+        if (colorArray.length !== 4) {
+          return false;
+        }
+        break;
+      case "CMYK":
+        if (colorArray.length !== 5) {
+          return false;
+        }
+        break;
+      default:
+        return false;
+    }
+
+    return colorArray
+      .slice(1)
+      .every(c => typeof c === "number" && c >= 0 && c <= 1);
+  }
+
+  static _getCorrectColor(colorArray) {
+    return Color._isValidColor(colorArray) ? colorArray : ["G", 0];
+  }
+
+  convert(colorArray, cColorSpace) {
+    if (!Color._isValidSpace(cColorSpace)) {
+      return this.black;
+    }
+
+    if (cColorSpace === "T") {
+      return ["T"];
+    }
+
+    colorArray = Color._getCorrectColor(colorArray);
+    if (colorArray[0] === cColorSpace) {
+      return colorArray;
+    }
+
+    if (colorArray[0] === "T") {
+      return this.convert(this.black, cColorSpace);
+    }
+
+    return ColorConverters[`${colorArray[0]}_${cColorSpace}`](
+      colorArray.slice(1)
+    );
+  }
+
+  equal(colorArray1, colorArray2) {
+    colorArray1 = Color._getCorrectColor(colorArray1);
+    colorArray2 = Color._getCorrectColor(colorArray2);
+
+    if (colorArray1[0] === "T" || colorArray2[0] === "T") {
+      return colorArray1[0] === "T" && colorArray2[0] === "T";
+    }
+
+    if (colorArray1[0] !== colorArray2[0]) {
+      colorArray2 = this.convert(colorArray2, colorArray1[0]);
+    }
+
+    return colorArray1.slice(1).every((c, i) => c === colorArray2[i + 1]);
+  }
+}
+
+export { Color };
diff --git a/src/scripting_api/constants.js b/src/scripting_api/constants.js
index 683e37324..6fb77808d 100644
--- a/src/scripting_api/constants.js
+++ b/src/scripting_api/constants.js
@@ -13,6 +13,105 @@
  * limitations under the License.
  */
 
+const Border = Object.freeze({
+  s: "solid",
+  d: "dashed",
+  b: "beveled",
+  i: "inset",
+  u: "underline",
+});
+
+const Cursor = Object.freeze({
+  visible: 0,
+  hidden: 1,
+  delay: 2,
+});
+
+const Display = Object.freeze({
+  visible: 0,
+  hidden: 1,
+  noPrint: 2,
+  noView: 3,
+});
+
+const Font = Object.freeze({
+  Times: "Times-Roman",
+  TimesB: "Times-Bold",
+  TimesI: "Times-Italic",
+  TimesBI: "Times-BoldItalic",
+  Helv: "Helvetica",
+  HelvB: "Helvetica-Bold",
+  HelvI: "Helvetica-Oblique",
+  HelvBI: "Helvetica-BoldOblique",
+  Cour: "Courier",
+  CourB: "Courier-Bold",
+  CourI: "Courier-Oblique",
+  CourBI: "Courier-BoldOblique",
+  Symbol: "Symbol",
+  ZapfD: "ZapfDingbats",
+  KaGo: "HeiseiKakuGo-W5-UniJIS-UCS2-H",
+  KaMi: "HeiseiMin-W3-UniJIS-UCS2-H",
+});
+
+const Highlight = Object.freeze({
+  n: "none",
+  i: "invert",
+  p: "push",
+  o: "outline",
+});
+
+const Position = Object.freeze({
+  textOnly: 0,
+  iconOnly: 1,
+  iconTextV: 2,
+  textIconV: 3,
+  iconTextH: 4,
+  textIconH: 5,
+  overlay: 6,
+});
+
+const ScaleHow = Object.freeze({
+  proportional: 0,
+  anamorphic: 1,
+});
+
+const ScaleWhen = Object.freeze({
+  always: 0,
+  never: 1,
+  tooBig: 2,
+  tooSmall: 3,
+});
+
+const Style = Object.freeze({
+  ch: "check",
+  cr: "cross",
+  di: "diamond",
+  ci: "circle",
+  st: "star",
+  sq: "square",
+});
+
+const Trans = Object.freeze({
+  blindsH: "BlindsHorizontal",
+  blindsV: "BlindsVertical",
+  boxI: "BoxIn",
+  boxO: "BoxOut",
+  dissolve: "Dissolve",
+  glitterD: "GlitterDown",
+  glitterR: "GlitterRight",
+  glitterRD: "GlitterRightDown",
+  random: "Random",
+  replace: "Replace",
+  splitHI: "SplitHorizontalIn",
+  splitHO: "SplitHorizontalOut",
+  splitVI: "SplitVerticalIn",
+  splitVO: "SplitVerticalOut",
+  wipeD: "WipeDown",
+  wipeL: "WipeLeft",
+  wipeR: "WipeRight",
+  wipeU: "WipeUp",
+});
+
 const ZoomType = Object.freeze({
   none: "NoVary",
   fitP: "FitPage",
@@ -23,4 +122,16 @@ const ZoomType = Object.freeze({
   refW: "ReflowWidth",
 });
 
-export { ZoomType };
+export {
+  Border,
+  Cursor,
+  Display,
+  Font,
+  Highlight,
+  Position,
+  ScaleHow,
+  ScaleWhen,
+  Style,
+  Trans,
+  ZoomType,
+};
diff --git a/src/scripting_api/field.js b/src/scripting_api/field.js
index 1eb0304c9..af62de320 100644
--- a/src/scripting_api/field.js
+++ b/src/scripting_api/field.js
@@ -13,6 +13,7 @@
  * limitations under the License.
  */
 
+import { Color } from "./color.js";
 import { PDFObject } from "./pdf_object.js";
 
 class Field extends PDFObject {
@@ -41,7 +42,6 @@ class Field extends PDFObject {
     this.editable = data.editable;
     this.exportValues = data.exportValues;
     this.fileSelect = data.fileSelect;
-    this.fillColor = data.fillColor;
     this.hidden = data.hidden;
     this.highlight = data.highlight;
     this.lineWidth = data.lineWidth;
@@ -59,10 +59,8 @@ class Field extends PDFObject {
     this.richText = data.richText;
     this.richValue = data.richValue;
     this.rotation = data.rotation;
-    this.strokeColor = data.strokeColor;
     this.style = data.style;
     this.submitName = data.submitName;
-    this.textColor = data.textColor;
     this.textFont = data.textFont;
     this.textSize = data.textSize;
     this.type = data.type;
@@ -73,6 +71,40 @@ class Field extends PDFObject {
     // Private
     this._document = data.doc;
     this._actions = this._createActionsMap(data.actions);
+
+    this._fillColor = data.fillColor | ["T"];
+    this._strokeColor = data.strokeColor | ["G", 0];
+    this._textColor = data.textColor | ["G", 0];
+  }
+
+  get fillColor() {
+    return this._fillColor;
+  }
+
+  set fillColor(color) {
+    if (Color._isValidColor(color)) {
+      this._fillColor = color;
+    }
+  }
+
+  get strokeColor() {
+    return this._strokeColor;
+  }
+
+  set strokeColor(color) {
+    if (Color._isValidColor(color)) {
+      this._strokeColor = color;
+    }
+  }
+
+  get textColor() {
+    return this._textColor;
+  }
+
+  set textColor(color) {
+    if (Color._isValidColor(color)) {
+      this._textColor = color;
+    }
   }
 
   setAction(cTrigger, cScript) {
@@ -131,7 +163,7 @@ class Field extends PDFObject {
         `"${error.toString()}" for event ` +
         `"${eventName}" in object ${this._id}.` +
         `\n${error.stack}`;
-      this._send({ id: "error", value });
+      this._send({ command: "error", value });
     }
 
     return true;
diff --git a/src/scripting_api/fullscreen.js b/src/scripting_api/fullscreen.js
new file mode 100644
index 000000000..87b7d7f9d
--- /dev/null
+++ b/src/scripting_api/fullscreen.js
@@ -0,0 +1,145 @@
+/* Copyright 2020 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 { Cursor } from "./constants.js";
+import { PDFObject } from "./pdf_object.js";
+
+class FullScreen extends PDFObject {
+  constructor(data) {
+    super(data);
+
+    this._backgroundColor = [];
+    this._clickAdvances = true;
+    this._cursor = Cursor.hidden;
+    this._defaultTransition = "";
+    this._escapeExits = true;
+    this._isFullScreen = true;
+    this._loop = false;
+    this._timeDelay = 3600;
+    this._usePageTiming = false;
+    this._useTimer = false;
+  }
+
+  get backgroundColor() {
+    return this._backgroundColor;
+  }
+
+  set backgroundColor(_) {
+    /* TODO or not */
+  }
+
+  get clickAdvances() {
+    return this._clickAdvances;
+  }
+
+  set clickAdvances(_) {
+    /* TODO or not */
+  }
+
+  get cursor() {
+    return this._cursor;
+  }
+
+  set cursor(_) {
+    /* TODO or not */
+  }
+
+  get defaultTransition() {
+    return this._defaultTransition;
+  }
+
+  set defaultTransition(_) {
+    /* TODO or not */
+  }
+
+  get escapeExits() {
+    return this._escapeExits;
+  }
+
+  set escapeExits(_) {
+    /* TODO or not */
+  }
+
+  get isFullScreen() {
+    return this._isFullScreen;
+  }
+
+  set isFullScreen(_) {
+    /* TODO or not */
+  }
+
+  get loop() {
+    return this._loop;
+  }
+
+  set loop(_) {
+    /* TODO or not */
+  }
+
+  get timeDelay() {
+    return this._timeDelay;
+  }
+
+  set timeDelay(_) {
+    /* TODO or not */
+  }
+
+  get transitions() {
+    // This list of possible value for transition has been found:
+    // https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/5186AcroJS.pdf#page=198
+    return [
+      "Replace",
+      "WipeRight",
+      "WipeLeft",
+      "WipeDown",
+      "WipeUp",
+      "SplitHorizontalIn",
+      "SplitHorizontalOut",
+      "SplitVerticalIn",
+      "SplitVerticalOut",
+      "BlindsHorizontal",
+      "BlindsVertical",
+      "BoxIn",
+      "BoxOut",
+      "GlitterRight",
+      "GlitterDown",
+      "GlitterRightDown",
+      "Dissolve",
+      "Random",
+    ];
+  }
+
+  set transitions(_) {
+    throw new Error("fullscreen.transitions is read-only");
+  }
+
+  get usePageTiming() {
+    return this._usePageTiming;
+  }
+
+  set usePageTiming(_) {
+    /* TODO or not */
+  }
+
+  get useTimer() {
+    return this._useTimer;
+  }
+
+  set useTimer(_) {
+    /* TODO or not */
+  }
+}
+
+export { FullScreen };
diff --git a/src/scripting_api/initialization.js b/src/scripting_api/initialization.js
index 255386a39..85fafd6ff 100644
--- a/src/scripting_api/initialization.js
+++ b/src/scripting_api/initialization.js
@@ -13,18 +13,38 @@
  * limitations under the License.
  */
 
+import {
+  Border,
+  Cursor,
+  Display,
+  Font,
+  Highlight,
+  Position,
+  ScaleHow,
+  ScaleWhen,
+  Style,
+  Trans,
+  ZoomType,
+} from "./constants.js";
 import { AForm } from "./aform.js";
 import { App } from "./app.js";
+import { Color } from "./color.js";
 import { Console } from "./console.js";
 import { Doc } from "./doc.js";
 import { Field } from "./field.js";
 import { ProxyHandler } from "./proxy.js";
 import { Util } from "./util.js";
-import { ZoomType } from "./constants.js";
 
 function initSandbox({ data, extra, out }) {
   const proxyHandler = new ProxyHandler(data.dispatchEventName);
-  const { send, crackURL } = extra;
+  const {
+    send,
+    crackURL,
+    setTimeout,
+    clearTimeout,
+    setInterval,
+    clearInterval,
+  } = extra;
   const doc = new Doc({
     send,
     ...data.docInfo,
@@ -32,8 +52,14 @@ function initSandbox({ data, extra, out }) {
   const _document = { obj: doc, wrapped: new Proxy(doc, proxyHandler) };
   const app = new App({
     send,
+    setTimeout,
+    clearTimeout,
+    setInterval,
+    clearInterval,
     _document,
     calculationOrder: data.calculationOrder,
+    proxyHandler,
+    ...data.appInfo,
   });
   const util = new Util({ crackURL });
   const aform = new AForm(doc, app, util);
@@ -50,9 +76,21 @@ function initSandbox({ data, extra, out }) {
 
   out.global = Object.create(null);
   out.app = new Proxy(app, proxyHandler);
+  out.color = new Proxy(new Color(), proxyHandler);
   out.console = new Proxy(new Console({ send }), proxyHandler);
   out.util = new Proxy(util, proxyHandler);
+  out.border = Border;
+  out.cursor = Cursor;
+  out.display = Display;
+  out.font = Font;
+  out.highlight = Highlight;
+  out.position = Position;
+  out.scaleHow = ScaleHow;
+  out.scaleWhen = ScaleWhen;
+  out.style = Style;
+  out.trans = Trans;
   out.zoomtype = ZoomType;
+
   for (const name of Object.getOwnPropertyNames(AForm.prototype)) {
     if (name.startsWith("AF")) {
       out[name] = aform[name].bind(aform);
diff --git a/src/scripting_api/quickjs-sandbox.js b/src/scripting_api/quickjs-sandbox.js
index 59b4a5b45..cbf6e4e87 100644
--- a/src/scripting_api/quickjs-sandbox.js
+++ b/src/scripting_api/quickjs-sandbox.js
@@ -83,7 +83,11 @@ class Sandbox {
   evalForTesting(code, key) {
     if (this._testMode) {
       this._evalInSandbox(
-        `send({ id: "${key}", result: ${code} });`,
+        `try {
+           send({ id: "${key}", result: ${code} });
+         } catch (error) {
+           send({ id: "${key}", result: error.message });
+         }`,
         this._alertOnError
       );
     }
diff --git a/src/scripting_api/thermometer.js b/src/scripting_api/thermometer.js
new file mode 100644
index 000000000..5e1647794
--- /dev/null
+++ b/src/scripting_api/thermometer.js
@@ -0,0 +1,69 @@
+/* Copyright 2020 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 { PDFObject } from "./pdf_object.js";
+
+class Thermometer extends PDFObject {
+  constructor(data) {
+    super(data);
+
+    this._cancelled = false;
+    this._duration = 100;
+    this._text = "";
+    this._value = 0;
+  }
+
+  get cancelled() {
+    return this._cancelled;
+  }
+
+  set cancelled(_) {
+    throw new Error("thermometer.cancelled is read-only");
+  }
+
+  get duration() {
+    return this._duration;
+  }
+
+  set duration(val) {
+    this._duration = val;
+  }
+
+  get text() {
+    return this._text;
+  }
+
+  set text(val) {
+    this._text = val;
+  }
+
+  get value() {
+    return this._value;
+  }
+
+  set value(val) {
+    this._value = val;
+  }
+
+  begin() {
+    /* TODO */
+  }
+
+  end() {
+    /* TODO */
+  }
+}
+
+export { Thermometer };
diff --git a/test/unit/scripting_spec.js b/test/unit/scripting_spec.js
index 6495da2ce..e4d6b1077 100644
--- a/test/unit/scripting_spec.js
+++ b/test/unit/scripting_spec.js
@@ -23,6 +23,15 @@ describe("Scripting", function () {
     return id;
   }
 
+  function myeval(code) {
+    const key = (test_id++).toString();
+    return sandbox.eval(code, key).then(() => {
+      const result = send_queue.get(key).result;
+      send_queue.delete(key);
+      return result;
+    });
+  }
+
   beforeAll(function (done) {
     test_id = 0;
     ref = 1;
@@ -92,6 +101,7 @@ describe("Scripting", function () {
           ],
         },
         calculationOrder: [],
+        appInfo: { language: "en-US", platform: "Linux x86_64" },
         dispatchEventName: "_dispatchMe",
       };
       sandbox.createSandbox(data);
@@ -115,15 +125,9 @@ describe("Scripting", function () {
   });
 
   describe("Util", function () {
-    function myeval(code) {
-      const key = (test_id++).toString();
-      return sandbox.eval(code, key).then(() => {
-        return send_queue.get(key).result;
-      });
-    }
-
     beforeAll(function (done) {
       sandbox.createSandbox({
+        appInfo: { language: "en-US", platform: "Linux x86_64" },
         objects: {},
         calculationOrder: [],
         dispatchEventName: "_dispatchMe",
@@ -214,7 +218,7 @@ describe("Scripting", function () {
           .then(() => done());
       });
 
-      it(" print a string with a percent", function (done) {
+      it("print a string with a percent", function (done) {
         myeval(`util.printf("%%s")`)
           .then(value => {
             expect(value).toEqual("%%s");
@@ -250,6 +254,7 @@ describe("Scripting", function () {
             },
           ],
         },
+        appInfo: { language: "en-US", platform: "Linux x86_64" },
         calculationOrder: [],
         dispatchEventName: "_dispatchMe",
       };
@@ -287,6 +292,7 @@ describe("Scripting", function () {
             },
           ],
         },
+        appInfo: { language: "en-US", platform: "Linux x86_64" },
         calculationOrder: [],
         dispatchEventName: "_dispatchMe",
       };
@@ -328,6 +334,7 @@ describe("Scripting", function () {
             },
           ],
         },
+        appInfo: { language: "en-US", platform: "Linux x86_64" },
         calculationOrder: [],
         dispatchEventName: "_dispatchMe",
       };
@@ -368,6 +375,7 @@ describe("Scripting", function () {
             },
           ],
         },
+        appInfo: { language: "en-US", platform: "Linux x86_64" },
         calculationOrder: [],
         dispatchEventName: "_dispatchMe",
       };
@@ -412,6 +420,7 @@ describe("Scripting", function () {
             },
           ],
         },
+        appInfo: { language: "en-US", platform: "Linux x86_64" },
         calculationOrder: [refId2],
         dispatchEventName: "_dispatchMe",
       };
@@ -435,4 +444,133 @@ describe("Scripting", function () {
         .catch(done.fail);
     });
   });
+
+  describe("Color", function () {
+    beforeAll(function (done) {
+      sandbox.createSandbox({
+        appInfo: { language: "en-US", platform: "Linux x86_64" },
+        objects: {},
+        calculationOrder: [],
+        dispatchEventName: "_dispatchMe",
+      });
+      done();
+    });
+
+    function round(color) {
+      return [
+        color[0],
+        ...color.slice(1).map(x => Math.round(x * 1000) / 1000),
+      ];
+    }
+
+    it("should convert RGB color for different color spaces", function (done) {
+      Promise.all([
+        myeval(`color.convert(["RGB", 0.1, 0.2, 0.3], "T")`).then(value => {
+          expect(round(value)).toEqual(["T"]);
+        }),
+        myeval(`color.convert(["RGB", 0.1, 0.2, 0.3], "G")`).then(value => {
+          expect(round(value)).toEqual(["G", 0.181]);
+        }),
+        myeval(`color.convert(["RGB", 0.1, 0.2, 0.3], "RGB")`).then(value => {
+          expect(round(value)).toEqual(["RGB", 0.1, 0.2, 0.3]);
+        }),
+        myeval(`color.convert(["RGB", 0.1, 0.2, 0.3], "CMYK")`).then(value => {
+          expect(round(value)).toEqual(["CMYK", 0.9, 0.8, 0.7, 0.7]);
+        }),
+      ]).then(() => done());
+    });
+
+    it("should convert CMYK color for different color spaces", function (done) {
+      Promise.all([
+        myeval(`color.convert(["CMYK", 0.1, 0.2, 0.3, 0.4], "T")`).then(
+          value => {
+            expect(round(value)).toEqual(["T"]);
+          }
+        ),
+        myeval(`color.convert(["CMYK", 0.1, 0.2, 0.3, 0.4], "G")`).then(
+          value => {
+            expect(round(value)).toEqual(["G", 0.371]);
+          }
+        ),
+        myeval(`color.convert(["CMYK", 0.1, 0.2, 0.3, 0.4], "RGB")`).then(
+          value => {
+            expect(round(value)).toEqual(["RGB", 0.5, 0.3, 0.4]);
+          }
+        ),
+        myeval(`color.convert(["CMYK", 0.1, 0.2, 0.3, 0.4], "CMYK")`).then(
+          value => {
+            expect(round(value)).toEqual(["CMYK", 0.1, 0.2, 0.3, 0.4]);
+          }
+        ),
+      ]).then(() => done());
+    });
+
+    it("should convert Gray color for different color spaces", function (done) {
+      Promise.all([
+        myeval(`color.convert(["G", 0.1], "T")`).then(value => {
+          expect(round(value)).toEqual(["T"]);
+        }),
+        myeval(`color.convert(["G", 0.1], "G")`).then(value => {
+          expect(round(value)).toEqual(["G", 0.1]);
+        }),
+        myeval(`color.convert(["G", 0.1], "RGB")`).then(value => {
+          expect(round(value)).toEqual(["RGB", 0.1, 0.1, 0.1]);
+        }),
+        myeval(`color.convert(["G", 0.1], "CMYK")`).then(value => {
+          expect(round(value)).toEqual(["CMYK", 0, 0, 0, 0.9]);
+        }),
+      ]).then(() => done());
+    });
+
+    it("should convert Transparent color for different color spaces", function (done) {
+      Promise.all([
+        myeval(`color.convert(["T"], "T")`).then(value => {
+          expect(round(value)).toEqual(["T"]);
+        }),
+        myeval(`color.convert(["T"], "G")`).then(value => {
+          expect(round(value)).toEqual(["G", 0]);
+        }),
+        myeval(`color.convert(["T"], "RGB")`).then(value => {
+          expect(round(value)).toEqual(["RGB", 0, 0, 0]);
+        }),
+        myeval(`color.convert(["T"], "CMYK")`).then(value => {
+          expect(round(value)).toEqual(["CMYK", 0, 0, 0, 1]);
+        }),
+      ]).then(() => done());
+    });
+  });
+
+  describe("App", function () {
+    beforeAll(function (done) {
+      sandbox.createSandbox({
+        appInfo: { language: "en-US", platform: "Linux x86_64" },
+        objects: {},
+        calculationOrder: [],
+        dispatchEventName: "_dispatchMe",
+      });
+      done();
+    });
+
+    it("should test language", function (done) {
+      Promise.all([
+        myeval(`app.language`).then(value => {
+          expect(value).toEqual("ENU");
+        }),
+        myeval(`app.language = "hello"`).then(value => {
+          expect(value).toEqual("app.language is read-only");
+        }),
+      ]).then(() => done());
+    });
+
+    it("should test platform", function (done) {
+      Promise.all([
+        myeval(`app.platform`).then(value => {
+          expect(value).toEqual("UNIX");
+        }),
+        myeval(`app.platform = "hello"`).then(value => {
+          expect(value).toEqual("app.platform is read-only");
+        }),
+      ]).then(() => done());
+    });
+  });
 });
diff --git a/web/app.js b/web/app.js
index 91384891b..094af54d0 100644
--- a/web/app.js
+++ b/web/app.js
@@ -1486,6 +1486,10 @@ const PDFViewerApplication = {
       objects,
       dispatchEventName,
       calculationOrder,
+      appInfo: {
+        platform: navigator.platform,
+        language: navigator.language,
+      },
       docInfo: {
         ...info,
         baseURL: this.baseUrl,