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:
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#
| Action | By default |
|---|---|
| Go to a page or a destination | Runs |
| A viewer action, such as next page | Runs |
| Show or hide a form field | Runs |
| Reset a form | Runs |
| Open a website | Goes to your UI adapter when the user clicked; reported otherwise |
| Goes to your UI adapter when the user clicked; never on its own | |
| Submit a form | Goes to your submit handler when the user clicked; never on its own |
| JavaScript | Off, 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:
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 {}| Handler | Called when |
|---|---|
openUri | An action opens a website or an email address |
print | An action or a script asks to print |
alert | A script shows a message |
gotoPage | A 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
},
});| Rule | Means |
|---|---|
'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.
| Method | What 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.
| Setting | What it does | Default |
|---|---|---|
javascript.enabled | Run the document’s JavaScript. A change applies to documents opened after it, since a document’s script engine starts when it opens | false |
javascript.identity | Who scripts think the user is, on top of the document’s user | the document’s user |
policy | The rules above, per kind of action and per origin | as in the table |
triggers | Run actions of the document, its pages, or annotations | all true |
openSequence | When 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#
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}`));
}
}| Event | Fires when | Carries |
|---|---|---|
executed$ | Actions ran, whatever started them | tree, result, source |
diagnosticReported$ | An action was blocked, reported or couldn’t run | code, action, source |
scriptFailed$ | A script threw an error | error, source |
scriptDiagnosticReported$ | A script was stopped from doing something, such as reaching the network, or used something the viewer doesn’t support | code, message |
openSequenceCompleted$ | The document’s opening actions ran | result |
settingsChanged$ | A setting changed | settings, 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.
Your feedback goes directly to the documentation team.