Skip to content

Commit

Permalink
Make tagged images visible for screen readers (bug 1708040)
Browse files Browse the repository at this point in the history
The idea is to insert a span in the text layer with an aria-role set to img
and use the bounding box provided by the attribute field in the tag dict in
order to have non-null dimensions for the image to make it "visible".
  • Loading branch information
calixteman committed Sep 5, 2024
1 parent e3fd62d commit 40eaa66
Show file tree
Hide file tree
Showing 12 changed files with 221 additions and 6 deletions.
32 changes: 30 additions & 2 deletions src/core/struct_tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@

import { AnnotationPrefix, stringToPDFString, warn } from "../shared/util.js";
import { Dict, isName, Name, Ref, RefSetCache } from "./primitives.js";
import { lookupNormalRect, stringToAsciiOrUTF16BE } from "./core_utils.js";
import { NumberTree } from "./name_number_tree.js";
import { stringToAsciiOrUTF16BE } from "./core_utils.js";
import { writeObject } from "./writer.js";

const MAX_DEPTH = 40;
Expand Down Expand Up @@ -751,10 +751,38 @@ class StructTreePage {
obj.role = node.role;
obj.children = [];
parent.children.push(obj);
const alt = node.dict.get("Alt");
let alt = node.dict.get("Alt");
if (typeof alt !== "string") {
alt = node.dict.get("ActualText");
}
if (typeof alt === "string") {
obj.alt = stringToPDFString(alt);
}

const a = node.dict.get("A");
if (a instanceof Dict) {
const bbox = lookupNormalRect(a.getArray("BBox"), null);
if (bbox) {
obj.bbox = bbox;
} else {
const width = a.get("Width");
const height = a.get("Height");
if (
typeof width === "number" &&
width > 0 &&
typeof height === "number" &&
height > 0
) {
obj.bbox = [0, 0, width, height];
}
}
// TODO: If the bbox is not available, we should try to get it from
// the content stream.
// For example when rendering on the canvas the commands between the
// beginning and the end of the marked-content sequence, we can
// compute the overall bbox.
}

const lang = node.dict.get("Lang");
if (typeof lang === "string") {
obj.lang = stringToPDFString(lang);
Expand Down
5 changes: 3 additions & 2 deletions src/display/editor/annotation_editor_layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,8 @@ class AnnotationEditorLayer {
const { target } = event;
if (
target === this.#textLayer.div ||
(target.classList.contains("endOfContent") &&
((target.getAttribute("role") === "img" ||
target.classList.contains("endOfContent")) &&
this.#textLayer.div.contains(target))
) {
const { isMac } = FeatureTest.platform;
Expand All @@ -413,7 +414,7 @@ class AnnotationEditorLayer {
HighlightEditor.startHighlighting(
this,
this.#uiManager.direction === "ltr",
event
{ target: this.#textLayer.div, x: event.x, y: event.y }
);
this.#textLayer.div.addEventListener(
"pointerup",
Expand Down
40 changes: 40 additions & 0 deletions test/integration/accessibility_spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -241,4 +241,44 @@ describe("accessibility", () => {
);
});
});

describe("Figure in the content stream", () => {
let pages;

beforeAll(async () => {
pages = await loadAndWait("bug1708040.pdf", ".textLayer");
});

afterAll(async () => {
await closePages(pages);
});

it("must check that an image is correctly inserted in the text layer", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
expect(await isStructTreeVisible(page))
.withContext(`In ${browserName}`)
.toBeTrue();

const spanId = await page.evaluate(() => {
const el = document.querySelector(
`.structTree span[role="figure"]`
);
return el.getAttribute("aria-owns") || null;
});

expect(spanId).withContext(`In ${browserName}`).not.toBeNull();

const ariaLabel = await page.evaluate(id => {
const img = document.querySelector(`#${id} > span[role="img"]`);
return img.getAttribute("aria-label");
}, spanId);

expect(ariaLabel)
.withContext(`In ${browserName}`)
.toEqual("A logo of a fox and a globe");
})
);
});
});
});
47 changes: 47 additions & 0 deletions test/integration/highlight_editor_spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2020,4 +2020,51 @@ describe("Highlight Editor", () => {
);
});
});

