EmbedPDF

Actions & scripts

A PDF can do things by itself: a button that resets a form, a field that adds up others, a link that opens a website, a script that runs when the document opens. The actions plugin runs them, with rules you choose. Add withActions() to provideEmbedPdf(), and withActionsUi() for the browser’s defaults for opening websites and printing:

actions-setup.ts
import { Component } from '@angular/core';
import { provideEmbedPdf } from '@embedpdf/angular/runtime';
import { withActions, withActionsUi } from '@embedpdf/angular/actions';
import { engine } from './pdf';

@Component({
  selector: 'app-contract-viewer',
  providers: [provideEmbedPdf({ engine }, /* … */ withActions(), withActionsUi())],
  template: `<!-- your viewer -->`,
})
export class ContractViewer {}

Without it, the plugin still runs what stays inside the viewer, such as going to a page or resetting a form, but it won’t open a website or a print dialog.

What runs#

ActionBy default
Go to a page or a destinationRuns
A viewer action, such as next pageRuns
Show or hide a form fieldRuns
Reset a formRuns
Open a websiteGoes to your UI adapter when the user clicked; reported otherwise
PrintGoes to your UI adapter when the user clicked; never on its own
Submit a formGoes to your submit handler when the user clicked; never on its own
JavaScriptOff, until you turn it on

Actions that start a program, open another file on the user’s computer, play media or import data never run.

Turn on JavaScript#

Many forms calculate totals, check values and format dates with JavaScript. Turn it on:

withActions({ javascript: { enabled: true } });

Scripts run in a sealed sandbox: they can read and change the document’s form fields and move between pages, but they can’t reach your app, the page’s HTML or the network. Each script has a time budget, so a script that loops forever is stopped instead of freezing the viewer.

A script can ask who the user is (identity.name in Acrobat’s JavaScript). It gets the user the document was opened for, and you can add the fields scripts often ask for:

withActions({
  javascript: { enabled: true, identity: { corporation: 'Acme', email: 'dana@acme.com' } },
});

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  inject,
  Injectable,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import {
  EpdfActions,
  withActions,
  withActionsUi,
  type ActionContext,
  type PdfActionTree,
} from '@embedpdf/angular/actions';
import { localEngine } from '@embedpdf/engine';

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// A JavaScript action, as a PDF carries it behind a button or a link.
const script = (code: string): PdfActionTree => ({
  root: { type: 'javascript', subtype: 'JavaScript', script: code, next: [] },
  incomplete: false,
  warningFlags: 0,
  warnings: [],
});
const click: ActionContext = {
  origin: 'user',
  source: { kind: 'api' },
  event: { scope: 'activate' },
};

const GREETING = "app.alert('Hello, ' + identity.name + ' from ' + identity.corporation + '!');";
const MISTAKE = 'app.alrt("typo");';

interface OutputLine {
  kind: 'alert' | 'error';
  text: string;
}

/** What the script said: its alerts and its errors, newest first. */
@Injectable()
export class ScriptOutput {
  readonly lines = signal<OutputLine[]>([]);

  show(kind: OutputLine['kind'], text: string) {
    this.lines.update((lines) => [{ kind, text }, ...lines].slice(0, 5));
  }
}

