From 71ecc3129b1b3884be4752752a76451f49948e9b Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Wed, 30 Sep 2020 20:58:45 +0200 Subject: [PATCH] Add the possibility to collect Javascript actions --- src/core/annotation.js | 166 ++++++++++++++++++++++++++++++++++- src/core/document.js | 72 ++++++++++++++- src/core/worker.js | 4 + src/display/api.js | 12 +++ src/shared/util.js | 23 +++++ test/unit/annotation_spec.js | 100 ++++++++++++++++++++- test/unit/document_spec.js | 9 ++ 7 files changed, 383 insertions(+), 3 deletions(-) diff --git a/src/core/annotation.js b/src/core/annotation.js index b0736ad697189..d8c29011e54a6 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -14,12 +14,14 @@ */ import { + AnnotationActionEventType, AnnotationBorderStyleType, AnnotationFieldFlag, AnnotationFlag, AnnotationReplyType, AnnotationType, assert, + bytesToString, escapeString, getModificationDate, isString, @@ -30,7 +32,15 @@ import { warn, } from "../shared/util.js"; import { Catalog, FileSpec, ObjectLoader } from "./obj.js"; -import { Dict, isDict, isName, isRef, isStream, Name } from "./primitives.js"; +import { + Dict, + isDict, + isName, + isRef, + isStream, + Name, + RefSet, +} from "./primitives.js"; import { ColorSpace } from "./colorspace.js"; import { getInheritableProperty } from "./core_utils.js"; import { OperatorList } from "./operator_list.js"; @@ -569,6 +579,20 @@ class Annotation { return null; } + /** + * Get field data for usage in JS sandbox. + * + * Field object is defined here: + * https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/js_api_reference.pdf#page=16 + * + * @public + * @memberof Annotation + * @returns {Object | null} + */ + getFieldObject() { + return null; + } + /** * Reset the annotation. * @@ -903,6 +927,7 @@ class WidgetAnnotation extends Annotation { data.annotationType = AnnotationType.WIDGET; data.fieldName = this._constructFieldName(dict); + data.actions = this._collectActions(params.xref, dict); const fieldValue = getInheritableProperty({ dict, @@ -937,6 +962,7 @@ class WidgetAnnotation extends Annotation { } data.readOnly = this.hasFieldFlag(AnnotationFieldFlag.READONLY); + data.hidden = this.hasFieldFlag(AnnotationFieldFlag.HIDDEN); // Hide signatures because we cannot validate them, and unset the fieldValue // since it's (most likely) a `Dict` which is non-serializable and will thus @@ -944,6 +970,7 @@ class WidgetAnnotation extends Annotation { if (data.fieldType === "Sig") { data.fieldValue = null; this.setFlags(AnnotationFlag.HIDDEN); + data.hidden = true; } } @@ -1366,6 +1393,87 @@ class WidgetAnnotation extends Annotation { } return localResources || Dict.empty; } + + _collectJS(entry, xref, list, parents) { + if (!entry) { + return; + } + + let parent = null; + if (isRef(entry)) { + if (parents.has(entry)) { + // If we've already found entry then we've a cycle. + return; + } + parent = entry; + parents.put(parent); + entry = xref.fetch(entry); + } + if (Array.isArray(entry)) { + for (const element of entry) { + this._collectJS(element, xref, list, parents); + } + } else if (entry instanceof Dict) { + if (isName(entry.get("S"), "JavaScript") && entry.has("JS")) { + const js = entry.get("JS"); + let code; + if (isStream(js)) { + code = bytesToString(js.getBytes()); + } else { + code = js; + } + code = stringToPDFString(code); + if (code) { + list.push(code); + } + } + this._collectJS(entry.getRaw("Next"), xref, list, parents); + } + + if (parent) { + parents.remove(parent); + } + } + + _collectActions(xref, dict) { + const actions = Object.create(null); + if (dict.has("AA")) { + const additionalActions = dict.get("AA"); + for (const key of additionalActions.getKeys()) { + if (key in AnnotationActionEventType) { + const actionDict = additionalActions.getRaw(key); + const parents = new RefSet(); + const list = []; + this._collectJS(actionDict, xref, list, parents); + if (list.length > 0) { + actions[AnnotationActionEventType[key]] = list; + } + } + } + } + // Collect the Action if any (we may have one on pushbutton) + if (dict.has("A")) { + const actionDict = dict.get("A"); + const parents = new RefSet(); + const list = []; + this._collectJS(actionDict, xref, list, parents); + if (list.length > 0) { + actions.Action = list; + } + } + return actions; + } + + getFieldObject() { + if (this.data.fieldType === "Sig") { + return { + id: this.data.id, + value: null, + type: "signature", + }; + } + return null; + } } class TextWidgetAnnotation extends WidgetAnnotation { @@ -1516,6 +1624,23 @@ class TextWidgetAnnotation extends WidgetAnnotation { return chunks; } + + getFieldObject() { + return { + id: this.data.id, + value: this.data.fieldValue, + multiline: this.data.multiLine, + password: this.hasFieldFlag(AnnotationFieldFlag.PASSWORD), + charLimit: this.data.maxLen, + comb: this.data.comb, + editable: !this.data.readOnly, + hidden: this.data.hidden, + name: this.data.fieldName, + rect: this.data.rect, + actions: this.data.actions, + type: "text", + }; + } } class ButtonWidgetAnnotation extends WidgetAnnotation { @@ -1793,6 +1918,28 @@ class ButtonWidgetAnnotation extends WidgetAnnotation { docBaseUrl: params.pdfManager.docBaseUrl, }); } + + getFieldObject() { + let type = "button"; + let value = null; + if (this.data.checkBox) { + type = "checkbox"; + value = this.data.fieldValue && this.data.fieldValue !== "Off"; + } else if (this.data.radioButton) { + type = "radiobutton"; + value = this.data.fieldValue === this.data.buttonValue; + } + return { + id: this.data.id, + value, + editable: !this.data.readOnly, + name: this.data.fieldName, + rect: this.data.rect, + hidden: this.data.hidden, + actions: this.data.actions, + type, + }; + } } class ChoiceWidgetAnnotation extends WidgetAnnotation { @@ -1843,6 +1990,23 @@ class ChoiceWidgetAnnotation extends WidgetAnnotation { this.data.multiSelect = this.hasFieldFlag(AnnotationFieldFlag.MULTISELECT); this._hasText = true; } + + getFieldObject() { + const type = this.data.combo ? "combobox" : "listbox"; + const value = + this.data.fieldValue.length > 0 ? this.data.fieldValue[0] : null; + return { + id: this.data.id, + value, + editable: !this.data.readOnly, + name: this.data.fieldName, + rect: this.data.rect, + multipleSelection: this.data.multiSelect, + hidden: this.data.hidden, + actions: this.data.actions, + type, + }; + } } class TextAnnotation extends MarkupAnnotation { diff --git a/src/core/document.js b/src/core/document.js index 51bc738c6fab0..ee194660a7e75 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -708,7 +708,7 @@ class PDFDocument { } get formInfo() { - const formInfo = { hasAcroForm: false, hasXfa: false }; + const formInfo = { hasAcroForm: false, hasXfa: false, fields: null }; const acroForm = this.catalog.acroForm; if (!acroForm) { return shadow(this, "formInfo", formInfo); @@ -736,6 +736,9 @@ class PDFDocument { const hasOnlyDocumentSignatures = !!(sigFlags & 0x1) && this._hasOnlyDocumentSignatures(fields); formInfo.hasAcroForm = hasFields && !hasOnlyDocumentSignatures; + if (hasFields) { + formInfo.fields = fields; + } } catch (ex) { if (ex instanceof MissingDataException) { throw ex; @@ -935,6 +938,73 @@ class PDFDocument { ? this.catalog.cleanup(manuallyTriggered) : clearPrimitiveCaches(); } + + _collectFieldObjects(name, fieldRef, promises) { + const field = this.xref.fetchIfRef(fieldRef); + if (field.has("T")) { + const partName = stringToPDFString(field.get("T")); + if (name === "") { + name = partName; + } else { + name = `${name}.${partName}`; + } + } + + if (!(name in promises)) { + promises.set(name, []); + } + promises.get(name).push( + AnnotationFactory.create( + this.xref, + fieldRef, + this.pdfManager, + this._localIdFactory + ) + .then(annotation => annotation.getFieldObject()) + .catch(function (reason) { + warn(`_collectFieldObjects: "${reason}".`); + return null; + }) + ); + + if (field.has("Kids")) { + const kids = field.get("Kids"); + for (const kid of kids) { + this._collectFieldObjects(name, kid, promises); + } + } + } + + get fieldObjects() { + const formInfo = this.formInfo; + if (!formInfo.fields) { + return shadow(this, "fieldObjects", Promise.resolve(null)); + } + + const allFields = Object.create(null); + const fieldPromises = new Map(); + for (const fieldRef of formInfo.fields) { + this._collectFieldObjects("", fieldRef, fieldPromises); + } + + const allPromises = []; + for (const [name, promises] of fieldPromises.entries()) { + allPromises.push( + Promise.all(promises).then(fields => { + fields = fields.filter(field => field !== null); + if (fields.length > 0) { + allFields[name] = fields; + } + }) + ); + } + + return shadow( + this, + "fieldObjects", + Promise.all(allPromises).then(() => allFields) + ); + } } export { Page, PDFDocument }; diff --git a/src/core/worker.js b/src/core/worker.js index 673b3f45cf291..1404d94f73b3f 100644 --- a/src/core/worker.js +++ b/src/core/worker.js @@ -516,6 +516,10 @@ class WorkerMessageHandler { }); }); + handler.on("GetFieldObjects", function (data) { + return pdfManager.ensureDoc("fieldObjects"); + }); + handler.on("SaveDocument", function ({ numPages, annotationStorage, diff --git a/src/display/api.js b/src/display/api.js index 0c184d8b2ad75..335e050d16c74 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -876,6 +876,14 @@ class PDFDocumentProxy { saveDocument(annotationStorage) { return this._transport.saveDocument(annotationStorage); } + + /** + * @returns {Promise>} A promise that is resolved with an + * {Array} containing field data for the JS sandbox. + */ + getFieldObjects() { + return this._transport.getFieldObjects(); + } } /** @@ -2549,6 +2557,10 @@ class WorkerTransport { }); } + getFieldObjects() { + return this.messageHandler.sendWithPromise("GetFieldObjects", null); + } + getDestinations() { return this.messageHandler.sendWithPromise("GetDestinations", null); } diff --git a/src/shared/util.js b/src/shared/util.js index 9b2b0edf11922..f7ec7526bdf4a 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -144,6 +144,28 @@ const AnnotationBorderStyleType = { UNDERLINE: 5, }; +const AnnotationActionEventType = { + E: "MouseEnter", + X: "MouseExit", + D: "MouseDown", + U: "MouseUp", + Fo: "Focus", + Bl: "Blur", + PO: "PageOpen", + PC: "PageClose", + PV: "PageVisible", + PI: "PageInvisible", + K: "Keystroke", + F: "Format", + V: "Validate", + C: "Calculate", + WC: "WillClose", + WS: "WillSave", + DS: "DidSave", + WP: "WillPrint", + DP: "DidPrint", +}; + const StreamType = { UNKNOWN: "UNKNOWN", FLATE: "FLATE", @@ -971,6 +993,7 @@ export { OPS, VerbosityLevel, UNSUPPORTED_FEATURES, + AnnotationActionEventType, AnnotationBorderStyleType, AnnotationFieldFlag, AnnotationFlag, diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index 2d9093e837455..c390a7aeac8b1 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -298,6 +298,7 @@ describe("annotation", function () { expect(annotation.hasFlag(AnnotationFlag.NOZOOM)).toEqual(true); expect(annotation.hasFlag(AnnotationFlag.PRINT)).toEqual(true); expect(annotation.hasFlag(AnnotationFlag.READONLY)).toEqual(false); + expect(annotation.hasFlag(AnnotationFlag.HIDDEN)).toEqual(false); }); it("should be viewable and not printable by default", function () { @@ -1433,6 +1434,7 @@ describe("annotation", function () { expect(data.textAlignment).toEqual(null); expect(data.maxLen).toEqual(null); expect(data.readOnly).toEqual(false); + expect(data.hidden).toEqual(false); expect(data.multiLine).toEqual(false); expect(data.comb).toEqual(false); done(); @@ -1457,6 +1459,7 @@ describe("annotation", function () { expect(data.textAlignment).toEqual(null); expect(data.maxLen).toEqual(null); expect(data.readOnly).toEqual(false); + expect(data.hidden).toEqual(false); expect(data.multiLine).toEqual(false); expect(data.comb).toEqual(false); done(); @@ -1484,6 +1487,7 @@ describe("annotation", function () { expect(data.textAlignment).toEqual(1); expect(data.maxLen).toEqual(20); expect(data.readOnly).toEqual(true); + expect(data.hidden).toEqual(false); expect(data.multiLine).toEqual(true); done(); }, done.fail); @@ -1863,6 +1867,92 @@ describe("annotation", function () { done(); }, done.fail); }); + + it("should get field object for usage in JS sandbox", function (done) { + const textWidgetRef = Ref.get(123, 0); + const xDictRef = Ref.get(141, 0); + const dDictRef = Ref.get(262, 0); + const next0Ref = Ref.get(314, 0); + const next1Ref = Ref.get(271, 0); + const next2Ref = Ref.get(577, 0); + const next00Ref = Ref.get(413, 0); + const xDict = new Dict(); + const dDict = new Dict(); + const next0Dict = new Dict(); + const next1Dict = new Dict(); + const next2Dict = new Dict(); + const next00Dict = new Dict(); + + const xref = new XRefMock([ + { ref: textWidgetRef, data: textWidgetDict }, + { ref: xDictRef, data: xDict }, + { ref: dDictRef, data: dDict }, + { ref: next0Ref, data: next0Dict }, + { ref: next00Ref, data: next00Dict }, + { ref: next1Ref, data: next1Dict }, + { ref: next2Ref, data: next2Dict }, + ]); + + const JS = Name.get("JavaScript"); + const additionalActionsDict = new Dict(); + const eDict = new Dict(); + eDict.set("JS", "hello()"); + eDict.set("S", JS); + additionalActionsDict.set("E", eDict); + + // Test the cycle detection here + xDict.set("JS", "world()"); + xDict.set("S", JS); + xDict.set("Next", [next0Ref, next1Ref, next2Ref, xDictRef]); + + next0Dict.set("JS", "olleh()"); + next0Dict.set("S", JS); + next0Dict.set("Next", next00Ref); + + next00Dict.set("JS", "foo()"); + next00Dict.set("S", JS); + next00Dict.set("Next", next0Ref); + + next1Dict.set("JS", "dlrow()"); + next1Dict.set("S", JS); + next1Dict.set("Next", xDictRef); + + next2Dict.set("JS", "oof()"); + next2Dict.set("S", JS); + + dDict.set("JS", "bar()"); + dDict.set("S", JS); + dDict.set("Next", dDictRef); + additionalActionsDict.set("D", dDictRef); + + additionalActionsDict.set("X", xDictRef); + textWidgetDict.set("AA", additionalActionsDict); + + partialEvaluator.xref = xref; + + AnnotationFactory.create( + xref, + textWidgetRef, + pdfManagerMock, + idFactoryMock + ) + .then(annotation => { + return annotation.getFieldObject(); + }) + .then(object => { + const actions = object.actions; + expect(actions.MouseEnter).toEqual(["hello()"]); + expect(actions.MouseExit).toEqual([ + "world()", + "olleh()", + "foo()", + "dlrow()", + "oof()", + ]); + expect(actions.MouseDown).toEqual(["bar()"]); + done(); + }, done.fail); + }); }); describe("ButtonWidgetAnnotation", function () { @@ -2524,7 +2614,11 @@ describe("annotation", function () { it("should handle push buttons", function (done) { const buttonWidgetRef = Ref.get(124, 0); buttonWidgetDict.set("Ff", AnnotationFieldFlag.PUSHBUTTON); - buttonWidgetDict.set("A", "whatever"); + + const actionDict = new Dict(); + actionDict.set("S", Name.get("JavaScript")); + actionDict.set("JS", "do_something();"); + buttonWidgetDict.set("A", actionDict); const xref = new XRefMock([ { ref: buttonWidgetRef, data: buttonWidgetDict }, @@ -2538,6 +2632,7 @@ describe("annotation", function () { ).then(({ data }) => { expect(data.annotationType).toEqual(AnnotationType.WIDGET); expect(data.pushButton).toEqual(true); + expect(data.actions.Action).toEqual(["do_something();"]); done(); }, done.fail); }); @@ -2779,6 +2874,7 @@ describe("annotation", function () { ).then(({ data }) => { expect(data.annotationType).toEqual(AnnotationType.WIDGET); expect(data.readOnly).toEqual(false); + expect(data.hidden).toEqual(false); expect(data.combo).toEqual(false); expect(data.multiSelect).toEqual(false); done(); @@ -2801,6 +2897,7 @@ describe("annotation", function () { ).then(({ data }) => { expect(data.annotationType).toEqual(AnnotationType.WIDGET); expect(data.readOnly).toEqual(false); + expect(data.hidden).toEqual(false); expect(data.combo).toEqual(false); expect(data.multiSelect).toEqual(false); done(); @@ -2828,6 +2925,7 @@ describe("annotation", function () { ).then(({ data }) => { expect(data.annotationType).toEqual(AnnotationType.WIDGET); expect(data.readOnly).toEqual(true); + expect(data.hidden).toEqual(false); expect(data.combo).toEqual(true); expect(data.multiSelect).toEqual(true); done(); diff --git a/test/unit/document_spec.js b/test/unit/document_spec.js index 0586898d72bd8..ba9968de9ee15 100644 --- a/test/unit/document_spec.js +++ b/test/unit/document_spec.js @@ -63,6 +63,7 @@ describe("document", function () { expect(pdfDocument.formInfo).toEqual({ hasAcroForm: false, hasXfa: false, + fields: null, }); }); @@ -75,6 +76,7 @@ describe("document", function () { expect(pdfDocument.formInfo).toEqual({ hasAcroForm: false, hasXfa: false, + fields: null, }); acroForm.set("XFA", ["foo", "bar"]); @@ -82,6 +84,7 @@ describe("document", function () { expect(pdfDocument.formInfo).toEqual({ hasAcroForm: false, hasXfa: true, + fields: null, }); acroForm.set("XFA", new StringStream("")); @@ -89,6 +92,7 @@ describe("document", function () { expect(pdfDocument.formInfo).toEqual({ hasAcroForm: false, hasXfa: false, + fields: null, }); acroForm.set("XFA", new StringStream("non-empty")); @@ -96,6 +100,7 @@ describe("document", function () { expect(pdfDocument.formInfo).toEqual({ hasAcroForm: false, hasXfa: true, + fields: null, }); }); @@ -108,6 +113,7 @@ describe("document", function () { expect(pdfDocument.formInfo).toEqual({ hasAcroForm: false, hasXfa: false, + fields: null, }); acroForm.set("Fields", ["foo", "bar"]); @@ -115,6 +121,7 @@ describe("document", function () { expect(pdfDocument.formInfo).toEqual({ hasAcroForm: true, hasXfa: false, + fields: ["foo", "bar"], }); // If the first bit of the `SigFlags` entry is set and the `Fields` array @@ -125,6 +132,7 @@ describe("document", function () { expect(pdfDocument.formInfo).toEqual({ hasAcroForm: true, hasXfa: false, + fields: ["foo", "bar"], }); const annotationDict = new Dict(); @@ -147,6 +155,7 @@ describe("document", function () { expect(pdfDocument.formInfo).toEqual({ hasAcroForm: false, hasXfa: false, + fields: null, }); }); });