---
title: "Rendering — Angular"
description: "Draw every page sharp at every zoom, within a memory budget you choose, and render pages yourself for previews and exports."
framework: "Angular"
source: "https://www.embedpdf.com/docs/headless/angular/viewing/render"
---

# Rendering

The render plugin draws the pages. Put `<epdf-render-layer>` in each page, and the plugin decides what
to render, how big and when, keeps the results cached, and redraws only the pages whose content
changed:

**`basic.ts`**

```typescript
import { ChangeDetectionStrategy, Component, 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 { localEngine } from '@embedpdf/engine';

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

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

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

**`basic.css`**

```css
/* Colors */
:root {
  --muted: #5a6b92;
  --line: #e6eaf2;
  --canvas: #f7f9fc;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The pages */
.stage {
  height: 460px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

- **Sharp text.** Pages render at exactly the pixels your screen needs, so small text stays crisp,
  even on a low-resolution display.
- **A memory ceiling.** A whole-page picture is never wider than 640 device pixels. Zooming in
  further uses small tiles for the part you can see, so deep zoom never asks for a giant image.
- **Caching.** Scrolling away and back is instant, and two views of the same page share one render.

## Sharp at every zoom

A page shows a whole-page picture first, which appears fast. When you zoom in past what that
picture can show sharply, tiles take over for the part you can see:

**`deep-zoom.ts`**

```typescript
import { ChangeDetectionStrategy, Component, 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 { localEngine } from '@embedpdf/engine';

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

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: welcome }] },
      // Open at 250%: past what a whole-page picture shows sharply, so tiles carry it.
      withStage({ zoom: { level: 2.5 } }),
      withRender(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './deep-zoom.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <div class="toolbar">
        <button type="button" class="button" aria-label="Zoom out" (click)="stage.zoomOut()">
          −
        </button>
        <output class="readout">{{ percent(stage.zoomLevel()) }}%</output>
        <button type="button" class="button" aria-label="Zoom in" (click)="stage.zoomIn()">
          +
        </button>
        <button type="button" class="button push" (click)="stage.fitWidth()">Fit width</button>
      </div>

      <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 {
  protected percent(zoomLevel: number): number {
    return Math.round(zoomLevel * 100);
  }
}
```

**`deep-zoom.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The zoom controls, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.button {
  min-width: 38px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 11px;
  cursor: pointer;
  white-space: nowrap;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease;
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:active {
  transform: translateY(1px);
}

.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
  transform: none;
}

.readout {
  min-width: 52px;
  text-align: center;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* Fit width sits at the far end */
.push {
  margin-left: auto;
}

/* The pages */
.stage {
  height: 440px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

> **Try it:** it opens at 250%, past what a whole-page picture shows sharply. Zoom in further: only
> the tiles you can see load, the area under the pointer sharpens first, and when you stop, the
> tiles redraw at exactly your zoom.

While you pinch, the pixels you have scale along, so nothing reloads mid-gesture. About 150
milliseconds after the zoom comes to rest, the page redraws at the new size. You configure none of
this.

## Set the memory budget

One setting decides how big a whole-page picture may get, in device pixels:

```ts
withRender({ fullPage: { maxWidth: 1280 } });
```

The default, `640`, makes the first picture of each page small and fast, and lets tiles carry the
sharpness at normal reading zooms. A bigger budget means fewer tiles but a slower first paint and
more memory. For a view that should never tile, such as a thumbnail strip, set `tiles` to `false` on
its layer.

**`budget.ts`**

```typescript
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  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 { EpdfRender, 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', id: 'welcome', bytes: new Uint8Array(await response.arrayBuffer()) };
};

const BUDGETS = [320, 640, 1280];

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: welcome }] },
      // Zoomed in, where the budget and the tiles show.
      withStage({ zoom: { level: 2 } }),
      withRender(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './budget.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <div class="toolbar">
        <span class="label">Budget</span>
        <div class="segmented" role="group" aria-label="Whole-page budget">
          @for (width of budgets; track width) {
            <button
              type="button"
              [attr.aria-pressed]="width === maxWidth()"
              (click)="render.updateSettings({ fullPage: { maxWidth: width } })"
            >
              {{ width }} px
            </button>
          }
        </div>
        <span class="label">Tiles</span>
        <div class="segmented" role="group" aria-label="Tiles">
          <button
            type="button"
            [attr.aria-pressed]="tiles()"
            (click)="render.updateSettings({ tiles: { size: 512 } })"
          >
            On
          </button>
          <button
            type="button"
            [attr.aria-pressed]="!tiles()"
            (click)="render.updateSettings({ tiles: false })"
          >
            Off
          </button>
        </div>
        <div class="zoom">
          <button type="button" class="button" aria-label="Zoom out" (click)="stage.zoomOut()">
            −
          </button>
          <output class="readout">{{ percent(stage.zoomLevel()) }}%</output>
          <button type="button" class="button" aria-label="Zoom in" (click)="stage.zoomIn()">
            +
          </button>
        </div>
      </div>

      <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 {
  protected readonly render = inject(EpdfRender);
  protected readonly budgets = BUDGETS;
  protected readonly maxWidth = computed(() => this.render.settings().fullPage.maxWidth);
  protected readonly tiles = computed(() => this.render.settings().tiles !== false);

  protected percent(zoomLevel: number): number {
    return Math.round(zoomLevel * 100);
  }
}
```

**`budget.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --label: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The render settings, and the zoom to see them at */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

/* A small caption in front of a control */
.label {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--label);
}