// Edit the script and run it. Its alerts and its errors show below it.
@Component({
  selector: 'demo-script-console',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <section class="panel">
      <label class="name" for="script">
        Script {{ actions.isScriptingEnabled() ? '' : '(JavaScript is off)' }}
      </label>
      <textarea
        #area
        id="script"
        class="code"
        spellcheck="false"
        rows="3"
        [value]="code()"
        (input)="code.set(area.value)"
      ></textarea>
      <div class="actions">
        <button type="button" class="button primary" (click)="run()">Run</button>
        <button type="button" class="button" (click)="code.set(mistake)">
          A script with a mistake
        </button>
      </div>
      <ol class="output" aria-live="polite">
        @for (line of output.lines(); track $index) {
          <li [class]="line.kind">{{ line.text }}</li>
        }
      </ol>
    </section>
  `,
})
export class ScriptConsole {
  protected readonly actions = inject(EpdfActions);
  protected readonly output = inject(ScriptOutput);
  protected readonly code = signal(GREETING);
  protected readonly mistake = MISTAKE;

  constructor() {
    this.actions.scriptFailed$
      .pipe(takeUntilDestroyed())
      .subscribe(({ error }) => this.output.show('error', error.message));

    // The greeting runs once on load.
    void this.actions.execute(script(GREETING), click);
  }

  protected run() {
    void this.actions.execute(script(this.code()), click);
  }
}

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, ScriptConsole],
  providers: [
    ScriptOutput,
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      // JavaScript on, and two fields scripts often ask for about the user.
      withActions({
        javascript: { enabled: true, identity: { name: 'Dana Smith', corporation: 'Acme' } },
      }),
      // A script's alert goes to your UI adapter: here, a line below the script.
      withActionsUi(() => {
        const output = inject(ScriptOutput);
        return { alert: (message) => output.show('alert', message) };
      }),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './javascript.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <demo-script-console *epdfDocumentGate="let document; fallback: loading" />

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {}

How websites open, printing and alerts#

withActionsUi() installs the browser’s defaults: a website opens in a new tab after a safety check, printing opens the print dialog, and a script’s alert shows the browser’s alert. Replace any of them with your own UI:

actions-ui.ts
import { Component, inject } from '@angular/core';
import { provideEmbedPdf } from '@embedpdf/angular/runtime';
import { withActions, withActionsUi } from '@embedpdf/angular/actions';
import { engine } from './pdf';
import { Toasts } from './toasts';

@Component({
  selector: 'app-contract-viewer',
  providers: [
    provideEmbedPdf(
      { engine },
      /* … */
      withActions(),
      // Runs in an injection context, so your handlers can use your services
      withActionsUi(() => {
        const toasts = inject(Toasts);
        return {
          openUri: (uri) => {
            if (confirm(`Open ${uri}?`)) window.open(uri, '_blank', 'noopener');
          },
          alert: (message) => toasts.show(message),
        };
      }),
    ),
  ],
  template: `<!-- your viewer -->`,
})
export class ContractViewer {}

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  inject,
  Injectable,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import {
  EpdfActions,
  withActions,
  withActionsUi,
  type ActionContext,
  type PdfActionTree,
} from '@embedpdf/angular/actions';
import { localEngine } from '@embedpdf/engine';

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// A link to a website, as a PDF carries it: what getActionTree() reads from a link.
const websiteLink: PdfActionTree = {
  root: { type: 'uri', subtype: 'URI', uri: 'https://www.embedpdf.com', isMap: false, next: [] },
  incomplete: false,
  warningFlags: 0,
  warnings: [],
};
const click: ActionContext = {
  origin: 'user',
  source: { kind: 'api' },
  event: { scope: 'activate' },
};

/** The website the document wants to open, while the user decides. */
@Injectable()
export class LinkPrompt {
  readonly asking = signal<string | null>(null);
}

// Your UI decides how a website opens: here, it asks first.
@Component({
  selector: 'demo-ask-before-opening',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <section class="panel">
      <p class="lead">The document links to <code>https://www.embedpdf.com</code>.</p>
      <button type="button" class="button" (click)="clickLink()">Click the link</button>
      @if (prompt.asking(); as asking) {
        <div class="prompt" role="alertdialog" aria-label="Open a website">
          <p class="prompt-text">
            This document wants to open <strong>{{ asking }}</strong
            >.
          </p>
          <div class="prompt-actions">
            <button type="button" class="button" (click)="prompt.asking.set(null)">
              Stay here
            </button>
            <button type="button" class="button primary" (click)="open(asking)">
              Open in a new tab
            </button>
          </div>
        </div>
      }
    </section>
  `,
})
export class AskBeforeOpening {
  private readonly actions = inject(EpdfActions);
  protected readonly prompt = inject(LinkPrompt);

  constructor() {
    // The link is clicked once on load, so the question is there to see.
    this.clickLink();
  }

  protected clickLink() {
    void this.actions.execute(websiteLink, click);
  }

