diff --git a/__mocks__/_pdf5.pdf b/__mocks__/_pdf5.pdf new file mode 100644 index 000000000..50b0a682d Binary files /dev/null and b/__mocks__/_pdf5.pdf differ diff --git a/packages/react-pdf/README.md b/packages/react-pdf/README.md index fc1c617df..91f0caaee 100644 --- a/packages/react-pdf/README.md +++ b/packages/react-pdf/README.md @@ -539,28 +539,29 @@ Loads a document passed using `file` prop. #### Props -| Prop name | Description | Default value | Example values | -| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| className | Class name(s) that will be added to rendered element along with the default `react-pdf__Document`. | n/a | | -| error | What the component should display in case of an error. | `"Failed to load PDF file."` | | -| externalLinkRel | Link rel for links rendered in annotations. | `"noopener noreferrer nofollow"` | One of valid [values for `rel` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-rel). | -| externalLinkTarget | Link target for external links rendered in annotations. | unset, which means that default behavior will be used | One of valid [values for `target` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-target). | -| file | 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.
**Warning**: Since equality check (`===`) is used to determine if `file` object has changed, it must be memoized by setting it in component's state, `useMemo` or other similar technique. | n/a | | -| imageResourcesPath | The path used to prefix the src attributes of annotation SVGs. | n/a (pdf.js will fallback to an empty string) | `"/public/images/"` | -| inputRef | A prop that behaves like [ref](https://reactjs.org/docs/refs-and-the-dom.html), but it's passed to main `
` rendered by `` component. | n/a | | -| loading | What the component should display while loading. | `"Loading PDF…"` | | -| noData | What the component should display in case of no data. | `"No PDF file specified."` | | -| onItemClick | 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. | n/a | `({ dest, pageIndex, pageNumber }) => alert('Clicked an item from page ' + pageNumber + '!')` | -| onLoadError | Function called in case of an error while loading a document. | n/a | `(error) => alert('Error while loading document! ' + error.message)` | -| onLoadProgress | Function called, potentially multiple times, as the loading progresses. | n/a | `({ loaded, total }) => alert('Loading a document: ' + (loaded / total) * 100 + '%')` | -| onLoadSuccess | Function called when the document is successfully loaded. | n/a | `(pdf) => alert('Loaded a file with ' + pdf.numPages + ' pages!')` | -| onPassword | Function called when a password-protected PDF is loaded. | Function that prompts the user for password. | `(callback) => callback('s3cr3t_p4ssw0rd')` | -| onSourceError | Function called in case of an error while retrieving document source from `file` prop. | n/a | `(error) => alert('Error while retrieving document source! ' + error.message)` | -| onSourceSuccess | Function called when document source is successfully retrieved from `file` prop. | n/a | `() => alert('Document source retrieved!')` | -| options | An object in which additional parameters to be passed to PDF.js can be defined. Most notably: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).

**Note**: Make sure to define options object outside of your React component or use `useMemo` if you can't. | n/a | `{ cMapUrl: '/cmaps/' }` | -| renderMode | Rendering mode of the document. Can be `"canvas"`, `"custom"` or `"none"`. If set to `"custom"`, `customRenderer` must also be provided. | `"canvas"` | `"custom"` | -| rotate | 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. | n/a | `90` | -| scale | Document scale. | `1` | `0.5` | +| Prop name | Description | Default value | Example values | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| className | Class name(s) that will be added to rendered element along with the default `react-pdf__Document`. | n/a | | +| error | What the component should display in case of an error. | `"Failed to load PDF file."` | | +| externalLinkRel | Link rel for links rendered in annotations. | `"noopener noreferrer nofollow"` | One of valid [values for `rel` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-rel). | +| externalLinkTarget | Link target for external links rendered in annotations. | unset, which means that default behavior will be used | One of valid [values for `target` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-target). | +| file | 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.
**Warning**: Since equality check (`===`) is used to determine if `file` object has changed, it must be memoized by setting it in component's state, `useMemo` or other similar technique. | n/a | | +| imageResourcesPath | The path used to prefix the src attributes of annotation SVGs. | n/a (pdf.js will fallback to an empty string) | `"/public/images/"` | +| inputRef | A prop that behaves like [ref](https://reactjs.org/docs/refs-and-the-dom.html), but it's passed to main `
` rendered by `` component. | n/a |
  • Function:
    `(ref) => { this.myDocument = ref; }`
  • Ref created using `createRef`:
    `this.ref = createRef();`

    `inputRef={this.ref}`
  • Ref created using `useRef`:
    `const ref = useRef();`

    `inputRef={ref}`
