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

# Rendering

The render plugin draws the pages. Put `<RenderLayer>` 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.vue`**

```vue
<script setup lang="ts">
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { Stage, stagePlugin } from '@embedpdf/vue/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/vue/render';
import { localEngine } from '@embedpdf/engine';

import './basic.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin()];

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()) };
};
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <Stage class="stage">
        <template #page><RenderLayer /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`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:

**`App.vue`**

```vue
<script setup lang="ts">
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { Stage, stagePlugin } from '@embedpdf/vue/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/vue/render';
import { localEngine } from '@embedpdf/engine';
import ZoomBar from './ZoomBar.vue';

import '../deep-zoom.css';

const engine = localEngine();
// Open at 250%: past what a whole-page picture shows sharply, so tiles carry it.
const plugins = [stagePlugin({ zoom: { level: 2.5 } }), renderPlugin()];

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()) };
};
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <ZoomBar />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`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);
}
```

**`ZoomBar.vue`**

```vue
<script setup lang="ts">
import { useStage, useStageState } from '@embedpdf/vue/stage';

const stage = useStage();
const zoomLevel = useStageState((state) => state.zoomLevel);
</script>

<template>
  <div class="toolbar">
    <button type="button" class="button" aria-label="Zoom out" @click="stage.zoomOut()">−</button>
    <output class="readout">{{ Math.round(zoomLevel * 100) }}%</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>
</template>
```

> **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
renderPlugin({ 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.

**`App.vue`**

```vue
<script setup lang="ts">
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { Stage, stagePlugin } from '@embedpdf/vue/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/vue/render';
import { localEngine } from '@embedpdf/engine';
import BudgetControls from './BudgetControls.vue';

import '../budget.css';

const engine = localEngine();
// Zoomed in, where the budget and the tiles show.
const plugins = [stagePlugin({ zoom: { level: 2 } }), renderPlugin()];

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()) };
};
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <BudgetControls />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`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);
}
```

**`BudgetControls.vue`**

```vue
<script setup lang="ts">
import { useRender, useRenderSettings } from '@embedpdf/vue/render';
import { useStage, useStageState } from '@embedpdf/vue/stage';

const BUDGETS = [320, 640, 1280];

const render = useRender();
const stage = useStage();
const maxWidth = useRenderSettings((settings) => settings.fullPage.maxWidth);
const tiles = useRenderSettings((settings) => settings.tiles !== false);
const zoomLevel = useStageState((state) => state.zoomLevel);
</script>

<template>
  <div class="toolbar">
    <span class="label">Budget</span>
    <div class="segmented" role="group" aria-label="Whole-page budget">
      <button
        v-for="width in BUDGETS"
        :key="width"
        type="button"
        :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"
        :aria-pressed="tiles"
        @click="render.updateSettings({ tiles: { size: 512 } })"
      >
        On
      </button>
      <button type="button" :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">{{ Math.round(zoomLevel * 100) }}%</output>
      <button type="button" class="button" aria-label="Zoom in" @click="stage.zoomIn()">+</button>
    </div>
  </div>
</template>
```

> **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:

**`App.vue`**

```vue
<script setup lang="ts">
import { ref } from 'vue';
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { Stage, stagePlugin } from '@embedpdf/vue/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/vue/render';
import { interactionPlugin } from '@embedpdf/vue/interaction';
import { AnnotationLayer, annotationPlugin } from '@embedpdf/vue/annotation';
import { localEngine } from '@embedpdf/engine';
import AddRectangle from './AddRectangle.vue';

import '../annotations.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin(), annotationPlugin()];

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()) };
};

type Painter = 'picture' | 'layer';

const painter = ref<Painter>('picture');
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <AddRectangle />
      <div class="toolbar">
        <div class="segmented" role="group" aria-label="Who draws the annotations">
          <button
            type="button"
            :aria-pressed="painter === 'picture'"
            @click="painter = 'picture'"
          >
            In the page picture
          </button>
          <button type="button" :aria-pressed="painter === 'layer'" @click="painter = 'layer'">
            Drawn by the annotation layer
          </button>
        </div>
        <p class="hint">
          {{
            painter === 'picture'
              ? 'Part of the picture: it can’t be picked up.'
              : 'Left out of the picture: click it, then drag it.'
          }}
        </p>
      </div>
      <Stage class="stage">
        <template #page>
          <!-- Left out of the picture when the annotation layer draws them. -->
          <RenderLayer :annotations="painter === 'picture'" />
          <AnnotationLayer v-if="painter === 'layer'" />
        </template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`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);
}
```

**`AddRectangle.vue`**

```vue
<!-- A rectangle on the first page, made on load so there's an annotation to show. -->
<script setup lang="ts">
import { computed, watch } from 'vue';
import { usePageList } from '@embedpdf/vue/runtime';
import { useAnnotation } from '@embedpdf/vue/annotation';

