diff --git a/src/core/annotation.js b/src/core/annotation.js index 9b794afc58b19..379a89de42af1 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -381,11 +381,10 @@ class AnnotationFactory { ); break; case AnnotationEditorType.STAMP: - if (!isOffscreenCanvasSupported) { - break; - } - const image = await imagePromises.get(annotation.bitmapId); - if (image.imageStream) { + const image = isOffscreenCanvasSupported + ? await imagePromises?.get(annotation.bitmapId) + : null; + if (image?.imageStream) { const { imageStream, smaskStream } = image; const buffer = []; if (smaskStream) { @@ -488,11 +487,10 @@ class AnnotationFactory { ); break; case AnnotationEditorType.STAMP: - if (!options.isOffscreenCanvasSupported) { - break; - } - const image = await imagePromises.get(annotation.bitmapId); - if (image.imageStream) { + const image = options.isOffscreenCanvasSupported + ? await imagePromises?.get(annotation.bitmapId) + : null; + if (image?.imageStream) { const { imageStream, smaskStream } = image; if (smaskStream) { imageStream.dict.set("SMask", smaskStream); @@ -653,17 +651,6 @@ class Annotation { const isLocked = !!(this.flags & AnnotationFlag.LOCKED); const isContentLocked = !!(this.flags & AnnotationFlag.LOCKEDCONTENTS); - if (annotationGlobals.structTreeRoot) { - let structParent = dict.get("StructParent"); - structParent = - Number.isInteger(structParent) && structParent >= 0 ? structParent : -1; - - annotationGlobals.structTreeRoot.addAnnotationIdToPage( - params.pageRef, - structParent - ); - } - // Expose public properties using a data object. this.data = { annotationFlags: this.flags, @@ -682,8 +669,20 @@ class Annotation { noRotate: !!(this.flags & AnnotationFlag.NOROTATE), noHTML: isLocked && isContentLocked, isEditable: false, + structParent: -1, }; + if (annotationGlobals.structTreeRoot) { + let structParent = dict.get("StructParent"); + this.data.structParent = structParent = + Number.isInteger(structParent) && structParent >= 0 ? structParent : -1; + + annotationGlobals.structTreeRoot.addAnnotationIdToPage( + params.pageRef, + structParent + ); + } + if (params.collectFields) { // Fields can act as container for other fields and have // some actions even if no Annotation inherit from them. @@ -1751,10 +1750,7 @@ class MarkupAnnotation extends Annotation { } static async createNewAnnotation(xref, annotation, dependencies, params) { - let oldAnnotation; - if (annotation.ref) { - oldAnnotation = (await xref.fetchIfRefAsync(annotation.ref)).clone(); - } else { + if (!annotation.ref) { annotation.ref = xref.getNewTemporaryRef(); } @@ -1767,12 +1763,11 @@ class MarkupAnnotation extends Annotation { const apRef = xref.getNewTemporaryRef(); annotationDict = this.createNewDict(annotation, xref, { apRef, - oldAnnotation, }); await writeObject(apRef, ap, buffer, xref); dependencies.push({ ref: apRef, data: buffer.join("") }); } else { - annotationDict = this.createNewDict(annotation, xref, { oldAnnotation }); + annotationDict = this.createNewDict(annotation, xref, {}); } if (Number.isInteger(annotation.parentTreeId)) { annotationDict.set("StructParent", annotation.parentTreeId); @@ -1791,7 +1786,11 @@ class MarkupAnnotation extends Annotation { params ) { const ap = await this.createNewAppearanceStream(annotation, xref, params); - const annotationDict = this.createNewDict(annotation, xref, { ap }); + const annotationDict = this.createNewDict( + annotation, + xref, + ap ? { ap } : {} + ); const newAnnotation = new this.prototype.constructor({ dict: annotationDict, @@ -3904,8 +3903,9 @@ class FreeTextAnnotation extends MarkupAnnotation { return this._hasAppearance; } - static createNewDict(annotation, xref, { apRef, ap, oldAnnotation }) { - const { color, fontSize, rect, rotation, user, value } = annotation; + static createNewDict(annotation, xref, { apRef, ap }) { + const { color, fontSize, oldAnnotation, rect, rotation, user, value } = + annotation; const freetext = oldAnnotation || new Dict(xref); freetext.set("Type", Name.get("Annot")); freetext.set("Subtype", Name.get("FreeText")); @@ -4646,8 +4646,9 @@ class HighlightAnnotation extends MarkupAnnotation { } } - static createNewDict(annotation, xref, { apRef, ap, oldAnnotation }) { - const { color, opacity, rect, rotation, user, quadPoints } = annotation; + static createNewDict(annotation, xref, { apRef, ap }) { + const { color, oldAnnotation, opacity, rect, rotation, user, quadPoints } = + annotation; const highlight = oldAnnotation || new Dict(xref); highlight.set("Type", Name.get("Annot")); highlight.set("Subtype", Name.get("Highlight")); @@ -4943,10 +4944,14 @@ class StampAnnotation extends MarkupAnnotation { } static createNewDict(annotation, xref, { apRef, ap }) { - const { rect, rotation, user } = annotation; - const stamp = new Dict(xref); + const { oldAnnotation, rect, rotation, user } = annotation; + const stamp = oldAnnotation || new Dict(xref); stamp.set("Type", Name.get("Annot")); stamp.set("Subtype", Name.get("Stamp")); + stamp.set( + oldAnnotation ? "M" : "CreationDate", + `D:${getModificationDate()}` + ); stamp.set("CreationDate", `D:${getModificationDate()}`); stamp.set("Rect", rect); stamp.set("F", 4); @@ -4972,6 +4977,11 @@ class StampAnnotation extends MarkupAnnotation { } static async createNewAppearanceStream(annotation, xref, params) { + if (annotation.oldAnnotation) { + // We'll use the AP we already have. + return null; + } + const { rotation } = annotation; const { imageRef, width, height } = params.image; const resources = new Dict(xref); diff --git a/src/core/document.js b/src/core/document.js index 59f36f97f578f..c484bae1d5df4 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -274,7 +274,8 @@ class Page { ); } - #replaceIdByRef(annotations, deletedAnnotations, existingAnnotations) { + async #replaceIdByRef(annotations, deletedAnnotations, existingAnnotations) { + const promises = []; for (const annotation of annotations) { if (annotation.id) { const ref = Ref.fromString(annotation.id); @@ -294,9 +295,22 @@ class Page { } existingAnnotations?.put(ref); annotation.ref = ref; + promises.push( + this.xref.fetchAsync(ref).then( + obj => { + if (obj instanceof Dict) { + annotation.oldAnnotation = obj.clone(); + } + }, + () => { + warn(`Cannot fetch \`oldAnnotation\` for: ${ref}.`); + } + ) + ); delete annotation.id; } } + await Promise.all(promises); } async saveNewAnnotations(handler, task, annotations, imagePromises) { @@ -319,7 +333,11 @@ class Page { const deletedAnnotations = new RefSetCache(); const existingAnnotations = new RefSet(); - this.#replaceIdByRef(annotations, deletedAnnotations, existingAnnotations); + await this.#replaceIdByRef( + annotations, + deletedAnnotations, + existingAnnotations + ); const pageDict = this.pageDict; const annotationsArray = this.annotations.filter( @@ -489,23 +507,23 @@ class Page { } deletedAnnotations = new RefSet(); - this.#replaceIdByRef(newAnnots, deletedAnnotations, null); - - newAnnotationsPromise = annotationGlobalsPromise.then( - annotationGlobals => { - if (!annotationGlobals) { - return null; - } - return AnnotationFactory.printNewAnnotations( - annotationGlobals, - partialEvaluator, - task, - newAnnots, - imagePromises - ); + newAnnotationsPromise = Promise.all([ + annotationGlobalsPromise, + this.#replaceIdByRef(newAnnots, deletedAnnotations, null), + ]).then(([annotationGlobals]) => { + if (!annotationGlobals) { + return null; } - ); + + return AnnotationFactory.printNewAnnotations( + annotationGlobals, + partialEvaluator, + task, + newAnnots, + imagePromises + ); + }); } const pageListPromise = Promise.all([ diff --git a/src/core/name_number_tree.js b/src/core/name_number_tree.js index 9f814a55439ee..461711d1f3593 100644 --- a/src/core/name_number_tree.js +++ b/src/core/name_number_tree.js @@ -74,7 +74,7 @@ class NameOrNumberTree { return map; } - get(key) { + getRaw(key) { if (!this.root) { return null; } @@ -135,12 +135,16 @@ class NameOrNumberTree { } else if (key > currentKey) { l = m + 2; } else { - return xref.fetchIfRef(entries[m + 1]); + return entries[m + 1]; } } } return null; } + + get(key) { + return this.xref.fetchIfRef(this.getRaw(key)); + } } class NameTree extends NameOrNumberTree { diff --git a/src/core/struct_tree.js b/src/core/struct_tree.js index f11d4b964a90e..44e7e5b9ca5b4 100644 --- a/src/core/struct_tree.js +++ b/src/core/struct_tree.js @@ -141,10 +141,12 @@ class StructTreeRoot { const nextKey = await this.#writeKids({ newAnnotationsByPage, structTreeRootRef, + structTreeRoot: null, kids, nums, xref, pdfManager, + newRefs, cache, }); structTreeRoot.set("ParentTreeNextKey", nextKey); @@ -209,8 +211,12 @@ class StructTreeRoot { for (const element of elements) { if (element.accessibilityData?.type) { - // Each tag must have a structure type. - element.parentTreeId = nextKey++; + // structParent can be undefined and in this case the positivity check + // will fail (it's why the expression isn't equivalent to a `.<.`). + if (!(element.accessibilityData.structParent >= 0)) { + // Each tag must have a structure type. + element.parentTreeId = nextKey++; + } hasNothingToUpdate = false; } } @@ -259,16 +265,24 @@ class StructTreeRoot { parentTree.set("Nums", nums); } - const newNextkey = await StructTreeRoot.#writeKids({ + const newNextKey = await StructTreeRoot.#writeKids({ newAnnotationsByPage, structTreeRootRef, + structTreeRoot: this, kids: null, nums, xref, pdfManager, + newRefs, cache, }); - structTreeRoot.set("ParentTreeNextKey", newNextkey); + + if (newNextKey === -1) { + // No new tags were added. + return; + } + + structTreeRoot.set("ParentTreeNextKey", newNextKey); if (numsRef) { cache.put(numsRef, nums); @@ -285,17 +299,22 @@ class StructTreeRoot { static async #writeKids({ newAnnotationsByPage, structTreeRootRef, + structTreeRoot, kids, nums, xref, pdfManager, + newRefs, cache, }) { const objr = Name.get("OBJR"); - let nextKey = -Infinity; + let nextKey = -1; + let structTreePageObjs; + const buffer = []; for (const [pageIndex, elements] of newAnnotationsByPage) { - const { ref: pageRef } = await pdfManager.getPage(pageIndex); + const page = await pdfManager.getPage(pageIndex); + const { ref: pageRef } = page; const isPageRef = pageRef instanceof Ref; for (const { accessibilityData, @@ -306,31 +325,43 @@ class StructTreeRoot { if (!accessibilityData?.type) { continue; } - const { type, title, lang, alt, expanded, actualText } = - accessibilityData; + + // We've some accessibility data, so we need to create a new tag or + // update an existing one. + const { structParent } = accessibilityData; + + if ( + structTreeRoot && + Number.isInteger(structParent) && + structParent >= 0 + ) { + let objs = (structTreePageObjs ||= new Map()).get(pageIndex); + if (objs === undefined) { + // We need to collect the objects for the page. + const structTreePage = new StructTreePage( + structTreeRoot, + page.pageDict + ); + objs = structTreePage.collectObjects(pageRef); + structTreePageObjs.set(pageIndex, objs); + } + const objRef = objs?.get(structParent); + if (objRef) { + // We update the existing tag. + const tagDict = xref.fetch(objRef).clone(); + StructTreeRoot.#writeProperties(tagDict, accessibilityData); + buffer.length = 0; + await writeObject(objRef, tagDict, buffer, xref); + newRefs.push({ ref: objRef, data: buffer.join("") }); + continue; + } + } nextKey = Math.max(nextKey, parentTreeId); const tagRef = xref.getNewTemporaryRef(); const tagDict = new Dict(xref); - // The structure type is required. - tagDict.set("S", Name.get(type)); - - if (title) { - tagDict.set("T", stringToAsciiOrUTF16BE(title)); - } - if (lang) { - tagDict.set("Lang", lang); - } - if (alt) { - tagDict.set("Alt", stringToAsciiOrUTF16BE(alt)); - } - if (expanded) { - tagDict.set("E", stringToAsciiOrUTF16BE(expanded)); - } - if (actualText) { - tagDict.set("ActualText", stringToAsciiOrUTF16BE(actualText)); - } + StructTreeRoot.#writeProperties(tagDict, accessibilityData); await this.#updateParentTag({ structTreeParent, @@ -358,6 +389,30 @@ class StructTreeRoot { return nextKey + 1; } + static #writeProperties( + tagDict, + { type, title, lang, alt, expanded, actualText } + ) { + // The structure type is required. + tagDict.set("S", Name.get(type)); + + if (title) { + tagDict.set("T", stringToAsciiOrUTF16BE(title)); + } + if (lang) { + tagDict.set("Lang", stringToAsciiOrUTF16BE(lang)); + } + if (alt) { + tagDict.set("Alt", stringToAsciiOrUTF16BE(alt)); + } + if (expanded) { + tagDict.set("E", stringToAsciiOrUTF16BE(expanded)); + } + if (actualText) { + tagDict.set("ActualText", stringToAsciiOrUTF16BE(actualText)); + } + } + static #collectParents({ elements, xref, pageDict, numberTree }) { const idToElements = new Map(); for (const element of elements) { @@ -616,8 +671,40 @@ class StructTreePage { this.nodes = []; } + /** + * Collect all the objects (i.e. tag) that are part of the page and return a + * map of the structure element id to the object reference. + * @param {Ref} pageRef + * @returns {Map} + */ + collectObjects(pageRef) { + if (!this.root || !this.rootDict || !(pageRef instanceof Ref)) { + return null; + } + + const parentTree = this.rootDict.get("ParentTree"); + if (!parentTree) { + return null; + } + const ids = this.root.structParentIds?.get(pageRef); + if (!ids) { + return null; + } + + const map = new Map(); + const numberTree = new NumberTree(parentTree, this.rootDict.xref); + + for (const [elemId] of ids) { + const obj = numberTree.getRaw(elemId); + if (obj instanceof Ref) { + map.set(elemId, obj); + } + } + return map; + } + parse(pageRef) { - if (!this.root || !this.rootDict) { + if (!this.root || !this.rootDict || !(pageRef instanceof Ref)) { return; } @@ -626,8 +713,7 @@ class StructTreePage { return; } const id = this.pageDict.get("StructParents"); - const ids = - pageRef instanceof Ref && this.root.structParentIds?.get(pageRef); + const ids = this.root.structParentIds?.get(pageRef); if (!Number.isInteger(id) && !ids) { return; } diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index cda431a14c61a..001000de0b98c 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -672,3 +672,4 @@ !issue16038.pdf !highlight_popup.pdf !issue18072.pdf +!stamps.pdf diff --git a/test/pdfs/stamps.pdf b/test/pdfs/stamps.pdf new file mode 100755 index 0000000000000..3727a6e252dd5 Binary files /dev/null and b/test/pdfs/stamps.pdf differ diff --git a/test/test_manifest.json b/test/test_manifest.json index fa14966541542..85e2b1058f9d4 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -10578,5 +10578,94 @@ "noPrint": false } } + }, + { + "id": "stamps-editor-save-print", + "file": "pdfs/stamps.pdf", + "md5": "0f8e16d204d4863be159f77aa8045938", + "rounds": 1, + "lastPage": 1, + "type": "eq", + "save": true, + "print": true, + "annotationStorage": { + "pdfjs_internal_editor_0": { + "annotationType": 13, + "pageIndex": 0, + "rect": [87, 72, 251, 382], + "rotation": 0, + "isSvg": false, + "structTreeParentId": null, + "id": "25R" + }, + "pdfjs_internal_editor_1": { + "annotationType": 13, + "pageIndex": 0, + "rect": [350, 337, 524, 522], + "rotation": 0, + "isSvg": false, + "structTreeParentId": null, + "id": "34R" + }, + "pdfjs_internal_editor_2": { + "annotationType": 13, + "pageIndex": 0, + "rect": [82, 608, 234, 735], + "rotation": 0, + "isSvg": false, + "structTreeParentId": null, + "id": "58R" + }, + "pdfjs_internal_editor_3": { + "pageIndex": 0, + "deleted": true, + "id": "37R", + "popupRef": "44R" + } + } + }, + { + "id": "stamps-editor-print", + "file": "pdfs/stamps.pdf", + "md5": "0f8e16d204d4863be159f77aa8045938", + "rounds": 1, + "lastPage": 1, + "type": "eq", + "print": true, + "annotationStorage": { + "pdfjs_internal_editor_0": { + "annotationType": 13, + "pageIndex": 0, + "rect": [87, 72, 251, 382], + "rotation": 0, + "isSvg": false, + "structTreeParentId": null, + "id": "25R" + }, + "pdfjs_internal_editor_1": { + "annotationType": 13, + "pageIndex": 0, + "rect": [350, 337, 524, 522], + "rotation": 0, + "isSvg": false, + "structTreeParentId": null, + "id": "34R" + }, + "pdfjs_internal_editor_2": { + "annotationType": 13, + "pageIndex": 0, + "rect": [82, 608, 234, 735], + "rotation": 0, + "isSvg": false, + "structTreeParentId": null, + "id": "58R" + }, + "pdfjs_internal_editor_3": { + "pageIndex": 0, + "deleted": true, + "id": "37R", + "popupRef": "44R" + } + } } ] diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index 9e5739fd73f88..9ef611407352f 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -4283,6 +4283,7 @@ describe("annotation", function () { value: "Hello PDF.js World !", id: "143R", ref: freeTextRef, + oldAnnotation: freeTextDict, }, ] ); diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index 55fed49a44564..bb78c9c32b31d 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -2800,7 +2800,7 @@ describe("api", function () { await loadingTask.destroy(); }); - it("write a new stamp annotation in a non-tagged pdf, save and check that the structure tree", async function () { + it("write a new stamp annotation in a non-tagged pdf, save and check the structure tree", async function () { if (isNodeJS) { pending("Cannot create a bitmap from Node.js."); } @@ -2949,6 +2949,52 @@ describe("api", function () { await loadingTask.destroy(); }); + it("write an updated stamp annotation in a tagged pdf, save and check the structure tree", async function () { + let loadingTask = getDocument(buildGetDocumentParams("stamps.pdf")); + let pdfDoc = await loadingTask.promise; + pdfDoc.annotationStorage.setValue("pdfjs_internal_editor_1", { + annotationType: AnnotationEditorType.STAMP, + pageIndex: 0, + rect: [72.5, 134.17, 246.49, 318.7], + rotation: 0, + isSvg: false, + structTreeParentId: null, + accessibilityData: { + type: "Figure", + alt: "The Firefox logo", + structParent: -1, + }, + id: "34R", + }); + pdfDoc.annotationStorage.setValue("pdfjs_internal_editor_4", { + annotationType: AnnotationEditorType.STAMP, + pageIndex: 0, + rect: [335.1, 394.83, 487.17, 521.47], + rotation: 0, + isSvg: false, + structTreeParentId: null, + accessibilityData: { + type: "Figure", + alt: "An elephant with a red hat", + structParent: 0, + }, + id: "58R", + }); + + const data = await pdfDoc.saveDocument(); + await loadingTask.destroy(); + + loadingTask = getDocument(data); + pdfDoc = await loadingTask.promise; + const page = await pdfDoc.getPage(1); + const tree = await page.getStructTree(); + + expect(tree.children[0].alt).toEqual("An elephant with a red hat"); + expect(tree.children[1].alt).toEqual("The Firefox logo"); + + await loadingTask.destroy(); + }); + it("read content from multiline textfield containing an empty line", async function () { const loadingTask = getDocument(buildGetDocumentParams("issue17492.pdf")); const pdfDoc = await loadingTask.promise;