/* One choice of a few: the pressed button is the current one */
.segmented {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 2px;
  padding: 3px;
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 11px;
}

.segmented button {
  font: inherit;
  font-size: 12.5px;
  font-weight: 550;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  padding: 6px 12px;
  cursor: pointer;
  white-space: nowrap;
}

.segmented button:hover {
  color: var(--ink);
}

.segmented button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--surface);
  box-shadow: 0 1px 2px rgb(7 32 76 / 0.1);
}

.segmented button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.button {
  min-width: 38px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 11px;
  cursor: pointer;
  white-space: nowrap;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease;
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:active {
  transform: translateY(1px);
}

.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
  transform: none;
}

.readout {
  min-width: 52px;
  text-align: center;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.zoom {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-left: auto;
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

> **Try it:** turn the tiles off, then pick a smaller budget. At 200% the page goes soft: the
> whole-page picture is all there is.

## What the picture draws

A page's picture draws the page with its annotations and its form fields, each when the user may
read it: the document as it is, right for thumbnails and read-only views. A layer on the page that
paints a part itself takes that part out of the picture: an [annotation layer](../annotations) the
annotations, so people can select and change them, and a [form layer](../forms) the form fields.
You write nothing for it:

**`annotations.ts`**

```typescript
import { ChangeDetectionStrategy, Component, 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 { withInteraction } from '@embedpdf/angular/interaction';
import { EpdfAnnotationLayer, withAnnotation } from '@embedpdf/angular/annotation';
import { localEngine } from '@embedpdf/engine';

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

type Painter = 'picture' | 'layer';

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfAnnotationLayer],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: report }] },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './annotations.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <div class="toolbar">
        <div class="segmented" role="group" aria-label="Who draws the annotations">
          <button
            type="button"
            [attr.aria-pressed]="painter() === 'picture'"
            (click)="painter.set('picture')"
          >
            In the page picture
          </button>
          <button
            type="button"
            [attr.aria-pressed]="painter() === 'layer'"
            (click)="painter.set('layer')"
          >
            Drawn by the annotation layer
          </button>
        </div>
        <p class="hint">
          @if (painter() === 'picture') {
            Part of the picture: it can’t be picked up.
          } @else {
            Left out of the picture: click it, then drag it.
          }
        </p>
      </div>
      <epdf-stage class="stage">
        <ng-template epdfPage>
          @if (painter() === 'picture') {
            <epdf-render-layer />
          } @else {
            <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 {
  protected readonly painter = signal<Painter>('picture');
}
```

**`annotations.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* Who draws the annotations, and what that means */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

/* One choice of a few: the pressed button is the current one */
.segmented {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 2px;
  padding: 3px;
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 11px;
}

.segmented button {
  font: inherit;
  font-size: 12.5px;
  font-weight: 550;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  padding: 6px 12px;
  cursor: pointer;
  white-space: nowrap;
}

.segmented button:hover {
  color: var(--ink);
}

.segmented button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--surface);
  box-shadow: 0 1px 2px rgb(7 32 76 / 0.1);
}

.segmented button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.hint {
  margin: 0;
  font-size: 13px;
  color: var(--muted);
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

To decide yourself, set `annotations` or `formFields` on the render layer: `false` leaves the part
out, `true` draws it. A clean preview without the annotations:

**`render-without-annotations.ts`**

```typescript
import { Component } from '@angular/core';
import { EpdfRenderLayer } from '@embedpdf/angular/render';
import { EpdfPageTemplate, EpdfStage } from '@embedpdf/angular/stage';

@Component({
  selector: 'app-clean-pages',
  imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
  template: `
    <epdf-stage>
      <ng-template epdfPage>
        <epdf-render-layer [annotations]="false" />
      </ng-template>
    </epdf-stage>
  `,
})
export class CleanPages {}
```

## Show that a page is loading

The first picture of a page can take a moment: a heavy page, a slow connection, a server that's
just starting. Until it's on screen, the page shows its background, which looks like a blank page.
Put a layer of your own in each page, after the render layer, that shows an indicator while its
page waits:

**`page-loading.ts`**

```typescript
import { Component } from '@angular/core';
import { injectPictureState } from '@embedpdf/angular/render';

// Put <app-page-loading /> in each page, after <epdf-render-layer />
@Component({
  selector: 'app-page-loading',
  template: `
    @if (picture().status === 'loading' && picture().slow) {
      <div class="page-loading" role="status" aria-label="Loading page"></div>
    } @else if (picture().status === 'error') {
      <p class="page-error">This page couldn’t load.</p>
    }
  `,
})
export class PageLoading {
  protected readonly picture = injectPictureState();
}
```

`injectPictureState()`
reads the page it's in. `status` is `'loading'` until a picture of the page is on screen, then
`'ready'`. `slow` turns on once the page has waited 400 milliseconds, so a page that shows quickly
never flashes an indicator. Getting sharper isn't loading: tiles arriving as you zoom, and a page
redrawing after a change, keep the old picture on screen, so the page stays `'ready'`.

| `status`      | What it means                                                  |
| :------------ | :------------------------------------------------------------- |
| `'idle'`      | The view doesn't show this page                                |
| `'loading'`   | The page is in the view, and no picture of it is on screen yet |
| `'ready'`     | A picture of it is on screen                                   |
| `'forbidden'` | The user may not see it: no [`doc.render`](#permissions)       |
| `'error'`     | It couldn't be drawn, for example after a network failure      |

Each view has its own: a page can be `'ready'` in the main view while its thumbnail still loads.
Outside a page, `render.getPictureState(page, view)` reads the same, and `onPictureStateChanged`
fires on every change. Change the wait with `slowAfterMs`; `0` turns `slow` on at once:

```ts
withRender({ slowAfterMs: 200 });
```

## Render a page yourself

For your own preview, an export or a page picker, render any page on demand:

```ts
const image = await render.renderPage(page.ref, { width: 800 });
const { url, revoke } = await image.objectUrl();
// …show it, and call revoke() when you're done
```

`renderPage` renders exactly the size you ask for, at `width` pixels or at a `scale`, and shares the
layers' cache. For many pages at once, use `renderPages`, which renders a few at a time:

```ts
const { applied } = await render.renderPages(
  pages.map((page) => page.ref),
  { width: 300 },
);
```

For a page picker or a strip, `renderThumbnail` renders the page's
[thumbnail](https://www.embedpdf.com/docs/engine/documents/rendering#thumbnails): the whole page, 512 pixels on its long
side. It needs only `doc.render.thumbnail`, so it works for users who may not render pages:

```ts
const thumbnail = await render.renderThumbnail(page.ref);
```

To render part of a page, give its `rect` in [page space](https://www.embedpdf.com/docs/engine/concepts/coordinates): from
the page's top-left, y down, in points. `width` and `scale` then size that part. `rotation` turns the
picture clockwise:

```ts
const part = await render.renderPage(page.ref, {
  rect: { x: 72, y: 72, width: 200, height: 100 },
  scale: 2, // 400 × 200 pixels
});
```

A part of a page, or a turned picture, is rendered for you alone: it doesn't go through the layers'
cache. A `rect` that runs past the page's edge is cut at it, and one that misses the page fails with
`invalid-input`.

| Option               | What it is                                            | Default                                                           |
| :------------------- | :---------------------------------------------------- | :---------------------------------------------------------------- |
| `width`              | The width of the image, in pixels                     | —                                                                 |
| `scale`              | Or a scale: `1` is the page's size in points          | `1`                                                               |
| `rect`               | The part of the page to render, in page space         | the whole page                                                    |
| `rotation`           | Turn the picture clockwise: `0`, `90`, `180` or `270` | `0`: the page as stored                                           |
| `includeAnnotations` | Draw the annotations into the image                   | when the user may read them                                       |
| `includeFormFields`  | Draw the form fields into the image                   | `false` without annotations; else when the user may read the form |
| `format`             | `'png'`, `'webp'` or `'bmp'`                          | per engine                                                        |
| `quality`            | For WebP, from `0` to `1`                             | per engine                                                        |
| `concurrency`        | For `renderPages`, how many pages render at once      | `4`                                                               |

Asking for annotations or form fields the user may not read fails with `permission-denied`.

**`render-page.ts`**

```typescript
import {
  ChangeDetectionStrategy,
  Component,
  effect,
  inject,
  signal,
  untracked,
  ViewEncapsulation,
} from '@angular/core';
import { EpdfDocument, EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput, PageRef } from '@embedpdf/angular/runtime';
import { EpdfRender, 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', id: 'welcome', bytes: new Uint8Array(await response.arrayBuffer()) };
};

interface Thumbnail {
  page: PageRef;
  url: string;
}

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate],
  // No Stage: these pictures are plain images, rendered on demand.
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: welcome }] },
      withRender(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './render-page.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <div *epdfDocumentGate="let document; fallback: loading" class="images">
      <div class="strip" role="listbox" aria-label="Pages">
        @for (thumbnail of thumbnails(); track thumbnail.page.objectNumber; let index = $index) {
          <button
            type="button"
            role="option"
            class="thumbnail"
            [attr.aria-selected]="index === selected()"
            (click)="selected.set(index)"
          >
            <img [src]="thumbnail.url" [alt]="'Page ' + (index + 1)" />
            <span>{{ index + 1 }}</span>
          </button>
        }
      </div>
      <div class="preview">
        @if (preview(); as url) {
          <img [src]="url" [alt]="'Page ' + (selected() + 1) + ', 640 pixels wide'" />
        }
      </div>
    </div>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  private readonly render = inject(EpdfRender);
  private readonly document = inject(EpdfDocument);
  protected readonly thumbnails = signal<Thumbnail[]>([]);
  protected readonly selected = signal(0);
  protected readonly preview = signal<string | null>(null);

  constructor() {
    // A small picture of every page, a few rendered at a time.
    effect((onCleanup) => {
      const pages = this.document.pages();
      if (pages.length === 0) return;
      const controller = new AbortController();
      const revokes: (() => void)[] = [];
      onCleanup(() => {
        controller.abort();
        revokes.forEach((revoke) => revoke());
      });
      (async () => {
        const { applied } = await this.render.renderPages(
          pages.map((page) => page.ref),
          { width: 120, signal: controller.signal },
        );
        const ready = await Promise.all(
          applied.map(async ({ page, image }) => {
            const { url, revoke } = await image.objectUrl();
            revokes.push(revoke);
            return { page, url };
          }),
        );
        if (!controller.signal.aborted) this.thumbnails.set(ready);
      })().catch(() => {
        // cancelled: the document closed or the list changed
      });
    });

    // The chosen page, exactly 640 pixels wide.
    effect((onCleanup) => {
      const selected = this.selected();
      if (this.document.status() !== 'ready') return;
      const controller = new AbortController();
      let revoke: (() => void) | undefined;
      onCleanup(() => {
        controller.abort();
        revoke?.();
      });
      untracked(async () => {
        const image = await this.render.renderPage(selected, {
          width: 640,
          signal: controller.signal,
        });
        const object = await image.objectUrl();
        if (controller.signal.aborted) {
          object.revoke();
          return;
        }
        revoke = object.revoke;
        this.preview.set(object.url);
      }).catch(() => {
        // cancelled: another page was picked
      });
    });
  }
}
```

**`render-page.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The page pictures beside the large one; above it on a narrow screen */
.images {
  container-type: inline-size;
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
}

