Repository: Hopding/pdf-lib
Pull request #654 (2020-10-31 18:02:37)

Add PDFDocument.addJavaScript method


Patch:
diff --git a/src/api/PDFDocument.ts b/src/api/PDFDocument.ts
index 646addf..8894d76 100644
--- a/src/api/PDFDocument.ts
+++ b/src/api/PDFDocument.ts
@@ -61,6 +61,8 @@ import {
 } from 'src/utils';
 import FileEmbedder from 'src/core/embedders/FileEmbedder';
 import PDFEmbeddedFile from 'src/api/PDFEmbeddedFile';
+import PDFJavaScript from 'src/api/PDFJavaScript';
+import JavaScriptEmbedder from 'src/core/embedders/JavaScriptEmbedder';
 
 /**
  * Represents a PDF document.
@@ -182,6 +184,7 @@ export default class PDFDocument {
   private readonly images: PDFImage[];
   private readonly embeddedPages: PDFEmbeddedPage[];
   private readonly embeddedFiles: PDFEmbeddedFile[];
+  private readonly javaScripts: PDFJavaScript[];
 
   private constructor(
     context: PDFContext,
@@ -202,6 +205,7 @@ export default class PDFDocument {
     this.images = [];
     this.embeddedPages = [];
     this.embeddedFiles = [];
+    this.javaScripts = [];
 
     if (!ignoreEncryption && this.isEncrypted) throw new EncryptedPDFError();
 
@@ -713,6 +717,41 @@ export default class PDFDocument {
     return copiedPages;
   }
 
+  /**
+   * Add JavaScript to this document. The supplied `script` is executed when the
+   * document is opened. The `script` can be used to perform some operation
+   * when the document is opened (e.g. logging to the console), or it can be
+   * used to define a function that can be referenced later in a JavaScript
+   * action. For example:
+   * ```js
+   * // Show "Hello World!" in the console when the PDF is opened
+   * pdfDoc.addJavaScript(
+   *   'main',
+   *   'console.show(); console.println("Hello World!");'
+   * );
+   *
+   * // Define a function named "foo" that can be called in JavaScript Actions
+   * pdfDoc.addJavaScript(
+   *   'foo',
+   *   'function foo() { return "foo"; }'
+   * );
+   * ```
+   * See the [JavaScript for Acrobat API Reference](https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/js_api_reference.pdf)
+   * for details.
+   * @param name The name of the script. Must be unique per document.
+   * @param script The JavaScript to execute.
+   */
+  addJavaScript(name: string, script: string) {
+    assertIs(name, 'name', ['string']);
+    assertIs(script, 'script', ['string']);
+
+    const embedder = JavaScriptEmbedder.for(script, name);
+
+    const ref = this.context.nextRef();
+    const javaScript = PDFJavaScript.of(ref, this, embedder);
+    this.javaScripts.push(javaScript);
+  }
+
   /**
    * Add an attachment to this document. Attachments are visible in the
    * "Attachments" panel of Adobe Acrobat and some other PDF readers. Any
@@ -1130,6 +1169,7 @@ export default class PDFDocument {
     await this.embedAll(this.images);
     await this.embedAll(this.embeddedPages);
     await this.embedAll(this.embeddedFiles);
+    await this.embedAll(this.javaScripts);
   }
 
   /**
diff --git a/src/api/PDFJavaScript.ts b/src/api/PDFJavaScript.ts
new file mode 100644
index 0000000..8727d6d
--- /dev/null
+++ b/src/api/PDFJavaScript.ts
@@ -0,0 +1,82 @@
+import Embeddable from 'src/api/Embeddable';
+import PDFDocument from 'src/api/PDFDocument';
+import JavaScriptEmbedder from 'src/core/embedders/JavaScriptEmbedder';
+import { PDFName, PDFArray, PDFDict, PDFHexString, PDFRef } from 'src/core';
+
+/**
+ * Represents JavaScript that has been embedded in a [[PDFDocument]].
+ */
+export default class PDFJavaScript implements Embeddable {
+  /**
+   * > **NOTE:** You probably don't want to call this method directly. Instead,
+   * > consider using the [[PDFDocument.addJavaScript]] method, which will
+   * create instances of [[PDFJavaScript]] for you.
+   *
+   * Create an instance of [[PDFJavaScript]] from an existing ref and script
+   *
+   * @param ref The unique reference for this script.
+   * @param doc The document to which the script will belong.
+   * @param embedder The embedder that will be used to embed the script.
+   */
+  static of = (ref: PDFRef, doc: PDFDocument, embedder: JavaScriptEmbedder) =>
+    new PDFJavaScript(ref, doc, embedder);
+
+  /** The unique reference assigned to this embedded script within the document. */
+  readonly ref: PDFRef;
+
+  /** The document to which this embedded script belongs. */
+  readonly doc: PDFDocument;
+
+  private alreadyEmbedded = false;
+  private readonly embedder: JavaScriptEmbedder;
+
+  private constructor(
+    ref: PDFRef,
+    doc: PDFDocument,
+    embedder: JavaScriptEmbedder,
+  ) {
+    this.ref = ref;
+    this.doc = doc;
+    this.embedder = embedder;
+  }
+
+  /**
+   * > **NOTE:** You probably don't need to call this method directly. The
+   * > [[PDFDocument.save]] and [[PDFDocument.saveAsBase64]] methods will
+   * > automatically ensure all JavaScripts get embedded.
+   *
+   * Embed this JavaScript in its document.
+   *
+   * @returns Resolves when the embedding is complete.
+   */
+  async embed(): Promise<void> {
+    if (!this.alreadyEmbedded) {
+      const { catalog, context } = this.doc;
+
+      const ref = await this.embedder.embedIntoContext(
+        this.doc.context,
+        this.ref,
+      );
+
+      if (!catalog.has(PDFName.of('Names'))) {
+        catalog.set(PDFName.of('Names'), context.obj({}));
+      }
+      const Names = catalog.lookup(PDFName.of('Names'), PDFDict);
+
+      if (!Names.has(PDFName.of('JavaScript'))) {
+        Names.set(PDFName.of('JavaScript'), context.obj({}));
+      }
+      const Javascript = Names.lookup(PDFName.of('JavaScript'), PDFDict);
+
+      if (!Javascript.has(PDFName.of('Names'))) {
+        Javascript.set(PDFName.of('Names'), context.obj([]));
+      }
+      const JSNames = Javascript.lookup(PDFName.of('Names'), PDFArray);
+
+      JSNames.push(PDFHexString.fromText(this.embedder.scriptName));
+      JSNames.push(ref);
+
+      this.alreadyEmbedded = true;
+    }
+  }
+}
diff --git a/src/api/index.ts b/src/api/index.ts
index ed301b6..1c7e069 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -15,4 +15,5 @@ export { default as PDFFont } from 'src/api/PDFFont';
 export { default as PDFImage } from 'src/api/PDFImage';
 export { default as PDFPage } from 'src/api/PDFPage';
 export { default as PDFEmbeddedPage } from 'src/api/PDFEmbeddedPage';
