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, docAttachments: annotationGlobals.attachments,
}); });
} }
get overlaysTextContent() {
return true;
}
} }
class PopupAnnotation extends Annotation { class PopupAnnotation extends Annotation {

View File

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

View File

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

View File

@ -734,3 +734,4 @@
!issue20062.pdf !issue20062.pdf
!issue20102.pdf !issue20102.pdf
!issue20065.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, destArray: explicitDest,
ignoreDestinationZoom: this._ignoreDestinationZoom, 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 }
);
} }
/** /**