1
0
Fork 0
mirror of https://github.com/mozilla/pdf.js.git synced 2025-04-20 15:18:08 +02:00

Merge pull request #13171 from brendandahl/struct-tree

[api-minor] Add support for basic structure tree for accessibility.
This commit is contained in:
Tim van der Meij 2021-04-09 21:32:44 +02:00 committed by GitHub
commit 03c8c89002
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 911 additions and 14 deletions

View file

@ -41,6 +41,7 @@ import { AnnotationLayerBuilder } from "./annotation_layer_builder.js";
import { NullL10n } from "./l10n_utils.js";
import { PDFPageView } from "./pdf_page_view.js";
import { SimpleLinkService } from "./pdf_link_service.js";
import { StructTreeLayerBuilder } from "./struct_tree_layer_builder.js";
import { TextLayerBuilder } from "./text_layer_builder.js";
import { XfaLayerBuilder } from "./xfa_layer_builder.js";
@ -545,6 +546,7 @@ class BaseViewer {
textLayerMode: this.textLayerMode,
annotationLayerFactory: this,
xfaLayerFactory,
structTreeLayerFactory: this,
imageResourcesPath: this.imageResourcesPath,
renderInteractiveForms: this.renderInteractiveForms,
renderer: this.renderer,
@ -1328,6 +1330,16 @@ class BaseViewer {
});
}
/**
* @param {PDFPage} pdfPage
* @returns {StructTreeLayerBuilder}
*/
createStructTreeLayerBuilder(pdfPage) {
return new StructTreeLayerBuilder({
pdfPage,
});
}
/**
* @type {boolean} Whether all pages of the PDF document have identical
* widths and heights.

View file

@ -216,6 +216,17 @@ class IPDFXfaLayerFactory {
createXfaLayerBuilder(pageDiv, pdfPage) {}
}
/**
* @interface
*/
class IPDFStructTreeLayerFactory {
/**
* @param {PDFPage} pdfPage
* @returns {StructTreeLayerBuilder}
*/
createStructTreeLayerBuilder(pdfPage) {}
}
/**
* @interface
*/
@ -254,6 +265,7 @@ export {
IPDFAnnotationLayerFactory,
IPDFHistory,
IPDFLinkService,
IPDFStructTreeLayerFactory,
IPDFTextLayerFactory,
IPDFXfaLayerFactory,
IRenderableView,

View file

@ -49,6 +49,7 @@ import { viewerCompatibilityParams } from "./viewer_compatibility.js";
* The default value is `TextLayerMode.ENABLE`.
* @property {IPDFAnnotationLayerFactory} annotationLayerFactory
* @property {IPDFXfaLayerFactory} xfaLayerFactory
* @property {IPDFStructTreeLayerFactory} structTreeLayerFactory
* @property {string} [imageResourcesPath] - Path for image resources, mainly
* for annotation icons. Include trailing slash.
* @property {boolean} renderInteractiveForms - Turns on rendering of
@ -102,6 +103,7 @@ class PDFPageView {
this.textLayerFactory = options.textLayerFactory;
this.annotationLayerFactory = options.annotationLayerFactory;
this.xfaLayerFactory = options.xfaLayerFactory;
this.structTreeLayerFactory = options.structTreeLayerFactory;
this.renderer = options.renderer || RendererType.CANVAS;
this.enableWebGL = options.enableWebGL || false;
this.l10n = options.l10n || NullL10n;
@ -116,6 +118,7 @@ class PDFPageView {
this.textLayer = null;
this.zoomLayer = null;
this.xfaLayer = null;
this.structTreeLayer = null;
const div = document.createElement("div");
div.className = "page";
@ -354,6 +357,10 @@ class PDFPageView {
this.annotationLayer.cancel();
this.annotationLayer = null;
}
if (this._onTextLayerRendered) {
this.eventBus._off("textlayerrendered", this._onTextLayerRendered);
this._onTextLayerRendered = null;
}
}
cssTransform(target, redrawAnnotations = false) {
@ -556,11 +563,12 @@ class PDFPageView {
this.paintTask = paintTask;
const resultPromise = paintTask.promise.then(
function () {
return finishPaintTask(null).then(function () {
() => {
return finishPaintTask(null).then(() => {
if (textLayer) {
const readableStream = pdfPage.streamTextContent({
normalizeWhitespace: true,
includeMarkedContent: true,
});
textLayer.setTextContentStream(readableStream);
textLayer.render();
@ -599,6 +607,29 @@ class PDFPageView {
this._renderXfaLayer();
}
// The structure tree is currently only supported when the text layer is
// enabled and a canvas is used for rendering.
if (this.structTreeLayerFactory && this.textLayer && this.canvas) {
// The structure tree must be generated after the text layer for the
// aria-owns to work.
this._onTextLayerRendered = event => {
if (event.pageNumber !== this.id) {
return;
}
this.eventBus._off("textlayerrendered", this._onTextLayerRendered);
this._onTextLayerRendered = null;
this.pdfPage.getStructTree().then(tree => {
const treeDom = this.structTreeLayer.render(tree);
treeDom.classList.add("structTree");
this.canvas.appendChild(treeDom);
});
};
this.eventBus._on("textlayerrendered", this._onTextLayerRendered);
this.structTreeLayer = this.structTreeLayerFactory.createStructTreeLayerBuilder(
pdfPage
);
}
div.setAttribute("data-loaded", true);
this.eventBus.dispatch("pagerender", {

View file

@ -0,0 +1,149 @@
/* Copyright 2021 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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,
};
const HEADING_PATTERN = /^H(\d+)$/;
/**
* @typedef {Object} StructTreeLayerBuilderOptions
* @property {PDFPage} pdfPage
*/
class StructTreeLayerBuilder {
/**
* @param {StructTreeLayerBuilderOptions} options
*/
constructor({ pdfPage }) {
this.pdfPage = pdfPage;
}
render(structTree) {
return this._walk(structTree);
}
_setAttributes(structElement, htmlElement) {
if (structElement.alt !== undefined) {
htmlElement.setAttribute("aria-label", structElement.alt);
}
if (structElement.id !== undefined) {
htmlElement.setAttribute("aria-owns", structElement.id);
}
}
_walk(node) {
if (!node) {
return null;
}
const element = document.createElement("span");
if ("role" in node) {
const { role } = node;
const match = role.match(HEADING_PATTERN);
if (match) {
element.setAttribute("role", "heading");
element.setAttribute("aria-level", match[1]);
} else if (PDF_ROLE_TO_HTML_ROLE[role]) {
element.setAttribute("role", PDF_ROLE_TO_HTML_ROLE[role]);
}
}
this._setAttributes(node, element);
if (node.children) {
if (node.children.length === 1 && "id" in node.children[0]) {
// Often there is only one content node so just set the values on the
// parent node to avoid creating an extra span.
this._setAttributes(node.children[0], element);
} else {
for (const kid of node.children) {
element.appendChild(this._walk(kid));
}
}
}
return element;
}
}
/**
* @implements IPDFStructTreeLayerFactory
*/
class DefaultStructTreeLayerFactory {
/**
* @param {PDFPage} pdfPage
* @returns {StructTreeLayerBuilder}
*/
createStructTreeLayerBuilder(pdfPage) {
return new StructTreeLayerBuilder({
pdfPage,
});
}
}
export { DefaultStructTreeLayerFactory, StructTreeLayerBuilder };

View file

@ -24,7 +24,7 @@
line-height: 1;
}
.textLayer > span {
.textLayer span {
color: transparent;
position: absolute;
white-space: pre;

View file

@ -175,7 +175,7 @@ select {
display: none !important;
}
.pdfViewer.enablePermissions .textLayer > span {
.pdfViewer.enablePermissions .textLayer span {
user-select: none !important;
cursor: not-allowed;
}
@ -195,12 +195,12 @@ select {
display: none;
}
.pdfPresentationMode:fullscreen .textLayer > span {
.pdfPresentationMode:fullscreen .textLayer span {
cursor: none;
}
.pdfPresentationMode.pdfPresentationModeControls > *,
.pdfPresentationMode.pdfPresentationModeControls .textLayer > span {
.pdfPresentationMode.pdfPresentationModeControls .textLayer span {
cursor: default;
}
@ -1653,19 +1653,19 @@ html[dir="rtl"] #documentPropertiesOverlay .row > * {
mix-blend-mode: screen;
}
#viewer.textLayer-visible .textLayer > span {
#viewer.textLayer-visible .textLayer span {
background-color: rgba(255, 255, 0, 0.1);
color: rgba(0, 0, 0, 1);
border: solid 1px rgba(255, 0, 0, 0.5);
box-sizing: border-box;
}
#viewer.textLayer-hover .textLayer > span:hover {
#viewer.textLayer-hover .textLayer span:hover {
background-color: rgba(255, 255, 255, 1);
color: rgba(0, 0, 0, 1);
}
#viewer.textLayer-shadow .textLayer > span {
#viewer.textLayer-shadow .textLayer span {
background-color: rgba(255, 255, 255, 0.6);
color: rgba(0, 0, 0, 1);
}