Skip to content

Commit

Permalink
[Annotations] Add some aria-owns in the text layer to link to annotat…
Browse files Browse the repository at this point in the history
…ions (bug 1780375)

This patch doesn't structurally change the text layer: it just adds some aria-owns
attributes to some spans.
The aria-owns attribute expect to have an element id, hence it's why it adds back an
id on the element rendering an annotation, but this id is built in using crypto.randomUUID
to avoid any potential issues with the hash in the url.
The elements in the annotation layer are moved into the DOM in order to have them in the
same "order" as they visually are.
The overall goal is to help screen readers to present to the user the annotations as
they visually are and as they come in the text flow.
It is clearly not perfect, but it should improve readability for some people with visual
disabilities.
  • Loading branch information
calixteman committed Aug 4, 2022
1 parent e88c90e commit 38a0e2f
Show file tree
Hide file tree
Showing 22 changed files with 433 additions and 243 deletions.
22 changes: 21 additions & 1 deletion src/display/annotation_layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import {
DOMSVGFactory,
getFilenameFromUrl,
IdRandomPrefix,
PDFDateString,
} from "./display_utils.js";
import { AnnotationStorage } from "./annotation_storage.js";
Expand Down Expand Up @@ -2473,7 +2474,7 @@ class AnnotationLayer {
* @memberof AnnotationLayer
*/
static render(parameters) {
const { annotations, div, viewport } = parameters;
const { annotations, div, viewport, accessibilityManager } = parameters;

this.#setDimensions(div, viewport);

Expand Down Expand Up @@ -2525,15 +2526,34 @@ class AnnotationLayer {
}
if (Array.isArray(rendered)) {
for (const renderedElement of rendered) {
const contentElement =
renderedElement.firstChild || renderedElement;
contentElement.id = `${IdRandomPrefix}${data.id}`;

div.append(renderedElement);
accessibilityManager?.moveElementInDOM(
div,
rendered,
contentElement,
/* isRemovable = */ false
);
}
} else {
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 {
const contentElement = rendered.firstChild || rendered;
contentElement.id = `${IdRandomPrefix}${data.id}`;

div.append(rendered);
accessibilityManager?.moveElementInDOM(
div,
rendered,
contentElement,
/* isRemovable = */ false
);
}
}
}
Expand Down
14 changes: 14 additions & 0 deletions src/display/display_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ import { BaseException, stringToBytes, Util, warn } from "../shared/util.js";

const SVG_NS = "http://www.w3.org/2000/svg";

const IdRandomPrefix = (() => {
if (typeof globalThis.crypto !== "undefined") {
return `${globalThis.crypto.randomUUID()}-`;
}

const digits = new Array(36)
.fill(null)
.map(() => Math.floor(Math.random() * 16).toString(16));
digits[8] = digits[13] = digits[18] = digits[23] = "-";

return digits.join("");
})();

class PixelsPerInch {
static CSS = 96.0;

Expand Down Expand Up @@ -653,6 +666,7 @@ export {
getPdfFilenameFromUrl,
getRGB,
getXfaPageViewport,
IdRandomPrefix,
isDataScheme,
isPdfFile,
isValidFetchUrl,
Expand Down
208 changes: 20 additions & 188 deletions src/display/editor/annotation_editor_layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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
*/
Expand All @@ -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);
Expand All @@ -51,14 +55,8 @@ class AnnotationEditorLayer {

#isCleaningUp = false;

#textLayerMap = new WeakMap();

#textNodes = new Map();

#uiManager;

#waitingEditors = new Set();

static _initialized = false;

/**
Expand All @@ -76,43 +74,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
Expand Down Expand Up @@ -226,7 +192,7 @@ class AnnotationEditorLayer {

detach(editor) {
this.#editors.delete(editor.id);
this.removePointerInTextLayer(editor);
this.#accessibilityManager?.removePointerInTextLayer(editor.contentDiv);
}

/**
Expand Down Expand Up @@ -279,147 +245,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
Expand All @@ -435,11 +260,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
Expand Down Expand Up @@ -645,7 +479,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();
}

Expand All @@ -666,15 +500,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);
}

Expand Down
8 changes: 0 additions & 8 deletions src/display/editor/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading

0 comments on commit 38a0e2f

Please sign in to comment.