EmbedPDF

Events, saving & sharing

Every annotation change fires an event, whoever made it: the user, your code, or someone else on a shared document. Listen for them to keep your own UI or server in step:

events.ts
import { Directive, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EpdfAnnotation } from '@embedpdf/angular/annotation';

// <div appActivityLog>…</div>, on any element under the viewer's providers
@Directive({ selector: '[appActivityLog]' })
export class ActivityLog {
  constructor() {
    inject(EpdfAnnotation)
      .created$.pipe(takeUntilDestroyed())
      .subscribe(({ annotation, origin }) => {
        console.log(`${annotation.author} added a ${annotation.subtype}`);
      });
  }
}

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  effect,
  inject,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EpdfDocument, 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 { EpdfInteraction, withInteraction } from '@embedpdf/angular/interaction';
import { EpdfAnnotation, EpdfAnnotationLayer, withAnnotation } from '@embedpdf/angular/annotation';
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()) };
};

interface Entry {
  id: number;
  text: string;
  origin: string;
}

// Every change, once the engine has saved it, newest first.
@Component({
  selector: 'ol[demoActivityLog]',
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { class: 'panel log' },
  template: `
    @for (entry of entries(); track entry.id) {
      <li class="entry">
        <span>{{ entry.text }}</span>
        <span class="origin">{{ entry.origin }}</span>
      </li>
    } @empty {
      <li class="empty">Nothing yet</li>
    }
  `,
})
export class ActivityLog {
  private readonly annotation = inject(EpdfAnnotation);
  protected readonly entries = signal<Entry[]>([]);
  private nextId = 0;

  constructor() {
    this.annotation.created$
      .pipe(takeUntilDestroyed())
      .subscribe(({ annotation, origin }) =>
        this.log(`${annotation.author ?? 'Someone'} added a ${annotation.subtype}`, origin.kind),
      );
    this.annotation.updated$
      .pipe(takeUntilDestroyed())
      .subscribe(({ annotation, origin }) =>
        this.log(`Changed a ${annotation.subtype}`, origin.kind),
      );
    this.annotation.deleted$
      .pipe(takeUntilDestroyed())
      .subscribe(({ refs, origin }) =>
        this.log(
          `Deleted ${refs.length === 1 ? 'one annotation' : `${refs.length} annotations`}`,
          origin.kind,
        ),
      );
    this.annotation.reordered$
      .pipe(takeUntilDestroyed())
      .subscribe(({ order, origin }) =>
        this.log(`Restacked ${order.length} on a page`, origin.kind),
      );
  }

  private log(text: string, origin: string) {
    this.entries.update((current) =>
      [{ id: this.nextId++, text, origin }, ...current].slice(0, 30),
    );
  }
}

const TOOLS = [
  { id: 'pointer', label: 'Select' },
  { id: 'square', label: 'Rectangle' },
  { id: 'ink', label: 'Pen' },
];