const annotation = useAnnotation();
const pages = usePageList();
const firstPage = computed(() => pages.value[0]?.ref);

let added = false;
watch(
  firstPage,
  (page) => {
    if (!page || added) return;
    added = true;
    void annotation.create(page, {
      subtype: 'square',
      box: { x: 72, y: 96, width: 300, height: 140 },
      color: '#e11d48',
      interiorColor: '#ffe4e6',
      opacity: 0.7,
      strokeWidth: 3,
    });
  },
  { immediate: true },
);
</script>

<template></template>
```

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.vue`**

```vue
<script setup lang="ts">
import { RenderLayer } from '@embedpdf/vue/render';
import { Stage } from '@embedpdf/vue/stage';
</script>

<template>
  <Stage>
    <template #page>
      <RenderLayer :annotations="false" />
    </template>
  </Stage>
</template>
```

## 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 thumbnail = await render.renderThumbnail(page.ref, { maxWidth: 200 });

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

| 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`                                                               |
| `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`.

**`App.vue`**

```vue
<script setup lang="ts">
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { renderPlugin } from '@embedpdf/vue/render';
import { localEngine } from '@embedpdf/engine';
import PageImages from './PageImages.vue';

import '../render-page.css';

const engine = localEngine();
// No Stage: these pictures are plain images, rendered on demand.
const plugins = [renderPlugin()];

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()) };
};
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <PageImages />
    </DocumentGate>
  </Viewer>
</template>
```

**`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);
}
```

**`PageImages.vue`**

```vue
<script setup lang="ts">
import { ref, shallowRef, watch } from 'vue';
import { usePageList } from '@embedpdf/vue/runtime';
import type { PageRef } from '@embedpdf/vue/runtime';
import { useRender } from '@embedpdf/vue/render';

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

const render = useRender();
const pages = usePageList();
const thumbnails = shallowRef<Thumbnail[]>([]);
const selected = ref(0);
const preview = ref<string | null>(null);

// A small picture of every page, a few rendered at a time.
watch(
  pages,
  (list, _previous, onCleanup) => {
    const controller = new AbortController();
    const revokes: (() => void)[] = [];
    onCleanup(() => {
      controller.abort();
      revokes.forEach((revoke) => revoke());
    });
    (async () => {
      const { applied } = await render.renderPages(
        list.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) thumbnails.value = ready;
    })().catch(() => {
      // cancelled: the document closed or the list changed
    });
  },
  { immediate: true },
);

// The chosen page, exactly 640 pixels wide.
watch(
  selected,
  (pageIndex, _previous, onCleanup) => {
    const controller = new AbortController();
    let revoke: (() => void) | undefined;
    onCleanup(() => {
      controller.abort();
      revoke?.();
    });
    (async () => {
      const image = await render.renderPage(pageIndex, { width: 640, signal: controller.signal });
      const object = await image.objectUrl();
      if (controller.signal.aborted) {
        object.revoke();
        return;
      }
      revoke = object.revoke;
      preview.value = object.url;
    })().catch(() => {
      // cancelled: another page was picked
    });
  },
  { immediate: true },
);
</script>

<template>
  <div class="images">
    <div class="strip" role="listbox" aria-label="Pages">
      <button
        v-for="({ page, url }, index) in thumbnails"
        :key="page.objectNumber"
        type="button"
        role="option"
        class="thumbnail"
        :aria-selected="index === selected"
        @click="selected = index"
      >
        <img :src="url" :alt="`Page ${index + 1}`" />
        <span>{{ index + 1 }}</span>
      </button>
    </div>
    <div class="preview">
      <img v-if="preview" :src="preview" :alt="`Page ${selected + 1}, 640 pixels wide`" />
    </div>
  </div>