| +| loading | What the component should display while loading. | `"Loading PDF…"` |
  • String:
    `"Please wait!"`
  • React element:
    `

    Please wait!

    `
  • Function:
    `this.renderLoader`
| +| noData | What the component should display in case of no data. | `"No PDF file specified."` |
  • String:
    `"Please select a file."`
  • React element:
    `

    Please select a file.

    `
  • Function:
    `this.renderNoData`
| +| onItemClick | 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. | n/a | `({ dest, pageIndex, pageNumber }) => alert('Clicked an item from page ' + pageNumber + '!')` | +| onLoadError | Function called in case of an error while loading a document. | n/a | `(error) => alert('Error while loading document! ' + error.message)` | +| onLoadProgress | Function called, potentially multiple times, as the loading progresses. | n/a | `({ loaded, total }) => alert('Loading a document: ' + (loaded / total) * 100 + '%')` | +| onLoadSuccess | Function called when the document is successfully loaded. | n/a | `(pdf) => alert('Loaded a file with ' + pdf.numPages + ' pages!')` | +| onPassword | Function called when a password-protected PDF is loaded. | Function that prompts the user for password. | `(callback) => callback('s3cr3t_p4ssw0rd')` | +| onSourceError | Function called in case of an error while retrieving document source from `file` prop. | n/a | `(error) => alert('Error while retrieving document source! ' + error.message)` | +| onSourceSuccess | Function called when document source is successfully retrieved from `file` prop. | n/a | `() => alert('Document source retrieved!')` | +| optionalContentConfig | An `OptionalContentConfig` object that can be used to control the visibility of optional content groups (OCGs) in the PDF document. This is useful for PDFs that contain layers, such as maps or technical drawings, where you may want to toggle the visibility of certain layers. | `null` | `pdfDocument.getOptionalContentConfig()` | +| options | 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);
  • `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).

**Note**: Make sure to define options object outside of your React component or use `useMemo` if you can't. | n/a | `{ cMapUrl: '/cmaps/' }` | +| renderMode | Rendering mode of the document. Can be `"canvas"`, `"custom"` or `"none"`. If set to `"custom"`, `customRenderer` must also be provided. | `"canvas"` | `"custom"` | +| rotate | 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. | n/a | `90` | +| scale | Document scale. | `1` | `0.5` | ### Page diff --git a/packages/react-pdf/src/Document.spec.tsx b/packages/react-pdf/src/Document.spec.tsx index 0f72fc487..7d1b3741a 100644 --- a/packages/react-pdf/src/Document.spec.tsx +++ b/packages/react-pdf/src/Document.spec.tsx @@ -10,11 +10,13 @@ import Page from './Page.js'; import { loadPDF, makeAsyncCallback, muteConsole, restoreConsole } from '../../../test-utils.js'; import type { PDFDocumentProxy } from 'pdfjs-dist'; +import type { DocumentContextType, ScrollPageIntoViewArgs } from './shared/types.js'; import type LinkService from './LinkService.js'; -import type { ScrollPageIntoViewArgs } from './shared/types.js'; +import type OptionalContentService from './OptionalContentService.js'; const pdfFile = await loadPDF('../../__mocks__/_pdf.pdf'); const pdfFile2 = await loadPDF('../../__mocks__/_pdf2.pdf'); +const pdfFile5 = await loadPDF('../../__mocks__/_pdf5.pdf'); const OK = Symbol('OK'); @@ -482,6 +484,56 @@ describe('Document', () => { expect(child).toBeInTheDocument(); }); + + it('passes optionalContentService prop to its children', async () => { + const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback(); + + const instance = createRef<{ + linkService: React.RefObject; + pages: React.RefObject; + optionalContentService: React.RefObject; + viewer: React.RefObject<{ scrollPageIntoView: (args: ScrollPageIntoViewArgs) => void }>; + }>(); + + let documentContext: DocumentContextType | undefined; + + render( + + + {(context) => { + documentContext = context; + return null; + }} + + , + ); + + if (!instance.current) { + throw new Error('Document ref is not set'); + } + + await onLoadSuccessPromise; + + const optionalContentService = instance.current.optionalContentService.current; + + if (!optionalContentService) { + throw new Error('optional content service is not initialized'); + } + + optionalContentService.setVisibility('1R', false); + + if (!documentContext) { + throw new Error('Document context is not set'); + } + + expect(documentContext.optionalContentService).toBeDefined(); + + if (!documentContext.optionalContentService) { + throw new Error('Optional content config is not set'); + } + + expect(documentContext.optionalContentService.isVisible('1R')).toBe(false); + }); }); describe('viewer', () => { @@ -492,6 +544,7 @@ describe('Document', () => { const instance = createRef<{ linkService: React.RefObject; pages: React.RefObject; + optionalContentService: React.RefObject; viewer: React.RefObject<{ scrollPageIntoView: (args: ScrollPageIntoViewArgs) => void }>; }>(); @@ -534,6 +587,7 @@ describe('Document', () => { linkService: React.RefObject; // biome-ignore lint/suspicious/noExplicitAny: Intentional use to simplify the test pages: React.RefObject; + optionalContentService: React.RefObject; viewer: React.RefObject<{ scrollPageIntoView: (args: ScrollPageIntoViewArgs) => void }>; }>(); diff --git a/packages/react-pdf/src/Document.tsx b/packages/react-pdf/src/Document.tsx index f040f8ab9..4c0eec88e 100644 --- a/packages/react-pdf/src/Document.tsx +++ b/packages/react-pdf/src/Document.tsx @@ -12,6 +12,7 @@ import warning from 'warning'; import DocumentContext from './DocumentContext.js'; import LinkService from './LinkService.js'; import Message from './Message.js'; +import OptionalContentService from './OptionalContentService.js'; import PasswordResponses from './PasswordResponses.js'; import useResolver from './shared/hooks/useResolver.js'; @@ -245,6 +246,7 @@ const Document: React.ForwardRefExoticComponent< DocumentProps & React.RefAttributes<{ linkService: React.RefObject; + optionalContentService: React.RefObject; pages: React.RefObject; viewer: React.RefObject<{ scrollPageIntoView: (args: ScrollPageIntoViewArgs) => void }>; }> @@ -282,6 +284,8 @@ const Document: React.ForwardRefExoticComponent< const linkService = useRef(new LinkService()); + const optionalContentService = useRef(new OptionalContentService()); + const pages = useRef([]); const prevFile = useRef(undefined); @@ -337,6 +341,7 @@ const Document: React.ForwardRefExoticComponent< ref, () => ({ linkService, + optionalContentService, pages, viewer, }), @@ -531,7 +536,9 @@ const Document: React.ForwardRefExoticComponent< const loadingTask = destroyable; const loadingPromise = loadingTask.promise - .then((nextPdf) => { + .then(async (nextPdf) => { + optionalContentService.current.setDocument(nextPdf); + await optionalContentService.current.loadOptionalContentConfig(); pdfDispatch({ type: 'RESOLVE', value: nextPdf }); }) .catch((error) => { @@ -585,6 +592,7 @@ const Document: React.ForwardRefExoticComponent< imageResourcesPath, linkService: linkService.current, onItemClick, + optionalContentService: optionalContentService.current, pdf, registerPage, renderMode, diff --git a/packages/react-pdf/src/OptionalContentService.ts b/packages/react-pdf/src/OptionalContentService.ts new file mode 100644 index 000000000..4f04d66e1 --- /dev/null +++ b/packages/react-pdf/src/OptionalContentService.ts @@ -0,0 +1,131 @@ +import type { PDFDocumentProxy } from 'pdfjs-dist'; +import type { OptionalContentConfig } from 'pdfjs-dist/types/src/display/optional_content_config.js'; + +/** + * A service responsible for managing the optional content configuration (OCC) of a PDF document + * and controlling the visibility of optional content groups (OCGs). + */ +export default class OptionalContentService { + private optionalContentConfig?: OptionalContentConfig; + private pdfDocument?: PDFDocumentProxy | null; + private visibilityChangeListener: Array<(id: string, visible?: boolean) => void>; + + constructor() { + this.pdfDocument = undefined; + this.visibilityChangeListener = []; + } + + /** + * Sets the PDF document for internal use. + * + * @param {PDFDocumentProxy} pdfDocument - The PDF document instance to be set. + * @return {void} + */ + public setDocument(pdfDocument: PDFDocumentProxy): void { + this.pdfDocument = pdfDocument; + } + + /** + * Loads the optional content configuration for the associated PDF document. + * If the PDF document is not set or the configuration cannot be loaded, an error will be thrown. + * + * @return {Promise} A promise that resolves to the loaded optional content configuration. + * @throws {Error} Throws an error if the PDF document is not set or the configuration cannot be loaded. + */ + public async loadOptionalContentConfig(): Promise { + if (!this.pdfDocument) { + throw new Error('The PDF document is not set. Call setDocument() first.'); + } + + this.optionalContentConfig = await this.pdfDocument.getOptionalContentConfig(); + + if (!this.optionalContentConfig) { + throw new Error('The optional content configuration could not be loaded.'); + } + + return this.optionalContentConfig; + } + + /** + * Retrieves the optional content configuration. + * Throws an error if the configuration is not loaded before calling this method. + * + * @return {OptionalContentConfig} The loaded optional content configuration. + */ + public getOptionalContentConfig(): OptionalContentConfig { + if (!this.optionalContentConfig) { + throw new Error( + 'The optional content configuration is not loaded. Call loadOptionalContentConfig() first.', + ); + } + + return this.optionalContentConfig; + } + + /** + * Sets the visibility of a specific element and optionally preserves the related behavior. + * + * @param {string} id - The identifier of the element whose visibility is being set. + * @param {boolean} [visible] - Optional. Determines whether the element is visible or not. Defaults to undefined. + * @param {boolean} [preserveRB] - Optional. Indicates if related behavior should be preserved. Defaults to undefined. + */ + public setVisibility(id: string, visible?: boolean, preserveRB?: boolean): void { + if (!this.optionalContentConfig) { + throw new Error( + 'The optional content configuration is not loaded. Call loadOptionalContentConfig() first.', + ); + } + + this.optionalContentConfig.setVisibility(id, visible, preserveRB); + + for (const listener of this.visibilityChangeListener) { + listener(id, visible); + } + } + + /** + * Determines whether a specific group identified by its ID is visible + * in the optional content configuration. + * + * @param {string} id - The identifier of the group to check visibility for. + * @return {boolean} Returns true if the group is visible; otherwise, false. + * @throws {Error} Throws an error if the optional content configuration + * is not loaded. + */ + public isVisible(id: string): boolean { + if (!this.optionalContentConfig) { + throw new Error( + 'The optional content configuration is not loaded. Call loadOptionalContentConfig() first.', + ); + } + + return this.optionalContentConfig.getGroup(id).visible; + } + + /** + * Registers a listener callback to be invoked whenever a visibility change event occurs. + * + * @param {function} callback - A function that is called when a visibility change occurs. The callback receives the following parameters: + * - id: The unique identifier of the group with the visibility change. + * - visible: Optional parameter indicating the visibility status as a boolean. + */ + public addVisibilityChangeListener(callback: (id: string, visible?: boolean) => void): void { + this.visibilityChangeListener.push(callback); + } + + /** + * Removes a visibility change listener from the internal listener collection. + * + * @param callback A function reference to the listener that should be removed. + */ + public removeVisibilityChangeListener(callback: (id: string, visible?: boolean) => void): void { + for (let i = 0, ii = this.visibilityChangeListener.length; i < ii; i++) { + const listener = this.visibilityChangeListener[i]; + + if (listener === callback) { + this.visibilityChangeListener.splice(i, 1); + break; + } + } + } +} diff --git a/packages/react-pdf/src/Page.spec.tsx b/packages/react-pdf/src/Page.spec.tsx index 730471e97..b49ed95f4 100644 --- a/packages/react-pdf/src/Page.spec.tsx +++ b/packages/react-pdf/src/Page.spec.tsx @@ -14,10 +14,12 @@ import { loadPDF, makeAsyncCallback, muteConsole, restoreConsole } from '../../. import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'; import type { DocumentContextType, PageCallback } from './shared/types.js'; +import OptionalContentService from './OptionalContentService.js'; const pdfFile = await loadPDF('../../__mocks__/_pdf.pdf'); const pdfFile2 = await loadPDF('../../__mocks__/_pdf2.pdf'); const pdfFile4 = await loadPDF('../../__mocks__/_pdf4.pdf'); +const pdfFile5 = await loadPDF('../../__mocks__/_pdf5.pdf'); function renderWithContext(children: React.ReactNode, context: Partial) { const { rerender, ...otherResult } = render( @@ -47,6 +49,7 @@ describe('Page', () => { let pdf: PDFDocumentProxy; let pdf2: PDFDocumentProxy; let pdf4: PDFDocumentProxy; + let pdf5: PDFDocumentProxy; // Object with basic loaded page information that shall match after successful loading const desiredLoadedPage: Partial = {}; @@ -78,6 +81,8 @@ describe('Page', () => { unregisterPageArguments = [page._pageIndex]; pdf4 = await pdfjs.getDocument({ data: pdfFile4.arrayBuffer }).promise; + + pdf5 = await pdfjs.getDocument({ data: pdfFile5.arrayBuffer }).promise; }); describe('loading', () => { @@ -754,6 +759,87 @@ describe('Page', () => { expect(child).toBeInTheDocument(); }); + + it('requests page to be rendered with default visibility given no optionalContentConfig', async () => { + const { func: onRenderSuccess, promise: onRenderSuccessPromise } = + makeAsyncCallback<[PageCallback]>(); + + const { container } = renderWithContext( + , + { + linkService, + pdf: pdf5, + }, + ); + + await onRenderSuccessPromise; + + const pageCanvas = container.querySelector('.react-pdf__Page__canvas') as HTMLCanvasElement; + const context = pageCanvas.getContext('2d'); + + if (!context) { + throw new Error('CanvasRenderingContext2D is not available'); + } + + const imageData = context.getImageData(100, 100, 1, 1); + + // Should render green pixel because the layer is visible + expect(imageData.data).toStrictEqual(new Uint8ClampedArray([191, 255, 191, 255])); + }); + + it('requests page to be changed when updating with optionalContentService', async () => { + let isFirstRender: boolean = true; + const { func: onRenderSuccess, promise: onRenderSuccessPromise } = + makeAsyncCallback<[PageCallback]>(); + const { func: onRerenderSuccess, promise: onRerenderSuccessPromise } = + makeAsyncCallback<[PageCallback]>(); + + const optionalContentService = new OptionalContentService(); + optionalContentService.setDocument(pdf5); + await optionalContentService.loadOptionalContentConfig(); + + const { container } = renderWithContext( + { + if (isFirstRender) { + isFirstRender = false; + onRenderSuccess(page); + } else { + onRerenderSuccess(page); + } + }} + pageIndex={0} + />, + { + linkService, + optionalContentService, + pdf: pdf5, + }, + ); + + await onRenderSuccessPromise; + + const pageCanvas = container.querySelector('.react-pdf__Page__canvas') as HTMLCanvasElement; + const context = pageCanvas.getContext('2d'); + + if (!context) { + throw new Error('CanvasRenderingContext2D is not available'); + } + + let imageData = context.getImageData(100, 100, 1, 1); + + // Should render green pixel because the layer is visible + expect(imageData.data).toStrictEqual(new Uint8ClampedArray([191, 255, 191, 255])); + + optionalContentService.setVisibility('1R', false); + + await onRerenderSuccessPromise; + + imageData = context.getImageData(100, 100, 1, 1); + + // Should render white pixel because the layer is hidden + expect(imageData.data).toStrictEqual(new Uint8ClampedArray([255, 255, 255, 255])); + }); }); it('requests page to be rendered without forms by default', async () => { diff --git a/packages/react-pdf/src/Page.tsx b/packages/react-pdf/src/Page.tsx index 19a1205d2..b63dee941 100644 --- a/packages/react-pdf/src/Page.tsx +++ b/packages/react-pdf/src/Page.tsx @@ -334,6 +334,7 @@ export default function Page(props: PageProps): React.ReactElement { onRenderSuccess: onRenderSuccessProps, onRenderTextLayerError: onRenderTextLayerErrorProps, onRenderTextLayerSuccess: onRenderTextLayerSuccessProps, + optionalContentService, pageIndex: pageIndexProps, pageNumber: pageNumberProps, pdf, @@ -509,6 +510,7 @@ export default function Page(props: PageProps): React.ReactElement { onRenderSuccess: onRenderSuccessProps, onRenderTextLayerError: onRenderTextLayerErrorProps, onRenderTextLayerSuccess: onRenderTextLayerSuccessProps, + optionalContentService, page, pageIndex, pageNumber, @@ -535,6 +537,7 @@ export default function Page(props: PageProps): React.ReactElement { onRenderSuccessProps, onRenderTextLayerErrorProps, onRenderTextLayerSuccessProps, + optionalContentService, page, pageIndex, pageNumber, diff --git a/packages/react-pdf/src/Page/Canvas.tsx b/packages/react-pdf/src/Page/Canvas.tsx index 680fb5169..af7fead7b 100644 --- a/packages/react-pdf/src/Page/Canvas.tsx +++ b/packages/react-pdf/src/Page/Canvas.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import mergeRefs from 'merge-refs'; import * as pdfjs from 'pdfjs-dist'; import invariant from 'tiny-invariant'; @@ -14,6 +14,7 @@ import { cancelRunningTask, getDevicePixelRatio, isCancelException, + isProvided, makePageCallback, } from '../shared/utils.js'; @@ -37,6 +38,7 @@ export default function Canvas(props: CanvasProps): React.ReactElement { devicePixelRatio = getDevicePixelRatio(), onRenderError: onRenderErrorProps, onRenderSuccess: onRenderSuccessProps, + optionalContentService, page, renderForms, renderTextLayer, @@ -49,6 +51,30 @@ export default function Canvas(props: CanvasProps): React.ReactElement { const canvasElement = useRef(null); + const [optionalContentConfigLastUpdate, setOptionalContentConfigLastUpdate] = useState( + new Date(), + ); + + const onLayerVisibilityChange = useCallback((): void => { + if (!optionalContentService) { + return; + } + + setOptionalContentConfigLastUpdate(new Date()); + }, [optionalContentService]); + + useEffect(() => { + if (!optionalContentService) { + return; + } + + optionalContentService.addVisibilityChangeListener(onLayerVisibilityChange); + + return () => { + optionalContentService.removeVisibilityChangeListener(onLayerVisibilityChange); + }; + }, [optionalContentService, onLayerVisibilityChange]); + /** * Called when a page is rendered successfully. */ @@ -115,6 +141,9 @@ export default function Canvas(props: CanvasProps): React.ReactElement { annotationMode: renderForms ? ANNOTATION_MODE.ENABLE_FORMS : ANNOTATION_MODE.ENABLE, canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D, viewport: renderViewport, + optionalContentConfigPromise: isProvided(optionalContentService) + ? Promise.resolve(optionalContentService.getOptionalContentConfig()) + : undefined, }; if (canvasBackground) { renderContext.background = canvasBackground; @@ -133,7 +162,15 @@ export default function Canvas(props: CanvasProps): React.ReactElement { return () => cancelRunningTask(runningTask); }, - [canvasBackground, page, renderForms, renderViewport, viewport], + [ + canvasBackground, + optionalContentConfigLastUpdate, + optionalContentService, + page, + renderForms, + renderViewport, + viewport, + ], ); const cleanup = useCallback(() => { diff --git a/packages/react-pdf/src/index.ts b/packages/react-pdf/src/index.ts index b39ca7bc0..2c33bd299 100644 --- a/packages/react-pdf/src/index.ts +++ b/packages/react-pdf/src/index.ts @@ -16,6 +16,7 @@ export type { DocumentProps } from './Document.js'; export type { OutlineProps } from './Outline.js'; export type { PageProps } from './Page.js'; export type { + OptionalContentConfig, PasswordResponses as PasswordResponsesType, StructTreeNode, TextContent, diff --git a/packages/react-pdf/src/shared/types.ts b/packages/react-pdf/src/shared/types.ts index 769d74de0..4ff354e87 100644 --- a/packages/react-pdf/src/shared/types.ts +++ b/packages/react-pdf/src/shared/types.ts @@ -14,9 +14,18 @@ import type { TextMarkedContent, TypedArray, } from 'pdfjs-dist/types/src/display/api.js'; +import type { OptionalContentConfig } from 'pdfjs-dist/types/src/display/optional_content_config.js'; import type LinkService from '../LinkService.js'; +import type OptionalContentService from '../OptionalContentService.js'; -export type { PasswordResponses, StructTreeNode, TextContent, TextItem, TextMarkedContent }; +export type { + OptionalContentConfig, + PasswordResponses, + StructTreeNode, + TextContent, + TextItem, + TextMarkedContent, +}; type NullableObject = { [P in keyof T]: T[P] | null }; @@ -134,6 +143,7 @@ export type DocumentContextType = { imageResourcesPath?: ImageResourcesPath; linkService: LinkService; onItemClick?: (args: OnItemClickArgs) => void; + optionalContentService?: OptionalContentService; pdf?: PDFDocumentProxy | false; registerPage: RegisterPage; renderMode?: RenderMode; @@ -158,6 +168,7 @@ export type PageContextType = { onRenderSuccess?: OnRenderSuccess; onRenderTextLayerError?: OnRenderTextLayerError; onRenderTextLayerSuccess?: OnRenderTextLayerSuccess; + optionalContentService?: OptionalContentService; page: PDFPageProxy | false | undefined; pageIndex: number; pageNumber: number; diff --git a/test/Test.tsx b/test/Test.tsx index 30a503461..7bbc4f845 100644 --- a/test/Test.tsx +++ b/test/Test.tsx @@ -16,6 +16,7 @@ import { isArrayBuffer, isBlob, isBrowser, loadFromFile, dataURItoBlob } from '. import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'; import type { ExternalLinkTarget, File, PassMethod, RenderMode } from './shared/types.js'; +import type { OptionalContentConfig } from 'react-pdf'; const { PDFDataRangeTransport } = pdfjs; @@ -77,6 +78,9 @@ export default function Test() { const [file, setFile] = useState(null); const [fileForProps, setFileForProps] = useState(); const [numPages, setNumPages] = useState(); + const [optionalContentConfigLayerVisible, setOptionalContentConfigLayerVisible] = + useState(true); + const [optionalContentConfig, setOptionalContentConfig] = useState(); const [pageHeight, setPageHeight] = useState(); const [pageNumber, setPageNumber] = useState(); const [pageScale, setPageScale] = useState(); @@ -99,6 +103,10 @@ export default function Test() { const { numPages: nextNumPages } = document; setNumPages(nextNumPages); setPageNumber(1); + + document.getOptionalContentConfig({ intent: 'display' }).then((nextOptionalContentConfig) => { + setOptionalContentConfig(nextOptionalContentConfig); + }); }, []); const onDocumentLoadError = useCallback((error: Error) => { @@ -127,6 +135,14 @@ export default function Test() { [], ); + if (optionalContentConfig) { + if (optionalContentConfig.isVisible('1R') !== optionalContentConfigLayerVisible) { + optionalContentConfig.setVisibility('1R', optionalContentConfigLayerVisible); + + // FIXME: This should somehow trigger a re-render of the document + } + } + useEffect(() => { (async () => { const nextFileForProps = await (async () => { @@ -207,6 +223,7 @@ export default function Test() { const documentProps = { externalLinkTarget, file: fileForProps, + optionalContentConfig, options, rotate, }; @@ -251,6 +268,7 @@ export default function Test() { canvasBackground={canvasBackground} devicePixelRatio={devicePixelRatio} displayAll={displayAll} + optionalContentConfigLayerVisible={optionalContentConfigLayerVisible} pageHeight={pageHeight} pageScale={pageScale} pageWidth={pageWidth} @@ -259,6 +277,7 @@ export default function Test() { setCanvasBackground={setCanvasBackground} setDevicePixelRatio={setDevicePixelRatio} setDisplayAll={setDisplayAll} + setOptionalContentConfigLayerVisible={setOptionalContentConfigLayerVisible} setPageHeight={setPageHeight} setPageScale={setPageScale} setPageWidth={setPageWidth} diff --git a/test/ViewOptions.tsx b/test/ViewOptions.tsx index f74c03653..02d0bcaa3 100644 --- a/test/ViewOptions.tsx +++ b/test/ViewOptions.tsx @@ -6,6 +6,7 @@ type ViewOptionsProps = { canvasBackground?: string; devicePixelRatio?: number; displayAll: boolean; + optionalContentConfigLayerVisible?: boolean; pageHeight?: number; pageScale?: number; pageWidth?: number; @@ -14,6 +15,7 @@ type ViewOptionsProps = { setCanvasBackground: (value: string | undefined) => void; setDevicePixelRatio: (value: number | undefined) => void; setDisplayAll: (value: boolean) => void; + setOptionalContentConfigLayerVisible: (value: boolean) => void; setPageHeight: (value: number | undefined) => void; setPageScale: (value: number | undefined) => void; setPageWidth: (value: number | undefined) => void; @@ -25,6 +27,7 @@ export default function ViewOptions({ canvasBackground, devicePixelRatio, displayAll, + optionalContentConfigLayerVisible, pageHeight, pageScale, pageWidth, @@ -33,6 +36,7 @@ export default function ViewOptions({ setCanvasBackground, setDevicePixelRatio, setDisplayAll, + setOptionalContentConfigLayerVisible, setPageHeight, setPageScale, setPageWidth, @@ -53,6 +57,7 @@ export default function ViewOptions({ const renderCustomId = useId(); const renderNoneId = useId(); const rotationId = useId(); + const optionalContentConfigLayerVisibleId = useId(); const displayAllId = useId(); function onCanvasBackgroundChange(event: React.ChangeEvent) { @@ -68,6 +73,10 @@ export default function ViewOptions({ setDevicePixelRatio(devicePixelRatio); } + function onOptionalContentConfigLayerVisibleChange(event: React.ChangeEvent) { + setOptionalContentConfigLayerVisible(event.target.checked); + } + function onDisplayAllChange(event: React.ChangeEvent) { setDisplayAll(event.target.checked); } @@ -298,8 +307,25 @@ export default function ViewOptions({
- - +
+ + +
+ +
+ + +
); }