@Component({
  selector: 'demo-toolbar',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toolbar">
      <div class="segmented" role="group" aria-label="Tool">
        @for (tool of tools; track tool.id) {
          <button
            type="button"
            [attr.aria-pressed]="interaction.activeToolId() === tool.id"
            (click)="interaction.activateTool(tool.id)"
          >
            {{ tool.label }}
          </button>
        }
      </div>
      <button
        type="button"
        class="button"
        [disabled]="annotation.selected().length === 0"
        (click)="annotation.selection.delete()"
      >
        Delete
      </button>
    </div>
  `,
})
export class Toolbar {
  protected readonly annotation = inject(EpdfAnnotation);
  protected readonly interaction = inject(EpdfInteraction);
  protected readonly tools = TOOLS;
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfAnnotationLayer,
    ActivityLog,
    Toolbar,
  ],
  providers: [
    provideEmbedPdf(
      {
        engine: () => localEngine(),
        identity: { userId: 'u_381', displayName: 'Dana Smith' },
        initialDocuments: [{ source: ebook }],
      },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './events.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <demo-toolbar />
      <div class="viewer">
        <epdf-stage class="stage">
          <ng-template epdfPage>
            <epdf-render-layer />
            <epdf-annotation-layer />
          </ng-template>
        </epdf-stage>
        <ol demoActivityLog></ol>
      </div>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  private readonly annotation = inject(EpdfAnnotation);
  private readonly pages = inject(EpdfDocument).pages;
  private added = false;

  constructor() {
    // On load: a rectangle on the cover, so the log starts with its event.
    effect(() => {
      const cover = this.pages()[0]?.ref;
      if (this.annotation.status() !== 'ready' || !cover || this.added) return;
      this.added = true;
      void this.annotation.create(cover, {
        subtype: 'square',
        box: { x: 96, y: 506, width: 178, height: 54 },
        color: '#e5484d',
        strokeWidth: 3,
      });
    });
  }
}

Try it: draw, move and delete. Each change is logged once the engine has saved it, as local: this viewer made it.

takeUntilDestroyed() stops listening when the component or directive is destroyed. Anywhere else, such as in a service, keep the subscription and end it yourself:

const subscription = annotation.created$.subscribe(({ annotation }) => {
  /* … */
});
 
subscription.unsubscribe();

Events#

EventFires whenCarries
created$An annotation was added.annotation, origin
updated$An annotation was changed.annotation, origin
deleted$Annotations were deleted: one, plus its popup and replies.refs, page, origin
reordered$The drawing order on a page changed.page, order, origin
resynced$Annotations were read again, as after loading a document.pages
writeFailed$The engine refused a change the user made.refs, error
settingsChanged$A setting changed, through updateSettings() or resetSettings().settings, changed
  • Once, and after it’s saved. created$, updated$, deleted$ and reordered$ fire when the engine has saved the change, never while it’s still pending. A change fires once, however many screens show it.
  • Loading isn’t a list of creates. When a document opens, its annotations arrive as one resynced$, not as a created$ per annotation. The same happens after a change that touches whole pages, such as flattening or redaction. Then read the pages it names again with list().
  • annotation is the record after the change. It’s what get() returns now.
  • order is the whole page. After a reorder, order holds the page’s annotations as refs, bottom to top, the same order list({ pages: [page] }) returns.

The selection, a shape being drawn, text editing and hover have events of their own (selectionChanged$, draftChanged$, editingChanged$, hoverChanged$); see Selecting & editing. A tool’s defaults have tools.defaultsChanged$; see Tools & defaults.

Where a change came from#

origin says who made the change:

FieldWhat it is
origin.kind'local' when this viewer made the change, 'remote' when someone else did.
origin.subThe user who made it (on a shared document).
origin.tsWhen it happened, in milliseconds.
origin.txShared by every change one call made, such as an import.
remote-toast.ts
import { Directive, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs';
import { EpdfAnnotation } from '@embedpdf/angular/annotation';
import { toast } from './toast'; // your own

@Directive({ selector: '[appRemoteChanges]' })
export class RemoteChanges {
  constructor() {
    inject(EpdfAnnotation)
      .updated$.pipe(
        filter(({ origin }) => origin.kind === 'remote'),
        takeUntilDestroyed(),
      )
      .subscribe(({ annotation }) => toast(`${annotation.author} changed a comment`));
  }
}

These are the engine’s own events, so the fields mean the same as on Events.

Save the PDF#

Annotations are part of the document. Save the document and they’re in the file:

const bytes = await documents.download(); // the whole PDF, with every annotation

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  ViewEncapsulation,
} from '@angular/core';
import {
  EpdfDocument,
  EpdfDocumentGate,
  EpdfDocuments,
  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 { EpdfInteraction, withInteraction } from '@embedpdf/angular/interaction';
import { EpdfAnnotation, EpdfAnnotationLayer, withAnnotation } from '@embedpdf/angular/annotation';
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()) };
};

// The annotations are part of the document: the downloaded PDF has every one.
@Component({
  selector: 'demo-save-bar',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toolbar">
      <button
        type="button"
        class="button"
        [attr.aria-pressed]="drawing()"
        (click)="interaction.activateTool(drawing() ? 'pointer' : 'ink')"
      >
        Pen
      </button>
      <button type="button" class="button" (click)="download()">Download the PDF</button>
      <span class="spacer"></span>
      <output class="readout">
        {{ document.hasUnsavedChanges() ? 'Unsaved changes' : 'All saved' }}
      </output>
    </div>
  `,
})
export class SaveBar {
  private readonly documents = inject(EpdfDocuments);
  protected readonly interaction = inject(EpdfInteraction);
  protected readonly document = inject(EpdfDocument);
  protected readonly drawing = computed(() => this.interaction.activeToolId() === 'ink');

