'use client';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import clsx from 'clsx';
import { dequal } from 'dequal';
import makeEventProps from 'make-event-props';
import * as pdfjs from 'pdfjs-dist';
import invariant from 'tiny-invariant';
import warning from 'warning';
import DocumentContext from './DocumentContext.js';
import LinkService from './LinkService.js';
import Message from './Message.js';
import PasswordResponses from './PasswordResponses.js';
import useResource from './shared/hooks/useResource.js';
import areDocumentInputsEqual from './shared/areDocumentInputsEqual.js';
import ResourceCache from './shared/ResourceCache.js';
import {
dataURItoByteString,
displayCORSWarning,
isArrayBuffer,
isBlob,
isBrowser,
isDataURI,
loadFromFile,
} from './shared/utils.js';
import type { EventProps } from 'make-event-props';
import type { PDFDocumentLoadingTask, PDFDocumentProxy } from 'pdfjs-dist';
import type { DocumentInitParameters } from 'pdfjs-dist/types/src/display/api.js';
import type {
ClassName,
DocumentCallback,
DocumentContextType,
DocumentRenderProps,
ExternalLinkRel,
ExternalLinkTarget,
File,
ImageResourcesPath,
NodeOrRenderer,
OnDocumentLoadError,
OnDocumentLoadProgress,
OnDocumentLoadSuccess,
OnError,
OnItemClickArgs,
OnPasswordCallback,
Options,
PasswordResponse,
RenderMode,
ScrollPageIntoViewArgs,
Source,
} from './shared/types.js';
const { PDFDataRangeTransport } = pdfjs;
type OnItemClick = (args: OnItemClickArgs) => void;
type OnPassword = (callback: OnPasswordCallback, reason: PasswordResponse) => void;
type OnSourceError = OnError;
type OnSourceSuccess = () => void;
export type DocumentProps = {
children?: React.ReactNode | ((props: DocumentRenderProps) => React.ReactNode);
/**
* Class name(s) that will be added to rendered element along with the default `react-pdf__Document`.
*
* @example 'custom-class-name-1 custom-class-name-2'
* @example ['custom-class-name-1', 'custom-class-name-2']
*/
className?: ClassName;
/**
* What the component should display in case of an error.
*
* @default 'Failed to load PDF file.'
* @example 'An error occurred!'
* @example
An error occurred!
* @example {this.renderError}
*/
error?: NodeOrRenderer;
/**
* Link rel for links rendered in annotations.
*
* @default 'noopener noreferrer nofollow'
*/
externalLinkRel?: ExternalLinkRel;
/**
* Link target for external links rendered in annotations.
*/
externalLinkTarget?: ExternalLinkTarget;
/**
* What PDF should be displayed.
*
* Its value can be an URL, a file (imported using `import … from …` or from file input form element), or an object with parameters (`url` - URL; `data` - data, preferably Uint8Array; `range` - PDFDataRangeTransport.
*
* Suspense compares plain parameter objects by value. Keep binary inputs and range transports outside the suspended subtree. With `suspense={false}`, memoize object props to avoid unnecessary reloads.
*
* @example 'https://example.com/sample.pdf'
* @example importedPdf
* @example { url: 'https://example.com/sample.pdf' }
*/
file?: File;
/**
* The path used to prefix the src attributes of annotation SVGs.
*
* @default ''
* @example '/public/images/'
*/
imageResourcesPath?: ImageResourcesPath;
/**
* A prop that behaves like [ref](https://reactjs.org/docs/refs-and-the-dom.html), but it's passed to main `
` rendered by `` component.
*
* @example (ref) => { this.myDocument = ref; }
* @example this.ref
* @example ref
*/
inputRef?: React.Ref;
/**
* What the component should display while loading.
*
* @default 'Loading PDF…'
* @example 'Please wait!'
* @example
Please wait!
* @example {this.renderLoader}
*/
loading?: NodeOrRenderer;
/**
* What the component should display in case of no data.
*
* @default 'No PDF file specified.'
* @example 'Please select a file.'
* @example
Please select a file.
* @example {this.renderNoData}
*/
noData?: NodeOrRenderer;
/**
* Function called when an outline item or a thumbnail has been clicked. Usually, you would like to use this callback to move the user wherever they requested to.
*
* @example ({ dest, pageIndex, pageNumber }) => alert('Clicked an item from page ' + pageNumber + '!')
*/
onItemClick?: OnItemClick;
/**
* Function called in case of an error while loading a document.
*
* @example (error) => alert('Error while loading document! ' + error.message)
*/
onLoadError?: OnDocumentLoadError;
/**
* Function called, potentially multiple times, as the loading progresses.
*
* @example ({ loaded, total }) => alert('Loading a document: ' + (loaded / total) * 100 + '%')
*/
onLoadProgress?: OnDocumentLoadProgress;
/**
* Function called when the document is successfully loaded.
*
* @example (pdf) => alert('Loaded a file with ' + pdf.numPages + ' pages!')
*/
onLoadSuccess?: OnDocumentLoadSuccess;
/**
* Function called when a password-protected PDF is loaded.
*
* @example (callback) => callback('s3cr3t_p4ssw0rd')
*/
onPassword?: OnPassword;
/**
* Function called in case of an error while retrieving document source from `file` prop.
*
* @example (error) => alert('Error while retrieving document source! ' + error.message)
*/
onSourceError?: OnSourceError;
/**
* Function called when document source is successfully retrieved from `file` prop.
*
* @example () => alert('Document source retrieved!')
*/
onSourceSuccess?: OnSourceSuccess;
/**
* An object in which additional parameters to be passed to PDF.js can be defined. Most notably:
* - `cMapUrl`;
* - `httpHeaders` - custom request headers, e.g. for authorization);
* - `wasmUrl`;
* - `withCredentials` - a boolean to indicate whether or not to include cookies in the request (defaults to `false`)
*
* For a full list of possible parameters, check [PDF.js documentation on DocumentInitParameters](https://mozilla.github.io/pdf.js/api/draft/module-pdfjsLib.html#~DocumentInitParameters).
*
* Suspense compares plain options by value. Keep workers and other non-plain values outside the suspended subtree. With `suspense={false}`, memoize options to avoid unnecessary reloads.
*
* @example { cMapUrl: '/cmaps/', wasmUrl: '/wasm/' }
*/
options?: Options;
/**
* Rendering mode of the document. Can be `"canvas"`, `"custom"` or `"none"``. If set to `"custom"`, `customRenderer` must also be provided.
*
* @default 'canvas'
* @example 'custom'
*/
renderMode?: RenderMode;
/**
* Rotation of the document in degrees. If provided, will change rotation globally, even for the pages which were given `rotate` prop of their own. `90` = rotated to the right, `180` = upside down, `270` = rotated to the left.
*
* @example 90
*/
rotate?: number | null;
/**
* Document scale.
*
* @default 1
* @example 0.5
*/
scale?: number;
/**
* Whether loading suspends and errors propagate to the nearest Error Boundary.
* Set to `false` to use the component's loading and error behavior instead.
*
* @default true
*/
suspense?: boolean;
} & EventProps;
const defaultOnPassword: OnPassword = (callback, reason) => {
switch (reason) {
case PasswordResponses.NEED_PASSWORD: {
const password = prompt('Enter the password to open this PDF file.');
callback(password);
break;
}
case PasswordResponses.INCORRECT_PASSWORD: {
const password = prompt('Invalid password. Please try again.');
callback(password);
break;
}
default:
}
};
function isParameterObject(file: File): file is Source {
return (
typeof file === 'object' &&
file !== null &&
('data' in file || 'range' in file || 'url' in file)
);
}
async function findDocumentSource(file: File | undefined): Promise {
if (!file) {
return null;
}
// File is a string
if (typeof file === 'string') {
if (isDataURI(file)) {
const fileByteString = dataURItoByteString(file);
return { data: fileByteString };
}
displayCORSWarning();
return { url: file };
}
// File is PDFDataRangeTransport
if (file instanceof PDFDataRangeTransport) {
return { range: file };
}
// File is an ArrayBuffer
if (isArrayBuffer(file)) {
return { data: file };
}
/**
* The cases below are browser-only.
* If you're running on a non-browser environment, these cases will be of no use.
*/
if (isBrowser) {
// File is a Blob
if (isBlob(file)) {
const data = await loadFromFile(file);
return { data };
}
}
// At this point, file must be an object
invariant(
typeof file === 'object',
'Invalid parameter in file, need either Uint8Array, string or a parameter object',
);
invariant(isParameterObject(file), 'Invalid parameter object: need either .data, .range or .url');
// File .url is a string
if ('url' in file && typeof file.url === 'string') {
if (isDataURI(file.url)) {
const { url, ...otherParams } = file;
const fileByteString = dataURItoByteString(url);
return { data: fileByteString, ...otherParams };
}
displayCORSWarning();
}
return file;
}
const sourceCache = new ResourceCache<[File | undefined], Source | null>({
areKeysEqual: areDocumentInputsEqual,
});
const documentCache = new ResourceCache<[File | undefined, Options | undefined], PDFDocumentProxy>({
areKeysEqual: areDocumentInputsEqual,
});
function createDocumentTask(
source: Source,
options: Options | undefined,
onPassword: OnPassword,
onLoadProgress: OnDocumentLoadProgress | undefined,
): PDFDocumentLoadingTask {
const initParameters: DocumentInitParameters = options ? { ...source, ...options } : source;
const parameters = { ...initParameters };
// PDF.js transfers input buffers to its worker. Keep the cached source reusable
// after eviction, and when another document uses the same input.
if (parameters.data instanceof ArrayBuffer) {
parameters.data = parameters.data.slice(0);
} else if (ArrayBuffer.isView(parameters.data)) {
const data = parameters.data;
parameters.data = new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice();
}
const task = pdfjs.getDocument(parameters);
task.onPassword = onPassword;
if (onLoadProgress) {
task.onProgress = onLoadProgress;
}
return task;
}
/**
* Loads a document passed using `file` prop.
*/
const Document: React.ForwardRefExoticComponent<
DocumentProps &
React.RefAttributes<{
linkService: React.RefObject;
pages: React.RefObject;
viewer: React.RefObject<{ scrollPageIntoView: (args: ScrollPageIntoViewArgs) => void }>;
}>
> = forwardRef(function Document(
{
children,
className,
error = 'Failed to load PDF file.',
externalLinkRel,
externalLinkTarget,
file,
inputRef,
imageResourcesPath,
loading = 'Loading PDF…',
noData = 'No PDF file specified.',
onItemClick,
onLoadError: onLoadErrorProps,
onLoadProgress,
onLoadSuccess: onLoadSuccessProps,
onPassword = defaultOnPassword,
onSourceError: onSourceErrorProps,
onSourceSuccess: onSourceSuccessProps,
options,
renderMode,
rotate,
scale,
suspense = true,
...otherProps
},
ref,
) {
const loadSource = useCallback(() => ({ promise: findDocumentSource(file) }), [file]);
const { value: source, error: sourceError } = useResource(
!suspense || file ? { cache: sourceCache, key: [file], load: loadSource } : undefined,
suspense,
onSourceErrorProps,
);
// biome-ignore lint/correctness/useExhaustiveDependencies: Callback changes should not restart the document load
const loadPdf = useMemo(() => {
if (!source) {
return;
}
return () => {
const task = createDocumentTask(source, options, onPassword, onLoadProgress);
return { promise: task.promise, dispose: () => task.destroy() };
};
}, [options, source, suspense]);
const { value: pdf, error: pdfError } = useResource(
loadPdf ? { cache: documentCache, key: [file, options], load: loadPdf } : undefined,
suspense,
onLoadErrorProps,
);
const linkService = useRef(new LinkService());
const pages = useRef([]);
const prevFile = useRef(undefined);
const prevOptions = useRef(undefined);
if (!suspense && file && file !== prevFile.current && isParameterObject(file)) {
warning(
!dequal(file, prevFile.current),
`File prop passed to changed, but it's equal to previous one. This might result in unnecessary reloads. Consider memoizing the value passed to "file" prop.`,
);
prevFile.current = file;
}
// Detect non-memoized changes in options prop
if (!suspense && options && options !== prevOptions.current) {
warning(
!dequal(options, prevOptions.current),
`Options prop passed to changed, but it's equal to previous one. This might result in unnecessary reloads. Consider memoizing the value passed to "options" prop.`,
);
prevOptions.current = options;
}
const viewer = useRef({
// Handling jumping to internal links target
scrollPageIntoView: (args: ScrollPageIntoViewArgs) => {
const { dest, pageNumber, pageIndex = pageNumber - 1 } = args;
// First, check if custom handling of onItemClick was provided
if (onItemClick) {
onItemClick({ dest, pageIndex, pageNumber });
return;
}
// If not, try to look for target page within the .
const page = pages.current[pageIndex];
if (page) {
// Scroll to the page automatically
page.scrollIntoView();
return;
}
warning(
false,
`An internal link leading to page ${pageNumber} was clicked, but neither was provided with onItemClick nor it was able to find the page within itself. Either provide onItemClick to and handle navigating by yourself or ensure that all pages are rendered within .`,
);
},
});
useImperativeHandle(
ref,
() => ({
linkService,
pages,
viewer,
}),
[],
);
/**
* Called when a document source is resolved correctly
*/
function onSourceSuccess() {
if (onSourceSuccessProps) {
onSourceSuccessProps();
}
}
/**
* Called when a document source failed to be resolved correctly
*/
function onSourceError() {
if (!sourceError) {
// Impossible, but TypeScript doesn't know that
return;
}
warning(false, sourceError.toString());
if (onSourceErrorProps) {
onSourceErrorProps(sourceError);
}
}
// biome-ignore lint/correctness/useExhaustiveDependencies: Omitted callbacks so they are not called every time they change
useEffect(() => {
if (typeof source === 'undefined') {
return;
}
if (source === false) {
onSourceError();
return;
}
onSourceSuccess();
}, [source]);
/**
* Called when a document is read successfully
*/
function onLoadSuccess() {
if (!pdf) {
// Impossible, but TypeScript doesn't know that
return;
}
if (onLoadSuccessProps) {
onLoadSuccessProps(pdf);
}
linkService.current.setDocument(pdf);
}
/**
* Called when a document failed to read successfully
*/
function onLoadError() {
if (!pdfError) {
// Impossible, but TypeScript doesn't know that
return;
}
warning(false, pdfError.toString());
if (onLoadErrorProps) {
onLoadErrorProps(pdfError);
}
}
// biome-ignore lint/correctness/useExhaustiveDependencies: Omitted callbacks so they are not called every time they change
useEffect(() => {
if (typeof pdf === 'undefined') {
return;
}
if (pdf === false) {
onLoadError();
return;
}
onLoadSuccess();
}, [pdf]);
useEffect(
function setupLinkService() {
linkService.current.setViewer(viewer.current);
linkService.current.setExternalLinkRel(externalLinkRel);
linkService.current.setExternalLinkTarget(externalLinkTarget);
},
[externalLinkRel, externalLinkTarget],
);
const registerPage = useCallback((pageIndex: number, ref: HTMLDivElement) => {
pages.current[pageIndex] = ref;
}, []);
const unregisterPage = useCallback((pageIndex: number) => {
delete pages.current[pageIndex];
}, []);
const childContext = useMemo(
() => ({
imageResourcesPath,
linkService: linkService.current,
onItemClick,
pdf,
registerPage,
renderMode,
rotate,
scale,
suspense,
unregisterPage,
}),
[
imageResourcesPath,
onItemClick,
pdf,
registerPage,
renderMode,
rotate,
scale,
suspense,
unregisterPage,
],
);
const eventProps = useMemo(
() => makeEventProps(otherProps, () => pdf),
// biome-ignore lint/correctness/useExhaustiveDependencies: FIXME
[otherProps, pdf],
);
function renderChildren() {
function isFulfilledContext(context: DocumentContextType): context is DocumentRenderProps {
return Boolean(context?.pdf);
}
if (!isFulfilledContext(childContext)) {
// Impossible, but TypeScript doesn't know that
throw new Error('pdf is undefined');
}
const resolvedChildren = typeof children === 'function' ? children(childContext) : children;
return (
{resolvedChildren}
);
}
function renderContent() {
if (!file) {
return {typeof noData === 'function' ? noData() : noData};
}
if (pdf === undefined || pdf === null) {
return (
{typeof loading === 'function' ? loading() : loading}
);
}
if (pdf === false) {
return {typeof error === 'function' ? error() : error};
}
return renderChildren();
}
return (