Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement StructTree, improve accessibility #1498

Merged
merged 12 commits into from
Jun 6, 2023
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,8 @@ Displays a page. Should be placed inside `<Document />`. Alternatively, it can h
| onRenderTextLayerSuccess | Function called when the text layer is successfully rendered on the screen. | n/a | `() => alert('Rendered the page!')` |
| onGetAnnotationsSuccess | Function called when annotations are successfully loaded. | n/a | `(annotations) => alert('Now displaying ' + annotations.length + ' annotations!')` |
| onGetAnnotationsError | Function called in case of an error while loading annotations. | n/a | `(error) => alert('Error while loading annotations! ' + error.message)` |
| onGetStructTreeSuccess | Function called when structure tree is successfully loaded. | n/a | `(structTree) => alert(JSON.stringify(structTree))` |
| onGetStructTreeError | Function called in case of an error while loading structure tree. | n/a | `(error) => alert('Error while loading structure tree! ' + error.message)` |
| onGetTextSuccess | Function called when text layer items are successfully loaded. | n/a | `({ items, styles }) => alert('Now displaying ' + items.length + ' text layer items!')` |
| onGetTextError | Function called in case of an error while loading text layer items. | n/a | `(error) => alert('Error while loading text layer items! ' + error.message)` |
| pageIndex | Which page from PDF file should be displayed, by page index. | `0` | `1` |
Expand Down
1 change: 1 addition & 0 deletions __mocks__/_failing_page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default {
},
getAnnotations: () => new Promise((resolve, reject) => reject(new Error())),
getOperatorList: () => new Promise((resolve, reject) => reject(new Error())),
getStructTree: () => new Promise<void>((resolve, reject) => reject(new Error())),
getTextContent: () => new Promise((resolve, reject) => reject(new Error())),
getViewport: () => ({
width: 600,
Expand Down
9 changes: 9 additions & 0 deletions src/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import type {
NodeOrRenderer,
OnGetAnnotationsError,
OnGetAnnotationsSuccess,
OnGetStructTreeError,
OnGetStructTreeSuccess,
OnGetTextError,
OnGetTextSuccess,
OnPageLoadError,
Expand Down Expand Up @@ -68,6 +70,8 @@ export type PageProps = {
noData?: NodeOrRenderer;
onGetAnnotationsError?: OnGetAnnotationsError;
onGetAnnotationsSuccess?: OnGetAnnotationsSuccess;
onGetStructTreeError?: OnGetStructTreeError;
onGetStructTreeSuccess?: OnGetStructTreeSuccess;
onGetTextError?: OnGetTextError;
onGetTextSuccess?: OnGetTextSuccess;
onLoadError?: OnPageLoadError;
Expand Down Expand Up @@ -113,6 +117,8 @@ export default function Page(props: PageProps) {
noData = 'No page specified.',
onGetAnnotationsError: onGetAnnotationsErrorProps,
onGetAnnotationsSuccess: onGetAnnotationsSuccessProps,
onGetStructTreeError: onGetStructTreeErrorProps,
onGetStructTreeSuccess: onGetStructTreeSuccessProps,
onGetTextError: onGetTextErrorProps,
onGetTextSuccess: onGetTextSuccessProps,
onLoadError: onLoadErrorProps,
Expand Down Expand Up @@ -286,6 +292,8 @@ export default function Page(props: PageProps) {
devicePixelRatio,
onGetAnnotationsError: onGetAnnotationsErrorProps,
onGetAnnotationsSuccess: onGetAnnotationsSuccessProps,
onGetStructTreeError: onGetStructTreeErrorProps,
onGetStructTreeSuccess: onGetStructTreeSuccessProps,
onGetTextError: onGetTextErrorProps,
onGetTextSuccess: onGetTextSuccessProps,
onRenderAnnotationLayerError: onRenderAnnotationLayerErrorProps,
Expand All @@ -298,6 +306,7 @@ export default function Page(props: PageProps) {
pageIndex,
pageNumber,
renderForms,
renderTextLayer: renderTextLayerProps,
rotate,
scale,
}
Expand Down
35 changes: 35 additions & 0 deletions src/Page/PageCanvas.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,40 @@ describe('PageCanvas', () => {
expect(canvasRef).toHaveBeenCalled();
expect(canvasRef).toHaveBeenCalledWith(expect.any(HTMLElement));
});

it('does not request structure tree to be rendered when renderTextLayer = false', async () => {
const { func: onRenderSuccess, promise: onRenderSuccessPromise } = makeAsyncCallback();

const { container } = renderWithContext(<PageCanvas />, {
onRenderSuccess,
page: pageWithRendererMocked,
renderTextLayer: false,
});

await onRenderSuccessPromise;

const structTree = container.querySelector('.react-pdf__Page__structTree');

expect(structTree).not.toBeInTheDocument();
});

it('renders StructTree when given renderTextLayer = true', async () => {
const { func: onGetStructTreeSuccess, promise: onGetStructTreeSuccessPromise } =
makeAsyncCallback();

const { container } = renderWithContext(<PageCanvas />, {
onGetStructTreeSuccess,
page: pageWithRendererMocked,
renderTextLayer: true,
});

expect.assertions(1);

await onGetStructTreeSuccessPromise;

const canvas = container.querySelector('canvas') as HTMLCanvasElement;

expect(canvas.children.length).toBeGreaterThan(0);
});
});
});
7 changes: 6 additions & 1 deletion src/Page/PageCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import invariant from 'tiny-invariant';
import warning from 'tiny-warning';
import * as pdfjs from 'pdfjs-dist';

import StructTree from '../StructTree';

import usePageContext from '../shared/hooks/usePageContext';
import {
cancelRunningTask,
Expand Down Expand Up @@ -35,6 +37,7 @@ export default function PageCanvas(props: PageCanvasProps) {
onRenderSuccess: onRenderSuccessProps,
page,
renderForms,
renderTextLayer,
rotate,
scale,
} = mergedProps;
Expand Down Expand Up @@ -168,7 +171,9 @@ export default function PageCanvas(props: PageCanvasProps) {
display: 'block',
userSelect: 'none',
}}
/>
>
{renderTextLayer ? <StructTree /> : null}
</canvas>
);
}

