From 84d7cccb1d2d26215ba7bd346b03b7375f9c3880 Mon Sep 17 00:00:00 2001 From: calixteman Date: Tue, 30 Mar 2021 17:50:35 +0200 Subject: [PATCH] JS - Handle correctly hierarchy of fields (#13133) * JS - Handle correctly hierarchy of fields - it aims to fix #13132; - annotations can inherit their actions from the parent field; - there are some fields which act as a container for other fields: - they can be access through js so need to add them with an empty type (nothing in the spec about that but checked in Acrobat); - calculation order list (CO) can reference them so need make them through this.getField; - getArray method must return kids. - field values are number, string, ... depending of their type but nothing in the spec on how to know what's the type: - according to the comment for Canonical Format: https://www.adobe.com/content/dam/acom/en/devnet/pdf/pdfs/PDF32000_2008.pdf#page=461 - it seems that this "type" can be guessed from js action Format (when setting a type in Acrobat DC, the only affected thing is this action). - util.scand with an empty string returns the current date. --- src/core/annotation.js | 191 +++++++++++++++++----------- src/core/core_utils.js | 37 ++++-- src/core/document.js | 6 +- src/display/annotation_layer.js | 6 +- src/scripting_api/common.js | 34 ++++- src/scripting_api/event.js | 15 ++- src/scripting_api/field.js | 27 +++- src/scripting_api/initialization.js | 32 ++++- src/scripting_api/util.js | 14 +- test/integration/scripting_spec.js | 67 ++++++++++ test/pdfs/issue13132.pdf.link | 1 + test/test_manifest.json | 6 + web/pdf_scripting_manager.js | 7 +- 13 files changed, 337 insertions(+), 106 deletions(-) create mode 100644 test/pdfs/issue13132.pdf.link diff --git a/src/core/annotation.js b/src/core/annotation.js index bd65ebd61..d6ace4016 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -64,10 +64,11 @@ class AnnotationFactory { * @param {Object} ref * @param {PDFManager} pdfManager * @param {Object} idFactory + * @param {boolean} collectFields * @returns {Promise} A promise that is resolved with an {Annotation} * instance. */ - static create(xref, ref, pdfManager, idFactory) { + static create(xref, ref, pdfManager, idFactory, collectFields) { return pdfManager.ensureCatalog("acroForm").then(acroForm => { return pdfManager.ensure(this, "_create", [ xref, @@ -75,6 +76,7 @@ class AnnotationFactory { pdfManager, idFactory, acroForm, + collectFields, ]); }); } @@ -82,7 +84,7 @@ class AnnotationFactory { /** * @private */ - static _create(xref, ref, pdfManager, idFactory, acroForm) { + static _create(xref, ref, pdfManager, idFactory, acroForm, collectFields) { const dict = xref.fetchIfRef(ref); if (!isDict(dict)) { return undefined; @@ -103,6 +105,7 @@ class AnnotationFactory { id, pdfManager, acroForm: acroForm instanceof Dict ? acroForm : Dict.empty, + collectFields, }; switch (subtype) { @@ -178,15 +181,17 @@ class AnnotationFactory { return new FileAttachmentAnnotation(parameters); default: - if (!subtype) { - warn("Annotation is missing the required /Subtype."); - } else { - warn( - 'Unimplemented annotation type "' + - subtype + - '", ' + - "falling back to base annotation." - ); + if (!collectFields) { + if (!subtype) { + warn("Annotation is missing the required /Subtype."); + } else { + warn( + 'Unimplemented annotation type "' + + subtype + + '", ' + + "falling back to base annotation." + ); + } } return new Annotation(parameters); } @@ -345,6 +350,31 @@ class Annotation { subtype: params.subtype, }; + if (params.collectFields) { + // Fields can act as container for other fields and have + // some actions even if no Annotation inherit from them. + // Those fields can be referenced by CO (calculation order). + const kids = dict.get("Kids"); + if (Array.isArray(kids)) { + const kidIds = []; + for (const kid of kids) { + if (isRef(kid)) { + kidIds.push(kid.toString()); + } + } + if (kidIds.length !== 0) { + this.data.kidIds = kidIds; + } + } + + this.data.actions = collectActions( + params.xref, + dict, + AnnotationActionEventType + ); + this.data.fieldName = this._constructFieldName(dict); + } + this._fallbackFontDict = null; } @@ -644,6 +674,15 @@ class Annotation { * @returns {Object | null} */ getFieldObject() { + if (this.data.kidIds) { + return { + id: this.data.id, + actions: this.data.actions, + name: this.data.fieldName, + type: "", + kidIds: this.data.kidIds, + }; + } return null; } @@ -670,6 +709,65 @@ class Annotation { stream.reset(); } } + + /** + * Construct the (fully qualified) field name from the (partial) field + * names of the field and its ancestors. + * + * @private + * @memberof Annotation + * @param {Dict} dict - Complete widget annotation dictionary + * @returns {string} + */ + _constructFieldName(dict) { + // Both the `Parent` and `T` fields are optional. While at least one of + // them should be provided, bad PDF generators may fail to do so. + if (!dict.has("T") && !dict.has("Parent")) { + warn("Unknown field name, falling back to empty field name."); + return ""; + } + + // If no parent exists, the partial and fully qualified names are equal. + if (!dict.has("Parent")) { + return stringToPDFString(dict.get("T")); + } + + // Form the fully qualified field name by appending the partial name to + // the parent's fully qualified name, separated by a period. + const fieldName = []; + if (dict.has("T")) { + fieldName.unshift(stringToPDFString(dict.get("T"))); + } + + let loopDict = dict; + const visited = new RefSet(); + if (dict.objId) { + visited.put(dict.objId); + } + while (loopDict.has("Parent")) { + loopDict = loopDict.get("Parent"); + if ( + !(loopDict instanceof Dict) || + (loopDict.objId && visited.has(loopDict.objId)) + ) { + // Even though it is not allowed according to the PDF specification, + // bad PDF generators may provide a `Parent` entry that is not a + // dictionary, but `null` for example (issue 8143). + // + // If parent has been already visited, it means that we're + // in an infinite loop. + break; + } + if (loopDict.objId) { + visited.put(loopDict.objId); + } + + if (loopDict.has("T")) { + fieldName.unshift(stringToPDFString(loopDict.get("T"))); + } + } + return fieldName.join("."); + } } /** @@ -995,8 +1093,16 @@ class WidgetAnnotation extends Annotation { this.ref = params.ref; data.annotationType = AnnotationType.WIDGET; - data.fieldName = this._constructFieldName(dict); - data.actions = collectActions(params.xref, dict, AnnotationActionEventType); + if (data.fieldName === undefined) { + data.fieldName = this._constructFieldName(dict); + } + if (data.actions === undefined) { + data.actions = collectActions( + params.xref, + dict, + AnnotationActionEventType + ); + } const fieldValue = getInheritableProperty({ dict, @@ -1059,65 +1165,6 @@ class WidgetAnnotation extends Annotation { } } - /** - * Construct the (fully qualified) field name from the (partial) field - * names of the field and its ancestors. - * - * @private - * @memberof WidgetAnnotation - * @param {Dict} dict - Complete widget annotation dictionary - * @returns {string} - */ - _constructFieldName(dict) { - // Both the `Parent` and `T` fields are optional. While at least one of - // them should be provided, bad PDF generators may fail to do so. - if (!dict.has("T") && !dict.has("Parent")) { - warn("Unknown field name, falling back to empty field name."); - return ""; - } - - // If no parent exists, the partial and fully qualified names are equal. - if (!dict.has("Parent")) { - return stringToPDFString(dict.get("T")); - } - - // Form the fully qualified field name by appending the partial name to - // the parent's fully qualified name, separated by a period. - const fieldName = []; - if (dict.has("T")) { - fieldName.unshift(stringToPDFString(dict.get("T"))); - } - - let loopDict = dict; - const visited = new RefSet(); - if (dict.objId) { - visited.put(dict.objId); - } - while (loopDict.has("Parent")) { - loopDict = loopDict.get("Parent"); - if ( - !(loopDict instanceof Dict) || - (loopDict.objId && visited.has(loopDict.objId)) - ) { - // Even though it is not allowed according to the PDF specification, - // bad PDF generators may provide a `Parent` entry that is not a - // dictionary, but `null` for example (issue 8143). - // - // If parent has been already visited, it means that we're - // in an infinite loop. - break; - } - if (loopDict.objId) { - visited.put(loopDict.objId); - } - - if (loopDict.has("T")) { - fieldName.unshift(stringToPDFString(loopDict.get("T"))); - } - } - return fieldName.join("."); - } - /** * Decode the given form value. * diff --git a/src/core/core_utils.js b/src/core/core_utils.js index 70309919e..c80dc4b52 100644 --- a/src/core/core_utils.js +++ b/src/core/core_utils.js @@ -287,19 +287,34 @@ function _collectJS(entry, xref, list, parents) { function collectActions(xref, dict, eventType) { const actions = Object.create(null); - if (dict.has("AA")) { - const additionalActions = dict.get("AA"); - for (const key of additionalActions.getKeys()) { - const action = eventType[key]; - if (!action) { + const additionalActionsDicts = getInheritableProperty({ + dict, + key: "AA", + stopWhenFound: false, + }); + if (additionalActionsDicts) { + // additionalActionsDicts contains dicts from ancestors + // as they're found in the tree from bottom to top. + // So the dicts are visited in reverse order to guarantee + // that actions from elder ancestors will be overwritten + // by ones from younger ancestors. + for (let i = additionalActionsDicts.length - 1; i >= 0; i--) { + const additionalActions = additionalActionsDicts[i]; + if (!(additionalActions instanceof Dict)) { continue; } - const actionDict = additionalActions.getRaw(key); - const parents = new RefSet(); - const list = []; - _collectJS(actionDict, xref, list, parents); - if (list.length > 0) { - actions[action] = list; + for (const key of additionalActions.getKeys()) { + const action = eventType[key]; + if (!action) { + continue; + } + const actionDict = additionalActions.getRaw(key); + const parents = new RefSet(); + const list = []; + _collectJS(actionDict, xref, list, parents); + if (list.length > 0) { + actions[action] = list; + } } } } diff --git a/src/core/document.js b/src/core/document.js index 60613fc04..ff5c76cb8 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -471,7 +471,8 @@ class Page { this.xref, annotationRef, this.pdfManager, - this._localIdFactory + this._localIdFactory, + /* collectFields */ false ).catch(function (reason) { warn(`_parsedAnnotations: "${reason}".`); return null; @@ -1098,7 +1099,8 @@ class PDFDocument { this.xref, fieldRef, this.pdfManager, - this._localIdFactory + this._localIdFactory, + /* collectFields */ true ) .then(annotation => annotation && annotation.getFieldObject()) .catch(function (reason) { diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 52e810429..84b3875cd 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -636,9 +636,11 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { // NOTE: We cannot set the values using `element.value` below, since it // prevents the AnnotationLayer rasterizer in `test/driver.js` // from parsing the elements correctly for the reference tests. - const textContent = storage.getValue(id, { + const storedData = storage.getValue(id, { value: this.data.fieldValue, - }).value; + valueAsString: this.data.fieldValue, + }); + const textContent = storedData.valueAsString || storedData.value || ""; const elementData = { userValue: null, formattedValue: null, diff --git a/src/scripting_api/common.js b/src/scripting_api/common.js index 901859f9e..3728689cf 100644 --- a/src/scripting_api/common.js +++ b/src/scripting_api/common.js @@ -13,6 +13,14 @@ * limitations under the License. */ +const FieldType = { + none: 0, + number: 1, + percent: 2, + date: 3, + time: 4, +}; + function createActionsMap(actions) { const actionsMap = new Map(); if (actions) { @@ -23,4 +31,28 @@ function createActionsMap(actions) { return actionsMap; } -export { createActionsMap }; +function getFieldType(actions) { + let format = actions.get("Format"); + if (!format) { + return FieldType.none; + } + + format = format[0]; + + format = format.trim(); + if (format.startsWith("AFNumber_")) { + return FieldType.number; + } + if (format.startsWith("AFPercent_")) { + return FieldType.percent; + } + if (format.startsWith("AFDate_")) { + return FieldType.date; + } + if (format.startsWith("AFTime__")) { + return FieldType.time; + } + return FieldType.none; +} + +export { createActionsMap, FieldType, getFieldType }; diff --git a/src/scripting_api/event.js b/src/scripting_api/event.js index 2806a6a0c..c49c45302 100644 --- a/src/scripting_api/event.js +++ b/src/scripting_api/event.js @@ -187,16 +187,27 @@ class EventDispatcher { continue; } + event.value = null; const target = this._objects[targetId]; this.runActions(source, target, event, "Calculate"); + if (!event.rc) { + continue; + } + if (event.value !== null) { + target.wrapped.value = event.value; + } + + event.value = target.obj.value; this.runActions(target, target, event, "Validate"); if (!event.rc) { continue; } - target.wrapped.value = event.value; + event.value = target.obj.value; this.runActions(target, target, event, "Format"); - target.wrapped.valueAsString = event.value; + if (event.value !== null) { + target.wrapped.valueAsString = event.value; + } } } } diff --git a/src/scripting_api/field.js b/src/scripting_api/field.js index 5432218cc..0ae9e4cbc 100644 --- a/src/scripting_api/field.js +++ b/src/scripting_api/field.js @@ -13,8 +13,8 @@ * limitations under the License. */ +import { createActionsMap, FieldType, getFieldType } from "./common.js"; import { Color } from "./color.js"; -import { createActionsMap } from "./common.js"; import { PDFObject } from "./pdf_object.js"; class Field extends PDFObject { @@ -82,8 +82,11 @@ class Field extends PDFObject { this._textColor = data.textColor || ["G", 0]; this._value = data.value || ""; this._valueAsString = data.valueAsString; + this._kidIds = data.kidIds || null; + this._fieldType = getFieldType(this._actions); this._globalEval = data.globalEval; + this._appObjects = data.appObjects; } get currentValueIndices() { @@ -200,7 +203,23 @@ class Field extends PDFObject { } set value(value) { - this._value = value; + if (value === "") { + this._value = ""; + } else if (typeof value === "string") { + switch (this._fieldType) { + case FieldType.number: + case FieldType.percent: + value = parseFloat(value); + if (!isNaN(value)) { + this._value = value; + } + break; + default: + this._value = value; + } + } else { + this._value = value; + } if (this._isChoice) { if (this.multipleSelection) { const values = new Set(value); @@ -332,6 +351,10 @@ class Field extends PDFObject { } getArray() { + if (this._kidIds) { + return this._kidIds.map(id => this._appObjects[id].wrapped); + } + if (this._children === null) { this._children = this._document.obj._getChildren(this._fieldPath); } diff --git a/src/scripting_api/initialization.js b/src/scripting_api/initialization.js index 17f36e577..f1c4484e7 100644 --- a/src/scripting_api/initialization.js +++ b/src/scripting_api/initialization.js @@ -67,20 +67,39 @@ function initSandbox(params) { }); const util = new Util({ externalCall }); + const appObjects = app._objects; if (data.objects) { + const annotations = []; + for (const [name, objs] of Object.entries(data.objects)) { - const obj = objs[0]; - obj.send = send; + annotations.length = 0; + let container = null; + + for (const obj of objs) { + if (obj.type !== "") { + annotations.push(obj); + } else { + container = obj; + } + } + + let obj = container; + if (annotations.length > 0) { + obj = annotations[0]; + obj.send = send; + } + obj.globalEval = globalEval; obj.doc = _document; obj.fieldPath = name; + obj.appObjects = appObjects; let field; if (obj.type === "radiobutton") { - const otherButtons = objs.slice(1); + const otherButtons = annotations.slice(1); field = new RadioButtonField(otherButtons, obj); } else if (obj.type === "checkbox") { - const otherButtons = objs.slice(1); + const otherButtons = annotations.slice(1); field = new CheckboxField(otherButtons, obj); } else { field = new Field(obj); @@ -90,7 +109,10 @@ function initSandbox(params) { doc._addField(name, wrapped); const _object = { obj: field, wrapped }; for (const object of objs) { - app._objects[object.id] = _object; + appObjects[object.id] = _object; + } + if (container) { + appObjects[container.id] = _object; } } } diff --git a/src/scripting_api/util.js b/src/scripting_api/util.js index 234c8ed38..4a55affae 100644 --- a/src/scripting_api/util.js +++ b/src/scripting_api/util.js @@ -372,6 +372,10 @@ class Util extends PDFObject { } scand(cFormat, cDate) { + if (cDate === "") { + return new Date(); + } + switch (cFormat) { case 0: return this.scand("D:yyyymmddHHMMss", cDate); @@ -525,14 +529,14 @@ class Util extends PDFObject { } ); - this._scandCache.set(cFormat, [new RegExp(re, "g"), actions]); + this._scandCache.set(cFormat, [re, actions]); } - const [regexForFormat, actions] = this._scandCache.get(cFormat); + const [re, actions] = this._scandCache.get(cFormat); - const matches = regexForFormat.exec(cDate); - if (matches.length !== actions.length + 1) { - throw new Error("Invalid date in util.scand"); + const matches = new RegExp(re, "g").exec(cDate); + if (!matches || matches.length !== actions.length + 1) { + return null; } const data = { diff --git a/test/integration/scripting_spec.js b/test/integration/scripting_spec.js index 2c9a00402..7f115e39e 100644 --- a/test/integration/scripting_spec.js +++ b/test/integration/scripting_spec.js @@ -695,4 +695,71 @@ describe("Interaction", () => { ); }); }); + + describe("in issue13132.pdf", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("issue13132.pdf", "#\\31 71R"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must compute sum of fields", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await page.waitForFunction( + "window.PDFViewerApplication.scriptingReady === true" + ); + + await page.evaluate(() => { + window.document.getElementById("171R").scrollIntoView(); + }); + + let sum = 0; + for (const [id, val] of [ + ["#\\31 38R", 1], + ["#\\37 7R", 2], + ["#\\39 3R", 3], + ["#\\31 51R", 4], + ["#\\37 9R", 5], + ]) { + const prev = await page.$eval("#\\31 71R", el => el.value); + + await page.type(id, val.toString(), { delay: 100 }); + await page.keyboard.press("Tab"); + + await page.waitForFunction( + _prev => + getComputedStyle(document.querySelector("#\\31 71R")).value !== + _prev, + {}, + prev + ); + + sum += val; + + const total = await page.$eval("#\\31 71R", el => el.value); + expect(total).withContext(`In ${browserName}`).toEqual(`£${sum}`); + } + + // Some unrendered annotations have been updated, so check + // that they've the correct value when rendered. + await page.evaluate(() => { + window.document + .querySelectorAll('[data-page-number="4"][class="page"]')[0] + .scrollIntoView(); + }); + await page.waitForSelector("#\\32 99R", { + timeout: 0, + }); + + const total = await page.$eval("#\\32 99R", el => el.value); + expect(total).withContext(`In ${browserName}`).toEqual(`£${sum}`); + }) + ); + }); + }); }); diff --git a/test/pdfs/issue13132.pdf.link b/test/pdfs/issue13132.pdf.link new file mode 100644 index 000000000..3a87df032 --- /dev/null +++ b/test/pdfs/issue13132.pdf.link @@ -0,0 +1 @@ +https://web.archive.org/web/20210324123420/https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/866023/IHT205_2004-2006.pdf diff --git a/test/test_manifest.json b/test/test_manifest.json index 42746e39d..0800d4c37 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -2561,6 +2561,12 @@ "link": true, "type": "load" }, + { "id": "issue13132", + "file": "pdfs/issue13132.pdf", + "md5": "1b28964b9188047bc6c786302c95029f", + "link": true, + "type": "other" + }, { "id": "issue4722", "file": "pdfs/issue4722.pdf", "md5": "a42ca858af7d179358f92f47e57c0fed", diff --git a/web/pdf_scripting_manager.js b/web/pdf_scripting_manager.js index 9906fe8e0..a1dd0339a 100644 --- a/web/pdf_scripting_manager.js +++ b/web/pdf_scripting_manager.js @@ -313,10 +313,9 @@ class PDFScriptingManager { if (element) { element.dispatchEvent(new CustomEvent("updatefromsandbox", { detail })); } else { - if (value !== undefined && value !== null) { - // The element hasn't been rendered yet, use the AnnotationStorage. - this._pdfDocument?.annotationStorage.setValue(id, value); - } + delete detail.id; + // The element hasn't been rendered yet, use the AnnotationStorage. + this._pdfDocument?.annotationStorage.setValue(id, detail); } }