Repository: Hopding/pdf-lib
Pull request #719 (2020-12-20 23:14:49)

Add option to show document's title in window's title bar
Tweaks to #708 


Patch:
diff --git a/scratchpad/index.ts b/scratchpad/index.ts
index 8a52d32..74d2a91 100644
--- a/scratchpad/index.ts
+++ b/scratchpad/index.ts
@@ -6,83 +6,21 @@ import { PDFDocument } from 'src/index';
   // Create a new PDFDocument
   const pdfDoc = await PDFDocument.create();
 
+  // Set the title and include the new option.
+  pdfDoc.setTitle('Scratchpad Test Doc', { showInWindowTitleBar: true });
+
   // Add a blank page to the document
   const page = pdfDoc.addPage([550, 750]);
 
-  // Get the form so we can add fields to it
-  const form = pdfDoc.getForm();
-
-  // Add the superhero text field and description
-  page.drawText('Enter your favorite superhero:', { x: 50, y: 700, size: 20 });
-
-  const superheroField = form.createTextField('favorite.superhero');
-  superheroField.setText('One Punch Man');
-  superheroField.addToPage(page, { x: 55, y: 640 });
-
-  // Add the rocket radio group, labels, and description
-  page.drawText('Select your favorite rocket:', { x: 50, y: 600, size: 20 });
-
-  page.drawText('Falcon Heavy', { x: 120, y: 560, size: 18 });
-  page.drawText('Saturn IV', { x: 120, y: 500, size: 18 });
-  page.drawText('Delta IV Heavy', { x: 340, y: 560, size: 18 });
-  page.drawText('Space Launch System', { x: 340, y: 500, size: 18 });
-
-  const rocketField = form.createRadioGroup('favorite.rocket');
-  rocketField.addOptionToPage('Falcon Heavy', page, { x: 55, y: 540 });
-  rocketField.addOptionToPage('Saturn IV', page, { x: 55, y: 480 });
-  rocketField.addOptionToPage('Delta IV Heavy', page, { x: 275, y: 540 });
-  rocketField.addOptionToPage('Space Launch System', page, { x: 275, y: 480 });
-  rocketField.select('Saturn IV');
-
-  // Add the gundam check boxes, labels, and description
-  page.drawText('Select your favorite gundams:', { x: 50, y: 440, size: 20 });
-
-  page.drawText('Exia', { x: 120, y: 400, size: 18 });
-  page.drawText('Kyrios', { x: 120, y: 340, size: 18 });
-  page.drawText('Virtue', { x: 340, y: 400, size: 18 });
-  page.drawText('Dynames', { x: 340, y: 340, size: 18 });
-
-  const exiaField = form.createCheckBox('gundam.exia');
-  const kyriosField = form.createCheckBox('gundam.kyrios');
-  const virtueField = form.createCheckBox('gundam.virtue');
-  const dynamesField = form.createCheckBox('gundam.dynames');
-
-  exiaField.addToPage(page, { x: 55, y: 380 });
-  kyriosField.addToPage(page, { x: 55, y: 320 });
-  virtueField.addToPage(page, { x: 275, y: 380 });
-  dynamesField.addToPage(page, { x: 275, y: 320 });
-
-  exiaField.check();
-  dynamesField.check();
-
-  // Add the planet dropdown and description
-  page.drawText('Select your favorite planet*:', { x: 50, y: 280, size: 20 });
-
-  const planetsField = form.createDropdown('favorite.planet');
-  planetsField.addOptions(['Venus', 'Earth', 'Mars', 'Pluto']);
-  planetsField.select('Pluto');
-  planetsField.addToPage(page, { x: 55, y: 220 });
-
-  // Add the person option list and description
-  page.drawText('Select your favorite person:', { x: 50, y: 180, size: 18 });
-
-  const personField = form.createOptionList('favorite.person');
-  personField.addOptions([
-    'Julius Caesar',
-    'Ada Lovelace',
-    'Cleopatra',
-    'Aaron Burr',
-    'Mark Antony',
-  ]);
-  personField.select('Ada Lovelace');
-  personField.addToPage(page, { x: 55, y: 70 });
-
-  // Just saying...
-  page.drawText(`* Pluto should be a planet too!`, { x: 15, y: 15, size: 15 });
+  // Manual test...
+  page.drawText(
+    `The window's title should match what we set in the metadata.`,
+    { x: 15, y: 400, size: 15 },
+  );
 
-  // Save the PDF with filled form fields
+  // Save the PDF
   const pdfBytes = await pdfDoc.save();
 
   fs.writeFileSync('out.pdf', pdfBytes);
-  openPdf('out.pdf', Reader.Preview);
+  openPdf('out.pdf', Reader.Acrobat);
 })();
