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 welcome = async (): Promise<OpenInput> => {
const response = await fetch('/pdfs/welcome.pdf');
return { kind: 'bytes', id: 'welcome', bytes: new Uint8Array(await response.arrayBuffer()) };
};
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: welcome }]}>
<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 welcome = async (): Promise<OpenInput> => {
const response = await fetch('/pdfs/welcome.pdf');
return { kind: 'bytes', id: 'welcome', 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: welcome }]}>
<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 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];
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: welcome }]}>
<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 { useState } from 'react';
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 { interactionPlugin } from '@embedpdf/react/interaction';
import { AnnotationLayer, annotationPlugin } from '@embedpdf/react/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';
export default function App() {
const [painter, setPainter] = useState<Painter>('picture');
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: report }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<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>;
}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:
import { RenderLayer, usePictureState } from '@embedpdf/react/render';
import { Stage } from '@embedpdf/react/stage';
export function Pages() {
return (
<Stage>
{() => (
<>
<RenderLayer />
<PageLoading />
</>
)}
</Stage>
);
}
// A layer of the page: an indicator while its picture is on its way
function PageLoading() {
const { status, slow } = usePictureState();
if (status === 'loading' && slow) {
return <div className="page-loading" role="status" aria-label="Loading page" />;
}
if (status === 'error') return <p className="page-error">This page couldn’t load.</p>;
return null;
}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);To render part of a page, give its rect in page space: from
the page’s top-left, y down, in points. width and scale then size that part. rotation turns the
picture clockwise:
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.
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 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;
}
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: welcome }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<PageImages />
</DocumentGate>
</Viewer>
);
}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:
interaction.activateTool('snapshot');Loading live preview…
import { useEffect, useState } from 'react';
import { Viewer, DocumentGate, copyImage, saveFile } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, SnapshotLayer, renderPlugin, useSnapshot } from '@embedpdf/react/render';
import {
interactionPlugin,
useInteraction,
useInteractionState,
} from '@embedpdf/react/interaction';
import { localEngine } from '@embedpdf/engine';
import './snapshot.css';
const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin({ defaultTool: 'pan' })];
const welcome = async (): Promise<OpenInput> => {
const response = await fetch('/pdfs/welcome.pdf');
return { kind: 'bytes', id: 'welcome', bytes: new Uint8Array(await response.arrayBuffer()) };
};
function SnapshotToolbar() {
const interaction = useInteraction();
const { activeToolId } = useInteractionState();
const snapshot = useSnapshot();
const [said, say] = useState('');
// A new snapshot, or none: nothing copied yet.
useEffect(() => say(''), [snapshot]);
const copy = () => {
if (!snapshot) return;
// In the click itself: the picture goes to the clipboard as it comes.
copyImage(snapshot.image).then(
() => say('Copied: paste it anywhere'),
() => say('This browser didn’t allow the copy'),
);
};
const save = async () => {
if (!snapshot) return;
saveFile(await (await snapshot.image).blob(), 'snapshot.png');
};
return (
<div className="toolbar">
<div className="segmented" role="group" aria-label="Tool">
<button
type="button"
className="segment"
aria-pressed={activeToolId === 'pan'}
onClick={() => interaction.activateTool('pan')}
>
Hand
</button>
<button
type="button"
className="segment"
aria-pressed={activeToolId === 'snapshot'}
onClick={() => interaction.activateTool('snapshot')}
>
Snapshot
</button>
</div>
<div className="actions">
<button type="button" className="button" disabled={!snapshot} onClick={copy}>
Copy
</button>
<button type="button" className="button" disabled={!snapshot} onClick={save}>
Save
</button>
</div>
<output className="readout">
{said || (activeToolId === 'snapshot' && !snapshot ? 'Drag a box on a page' : '')}
</output>
</div>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: welcome }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<SnapshotToolbar />
<Stage className="stage">
{() => (
<>
<RenderLayer />
<SnapshotLayer />
</>
)}
</Stage>
</DocumentGate>
</Viewer>
);
}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.
Escapeandrender.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.scalechanges 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.
useSnapshot()
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:
import { copyImage, saveFile } from '@embedpdf/react/runtime';
import { useSnapshot } from '@embedpdf/react/render';
// Beside the pages: copy the snapshot, or save it as a file
export function SnapshotActions() {
const snapshot = useSnapshot();
if (!snapshot) return null;
const save = async () => saveFile(await (await snapshot.image).blob(), 'snapshot.png');
return (
<div className="snapshot-actions">
{/* In the click itself, not after an await: the picture follows when it's there */}
<button onClick={() => copyImage(snapshot.image)}>Copy</button>
<button onClick={save}>Save</button>
</div>
);
}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 is registered. It needs
doc.content.copy 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:
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 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;
}
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: welcome }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<Redraws />
<Stage className="stage">{() => <RenderLayer />}</Stage>
</DocumentGate>
</Viewer>
);
}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 |
canSnapshot() | Whether the user may take a snapshot. Permissions |
getSnapshot() | The snapshot held now, { page, rect, image }, or null. Take a snapshot |
clearSnapshot() | Lets the snapshot go. 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 |
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 |
snapshot.scale | 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 |
<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. <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.
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 |
onPictureStateChanged | A page’s picture came on screen, failed, went slow, or left its view | page, view, status, slow |
onSnapshotChanged | A snapshot was taken, or let go | snapshot: { page, rect, image }, or null |
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() |
doc.content.copy | Snapshots, 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 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.
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.