Expand Down
29 changes: 15 additions & 14 deletions src/Page/TextLayer.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ function renderWithContext(children: React.ReactNode, context: Partial<PageConte
};
}

function getTextItems(container: HTMLElement) {
const wrapper = container.firstElementChild as HTMLDivElement;

return wrapper.querySelectorAll('.markedContent > *:not(.markedContent');
}

describe('TextLayer', () => {
// Loaded page
let page: PDFPageProxy;
Expand Down Expand Up @@ -136,10 +142,9 @@ describe('TextLayer', () => {

await onRenderTextLayerSuccessPromise;

const wrapper = container.firstElementChild as HTMLDivElement;
const textItems = wrapper.children;
const textItems = getTextItems(container);

expect(textItems).toHaveLength(desiredTextItems.length + 1);
expect(textItems).toHaveLength(desiredTextItems.length);
});

it('renders text content properly given customTextRenderer', async () => {
Expand All @@ -158,10 +163,9 @@ describe('TextLayer', () => {

await onRenderTextLayerSuccessPromise;

const wrapper = container.firstElementChild as HTMLDivElement;
const textItems = wrapper.children;
const textItems = getTextItems(container);

expect(textItems).toHaveLength(desiredTextItems.length + 1);
expect(textItems).toHaveLength(desiredTextItems.length);
});

it('maps textContent items to actual TextLayer children properly', async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I feel this test is pointless now as there is no longer a direct mapping between customTextRender and normal.

Expand All @@ -177,8 +181,7 @@ describe('TextLayer', () => {

await onRenderTextLayerSuccessPromise;

const wrapper = container.firstElementChild as HTMLDivElement;
const innerHTML = wrapper.innerHTML;
const textItems = getTextItems(container);

const { func: onRenderTextLayerSuccess2, promise: onRenderTextLayerSuccessPromise2 } =
makeAsyncCallback();
Expand All @@ -193,10 +196,9 @@ describe('TextLayer', () => {

await onRenderTextLayerSuccessPromise2;

const wrapper2 = container.firstElementChild as HTMLDivElement;
const innerHTML2 = wrapper2.innerHTML;
const textItems2 = getTextItems(container);

expect(innerHTML).toEqual(innerHTML2);
expect(textItems).toEqual(textItems2);
});

it('calls customTextRenderer with necessary arguments', async () => {
Expand All @@ -215,10 +217,9 @@ describe('TextLayer', () => {

await onRenderTextLayerSuccessPromise;

const wrapper = container.firstElementChild as HTMLDivElement;
const textItems = wrapper.children;
const textItems = getTextItems(container);

expect(textItems).toHaveLength(desiredTextItems.length + 1);
expect(textItems).toHaveLength(desiredTextItems.length);

expect(customTextRenderer).toHaveBeenCalledTimes(desiredTextItems.length);
expect(customTextRenderer).toHaveBeenCalledWith(
Expand Down
6 changes: 4 additions & 2 deletions src/Page/TextLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export default function TextLayer() {

layer.innerHTML = '';

const textContentSource = page.streamTextContent();
const textContentSource = page.streamTextContent({ includeMarkedContent: true });

const parameters = {
container: layer,
Expand All @@ -201,14 +201,16 @@ export default function TextLayer() {
layer.append(end);
endElement.current = end;

const layerChildrenDeep = layer.querySelectorAll('.markedContent > *:not(.markedContent');
wojtekmaj marked this conversation as resolved.
Show resolved Hide resolved

if (customTextRenderer) {
let index = 0;
textContent.items.forEach((item, itemIndex) => {
if (!isTextItem(item)) {
return;
}

const child = layer.children[index];
const child = layerChildrenDeep[index];

if (!child) {
return;
Expand Down
142 changes: 142 additions & 0 deletions src/StructTree.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { beforeAll, describe, expect, it } from 'vitest';
import React from 'react';
import { render } from '@testing-library/react';

import { pdfjs } from './index.test';

import StructTree from './StructTree';

import failingPage from '../__mocks__/_failing_page';
import { loadPDF, makeAsyncCallback, muteConsole, restoreConsole } from '../test-utils';

import PageContext from './PageContext';

import type { PDFPageProxy } from 'pdfjs-dist';
import type { PageContextType } from './shared/types';
import { StructTreeNode } from 'pdfjs-dist/types/src/display/api';

const pdfFile = loadPDF('./__mocks__/_pdf.pdf');

function renderWithContext(children: React.ReactNode, context: Partial<PageContextType>) {
const { rerender, ...otherResult } = render(
<PageContext.Provider value={context as PageContextType}>{children}</PageContext.Provider>,
);

return {
...otherResult,
rerender: (nextChildren: React.ReactNode, nextContext: Partial<PageContextType> = context) =>
rerender(
<PageContext.Provider value={nextContext as PageContextType}>
{nextChildren}
</PageContext.Provider>,
),
};
}

describe('StructTree', () => {
// Loaded page
let page: PDFPageProxy;
let page2: PDFPageProxy;

// Loaded structure tree
let desiredStructTree: StructTreeNode;
let desiredStructTree2: StructTreeNode;

beforeAll(async () => {
const pdf = await pdfjs.getDocument({ data: pdfFile.arrayBuffer }).promise;

page = await pdf.getPage(1);
desiredStructTree = await page.getStructTree();

page2 = await pdf.getPage(2);
desiredStructTree2 = await page2.getStructTree();
});

describe('loading', () => {
it('loads structure tree and calls onGetStructTreeSuccess callback properly', async () => {
const { func: onGetStructTreeSuccess, promise: onGetStructTreeSuccessPromise } =
makeAsyncCallback();

renderWithContext(<StructTree />, {
onGetStructTreeSuccess,
page,
});

expect.assertions(1);

await expect(onGetStructTreeSuccessPromise).resolves.toMatchObject([desiredStructTree]);
});

it('calls onGetStructTreeError when failed to load annotations', async () => {
const { func: onGetStructTreeError, promise: onGetStructTreeErrorPromise } =
makeAsyncCallback();

muteConsole();

renderWithContext(<StructTree />, {
onGetStructTreeError,
page: failingPage,
});

expect.assertions(1);

await expect(onGetStructTreeErrorPromise).resolves.toMatchObject([expect.any(Error)]);

restoreConsole();
});

it('replaces structure tree properly when page is changed', async () => {
const { func: onGetStructTreeSuccess, promise: onGetStructTreeSuccessPromise } =
makeAsyncCallback();

const { rerender } = renderWithContext(<StructTree />, {
onGetStructTreeSuccess,
page,
});

expect.assertions(2);

await expect(onGetStructTreeSuccessPromise).resolves.toMatchObject([desiredStructTree]);

const { func: onGetStructTreeSuccess2, promise: onGetStructTreeSuccessPromise2 } =
makeAsyncCallback();

rerender(<StructTree />, {
onGetStructTreeSuccess: onGetStructTreeSuccess2,
page: page2,
});

await expect(onGetStructTreeSuccessPromise2).resolves.toMatchObject([desiredStructTree2]);
});

it('throws an error when placed outside Page', () => {
muteConsole();

expect(() => render(<StructTree />)).toThrow();

restoreConsole();
});
});

describe('rendering', () => {
it('renders structure tree properly', async () => {
const { func: onGetStructTreeSuccess, promise: onGetStructTreeSuccessPromise } =
makeAsyncCallback();

const { container } = renderWithContext(<StructTree />, {
onGetStructTreeSuccess,
page,
});

expect.assertions(1);

await onGetStructTreeSuccessPromise;

const wrapper = container.firstElementChild as HTMLSpanElement;

expect(wrapper.outerHTML).toBe(
'<span class="react-pdf__Page__structTree structTree"><span><span role="heading" aria-level="1" aria-owns="page3R_mcid0"></span><span aria-owns="page3R_mcid1"></span><span aria-owns="page3R_mcid2"></span><span role="figure" aria-owns="page3R_mcid12"></span><span aria-owns="page3R_mcid3"></span><span aria-owns="page3R_mcid4"></span><span role="heading" aria-level="2" aria-owns="page3R_mcid5"></span><span aria-owns="page3R_mcid6"></span><span><span aria-owns="page3R_mcid7"></span><span role="link"><span aria-owns="13R"></span><span aria-owns="page3R_mcid8"></span></span><span aria-owns="page3R_mcid9"></span></span><span aria-owns="page3R_mcid10"></span><span aria-owns="page3R_mcid11"></span></span></span>',
);
});
});
});
Loading