Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added __mocks__/_pdf5.pdf
Binary file not shown.
45 changes: 23 additions & 22 deletions packages/react-pdf/README.md

Large diffs are not rendered by default.

56 changes: 55 additions & 1 deletion packages/react-pdf/src/Document.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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<LinkService>;
pages: React.RefObject<HTMLDivElement[]>;
optionalContentService: React.RefObject<OptionalContentService>;
viewer: React.RefObject<{ scrollPageIntoView: (args: ScrollPageIntoViewArgs) => void }>;
}>();

let documentContext: DocumentContextType | undefined;

render(
<Document file={pdfFile5.file} onLoadSuccess={onLoadSuccess} ref={instance}>
<DocumentContext.Consumer>
{(context) => {
documentContext = context;
return null;
}}
</DocumentContext.Consumer>
</Document>,
);

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', () => {
Expand All @@ -492,6 +544,7 @@ describe('Document', () => {
const instance = createRef<{
linkService: React.RefObject<LinkService>;
pages: React.RefObject<HTMLDivElement[]>;
optionalContentService: React.RefObject<OptionalContentService>;
viewer: React.RefObject<{ scrollPageIntoView: (args: ScrollPageIntoViewArgs) => void }>;
}>();

Expand Down Expand Up @@ -534,6 +587,7 @@ describe('Document', () => {
linkService: React.RefObject<LinkService>;
// biome-ignore lint/suspicious/noExplicitAny: Intentional use to simplify the test
pages: React.RefObject<any[]>;
optionalContentService: React.RefObject<OptionalContentService>;
viewer: React.RefObject<{ scrollPageIntoView: (args: ScrollPageIntoViewArgs) => void }>;
}>();

Expand Down
10 changes: 9 additions & 1 deletion packages/react-pdf/src/Document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -245,6 +246,7 @@ const Document: React.ForwardRefExoticComponent<
DocumentProps &
React.RefAttributes<{
linkService: React.RefObject<LinkService>;
optionalContentService: React.RefObject<OptionalContentService>;
pages: React.RefObject<HTMLDivElement[]>;
viewer: React.RefObject<{ scrollPageIntoView: (args: ScrollPageIntoViewArgs) => void }>;
}>
Expand Down Expand Up @@ -282,6 +284,8 @@ const Document: React.ForwardRefExoticComponent<

const linkService = useRef(new LinkService());

const optionalContentService = useRef(new OptionalContentService());

const pages = useRef<HTMLDivElement[]>([]);

const prevFile = useRef<File | undefined>(undefined);
Expand Down Expand Up @@ -337,6 +341,7 @@ const Document: React.ForwardRefExoticComponent<
ref,
() => ({
linkService,
optionalContentService,
pages,
viewer,
}),
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -585,6 +592,7 @@ const Document: React.ForwardRefExoticComponent<
imageResourcesPath,
linkService: linkService.current,
onItemClick,
optionalContentService: optionalContentService.current,
pdf,
registerPage,
renderMode,
Expand Down
131 changes: 131 additions & 0 deletions packages/react-pdf/src/OptionalContentService.ts
Original file line number Diff line number Diff line change
@@ -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<OptionalContentConfig>} 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<OptionalContentConfig> {
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;
}
}
}
}
86 changes: 86 additions & 0 deletions packages/react-pdf/src/Page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DocumentContextType>) {
const { rerender, ...otherResult } = render(
Expand Down Expand Up @@ -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<PDFPageProxy> = {};
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(
<Page onRenderSuccess={onRenderSuccess} pageIndex={0} />,
{
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(
<Page
onRenderSuccess={(page: PageCallback) => {
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 () => {
Expand Down
Loading