  protected open(uri: string) {
    window.open(uri, '_blank', 'noopener');
    this.prompt.asking.set(null);
  }
}

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, AskBeforeOpening],
  providers: [
    LinkPrompt,
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withActions(),
      withActionsUi(() => {
        const prompt = inject(LinkPrompt);
        return { openUri: (uri) => prompt.asking.set(uri) };
      }),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './website-links.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <demo-ask-before-opening *epdfDocumentGate="let document; fallback: loading" />

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {}
HandlerCalled when
openUriAn action opens a website or an email address
printAn action or a script asks to print
alertA script shows a message
gotoPageA script moves to a page

Change the rules#

Each kind of action has a rule for each way it can start: a click by the user (user), the pointer moving over something (hover), or the document opening or closing (lifecycle):

withActions({
  policy: {
    uri: { user: 'adapter', hover: 'block', lifecycle: 'block' }, // websites only on click
    javascript: { lifecycle: 'block' }, // no scripts when the document opens
  },
});
RuleMeans
'allow'Run it
'adapter'Hand it to your UI adapter (a form submit to your submit handler), which decides
'report'Don’t run it; report it in diagnosticReported$
'block'Don’t run it

The rules can change while the app runs, for example when the user trusts a document:

actions.updateSettings({ policy: { javascript: { lifecycle: 'allow' } } });

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  inject,
  Injectable,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import {
  EpdfActions,
  withActions,
  withActionsUi,
  type ActionContext,
  type ActionOrigin,
  type ActionPolicyDecision,
  type PdfActionTree,
} from '@embedpdf/angular/actions';
import { localEngine } from '@embedpdf/engine';

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// A link to a website, as a PDF carries it: what getActionTree() reads from a link.
const websiteLink: PdfActionTree = {
  root: { type: 'uri', subtype: 'URI', uri: 'https://www.embedpdf.com', isMap: false, next: [] },
  incomplete: false,
  warningFlags: 0,
  warnings: [],
};

// The three ways an action can start, each as the context it runs with.
const STARTS: ReadonlyArray<{ label: string; context: ActionContext }> = [
  {
    label: 'A click',
    context: { origin: 'user', source: { kind: 'api' }, event: { scope: 'activate' } },
  },
  {
    label: 'The pointer over it',
    context: {
      origin: 'hover',
      source: { kind: 'api' },
      event: { scope: 'annotation', name: 'cursorEnter' },
    },
  },
  {
    label: 'The document opening',
    context: {
      origin: 'lifecycle',
      source: { kind: 'api' },
      event: { scope: 'document', name: 'open' },
    },
  },
];
const RULES: readonly ActionPolicyDecision[] = ['allow', 'adapter', 'report', 'block'];

/** What happened to the link, newest first. */
@Injectable()
export class RunLog {
  readonly lines = signal<string[]>([]);

  note(line: string) {
    this.lines.update((lines) => [line, ...lines].slice(0, 6));
  }
}

// The rules for websites, changed while the app runs, and what happens when the link runs.
@Component({
  selector: 'demo-website-rules',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <section class="panel">
      <ul class="starts">
        @for (start of starts; track start.context.origin) {
          <li class="start">
            <span class="start-label">
              {{ start.label }} <code>{{ start.context.origin }}</code>
            </span>
            <select
              #rule
              class="field"
              [attr.aria-label]="'The rule for ' + start.context.origin"
              (change)="setRule(start.context.origin, rule.value)"
            >
              @for (option of rulesToPick; track option) {
                <option [value]="option" [selected]="option === rules()[start.context.origin]">
                  {{ option }}
                </option>
              }
            </select>
            <button type="button" class="button" (click)="run(start.context)">Run the link</button>
          </li>
        }
      </ul>
      <div class="footer">
        <ol class="log" aria-live="polite">
          @for (line of log.lines(); track $index) {
            <li>{{ line }}</li>
          }
        </ol>
        <button type="button" class="button" (click)="actions.resetSettings()">
          Back to the registered rules
        </button>
      </div>
    </section>
  `,
})
export class WebsiteRules {
  protected readonly actions = inject(EpdfActions);
  protected readonly log = inject(RunLog);
  protected readonly starts = STARTS;
  protected readonly rulesToPick = RULES;
  protected readonly rules = computed(() => this.actions.settings().policy.uri);

  constructor() {
    this.actions.diagnosticReported$
      .pipe(takeUntilDestroyed())
      .subscribe(({ code, action }) => this.log.note(`The ${action} action was not run: ${code}`));

    // One click on load, so the log shows what a click does.
    this.run(STARTS[0].context);
  }

  protected run(context: ActionContext) {
    void this.actions.execute(websiteLink, context);
  }

  protected setRule(origin: ActionOrigin, rule: string) {
    this.actions.updateSettings({
      policy: { uri: { [origin]: rule as ActionPolicyDecision } },
    });
  }
}

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, WebsiteRules],
  providers: [
    RunLog,
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      // Websites open only on a click, as the page's example registers it.
      withActions({
        policy: { uri: { user: 'adapter', hover: 'block', lifecycle: 'block' } },
      }),
      // Instead of opening a tab, the adapter notes the website it was given.
      withActionsUi(() => {
        const log = inject(RunLog);
        return { openUri: (uri) => log.note(`Your adapter got ${uri}`) };
      }),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './rules.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <demo-website-rules *epdfDocumentGate="let document; fallback: loading" />

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {}

Run an action from code#

await actions.executeNamed('NextPage');

executeNamed runs a viewer action by its name: 'NextPage', 'PrevPage', 'FirstPage', 'LastPage' or 'Print'. It follows the same rules as a click.

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  inject,
  Injectable,
  input,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import {
  EpdfActions,
  withActions,
  withActionsUi,
  type PdfNamedAction,
} from '@embedpdf/angular/actions';
import { localEngine } from '@embedpdf/engine';

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

const VERBS: ReadonlyArray<[PdfNamedAction, string]> = [
  ['FirstPage', '⇤ First'],
  ['PrevPage', '‹ Previous'],
  ['NextPage', 'Next ›'],
  ['LastPage', 'Last ⇥'],
  ['Print', 'Print'],
];

/** What the last action did. */
@Injectable()
export class LastRun {
  readonly text = signal<string | null>(null);
}

// The viewer actions a PDF's buttons name, run from your own buttons.
@Component({
  selector: 'demo-named-actions',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toolbar">
      @for (verb of verbs; track verb[0]) {
        <button
          type="button"
          class="button"
          [disabled]="!actions.canExecuteNamed(verb[0])"
          (click)="run(verb[0])"
        >
          {{ verb[1] }}
        </button>
      }
      <span class="spacer"></span>
      <output class="readout">
        Page {{ stage().currentPageIndex() + 1 }} of {{ stage().pageCount() }}
        @if (ran.text(); as text) {
          <span class="ran"> · {{ text }}</span>
        }
      </output>
    </div>
  `,
})
export class NamedActions {
  readonly stage = input.required<EpdfStage>();
  protected readonly actions = inject(EpdfActions);
  protected readonly ran = inject(LastRun);
  protected readonly verbs = VERBS;

  constructor() {
    // On load, as if a "Last page" button in the PDF was clicked. Running it twice changes nothing.
    void this.actions
      .executeNamed('LastPage')
      .then(({ status }) => this.ran.text.set(`LastPage: ${status}`));
  }

  protected async run(name: PdfNamedAction) {
    const { status } = await this.actions.executeNamed(name);
    if (name !== 'Print') this.ran.text.set(`${name}: ${status}`);
  }
}

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer, NamedActions],
  providers: [
    LastRun,
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withActions(),
      // Print goes to your UI adapter. This one says so, instead of opening the browser's print dialog.
      withActionsUi(() => {
        const ran = inject(LastRun);
        return { print: () => ran.text.set('Print: your print handler ran') };
      }),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './run-from-code.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <demo-named-actions [stage]="stage" />
      <epdf-stage #stage="epdfStage" class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
        </ng-template>
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {}

Methods#

Everything inject(EpdfActions) gives you.

MethodWhat it does
executeNamed(name, context?)Runs a viewer action by its name, such as 'NextPage', as if the user clicked it. Run an action from code
execute(tree, context)Runs actions you already have, such as a tree from getActionTree(), under the rules. Resolves { status, nodes, diagnostics }: an action that fails is reported there, not thrown.
dispatch(trigger)Reports that something happened, such as a click on a link or a page opening, and runs the actions behind it. The viewer does this for its own clicks and pages. Never rejects: problems come back in the result.
getActionTree(source)Reads the actions behind a link or button, a form field, a page or the document, without running them. Resolves null when there are none.
isScriptingEnabled()Whether this document’s JavaScript runs: it’s turned on and the script engine started. Turn on JavaScript
setUiAdapter(adapter)Installs your handlers for websites, printing and alerts in place of the ones before, and returns a function that removes them. withActionsUi() does this for you. How websites open, printing and alerts
setSubmitHandler(handler)Sets the function a form’s Submit button hands its values to, with the address the PDF names. The viewer never sends them anywhere itself. Returns a function that removes it.
registerExecutor(type, executor)Runs one kind of action, such as 'goto', with your own code in place of the built-in one. Returns a function that removes it.
runDocumentVerb(verb, operation)Runs your own save ('save') or print ('print') between the document’s before and after actions, and resolves with what operation returns. download() and print() do this by themselves.
prepareClose()Runs the document’s closing actions. Call it before documents.close(), which skips them.
getSettings(), updateSettings(changes), resetSettings()Read the settings, change the rules while the app runs, and go back to what you registered. Settings
canExecute(tree, context), canExecuteNamed(name)Whether the rules would run these actions, or this viewer action. Change the rules
canDispatch(trigger)Whether that kind of trigger is turned on in the triggers setting. Settings

context is { origin, source, event }: why the actions run ('user', 'hover' or 'lifecycle', as in Change the rules), what started them, and which event it was. executeNamed() fills it in as a click from your code. Every method that returns a promise also takes a signal in its options, to cancel it.

Settings#

Set them when you register the plugin, and change them while the app runs with actions.updateSettings({ … }); actions.resetSettings() goes back to what you registered, and inject(EpdfActions).settings() gives you the current settings.

SettingWhat it doesDefault
javascript.enabledRun the document’s JavaScript. A change applies to documents opened after it, since a document’s script engine starts when it opensfalse
javascript.identityWho scripts think the user is, on top of the document’s userthe document’s user
policyThe rules above, per kind of action and per originas in the table
triggersRun actions of the document, its pages, or annotationsall true
openSequenceWhen the document’s opening actions run: 'auto', 'headless' (as the document opens, for an app without a Stage), 'off'. A change applies to documents whose opening actions haven’t run yet'auto'

Events#

action-diagnostics.ts
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EpdfActions } from '@embedpdf/angular/actions';

