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…
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/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()) };
};
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<Stage className="stage">{() => <RenderLayer />}</Stage>
</DocumentGate>
</Viewer>
);
}- 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…
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage, useStageState } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';
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()) };
};
function ZoomBar() {
const stage = useStage();
const zoomLevel = useStageState((state) => state.zoomLevel);
return (
<div className="toolbar">
<button
type="button"
className="button"
aria-label="Zoom out"
onClick={() => stage.zoomOut()}
>
−
</button>
<output className="readout">{Math.round(zoomLevel * 100)}%</output>
<button type="button" className="button" aria-label="Zoom in" onClick={() => stage.zoomIn()}>
+
</button>
<button type="button" className="button push" onClick={() => stage.fitWidth()}>
Fit width
</button>
</div>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<ZoomBar />
<Stage className="stage">{() => <RenderLayer />}</Stage>
</DocumentGate>
</Viewer>
);
}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…
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage, useStageState } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin, useRender, useRenderSettings } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';
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()) };
};
const BUDGETS = [320, 640, 1280];
function BudgetControls() {
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);
return (
<div className="toolbar">
<span className="label">Budget</span>
<div className="segmented" role="group" aria-label="Whole-page budget">
{BUDGETS.map((width) => (
<button
key={width}
type="button"
aria-pressed={width === maxWidth}
onClick={() => render.updateSettings({ fullPage: { maxWidth: width } })}
>
{width} px
</button>
))}
</div>
<span className="label">Tiles</span>
<div className="segmented" role="group" aria-label="Tiles">
<button
type="button"
aria-pressed={tiles}
onClick={() => render.updateSettings({ tiles: { size: 512 } })}
>
On
</button>
<button
type="button"
aria-pressed={!tiles}
onClick={() => render.updateSettings({ tiles: false })}
>
Off
</button>
</div>
<div className="zoom">
<button
type="button"
className="button"
aria-label="Zoom out"
onClick={() => stage.zoomOut()}
>
−
</button>
<output className="readout">{Math.round(zoomLevel * 100)}%</output>
<button
type="button"
className="button"
aria-label="Zoom in"
onClick={() => stage.zoomIn()}
>
+
</button>
</div>
</div>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<BudgetControls />
<Stage className="stage">{() => <RenderLayer />}</Stage>
</DocumentGate>
</Viewer>
);
}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…
import { useEffect, useRef, useState } from 'react';
import { Viewer, DocumentGate, usePageList } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { interactionPlugin } from '@embedpdf/react/interaction';
import { AnnotationLayer, annotationPlugin, useAnnotation } from '@embedpdf/react/annotation';
import { localEngine } from '@embedpdf/engine';
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()) };
};
// A rectangle on the first page, made on load so there's an annotation to show.
function AddRectangle() {
const annotation = useAnnotation();
const firstPage = usePageList()[0]?.ref;
const added = useRef(false);
useEffect(() => {
if (!firstPage || added.current) return;
added.current = true;
void annotation.create(firstPage, {
subtype: 'square',
box: { x: 72, y: 96, width: 300, height: 140 },
color: '#e11d48',
interiorColor: '#ffe4e6',
opacity: 0.7,
strokeWidth: 3,
});
}, [annotation, firstPage]);
return null;
}
type Painter = 'picture' | 'layer';
export default function App() {
const [painter, setPainter] = useState<Painter>('picture');
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<AddRectangle />
<div className="toolbar">
<div className="segmented" role="group" aria-label="Who draws the annotations">
<button
type="button"
aria-pressed={painter === 'picture'}
onClick={() => setPainter('picture')}
>
In the page picture
</button>
<button
type="button"
aria-pressed={painter === 'layer'}
onClick={() => setPainter('layer')}
>
Drawn by the annotation layer
</button>
</div>
<p className="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 className="stage">
{() =>
painter === 'picture' ? (
<RenderLayer />
) : (
<>
<RenderLayer />
<AnnotationLayer />
</>
)
}
</Stage>
</DocumentGate>
</Viewer>
);
}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:
import { RenderLayer } from '@embedpdf/react/render';
import { Stage } from '@embedpdf/react/stage';
export function CleanPages() {
return <Stage>{() => <RenderLayer annotations={false} />}</Stage>;
}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 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.
Loading live preview…
import { useEffect, useState } from 'react';
import { Viewer, DocumentGate, usePageList } from '@embedpdf/react/runtime';
import type { OpenInput, PageRef } from '@embedpdf/react/runtime';
import { renderPlugin, useRender } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';
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()) };
};
interface Thumbnail {
page: PageRef;
url: string;
}
function PageImages() {
const render = useRender();
const pages = usePageList();
const [thumbnails, setThumbnails] = useState<Thumbnail[]>([]);
const [selected, setSelected] = useState(0);
const [preview, setPreview] = useState<string | null>(null);
// A small picture of every page, a few rendered at a time.
useEffect(() => {
const controller = new AbortController();
const revokes: (() => void)[] = [];
(async () => {
const { applied } = await 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) setThumbnails(ready);
})().catch(() => {
// cancelled: the document closed or the list changed
});
return () => {
controller.abort();
revokes.forEach((revoke) => revoke());
};
}, [render, pages]);
// The chosen page, exactly 640 pixels wide.
useEffect(() => {
const controller = new AbortController();
let revoke: (() => void) | undefined;
(async () => {
const image = await render.renderPage(selected, { width: 640, signal: controller.signal });
const object = await image.objectUrl();
if (controller.signal.aborted) {
object.revoke();
return;
}
revoke = object.revoke;
setPreview(object.url);
})().catch(() => {
// cancelled: another page was picked
});
return () => {
controller.abort();
revoke?.();
};
}, [render, selected]);
return (
<div className="images">
<div className="strip" role="listbox" aria-label="Pages">
{thumbnails.map(({ page, url }, index) => (
<button
key={page.objectNumber}
type="button"
role="option"
className="thumbnail"
aria-selected={index === selected}
onClick={() => setSelected(index)}
>
<img src={url} alt={`Page ${index + 1}`} />
<span>{index + 1}</span>
</button>
))}
</div>
<div className="preview">
{preview ? <img src={preview} alt={`Page ${selected + 1}, 640 pixels wide`} /> : null}
</div>
</div>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<PageImages />
</DocumentGate>
</Viewer>
);
}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…
import { useEffect, useRef, useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStageState } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin, useRender, useRenderEvent } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';
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()) };
};
interface Entry {
id: number;
text: string;
}
function Redraws() {
const render = useRender();
const currentPage = useStageState((state) => state.currentPage);
const [entries, setEntries] = useState<Entry[]>([]);
const count = useRef(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';
const entry = { id: count.current++, text: `${what} · from ${from}` };
setEntries((current) => [entry, ...current].slice(0, 4));
},
);
// Redraw the first page on load, so there's something in the list.
useEffect(() => render.invalidate({ pages: [0] }), [render]);
// Changes whenever this page's pixels do: key your own long-lived renders on it.
const epoch = currentPage ? render.getRenderEpoch(currentPage) : 0;
return (
<div className="panel">
<div className="toolbar">
<button
type="button"
className="button"
disabled={!currentPage}
onClick={() => currentPage && render.invalidate({ pages: [currentPage] })}
>
Redraw this page
</button>
<button
type="button"
className="button"
disabled={!currentPage}
onClick={() =>
currentPage && render.invalidate({ pages: [currentPage], scope: 'annotations' })
}
>
Only its annotations
</button>
<output className="badge">
render epoch <strong>{epoch}</strong>
</output>
</div>
<ol className="log" aria-live="polite">
{entries.map((entry) => (
<li key={entry.id} className="entry">
<code>onInvalidated</code> {entry.text}
</li>
))}
</ol>
</div>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<Redraws />
<Stage className="stage">{() => <RenderLayer />}</Stage>
</DocumentGate>
</Viewer>
);
}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, { 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 |
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. |
canRender() | Whether page images are allowed. 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 |
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), and tiles (default true).
Events#
import { RenderLayer, useRenderEvent } from '@embedpdf/react/render';
import { Stage } from '@embedpdf/react/stage';
import { refreshMyPreviews } from './previews';
export function Reader() {
useRenderEvent(
(render) => render.onInvalidated,
({ pages }) => refreshMyPreviews(pages),
);
return <Stage>{() => <RenderLayer />}</Stage>;
}| 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.
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.