  protected async download() {
    saveFile(await this.documents.download(), 'ebook-annotated.pdf');
  }
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfAnnotationLayer,
    SaveBar,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './download.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <demo-save-bar />
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-annotation-layer />
        </ng-template>
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  private readonly annotation = inject(EpdfAnnotation);
  private readonly pages = inject(EpdfDocument).pages;
  private added = false;

  constructor() {
    // On load: a rectangle on the cover, so there's a change to save.
    effect(() => {
      const cover = this.pages()[0]?.ref;
      if (this.annotation.status() !== 'ready' || !cover || this.added) return;
      this.added = true;
      void this.annotation.create(cover, {
        subtype: 'square',
        box: { x: 96, y: 506, width: 178, height: 54 },
        color: '#e5484d',
        strokeWidth: 3,
      });
    });
  }
}

A save waits for pending changes, so what you save is what the user sees. For the difference between a quick save that appends the changes and a full rewrite, see Saving.

Export and import#

To take annotations out of a document, as data rather than inside a PDF, export them. One call gives you a bundle: every annotation with the bytes it carries (a stamp’s drawing, an attached file). One call puts a bundle into a document. AnnotationTransfer comes from @embedpdf/angular/annotation:

const bundle = await annotation.export(); // everything
const text = AnnotationTransfer.stringify(bundle); // one JSON text, ready to store
 
// later, in the same document or another one
const { annotations, dropped } = await annotation.import(await AnnotationTransfer.parse(text), {
  pages: 'by-position',
});

Use it to:

  • keep annotations in your own database, apart from the PDF;
  • carry a review over to a new version of a document;
  • let a user paste annotations from one document into another.

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  effect,
  inject,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import { EpdfDocument, 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 { withInteraction } from '@embedpdf/angular/interaction';
import {
  AnnotationTransfer,
  EpdfAnnotation,
  EpdfAnnotationLayer,
  withAnnotation,
} from '@embedpdf/angular/annotation';
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()) };
};

// The bundle as one JSON text: what you'd store in your own database.
@Component({
  selector: 'div[demoTransfer]',
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { class: 'panel transfer' },
  template: `
    <div class="toolbar">
      <button type="button" class="button" (click)="exportAll()">Export</button>
      <button
        type="button"
        class="button"
        [disabled]="annotations().length === 0"
        (click)="deleteAll()"
      >
        Delete all
      </button>
      <button type="button" class="button" [disabled]="text() === ''" (click)="importText()">
        Import
      </button>
    </div>
    <output class="readout">{{ status() }}</output>
    <textarea
      #bundle
      class="bundle"
      aria-label="The exported bundle"
      spellcheck="false"
      [value]="text()"
      (input)="text.set(bundle.value)"
    ></textarea>
  `,
})
export class Transfer {
  private readonly annotation = inject(EpdfAnnotation);
  private readonly pages = inject(EpdfDocument).pages;
  protected readonly annotations = this.annotation.watch();
  protected readonly text = signal('');
  protected readonly status = signal('');
  private started = false;

  constructor() {
    // On load: a note and a rectangle on the cover, exported at once.
    effect(() => {
      const cover = this.pages()[0]?.ref;
      if (this.annotation.status() !== 'ready' || !cover || this.started) return;
      this.started = true;
      void Promise.all([
        this.annotation.create(cover, {
          subtype: 'text',
          rect: { x: 470, y: 232, width: 20, height: 20 },
          contents: 'Can we shorten the title?',
          color: '#facc15',
        }),
        this.annotation.create(cover, {
          subtype: 'square',
          box: { x: 96, y: 506, width: 178, height: 54 },
          color: '#e5484d',
          strokeWidth: 3,
        }),
      ]).then(() => this.exportAll());
    });
  }

