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:
Loading live preview…
<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 welcome = async (): Promise<OpenInput> => {
const response = await fetch('/pdfs/welcome.pdf');
return { kind: 'bytes', id: 'welcome', bytes: new Uint8Array(await response.arrayBuffer()) };
};
</script>
<template>
<Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: welcome }]">
<DocumentGate>
<template #fallback><p class="loading">Loading…</p></template>
<Stage class="stage">
<template #page><RenderLayer /></template>
</Stage>
</DocumentGate>
</Viewer>
</template>- 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:
Loading live preview…
<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 welcome = async (): Promise<OpenInput> => {
const response = await fetch('/pdfs/welcome.pdf');
return { kind: 'bytes', id: 'welcome', bytes: new Uint8Array(await response.arrayBuffer()) };
};
</script>
<template>
<Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: welcome }]">
<DocumentGate>
<template #fallback><p class="loading">Loading…</p></template>
<ZoomBar />
<Stage class="stage">
<template #page><RenderLayer /></template>
</Stage>
</DocumentGate>
</Viewer>
</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:
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.
Loading live preview…
<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 welcome = async (): Promise<OpenInput> => {
const response = await fetch('/pdfs/welcome.pdf');
return { kind: 'bytes', id: 'welcome', bytes: new Uint8Array(await response.arrayBuffer()) };
};
</script>
<template>
<Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: welcome }]">
<DocumentGate>
<template #fallback><p class="loading">Loading…</p></template>
<BudgetControls />
<Stage class="stage">
<template #page><RenderLayer /></template>
</Stage>
</DocumentGate>
</Viewer>
</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 the annotations, so people can select and change them, and a form layer the form fields. You write nothing for it:
Loading live preview…
<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 '../annotations.css';
const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin(), annotationPlugin()];
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';
const painter = ref<Painter>('picture');
</script>
<template>
<Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: report }]">
<DocumentGate>
<template #fallback><p class="loading">Loading…</p></template>
<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>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:
<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>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:
<!-- PageLoading.vue: put <PageLoading /> in each page, after <RenderLayer /> -->
<script setup lang="ts">
import { usePictureState } from '@embedpdf/vue/render';
const picture = usePictureState();
</script>
<template>
<div
v-if="picture.status === 'loading' && picture.slow"
class="page-loading"
role="status"
aria-label="Loading page"
/>
<p v-else-if="picture.status === 'error'" class="page-error">This page couldn’t load.</p>
</template>usePictureState()
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 |
'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:
renderPlugin({ slowAfterMs: 200 });Render a page yourself#
For your own preview, an export or a page picker, render any page on demand:
const image = await render.renderPage(page.ref, { width: 800 });
const { url, revoke } = await image.objectUrl();
// …show it, and call revoke() when you're donerenderPage 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:
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: 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:
const thumbnail = await render.renderThumbnail(page.ref);| 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.
Loading live preview…
<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 welcome = async (): Promise<OpenInput> => {
const response = await fetch('/pdfs/welcome.pdf');
return { kind: 'bytes', id: 'welcome', bytes: new Uint8Array(await response.arrayBuffer()) };
};
</script>
<template>
<Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: welcome }]">
<DocumentGate>
<template #fallback><p class="loading">Loading…</p></template>
<PageImages />
</DocumentGate>
</Viewer>
</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:
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 changedYour own long-lived renders can keep up the same way: render.getRenderEpoch(page.ref) changes
whenever the page’s pixels do.
Loading live preview…
<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 welcome = async (): Promise<OpenInput> => {
const response = await fetch('/pdfs/welcome.pdf');
return { kind: 'bytes', id: 'welcome', bytes: new Uint8Array(await response.arrayBuffer()) };
};
</script>
<template>
<Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: welcome }]">
<DocumentGate>
<template #fallback><p class="loading">Loading…</p></template>
<Redraws />
<Stage class="stage">
<template #page><RenderLayer /></template>
</Stage>
</DocumentGate>
</Viewer>
</template>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:
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 <RenderLayer> 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.
pagehas the page’sref,pageIndex,view,transform(its size, zoom and turn on screen) andgetViewDemand()(the part of it on screen), the same in every framework.reportis how the page’s loading state knows: the page is'loading'until you callreport.shown(), once its first picture is on screen.report.failed(error)makes it'error', or'forbidden'for apermission-deniederror. 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 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 |
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 |
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 |
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. |
getPictureState(page, view) | Whether a picture of the page is on screen in a view: { status, slow }. Show that a page is loading |
canRender() | Whether page images are allowed. Permissions |
canRenderThumbnail() | Whether thumbnails are allowed. Permissions |
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 |
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 |
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 |
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 |
<RenderLayer> takes four props: annotations and formFields, unset by default
(see What the picture draws), tiles (default true), and thumbnail:
in a strip of thumbnails, <RenderLayer thumbnail /> draws each page’s
thumbnail instead, one small picture without tiles,
which needs only doc.render.thumbnail.
Events#
<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 |
onPictureStateChanged | A page’s picture came on screen, failed, went slow, or left its view | page, view, status, slow |
onSettingsChanged | 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 (<RenderLayer thumbnail />, renderThumbnail) | render.canRenderThumbnail() |
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 is
'forbidden'. A strip of thumbnails still draws with
doc.render.thumbnail.
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.
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 for page sizes and what a render costs.
Your feedback goes directly to the documentation team.