</template>
```

## 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.

**`App.vue`**

```vue
<script setup lang="ts">
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { Stage, stagePlugin } from '@embedpdf/vue/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/vue/render';
import { localEngine } from '@embedpdf/engine';
import Redraws from './Redraws.vue';

import '../redraws.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin()];

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()) };
};
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <Redraws />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`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);
}
```

**`Redraws.vue`**

```vue
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRender, useRenderEvent } from '@embedpdf/vue/render';
import { useStageState } from '@embedpdf/vue/stage';

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

const render = useRender();
const currentPage = useStageState((state) => state.currentPage);
const entries = ref<Entry[]>([]);
let count = 0;

useRenderEvent(
  (render) => render.onInvalidated,
  ({ pages, scope, origin }) => {
    const what = `${pages.length === 1 ? '1 page' : `${pages.length} pages`} · ${scope}`;
    const from = origin ? `an edit (${origin.kind})` : 'your code';
    entries.value = [{ id: count++, text: `${what} · from ${from}` }, ...entries.value].slice(0, 4);
  },
);

// Redraw the first page on load, so there's something in the list.
onMounted(() => render.invalidate({ pages: [0] }));

// Changes whenever this page's pixels do: key your own long-lived renders on it.
// Read as the list updates, which every redraw does.
const epoch = () => (currentPage.value ? render.getRenderEpoch(currentPage.value) : 0);

function redrawPage() {
  if (currentPage.value) render.invalidate({ pages: [currentPage.value] });
}

function redrawAnnotations() {
  if (currentPage.value) render.invalidate({ pages: [currentPage.value], scope: 'annotations' });
}
</script>

<template>
  <div class="panel">
    <div class="toolbar">
      <button type="button" class="button" :disabled="!currentPage" @click="redrawPage">
        Redraw this page
      </button>
      <button type="button" class="button" :disabled="!currentPage" @click="redrawAnnotations">
        Only its annotations
      </button>
      <output class="badge">
        render epoch <strong>{{ epoch() }}</strong>
      </output>
    </div>
    <ol class="log" aria-live="polite">
      <li v-for="entry in entries" :key="entry.id" class="entry">
        <code>onInvalidated</code> {{ entry.text }}
      </li>
    </ol>
  </div>
</template>
```

## Methods

Everything `useRender()` 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, { maxWidth })`                         | Renders a small picture of a page, `maxWidth` pixels wide                                                                                                                                                     |
| `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). |
| `canRender()`                                                 | Whether page images are allowed. [Permissions](#permissions)                                                                                                                                                  |
| `getSettings()`, `updateSettings(changes)`, `resetSettings()` | Reads the settings, changes them while the app runs, or goes back to what you registered. In a component, use `useRenderSettings()`. [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
`useRenderSettings()` 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`      |
| `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`    |

`<RenderLayer>` takes three props: `annotations` and `formFields`, unset by default
(see [What the picture draws](#what-the-picture-draws)), and `tiles` (default `true`).

## Events

**`refresh-previews.vue`**

```vue
<script setup lang="ts">
import { RenderLayer, useRenderEvent } from '@embedpdf/vue/render';
import { Stage } from '@embedpdf/vue/stage';
import { refreshMyPreviews } from './previews';

useRenderEvent(
  (render) => render.onInvalidated,
  ({ pages }) => refreshMyPreviews(pages),
);
</script>

<template>
  <Stage>
    <template #page>
      <RenderLayer />
    </template>
  </Stage>
</template>
```

| Event               | Fires when                                 | Carries                    |
| :------------------ | :----------------------------------------- | :------------------------- |
| `onInvalidated`     | Pages' pixels changed and they will redraw | `pages`, `scope`, `origin` |
| `onSettingsChanged` | The settings changed                       | `settings`, `changed`      |

## Permissions

| Permission   | What it allows                            | Check                |
| :----------- | :---------------------------------------- | :------------------- |
| `doc.render` | Page images, thumbnails and page sections | `render.canRender()` |

Without it, pages keep their size and place, so the Stage still scrolls and the other plugins still
work, but nothing is painted.

## 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/vue/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.
