CloudPDF
DocsPricing
Start building

Outline

A document’s outline is its table of contents: titled items, nested, each going to a place in the document. Acrobat calls the items bookmarks. Register withOutline() to show it in your UI:

outline-list.ts
import { Component, inject } from '@angular/core';
import { EpdfOutline } from '@embedpdf/angular/outline';

@Component({
  selector: 'app-outline-list',
  template: `
    <ul role="tree">
      @for (row of outline.rows(); track row.item.ref.objectNumber) {
        <li
          role="treeitem"
          [attr.aria-level]="row.depth + 1"
          [attr.aria-expanded]="row.hasChildren ? row.expanded : null"
          [attr.aria-current]="
            row.item.ref.objectNumber === outline.current()?.objectNumber ? 'location' : null
          "
          [style.padding-inline-start.px]="row.depth * 16"
        >
          @if (row.hasChildren) {
            <button (click)="outline.toggle(row.item.ref)">{{ row.expanded ? '▾' : '▸' }}</button>
          }
          <button (click)="outline.activate(row.item.ref)">{{ row.item.title }}</button>
        </li>
      }
    </ul>
  `,
})
export class OutlineList {
  protected readonly outline = inject(EpdfOutline);
}

What inject(EpdfOutline) gives you updates by itself: after your own changes, and on a shared document, after someone else’s. Nothing is read when a document opens: the outline loads the first time something asks for it, so a document nobody shows the outline of costs nothing.

Show the outline#

rows are the rows a tree shows: every item whose parents are all expanded, depth first. Each row has the item, its depth (0 at the top level), hasChildren, and whether it’s expanded. items is every item, folded or not, when you’d rather build the tree yourself.

toggle(ref) folds or unfolds an item; expand(ref), collapse(ref), expandAll() and collapseAll() do one of them. The items the file opens start expanded, and from then on what’s expanded is the reader’s: it doesn’t change the document.

An item has its title, its color (null when the file sets none, so draw your own text colour), and whether it’s bold or italic. status says whether the outline hasn’t been asked for yet ('idle'), is loading, is there ('ready'), or couldn’t be read. incomplete is true when part of the outline couldn’t be read, such as a loop in a damaged file; what could be read is listed.

Follow an item#

activate(ref) goes where the item goes, as a click does: a page moves the view, a website opens, and an item that runs a script runs it, through the actions plugin when you’ve registered it. It returns what happened at once, so a website can still open from the reader’s click.

current is the item for where the reader is: the last item, in reading order, that goes to a place at or above the top of the view. Mark its row, and the reader always sees where they are in the outline:

const isCurrent = row.item.ref.objectNumber === current?.objectNumber;

Loading live preview…

import { ChangeDetectionStrategy, Component, inject, 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 { EpdfOutline, withOutline } from '@embedpdf/angular/outline';
import type { OutlineItem } from '@embedpdf/angular/outline';
import { cloudEngine } from '@cloudpdf/engine';

const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };

