Make the link annotations correctly announced by screen readers (bug 1708041)

And focus the targeted page when the user clicks on a link.
This commit is contained in:
Calixte Denizet 2025-07-15 21:11:05 +02:00
parent bfc7fc4da9
commit f695e0ca62
7 changed files with 111 additions and 14 deletions

View File

@ -3833,6 +3833,10 @@ class LinkAnnotation extends Annotation {
docAttachments: annotationGlobals.attachments,
});
}
get overlaysTextContent() {
return true;
}
}
class PopupAnnotation extends Annotation {

View File

@ -24,7 +24,7 @@ class SingleIntersector {
#maxY = -Infinity;
#quadPoints;
#quadPoints = null;
#text = [];
@ -36,7 +36,13 @@ class SingleIntersector {
constructor(annotation) {
this.#annotation = annotation;
const quadPoints = (this.#quadPoints = annotation.data.quadPoints);
const quadPoints = annotation.data.quadPoints;
if (!quadPoints) {
// If there are no quad points, we use the rectangle to determine the
// bounds of the annotation.
[this.#minX, this.#minY, this.#maxX, this.#maxY] = annotation.data.rect;
return;
}
for (let i = 0, ii = quadPoints.length; i < ii; i += 8) {
this.#minX = Math.min(this.#minX, quadPoints[i]);
@ -44,6 +50,9 @@ class SingleIntersector {
this.#minY = Math.min(this.#minY, quadPoints[i + 5]);
this.#maxY = Math.max(this.#maxY, quadPoints[i + 1]);
}
if (quadPoints.length > 8) {
this.#quadPoints = quadPoints;
}
}
overlaps(other) {
@ -73,7 +82,7 @@ class SingleIntersector {
}
const quadPoints = this.#quadPoints;
if (quadPoints.length === 8) {
if (!quadPoints) {
// We've only one quad, so if we intersect min/max bounds then we
// intersect the quad.
return true;
@ -150,7 +159,7 @@ class Intersector {
constructor(annotations) {
for (const annotation of annotations) {
if (!annotation.data.quadPoints) {
if (!annotation.data.quadPoints && !annotation.data.rect) {
continue;
}
const intersector = new SingleIntersector(annotation);

View File

@ -276,7 +276,10 @@ class AnnotationElement {
const container = document.createElement("section");
container.setAttribute("data-annotation-id", data.id);
if (!(this instanceof WidgetAnnotationElement)) {
if (
!(this instanceof WidgetAnnotationElement) &&
!(this instanceof LinkAnnotationElement)
) {
container.tabIndex = 0;
}
const { style } = container;
@ -797,16 +800,21 @@ class LinkAnnotationElement extends AnnotationElement {
linkService.addLinkAttributes(link, data.url, data.newWindow);
isBound = true;
} else if (data.action) {
this._bindNamedAction(link, data.action);
this._bindNamedAction(link, data.action, data.overlaidText);
isBound = true;
} else if (data.attachment) {
this.#bindAttachment(link, data.attachment, data.attachmentDest);
this.#bindAttachment(
link,
data.attachment,
data.overlaidText,
data.attachmentDest
);
isBound = true;
} else if (data.setOCGState) {
this.#bindSetOCGState(link, data.setOCGState);
this.#bindSetOCGState(link, data.setOCGState, data.overlaidText);
isBound = true;
} else if (data.dest) {
this._bindLink(link, data.dest);
this._bindLink(link, data.dest, data.overlaidText);
isBound = true;
} else {
if (
@ -848,9 +856,10 @@ class LinkAnnotationElement extends AnnotationElement {
* @private
* @param {Object} link
* @param {Object} destination
* @param {string} [overlaidText]
* @memberof LinkAnnotationElement
*/
_bindLink(link, destination) {
_bindLink(link, destination, overlaidText = "") {
link.href = this.linkService.getDestinationHash(destination);
link.onclick = () => {
if (destination) {
@ -861,6 +870,9 @@ class LinkAnnotationElement extends AnnotationElement {
if (destination || destination === /* isTooltipOnly = */ "") {
this.#setInternalLink();
}
if (overlaidText) {
link.title = overlaidText;
}
}
/**
@ -869,14 +881,18 @@ class LinkAnnotationElement extends AnnotationElement {
* @private
* @param {Object} link
* @param {Object} action
* @param {string} [overlaidText]
* @memberof LinkAnnotationElement
*/
_bindNamedAction(link, action) {
_bindNamedAction(link, action, overlaidText = "") {
link.href = this.linkService.getAnchorUrl("");
link.onclick = () => {
this.linkService.executeNamedAction(action);
return false;
};
if (overlaidText) {
link.title = overlaidText;
}
this.#setInternalLink();
}
@ -884,12 +900,15 @@ class LinkAnnotationElement extends AnnotationElement {
* Bind attachments to the link element.
* @param {Object} link
* @param {Object} attachment
* @param {str} [dest]
* @param {string} [overlaidText]
* @param {string} [dest]
*/
#bindAttachment(link, attachment, dest = null) {
#bindAttachment(link, attachment, overlaidText = "", dest = null) {
link.href = this.linkService.getAnchorUrl("");
if (attachment.description) {
link.title = attachment.description;
} else if (overlaidText) {
link.title = overlaidText;
}
link.onclick = () => {
this.downloadManager?.openOrDownloadData(
@ -906,13 +925,17 @@ class LinkAnnotationElement extends AnnotationElement {
* Bind SetOCGState actions to the link element.
* @param {Object} link
* @param {Object} action
* @param {string} [overlaidText]
*/
#bindSetOCGState(link, action) {
#bindSetOCGState(link, action, overlaidText = "") {
link.href = this.linkService.getAnchorUrl("");
link.onclick = () => {
this.linkService.executeSetOCGState(action);
return false;
};
if (overlaidText) {
link.title = overlaidText;
}
this.#setInternalLink();
}
@ -947,6 +970,9 @@ class LinkAnnotationElement extends AnnotationElement {
return false;
};
}
if (data.overlaidText) {
link.title = data.overlaidText;
}
if (!link.onclick) {
link.onclick = () => false;

View File

@ -247,6 +247,51 @@ describe("Text widget", () => {
});
});
describe("Link annotations with internal destinations", () => {
describe("bug1708041.pdf", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"bug1708041.pdf",
".page[data-page-number='1'] .annotationLayer"
);
});
afterEach(async () => {
await closePages(pages);
});
it("must click on a link and check if it navigates to the correct page", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const pageOneSelector = ".page[data-page-number='1']";
const linkSelector = `${pageOneSelector} #pdfjs_internal_id_42R`;
await page.waitForSelector(linkSelector);
const linkTitle = await page.$eval(linkSelector, el => el.title);
expect(linkTitle)
.withContext(`In ${browserName}`)
.toEqual("Go to the last page");
await page.click(linkSelector);
const pageSixTextLayerSelector =
".page[data-page-number='6'] .textLayer";
await page.waitForSelector(pageSixTextLayerSelector, {
visible: true,
});
await page.waitForFunction(
sel => {
const textLayer = document.querySelector(sel);
return document.activeElement === textLayer;
},
{},
pageSixTextLayerSelector
);
})
);
});
});
});
describe("Annotation and storage", () => {
describe("issue14023.pdf", () => {
let pages;

View File

@ -734,3 +734,4 @@
!issue20062.pdf
!issue20102.pdf
!issue20065.pdf
!bug1708041.pdf

BIN
test/pdfs/bug1708041.pdf Normal file

Binary file not shown.

View File

@ -192,6 +192,18 @@ class PDFLinkService {
destArray: explicitDest,
ignoreDestinationZoom: this._ignoreDestinationZoom,
});
const ac = new AbortController();
this.eventBus._on(
"textlayerrendered",
evt => {
if (evt.pageNumber === pageNumber) {
evt.source.textLayer.div.focus();
ac.abort();
}
},
{ signal: ac.signal }
);
}
/**