EmbedPDF

Render

The render plugin draws your pages. You mount one layer per page; it decides what to actually rasterize — how big, when, and in which format — keeps the results cached, and refreshes exactly the pages whose pixels changed.

You place <RenderLayer /> inside a page. The plugin handles the pixels.

Your first render#

Register renderPlugin() next to stagePlugin() and render a <RenderLayer /> for each visible page:

This example isn’t available for Vue yet. You can read the React version in the meantime.

What you get from this alone:

  • Crisp text at rest. On the local engine every raster is rendered at exactly the pixels your screen needs — never scaled up or down — which is the only way small text stays sharp on low-resolution displays.
  • A memory ceiling. Full-page bitmaps never exceed the pixel budget (640 device pixels wide by default). Deep zoom cannot ask the engine for a gigantic page-sized allocation, no matter how far the user goes.
  • Caching for free. Repeated asks for the same raster collapse into one engine call, and results are kept in an LRU so scrolling away and back is instant.

Crisp at every zoom#

A page paints from two planes. The base plane is a whole-page bitmap, capped at the budget — it paints immediately and doubles as the backdrop while sharper pixels load. The tile plane covers just the visible region with small tiles whenever the view demands more pixels than the base may spend. Zoom in far enough and tiles take over sharpness; zoom back out and they stand down. You configure neither — the arithmetic decides.

This example isn’t available for Vue yet. You can read the React version in the meantime.

Try it: zoom in past 200% and watch the network panel. Only tiles for the visible region are fetched, the area under your cursor sharpens first, and when you stop zooming the tiles re-render at exactly your resting zoom.

While a zoom gesture is in motion, the current pixels scale in CSS — nothing refetches mid-pinch. When the zoom settles (about 150 ms of rest), both planes adopt the new demand and re-render. You read at rest; that is where the pixels are exact.

The two engines pick their render sizes differently, on purpose:

  • Local engine — renders exact sizes. Every render is private to this browser, so the plugin asks for precisely the pixels on screen.
  • Cloud engine — renders on the deployment’s advertised ladder of sizes, so rasters are shared, CDN-cached artifacts. The plugin snaps every ask to the ladder automatically.

Same code, same components — the deployment’s policy simply wins when there is one.

How big renders get#

One number controls the base plane: the budget. It is denominated in device pixels (CSS pixels × devicePixelRatio), because pixels are what cost memory — a “scale” cap would mean wildly different things on an A6 flyer and an A0 poster.

SettingThe question it answersDefault
fullPage.maxWidthHow wide may a full-page bitmap ever be, in device pixels?640
fullPage.quantizeRender exact sizes, or snap to a ladder of reusable sizes?'exact'
// A heavier base: crisp base-only rendering up to 1280 device px,
// tiles take over beyond that.
renderPlugin({ fullPage: { maxWidth: 1280 } });

The default budget is deliberately small: the first paint of a page is a tiny render that appears fast, and the tile plane carries sharpness for normal reading zooms. Raising it trades slower first paint for fewer tile requests.

On the cloud engine the deployment’s ladder replaces all of this — with one exception: setting maxWidth yourself also filters the advertised ladder, which is the knob for memory-constrained embeds (every ladder size is CDN-valid, so choosing lower ones is always safe).

Deep-zoom tiles#

Tiles engage when the view demands more pixels across a page than the base supplies — on a default setup, whenever a page is displayed wider than 640 device pixels. Each tile is a fixed-size square, so the cost of a tiled screen is constant at any zoom: roughly the visible area divided by 512². Only the visible region and a small prefetch ring around it are fetched, and tiles from your previous zoom stay on screen (scaled) until their sharper replacements have actually decoded — the page never blanks mid-zoom.

SettingThe question it answersDefault
tiles.sizeTile edge, in device pixels512
tiles.quantizeExact tile sharpness at rest, or a ladder of reusable levels?'exact'
tiles.maxScaleThe sharpness ceiling (device px per PDF point); beyond it tiles stretch128
tiles.bleedOverlap neighboring tiles by this many device px (kills seams)1
tiles.engageAtHow much stretch to tolerate before tiles fire1.0 exact / 1.25 ladder
tiles.prefetchRing around the view: { margin, velocityBias }0.5, true
tiles.settleMsHow long a zoom must rest before new tiles fetch150
tiles.fadeMsCross-fade tile arrivals (0 = instant)0

