An Expo module for displaying and annotating PDFs, with support for iOS, Android, and Web.
๐ API Reference ยท ๐ Example App
Status: Early development โ actively tested in the Choir app.
| Platform | PDF rendering | Annotations |
|---|---|---|
| iOS | โ | โ |
| Android | โ | โ |
| Web | โ | โ |
npm install @tobyt/expo-pdf-markup
npx pod-install
Web rendering uses pdfjs-dist. Install it as a dependency:
npm install pdfjs-dist
Then add the Metro config plugin to your metro.config.js:
const { getDefaultConfig } = require('expo/metro-config');
const { withPdfMarkup } = require('@tobyt/expo-pdf-markup/metro');
const config = getDefaultConfig(__dirname);
module.exports = withPdfMarkup(config);
withPdfMarkup does two things automatically:
import.meta in pdfjs-dist so Metro can bundle it (pdfjs-dist v4 uses ESM syntax in a Node.js-only code path that is otherwise unreachable in a browser).public/pdf.worker.min.mjs in your project root so it is served alongside your app (same-origin, no CORS issues). The default worker URL is ./pdf.worker.min.mjs (relative to the page), so it works whether your app is hosted at the root or a sub-path.The public/pdf.worker.min.mjs file is regenerated on each Metro start if missing, so you can add it to .gitignore:
public/pdf.worker.min.mjs
If you are not using Metro (e.g. Webpack or a custom CDN), set the worker URL before mounting the view:
import { setPdfJsWorkerSrc } from '@tobyt/expo-pdf-markup';
setPdfJsWorkerSrc('https://your-cdn.example.com/pdf.worker.min.mjs');
expo-asset returns a full URL on web (https://โฆ or http://โฆ), not a local path. Pass it directly to source โ pdfjs accepts URLs:
// asset.localUri on web is already a URL; the .replace() is a no-op
setPdfPath(asset.localUri.replace('file://', ''));
Full API reference is available at tobyt42.github.io/expo-pdf-markup.
import { ExpoPdfMarkupView } from '@tobyt/expo-pdf-markup';
import type { AnnotationMode } from '@tobyt/expo-pdf-markup';
import { Asset } from 'expo-asset';
import { useEffect, useState } from 'react';
import { StyleSheet } from 'react-native';
export default function App() {
const [pdfPath, setPdfPath] = useState<string | null>(null);
const [annotations, setAnnotations] = useState(JSON.stringify({ version: 1, annotations: [] }));
useEffect(() => {
async function preparePdf() {
const asset = Asset.fromModule(require('./assets/document.pdf'));
await asset.downloadAsync();
if (asset.localUri) setPdfPath(asset.localUri.replace('file://', ''));
}
preparePdf();
}, []);
if (!pdfPath) return null;
return (
<ExpoPdfMarkupView
source={pdfPath}
style={StyleSheet.absoluteFill}
annotationMode="ink"
annotationColor="#FF0000"
annotationLineWidth={3}
annotations={annotations}
onLoadComplete={({ nativeEvent: { pageCount } }) => console.log(`Loaded ${pageCount} pages`)}
onPageChanged={({ nativeEvent: { page, pageCount, pageWidth, pageHeight } }) =>
console.log(`Page ${page + 1} of ${pageCount} (${pageWidth}ร${pageHeight}pt)`)
}
onAnnotationsChanged={({ nativeEvent }) => setAnnotations(nativeEvent.annotations)}
onError={({ nativeEvent: { message } }) => console.error(message)}
/>
);
}
If you provide onTextInputRequested, the built-in native prompt is skipped and your callback is
used instead. The callback receives a request object with mode, page, point, and
currentText for edit flows, so you can prefill your own UI when the user taps an existing text
annotation while the text tool is active.
This module uses the Expo Modules API. The example directory contains a test app.
# Build the module
npm run build
# Run the example app
cd example
npx expo start
Toby Terhoeven |
MIT