Add tagged annotations in the structure tree (bug 1850797)

This commit is contained in:
Calixte Denizet 2023-08-30 20:00:05 +02:00
parent 92f7653cfb
commit d185db2b70
10 changed files with 152 additions and 47 deletions

View File

@ -77,10 +77,11 @@ class AnnotationFactory {
* @param {PDFManager} pdfManager * @param {PDFManager} pdfManager
* @param {Object} idFactory * @param {Object} idFactory
* @param {boolean} collectFields * @param {boolean} collectFields
* @param {Object} [pageRef]
* @returns {Promise} A promise that is resolved with an {Annotation} * @returns {Promise} A promise that is resolved with an {Annotation}
* instance. * instance.
*/ */
static create(xref, ref, pdfManager, idFactory, collectFields) { static create(xref, ref, pdfManager, idFactory, collectFields, pageRef) {
return Promise.all([ return Promise.all([
pdfManager.ensureCatalog("acroForm"), pdfManager.ensureCatalog("acroForm"),
// Only necessary to prevent the `pdfManager.docBaseUrl`-getter, used // Only necessary to prevent the `pdfManager.docBaseUrl`-getter, used
@ -91,18 +92,29 @@ class AnnotationFactory {
pdfManager.ensureCatalog("attachments"), pdfManager.ensureCatalog("attachments"),
pdfManager.ensureDoc("xfaDatasets"), pdfManager.ensureDoc("xfaDatasets"),
collectFields ? this._getPageIndex(xref, ref, pdfManager) : -1, collectFields ? this._getPageIndex(xref, ref, pdfManager) : -1,
]).then(([acroForm, baseUrl, attachments, xfaDatasets, pageIndex]) => pageRef ? pdfManager.ensureCatalog("structTreeRoot") : null,
pdfManager.ensure(this, "_create", [ ]).then(
xref, ([
ref,
pdfManager,
idFactory,
acroForm, acroForm,
baseUrl,
attachments, attachments,
xfaDatasets, xfaDatasets,
collectFields,
pageIndex, pageIndex,
]) structTreeRoot,
]) =>
pdfManager.ensure(this, "_create", [
xref,
ref,
pdfManager,
idFactory,
acroForm,
attachments,
xfaDatasets,
collectFields,
pageIndex,
structTreeRoot,
pageRef,
])
); );
} }
@ -118,7 +130,9 @@ class AnnotationFactory {
attachments = null, attachments = null,
xfaDatasets, xfaDatasets,
collectFields, collectFields,
pageIndex = -1 pageIndex = -1,
structTreeRoot = null,
pageRef = null
) { ) {
const dict = xref.fetchIfRef(ref); const dict = xref.fetchIfRef(ref);
if (!(dict instanceof Dict)) { if (!(dict instanceof Dict)) {
@ -150,6 +164,8 @@ class AnnotationFactory {
!collectFields && acroFormDict.get("NeedAppearances") === true, !collectFields && acroFormDict.get("NeedAppearances") === true,
pageIndex, pageIndex,
evaluatorOptions: pdfManager.evaluatorOptions, evaluatorOptions: pdfManager.evaluatorOptions,
structTreeRoot,
pageRef,
}; };
switch (subtype) { switch (subtype) {
@ -594,6 +610,13 @@ class Annotation {
const isLocked = !!(this.flags & AnnotationFlag.LOCKED); const isLocked = !!(this.flags & AnnotationFlag.LOCKED);
const isContentLocked = !!(this.flags & AnnotationFlag.LOCKEDCONTENTS); const isContentLocked = !!(this.flags & AnnotationFlag.LOCKEDCONTENTS);
if (params.structTreeRoot) {
let structParent = dict.get("StructParent");
structParent =
Number.isInteger(structParent) && structParent >= 0 ? structParent : -1;
params.structTreeRoot.addAnnotationIdToPage(params.pageRef, structParent);
}
// Expose public properties using a data object. // Expose public properties using a data object.
this.data = { this.data = {
annotationFlags: this.flags, annotationFlags: this.flags,

View File

@ -651,6 +651,9 @@ class Page {
if (!structTreeRoot) { if (!structTreeRoot) {
return null; return null;
} }
// Ensure that the structTree will contain the page's annotations.
await this._parsedAnnotations;
const structTree = await this.pdfManager.ensure(this, "_parseStructTree", [ const structTree = await this.pdfManager.ensure(this, "_parseStructTree", [
structTreeRoot, structTreeRoot,
]); ]);
@ -662,7 +665,7 @@ class Page {
*/ */
_parseStructTree(structTreeRoot) { _parseStructTree(structTreeRoot) {
const tree = new StructTreePage(structTreeRoot, this.pageDict); const tree = new StructTreePage(structTreeRoot, this.pageDict);
tree.parse(); tree.parse(this.ref);
return tree; return tree;
} }
@ -740,7 +743,8 @@ class Page {
annotationRef, annotationRef,
this.pdfManager, this.pdfManager,
this._localIdFactory, this._localIdFactory,
/* collectFields */ false /* collectFields */ false,
this.ref
).catch(function (reason) { ).catch(function (reason) {
warn(`_parsedAnnotations: "${reason}".`); warn(`_parsedAnnotations: "${reason}".`);
return null; return null;
@ -1719,7 +1723,8 @@ class PDFDocument {
fieldRef, fieldRef,
this.pdfManager, this.pdfManager,
this._localIdFactory, this._localIdFactory,
/* collectFields */ true /* collectFields */ true,
/* pageRef */ null
) )
.then(annotation => annotation?.getFieldObject()) .then(annotation => annotation?.getFieldObject())
.catch(function (reason) { .catch(function (reason) {

View File

@ -13,29 +13,48 @@
* limitations under the License. * limitations under the License.
*/ */
import { Dict, isName, Name, Ref } from "./primitives.js"; import { AnnotationPrefix, stringToPDFString, warn } from "../shared/util.js";
import { stringToPDFString, warn } from "../shared/util.js"; import { Dict, isName, Name, Ref, RefSetCache } from "./primitives.js";
import { NumberTree } from "./name_number_tree.js"; import { NumberTree } from "./name_number_tree.js";
const MAX_DEPTH = 40; const MAX_DEPTH = 40;
const StructElementType = { const StructElementType = {
PAGE_CONTENT: "PAGE_CONTENT", PAGE_CONTENT: 1,
STREAM_CONTENT: "STREAM_CONTENT", STREAM_CONTENT: 2,
OBJECT: "OBJECT", OBJECT: 3,
ELEMENT: "ELEMENT", ANNOTATION: 4,
ELEMENT: 5,
}; };
class StructTreeRoot { class StructTreeRoot {
constructor(rootDict) { constructor(rootDict) {
this.dict = rootDict; this.dict = rootDict;
this.roleMap = new Map(); this.roleMap = new Map();
this.structParentIds = null;
} }
init() { init() {
this.readRoleMap(); this.readRoleMap();
} }
#addIdToPage(pageRef, id, type) {
if (!(pageRef instanceof Ref) || id < 0) {
return;
}
this.structParentIds ||= new RefSetCache();
let ids = this.structParentIds.get(pageRef);
if (!ids) {
ids = [];
this.structParentIds.put(pageRef, ids);
}
ids.push([id, type]);
}
addAnnotationIdToPage(pageRef, id) {
this.#addIdToPage(pageRef, id, StructElementType.ANNOTATION);
}
readRoleMap() { readRoleMap() {
const roleMapDict = this.dict.get("RoleMap"); const roleMapDict = this.dict.get("RoleMap");
if (!(roleMapDict instanceof Dict)) { if (!(roleMapDict instanceof Dict)) {
@ -129,12 +148,10 @@ class StructElementNode {
if (this.tree.pageDict.objId !== pageObjId) { if (this.tree.pageDict.objId !== pageObjId) {
return null; return null;
} }
const kidRef = kidDict.getRaw("Stm");
return new StructElement({ return new StructElement({
type: StructElementType.STREAM_CONTENT, type: StructElementType.STREAM_CONTENT,
refObjId: refObjId: kidRef instanceof Ref ? kidRef.toString() : null,
kidDict.getRaw("Stm") instanceof Ref
? kidDict.getRaw("Stm").toString()
: null,
pageObjId, pageObjId,
mcid: kidDict.get("MCID"), mcid: kidDict.get("MCID"),
}); });
@ -144,12 +161,10 @@ class StructElementNode {
if (this.tree.pageDict.objId !== pageObjId) { if (this.tree.pageDict.objId !== pageObjId) {
return null; return null;
} }
const kidRef = kidDict.getRaw("Obj");
return new StructElement({ return new StructElement({
type: StructElementType.OBJECT, type: StructElementType.OBJECT,
refObjId: refObjId: kidRef instanceof Ref ? kidRef.toString() : null,
kidDict.getRaw("Obj") instanceof Ref
? kidDict.getRaw("Obj").toString()
: null,
pageObjId, pageObjId,
}); });
} }
@ -186,7 +201,7 @@ class StructTreePage {
this.nodes = []; this.nodes = [];
} }
parse() { parse(pageRef) {
if (!this.root || !this.rootDict) { if (!this.root || !this.rootDict) {
return; return;
} }
@ -196,18 +211,42 @@ class StructTreePage {
return; return;
} }
const id = this.pageDict.get("StructParents"); const id = this.pageDict.get("StructParents");
if (!Number.isInteger(id)) { const ids =
return; pageRef instanceof Ref && this.root.structParentIds?.get(pageRef);
} if (!Number.isInteger(id) && !ids) {
const numberTree = new NumberTree(parentTree, this.rootDict.xref);
const parentArray = numberTree.get(id);
if (!Array.isArray(parentArray)) {
return; return;
} }
const map = new Map(); const map = new Map();
for (const ref of parentArray) { const numberTree = new NumberTree(parentTree, this.rootDict.xref);
if (ref instanceof Ref) {
this.addNode(this.rootDict.xref.fetch(ref), map); if (Number.isInteger(id)) {
const parentArray = numberTree.get(id);
if (Array.isArray(parentArray)) {
for (const ref of parentArray) {
if (ref instanceof Ref) {
this.addNode(this.rootDict.xref.fetch(ref), map);
}
}
}
}
if (!ids) {
return;
}
for (const [elemId, type] of ids) {
const obj = numberTree.get(elemId);
if (obj) {
const elem = this.addNode(this.rootDict.xref.fetchIfRef(obj), map);
if (
elem?.kids?.length === 1 &&
elem.kids[0].type === StructElementType.OBJECT
) {
// The node in the struct tree is wrapping an object (annotation
// or xobject), so we need to update the type of the node to match
// the type of the object.
elem.kids[0].type = type;
}
} }
} }
} }
@ -322,6 +361,11 @@ class StructTreePage {
type: "object", type: "object",
id: kid.refObjId, id: kid.refObjId,
}); });
} else if (kid.type === StructElementType.ANNOTATION) {
obj.children.push({
type: "annotation",
id: `${AnnotationPrefix}${kid.refObjId}`,
});
} }
} }
} }

View File

@ -21,6 +21,7 @@
import { import {
AnnotationBorderStyleType, AnnotationBorderStyleType,
AnnotationEditorType, AnnotationEditorType,
AnnotationPrefix,
AnnotationType, AnnotationType,
FeatureTest, FeatureTest,
LINE_FACTOR, LINE_FACTOR,
@ -30,7 +31,6 @@ import {
warn, warn,
} from "../shared/util.js"; } from "../shared/util.js";
import { import {
AnnotationPrefix,
DOMSVGFactory, DOMSVGFactory,
getFilenameFromUrl, getFilenameFromUrl,
PDFDateString, PDFDateString,

View File

@ -31,8 +31,6 @@ import {
const SVG_NS = "http://www.w3.org/2000/svg"; const SVG_NS = "http://www.w3.org/2000/svg";
const AnnotationPrefix = "pdfjs_internal_id_";
class PixelsPerInch { class PixelsPerInch {
static CSS = 96.0; static CSS = 96.0;
@ -1005,7 +1003,6 @@ function setLayerDimensions(
} }
export { export {
AnnotationPrefix,
deprecated, deprecated,
DOMCanvasFactory, DOMCanvasFactory,
DOMCMapReaderFactory, DOMCMapReaderFactory,

View File

@ -1047,6 +1047,8 @@ function getUuid() {
return bytesToString(buf); return bytesToString(buf);
} }
const AnnotationPrefix = "pdfjs_internal_id_";
export { export {
AbortException, AbortException,
AnnotationActionEventType, AnnotationActionEventType,
@ -1057,6 +1059,7 @@ export {
AnnotationFieldFlag, AnnotationFieldFlag,
AnnotationFlag, AnnotationFlag,
AnnotationMode, AnnotationMode,
AnnotationPrefix,
AnnotationReplyType, AnnotationReplyType,
AnnotationType, AnnotationType,
assert, assert,

View File

@ -139,4 +139,35 @@ describe("accessibility", () => {
); );
}); });
}); });
describe("Stamp annotation accessibility", () => {
let pages;
beforeAll(async () => {
pages = await loadAndWait("tagged_stamp.pdf", ".annotationLayer");
});
afterAll(async () => {
await closePages(pages);
});
it("must check that the stamp annotation is linked to the struct tree", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await page.waitForSelector(".structTree");
const isLinkedToStampAnnotation = await page.$eval(
".structTree [role='figure']",
el =>
document
.getElementById(el.getAttribute("aria-owns"))
.classList.contains("stampAnnotation")
);
expect(isLinkedToStampAnnotation)
.withContext(`In ${browserName}`)
.toEqual(true);
})
);
});
});
}); });

View File

@ -610,3 +610,4 @@
!annotation_hidden_noview.pdf !annotation_hidden_noview.pdf
!widget_hidden_print.pdf !widget_hidden_print.pdf
!empty_protected.pdf !empty_protected.pdf
!tagged_stamp.pdf

BIN
test/pdfs/tagged_stamp.pdf Executable file

Binary file not shown.

View File

@ -100,14 +100,15 @@ class StructTreeLayerBuilder {
} }
#setAttributes(structElement, htmlElement) { #setAttributes(structElement, htmlElement) {
if (structElement.alt !== undefined) { const { alt, id, lang } = structElement;
htmlElement.setAttribute("aria-label", structElement.alt); if (alt !== undefined) {
htmlElement.setAttribute("aria-label", alt);
} }
if (structElement.id !== undefined) { if (id !== undefined) {
htmlElement.setAttribute("aria-owns", structElement.id); htmlElement.setAttribute("aria-owns", id);
} }
if (structElement.lang !== undefined) { if (lang !== undefined) {
htmlElement.setAttribute("lang", structElement.lang); htmlElement.setAttribute("lang", lang);
} }
} }