+export { default as PDFJavaScript } from 'src/api/PDFJavaScript';
 export { default as Embeddable } from 'src/api/Embeddable';
diff --git a/src/core/embedders/JavaScriptEmbedder.ts b/src/core/embedders/JavaScriptEmbedder.ts
new file mode 100644
index 0000000..b054601
--- /dev/null
+++ b/src/core/embedders/JavaScriptEmbedder.ts
@@ -0,0 +1,34 @@
+import PDFHexString from 'src/core/objects/PDFHexString';
+import PDFContext from 'src/core/PDFContext';
+import PDFRef from 'src/core/objects/PDFRef';
+
+class JavaScriptEmbedder {
+  static for(script: string, scriptName: string) {
+    return new JavaScriptEmbedder(script, scriptName);
+  }
+
+  private readonly script: string;
+  readonly scriptName: string;
+
+  private constructor(script: string, scriptName: string) {
+    this.script = script;
+    this.scriptName = scriptName;
+  }
+
+  async embedIntoContext(context: PDFContext, ref?: PDFRef): Promise<PDFRef> {
+    const jsActionDict = context.obj({
+      Type: 'Action',
+      S: 'JavaScript',
+      JS: PDFHexString.fromText(this.script),
+    });
+
+    if (ref) {
+      context.assign(ref, jsActionDict);
+      return ref;
+    } else {
+      return context.register(jsActionDict);
+    }
+  }
+}
+
+export default JavaScriptEmbedder;