// Bookmarks the reader adds: each one goes to the place in view when it's added.
@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
  providers: [
    provideEmbedPdf(
      { engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
      withStage(),
      withRender(),
      withOutline(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './bookmarks.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <div class="layout">
        <aside class="bookmarks">
          <header class="bookmarks-header">
            <h3 class="bookmarks-title">Bookmarks</h3>
            <button
              type="button"
              class="button primary"
              [disabled]="!outline.canEdit()"
              (click)="add(stage.currentPageIndex())"
            >
              Add
            </button>
          </header>
          @if (outline.rows().length === 0) {
            <p class="empty">Scroll to a page, then add a bookmark.</p>
          } @else {
            <ul class="list">
              @for (row of outline.rows(); track row.item.ref.objectNumber) {
                <li
                  class="row"
                  [attr.data-current]="isCurrent(row.item) || null"
                  [attr.data-selected]="isSelected(row.item) || null"
                >
                  <button type="button" class="row-title" (click)="follow(row.item)">
                    {{ row.item.title }}
                  </button>
                  <button
                    type="button"
                    class="row-delete"
                    [attr.aria-label]="'Delete ' + row.item.title"
                    [disabled]="!outline.canEdit()"
                    (click)="outline.delete([row.item.ref])"
                  >
                    ×
                  </button>
                </li>
              }
            </ul>
          }
        </aside>

        <epdf-stage #stage="epdfStage" class="stage">
          <ng-template epdfPage>
            <epdf-render-layer />
          </ng-template>
        </epdf-stage>
      </div>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  protected readonly outline = inject(EpdfOutline);

  protected add(pageIndex: number) {
    void this.outline.create({ title: `Page ${pageIndex + 1}` }, { select: true });
  }

  protected follow(item: OutlineItem) {
    this.outline.select([item.ref]);
    this.outline.activate(item.ref);
  }

  protected isCurrent(item: OutlineItem): boolean {
    return item.ref.objectNumber === this.outline.current()?.objectNumber;
  }

  protected isSelected(item: OutlineItem): boolean {
    return item.ref.objectNumber === this.outline.selected()[0]?.objectNumber;
  }
}

Change the outline#

create() adds an item. Leave out where it goes and it goes to the place in view; leave out its title and it takes the selected text, or “Untitled” in the viewer’s language. It shows at once, and select: true selects it and unfolds the items it sits under, so it’s ready to rename:

await outline.create({ parent: chapter.ref, position: 'end' }, { select: true });

update(ref, changes) changes the fields you pass: a title, a target, a color, bold, italic, or open (whether it’s expanded when the document opens). null removes a target or a colour. move(refs, parent, position) moves items, with the items under them, to their new place, and delete(refs) deletes items with the items under them.

Each of these shows at once, is one step to undo, and resolves once the engine has it. A place is a neighbour, not a number: 'start', 'end', { before: ref } or { after: ref } among the parent’s children.

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  inject,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfOutline, withOutline } from '@embedpdf/angular/outline';
import type { OutlineItem, OutlineItemRef } from '@embedpdf/angular/outline';
import { cloudEngine } from '@cloudpdf/engine';

const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };

const same = (left: OutlineItemRef | null, right: OutlineItemRef | null) =>
  left?.objectNumber === right?.objectNumber;

// An outline being built: add items, nest them, fold them, rename and delete them.
@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate],
  providers: [
    provideEmbedPdf(
      { engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
      withOutline(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './nesting.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <div class="editor">
        <div class="toolbar">
          <input
            #field
            class="field"
            aria-label="Title"
            [value]="title()"
            (input)="title.set(field.value)"
          />
          <button type="button" class="button" [disabled]="!outline.canEdit()" (click)="add(false)">
            Add
          </button>
          <button
            type="button"
            class="button"
            [disabled]="!outline.canEdit() || !item()"
            (click)="add(true)"
          >
            Add inside
          </button>
          <button
            type="button"
            class="button"
            [disabled]="!outline.canEdit() || !previous()"
            (click)="indent()"
          >
            Indent
          </button>
          <button
            type="button"
            class="button"
            [disabled]="!outline.canEdit() || !parent()"
            (click)="outdent()"
          >
            Outdent
          </button>
          <button
            type="button"
            class="button"
            [disabled]="!outline.canEdit() || !item()"
            (click)="rename()"
          >
            Rename
          </button>
          <button
            type="button"
            class="button"
            [disabled]="!outline.canEdit() || !item()"
            (click)="remove()"
          >
            Delete
          </button>
        </div>
        @if (outline.rows().length === 0) {
          <p class="empty">This document has no outline yet. Add an item.</p>
        } @else {
          <ul class="tree" role="tree">
            @for (row of outline.rows(); track row.item.ref.objectNumber) {
              <li
                class="row"
                role="treeitem"
                [attr.aria-level]="row.depth + 1"
                [attr.aria-expanded]="row.hasChildren ? row.expanded : null"
                [attr.aria-selected]="row.item.ref.objectNumber === item()?.ref?.objectNumber"
                [style.padding-inline-start.px]="6 + row.depth * 18"
              >
                <button
                  type="button"
                  class="chevron"
                  [attr.aria-label]="row.expanded ? 'Collapse' : 'Expand'"
                  [disabled]="!row.hasChildren"
                  [attr.data-expanded]="row.expanded || null"
                  (click)="outline.toggle(row.item.ref)"
                ></button>
                <button type="button" class="row-title" (click)="pick(row.item)">
                  {{ row.item.title }}
                </button>
              </li>
            }
          </ul>
        }
      </div>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  protected readonly outline = inject(EpdfOutline);
  protected readonly title = signal('Chapter');
  protected readonly item = computed(() => {
    const [picked] = this.outline.selected();
    return this.outline.items().find((each) => same(each.ref, picked ?? null)) ?? null;
  });
  // The item before the picked one among its parent's children: where an indent puts it.
  protected readonly previous = computed(() => {
    const item = this.item();
    if (!item) return null;
    const siblings = this.outline.items().filter((each) => same(each.parent, item.parent));
    return siblings[siblings.indexOf(item) - 1] ?? null;
  });
  protected readonly parent = computed(() => {
    const item = this.item();
    return this.outline.items().find((each) => same(each.ref, item?.parent ?? null)) ?? null;
  });

  // After the picked item, or at the end.
  protected add(inside: boolean) {
    const item = this.item();
    const title = this.title();
    void this.outline.create(
      inside && item
        ? { title, parent: item.ref }
        : { title, parent: item?.parent ?? null, position: item ? { after: item.ref } : 'end' },
      { select: true },
    );
  }

  protected indent() {
    const item = this.item();
    const previous = this.previous();
    if (!item || !previous) return;
    void this.outline.move([item.ref], previous.ref, 'end');
    this.outline.expand(previous.ref);
  }

  protected outdent() {
    const item = this.item();
    const parent = this.parent();
    if (item && parent) void this.outline.move([item.ref], parent.parent, { after: parent.ref });
  }

  protected rename() {
    const item = this.item();
    if (item) void this.outline.update(item.ref, { title: this.title() });
  }

  protected remove() {
    const item = this.item();
    if (item) void this.outline.delete([item.ref]);
  }

  protected pick(item: OutlineItem) {
    this.outline.select([item.ref]);
    this.title.set(item.title);
  }
}

Select items#

select(refs) makes items the selection, and selected lists them. The selection is the reader’s, like what’s expanded: show it, and act on it with your own buttons.

outline.select([row.item.ref]);
await outline.delete(selected);

Methods#

Everything inject(EpdfOutline) gives you.

MethodWhat it does
get(ref)One item, or null.
getChildren(parent)An item’s children in order, or the top level’s for null.
isExpanded(ref)Whether an item’s children show. Show the outline
toggle(ref)Folds an expanded item, unfolds a folded one.
expand(ref), collapse(ref)Unfolds or folds one item.
expandAll(), collapseAll()Unfolds or folds every item.
reveal(ref)Unfolds every item ref sits under, so its row shows.
select(refs)Makes these items the selection; [] clears it. Select items
activate(ref)Goes where the item goes, as a click does, and returns what happened. Follow an item
getCurrentViewTarget()Where the view is, as a target: the page at its top and the spot at its top-left. What a new item goes to.
create(item, options)Adds an item; select: true selects it. Resolves { item }. Change the outline
update(ref, changes)Changes the fields you pass; null removes a target or a colour. Resolves { item }.
move(refs, parent, position)Moves items, with the items under them, to their new place.
delete(refs)Deletes items with the items under them, as one change.
ensureLoaded()Reads the outline from the file, unless it’s loaded or loading. Resolves once it’s there.
refresh()Reads the outline again. Rarely needed: it stays up to date by itself.

Every method that returns a promise also takes a signal in its options, to cancel it.

State#

Every value is a signal on inject(EpdfOutline): read it in a template or a computed(), and your view updates when it changes. The method in the last column reads the same value once.

FieldWhat it isRead once
items()Every item, depth first, with your changes shown before the engine answerslist()
rows()The rows a tree shows: item, depth, hasChildren, expandedlistRows()
current()The item for where the reader is, or nullgetCurrentItem()
selected()The selected items, in the order they were selectedgetSelected()
status()'idle' until something asks for it, then 'loading', 'ready', 'forbidden' or 'error'getStatus()
incomplete()Part of the outline couldn’t be readisIncomplete()
canEdit()Whether changing the outline is allowedcanEdit()

Events#

outline-changed.ts
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { filter, map } from 'rxjs';
import { EpdfOutline } from '@embedpdf/angular/outline';

@Component({
  selector: 'app-outline-notice',
  template: `<p role="status">{{ notice() }}</p>`,
})
export class OutlineNotice {
  private readonly outline = inject(EpdfOutline);

  protected readonly notice = toSignal(
    this.outline.changed$.pipe(
      filter(({ origin }) => origin.kind === 'remote'),
      map(({ type, items }) => `Someone ${type} ${items.length} bookmark(s)`),
    ),
    { initialValue: '' },
  );
}
EventFires whenCarries
changed$Items were created, updated, moved, deleted or restored, by you or by someone elsetype, items, origin
resynced$The outline was read from the file: the first time it’s asked for, after refresh(), or to catch up after a lost connectionoutline

type is 'created', 'updated', 'moved', 'deleted' or 'restored', and items names the items it touched; for a delete, the item first, then the items under it.

Permissions#

PermissionWhat it allowsCheck
doc.openReading the outlinenone: it opened
doc.outline.modifyAdding, changing, moving and deleting itemsoutline.canEdit()

Good to know#

Expanded, and open#

What’s expanded is the reader’s view, not the document: folding an item doesn’t change the file. An item’s open is the file’s own setting, whether its children show when the document opens, and the outline starts from it. Change it with update(ref, { open }).

Pages that are gone#

Deleting a page doesn’t delete the items that go to it: their target reads unsupported, and activate() reports it rather than going anywhere. See Outline.

The ready-made viewer#

The viewer’s Outline panel is built on this plugin. See Panels.