From ef01d55c96c75900bbffea879c6c1ee7a3be0fed Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Wed, 12 Aug 2026 11:30:30 +0200 Subject: [PATCH 1/2] Let link annotations own their text A tagged Link and its annotation otherwise expose duplicate link semantics. For a Link with one annotation and text-only content, let the annotation own the text spans. Keep the structure-tree fallback until that succeeds. --- src/display/annotation_layer.js | 11 +++++- test/unit/struct_tree_layer_builder_spec.js | 43 +++++++++++++++++++++ web/struct_tree_layer_builder.js | 42 +++++++++++++++++++- 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 9d43c57e24a3f..1fdf249689ba2 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -4136,12 +4136,19 @@ class AnnotationLayer { this.#hasAriaAttributesFromStructTree = true; for (const { contentElement, - data: { id }, + data: { hidden, id, oc }, } of this.#elements) { const annotationId = (contentElement.id = `${AnnotationPrefix}${id}`); + // An unbound link has no , hidden links aren't exposed, and + // optional-content visibility can change after this one-time setup. + // Keep the structure-tree Link fallback in all three cases; a visible + // optional-content link can therefore remain duplicated, matching the + // pre-existing behavior. + const enableLinkOwnership = + contentElement.localName === "a" && !hidden && !oc; promises.push( this.#structTreeLayer - ?.getAriaAttributes(annotationId) + ?.getAriaAttributes(annotationId, { enableLinkOwnership }) .then(ariaAttributes => { if (ariaAttributes) { for (const [key, value] of ariaAttributes) { diff --git a/test/unit/struct_tree_layer_builder_spec.js b/test/unit/struct_tree_layer_builder_spec.js index dc19d0fe6aa0a..70a58c36ac77c 100644 --- a/test/unit/struct_tree_layer_builder_spec.js +++ b/test/unit/struct_tree_layer_builder_spec.js @@ -360,6 +360,49 @@ describe("StructTreeLayerBuilder", function () { expect(row.id).toEqual(""); }); + it("only lets an eligible link annotation own its tagged text", async function () { + const annotationId = "pdfjs_internal_id_1R"; + const builder = build({ + role: "Root", + children: [ + { + role: "Link", + children: [ + { type: "content", id: "p1R_mc1" }, + { + role: "Lbl", + // BBox is layout-only. + bbox: [0, 0, 10, 10], + children: [{ type: "content", id: "p1R_mc2" }], + }, + { type: "annotation", id: annotationId }, + ], + }, + ], + }); + const tree = await builder.render(); + const link = tree.firstElementChild; + expect(link.getAttribute("role")).toEqual("link"); + const [text, label, annotation] = link.children; + expect(await builder.getAnnotationIds()).toEqual(new Set([annotationId])); + expect([text.id, label.id]).not.toContain(""); + expect(annotation.getAttribute("aria-owns")).toEqual(annotationId); + + // Merely asking for the annotation attributes keeps the fallback. + expect(await builder.getAriaAttributes(annotationId)).toBeUndefined(); + expect(link.getAttribute("role")).toEqual("link"); + + const attributes = await builder.getAriaAttributes(annotationId, { + enableLinkOwnership: true, + }); + + expect(attributes).toEqual( + new Map([["aria-owns", `${text.id} ${label.id}`]]) + ); + expect(link.getAttribute("role")).toBeNull(); + expect(link.childElementCount).toEqual(3); + }); + it("keeps structured annotations in the structure tree", async function () { const builder = build({ role: "Root", diff --git a/web/struct_tree_layer_builder.js b/web/struct_tree_layer_builder.js index 1db99a9c1cd1b..d6c8bb2a3205a 100644 --- a/web/struct_tree_layer_builder.js +++ b/web/struct_tree_layer_builder.js @@ -197,6 +197,10 @@ class StructTreeLayerBuilder { #elementAttributes = new Map(); + #pendingLinkOwnership = new Map(); + + #linkTextId = 0; + #structElementIdPrefix = `pdfjs_internal_struct_${getUuid()}_`; #structElementIds = new Map(); @@ -245,9 +249,26 @@ class StructTreeLayerBuilder { return promise; } - async getAriaAttributes(annotationId) { + /** + * @param {string} annotationId + * @param {Object} [options] + * @param {boolean} [options.enableLinkOwnership] + * @returns {Promise|null|undefined>} + */ + async getAriaAttributes(annotationId, { enableLinkOwnership = false } = {}) { try { await this.render(); + const ownership = this.#pendingLinkOwnership.get(annotationId); + if (ownership && enableLinkOwnership) { + const { element, ids } = ownership; + element.removeAttribute("role"); + if (ids.length > 0) { + this.#elementAttributes + .getOrInsertComputed(annotationId, makeMap) + .set("aria-owns", ids.join(" ")); + } + this.#pendingLinkOwnership.delete(annotationId); + } return this.#elementAttributes.get(annotationId); } catch { // If the structTree cannot be fetched, parsed, and/or rendered, @@ -639,6 +660,25 @@ class StructTreeLayerBuilder { parentNodes.pop(); } } + if (node.role === "Link") { + const annotations = node.children?.filter( + child => child.type === "annotation" + ); + if (annotations?.length === 1) { + const annotation = annotations[0]; + const ids = []; + for (const child of element.children) { + if (child.getAttribute("aria-owns") === annotation.id) { + continue; + } + child.id ||= `${this.#structElementIdPrefix}link_${this.#linkTextId++}`; + ids.push(child.id); + } + // Keep the structure-tree Link as a fallback until a stable, visible + // link annotation explicitly takes ownership of these children. + this.#pendingLinkOwnership.set(annotation.id, { element, ids }); + } + } return element; } } From 40c739b410e66dfe91ce4921ea1fc026219b49bb Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Mon, 17 Aug 2026 17:04:55 +0200 Subject: [PATCH 2/2] Use `Uint8Array.fromHex` in the `CFFParser` unit-tests setup --- test/unit/cff_parser_spec.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/unit/cff_parser_spec.js b/test/unit/cff_parser_spec.js index b46096d110759..39043b1754f8e 100644 --- a/test/unit/cff_parser_spec.js +++ b/test/unit/cff_parser_spec.js @@ -60,11 +60,7 @@ describe("CFFParser", function () { "8b06f79a93fc7c8c077d99f85695f75e" + "9908fb6e8cf87393f7108b09a70adf0b" + "f78e14"; - const fontArr = []; - for (let i = 0, ii = exampleFont.length; i < ii; i += 2) { - const hex = exampleFont.substring(i, i + 2); - fontArr.push(parseInt(hex, 16)); - } + const fontArr = Uint8Array.fromHex(exampleFont); fontData = new Stream(fontArr); });