EmbedPDF

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:

render-without-annotations.tsx
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:

page-loading.tsx
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'.

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

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.

OptionWhat it isDefault
widthThe width of the image, in pixels—
scaleOr a scale: 1 is the page’s size in points1
rectThe part of the page to render, in page spacethe whole page
rotationTurn the picture clockwise: 0, 90, 180 or 2700: the page as stored
includeAnnotationsDraw the annotations into the imagewhen the user may read them
includeFormFieldsDraw the form fields into the imagefalse without annotations; else when the user may read the form
format'png', 'webp' or 'bmp'per engine
qualityFor WebP, from 0 to 1per engine
concurrencyFor renderPages, how many pages render at once4

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. Escape and render.clearSnapshot() let it go.
  • Its picture is the box at 2 pixels per point (144 DPI) as PNG, turned as the page is shown, with the annotations and form fields the user may read. snapshot.scale changes the size. A picture over 16 million pixels, or over what your server allows, comes out smaller, to fit.
  • A change on its way is in it. The picture waits until the engine has every change made before the snapshot, so a stamp you placed a moment ago is there.

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:

snapshot-actions.tsx
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 changed

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

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

Methods#

Everything useRender() gives you.

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

SettingWhat it doesDefault
fullPage.maxWidthThe widest a whole-page picture may be, in device pixels640
fullPage.quantizeRender exact sizes, or snap to a few reusable sizes'exact'
tilesfalse turns tiles off everywhereon
tiles.sizeA tile’s edge, in device pixels512
tiles.maxScaleThe sharpest tiles get; zoom beyond it stretches them128
tiles.prefetch.marginHow far around the view to load tiles, as a share of the view0.5
tiles.prefetch.velocityBiasLoad further ahead in the direction you’re scrollingtrue
tiles.settleMsHow long a zoom rests before tiles redraw150
slowAfterMsHow long a page waits for its picture before it counts as slow400
format'png', 'webp', or 'bmp' (fastest, local engine only)per engine
qualityWebP quality, from 0 to 1per engine
debugLog what the plugin renders, and why, to the consolefalse
snapshot.scaleA snapshot’s pixels per point2
snapshot.box.fillInside the box you drag with the snapshot toolthe viewer’s accent at 8%
snapshot.box.strokeIts dashed edgethe 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#

refresh-previews.tsx
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>;
}
EventFires whenCarries
onInvalidatedPages’ pixels changed and they will redrawpages, scope, origin
onPictureStateChangedA page’s picture came on screen, failed, went slow, or left its viewpage, view, status, slow
onSnapshotChangedA snapshot was taken, or let gosnapshot: { page, rect, image }, or null
onSettingsChangedThe settings changedsettings, changed

Permissions#

PermissionWhat it allowsCheck
doc.renderPage images and page sections, and thumbnailsrender.canRender()
doc.render.thumbnailThumbnails only (<RenderLayer thumbnail />, renderThumbnail)render.canRenderThumbnail()
doc.content.copySnapshots, with doc.renderrender.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.

Was this page helpful?

Your feedback goes directly to the documentation team.