[Editor] Make the stamp annotations alt text readable by either VO or NVDA (bug 1912001)
This commit is contained in:
parent
0676ea19cf
commit
deedbd1c4f
@ -22,6 +22,8 @@
|
|||||||
/** @typedef {import("../../web/interfaces").IPDFLinkService} IPDFLinkService */
|
/** @typedef {import("../../web/interfaces").IPDFLinkService} IPDFLinkService */
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
/** @typedef {import("../src/display/editor/tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */
|
/** @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 {
|
import {
|
||||||
AnnotationBorderStyleType,
|
AnnotationBorderStyleType,
|
||||||
@ -2952,6 +2954,7 @@ class StampAnnotationElement extends AnnotationElement {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
this.container.classList.add("stampAnnotation");
|
this.container.classList.add("stampAnnotation");
|
||||||
|
this.container.setAttribute("role", "img");
|
||||||
|
|
||||||
if (!this.data.popupRef && this.hasPopupData) {
|
if (!this.data.popupRef && this.hasPopupData) {
|
||||||
this._createPopup();
|
this._createPopup();
|
||||||
@ -3059,6 +3062,7 @@ class FileAttachmentAnnotationElement extends AnnotationElement {
|
|||||||
* @property {Map<string, HTMLCanvasElement>} [annotationCanvasMap]
|
* @property {Map<string, HTMLCanvasElement>} [annotationCanvasMap]
|
||||||
* @property {TextAccessibilityManager} [accessibilityManager]
|
* @property {TextAccessibilityManager} [accessibilityManager]
|
||||||
* @property {AnnotationEditorUIManager} [annotationEditorUIManager]
|
* @property {AnnotationEditorUIManager} [annotationEditorUIManager]
|
||||||
|
* @property {StructTreeLayerBuilder} [structTreeLayer]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -3071,6 +3075,8 @@ class AnnotationLayer {
|
|||||||
|
|
||||||
#editableAnnotations = new Map();
|
#editableAnnotations = new Map();
|
||||||
|
|
||||||
|
#structTreeLayer = null;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
div,
|
div,
|
||||||
accessibilityManager,
|
accessibilityManager,
|
||||||
@ -3078,10 +3084,12 @@ class AnnotationLayer {
|
|||||||
annotationEditorUIManager,
|
annotationEditorUIManager,
|
||||||
page,
|
page,
|
||||||
viewport,
|
viewport,
|
||||||
|
structTreeLayer,
|
||||||
}) {
|
}) {
|
||||||
this.div = div;
|
this.div = div;
|
||||||
this.#accessibilityManager = accessibilityManager;
|
this.#accessibilityManager = accessibilityManager;
|
||||||
this.#annotationCanvasMap = annotationCanvasMap;
|
this.#annotationCanvasMap = annotationCanvasMap;
|
||||||
|
this.#structTreeLayer = structTreeLayer || null;
|
||||||
this.page = page;
|
this.page = page;
|
||||||
this.viewport = viewport;
|
this.viewport = viewport;
|
||||||
this.zIndex = 0;
|
this.zIndex = 0;
|
||||||
@ -3104,9 +3112,16 @@ class AnnotationLayer {
|
|||||||
return this.#editableAnnotations.size > 0;
|
return this.#editableAnnotations.size > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#appendElement(element, id) {
|
async #appendElement(element, id) {
|
||||||
const contentElement = element.firstChild || element;
|
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.div.append(element);
|
||||||
this.#accessibilityManager?.moveElementInDOM(
|
this.#accessibilityManager?.moveElementInDOM(
|
||||||
@ -3183,7 +3198,7 @@ class AnnotationLayer {
|
|||||||
if (data.hidden) {
|
if (data.hidden) {
|
||||||
rendered.style.visibility = "hidden";
|
rendered.style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
this.#appendElement(rendered, data.id);
|
await this.#appendElement(rendered, data.id);
|
||||||
|
|
||||||
if (element._isEditable) {
|
if (element._isEditable) {
|
||||||
this.#editableAnnotations.set(element.data.id, element);
|
this.#editableAnnotations.set(element.data.id, element);
|
||||||
|
|||||||
@ -13,7 +13,26 @@
|
|||||||
* limitations under the License.
|
* 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("accessibility", () => {
|
||||||
describe("structure tree", () => {
|
describe("structure tree", () => {
|
||||||
@ -30,19 +49,9 @@ describe("accessibility", () => {
|
|||||||
it("must build structure that maps to text layer", async () => {
|
it("must build structure that maps to text layer", async () => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
pages.map(async ([browserName, page]) => {
|
pages.map(async ([browserName, page]) => {
|
||||||
await page.waitForSelector(".structTree");
|
expect(await isStructTreeVisible(page))
|
||||||
const isVisible = await page.evaluate(() => {
|
.withContext(`In ${browserName}`)
|
||||||
let elem = document.querySelector(".structTree");
|
.toBeTrue();
|
||||||
while (elem) {
|
|
||||||
if (elem.getAttribute("aria-hidden") === "true") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
elem = elem.parentElement;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(isVisible).withContext(`In ${browserName}`).toBeTrue();
|
|
||||||
|
|
||||||
// Check the headings match up.
|
// Check the headings match up.
|
||||||
const head1 = await page.$eval(
|
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", () => {
|
describe("Annotation", () => {
|
||||||
@ -184,10 +209,10 @@ describe("accessibility", () => {
|
|||||||
it("must check the aria-label linked to the stamp annotation", async () => {
|
it("must check the aria-label linked to the stamp annotation", async () => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
pages.map(async ([browserName, page]) => {
|
pages.map(async ([browserName, page]) => {
|
||||||
await page.waitForSelector(".structTree");
|
await page.waitForSelector(".annotationLayer");
|
||||||
|
|
||||||
const ariaLabel = await page.$eval(
|
const ariaLabel = await page.$eval(
|
||||||
".structTree [role='figure']",
|
".annotationLayer section[role='img']",
|
||||||
el => el.getAttribute("aria-label")
|
el => el.getAttribute("aria-label")
|
||||||
);
|
);
|
||||||
expect(ariaLabel)
|
expect(ariaLabel)
|
||||||
|
|||||||
@ -92,11 +92,12 @@ class AnnotationLayerBuilder {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {PageViewport} viewport
|
* @param {PageViewport} viewport
|
||||||
|
* @param {Object} options
|
||||||
* @param {string} intent (default value is 'display')
|
* @param {string} intent (default value is 'display')
|
||||||
* @returns {Promise<void>} A promise that is resolved when rendering of the
|
* @returns {Promise<void>} A promise that is resolved when rendering of the
|
||||||
* annotations is complete.
|
* annotations is complete.
|
||||||
*/
|
*/
|
||||||
async render(viewport, intent = "display") {
|
async render(viewport, options, intent = "display") {
|
||||||
if (this.div) {
|
if (this.div) {
|
||||||
if (this._cancelled || !this.annotationLayer) {
|
if (this._cancelled || !this.annotationLayer) {
|
||||||
return;
|
return;
|
||||||
@ -136,6 +137,7 @@ class AnnotationLayerBuilder {
|
|||||||
annotationEditorUIManager: this._annotationEditorUIManager,
|
annotationEditorUIManager: this._annotationEditorUIManager,
|
||||||
page: this.pdfPage,
|
page: this.pdfPage,
|
||||||
viewport: viewport.clone({ dontFlip: true }),
|
viewport: viewport.clone({ dontFlip: true }),
|
||||||
|
structTreeLayer: options?.structTreeLayer || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.annotationLayer.render({
|
await this.annotationLayer.render({
|
||||||
|
|||||||
@ -383,7 +383,11 @@ class PDFPageView {
|
|||||||
async #renderAnnotationLayer() {
|
async #renderAnnotationLayer() {
|
||||||
let error = null;
|
let error = null;
|
||||||
try {
|
try {
|
||||||
await this.annotationLayer.render(this.viewport, "display");
|
await this.annotationLayer.render(
|
||||||
|
this.viewport,
|
||||||
|
{ structTreeLayer: this.structTreeLayer },
|
||||||
|
"display"
|
||||||
|
);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
console.error(`#renderAnnotationLayer: "${ex}".`);
|
console.error(`#renderAnnotationLayer: "${ex}".`);
|
||||||
error = ex;
|
error = ex;
|
||||||
@ -468,16 +472,12 @@ class PDFPageView {
|
|||||||
if (!this.textLayer) {
|
if (!this.textLayer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.structTreeLayer ||= new StructTreeLayerBuilder();
|
|
||||||
|
|
||||||
const tree = await (!this.structTreeLayer.renderingDone
|
const treeDom = await this.structTreeLayer?.render();
|
||||||
? this.pdfPage.getStructTree()
|
if (treeDom && this.canvas && treeDom.parentNode !== this.canvas) {
|
||||||
: null);
|
|
||||||
const treeDom = this.structTreeLayer?.render(tree);
|
|
||||||
if (treeDom) {
|
|
||||||
// Pause translation when inserting the structTree in the DOM.
|
// Pause translation when inserting the structTree in the DOM.
|
||||||
this.l10n.pause();
|
this.l10n.pause();
|
||||||
this.canvas?.append(treeDom);
|
this.canvas.append(treeDom);
|
||||||
this.l10n.resume();
|
this.l10n.resume();
|
||||||
}
|
}
|
||||||
this.structTreeLayer?.show();
|
this.structTreeLayer?.show();
|
||||||
@ -760,9 +760,6 @@ class PDFPageView {
|
|||||||
this.textLayer.cancel();
|
this.textLayer.cancel();
|
||||||
this.textLayer = null;
|
this.textLayer = null;
|
||||||
}
|
}
|
||||||
if (this.structTreeLayer && !this.textLayer) {
|
|
||||||
this.structTreeLayer = null;
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
this.annotationLayer &&
|
this.annotationLayer &&
|
||||||
(!keepAnnotationLayer || !this.annotationLayer.div)
|
(!keepAnnotationLayer || !this.annotationLayer.div)
|
||||||
@ -771,6 +768,9 @@ class PDFPageView {
|
|||||||
this.annotationLayer = null;
|
this.annotationLayer = null;
|
||||||
this._annotationCanvasMap = null;
|
this._annotationCanvasMap = null;
|
||||||
}
|
}
|
||||||
|
if (this.structTreeLayer && !(this.textLayer || this.annotationLayer)) {
|
||||||
|
this.structTreeLayer = null;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
this.annotationEditorLayer &&
|
this.annotationEditorLayer &&
|
||||||
(!keepAnnotationEditorLayer || !this.annotationEditorLayer.div)
|
(!keepAnnotationEditorLayer || !this.annotationEditorLayer.div)
|
||||||
@ -1067,6 +1067,10 @@ class PDFPageView {
|
|||||||
showCanvas?.(true);
|
showCanvas?.(true);
|
||||||
await this.#finishRenderTask(renderTask);
|
await this.#finishRenderTask(renderTask);
|
||||||
|
|
||||||
|
if (this.textLayer || this.annotationLayer) {
|
||||||
|
this.structTreeLayer ||= new StructTreeLayerBuilder(pdfPage);
|
||||||
|
}
|
||||||
|
|
||||||
this.#renderTextLayer();
|
this.#renderTextLayer();
|
||||||
|
|
||||||
if (this.annotationLayer) {
|
if (this.annotationLayer) {
|
||||||
|
|||||||
@ -74,19 +74,29 @@ const PDF_ROLE_TO_HTML_ROLE = {
|
|||||||
const HEADING_PATTERN = /^H(\d+)$/;
|
const HEADING_PATTERN = /^H(\d+)$/;
|
||||||
|
|
||||||
class StructTreeLayerBuilder {
|
class StructTreeLayerBuilder {
|
||||||
|
#promise;
|
||||||
|
|
||||||
#treeDom = undefined;
|
#treeDom = undefined;
|
||||||
|
|
||||||
get renderingDone() {
|
#elementAttributes = new Map();
|
||||||
return this.#treeDom !== undefined;
|
|
||||||
|
constructor(pdfPage) {
|
||||||
|
this.#promise = pdfPage.getStructTree();
|
||||||
}
|
}
|
||||||
|
|
||||||
render(structTree) {
|
async render() {
|
||||||
if (this.#treeDom !== undefined) {
|
if (this.#treeDom !== undefined) {
|
||||||
return this.#treeDom;
|
return this.#treeDom;
|
||||||
}
|
}
|
||||||
const treeDom = this.#walk(structTree);
|
const treeDom = (this.#treeDom = this.#walk(await this.#promise));
|
||||||
|
this.#promise = null;
|
||||||
treeDom?.classList.add("structTree");
|
treeDom?.classList.add("structTree");
|
||||||
return (this.#treeDom = treeDom);
|
return treeDom;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAriaAttributes(annotationId) {
|
||||||
|
await this.render();
|
||||||
|
return this.#elementAttributes.get(annotationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
@ -104,7 +114,24 @@ class StructTreeLayerBuilder {
|
|||||||
#setAttributes(structElement, htmlElement) {
|
#setAttributes(structElement, htmlElement) {
|
||||||
const { alt, id, lang } = structElement;
|
const { alt, id, lang } = structElement;
|
||||||
if (alt !== undefined) {
|
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) {
|
if (id !== undefined) {
|
||||||
htmlElement.setAttribute("aria-owns", id);
|
htmlElement.setAttribute("aria-owns", id);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user