Test patch:
diff --git a/apps/deno/tests/test1.ts b/apps/deno/tests/test1.ts
index 42e423b..52957b5 100644
--- a/apps/deno/tests/test1.ts
+++ b/apps/deno/tests/test1.ts
@@ -67,6 +67,11 @@ export default async (assets: Assets) => {
 
   const size = 750;
 
+  pdfDoc.addJavaScript(
+    'main',
+    'console.show(); console.println("Hello World!")',
+  );
+
   /********************** Page 1 **********************/
 
   // This page tests different drawing operations as well as adding custom
diff --git a/apps/node/tests/test1.ts b/apps/node/tests/test1.ts
index 01d60a1..113b70e 100644
--- a/apps/node/tests/test1.ts
+++ b/apps/node/tests/test1.ts
@@ -67,6 +67,11 @@ export default async (assets: Assets) => {
 
   const size = 750;
 
+  pdfDoc.addJavaScript(
+    'main',
+    'console.show(); console.println("Hello World!")',
+  );
+
   /********************** Page 1 **********************/
 
   // This page tests different drawing operations as well as adding custom
diff --git a/apps/rn/src/tests/test1.js b/apps/rn/src/tests/test1.js
index 3ded89a..afce41e 100644
--- a/apps/rn/src/tests/test1.js
+++ b/apps/rn/src/tests/test1.js
@@ -72,6 +72,11 @@ export default async () => {
 
   const size = 750;
 
+  pdfDoc.addJavaScript(
+    'main',
+    'console.show(); console.println("Hello World!")',
+  );
+
   /********************** Page 1 **********************/
 
   // This page tests different drawing operations as well as adding custom
diff --git a/apps/web/test1.html b/apps/web/test1.html
index f0ef47c..5bf04a8 100644
--- a/apps/web/test1.html
+++ b/apps/web/test1.html
@@ -120,6 +120,11 @@
 
       const size = 750;
 
+      pdfDoc.addJavaScript(
+        'main',
+        'console.show(); console.println("Hello World!")',
+      );
+
       /********************** Page 1 **********************/
 
       // This page tests different drawing operations as well as adding custom
diff --git a/tests/api/PDFDocument.spec.ts b/tests/api/PDFDocument.spec.ts
index 2d88d65..601cc52 100644
--- a/tests/api/PDFDocument.spec.ts
+++ b/tests/api/PDFDocument.spec.ts
@@ -3,7 +3,10 @@ import fs from 'fs';
 import {
   EncryptedPDFError,
   ParseSpeeds,
+  PDFArray,
+  PDFDict,
   PDFDocument,
+  PDFHexString,
   PDFName,
   PDFPage,
 } from 'src/index';
@@ -299,4 +302,42 @@ describe(`PDFDocument`, () => {
       );
     });
   });
+
+  describe(`addJavaScript method`, () => {
+    it(`adds the script to the catalog`, async () => {
+      const pdfDoc = await PDFDocument.create();
+      pdfDoc.addJavaScript(
+        'main',
+        'console.show(); console.println("Hello World");',
+      );
+      await pdfDoc.flush();
+
+      expect(pdfDoc.catalog.has(PDFName.of('Names')));
+      const Names = pdfDoc.catalog.lookup(PDFName.of('Names'), PDFDict);
+      expect(Names.has(PDFName.of('JavaScript')));
+      const Javascript = Names.lookup(PDFName.of('JavaScript'), PDFDict);
+      expect(Javascript.has(PDFName.of('Names')));
+      const JSNames = Javascript.lookup(PDFName.of('Names'), PDFArray);
+      expect(JSNames.lookup(0, PDFHexString).decodeText()).toEqual('main');
+    });
+
+    it(`does not overwrite scripts`, async () => {
+      const pdfDoc = await PDFDocument.create();
+      pdfDoc.addJavaScript(
+        'first',
+        'console.show(); console.println("First");',
+      );
+      pdfDoc.addJavaScript(
+        'second',
+        'console.show(); console.println("Second");',
+      );
+      await pdfDoc.flush();
+
+      const Names = pdfDoc.catalog.lookup(PDFName.of('Names'), PDFDict);
+      const Javascript = Names.lookup(PDFName.of('JavaScript'), PDFDict);
+      const JSNames = Javascript.lookup(PDFName.of('Names'), PDFArray);
+      expect(JSNames.lookup(0, PDFHexString).decodeText()).toEqual('first');
+      expect(JSNames.lookup(2, PDFHexString).decodeText()).toEqual('second');
+    });
+  });
 });