A lens that must never tile — even under deep zoom — opts out per layer with <RenderLayer tiles={false} />, or globally with renderPlugin({ tiles: false }). Past the budget such a lens rests on the scaled base (soft but cheap); the plugin logs a hint in that situation so it is never a mystery.

Annotations in the bitmap#

By default the rendered bitmap includes annotation appearances — highlights, stamps, filled form fields — so a plain <RenderLayer /> shows the document as it is. That is what you want for thumbnails and read-only views, and the raster refreshes automatically when an annotation changes (see below).

When an <AnnotationLayer> owns annotation rendering — live editing, hover states, form filling — exclude them from the bitmap so they are not drawn twice:

<RenderLayer annotations={false} />
<AnnotationLayer /* … */ />

One flag covers both planes — base and tiles always agree.

When pixels change#

A rendered bitmap is a snapshot: it goes stale the moment a confirmed edit changes those pixels. The plugin watches the document’s event stream and re-renders exactly the touched pages — an annotation edit refreshes that page’s bitmaps (and its thumbnail), whether the edit came from this user or a collaborator. Nothing refreshes mid-drag; invalidation lands once, at commit.

For pixel-changing operations the built-in map does not know about, declare the change yourself:

import { RenderToken } from '@embedpdf/react/render';
import { useCapability } from '@embedpdf/react/runtime';
 
const render = useCapability(RenderToken);
 
// After a confirmed operation that changed page pixels:
render.invalidate({ pons: [pageObjectNumber] }); // 'content' scope: repaint everything
render.invalidate({ pons: [pon], scope: 'annotations' }); // only baked appearances changed

Long-lived custom renders key on renderEpoch(pon) — when it bumps, refetch.

Faster local rendering#

Every raster is encoded before it crosses the engine boundary — PNG by default locally, the deployment’s format (usually WebP) on cloud. Encoding costs CPU per raster; if profiling shows it matters in your embed, 'bmp' skips compression entirely on the local engine:

renderPlugin({ format: 'bmp' }); // no encode step — fastest local rendering

The trade is memory: a BMP raster is uncompressed (a 512-px tile is ~1 MB versus tens of KB as WebP). On the cloud engine 'bmp' automatically conforms to the deployment’s advertised formats — the same code runs against both engines.

Rendering pages yourself#

For a custom lens — an export preview, a page picker you fully own — the capability renders any page on demand:

import { RenderToken } from '@embedpdf/react/render';
import { useCapability } from '@embedpdf/react/runtime';
 
const render = useCapability(RenderToken);
 
const image = await render.renderPage(pon, { scale: 0.5 });
const { url, revoke } = await image.objectUrl();
// …use the URL, call revoke() when done

renderPage goes through the same budget, cache, and policy conformance as the layers — that is the point. When you need scale-precise output instead (print, export at exactly 300 dpi), call the engine directly: doc.page(pon).render.image({ viewport: { kind: 'scale', scale } }) — the engine door is exact and unbudgeted.

All the settings#

Set any of these at registration with renderPlugin({ … }):

SettingWhat it doesDefault
fullPage.maxWidthThe pixel budget: full-page bitmaps never render wider (device px)640
fullPage.quantize'exact' sizes, or a width ladder for rung caching'exact'
tilesfalse disables the tile plane entirelyenabled
tiles.sizeTile edge in device px512
tiles.quantize'exact' tile sharpness, or a scale ladder for pyramid reuse'exact'
tiles.maxScaleThe sharpness ceiling (device px per point); beyond it tiles stretch128
tiles.bleedOverlap neighboring tiles by this many device px (kills seams)1
tiles.engageAtStretch tolerated before tiles engage1.0 / 1.25
tiles.prefetch.marginPrefetch ring size, as a fraction of the view per side0.5
tiles.prefetch.velocityBiasStretch the ring toward scroll directiontrue
tiles.settleMsRest time before a moving zoom adopts a new render size150
tiles.fadeMsTile arrival cross-fade, ms0
formatEncode format for all rasters: 'png', 'webp', 'bmp' (local-only)engine default
qualityEncoder quality for webp/pngengine default
debugLog tile scheduling and fetch outcomes to the consolefalse

On the cloud engine, the deployment’s advertised policy overrides quantize values and constrains format; your options apply unchanged on the local engine. Application code should import this public surface from @embedpdf/plugin-render (or your framework package’s /render entry). The plugin’s internals are not a public application contract.

Next steps#

Was this page helpful?

Your feedback goes directly to the documentation team.