diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index a0b4139102c35..5b8eff0254350 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -22,6 +22,8 @@ /** @typedef {import("../../web/interfaces").IPDFLinkService} IPDFLinkService */ // eslint-disable-next-line max-len /** @typedef {import("../src/display/editor/tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */ +// eslint-disable-next-line max-len +/** @typedef {import("../../web/struct_tree_layer_builder.js").StructTreeLayerBuilder} StructTreeLayerBuilder */ import { AnnotationBorderStyleType, @@ -2963,6 +2965,7 @@ class StampAnnotationElement extends AnnotationElement { render() { this.container.classList.add("stampAnnotation"); + this.container.setAttribute("role", "img"); if (!this.data.popupRef && this.hasPopupData) { this._createPopup(); @@ -3070,6 +3073,7 @@ class FileAttachmentAnnotationElement extends AnnotationElement { * @property {Map} [annotationCanvasMap] * @property {TextAccessibilityManager} [accessibilityManager] * @property {AnnotationEditorUIManager} [annotationEditorUIManager] + * @property {StructTreeLayerBuilder} [structTreeLayer] */ /** @@ -3082,6 +3086,8 @@ class AnnotationLayer { #editableAnnotations = new Map(); + #structTreeLayer = null; + constructor({ div, accessibilityManager, @@ -3089,10 +3095,12 @@ class AnnotationLayer { annotationEditorUIManager, page, viewport, + structTreeLayer, }) { this.div = div; this.#accessibilityManager = accessibilityManager; this.#annotationCanvasMap = annotationCanvasMap; + this.#structTreeLayer = structTreeLayer || null; this.page = page; this.viewport = viewport; this.zIndex = 0; @@ -3115,9 +3123,16 @@ class AnnotationLayer { return this.#editableAnnotations.size > 0; } - #appendElement(element, id) { + async #appendElement(element, id) { const contentElement = element.firstChild || element; - contentElement.id = `${AnnotationPrefix}${id}`; + const annotationId = (contentElement.id = `${AnnotationPrefix}${id}`); + const ariaAttributes = + await this.#structTreeLayer?.getAriaAttributes(annotationId); + if (ariaAttributes) { + for (const [key, value] of ariaAttributes) { + contentElement.setAttribute(key, value); + } + } this.div.append(element); this.#accessibilityManager?.moveElementInDOM( @@ -3194,7 +3209,7 @@ class AnnotationLayer { if (data.hidden) { rendered.style.visibility = "hidden"; } - this.#appendElement(rendered, data.id); + await this.#appendElement(rendered, data.id); if (element._isEditable) { this.#editableAnnotations.set(element.data.id, element); diff --git a/test/integration/accessibility_spec.mjs b/test/integration/accessibility_spec.mjs index b628a4283f8dc..ed7414ebb0db6 100644 --- a/test/integration/accessibility_spec.mjs +++ b/test/integration/accessibility_spec.mjs @@ -13,7 +13,26 @@ * limitations under the License. */ -import { closePages, loadAndWait } from "./test_utils.mjs"; +import { + awaitPromise, + closePages, + loadAndWait, + waitForPageRendered, +} from "./test_utils.mjs"; + +const isStructTreeVisible = async page => { + await page.waitForSelector(".structTree"); + return page.evaluate(() => { + let elem = document.querySelector(".structTree"); + while (elem) { + if (elem.getAttribute("aria-hidden") === "true") { + return false; + } + elem = elem.parentElement; + } + return true; + }); +}; describe("accessibility", () => { describe("structure tree", () => { @@ -30,19 +49,9 @@ describe("accessibility", () => { it("must build structure that maps to text layer", async () => { await Promise.all( pages.map(async ([browserName, page]) => { - await page.waitForSelector(".structTree"); - const isVisible = await page.evaluate(() => { - let elem = document.querySelector(".structTree"); - while (elem) { - if (elem.getAttribute("aria-hidden") === "true") { - return false; - } - elem = elem.parentElement; - } - return true; - }); - - expect(isVisible).withContext(`In ${browserName}`).toBeTrue(); + expect(await isStructTreeVisible(page)) + .withContext(`In ${browserName}`) + .toBeTrue(); // Check the headings match up. const head1 = await page.$eval( @@ -77,6 +86,22 @@ describe("accessibility", () => { }) ); }); + + it("must check that the struct tree is still there after zooming", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + for (let i = 0; i < 8; i++) { + expect(await isStructTreeVisible(page)) + .withContext(`In ${browserName}`) + .toBeTrue(); + + const handle = await waitForPageRendered(page); + await page.click(`#zoom${i < 4 ? "In" : "Out"}`); + await awaitPromise(handle); + } + }) + ); + }); }); describe("Annotation", () => { @@ -184,10 +209,10 @@ describe("accessibility", () => { it("must check the aria-label linked to the stamp annotation", async () => { await Promise.all( pages.map(async ([browserName, page]) => { - await page.waitForSelector(".structTree"); + await page.waitForSelector(".annotationLayer"); const ariaLabel = await page.$eval( - ".structTree [role='figure']", + ".annotationLayer section[role='img']", el => el.getAttribute("aria-label") ); expect(ariaLabel) diff --git a/web/annotation_layer_builder.js b/web/annotation_layer_builder.js index 2b56d506cd86f..4961d6a4bd148 100644 --- a/web/annotation_layer_builder.js +++ b/web/annotation_layer_builder.js @@ -92,11 +92,12 @@ class AnnotationLayerBuilder { /** * @param {PageViewport} viewport + * @param {Object} options * @param {string} intent (default value is 'display') * @returns {Promise} A promise that is resolved when rendering of the * annotations is complete. */ - async render(viewport, intent = "display") { + async render(viewport, options, intent = "display") { if (this.div) { if (this._cancelled || !this.annotationLayer) { return; @@ -136,6 +137,7 @@ class AnnotationLayerBuilder { annotationEditorUIManager: this._annotationEditorUIManager, page: this.pdfPage, viewport: viewport.clone({ dontFlip: true }), + structTreeLayer: options?.structTreeLayer || null, }); await this.annotationLayer.render({ diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index bfe3b476354f5..3193e3785440e 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -383,7 +383,11 @@ class PDFPageView { async #renderAnnotationLayer() { let error = null; try { - await this.annotationLayer.render(this.viewport, "display"); + await this.annotationLayer.render( + this.viewport, + { structTreeLayer: this.structTreeLayer }, + "display" + ); } catch (ex) { console.error(`#renderAnnotationLayer: "${ex}".`); error = ex; @@ -468,16 +472,12 @@ class PDFPageView { if (!this.textLayer) { return; } - this.structTreeLayer ||= new StructTreeLayerBuilder(); - const tree = await (!this.structTreeLayer.renderingDone - ? this.pdfPage.getStructTree() - : null); - const treeDom = this.structTreeLayer?.render(tree); - if (treeDom) { + const treeDom = await this.structTreeLayer?.render(); + if (treeDom && this.canvas && treeDom.parentNode !== this.canvas) { // Pause translation when inserting the structTree in the DOM. this.l10n.pause(); - this.canvas?.append(treeDom); + this.canvas.append(treeDom); this.l10n.resume(); } this.structTreeLayer?.show(); @@ -760,9 +760,6 @@ class PDFPageView { this.textLayer.cancel(); this.textLayer = null; } - if (this.structTreeLayer && !this.textLayer) { - this.structTreeLayer = null; - } if ( this.annotationLayer && (!keepAnnotationLayer || !this.annotationLayer.div) @@ -771,6 +768,9 @@ class PDFPageView { this.annotationLayer = null; this._annotationCanvasMap = null; } + if (this.structTreeLayer && !(this.textLayer || this.annotationLayer)) { + this.structTreeLayer = null; + } if ( this.annotationEditorLayer && (!keepAnnotationEditorLayer || !this.annotationEditorLayer.div) @@ -1067,6 +1067,10 @@ class PDFPageView { showCanvas?.(true); await this.#finishRenderTask(renderTask); + if (this.textLayer || this.annotationLayer) { + this.structTreeLayer ||= new StructTreeLayerBuilder(pdfPage); + } + this.#renderTextLayer(); if (this.annotationLayer) { diff --git a/web/struct_tree_layer_builder.js b/web/struct_tree_layer_builder.js index 5ede61cab1183..19de6f72025d8 100644 --- a/web/struct_tree_layer_builder.js +++ b/web/struct_tree_layer_builder.js @@ -74,19 +74,29 @@ const PDF_ROLE_TO_HTML_ROLE = { const HEADING_PATTERN = /^H(\d+)$/; class StructTreeLayerBuilder { + #promise; + #treeDom = undefined; - get renderingDone() { - return this.#treeDom !== undefined; + #elementAttributes = new Map(); + + constructor(pdfPage) { + this.#promise = pdfPage.getStructTree(); } - render(structTree) { + async render() { if (this.#treeDom !== undefined) { return this.#treeDom; } - const treeDom = this.#walk(structTree); + const treeDom = (this.#treeDom = this.#walk(await this.#promise)); + this.#promise = null; treeDom?.classList.add("structTree"); - return (this.#treeDom = treeDom); + return treeDom; + } + + async getAriaAttributes(annotationId) { + await this.render(); + return this.#elementAttributes.get(annotationId); } hide() { @@ -104,7 +114,24 @@ class StructTreeLayerBuilder { #setAttributes(structElement, htmlElement) { const { alt, id, lang } = structElement; if (alt !== undefined) { - htmlElement.setAttribute("aria-label", removeNullCharacters(alt)); + // Don't add the label in the struct tree layer but on the annotation + // in the annotation layer. + let added = false; + const label = removeNullCharacters(alt); + for (const child of structElement.children) { + if (child.type === "annotation") { + let attrs = this.#elementAttributes.get(child.id); + if (!attrs) { + attrs = new Map(); + this.#elementAttributes.set(child.id, attrs); + } + attrs.set("aria-label", label); + added = true; + } + } + if (!added) { + htmlElement.setAttribute("aria-label", label); + } } if (id !== undefined) { htmlElement.setAttribute("aria-owns", id);