@Component({ selector: 'app-action-diagnostics', template: '' })
export class ActionDiagnostics {
  constructor() {
    inject(EpdfActions)
      .diagnosticReported$.pipe(takeUntilDestroyed())
      .subscribe(({ code, action }) => console.warn(`Action ${action} was not run: ${code}`));
  }
}
EventFires whenCarries
executed$Actions ran, whatever started themtree, result, source
diagnosticReported$An action was blocked, reported or couldn’t runcode, action, source
scriptFailed$A script threw an errorerror, source
scriptDiagnosticReported$A script was stopped from doing something, such as reaching the network, or used something the viewer doesn’t supportcode, message
openSequenceCompleted$The document’s opening actions ranresult
settingsChanged$A setting changedsettings, changed

Permissions#

Actions don’t need a permission of their own, but some things they do do: printing needs doc.print, and changing a form field needs doc.forms.fill. An action the user may not perform is reported, never run.

Good to know#

When opening actions run#

With openSequence: 'auto', the actions a document runs when it opens wait for the first sign of a person: the UI adapter being installed, or a first click. So a document that plays a script on open doesn’t do it in a background tab nobody has looked at.

Scripts and forms#

Form calculations, validation and formatting are scripts too: with JavaScript on, the form plugin runs them as people type. See Filling forms.

Was this page helpful?

Your feedback goes directly to the documentation team.