Skip to content

Commit

Permalink
feat: 1494 Adds accessible semantics
Browse files Browse the repository at this point in the history
  • Loading branch information
MattL75 committed May 10, 2023
1 parent 2b04432 commit 821fc8b
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 4 deletions.
18 changes: 15 additions & 3 deletions src/Page/PageCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import mergeRefs from 'merge-refs';
import invariant from 'tiny-invariant';
import warning from 'tiny-warning';
Expand All @@ -15,7 +15,8 @@ import {

import { isRef } from '../shared/propTypes';

import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import type { RenderParameters, StructTreeNode } from 'pdfjs-dist/types/src/display/api';
import StructTree from '../StructTree';

const ANNOTATION_MODE = pdfjs.AnnotationMode;

Expand Down Expand Up @@ -45,6 +46,15 @@ export default function PageCanvas(props: PageCanvasProps) {

invariant(page, 'Attempted to render page canvas, but no page was specified.');

const [structTree, setStructTree] = useState<StructTreeNode | null>(null);

useEffect(() => {
page.getStructTree().then((tree) => {
setStructTree(tree);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const devicePixelRatio = devicePixelRatioProps || getDevicePixelRatio();

/**
Expand Down Expand Up @@ -169,7 +179,9 @@ export default function PageCanvas(props: PageCanvasProps) {
display: 'block',
userSelect: 'none',
}}
/>
>
{!!structTree && <StructTree node={structTree} />}
</canvas>
);
}

Expand Down
2 changes: 1 addition & 1 deletion 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 Down
35 changes: 35 additions & 0 deletions src/StructTree/StructTree.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { getAttributes } from './utils';
import type { StructTreeProps } from './types';
import type { StructTreeNode } from 'pdfjs-dist/types/src/display/api';

export default function StructTree({ node }: StructTreeProps) {
const attributes = useMemo(() => getAttributes(node), [node]);

const childNodes = useMemo(() => {
if (
node.children &&
!(node.children.length === 1 && node.children[0] && 'id' in node.children[0])
) {
return node.children.map((child, index) => (
// Safe to use index for key as the array is bound to the pdf structure
// eslint-disable-next-line react/no-array-index-key
<StructTree key={index} node={child as StructTreeNode} />
));
}
return null;
}, [node]);

return <span {...attributes}>{childNodes}</span>;
}

StructTree.propTypes = {
node: PropTypes.shape({
children: PropTypes.array,
role: PropTypes.string,
alt: PropTypes.string,
lang: PropTypes.string,
id: PropTypes.string,
}).isRequired,
};
58 changes: 58 additions & 0 deletions src/StructTree/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// From pdfjs-dist/lib/web/struct_tree_layer_builder.js
export const PDF_ROLE_TO_HTML_ROLE = {
// Document level structure types
Document: null, // There's a "document" role, but it doesn't make sense here.
DocumentFragment: null,
// Grouping level structure types
Part: 'group',
Sect: 'group', // XXX: There's a "section" role, but it's abstract.
Div: 'group',
Aside: 'note',
NonStruct: 'none',
// Block level structure types
P: null,
// H<n>,
H: 'heading',
Title: null,
FENote: 'note',
// Sub-block level structure type
Sub: 'group',
// General inline level structure types
Lbl: null,
Span: null,
Em: null,
Strong: null,
Link: 'link',
Annot: 'note',
Form: 'form',
// Ruby and Warichu structure types
Ruby: null,
RB: null,
RT: null,
RP: null,
Warichu: null,
WT: null,
WP: null,
// List standard structure types
L: 'list',
LI: 'listitem',
LBody: null,
// Table standard structure types
Table: 'table',
TR: 'row',
TH: 'columnheader',
TD: 'cell',
THead: 'columnheader',
TBody: null,
TFoot: null,
// Standard structure type Caption
Caption: null,
// Standard structure type Figure
Figure: 'figure',
// Standard structure type Formula
Formula: null,
// standard structure type Artifact
Artifact: null,
};

export const HEADING_PATTERN = /^H(\d+)$/;
1 change: 1 addition & 0 deletions src/StructTree/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './StructTree';
23 changes: 23 additions & 0 deletions src/StructTree/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { PDF_ROLE_TO_HTML_ROLE } from './constants';

export type PdfTagRole = keyof typeof PDF_ROLE_TO_HTML_ROLE;

export type StructTreeNode = {
children?: StructTreeNode[];
role?: string;
id?: string;
lang?: string;
alt?: string;
};

export type StructTreeProps = {
node: StructTreeNode;
};

export type StructTreeAttributes = {
lang?: string;
role?: string;
'aria-level'?: number;
'aria-label'?: string;
'aria-owns'?: string;
};
42 changes: 42 additions & 0 deletions src/StructTree/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* eslint-disable no-bitwise */
/* eslint-disable prefer-destructuring */
import { HEADING_PATTERN, PDF_ROLE_TO_HTML_ROLE } from './constants';
import type { StructTreeAttributes, StructTreeNode, PdfTagRole } from './types';

export const getRoleAttributes = (node: StructTreeNode) => {
const attributes: StructTreeAttributes = {};
if ('role' in node) {
const { role } = node;
const match = role?.match(HEADING_PATTERN);
if (match) {
attributes.role = 'heading';
attributes['aria-level'] = Number(match[1]);
} else if (role && PDF_ROLE_TO_HTML_ROLE[role as PdfTagRole]) {
attributes.role = PDF_ROLE_TO_HTML_ROLE[role as PdfTagRole] ?? undefined;
}
}
return attributes;
};

export const getStandardAttributes = (node: StructTreeNode): StructTreeAttributes => {
const attributes: StructTreeAttributes = {};
if (node.alt !== undefined) {
attributes['aria-label'] = node.alt;
}
if (node.lang !== undefined) {
attributes.lang = node.lang;
}
if (node.id !== undefined) {
attributes['aria-owns'] = node.id;
}
if (node.children?.length === 1 && node.children[0] && 'id' in node.children[0]) {
return { ...attributes, ...getStandardAttributes(node.children[0]) };
}
return attributes;
};

export const getAttributes = (node: StructTreeNode) => {
if (node) {
return { ...getRoleAttributes(node), ...getStandardAttributes(node) };
}
};

0 comments on commit 821fc8b

Please sign in to comment.