Skip to content

Commit

Permalink
Implement StructTree, improve accessibility (#1498)
Browse files Browse the repository at this point in the history
Closes #1494

Co-authored-by: Wojciech Maj <[email protected]>
  • Loading branch information
MattL75 and wojtekmaj authored Jun 6, 2023
1 parent d167ec9 commit f2bad6d
Show file tree
Hide file tree
Showing 13 changed files with 545 additions and 17 deletions.
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 @@ -282,6 +288,8 @@ export default function Page(props: PageProps) {
devicePixelRatio,
onGetAnnotationsError: onGetAnnotationsErrorProps,
onGetAnnotationsSuccess: onGetAnnotationsSuccessProps,
onGetStructTreeError: onGetStructTreeErrorProps,
onGetStructTreeSuccess: onGetStructTreeSuccessProps,
onGetTextError: onGetTextErrorProps,
onGetTextSuccess: onGetTextSuccessProps,
onRenderAnnotationLayerError: onRenderAnnotationLayerErrorProps,
Expand All @@ -294,6 +302,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 () => {
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 @@ -184,7 +184,7 @@ export default function TextLayer() {

layer.innerHTML = '';

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

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

const layerChildrenDeep = layer.querySelectorAll('.markedContent > *:not(.markedContent');

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

0 comments on commit f2bad6d

Please sign in to comment.