@reekon-tools/react-native-pdf-canvas
v0.2.0
Published
PDF pages as Skia images, drawn at exact doc-space rects inside somebody else's transform. One PDFium engine on iOS, Android and web. Owns no canvas, no gestures, no viewport state.
Readme
@reekon-tools/react-native-pdf-canvas
Renders PDF pages as Skia images positioned at exact PDF-space rectangles within a host-supplied transform. A single PDFium engine serves iOS, Android and web, so a given tile is byte-identical across all three platforms.
Overview
The package does not provide a PDF viewer. It owns no <Canvas>, no gesture handlers, no
viewport state and no document model; each of those remains the responsibility of the host
application. What it provides is rasterized page tiles, each paired with the document-space
rectangle it occupies, for the host to draw inside its own transform alongside its own content.
<Canvas>
<Group transform={worldTransform}>
<PdfContentView content={content} />
{/* Host content: annotations, measurements, overlays — same doc space, same transform */}
</Group>
</Canvas>Conventional PDF components encapsulate the scroll view, the zoom transform and the rendered output. Content drawn above such a component must be registered against its internal viewport, and that registration is approximate. This package inverts the arrangement: it emits rectangles in PDF points and never reads the host's transform. Four properties follow.
- Exact registration. A single transform is applied once, to the page and to the host's content alike. No error term is introduced, because the package performs no conversion to screen space.
- No contention for the frame budget. The package contributes zero Reanimated shared values to the Skia subtree, so a host driving its world transform from the UI thread retains a per-frame budget of one.
- Deferred rasterization. Rendering occurs when the host reports that its viewport has settled, rather than on every frame. A stale tile is soft but never mispositioned, which is the property that makes deferral safe.
- Bounded memory at any zoom level. Detail tiles track the viewport rather than the page, so a 36-inch sheet at 8× magnification costs no more than the same sheet at 1×.
Intended use
React Native and web applications that render their own content above a PDF and require it to register exactly: markup and annotation tools, field measurement, takeoff and estimating, CAD and drawing review, and form overlays.
Out of scope
The package supplies no viewer chrome — no scroll view, page controls or navigation — and deliberately requires the host to provide the canvas, the gesture handling and the page layout. It does not extract text, perform search, or modify documents; its function is rasterization.
Project status
Version 0.2.0 is a pre-release. The core, the cadence controller, the React layer and a deterministic fake backend are implemented and tested. All three platform backends render with PDFium, so annotations, form fields, passwords and page rotation behave identically on each. The native core is covered by a host-machine C++ suite, and the web backend by 45 tests driving real PDFium WebAssembly under Node.
Two areas remain unverified: the web backend has not yet been exercised in a browser, and the iOS binding requires a device pass.
Requirements
| Peer dependency | Range | Required |
| ---------------------------- | ---------- | ------------------------------------------ |
| react | >=19.0.0 | Always |
| @shopify/react-native-skia | >=2.8.0 | Always |
| react-native | >=0.78.0 | Native platforms only |
| react-native-reanimated | >=3.19.1 | Only if the host transform is driven by it |
| @embedpdf/pdfium | >=2.15.0 | Web only |
PDFium is not an npm dependency. It is retrieved at build time from
pdfium-binaries, pinned by SHA-256 digest in
scripts/pdfium-manifest.json, and adds approximately 6.5 MB per platform. iOS retrieves it
during CocoaPods podspec evaluation; Android retrieves it from a Gradle task. Air-gapped builds
may direct PDFCANVAS_PDFIUM_CACHE or PDFCANVAS_PDFIUM_MIRROR at a local copy, which is still
verified against the manifest.
Installation
npm install @reekon-tools/react-native-pdf-canvasThe platform backend is not registered automatically. It resides on a dedicated subpath so that importing the package never introduces a native module into a web or test bundle.
iOS and Android
Autolinking handles the native module. Register the backend once, during application startup:
import {setDefaultRasterizer} from '@reekon-tools/react-native-pdf-canvas';
import {getDefaultRasterizer} from '@reekon-tools/react-native-pdf-canvas/rasterizer';
setDefaultRasterizer(getDefaultRasterizer());Then run pod install in the ios directory.
Web
Web configuration requires three steps, performed once before the first render.
1. Load CanvasKit. This step may be omitted by hosts that already load CanvasKit themselves,
whether through WithSkiaWeb, RN Skia's LoadSkiaWeb, or a <script> tag that assigns
globalThis.CanvasKit; the package detects any of these. loadPdfCanvasSkiaWeb is the
awaitable equivalent:
import {loadPdfCanvasSkiaWeb} from '@reekon-tools/react-native-pdf-canvas/web-init';
// The host supplies the locator. The package never hardcodes a CanvasKit URL, because
// the version that must match is the one installed in the host's dependency tree.
await loadPdfCanvasSkiaWeb({
locateFile: file =>
`https://cdn.jsdelivr.net/npm/canvaskit-wasm@${canvasKitVersion}/bin/full/${file}`,
});2. Configure the PDFium worker. The package ships the worker but constructs neither a
Worker nor a URL of its own, as worker bundling and the location of pdfium.wasm are
properties of the host's build pipeline. Under Vite:
import PdfCanvasWorker from '@reekon-tools/react-native-pdf-canvas/worker?worker';
import wasmUrl from '@embedpdf/pdfium/pdfium.wasm?url';
import {configureWebPdfium} from '@reekon-tools/react-native-pdf-canvas/rasterizer';
import {setDefaultRasterizer} from '@reekon-tools/react-native-pdf-canvas';
setDefaultRasterizer(
configureWebPdfium({
createWorker: () => new PdfCanvasWorker(),
wasm: {url: wasmUrl},
}),
);Any other means of producing a module worker is equally acceptable, including
new Worker(url, {type: 'module'}). The wasm option also accepts {binary} for hosts with
their own asset pipeline, at the cost of a 4.6 MB structured clone per document.
3. Do not wrap PdfContentView in WithSkiaWeb. The component mounts within the host's
<Group> inside the host's <Canvas>, where a lazy component boundary would break the Skia
subtree. A WithSkiaWeb boundary belongs around the canvas as a whole.
If configuration is omitted, every openPdfDocument and usePdfDocument call fails with a
descriptive unsupported: No PageRasterizer available error rather than degrading silently.
createFakeRasterizer(), exported from the ./testing subpath, keeps the remainder of the API
usable under test.
Usage
import {Canvas, Group} from '@shopify/react-native-skia';
import {
usePdfDocument,
usePdfLayer,
PdfContentView,
} from '@reekon-tools/react-native-pdf-canvas';
function Sheet({uri, worldTransform, viewport}) {
// Both hooks must be mounted above the <Canvas>. See "Hook placement".
const {document} = usePdfDocument({uri});
const {content, controller} = usePdfLayer({document});
// Gesture handling and the transform remain the host's. Report settle events.
const pan = Gesture.Pan()
.onBegin(() => runOnJS(controller.suppress)())
.onFinalize(() => runOnJS(controller.settle)(viewport()));
return (
<GestureDetector gesture={pan}>
<Canvas style={{flex: 1}}>
<Group transform={worldTransform}>
<PdfContentView content={content} />
{/* Remaining host content, in the same doc space */}
</Group>
</Canvas>
</GestureDetector>
);
}viewport() returns a plain {visibleDocRect, scale} snapshot. The package never reads the
host's shared values; the host pushes the snapshot.
Viewport cadence
The host is required to report when its viewport settles. The package cannot determine this
independently: a withDecay fling animates for approximately 38 seconds at Reanimated's default
deceleration, and no reliable means exists for a library to observe a consumer's animation from
outside it.
| Method | Called on |
| ----------------------- | -------------------------------------------------- |
| controller.suppress() | Gesture begin |
| controller.settle(v) | Gesture end, or an animation's completion callback |
| controller.hint(v) | Continuous input: wheel, trackpad, momentum |
hint is debounced behind a trailing quiet timer. settle takes effect immediately.
Hook placement
Both hooks must be mounted above the <Canvas>. React context does not cross the canvas
boundary, as RN Skia mounts a separate reconciler for the Skia subtree, and a magnifier loupe
constitutes a second canvas with its own copy of every hook. A cache instantiated inside the
canvas is therefore created twice, concurrently, doubling tens of megabytes. Hoisting the hook
and passing the same content object to both canvases is what makes a loupe free.
API reference
Entry points
| Subpath | Exports |
| -------------- | ------------------------------------------------------------------------------------------------------------------- |
| . (root) | usePdfDocument, usePdfLayer, PdfContentView, openPdfDocument, createPdfController, layouts, policy, types |
| ./rasterizer | getDefaultRasterizer, configureWebPdfium |
| ./web-init | loadPdfCanvasSkiaWeb() — web only |
| ./worker | The PDFium module worker; a bundler entry point, web only |
| ./skia | ingestRaster, imageFromPixels, rasterByteLength, skiaApi() |
| ./testing | createFakeRasterizer() and the deterministic scenes |
The root barrel contains no module-scope Skia, which permits it to be evaluated in Node during a
server-side rendering pass. The Skia ingest seam is reached through the ./skia subpath.
Page layouts
singlePage(), continuousVertical(gap) and spread(gap) are provided. A host may supply any
function of the form (pages: DocSize[]) => DocRect[].
Render policy
Cadence is configured through a partial RasterPolicy. Every constant has an exported default:
usePdfLayer({document, policy: {sharpnessBand: 1.2, quietZoomMs: 400}});minEpochIntervalMs (default 300) should be measured before production use and set to the
observed p95 raster time. A value set too low permits a user adjusting the zoom to supersede
each render before it completes.
Platform support
| Platform | Engine | Backend identifier |
| -------- | --------------------------------------------- | ------------------ |
| iOS | PDFium via PDFium.xcframework | ios-pdfium |
| Android | PDFium via libpdfium.so | android-pdfium |
| Web | PDFium WebAssembly in a module worker | web-pdfium |
| Any | createFakeRasterizer(), for tests and demos | Configurable |
All three platform backends link the same pinned PDFium release. Annotations, form fields,
passwords and the /Rotate page attribute are therefore handled identically on each.
License
Licensed under the Apache License, Version 2.0. See LICENSE for the full terms.
The package may be used in commercial and closed-source software. Redistribution requires retaining the copyright notice and the attributions in NOTICE, and stating any modifications made to the files.
Third-party components
Because this package builds against PDFium, an application that ships it also ships PDFium and
its bundled components, licensed variously under BSD-3-Clause, MIT, Apache-2.0, the FreeType
License, the zlib License and the IJG License. NOTICE enumerates the attributions to
reproduce in an application's third-party notices. Complete license texts are included in the
licenses/ directory of every PDFium archive retrieved during the build.
Internals
The rationale behind each constant, each measured figure and each upstream defect the package
works around is documented in CLAUDE.md in the repository, alongside extended design
documentation under docs/. Neither is included in the published tarball. The relevant section
should be consulted before altering behavior it describes.
yarn install
yarn ci # Format check, typecheck, tests, build
yarn pdfium:fetch host # Retrieve and verify PDFium for the local machine
yarn native:test # Build and run the C++ core suite locally