diff --git a/src/api/PDFDocument.ts b/src/api/PDFDocument.ts
index 3beff51..b2529e0 100644
--- a/src/api/PDFDocument.ts
+++ b/src/api/PDFDocument.ts
@@ -43,6 +43,7 @@ import {
   LoadOptions,
   CreateOptions,
   EmbedFontOptions,
+  SetTitleOptions,
 } from 'src/api/PDFDocumentOptions';
 import PDFObject from 'src/core/objects/PDFObject';
 import { Fontkit } from 'src/types/fontkit';
@@ -389,12 +390,27 @@ export default class PDFDocument {
    * ```js
    * pdfDoc.setTitle('🥚 The Life of an Egg 🍳')
    * ```
+   *
+   * To display the title in the window's title bar, set the
+   * `showInWindowTitleBar` option to `true` (works for _most_ PDF readers).
+   * For example:
+   * ```js
+   * pdfDoc.setTitle('🥚 The Life of an Egg 🍳', { showInWindowTitleBar: true })
+   * ```
+   *
    * @param title The title of this document.
+   * @param options The options to be used when setting the title.
    */
-  setTitle(title: string): void {
+  setTitle(title: string, options?: SetTitleOptions): void {
     assertIs(title, 'title', ['string']);
     const key = PDFName.of('Title');
     this.getInfoDict().set(key, PDFHexString.fromText(title));
+
+    // Indicate that readers should display the title rather than the filename
+    if (options?.showInWindowTitleBar) {
+      const prefs = this.catalog.getOrCreateViewerPreferences();
+      prefs.setDisplayDocTitle(true);
+    }
   }
 
   /**
diff --git a/src/api/PDFDocumentOptions.ts b/src/api/PDFDocumentOptions.ts
index 32731c3..fc6430d 100644
--- a/src/api/PDFDocumentOptions.ts
+++ b/src/api/PDFDocumentOptions.ts
@@ -38,3 +38,7 @@ export interface EmbedFontOptions {
   customName?: string;
   features?: TypeFeatures;
 }
+
+export interface SetTitleOptions {
+  showInWindowTitleBar: boolean;
+}
diff --git a/src/core/interactive/ViewerPreferences.ts b/src/core/interactive/ViewerPreferences.ts
new file mode 100644
index 0000000..f24eec9
--- /dev/null
+++ b/src/core/interactive/ViewerPreferences.ts
@@ -0,0 +1,37 @@
+import PDFDict from 'src/core/objects/PDFDict';
+import PDFName from 'src/core/objects/PDFName';
+import PDFBool from 'src/core/objects/PDFBool';
+import PDFContext from 'src/core/PDFContext';
+
+class ViewerPreferences {
+  readonly dict: PDFDict;
+
+  static fromDict = (dict: PDFDict): ViewerPreferences =>
+    new ViewerPreferences(dict);
+
+  static create = (context: PDFContext) => {
+    const dict = context.obj({});
+    return new ViewerPreferences(dict);
+  };
+
+  protected constructor(dict: PDFDict) {
+    this.dict = dict;
+  }
+
+  DisplayDocTitle(): PDFBool | undefined {
+    const DisplayDocTitle = this.dict.lookup(PDFName.of('DisplayDocTitle'));
+    if (DisplayDocTitle instanceof PDFBool) return DisplayDocTitle;
+    return undefined;
+  }
+
+  getDisplayDocTitle(): boolean {
+    return this.DisplayDocTitle()?.asBoolean() ?? false;
+  }
+
+  setDisplayDocTitle(displayTitle: boolean) {
+    const DisplayDocTitle = this.dict.context.obj(displayTitle);
+    this.dict.set(PDFName.of('DisplayDocTitle'), DisplayDocTitle);
+  }
+}
+
+export default ViewerPreferences;
diff --git a/src/core/structures/PDFCatalog.ts b/src/core/structures/PDFCatalog.ts
index 21a75ca..93765f0 100644
--- a/src/core/structures/PDFCatalog.ts
+++ b/src/core/structures/PDFCatalog.ts
@@ -4,6 +4,7 @@ import PDFRef from 'src/core/objects/PDFRef';
 import PDFContext from 'src/core/PDFContext';
 import PDFPageTree from 'src/core/structures/PDFPageTree';
 import { PDFAcroForm } from 'src/core/acroform';
+import ViewerPreferences from '../interactive/ViewerPreferences';
 
 class PDFCatalog extends PDFDict {
   static withContextAndPages = (
@@ -43,6 +44,26 @@ class PDFCatalog extends PDFDict {
     return acroForm;
   }
 
+  ViewerPreferences(): PDFDict | undefined {
+    return this.lookupMaybe(PDFName.of('ViewerPreferences'), PDFDict);
+  }
+
+  getViewerPreferences(): ViewerPreferences | undefined {
+    const dict = this.ViewerPreferences();
+    if (!dict) return undefined;
+    return ViewerPreferences.fromDict(dict);
+  }
+
+  getOrCreateViewerPreferences(): ViewerPreferences {
+    let viewerPrefs = this.getViewerPreferences();
+    if (!viewerPrefs) {
+      viewerPrefs = ViewerPreferences.create(this.context);
+      const viewerPrefsRef = this.context.register(viewerPrefs.dict);
+      this.set(PDFName.of('ViewerPreferences'), viewerPrefsRef);
+    }
+    return viewerPrefs;
+  }
+
   /**
    * Inserts the given ref as a leaf node of this catalog's page tree at the
    * specified index (zero-based). Also increments the `Count` of each node in


Test patch:
diff --git a/apps/deno/tests/test1.ts b/apps/deno/tests/test1.ts
index d25c985..3627f37 100644
--- a/apps/deno/tests/test1.ts
+++ b/apps/deno/tests/test1.ts
@@ -36,7 +36,7 @@ const ipsumLines = [
 export default async (assets: Assets) => {
   const pdfDoc = await PDFDocument.create();
 
-  pdfDoc.setTitle('🥚 The Life of an Egg 🍳');
+  pdfDoc.setTitle('🥚 The Life of an Egg 🍳', { showInWindowTitleBar: true });
   pdfDoc.setAuthor('Humpty Dumpty');
   pdfDoc.setSubject('📘 An Epic Tale of Woe 📖');
   pdfDoc.setKeywords(['eggs', 'wall', 'fall', 'king', 'horses', 'men']);
diff --git a/apps/node/tests/test1.ts b/apps/node/tests/test1.ts
index d9a4fb7..62c1189 100644
--- a/apps/node/tests/test1.ts
+++ b/apps/node/tests/test1.ts
@@ -34,7 +34,7 @@ const ipsumLines = [
 export default async (assets: Assets) => {
   const pdfDoc = await PDFDocument.create();
 
-  pdfDoc.setTitle('🥚 The Life of an Egg 🍳');
+  pdfDoc.setTitle('🥚 The Life of an Egg 🍳', { showInWindowTitleBar: true });
   pdfDoc.setAuthor('Humpty Dumpty');
   pdfDoc.setSubject('📘 An Epic Tale of Woe 📖');
   pdfDoc.setKeywords(['eggs', 'wall', 'fall', 'king', 'horses', 'men']);
diff --git a/apps/rn/src/tests/test1.js b/apps/rn/src/tests/test1.js
index 4c2bc90..73faeaa 100644
--- a/apps/rn/src/tests/test1.js
+++ b/apps/rn/src/tests/test1.js
@@ -35,7 +35,7 @@ const ipsumLines = [
 export default async () => {
   const pdfDoc = await PDFDocument.create();
 
-  pdfDoc.setTitle('🥚 The Life of an Egg 🍳');
+  pdfDoc.setTitle('🥚 The Life of an Egg 🍳', { showInWindowTitleBar: true });
   pdfDoc.setAuthor('Humpty Dumpty');
   pdfDoc.setSubject('📘 An Epic Tale of Woe 📖');
   pdfDoc.setKeywords(['eggs', 'wall', 'fall', 'king', 'horses', 'men']);
diff --git a/tests/api/PDFDocument.spec.ts b/tests/api/PDFDocument.spec.ts
index 601cc52..a392dfa 100644
--- a/tests/api/PDFDocument.spec.ts
+++ b/tests/api/PDFDocument.spec.ts
@@ -303,6 +303,40 @@ describe(`PDFDocument`, () => {
     });
   });
 
+  describe(`setTitle() method with options`, () => {
+    it(`defaults to an undefined ViewerPreferences dict`, async () => {
+      const pdfDoc = await PDFDocument.create();
+
+      expect(
+        pdfDoc.catalog.lookupMaybe(PDFName.of('ViewerPreferences'), PDFDict),
+      ).toBeUndefined();
+    });
+
+    it(`does not set the ViewerPreferences dict if the option is not set`, async () => {
+      const pdfDoc = await PDFDocument.create();
+
+      pdfDoc.setTitle('Testing setTitle Title');
+
+      expect(
+        pdfDoc.catalog.lookupMaybe(PDFName.of('ViewerPreferences'), PDFDict),
+      ).toBeUndefined();
+
+      expect(pdfDoc.getTitle()).toBe('Testing setTitle Title');
+    });
+
+    it(`creates the ViewerPreferences dict when the option is set`, async () => {
+      const pdfDoc = await PDFDocument.create();
+
+      pdfDoc.setTitle('ViewerPrefs Test Creation', {
+        showInWindowTitleBar: true,
+      });
+
+      expect(
+        pdfDoc.catalog.lookupMaybe(PDFName.of('ViewerPreferences'), PDFDict),
+      );
+    });
+  });
+
   describe(`addJavaScript method`, () => {
     it(`adds the script to the catalog`, async () => {
       const pdfDoc = await PDFDocument.create();