Merge pull request #15237 from calixteman/annotation_a11y

[Annotations] Add some aria-owns in the text layer to link to annotations (bug 1780375)
This commit is contained in:
calixteman 2022-08-12 15:04:56 +02:00 committed by GitHub
commit 6b4c2464ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 436 additions and 246 deletions

View File

@ -29,6 +29,7 @@ import {
warn,
} from "../shared/util.js";
import {
AnnotationPrefix,
DOMSVGFactory,
getFilenameFromUrl,
PDFDateString,
@ -1901,7 +1902,8 @@ class PopupElement {
}
if (this.hideElement.hidden) {
this.hideElement.hidden = false;
this.container.style.zIndex += 1;
this.container.style.zIndex =
parseInt(this.container.style.zIndex) + 1000;
}
}
@ -1918,7 +1920,8 @@ class PopupElement {
}
if (!this.hideElement.hidden && !this.pinned) {
this.hideElement.hidden = true;
this.container.style.zIndex -= 1;
this.container.style.zIndex =
parseInt(this.container.style.zIndex) - 1000;
}
}
}
@ -2465,6 +2468,19 @@ class FileAttachmentAnnotationElement extends AnnotationElement {
*/
class AnnotationLayer {
static #appendElement(element, id, div, accessibilityManager) {
const contentElement = element.firstChild || element;
contentElement.id = `${AnnotationPrefix}${id}`;
div.append(element);
accessibilityManager?.moveElementInDOM(
div,
element,
contentElement,
/* isRemovable = */ false
);
}
/**
* Render a new annotation layer with all annotation elements.
*
@ -2473,9 +2489,10 @@ class AnnotationLayer {
* @memberof AnnotationLayer
*/
static render(parameters) {
const { annotations, div, viewport } = parameters;
const { annotations, div, viewport, accessibilityManager } = parameters;
this.#setDimensions(div, viewport);
let zIndex = 0;
for (const data of annotations) {
if (data.annotationType !== AnnotationType.POPUP) {
@ -2508,15 +2525,33 @@ class AnnotationLayer {
}
if (Array.isArray(rendered)) {
for (const renderedElement of rendered) {
div.append(renderedElement);
renderedElement.style.zIndex = zIndex++;
AnnotationLayer.#appendElement(
renderedElement,
data.id,
div,
accessibilityManager
);
}
} else {
// The accessibility manager will move the annotation in the DOM in
// order to match the visual ordering.
// But if an annotation is above an other one, then we must draw it
// after the other one whatever the order is in the DOM, hence the
// use of the z-index.
rendered.style.zIndex = zIndex++;
if (element instanceof PopupAnnotationElement) {
// Popup annotation elements should not be on top of other
// annotation elements to prevent interfering with mouse events.
div.prepend(rendered);
} else {
div.append(rendered);
AnnotationLayer.#appendElement(
rendered,
data.id,
div,
accessibilityManager
);
}
}
}

View File

@ -23,6 +23,8 @@ import { BaseException, stringToBytes, Util, warn } from "../shared/util.js";
const SVG_NS = "http://www.w3.org/2000/svg";
const AnnotationPrefix = "pdfjs_internal_id_";
class PixelsPerInch {
static CSS = 96.0;
@ -652,6 +654,7 @@ function getCurrentTransformInverse(ctx) {
}
export {
AnnotationPrefix,
binarySearchFirstItem,
deprecated,
DOMCanvasFactory,

View File

@ -18,11 +18,12 @@
/** @typedef {import("./tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */
// eslint-disable-next-line max-len
/** @typedef {import("../annotation_storage.js").AnnotationStorage} AnnotationStorage */
// eslint-disable-next-line max-len
/** @typedef {import("../../web/text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
/** @typedef {import("../../web/interfaces").IL10n} IL10n */
import { AnnotationEditorType, shadow } from "../../shared/util.js";
import { bindEvents, KeyboardManager } from "./tools.js";
import { binarySearchFirstItem } from "../display_utils.js";
import { AnnotationEditorType } from "../../shared/util.js";
import { FreeTextEditor } from "./freetext.js";
import { InkEditor } from "./ink.js";
@ -33,6 +34,7 @@ import { InkEditor } from "./ink.js";
* @property {AnnotationEditorUIManager} uiManager
* @property {boolean} enabled
* @property {AnnotationStorage} annotationStorage
* @property {TextAccessibilityManager} [accessibilityManager]
* @property {number} pageIndex
* @property {IL10n} l10n
*/
@ -41,6 +43,8 @@ import { InkEditor } from "./ink.js";
* Manage all the different editors on a page.
*/
class AnnotationEditorLayer {
#accessibilityManager;
#allowClick = false;
#boundPointerup = this.pointerup.bind(this);
@ -53,14 +57,8 @@ class AnnotationEditorLayer {
#isCleaningUp = false;
#textLayerMap = new WeakMap();
#textNodes = new Map();
#uiManager;
#waitingEditors = new Set();
static _initialized = false;
/**
@ -78,43 +76,11 @@ class AnnotationEditorLayer {
this.annotationStorage = options.annotationStorage;
this.pageIndex = options.pageIndex;
this.div = options.div;
this.#accessibilityManager = options.accessibilityManager;
this.#uiManager.addLayer(this);
}
get textLayerElements() {
// When zooming the text layer is removed from the DOM and sometimes
// it's rebuilt hence the nodes are no longer valid.
const textLayer = this.div.parentNode
.getElementsByClassName("textLayer")
.item(0);
if (!textLayer) {
return shadow(this, "textLayerElements", null);
}
let textChildren = this.#textLayerMap.get(textLayer);
if (textChildren) {
return textChildren;
}
textChildren = textLayer.querySelectorAll(`span[role="presentation"]`);
if (textChildren.length === 0) {
return shadow(this, "textLayerElements", null);
}
textChildren = Array.from(textChildren);
textChildren.sort(AnnotationEditorLayer.#compareElementPositions);
this.#textLayerMap.set(textLayer, textChildren);
return textChildren;
}
get #hasTextLayer() {
return !!this.div.parentNode.querySelector(".textLayer .endOfContent");
}
/**
* Update the toolbar if it's required to reflect the tool currently used.
* @param {number} mode
@ -228,7 +194,7 @@ class AnnotationEditorLayer {
detach(editor) {
this.#editors.delete(editor.id);
this.removePointerInTextLayer(editor);
this.#accessibilityManager?.removePointerInTextLayer(editor.contentDiv);
}
/**
@ -281,147 +247,6 @@ class AnnotationEditorLayer {
}
}
/**
* Compare the positions of two elements, it must correspond to
* the visual ordering.
*
* @param {HTMLElement} e1
* @param {HTMLElement} e2
* @returns {number}
*/
static #compareElementPositions(e1, e2) {
const rect1 = e1.getBoundingClientRect();
const rect2 = e2.getBoundingClientRect();
if (rect1.y + rect1.height <= rect2.y) {
return -1;
}
if (rect2.y + rect2.height <= rect1.y) {
return +1;
}
const centerX1 = rect1.x + rect1.width / 2;
const centerX2 = rect2.x + rect2.width / 2;
return centerX1 - centerX2;
}
/**
* Function called when the text layer has finished rendering.
*/
onTextLayerRendered() {
this.#textNodes.clear();
for (const editor of this.#waitingEditors) {
if (editor.isAttachedToDOM) {
this.addPointerInTextLayer(editor);
}
}
this.#waitingEditors.clear();
}
/**
* Remove an aria-owns id from a node in the text layer.
* @param {AnnotationEditor} editor
*/
removePointerInTextLayer(editor) {
if (!this.#hasTextLayer) {
this.#waitingEditors.delete(editor);
return;
}
const { id } = editor;
const node = this.#textNodes.get(id);
if (!node) {
return;
}
this.#textNodes.delete(id);
let owns = node.getAttribute("aria-owns");
if (owns?.includes(id)) {
owns = owns
.split(" ")
.filter(x => x !== id)
.join(" ");
if (owns) {
node.setAttribute("aria-owns", owns);
} else {
node.removeAttribute("aria-owns");
node.setAttribute("role", "presentation");
}
}
}
/**
* Find the text node which is the nearest and add an aria-owns attribute
* in order to correctly position this editor in the text flow.
* @param {AnnotationEditor} editor
*/
addPointerInTextLayer(editor) {
if (!this.#hasTextLayer) {
// The text layer needs to be there, so we postpone the association.
this.#waitingEditors.add(editor);
return;
}
this.removePointerInTextLayer(editor);
const children = this.textLayerElements;
if (!children) {
return;
}
const { contentDiv } = editor;
const id = editor.getIdForTextLayer();
const index = binarySearchFirstItem(
children,
node =>
AnnotationEditorLayer.#compareElementPositions(contentDiv, node) < 0
);
const node = children[Math.max(0, index - 1)];
const owns = node.getAttribute("aria-owns");
if (!owns?.includes(id)) {
node.setAttribute("aria-owns", owns ? `${owns} ${id}` : id);
}
node.removeAttribute("role");
this.#textNodes.set(id, node);
}
/**
* Move a div in the DOM in order to respect the visual order.
* @param {HTMLDivElement} div
*/
moveDivInDOM(editor) {
this.addPointerInTextLayer(editor);
const { div, contentDiv } = editor;
if (!this.div.hasChildNodes()) {
this.div.append(div);
return;
}
const children = Array.from(this.div.childNodes).filter(
node => node !== div
);
if (children.length === 0) {
return;
}
const index = binarySearchFirstItem(
children,
node =>
AnnotationEditorLayer.#compareElementPositions(contentDiv, node) < 0
);
if (index === 0) {
children[0].before(div);
} else {
children[index - 1].after(div);
}
}
/**
* Add a new editor in the current view.
* @param {AnnotationEditor} editor
@ -437,11 +262,20 @@ class AnnotationEditorLayer {
editor.isAttachedToDOM = true;
}
this.moveDivInDOM(editor);
this.moveEditorInDOM(editor);
editor.onceAdded();
this.addToAnnotationStorage(editor);
}
moveEditorInDOM(editor) {
this.#accessibilityManager?.moveElementInDOM(
this.div,
editor.div,
editor.contentDiv,
/* isRemovable = */ true
);
}
/**
* Add an editor in the annotation storage.
* @param {AnnotationEditor} editor
@ -658,7 +492,7 @@ class AnnotationEditorLayer {
const endY = event.clientY - rect.y;
editor.translate(endX - editor.startX, endY - editor.startY);
this.moveDivInDOM(editor);
this.moveEditorInDOM(editor);
editor.div.focus();
}
@ -679,15 +513,13 @@ class AnnotationEditorLayer {
}
for (const editor of this.#editors.values()) {
this.removePointerInTextLayer(editor);
this.#accessibilityManager?.removePointerInTextLayer(editor.contentDiv);
editor.isAttachedToDOM = false;
editor.div.remove();
editor.parent = null;
}
this.#textNodes.clear();
this.div = null;
this.#editors.clear();
this.#waitingEditors.clear();
this.#uiManager.removeLayer(this);
}

View File

@ -489,14 +489,6 @@ class AnnotationEditor {
*/
enableEditing() {}
/**
* Get the id to use in aria-owns when a link is done in the text layer.
* @returns {string}
*/
getIdForTextLayer() {
return this.id;
}
/**
* Get some properties to update in the UI.
* @returns {Object}

View File

@ -385,11 +385,6 @@ class FreeTextEditor extends AnnotationEditor {
this.editorDiv.setAttribute("aria-multiline", true);
}
/** @inheritdoc */
getIdForTextLayer() {
return this.editorDiv.id;
}
/** @inheritdoc */
render() {
if (this.div) {

View File

@ -488,7 +488,7 @@ class InkEditor extends AnnotationEditor {
// When commiting, the position of this editor is changed, hence we must
// move it to the right position in the DOM.
this.parent.moveDivInDOM(this);
this.parent.moveEditorInDOM(this);
// After the div has been moved in the DOM, the focus may have been stolen
// by document.body, hence we just keep it here.
this.div.focus();

View File

@ -428,8 +428,6 @@ class AnnotationEditorUIManager {
#boundOnPageChanging = this.onPageChanging.bind(this);
#boundOnTextLayerRendered = this.onTextLayerRendered.bind(this);
#previousStates = {
isEditing: false,
isEmpty: true,
@ -474,14 +472,12 @@ class AnnotationEditorUIManager {
this.#eventBus = eventBus;
this.#eventBus._on("editingaction", this.#boundOnEditingAction);
this.#eventBus._on("pagechanging", this.#boundOnPageChanging);
this.#eventBus._on("textlayerrendered", this.#boundOnTextLayerRendered);
}
destroy() {
this.#removeKeyboardManager();
this.#eventBus._off("editingaction", this.#boundOnEditingAction);
this.#eventBus._off("pagechanging", this.#boundOnPageChanging);
this.#eventBus._off("textlayerrendered", this.#boundOnTextLayerRendered);
for (const layer of this.#allLayers.values()) {
layer.destroy();
}
@ -497,12 +493,6 @@ class AnnotationEditorUIManager {
this.#currentPageIndex = pageNumber - 1;
}
onTextLayerRendered({ pageNumber }) {
const pageIndex = pageNumber - 1;
const layer = this.#allLayers.get(pageIndex);
layer?.onTextLayerRendered();
}
focusMainContainer() {
this.#container.focus();
}

View File

@ -32,6 +32,7 @@ async function runTests(results) {
"find_spec.js",
"freetext_editor_spec.js",
"ink_editor_spec.js",
"a11y_spec.js",
],
});

View File

@ -66,4 +66,44 @@ describe("accessibility", () => {
);
});
});
describe("Annotation", () => {
let pages;
beforeAll(async () => {
pages = await loadAndWait(
"tracemonkey_a11y.pdf",
".textLayer .endOfContent"
);
});
afterAll(async () => {
await closePages(pages);
});
function getSpans(page) {
return page.evaluate(() => {
const elements = document.querySelectorAll(
`.textLayer span[aria-owns]:not([role="presentation"])`
);
const results = [];
for (const element of elements) {
results.push(element.innerText);
}
return results;
});
}
it("must check that some spans are linked to some annotations thanks to aria-owns", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const spanContents = await getSpans(page);
expect(spanContents)
.withContext(`In ${browserName}`)
.toEqual(["Languages", "@intel.com", "Abstract", "Introduction"]);
})
);
});
});
});

