diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index c5ba9a66204ee..db9969fed7933 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -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 + ); } } } diff --git a/src/display/display_utils.js b/src/display/display_utils.js index dff0a83c606a5..255d1d12ecc5c 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -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, diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index 4f310795631df..a2f6f64373ed8 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -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); } diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index e3bbe5a6695b1..368aa2da956d0 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -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} diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index cce09e3ed9833..060a6380f2fbc 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -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) { diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js index 10bffab45de59..09a54cfb8db34 100644 --- a/src/display/editor/ink.js +++ b/src/display/editor/ink.js @@ -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(); diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index 21955b292f4ee..bc75a0a710c7a 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -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(); } diff --git a/test/integration-boot.js b/test/integration-boot.js index 921511d9885c8..1c6f37f21ae01 100644 --- a/test/integration-boot.js +++ b/test/integration-boot.js @@ -32,6 +32,7 @@ async function runTests(results) { "find_spec.js", "freetext_editor_spec.js", "ink_editor_spec.js", + "a11y_spec.js", ], }); diff --git a/test/integration/accessibility_spec.js b/test/integration/accessibility_spec.js index 5db2f98dfea30..8ca890aae7819 100644 --- a/test/integration/accessibility_spec.js +++ b/test/integration/accessibility_spec.js @@ -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"]); + }) + ); + }); + }); }); diff --git a/test/integration/freetext_editor_spec.js b/test/integration/freetext_editor_spec.js index e66ee725815e9..db3688b736c11 100644 --- a/test/integration/freetext_editor_spec.js +++ b/test/integration/freetext_editor_spec.js @@ -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, diff --git a/test/integration/test_utils.js b/test/integration/test_utils.js index cba64a815783c..fa6443d8a2f73 100644 --- a/test/integration/test_utils.js +++ b/test/integration/test_utils.js @@ -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; diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 1b81cc0b25f54..6df734f409840 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -534,3 +534,4 @@ !bug1675139.pdf !issue15092.pdf !bug1782186.pdf +!tracemonkey_a11y.pdf diff --git a/test/pdfs/tracemonkey_a11y.pdf b/test/pdfs/tracemonkey_a11y.pdf new file mode 100644 index 0000000000000..abbd6d36ec614 Binary files /dev/null and b/test/pdfs/tracemonkey_a11y.pdf differ diff --git a/web/annotation_editor_layer_builder.js b/web/annotation_editor_layer_builder.js index e8cfbcee97495..836f0ba53ca13 100644 --- a/web/annotation_editor_layer_builder.js +++ b/web/annotation_editor_layer_builder.js @@ -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, diff --git a/web/annotation_layer_builder.css b/web/annotation_layer_builder.css index 2b54f5f54df86..75db1dc2bfd52 100644 --- a/web/annotation_layer_builder.css +++ b/web/annotation_layer_builder.css @@ -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)) diff --git a/web/annotation_layer_builder.js b/web/annotation_layer_builder.js index aa1d9ac4ba53e..25dd7002143d3 100644 --- a/web/annotation_layer_builder.js +++ b/web/annotation_layer_builder.js @@ -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} [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) { diff --git a/web/base_viewer.js b/web/base_viewer.js index 7fdb5c728a0be..e1541235fcb92 100644 --- a/web/base_viewer.js +++ b/web/base_viewer.js @@ -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} [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, }); } diff --git a/web/debugger.css b/web/debugger.css index 426342807d482..4de0814ab49b0 100644 --- a/web/debugger.css +++ b/web/debugger.css @@ -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); diff --git a/web/default_factory.js b/web/default_factory.js index 1908154a95bf9..b4b09ef524b7c 100644 --- a/web/default_factory.js +++ b/web/default_factory.js @@ -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} [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, }); } } diff --git a/web/interfaces.js b/web/interfaces.js index c9536c84a293f..71d9232cc2904 100644 --- a/web/interfaces.js +++ b/web/interfaces.js @@ -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} [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, }) {} } diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 842ce4a0c2201..8ed540718cdf6 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -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(); diff --git a/web/text_accessibility.js b/web/text_accessibility.js new file mode 100644 index 0000000000000..f4f52c93c6b73 --- /dev/null +++ b/web/text_accessibility.js @@ -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 }; diff --git a/web/text_layer_builder.js b/web/text_layer_builder.js index 69982b7a91873..592dcd55cba96 100644 --- a/web/text_layer_builder.js +++ b/web/text_layer_builder.js @@ -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) {