  protected async exportAll() {
    const bundle = await this.annotation.export(); // everything
    this.text.set(AnnotationTransfer.stringify(bundle));
    this.status.set(`Exported ${bundle.items.length}`);
  }

  protected async deleteAll() {
    await Promise.all(this.annotation.list().map((each) => this.annotation.delete(each.ref)));
    this.status.set('Deleted them all');
  }

  protected async importText() {
    try {
      const { annotations, dropped } = await this.annotation.import(
        await AnnotationTransfer.parse(this.text()),
      );
      this.status.set(`Imported ${annotations.length}, left out ${dropped.length}`);
    } catch (error) {
      this.status.set(error instanceof Error ? error.message : String(error));
    }
  }
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfAnnotationLayer,
    Transfer,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './export-import.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <div class="viewer">
        <epdf-stage class="stage">
          <ng-template epdfPage>
            <epdf-render-layer />
            <epdf-annotation-layer />
          </ng-template>
        </epdf-stage>
        <div demoTransfer></div>
      </div>
    </ng-container>

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

Try it: Delete all, then Import: they come back from the text. Import again: the annotations with a name are left out, since they’re there already.

Export waits for pending changes, and imported annotations show at once, like any other change.

What to export:

await annotation.export({ refs: annotation.selection.list().map((a) => a.ref) }); // the selection
await annotation.export({ pages: [page.ref] }); // one page

A selection brings what it needs: a reply brings the comment it answers, and a comment brings its replies.

How to import:

OptionWhat it doesDefault
pagesWhere each page’s annotations go: 'same' (the same page), 'by-position', or a list of pairs.'same'
attribution'restore' keeps who wrote each annotation, 'stamp' makes the importing user the author.'restore'

Use 'stamp' when a user pastes annotations: they become theirs. 'restore' writes annotations in other people’s names, so it needs a special permission; keep it for trusted jobs, such as loading from your own database.

The result tells you what happened:

  • annotations: the new annotations, in bundle order.
  • refMap: each annotation’s ref in the bundle, and its new ref here.
  • dropped: what was left out, and why. For example, an annotation whose name is already taken on its page. Importing the same bundle twice doesn’t duplicate annotations with a name.

The engine’s Import & export page has every option, reason and limit.

Keep annotations in your own database#

There are two ways to store annotations outside the PDF.

Store a bundle per document. Export when the user saves, store the text, and import it when the document opens again. It’s one row per document and one call each way:

// on save
await api.put(`/reviews/${docId}`, AnnotationTransfer.stringify(await annotation.export()));
 
// on open, once the annotations are loaded (status 'ready')
const text = await api.get(`/reviews/${docId}`);
if (text) await annotation.import(await AnnotationTransfer.parse(text));

Store each annotation as it changes. Listen for the events and write each change to your server. Key your rows by the annotation’s ref:

sync.ts
import { Directive, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs';
import { EpdfAnnotation, annotationKey } from '@embedpdf/angular/annotation';
import { api } from './api'; // your own

@Directive({ selector: '[appSyncToServer]' })
export class SyncToServer {
  constructor() {
    inject(EpdfAnnotation)
      .created$.pipe(
        filter(({ origin }) => origin.kind === 'local'),
        takeUntilDestroyed(),
      )
      .subscribe(({ annotation }) =>
        api.put(`/annotations/${annotationKey(annotation.ref)}`, annotation),
      );
  }
}

Handle updated$ the same way, and deleted$ by deleting each of its refs. On resynced$, compare the pages it names with list(). Only send local changes: a remote one was already saved by whoever made it.

Which id to store#

Store the annotation’s ref. It’s plain data, and it stays valid after the document is saved and opened again. To use it as a key in a map or a URL, turn it into a string with annotationKey(ref). See Refs & identity.

Good to know#

Unsaved changes#

To warn before someone leaves with annotations that aren’t downloaded yet, use the hasUnsavedChanges() signal on inject(EpdfDocument); see Warn before changes are lost.

Events on a shared document#

The local engine’s events come from this tab: the changes made here, by the user or your code.

Was this page helpful?

Your feedback goes directly to the documentation team.