View File

@ -15,7 +15,7 @@
const {
closePages,
editorPrefix,
getEditorSelector,
getSelectedEditors,
loadAndWait,
} = require("./test_utils.js");
@ -51,9 +51,9 @@ describe("Editor", () => {
const data = "Hello PDF.js World !!";
await page.mouse.click(rect.x + 100, rect.y + 100);
await page.type(`${editorPrefix}0 .internal`, data);
await page.type(`${getEditorSelector(0)} .internal`, data);
const editorRect = await page.$eval(`${editorPrefix}0`, el => {
const editorRect = await page.$eval(getEditorSelector(0), el => {
const { x, y, width, height } = el.getBoundingClientRect();
return {
x,
@ -73,7 +73,7 @@ describe("Editor", () => {
.withContext(`In ${browserName}`)
.toEqual(1);
const content = await page.$eval(`${editorPrefix}0`, el =>
const content = await page.$eval(getEditorSelector(0), el =>
el.innerText.trimEnd()
);
expect(content).withContext(`In ${browserName}`).toEqual(data);
@ -84,7 +84,7 @@ describe("Editor", () => {
it("must copy/paste", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const editorRect = await page.$eval(`${editorPrefix}0`, el => {
const editorRect = await page.$eval(getEditorSelector(0), el => {
const { x, y, width, height } = el.getBoundingClientRect();
return { x, y, width, height };
});
@ -107,11 +107,11 @@ describe("Editor", () => {
.withContext(`In ${browserName}`)
.toEqual(2);
const content = await page.$eval(`${editorPrefix}0`, el =>
const content = await page.$eval(getEditorSelector(0), el =>
el.innerText.trimEnd()
);
let pastedContent = await page.$eval(`${editorPrefix}1`, el =>
let pastedContent = await page.$eval(getEditorSelector(1), el =>
el.innerText.trimEnd()
);
@ -131,7 +131,7 @@ describe("Editor", () => {
.withContext(`In ${browserName}`)
.toEqual(3);
pastedContent = await page.$eval(`${editorPrefix}2`, el =>
pastedContent = await page.$eval(getEditorSelector(2), el =>
el.innerText.trimEnd()
);
expect(pastedContent)
@ -155,7 +155,7 @@ describe("Editor", () => {
for (const n of [0, 1, 2]) {
const hasEditor = await page.evaluate(sel => {
return !!document.querySelector(sel);
}, `${editorPrefix}${n}`);
}, getEditorSelector(n));
expect(hasEditor).withContext(`In ${browserName}`).toEqual(false);
}
@ -177,9 +177,9 @@ describe("Editor", () => {
const data = "Hello PDF.js World !!";
await page.mouse.click(rect.x + 100, rect.y + 100);
await page.type(`${editorPrefix}3 .internal`, data);
await page.type(`${getEditorSelector(3)} .internal`, data);
const editorRect = await page.$eval(`${editorPrefix}3`, el => {
const editorRect = await page.$eval(getEditorSelector(3), el => {
const { x, y, width, height } = el.getBoundingClientRect();
return { x, y, width, height };
});
@ -205,7 +205,7 @@ describe("Editor", () => {
let hasEditor = await page.evaluate(sel => {
return !!document.querySelector(sel);
}, `${editorPrefix}4`);
}, getEditorSelector(4));
expect(hasEditor).withContext(`In ${browserName}`).toEqual(true);
@ -215,7 +215,7 @@ describe("Editor", () => {
hasEditor = await page.evaluate(sel => {
return !!document.querySelector(sel);
}, `${editorPrefix}4`);
}, getEditorSelector(4));
expect(hasEditor).withContext(`In ${browserName}`).toEqual(false);
@ -227,7 +227,7 @@ describe("Editor", () => {
let length = await page.evaluate(sel => {
return document.querySelectorAll(sel).length;
}, `${editorPrefix}5, ${editorPrefix}6`);
}, `${getEditorSelector(5)}, ${getEditorSelector(6)}`);
expect(length).withContext(`In ${browserName}`).toEqual(2);
for (let i = 0; i < 2; i++) {
@ -238,7 +238,7 @@ describe("Editor", () => {
length = await page.evaluate(sel => {
return document.querySelectorAll(sel).length;
}, `${editorPrefix}5, ${editorPrefix}6`);
}, `${getEditorSelector(5)}, ${getEditorSelector(6)}`);
expect(length).withContext(`In ${browserName}`).toEqual(0);
})
);
@ -273,7 +273,7 @@ describe("Editor", () => {
stacksRect.x + stacksRect.width + 1,
stacksRect.y + stacksRect.height / 2
);
await page.type(`${editorPrefix}7 .internal`, data);
await page.type(`${getEditorSelector(7)} .internal`, data);
// Commit.
await page.keyboard.press("Escape");
@ -283,9 +283,9 @@ describe("Editor", () => {
return span?.getAttribute("aria-owns") || null;
});
expect(ariaOwns)
expect(ariaOwns.endsWith("_7-editor"))
.withContext(`In ${browserName}`)
.toEqual(`${editorPrefix}7-editor`.slice(1));
.toEqual(true);
})
);
});
@ -308,9 +308,9 @@ describe("Editor", () => {
const data = "Hello PDF.js World !!";
await page.mouse.click(rect.x + 100, rect.y + 100);
await page.type(`${editorPrefix}8 .internal`, data);
await page.type(`${getEditorSelector(8)} .internal`, data);
const editorRect = await page.$eval(`${editorPrefix}8`, el => {
const editorRect = await page.$eval(getEditorSelector(8), el => {
const { x, y, width, height } = el.getBoundingClientRect();
return { x, y, width, height };
});
@ -385,9 +385,9 @@ describe("Editor", () => {
rect.x + (i + 1) * 100,
rect.y + (i + 1) * 100
);
await page.type(`${editorPrefix}${i} .internal`, data);
await page.type(`${getEditorSelector(i)} .internal`, data);
const editorRect = await page.$eval(`${editorPrefix}${i}`, el => {
const editorRect = await page.$eval(getEditorSelector(i), el => {
const { x, y, width, height } = el.getBoundingClientRect();
return {
x,

View File

@ -73,19 +73,17 @@ function getComputedStyleSelector(id) {
return `getComputedStyle(${getQuerySelector(id)})`;
}
exports.getComputedStyleSelector = getComputedStyleSelector;
const editorPrefix = "#pdfjs_internal_editor_";
exports.editorPrefix = editorPrefix;
exports.getEditorSelector = n => `#pdfjs_internal_editor_${n}`;
function getSelectedEditors(page) {
return page.evaluate(prefix => {
return page.evaluate(() => {
const elements = document.querySelectorAll(".selectedEditor");
const results = [];
for (const { id } of elements) {
results.push(parseInt(id.slice(prefix.length)));
results.push(parseInt(id.split("_").at(-1)));
}
results.sort();
return results;
}, editorPrefix.slice(1));
});
}
exports.getSelectedEditors = getSelectedEditors;

View File

@ -534,3 +534,4 @@
!bug1675139.pdf
!issue15092.pdf
!bug1782186.pdf
!tracemonkey_a11y.pdf

Binary file not shown.

View File

@ -21,6 +21,8 @@
/** @typedef {import("../src/display/editor/tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */
// eslint-disable-next-line max-len
/** @typedef {import("../annotation_storage.js").AnnotationStorage} AnnotationStorage */
// eslint-disable-next-line max-len
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
/** @typedef {import("./interfaces").IL10n} IL10n */
import { AnnotationEditorLayer } from "pdfjs-lib";
@ -31,6 +33,7 @@ import { NullL10n } from "./l10n_utils.js";
* @property {number} mode - Editor mode
* @property {HTMLDivElement} pageDiv
* @property {PDFPageProxy} pdfPage
* @property {TextAccessibilityManager} accessibilityManager
* @property {AnnotationStorage} annotationStorage
* @property {IL10n} l10n - Localization service.
* @property {AnnotationEditorUIManager} uiManager
@ -46,6 +49,7 @@ class AnnotationEditorLayerBuilder {
this.pageDiv = options.pageDiv;
this.pdfPage = options.pdfPage;
this.annotationStorage = options.annotationStorage || null;
this.accessibilityManager = options.accessibilityManager;
this.l10n = options.l10n || NullL10n;
this.annotationEditorLayer = null;
this.div = null;
@ -83,6 +87,7 @@ class AnnotationEditorLayerBuilder {
uiManager: this.#uiManager,
div: this.div,
annotationStorage: this.annotationStorage,
accessibilityManager: this.accessibilityManager,
pageIndex: this.pdfPage._pageIndex,
l10n: this.l10n,
viewport: clonedViewport,

View File

@ -210,7 +210,6 @@
.annotationLayer .popup {
position: absolute;
z-index: 200;
max-width: calc(180px * var(--scale-factor));
background-color: rgba(255, 255, 153, 1);
box-shadow: 0 calc(2px * var(--scale-factor)) calc(5px * var(--scale-factor))

View File

@ -19,6 +19,8 @@
/** @typedef {import("./interfaces").IDownloadManager} IDownloadManager */
/** @typedef {import("./interfaces").IL10n} IL10n */
/** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */
// eslint-disable-next-line max-len
/** @typedef {import("./textaccessibility.js").TextAccessibilityManager} TextAccessibilityManager */
import { AnnotationLayer } from "pdfjs-lib";
import { NullL10n } from "./l10n_utils.js";
@ -40,6 +42,7 @@ import { NullL10n } from "./l10n_utils.js";
* [fieldObjectsPromise]
* @property {Object} [mouseState]
* @property {Map<string, HTMLCanvasElement>} [annotationCanvasMap]
* @property {TextAccessibilityManager} accessibilityManager
*/
class AnnotationLayerBuilder {
@ -60,6 +63,7 @@ class AnnotationLayerBuilder {
fieldObjectsPromise = null,
mouseState = null,
annotationCanvasMap = null,
accessibilityManager = null,
}) {
this.pageDiv = pageDiv;
this.pdfPage = pdfPage;
@ -74,6 +78,7 @@ class AnnotationLayerBuilder {
this._fieldObjectsPromise = fieldObjectsPromise;
this._mouseState = mouseState;
this._annotationCanvasMap = annotationCanvasMap;
this._accessibilityManager = accessibilityManager;
this.div = null;
this._cancelled = false;
@ -112,6 +117,7 @@ class AnnotationLayerBuilder {
fieldObjects,
mouseState: this._mouseState,
annotationCanvasMap: this._annotationCanvasMap,
accessibilityManager: this._accessibilityManager,
};
if (this.div) {

View File

@ -30,6 +30,8 @@
// eslint-disable-next-line max-len
/** @typedef {import("./interfaces").IPDFTextLayerFactory} IPDFTextLayerFactory */
/** @typedef {import("./interfaces").IPDFXfaLayerFactory} IPDFXfaLayerFactory */
// eslint-disable-next-line max-len
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
import {
AnnotationEditorType,
@ -1637,6 +1639,7 @@ class BaseViewer {
* @property {boolean} [enhanceTextSelection]
* @property {EventBus} eventBus
* @property {TextHighlighter} highlighter
* @property {TextAccessibilityManager} [accessibilityManager]
*/
/**
@ -1650,6 +1653,7 @@ class BaseViewer {
enhanceTextSelection = false,
eventBus,
highlighter,
accessibilityManager = null,
}) {
return new TextLayerBuilder({
textLayerDiv,
@ -1660,6 +1664,7 @@ class BaseViewer {
? false
: enhanceTextSelection,
highlighter,
accessibilityManager,
});
}
@ -1698,6 +1703,7 @@ class BaseViewer {
* [fieldObjectsPromise]
* @property {Map<string, HTMLCanvasElement>} [annotationCanvasMap] - Map some
* annotation ids with canvases used to render them.
* @property {TextAccessibilityManager} [accessibilityManager]
*/
/**
@ -1716,6 +1722,7 @@ class BaseViewer {
mouseState = this._scriptingManager?.mouseState,
fieldObjectsPromise = this.pdfDocument?.getFieldObjects(),
annotationCanvasMap = null,
accessibilityManager = null,
}) {
return new AnnotationLayerBuilder({
pageDiv,
@ -1731,6 +1738,7 @@ class BaseViewer {
mouseState,
fieldObjectsPromise,
annotationCanvasMap,
accessibilityManager,
});
}
@ -1741,6 +1749,7 @@ class BaseViewer {
* @property {PDFPageProxy} pdfPage
* @property {IL10n} l10n
* @property {AnnotationStorage} [annotationStorage] - Storage for annotation
* @property {TextAccessibilityManager} [accessibilityManager]
* data in forms.
*/
@ -1752,6 +1761,7 @@ class BaseViewer {
uiManager = this.#annotationEditorUIManager,
pageDiv,
pdfPage,
accessibilityManager = null,
l10n,
annotationStorage = this.pdfDocument?.annotationStorage,
}) {
@ -1760,6 +1770,7 @@ class BaseViewer {
pageDiv,
pdfPage,
annotationStorage,
accessibilityManager,
l10n,
});
}

View File

@ -92,6 +92,10 @@
box-sizing: border-box;
}
#viewer.textLayer-visible .textLayer span[aria-owns] {
background-color: rgba(255, 0, 0, 0.3);
}
#viewer.textLayer-hover .textLayer span:hover {
background-color: rgba(255, 255, 255, 1);
color: rgba(0, 0, 0, 1);

View File

@ -30,6 +30,8 @@
/** @typedef {import("./interfaces").IPDFTextLayerFactory} IPDFTextLayerFactory */
/** @typedef {import("./interfaces").IPDFXfaLayerFactory} IPDFXfaLayerFactory */
/** @typedef {import("./text_highlighter").TextHighlighter} TextHighlighter */
// eslint-disable-next-line max-len
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
import { AnnotationEditorLayerBuilder } from "./annotation_editor_layer_builder.js";
import { AnnotationLayerBuilder } from "./annotation_layer_builder.js";
@ -60,6 +62,7 @@ class DefaultAnnotationLayerFactory {
* [fieldObjectsPromise]
* @property {Map<string, HTMLCanvasElement>} [annotationCanvasMap] - Map some
* annotation ids with canvases used to render them.
* @property {TextAccessibilityManager} [accessibilityManager]
*/
/**
@ -78,6 +81,7 @@ class DefaultAnnotationLayerFactory {
mouseState = null,
fieldObjectsPromise = null,
annotationCanvasMap = null,
accessibilityManager = null,
}) {
return new AnnotationLayerBuilder({
pageDiv,
@ -92,6 +96,7 @@ class DefaultAnnotationLayerFactory {
fieldObjectsPromise,
mouseState,
annotationCanvasMap,
accessibilityManager,
});
}
}
@ -107,6 +112,7 @@ class DefaultAnnotationEditorLayerFactory {
* @property {PDFPageProxy} pdfPage
* @property {IL10n} l10n
* @property {AnnotationStorage} [annotationStorage] - Storage for annotation
* @property {TextAccessibilityManager} [accessibilityManager]
* data in forms.
*/
@ -118,6 +124,7 @@ class DefaultAnnotationEditorLayerFactory {
uiManager = null,
pageDiv,
pdfPage,
accessibilityManager = null,
l10n,
annotationStorage = null,
}) {
@ -125,6 +132,7 @@ class DefaultAnnotationEditorLayerFactory {
uiManager,
pageDiv,
pdfPage,
accessibilityManager,
l10n,
annotationStorage,
});
@ -163,6 +171,7 @@ class DefaultTextLayerFactory {
* @property {boolean} [enhanceTextSelection]
* @property {EventBus} eventBus
* @property {TextHighlighter} highlighter
* @property {TextAccessibilityManager} [accessibilityManager]
*/
/**
@ -176,6 +185,7 @@ class DefaultTextLayerFactory {
enhanceTextSelection = false,
eventBus,
highlighter,
accessibilityManager = null,
}) {
return new TextLayerBuilder({
textLayerDiv,
@ -184,6 +194,7 @@ class DefaultTextLayerFactory {
enhanceTextSelection,
eventBus,
highlighter,
accessibilityManager,
});
}
}

View File

@ -29,6 +29,8 @@
/** @typedef {import("./text_layer_builder").TextLayerBuilder} TextLayerBuilder */
/** @typedef {import("./ui_utils").RenderingStates} RenderingStates */
/** @typedef {import("./xfa_layer_builder").XfaLayerBuilder} XfaLayerBuilder */
// eslint-disable-next-line max-len
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
/**
* @interface
@ -162,6 +164,7 @@ class IPDFTextLayerFactory {
* @property {boolean} [enhanceTextSelection]
* @property {EventBus} eventBus
* @property {TextHighlighter} highlighter
* @property {TextAccessibilityManager} [accessibilityManager]
*/
/**
@ -175,6 +178,7 @@ class IPDFTextLayerFactory {
enhanceTextSelection = false,
eventBus,
highlighter,
accessibilityManager,
}) {}
}
@ -199,6 +203,7 @@ class IPDFAnnotationLayerFactory {
* [fieldObjectsPromise]
* @property {Map<string, HTMLCanvasElement>} [annotationCanvasMap] - Map some
* annotation ids with canvases used to render them.
* @property {TextAccessibilityManager} [accessibilityManager]
*/
/**
@ -217,6 +222,7 @@ class IPDFAnnotationLayerFactory {
mouseState = null,
fieldObjectsPromise = null,
annotationCanvasMap = null,
accessibilityManager = null,
}) {}
}
@ -231,6 +237,7 @@ class IPDFAnnotationEditorLayerFactory {
* @property {PDFPageProxy} pdfPage
* @property {IL10n} l10n
* @property {AnnotationStorage} [annotationStorage] - Storage for annotation
* @property {TextAccessibilityManager} [accessibilityManager]
* data in forms.
*/
@ -244,6 +251,7 @@ class IPDFAnnotationEditorLayerFactory {
pdfPage,
l10n,
annotationStorage = null,
accessibilityManager,
}) {}
}

View File

@ -51,6 +51,7 @@ import {
} from "./ui_utils.js";
import { compatibilityParams } from "./app_options.js";
import { NullL10n } from "./l10n_utils.js";
import { TextAccessibilityManager } from "./text_accessibility.js";
/**
* @typedef {Object} PDFPageViewOptions
@ -697,6 +698,7 @@ class PDFPageView {
let textLayer = null;
if (this.textLayerMode !== TextLayerMode.DISABLE && this.textLayerFactory) {
this._accessibilityManager ||= new TextAccessibilityManager();
const textLayerDiv = document.createElement("div");
textLayerDiv.className = "textLayer";
textLayerDiv.style.width = canvasWrapper.style.width;
@ -716,6 +718,7 @@ class PDFPageView {
this.textLayerMode === TextLayerMode.ENABLE_ENHANCE,
eventBus: this.eventBus,
highlighter: this.textHighlighter,
accessibilityManager: this._accessibilityManager,
});
}
this.textLayer = textLayer;
@ -733,6 +736,7 @@ class PDFPageView {
renderForms: this.#annotationMode === AnnotationMode.ENABLE_FORMS,
l10n: this.l10n,
annotationCanvasMap: this._annotationCanvasMap,
accessibilityManager: this._accessibilityManager,
});
}
@ -824,6 +828,7 @@ class PDFPageView {
pageDiv: div,
pdfPage,
l10n: this.l10n,
accessibilityManager: this._accessibilityManager,
}
);
this._renderAnnotationEditorLayer();

246
web/text_accessibility.js Normal file
View File

@ -0,0 +1,246 @@
/* Copyright 2022 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.
*/
import { binarySearchFirstItem } from "pdfjs-lib";
/**
* This class aims to provide some methods:
* - to reorder elements in the DOM with respect to the visual order;
* - to create a link, using aria-owns, between spans in the textLayer and
* annotations in the annotationLayer. The goal is to help to know
* where the annotations are in the text flow.
*/
class TextAccessibilityManager {
#enabled = false;
#textChildren = null;
#textNodes = new Map();
#waitingElements = new Map();
setTextMapping(textDivs) {
this.#textChildren = textDivs;
}
/**
* Compare the positions of two elements, it must correspond to
* the visual ordering.
*
* @param {HTMLElement} e1
* @param {HTMLElement} e2
* @returns {number}
*/
static #compareElementPositions(e1, e2) {
const rect1 = e1.getBoundingClientRect();
const rect2 = e2.getBoundingClientRect();
if (rect1.width === 0 && rect1.height === 0) {
return +1;
}
if (rect2.width === 0 && rect2.height === 0) {
return -1;
}
const top1 = rect1.y;
const bot1 = rect1.y + rect1.height;
const mid1 = rect1.y + rect1.height / 2;
const top2 = rect2.y;
const bot2 = rect2.y + rect2.height;
const mid2 = rect2.y + rect2.height / 2;
if (mid1 <= top2 && mid2 >= bot1) {
return -1;
}
if (mid2 <= top1 && mid1 >= bot2) {
return +1;
}
const centerX1 = rect1.x + rect1.width / 2;
const centerX2 = rect2.x + rect2.width / 2;
return centerX1 - centerX2;
}
/**
* Function called when the text layer has finished rendering.
*/
enable() {
if (this.#enabled) {
throw new Error("TextAccessibilityManager is already enabled.");
}
if (!this.#textChildren) {
throw new Error("Text divs and strings have not been set.");
}
this.#enabled = true;
this.#textChildren = this.#textChildren.slice();
this.#textChildren.sort(TextAccessibilityManager.#compareElementPositions);
if (this.#textNodes.size > 0) {
// Some links have been made before this manager has been disabled, hence
// we restore them.
const textChildren = this.#textChildren;
for (const [id, nodeIndex] of this.#textNodes) {
this.#addIdToAriaOwns(id, textChildren[nodeIndex]);
}
}
for (const [element, isRemovable] of this.#waitingElements) {
this.addPointerInTextLayer(element, isRemovable);
}
this.#waitingElements.clear();
}
disable() {
if (!this.#enabled) {
return;
}
// Don't clear this.#textNodes which is used to rebuild the aria-owns
// in case it's re-enabled at some point.
this.#waitingElements.clear();
this.#textChildren = null;
this.#enabled = false;
}
/**
* Remove an aria-owns id from a node in the text layer.
* @param {HTMLElement} element
*/
removePointerInTextLayer(element) {
if (!this.#enabled) {
this.#waitingElements.delete(element);
return;
}
const children = this.#textChildren;
if (!children || children.length === 0) {
return;
}
const { id } = element;
const nodeIndex = this.#textNodes.get(id);
if (nodeIndex === undefined) {
return;
}
const node = children[nodeIndex];
this.#textNodes.delete(id);
let owns = node.getAttribute("aria-owns");
if (owns?.includes(id)) {
owns = owns
.split(" ")
.filter(x => x !== id)
.join(" ");
if (owns) {
node.setAttribute("aria-owns", owns);
} else {
node.removeAttribute("aria-owns");
node.setAttribute("role", "presentation");
}
}
}
#addIdToAriaOwns(id, node) {
const owns = node.getAttribute("aria-owns");
if (!owns?.includes(id)) {
node.setAttribute("aria-owns", owns ? `${owns} ${id}` : id);
}
node.removeAttribute("role");
}
/**
* Find the text node which is the nearest and add an aria-owns attribute
* in order to correctly position this editor in the text flow.
* @param {HTMLElement} element
* @param {boolean} isRemovable
*/
addPointerInTextLayer(element, isRemovable) {
const { id } = element;
if (!id) {
return;
}
if (!this.#enabled) {
// The text layer needs to be there, so we postpone the association.
this.#waitingElements.set(element, isRemovable);
return;
}
if (isRemovable) {
this.removePointerInTextLayer(element);
}
const children = this.#textChildren;
if (!children || children.length === 0) {
return;
}
const index = binarySearchFirstItem(
children,
node =>
TextAccessibilityManager.#compareElementPositions(element, node) < 0
);
const nodeIndex = Math.max(0, index - 1);
this.#addIdToAriaOwns(id, children[nodeIndex]);
this.#textNodes.set(id, nodeIndex);
}
/**
* Move a div in the DOM in order to respect the visual order.
* @param {HTMLDivElement} element
*/
moveElementInDOM(container, element, contentElement, isRemovable) {
this.addPointerInTextLayer(contentElement, isRemovable);
if (!container.hasChildNodes()) {
container.append(element);
return;
}
const children = Array.from(container.childNodes).filter(
node => node !== element
);
if (children.length === 0) {
return;
}
const elementToCompare = contentElement || element;
const index = binarySearchFirstItem(
children,
node =>
TextAccessibilityManager.#compareElementPositions(
elementToCompare,
node
) < 0
);
if (index === 0) {
children[0].before(element);
} else {
children[index - 1].after(element);
}
}
}
export { TextAccessibilityManager };

View File

@ -17,6 +17,8 @@
/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
/** @typedef {import("./event_utils").EventBus} EventBus */
/** @typedef {import("./text_highlighter").TextHighlighter} TextHighlighter */
// eslint-disable-next-line max-len
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
import { renderTextLayer } from "pdfjs-lib";
@ -32,6 +34,7 @@ const EXPAND_DIVS_TIMEOUT = 300; // ms
* highlighting text from the find controller.
* @property {boolean} enhanceTextSelection - Option to turn on improved
* text selection.
* @property {TextAccessibilityManager} [accessibilityManager]
*/
/**
@ -47,6 +50,7 @@ class TextLayerBuilder {
viewport,
highlighter = null,
enhanceTextSelection = false,
accessibilityManager = null,
}) {
this.textLayerDiv = textLayerDiv;
this.eventBus = eventBus;
@ -60,6 +64,7 @@ class TextLayerBuilder {
this.textLayerRenderTask = null;
this.highlighter = highlighter;
this.enhanceTextSelection = enhanceTextSelection;
this.accessibilityManager = accessibilityManager;
this._bindMouse();
}
@ -97,6 +102,7 @@ class TextLayerBuilder {
this.textDivs.length = 0;
this.highlighter?.setTextMapping(this.textDivs, this.textContentItemsStr);
this.accessibilityManager?.setTextMapping(this.textDivs);
const textLayerFrag = document.createDocumentFragment();
this.textLayerRenderTask = renderTextLayer({
@ -114,6 +120,7 @@ class TextLayerBuilder {
this.textLayerDiv.append(textLayerFrag);
this._finishRendering();
this.highlighter?.enable();
this.accessibilityManager?.enable();
},
function (reason) {
// Cancelled or failed to render text layer; skipping errors.
@ -130,6 +137,7 @@ class TextLayerBuilder {
this.textLayerRenderTask = null;
}
this.highlighter?.disable();
this.accessibilityManager?.disable();
}
setTextContentStream(readableStream) {