From cdb641ce1f37db2946e63c3fd0cf9fd4288046f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20Gr=C3=B6nke?= Date: Thu, 30 Jul 2026 08:17:48 +0000 Subject: [PATCH] Expose the annotation name (/NM) and the subject (/Subj) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are in the file and neither reaches `getAnnotations()`, so a caller that reads a PDF's own annotations cannot see what the document calls them or what they say they are about. `/NM` is the annotation name: "a text string uniquely identifying it among all the annotations on its page" (ISO 32000-1, Table 164). It is not `data.id`, which is the object reference — a caller correlating annotations with anything outside the file needs the name the document gives them, and that is also the name viewers key their own records off. It is read on `Annotation`, where the common dictionary lives, as `data.annotationName`. `/Subj` is "text representing a short description of the subject being addressed by the annotation" (Table 170), a markup-annotation entry. It is read as `data.subjectObj` in the same `{ str, dir }` shape as `titleObj` and `contentsObj`, since it is a text string and can be right-to-left. A group member inherits it from the primary annotation, alongside the title, the contents and the dates it already inherits. Both are read-only additions to the data object; nothing that consumes it changes. --- src/core/annotation.js | 39 ++++++++++++++++++ test/unit/annotation_spec.js | 78 ++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/src/core/annotation.js b/src/core/annotation.js index 0ae082aead2a6..eccec71b9bff4 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -702,6 +702,7 @@ class Annotation { this.setTitle(dict.get("T")); this.setContents(dict.get("Contents")); + this.setAnnotationName(dict.get("NM")); this.setModificationDate(dict.get("M")); this.setFlags(dict.get("F")); this.setRectangle(dict.getArray("Rect")); @@ -738,6 +739,7 @@ class Annotation { contentsObj: this._contents, hasAppearance: !!this.appearance, id: params.id, + annotationName: this.annotationName, modificationDate: this.modificationDate, oc: this._oc, rect: this.rectangle, @@ -973,6 +975,37 @@ class Annotation { this._contents = this._parseStringHelper(contents); } + /** + * Set the subject. + * + * @public + * @memberof Annotation + * @param {string} subject - A short description of what the annotation is + * about, which viewers show beside its contents + */ + setSubject(subject) { + this._subject = this._parseStringHelper(subject); + } + + /** + * Set the annotation name. + * + * Unlike `id`, which identifies the object in the file, this is the name the + * document itself gives the annotation, and the one other annotations and + * external records refer to it by. + * + * @public + * @memberof Annotation + * @param {string} annotationName - The annotation name, uniquely identifying + * the annotation among those on its page + */ + setAnnotationName(annotationName) { + this.annotationName = + typeof annotationName === "string" + ? stringToPDFString(annotationName) + : null; + } + /** * Set the modification date. * @@ -1732,6 +1765,9 @@ class MarkupAnnotation extends Annotation { this.setContents(parent.get("Contents")); this.data.contentsObj = this._contents; + this.setSubject(parent.get("Subj")); + this.data.subjectObj = this._subject; + if (!parent.has("CreationDate")) { this.data.creationDate = null; } else { @@ -1758,6 +1794,9 @@ class MarkupAnnotation extends Annotation { } else { this.data.titleObj = this._title; + this.setSubject(dict.get("Subj")); + this.data.subjectObj = this._subject; + this.setCreationDate(dict.get("CreationDate")); this.data.creationDate = this.creationDate; diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index 6c6c8250c08c0..11fab74a07892 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -675,6 +675,7 @@ describe("annotation", function () { annotationDict.set("Subtype", Name.get("Text")); annotationDict.set("T", "ParentTitle"); annotationDict.set("Contents", "ParentText"); + annotationDict.set("Subj", "ParentSubject"); annotationDict.set("CreationDate", "D:20180423"); annotationDict.set("M", "D:20190423"); annotationDict.set("C", [0, 0, 1]); @@ -694,6 +695,7 @@ describe("annotation", function () { replyDict.set("RT", Name.get("Group")); replyDict.set("T", "ReplyTitle"); replyDict.set("Contents", "ReplyText"); + replyDict.set("Subj", "ReplySubject"); replyDict.set("CreationDate", "D:20180523"); replyDict.set("M", "D:20190523"); replyDict.set("C", [0.4]); @@ -717,12 +719,88 @@ describe("annotation", function () { expect(data.replyType).toEqual("Group"); expect(data.titleObj).toEqual({ str: "ParentTitle", dir: "ltr" }); expect(data.contentsObj).toEqual({ str: "ParentText", dir: "ltr" }); + expect(data.subjectObj).toEqual({ str: "ParentSubject", dir: "ltr" }); expect(data.creationDate).toEqual("D:20180423"); expect(data.modificationDate).toEqual("D:20190423"); expect(data.color).toEqual(new Uint8ClampedArray([0, 0, 255])); expect(data.popupRef).toEqual("820R"); }); + it("should parse the annotation name", async function () { + const annotationDict = new Dict(); + annotationDict.set("Type", Name.get("Annot")); + annotationDict.set("Subtype", Name.get("Text")); + annotationDict.set("NM", "the-name-the-document-gives-it"); + + const annotationRef = Ref.get(1, 0); + const xref = new XRefMock([{ ref: annotationRef, data: annotationDict }]); + + const { data } = await AnnotationFactory.create( + xref, + annotationRef, + annotationGlobalsMock, + idFactoryMock + ); + expect(data.annotationName).toEqual("the-name-the-document-gives-it"); + // The object id is a separate thing, and stays what it was. + expect(data.id).toEqual("1R"); + }); + + it("should handle a missing annotation name", async function () { + const annotationDict = new Dict(); + annotationDict.set("Type", Name.get("Annot")); + annotationDict.set("Subtype", Name.get("Text")); + + const annotationRef = Ref.get(1, 0); + const xref = new XRefMock([{ ref: annotationRef, data: annotationDict }]); + + const { data } = await AnnotationFactory.create( + xref, + annotationRef, + annotationGlobalsMock, + idFactoryMock + ); + expect(data.annotationName).toEqual(null); + }); + + it("should parse the subject", async function () { + const annotationDict = new Dict(); + annotationDict.set("Type", Name.get("Annot")); + annotationDict.set("Subtype", Name.get("Text")); + annotationDict.set("Subj", "What this is about"); + + const annotationRef = Ref.get(1, 0); + const xref = new XRefMock([{ ref: annotationRef, data: annotationDict }]); + + const { data } = await AnnotationFactory.create( + xref, + annotationRef, + annotationGlobalsMock, + idFactoryMock + ); + expect(data.subjectObj).toEqual({ + str: "What this is about", + dir: "ltr", + }); + }); + + it("should handle a missing subject", async function () { + const annotationDict = new Dict(); + annotationDict.set("Type", Name.get("Annot")); + annotationDict.set("Subtype", Name.get("Text")); + + const annotationRef = Ref.get(1, 0); + const xref = new XRefMock([{ ref: annotationRef, data: annotationDict }]); + + const { data } = await AnnotationFactory.create( + xref, + annotationRef, + annotationGlobalsMock, + idFactoryMock + ); + expect(data.subjectObj).toEqual({ str: "", dir: "ltr" }); + }); + it("should parse IRT/RT for a reply type", async function () { const annotationRef = Ref.get(819, 0); const annotationDict = new Dict();