.strip {
  flex: 1 1 100%;
  display: flex;
  gap: 10px;
  padding: 4px;
  overflow-x: auto;
}

.thumbnail {
  flex: none;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  width: 104px;
  font: inherit;
  font-size: 12px;
  font-weight: 600;
  color: var(--muted);
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}

.thumbnail img {
  width: 100%;
  border: 1px solid var(--line);
  border-radius: 4px;
  background: var(--surface);
  box-shadow: 0 1px 3px rgb(7 32 76 / 0.12);
}

.thumbnail[aria-selected='true'] {
  color: var(--accent);
}

.thumbnail[aria-selected='true'] img {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.thumbnail:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 4px;
  border-radius: 6px;
}

/* The large picture: exactly the pixels asked for, shown at the width there is */
.preview {
  flex: 1 1 100%;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  min-height: 300px;
  padding: 16px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
}

.preview img {
  max-width: 100%;
  box-shadow: 0 6px 18px rgb(0 0 0 / 0.18);
}

/* Side by side once there's room: the pictures in a column, after the rules
   above so they win */
@container (min-width: 560px) {
  .strip {
    flex: 0 0 120px;
    flex-direction: column;
    max-height: 460px;
    overflow: auto;
  }

  .preview {
    flex: 1 1 0;
    min-width: 0;
  }
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

## Take a snapshot

The `snapshot` tool takes part of a page as an image, to paste into a message or save as a file,
like Acrobat's Snapshot. Put `<SnapshotLayer>` in each page, after your other layers, to draw the
box, and switch to the tool:

```ts
interaction.activateTool('snapshot');
```

**`snapshot.ts`**

```typescript
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import { copyImage, EpdfDocumentGate, provideEmbedPdf, saveFile } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import {
  EpdfRender,
  EpdfRenderLayer,
  EpdfSnapshotLayer,
  withRender,
} from '@embedpdf/angular/render';
import { EpdfInteraction, withInteraction } from '@embedpdf/angular/interaction';
import { localEngine } from '@embedpdf/engine';

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

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfSnapshotLayer],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: welcome }] },
      withStage(),
      withRender(),
      withInteraction({ defaultTool: 'pan' }),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './snapshot.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <div class="toolbar">
        <div class="segmented" role="group" aria-label="Tool">
          <button
            type="button"
            class="segment"
            [attr.aria-pressed]="interaction.activeToolId() === 'pan'"
            (click)="interaction.activateTool('pan')"
          >
            Hand
          </button>
          <button
            type="button"
            class="segment"
            [attr.aria-pressed]="interaction.activeToolId() === 'snapshot'"
            (click)="interaction.activateTool('snapshot')"
          >
            Snapshot
          </button>
        </div>
        <div class="actions">
          <button type="button" class="button" [disabled]="!snapshot()" (click)="copy()">
            Copy
          </button>
          <button type="button" class="button" [disabled]="!snapshot()" (click)="save()">
            Save
          </button>
        </div>
        <output class="readout">{{ readout() }}</output>
      </div>
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-snapshot-layer />
        </ng-template>
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  protected readonly interaction = inject(EpdfInteraction);
  private readonly render = inject(EpdfRender);
  protected readonly snapshot = computed(() => this.render.getSnapshot());
  private readonly said = signal('');

  protected readonly readout = computed(
    () =>
      this.said() ||
      (this.interaction.activeToolId() === 'snapshot' && !this.snapshot()
        ? 'Drag a box on a page'
        : ''),
  );

  constructor() {
    // A new snapshot, or none: nothing copied yet.
    effect(() => {
      this.snapshot();
      this.said.set('');
    });
  }

  protected copy() {
    const snapshot = this.snapshot();
    if (!snapshot) return;
    // In the click itself: the picture goes to the clipboard as it comes.
    copyImage(snapshot.image).then(
      () => this.said.set('Copied: paste it anywhere'),
      () => this.said.set('This browser didn’t allow the copy'),
    );
  }

  protected async save() {
    const snapshot = this.snapshot();
    if (!snapshot) return;
    saveFile(await (await snapshot.image).blob(), 'snapshot.png');
  }
}
```

**`snapshot.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --accent-tint: #e7f0fe;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The tools and the snapshot's buttons, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 14px;
  margin-bottom: 14px;
}

.segmented {
  display: inline-flex;
  gap: 2px;
  padding: 3px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 11px;
  box-shadow: 0 1px 2px var(--shadow);
}

.segment,
.button {
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1;
  border-radius: 8px;
  padding: 8px 12px;
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}

.segment {
  min-width: 72px;
  color: var(--muted);
  background: transparent;
  border: 0;
}

.segment:hover {
  color: var(--ink);
  background: var(--tint);
}

.segment[aria-pressed='true'] {
  color: var(--accent);
  background: var(--accent-tint);
}

.actions {
  display: inline-flex;
  gap: 6px;
}

.button {
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  box-shadow: 0 1px 2px var(--shadow);
}

.button:hover:not(:disabled) {
  background: var(--tint);
}

.button:disabled {
  color: var(--muted);
  cursor: default;
  opacity: 0.6;
}

.segment:focus-visible,
.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.readout {
  font-size: 13px;
  color: var(--muted);
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

> **Try it:** switch to Snapshot and drag a box over the page, then copy it and paste it into a
> message, or save it. A click takes the whole page.

- **Drag a box on a page.** It stays on the page where it started. A click takes the whole page.
- **The box stays** when you let go: it is the snapshot until you take another or switch tools.
  `Escape` and `render.clearSnapshot()` let it go.
- **Its picture** is the box at 2 pixels per point (144 DPI) as PNG, turned as the page is shown,
  with the annotations and form fields the user may read. `snapshot.scale` changes the size. A
  picture over 16 million pixels, or over what your server allows, comes out smaller, to fit.
- **A change on its way is in it.** The picture waits until the engine has every change made
  before the snapshot, so a stamp you placed a moment ago is there.

`getSnapshot()` on `inject(EpdfRender)`
reads it: `{ page, rect, image }`, where `rect` is the box in page space and `image` resolves to the
picture, or `null` while there's none. Outside a component, `render.getSnapshot()` reads the same,
and `onSnapshotChanged` fires when one is taken or let go. To copy it or save it, `copyImage` and
`saveFile` from `/runtime` do the browser's part:

**`snapshot-actions.ts`**

```typescript
import { Component, inject } from '@angular/core';
import { copyImage, saveFile } from '@embedpdf/angular/runtime';
import { EpdfRender, type Snapshot } from '@embedpdf/angular/render';

// Beside the pages: copy the snapshot, or save it as a file
@Component({
  selector: 'app-snapshot-actions',
  template: `
    @if (render.getSnapshot(); as snapshot) {
      <div class="snapshot-actions">
        <!-- In the click itself, not after an await: the picture follows when it's there -->
        <button (click)="copy(snapshot)">Copy</button>
        <button (click)="save(snapshot)">Save</button>
      </div>
    }
  `,
})
export class SnapshotActions {
  protected readonly render = inject(EpdfRender);

  protected copy(snapshot: Snapshot) {
    void copyImage(snapshot.image);
  }

  protected async save(snapshot: Snapshot) {
    saveFile(await (await snapshot.image).blob(), 'snapshot.png');
  }
}
```

Call `copyImage` in the click itself, not after an `await`: Safari lets a page write to the
clipboard only while it handles a press or a key.

The render plugin adds the tool when the [interaction plugin](../ui/tools) is registered. It needs
[`doc.content.copy`](#permissions) as well as `doc.render`; `render.canSnapshot()` says whether the
user may take one. Without it, the tool draws no box.

## When a page changes

A rendered page is a snapshot, so the plugin watches the document and redraws exactly the pages an
edit touched, whether you, your code or someone else on a shared document made it. Nothing redraws
while someone is still dragging: it redraws once, when the change is saved.

For a change the plugin can't see, tell it:

```ts
render.invalidate({ pages: [page.ref] }); // redraw these pages
render.invalidate({ pages: [page.ref], scope: 'annotations' }); // only their annotations changed
render.invalidate({ pages: [page.ref], scope: 'fields' }); // only their form fields changed
```

Your own long-lived renders can keep up the same way: `render.getRenderEpoch(page.ref)` changes
whenever the page's pixels do.

**`redraws.ts`**

```typescript
import {
  ChangeDetectionStrategy,
  Component,
  effect,
  inject,
  signal,
  untracked,
  viewChild,
  ViewEncapsulation,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EpdfDocument, EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput, PageRef } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRender, 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', id: 'welcome', bytes: new Uint8Array(await response.arrayBuffer()) };
};

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

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: welcome }] },
      withStage(),
      withRender(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './redraws.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      @let currentPage = stage.currentPage();
      <div class="panel">
        <div class="toolbar">
          <button
            type="button"
            class="button"
            [disabled]="!currentPage"
            (click)="currentPage && render.invalidate({ pages: [currentPage] })"
          >
            Redraw this page
          </button>
          <button
            type="button"
            class="button"
            [disabled]="!currentPage"
            (click)="
              currentPage && render.invalidate({ pages: [currentPage], scope: 'annotations' })
            "
          >
            Only its annotations
          </button>
          <!-- Changes whenever this page's pixels do: key your own long-lived renders on it. -->
          <output class="badge">
            render epoch <strong>{{ epochOf(currentPage) }}</strong>
          </output>
        </div>
        <ol class="log" aria-live="polite">
          @for (entry of entries(); track entry.id) {
            <li class="entry"><code>invalidated$</code> {{ entry.text }}</li>
          }
        </ol>
      </div>

      <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 {
  protected readonly render = inject(EpdfRender);
  private readonly document = inject(EpdfDocument);
  protected readonly entries = signal<Entry[]>([]);
  private count = 0;

  constructor() {
    this.render.invalidated$.pipe(takeUntilDestroyed()).subscribe(({ pages, scope, origin }) => {
      const what = `${pages.length === 1 ? '1 page' : `${pages.length} pages`} · ${scope}`;
      const from = origin ? `an edit (${origin.kind})` : 'your code';
      const entry = { id: this.count++, text: `${what} · from ${from}` };
      this.entries.update((current) => [entry, ...current].slice(0, 4));
    });

    // Redraw the first page on load, so there's something in the list.
    effect(() => {
      if (this.document.status() !== 'ready') return;
      untracked(() => this.render.invalidate({ pages: [0] }));
    });
  }

  protected epochOf(page: PageRef | null): number {
    return page ? this.render.getRenderEpoch(page) : 0;
  }
}
```

**`redraws.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The redraw buttons and what the plugin announced */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.button {
  min-width: 38px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 11px;
  cursor: pointer;
  white-space: nowrap;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease;
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:active {
  transform: translateY(1px);
}

.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
  transform: none;
}

.badge {
  font-size: 12.5px;
  font-weight: 600;
  color: var(--muted);
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 6px 11px;
  white-space: nowrap;
  margin-left: auto;
}

.badge strong {
  color: var(--ink);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

.log {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin: 0 0 14px;
  padding: 0;
  list-style: none;
}

.entry {
  font-size: 12.5px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 6px 10px;
}

/* The newest announcement stands out */
.entry:first-child {
  background: var(--tint);
  border-color: var(--accent);
}

.entry code {
  margin-right: 6px;
  font-size: 12px;
  font-weight: 600;
  color: var(--accent);
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

## Paint the pictures another way

A plugin that shows the pages some other way, as protected video or on a canvas of its own, takes
the pictures over from the render plugin:

```ts
const giveBack = render.handOverPictures({
  paints: (view) => view === mainView, // the views it paints; the others keep the render plugin's
  paint(host, page, report) {
    // host: an empty box over the page's content, page: the page as the layers see it
    const picture = drawInto(host, page);
    picture.ready.then(
      () => report.shown(), // its picture is on screen
      (error) => report.failed(error), // it can't paint this page
    );
    return {
      update: (page) => picture.follow(page.transform), // the zoom or the turn changed
      destroy: () => picture.remove(),
    };
  },
});
```

While it holds them, every `<epdf-render-layer>` in a view it paints lends it its box instead of drawing:
no picture, no tiles, no renders for that view. Everything above stays as it is: selection, search
highlights, links, annotations and form fields still sit on the page, in page space. A view it
doesn't paint, such as a strip of thumbnails, keeps the render plugin's pictures.

- **`page`** has the page's `ref`, `pageIndex`, `view`, `transform` (its size, zoom and turn on
  screen) and `getViewDemand()` (the part of it on screen), the same in every framework.
- **`report`** is how the page's [loading state](#show-that-a-page-is-loading) knows: the page is
  `'loading'` until you call `report.shown()`, once its first picture is on screen.
  `report.failed(error)` makes it `'error'`, or `'forbidden'` for a `permission-denied` error.
  Later pictures of the same page don't report: the page keeps showing until they're there.
- **One painter at a time.** A second one is refused (`conflict`) until the first is given back.
- **Take them over in your plugin's `connect()`**, and the pages never show the render plugin's
  picture first: every plugin connects before the document shows. The hand-over lasts until the
  document closes, or until you call the function it returns.

## Methods

Everything `inject(EpdfRender)` gives you.

| Method                                                        | What it does                                                                                                                                                                                                  |
| :------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `renderPage(page, options?)`                                  | Renders one page at exactly the size you ask for, sharing the layers' cache. Resolves an image; `image.objectUrl()` gives a URL for an `<img>`. [Render a page yourself](#render-a-page-yourself)             |
| `renderThumbnail(page, options?)`                             | Renders the page's thumbnail: the whole page, 512 pixels on its long side. Takes `includeAnnotations` and `includeFormFields`. [Render a page yourself](#render-a-page-yourself)                              |
| `renderPages(pages, options?)`                                | Renders several pages, a few at a time. Resolves `{ applied, failed }`: each page with its image, in the order you gave, and the pages that failed with why.                                                  |
| `invalidate({ pages?, scope? })`                              | Redraws pages after a change the plugin can't see; without `pages`, every page. [When a page changes](#when-a-page-changes)                                                                                   |
| `getRenderEpoch(page, layers?)`                               | A number that changes whenever the page's pixels do, for a picture that draws what `layers` says (`includeAnnotations`, `includeFormFields`, as above). Key your own long-lived renders on it.                |
| `getRenderPolicy()`                                           | The sizes the engine renders best at: any size on the local engine, a short list of widths on a CloudPDF server. See [Render policy](https://www.embedpdf.com/docs/engine/documents/rendering#render-policy). |
| `getPictureState(page, view)`                                 | Whether a picture of the page is on screen in a view: `{ status, slow }`. [Show that a page is loading](#show-that-a-page-is-loading)                                                                         |
| `canRender()`                                                 | Whether page images are allowed. [Permissions](#permissions)                                                                                                                                                  |
| `canRenderThumbnail()`                                        | Whether thumbnails are allowed. [Permissions](#permissions)                                                                                                                                                   |
| `canSnapshot()`                                               | Whether the user may take a snapshot. [Permissions](#permissions)                                                                                                                                             |
| `getSnapshot()`                                               | The snapshot held now, `{ page, rect, image }`, or `null`. [Take a snapshot](#take-a-snapshot)                                                                                                                |
| `clearSnapshot()`                                             | Lets the snapshot go. [Take a snapshot](#take-a-snapshot)                                                                                                                                                     |
| `handOverPictures(painter)`, `getPicturePainter()`            | Lets another plugin paint the pages' pictures until the function it returns is called, and which one does, or `null`. [Paint the pictures another way](#paint-the-pictures-another-way)                       |
| `getSettings()`, `updateSettings(changes)`, `resetSettings()` | Reads the settings, changes them while the app runs, or goes back to what you registered. In a component, use `inject(EpdfRender).settings()`. [Settings](#settings)                                          |

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
`render.updateSettings({ … })`; `render.resetSettings()` goes back to what you registered, and
`inject(EpdfRender).settings()` gives you the current settings.

| Setting                       | What it does                                                     | Default                     |
| :---------------------------- | :--------------------------------------------------------------- | :-------------------------- |
| `fullPage.maxWidth`           | The widest a whole-page picture may be, in device pixels         | `640`                       |
| `fullPage.quantize`           | Render exact sizes, or snap to a few reusable sizes              | `'exact'`                   |
| `tiles`                       | `false` turns tiles off everywhere                               | on                          |
| `tiles.size`                  | A tile's edge, in device pixels                                  | `512`                       |
| `tiles.maxScale`              | The sharpest tiles get; zoom beyond it stretches them            | `128`                       |
| `tiles.prefetch.margin`       | How far around the view to load tiles, as a share of the view    | `0.5`                       |
| `tiles.prefetch.velocityBias` | Load further ahead in the direction you're scrolling             | `true`                      |
| `tiles.settleMs`              | How long a zoom rests before tiles redraw                        | `150`                       |
| `slowAfterMs`                 | How long a page waits for its picture before it counts as `slow` | `400`                       |
| `format`                      | `'png'`, `'webp'`, or `'bmp'` (fastest, local engine only)       | per engine                  |
| `quality`                     | WebP quality, from `0` to `1`                                    | per engine                  |
| `debug`                       | Log what the plugin renders, and why, to the console             | `false`                     |
| `snapshot.scale`              | A [snapshot](#take-a-snapshot)'s pixels per point                | `2`                         |
| `snapshot.box.fill`           | Inside the box you drag with the snapshot tool                   | the viewer's `accent` at 8% |
| `snapshot.box.stroke`         | Its dashed edge                                                  | the viewer's `accent`       |

`<epdf-render-layer>` takes four inputs: `annotations` and `formFields`, unset by default
(see [What the picture draws](#what-the-picture-draws)), `tiles` (default `true`), and `thumbnail`:
in a strip of thumbnails, `<epdf-render-layer thumbnail />` draws each page's
[thumbnail](https://www.embedpdf.com/docs/engine/documents/rendering#thumbnails) instead, one small picture without tiles,
which needs only `doc.render.thumbnail`. `<SnapshotLayer>` takes none. The box's colors also come
from CSS: `--epdf-snapshot-box-fill` and `--epdf-snapshot-box-stroke` win over the settings, as on
[Theming](../ui/theming).

## Events

**`refresh-previews.ts`**

```typescript
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EpdfRender, EpdfRenderLayer } from '@embedpdf/angular/render';
import { EpdfPageTemplate, EpdfStage } from '@embedpdf/angular/stage';
import { refreshMyPreviews } from './previews';

@Component({
  selector: 'app-reader',
  imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
  template: `
    <epdf-stage>
      <ng-template epdfPage>
        <epdf-render-layer />
      </ng-template>
    </epdf-stage>
  `,
})
export class Reader {
  constructor() {
    inject(EpdfRender)
      .invalidated$.pipe(takeUntilDestroyed())
      .subscribe(({ pages }) => refreshMyPreviews(pages));
  }
}
```

| Event                  | Fires when                                                           | Carries                                        |
| :--------------------- | :------------------------------------------------------------------- | :--------------------------------------------- |
| `invalidated$`         | Pages' pixels changed and they will redraw                           | `pages`, `scope`, `origin`                     |
| `pictureStateChanged$` | A page's picture came on screen, failed, went slow, or left its view | `page`, `view`, `status`, `slow`               |
| `snapshotChanged$`     | A snapshot was taken, or let go                                      | `snapshot`: `{ page, rect, image }`, or `null` |
| `settingsChanged$`     | The settings changed                                                 | `settings`, `changed`                          |

## Permissions

| Permission             | What it allows                                                         | Check                         |
| :--------------------- | :--------------------------------------------------------------------- | :---------------------------- |
| `doc.render`           | Page images and page sections, and thumbnails                          | `render.canRender()`          |
| `doc.render.thumbnail` | Thumbnails only (`<epdf-render-layer thumbnail />`, `renderThumbnail`) | `render.canRenderThumbnail()` |
| `doc.content.copy`     | [Snapshots](#take-a-snapshot), with `doc.render`                       | `render.canSnapshot()`        |

Without `doc.render`, pages keep their size and place, so the Stage still scrolls and the other
plugins still work, but nothing is painted: each page's [state](#show-that-a-page-is-loading) is
`'forbidden'`. A strip of thumbnails still draws with
`doc.render.thumbnail`.

`doc.content.copy` comes with copying text: a PDF that forbids copying forbids snapshots too, as in
Acrobat. It's a rule the viewer keeps, not one that hides pixels: someone who may see the pages
could still capture the screen.

## Good to know

### Two layers of pixels

Each page paints from a whole-page picture, capped at the budget, and tiles over the visible part
when the view needs more pixels than the budget allows. The whole-page picture is also what you see
while tiles load, so a page never goes blank mid-zoom. Each tile is a square of the same size, so a
screen of tiles costs about the same at any zoom.

### Local and cloud render sizes

The local engine renders exact sizes: every render is private to this browser, so it asks for
precisely the pixels on screen.

> **Render heavy pages on your server**
>
> With CloudPDF, pages are rendered on your server, so a phone never downloads the file or runs
> PDFium, however heavy the drawing. The server renders a few fixed sizes that a cache can keep, and
> the render plugin asks for those sizes by itself: your code doesn't change.
>
> [Render with CloudPDF](https://www.cloudpdf.com/docs/headless/angular/viewing/render)

### Faster rendering

Each image is compressed before it reaches the page: PNG on the local engine by default. If
profiling shows compression matters, `format: 'bmp'` skips it on the local engine, at the cost of
memory (a 512-pixel tile is about 1 MB, against tens of KB as WebP).

### The engine underneath

The plugin renders through the engine's page rendering; see
[Rendering](https://www.embedpdf.com/docs/engine/documents/rendering) for page sizes and what a render costs.
