Permissions
A document can be opened with less than full power: a link that lets people read and comment, a signing session that only fills in fields, a reviewer who may not download. The engine refuses everything else. This page is about building a UI that shows only what works, instead of buttons that fail.
Every verb has a check#
Every call that can be refused has a check next to it, named can and the verb. It answers one
question: would this work right now, for this user?
annotation.canCreate(); // may they draw new annotations?
annotation.canUpdate(ref); // may they change this one?
selection.canCopy(); // may selected text leave the document?
form.canFill(ref); // may they fill in this field?
documents.canDownload(); // may they download the file?A check already includes everything its call looks at: the user’s permissions, the flags on the document (a locked annotation, a read-only field) and what’s selected. You never combine them yourself, and you never read permission names: which permission a call needs is the plugin’s business, not yours.
The rule: show a button when its check is true. Read the check in your template, like any other state: the answer changes when the selection changes or the user gets new permissions.
import { Component, inject, input } from '@angular/core';
import { EpdfAnnotation, type AnnotationRef } from '@embedpdf/angular/annotation';
import { DrawingTools } from './drawing-tools';
@Component({
selector: 'app-annotation-actions',
imports: [DrawingTools],
template: `
@if (annotation.canCreate()) {
<app-drawing-tools />
}
<button [disabled]="!annotation.canDelete(annotationRef())">Delete</button>
`,
})
export class AnnotationActions {
readonly annotationRef = input.required<AnnotationRef>();
protected readonly annotation = inject(EpdfAnnotation);
}Each control below shows only when its check says yes. Pick another role, and the document opens again with its permissions:
Loading live preview…
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
signal,
untracked,
ViewEncapsulation,
} from '@angular/core';
import {
EpdfDocument,
EpdfDocumentGate,
EpdfDocuments,
EpdfViewer,
provideEmbedPdf,
saveFile,
} 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 { withInteraction } from '@embedpdf/angular/interaction';
import {
copySelection,
EpdfSelection,
EpdfSelectionLayer,
withSelection,
} from '@embedpdf/angular/selection';
import { EpdfSearch, EpdfSearchLayer, withSearch } from '@embedpdf/angular/search';
import { localEngine } from '@embedpdf/engine';
const welcome = async (): Promise<OpenInput> => {
const response = await fetch('/pdfs/welcome.pdf');
return { kind: 'bytes', bytes: new Uint8Array(await response.arrayBuffer()) };
};
// What each role may do, as permissions.
const roles = {
viewer: ['doc.open', 'doc.render', 'doc.text.select'],
reviewer: ['doc.open', 'doc.render', 'doc.text.select', 'doc.text.copy', 'doc.text.search'],
owner: [
'doc.open',
'doc.render',
'doc.text.select',
'doc.text.copy',
'doc.text.search',
'doc.download',
],
};
type Role = keyof typeof roles;
// Each control shows only when its check says yes.
@Component({
selector: 'demo-controls',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="toolbar">
@if (search.canSearch()) {
<input
#query
class="field"
type="search"
aria-label="Search"
[value]="text()"
(input)="text.set(query.value)"
/>
}
@if (selection.canCopy()) {
<button
type="button"
class="button"
[disabled]="!selection.hasSelection()"
(click)="copy()"
>
Copy
</button>
}
@if (documents.canDownload()) {
<button type="button" class="button" (click)="download()">Download</button>
}
</div>
<p class="note">{{ copied() ? 'Copied: “' + copied() + '”' : ' ' }}</p>
`,
})
export class Controls {
protected readonly documents = inject(EpdfDocuments);
protected readonly search = inject(EpdfSearch);
protected readonly selection = inject(EpdfSelection);
private readonly document = inject(EpdfDocument);
protected readonly text = signal('PDF');
protected readonly copied = signal('');
constructor() {
// On load: a search, and some text selected, so every check has something to act on.
effect(() => {
const text = this.text();
untracked(() => {
if (this.search.canSearch()) void this.search.search({ text });
});
});
effect(() => {
const cover = this.document.pages()[0]?.ref;
if (cover) untracked(() => this.selection.select({ page: cover, start: 28, count: 30 }));
});
}
protected async copy() {
await copySelection(this.selection).catch(() => {}); // the browser may refuse the clipboard
this.copied.set(await this.selection.readText());
}
protected async download() {
saveFile(await this.documents.download(), 'welcome.pdf');
}
}
@Component({
selector: 'demo-root',
imports: [
EpdfDocumentGate,
EpdfStage,
EpdfPageTemplate,
EpdfRenderLayer,
EpdfSearchLayer,
EpdfSelectionLayer,
Controls,
],
providers: [
provideEmbedPdf(
{
engine: () => localEngine(),
scope: roles.reviewer,
initialDocuments: [{ source: welcome, name: 'welcome.pdf' }],
},
withStage(),
withRender(),
withInteraction(),
withSelection(),
withSearch(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './checks.css',
encapsulation: ViewEncapsulation.None,
template: `
<div class="toolbar">
<label class="label">
Role
<select #picker class="select" [value]="role()" (change)="pickRole(picker.value)">
<option value="viewer">viewer: read and select</option>
<option value="reviewer">reviewer: also search and copy</option>
<option value="owner">owner: also download</option>
</select>
</label>
</div>
<ng-container *epdfDocumentGate="let document; fallback: opening">
<demo-controls />
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
<epdf-search-layer />
<epdf-selection-layer />
</ng-template>
</epdf-stage>
</ng-container>
<ng-template #opening><p class="loading">Opening…</p></ng-template>
`,
})
export class App {
private readonly viewer = inject(EpdfViewer);
private readonly documents = inject(EpdfDocuments);
private readonly document = inject(EpdfDocument);
protected readonly role = signal<Role>('reviewer');
// A document keeps the permissions it opened with, so a new role opens it again.
protected async pickRole(value: string) {
const role = value as Role;
this.role.set(role);
this.viewer.updateSettings({ scope: roles[role] });
const id = this.document.id();
if (!id) return;
await this.documents.close(id);
await this.documents.open(welcome, { name: 'welcome.pdf' });
}
}One answer per annotation#
Checks that take a ref answer for that one annotation, field or comment. In a shared document, a
user may hold annotations:update:self: change their own annotations, and only look at the rest.
You don’t handle that yourself. Someone else’s annotation answers canUpdate(ref) === false, and
shows the way a locked one does: it can be selected and commented on, but has no handles. Forms work
the same: a field the user may not fill shows as read-only.
Calling anyway is safe#
Checks are there to keep your UI honest; the engine doesn’t rely on them. A refused call rejects, the same way in every plugin:
import { Component, inject } from '@angular/core';
import { isPluginError } from '@embedpdf/angular/runtime';
import { EpdfForm, toFieldRef } from '@embedpdf/angular/form';
@Component({
selector: 'app-set-total-button',
template: `<button (click)="setTotal()">Set the total</button>`,
})
export class SetTotalButton {
private readonly form = inject(EpdfForm);
protected async setTotal() {
try {
await this.form.setValue(toFieldRef('total'), { value: '120.00' });
} catch (error) {
if (isPluginError(error, 'permission-denied')) {
console.log(error.permission); // what was missing, such as 'doc.forms.fill'
}
}
}
}Loading live preview…
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
ViewEncapsulation,
} from '@angular/core';
import {
EpdfDocumentGate,
EpdfDocuments,
isPluginError,
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 { localEngine } from '@embedpdf/engine';
const welcome = async (): Promise<OpenInput> => {
const response = await fetch('/pdfs/welcome.pdf');
return { kind: 'bytes', bytes: new Uint8Array(await response.arrayBuffer()) };
};
// Read and select, but no downloading.
const scope = ['doc.open', 'doc.render', 'doc.text.select'];
/** What the last download did: the bytes, or why it was refused. */
type Result =
| { kind: 'downloaded'; byteLength: number }
| { kind: 'refused'; code: string; permission: string | null };
@Component({
selector: 'demo-download-anyway',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="toolbar">
<button type="button" class="button" (click)="download()">Download anyway</button>
<output class="result">
@if (result(); as result) {
@if (result.kind === 'downloaded') {
Downloaded {{ result.byteLength }} bytes.
} @else {
Refused: <code>{{ result.code }}</code
>, missing <code>{{ result.permission }}</code>
}
}
</output>
</div>
`,
})
export class DownloadAnyway {
private readonly documents = inject(EpdfDocuments);
protected readonly result = signal<Result | null>(null);
constructor() {
// On load, the call the button makes, so the refusal shows at once.
void this.download();
}
protected async download() {
try {
const bytes = await this.documents.download();
this.result.set({ kind: 'downloaded', byteLength: bytes.byteLength });
} catch (error) {
if (isPluginError(error, 'permission-denied')) {
this.result.set({ kind: 'refused', code: error.code, permission: error.permission });
}
}
}
}
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer, DownloadAnyway],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), scope, initialDocuments: [{ source: welcome }] },
withStage(),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './refused.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: opening">
<demo-download-anyway />
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
</epdf-stage>
</ng-container>
<ng-template #opening><p class="loading">Opening…</p></ng-template>
`,
})
export class App {}The local and the cloud engine refuse the same way, so this one check covers both.
What people do with the mouse fails quietly instead: without permission to create, drawing with a tool paints nothing and saves nothing.
Where the limits come from#
A document gets its permissions when it opens, together with who the user is.
On the local engine you give both. Once in provideEmbedPdf(), for every document it opens, and per document
when one needs something else:
import { Component } from '@angular/core';
import { provideEmbedPdf } from '@embedpdf/angular/runtime';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { archive, engine, report } from './pdf';
@Component({
selector: 'app-review-viewer',
imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
providers: [
provideEmbedPdf(
{
engine,
identity: { userId: 'u_381', displayName: 'Dana Smith' },
scope: [
'doc.open',
'doc.render',
'doc.text.select',
'doc.annotate.read',
'annotations:create:self',
'annotations:update:self',
'annotations:delete:self',
],
initialDocuments: [
{ source: report },
{ source: archive, scope: ['doc.open', 'doc.render'] }, // this one is read-only
],
},
withStage(),
withRender(),
),
],
template: `
<epdf-stage>
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
</epdf-stage>
`,
})
export class ReviewViewer {}- A document’s own
scopereplaces the viewer’s. It’s the whole list for that document, not an addition, so it means exactly what it says. The same goes foridentity. identityis who the user is. Permissions that say “their own” (:self) or “their group” (:group) are checked against it, so they need one: opening without it fails.- A document keeps what it opened with. Change the viewer’s
scopeoridentitywithinject(EpdfViewer).updateSettings({ scope, identity }), and the documents you open after that get the new values.
Code that opens documents itself passes the same options to
documents.open(source, { scope, identity }).
Leave scope out and the user may do what the file allows, as in Acrobat: everything, unless the
PDF has restrictions of its own, such as no printing. That’s pdf.permissions plus what no PDF
flag governs: downloading, importing annotations and form data, running and submitting a form,
certifying the document, and putting annotations and fields in any group. Pass the scope your users will really get, so the
checks show you the UI they’ll see.
Every check#
Each plugin’s page has a Permissions section with the permission behind each check.
| Plugin | Checks |
|---|---|
| Documents | canDownload() canPrint() |
| Rendering | canRender() |
| Page editing | canEdit(), one answer for every page change; canExtract() for copying pages out |
| Metadata | canUpdate() |
| Selection | canSelect() canCopy() |
| Search | canSearch(), and canSearch({ snippets: true }) for the words around each match |
| Annotations | canRead() canCreate() canUpdate(ref) canDelete(ref); selection.canGroup() selection.canUngroup() |
| Comments | canReply(ref) canSetText(ref) canSetStatus(ref) canSetMarked(ref) canDelete(ref) canDeleteThread(ref) |
| Stamps | canPlace() canCreateFromAnnotations() |
| Measurements | canMeasure(page) canCalibrate() |
| Redaction | canMark() canUnmark(ref) canUpdateLabel(ref) canApply() |
| Forms | canRead() canFill(ref); building forms: canDesign() canRestoreAttribution() canWriteScripts() |
| Signatures | canSign(field) canCertify() canFill(field) canReadRevision() |
A command’s enabled is where these checks go, so every toolbar button and
shortcut follows them.
Your feedback goes directly to the documentation team.