describe("Free Highlight with an image in the struct tree", () => {
let pages;

beforeAll(async () => {
pages = await loadAndWait(
"bug1708040.pdf",
".annotationEditorLayer",
null,
null,
{ highlightEditorColors: "red=#AB0000" }
);
});

afterAll(async () => {
await closePages(pages);
});

it("must check that it's possible to draw on an image in a struct tree", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await switchToHighlight(page);

const rect = await getRect(page, `.textLayer span[role="img"]`);

const x = rect.x + rect.width / 2;
const y = rect.y + rect.height / 2;
const clickHandle = await waitForPointerUp(page);
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(rect.x - 1, rect.y - 1);
await page.mouse.up();
await awaitPromise(clickHandle);

await page.waitForSelector(getEditorSelector(0));
const usedColor = await page.evaluate(() => {
const highlight = document.querySelector(
`.page[data-page-number = "1"] .canvasWrapper > svg.highlight`
);
return highlight.getAttribute("fill");
});

expect(usedColor).withContext(`In ${browserName}`).toEqual("#AB0000");
})
);
});
});
});
1 change: 1 addition & 0 deletions test/pdfs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -664,3 +664,4 @@
!issue18561.pdf
!highlights.pdf
!highlight.pdf
!bug1708040.pdf
Binary file added test/pdfs/bug1708040.pdf
Binary file not shown.
2 changes: 2 additions & 0 deletions test/unit/api_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3807,11 +3807,13 @@ Caron Broadcasting, Inc., an Ohio corporation (“Lessee”).`)
role: "Figure",
children: [{ type: "content", id: "p406R_mc11" }],
alt: "d h c s logo",
bbox: [57.75, 676, 133.35, 752],
},
{
role: "Figure",
children: [{ type: "content", id: "p406R_mc1" }],
alt: "Great Seal of the State of California",
bbox: [481.5, 678, 544.5, 741],
},
{
role: "P",
Expand Down
44 changes: 44 additions & 0 deletions test/unit/struct_tree_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,48 @@ describe("struct tree", function () {
await loadingTask.destroy();
});
});

it("parses structure with a figure and its bounding box", async function () {
const filename = "bug1708040.pdf";
const params = buildGetDocumentParams(filename);
const loadingTask = getDocument(params);
const doc = await loadingTask.promise;
const page = await doc.getPage(1);
const struct = await page.getStructTree();
equalTrees(
{
children: [
{
role: "Document",
children: [
{
role: "Sect",
children: [
{
role: "P",
children: [{ type: "content", id: "p21R_mc0" }],
lang: "EN-US",
},
{
role: "P",
children: [{ type: "content", id: "p21R_mc1" }],
lang: "EN-US",
},
{
role: "Figure",
children: [{ type: "content", id: "p21R_mc2" }],
alt: "A logo of a fox and a globe\u0000",
bbox: [72, 287.782, 456, 695.032],
},
],
},
],
},
],
role: "Root",
},
struct
);
await loadingTask.destroy();
});
});
4 changes: 4 additions & 0 deletions web/annotation_editor_layer_builder.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@

&:not(.free) span {
cursor: var(--editorHighlight-editing-cursor);

&[role="img"] {
cursor: var(--editorFreeHighlight-editing-cursor);
}
}

&.free span {
Expand Down
5 changes: 4 additions & 1 deletion web/pdf_page_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -1068,7 +1068,10 @@ class PDFPageView {
await this.#finishRenderTask(renderTask);

if (this.textLayer || this.annotationLayer) {
this.structTreeLayer ||= new StructTreeLayerBuilder(pdfPage);
this.structTreeLayer ||= new StructTreeLayerBuilder(
pdfPage,
viewport.rawDims
);
}

this.#renderTextLayer();
Expand Down
42 changes: 41 additions & 1 deletion web/struct_tree_layer_builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,11 @@ class StructTreeLayerBuilder {

#elementAttributes = new Map();

constructor(pdfPage) {
#rawDims;

constructor(pdfPage, rawDims) {
this.#promise = pdfPage.getStructTree();
this.#rawDims = rawDims;
}

async render() {
Expand Down Expand Up @@ -156,6 +159,40 @@ class StructTreeLayerBuilder {
}
}

#addImageInTextLayer(node, element) {
const { alt, bbox, children } = node;
const child = children?.[0];
if (!this.#rawDims || !alt || !bbox || child?.type !== "content") {
return false;
}

const { id } = child;
if (!id) {
return false;
}
const span = document.getElementById(id);
if (!span) {
return false;
}

element.setAttribute("aria-owns", id);

const img = document.createElement("span");
span.append(img);
img.setAttribute("role", "img");
img.setAttribute("aria-label", removeNullCharacters(alt));

const { pageHeight, pageX, pageY } = this.#rawDims;
const calc = "calc(var(--scale-factor)*";
const { style } = img;
style.width = `${calc}${bbox[2] - bbox[0]}px)`;
style.height = `${calc}${bbox[3] - bbox[1]}px)`;
style.left = `${calc}${bbox[0] - pageX}px)`;
style.top = `${calc}${pageHeight - bbox[3] + pageY}px)`;

return true;
}

#walk(node) {
if (!node) {
return null;
Expand All @@ -171,6 +208,9 @@ class StructTreeLayerBuilder {
} else if (PDF_ROLE_TO_HTML_ROLE[role]) {
element.setAttribute("role", PDF_ROLE_TO_HTML_ROLE[role]);
}
if (role === "Figure" && this.#addImageInTextLayer(node, element)) {
return element;
}
}

this.#setAttributes(node, element);
Expand Down
5 changes: 5 additions & 0 deletions web/text_layer_builder.css
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@
}
/*#endif*/

span[role="img"] {
user-select: none;
cursor: default;
}

.highlight {
--highlight-bg-color: rgb(180 0 170 / 0.25);
--highlight-selected-bg-color: rgb(0 100 0 / 0.25);
Expand Down

0 comments on commit 40eaa66

Please sign in to comment.