pdf.js/src/core/xfa/bind.js
Calixte Denizet 429ffdcd2f XFA - Save filled data in the pdf when downloading the file (Bug 1716288)
- when binding (after parsing) we get a map between some template nodes and some data nodes;
  - so set user data in input handlers in using data node uids in the annotation storage;
  - to save the form, just put the value we have in the storage in the correct data nodes, serialize the xml as a string and then write the string at the end of the pdf using src/core/writer.js;
  - fix few bugs around data bindings:
    - the "Off" issue in Bug 1716980.
2021-06-25 18:57:01 +02:00

644 lines
17 KiB
JavaScript

/* Copyright 2021 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
$appendChild,
$clone,
$consumed,
$content,
$data,
$finalize,
$getAttributeIt,
$getChildren,
$getDataValue,
$getParent,
$getRealChildrenByNameIt,
$global,
$hasSettableValue,
$indexOf,
$insertAt,
$isBindable,
$isDataValue,
$isDescendent,
$namespaceId,
$nodeName,
$removeChild,
$setValue,
$text,
XFAAttribute,
XmlObject,
} from "./xfa_object.js";
import { BindItems, Field, Items, SetProperty, Text } from "./template.js";
import { createDataNode, searchNode } from "./som.js";
import { NamespaceIds } from "./namespaces.js";
import { warn } from "../../shared/util.js";
function createText(content) {
const node = new Text({});
node[$content] = content;
return node;
}
class Binder {
constructor(root) {
this.root = root;
this.datasets = root.datasets;
if (root.datasets && root.datasets.data) {
this.data = root.datasets.data;
} else {
this.data = new XmlObject(NamespaceIds.datasets.id, "data");
}
this.emptyMerge = this.data[$getChildren]().length === 0;
this.root.form = this.form = root.template[$clone]();
}
_isConsumeData() {
return !this.emptyMerge && this._mergeMode;
}
_isMatchTemplate() {
return !this._isConsumeData();
}
bind() {
this._bindElement(this.form, this.data);
return this.form;
}
getData() {
return this.data;
}
_bindValue(formNode, data, picture) {
// Nodes must have the same "type": container or value.
// Here we make the link between form node and
// data node (through $data property): we'll use it
// to save form data.
formNode[$data] = data;
if (formNode[$hasSettableValue]()) {
if (data[$isDataValue]()) {
const value = data[$getDataValue]();
// TODO: use picture.
formNode[$setValue](createText(value));
} else if (
formNode instanceof Field &&
formNode.ui &&
formNode.ui.choiceList &&
formNode.ui.choiceList.open === "multiSelect"
) {
const value = data[$getChildren]()
.map(child => child[$content].trim())
.join("\n");
formNode[$setValue](createText(value));
} else if (this._isConsumeData()) {
warn(`XFA - Nodes haven't the same type.`);
}
} else if (!data[$isDataValue]() || this._isMatchTemplate()) {
this._bindElement(formNode, data);
} else {
warn(`XFA - Nodes haven't the same type.`);
}
}
_findDataByNameToConsume(name, isValue, dataNode, global) {
if (!name) {
return null;
}
// Firstly, we try to find a node with the given name:
// - in dataNode;
// - if not found, then in parent;
// - and if not in found, then in grand-parent.
let generator, match;
for (let i = 0; i < 3; i++) {
generator = dataNode[$getRealChildrenByNameIt](
name,
/* allTransparent = */ false,
/* skipConsumed = */ true
);
// Try to find a match of the same kind.
while (true) {
match = generator.next().value;
if (!match) {
break;
}
if (isValue === match[$isDataValue]()) {
return match;
}
}
if (
dataNode[$namespaceId] === NamespaceIds.datasets.id &&
dataNode[$nodeName] === "data"
) {
break;
}
dataNode = dataNode[$getParent]();
}
if (!global) {
return null;
}
// Secondly, if global try to find it just under the root of datasets
// (which is the location of global variables).
generator = this.data[$getRealChildrenByNameIt](
name,
/* allTransparent = */ false,
/* skipConsumed = */ false
);
while (true) {
match = generator.next().value;
if (!match) {
break;
}
if (match[$global]) {
return match;
}
}
// Thirdly, try to find it in attributes.
generator = this.data[$getAttributeIt](name, /* skipConsumed = */ true);
match = generator.next().value;
if (match && match[$isDataValue]()) {
return match;
}
return null;
}
_setProperties(formNode, dataNode) {
// For example:
// <field name="LastName" ...>
// <setProperty ref="$data.Main.Style.NameFont" target="font.typeface"/>
// <setProperty ref="$data.Main.Style.NameSize" target="font.size"/>
// <setProperty ref="$data.Main.Help.LastName" target="assist.toolTip"/>
// </field>
if (!formNode.hasOwnProperty("setProperty")) {
return;
}
for (const { ref, target, connection } of formNode.setProperty.children) {
if (connection) {
// TODO: evaluate if we should implement this feature.
// Skip for security reasons.
continue;
}
if (!ref) {
continue;
}
const [node] = searchNode(
this.root,
dataNode,
ref,
false /* = dotDotAllowed */,
false /* = useCache */
);
if (!node) {
warn(`XFA - Invalid reference: ${ref}.`);
continue;
}
if (!node[$isDescendent](this.data)) {
warn(`XFA - Invalid node: must be a data node.`);
continue;
}
const [targetNode] = searchNode(
this.root,
formNode,
target,
false /* = dotDotAllowed */,
false /* = useCache */
);
if (!targetNode) {
warn(`XFA - Invalid target: ${target}.`);
continue;
}
if (!targetNode[$isDescendent](formNode)) {
warn(`XFA - Invalid target: must be a property or subproperty.`);
continue;
}
const targetParent = targetNode[$getParent]();
if (
targetNode instanceof SetProperty ||
targetParent instanceof SetProperty
) {
warn(
`XFA - Invalid target: cannot be a setProperty or one of its properties.`
);
continue;
}
if (
targetNode instanceof BindItems ||
targetParent instanceof BindItems
) {
warn(
`XFA - Invalid target: cannot be a bindItems or one of its properties.`
);
continue;
}
const content = node[$text]();
const name = targetNode[$nodeName];
if (targetNode instanceof XFAAttribute) {
const attrs = Object.create(null);
attrs[name] = content;
const obj = Reflect.construct(
Object.getPrototypeOf(targetParent).constructor,
[attrs]
);
targetParent[name] = obj[name];
continue;
}
if (!targetNode.hasOwnProperty($content)) {
warn(`XFA - Invalid node to use in setProperty`);
continue;
}
targetNode[$data] = node;
targetNode[$content] = content;
targetNode[$finalize]();
}
}
_bindItems(formNode, dataNode) {
// For example:
// <field name="CardName"...>
// <bindItems ref="$data.main.ccs.cc[*]" labelRef="uiname"
// valueRef="token"/>
// <ui><choiceList/></ui>
// </field>
if (
!formNode.hasOwnProperty("items") ||
!formNode.hasOwnProperty("bindItems") ||
formNode.bindItems.isEmpty()
) {
return;
}
for (const item of formNode.items.children) {
formNode[$removeChild](item);
}
formNode.items.clear();
const labels = new Items({});
const values = new Items({});
formNode[$appendChild](labels);
formNode.items.push(labels);
formNode[$appendChild](values);
formNode.items.push(values);
for (const { ref, labelRef, valueRef, connection } of formNode.bindItems
.children) {
if (connection) {
// TODO: evaluate if we should implement this feature.
// Skip for security reasons.
continue;
}
if (!ref) {
continue;
}
const nodes = searchNode(
this.root,
dataNode,
ref,
false /* = dotDotAllowed */,
false /* = useCache */
);
if (!nodes) {
warn(`XFA - Invalid reference: ${ref}.`);
continue;
}
for (const node of nodes) {
if (!node[$isDescendent](this.datasets)) {
warn(`XFA - Invalid ref (${ref}): must be a datasets child.`);
continue;
}
const [labelNode] = searchNode(
this.root,
node,
labelRef,
true /* = dotDotAllowed */,
false /* = useCache */
);
if (!labelNode) {
warn(`XFA - Invalid label: ${labelRef}.`);
continue;
}
if (!labelNode[$isDescendent](this.datasets)) {
warn(`XFA - Invalid label: must be a datasets child.`);
continue;
}
const [valueNode] = searchNode(
this.root,
node,
valueRef,
true /* = dotDotAllowed */,
false /* = useCache */
);
if (!valueNode) {
warn(`XFA - Invalid value: ${valueRef}.`);
continue;
}
if (!valueNode[$isDescendent](this.datasets)) {
warn(`XFA - Invalid value: must be a datasets child.`);
continue;
}
const label = createText(labelNode[$text]());
const value = createText(valueNode[$text]());
labels[$appendChild](label);
labels.text.push(label);
values[$appendChild](value);
values.text.push(value);
}
}
}
_bindOccurrences(formNode, matches, picture) {
// Insert nodes which are not in the template but reflect
// what we've in data tree.
let baseClone;
if (matches.length > 1) {
// Clone before binding to avoid bad state.
baseClone = formNode[$clone]();
}
this._bindValue(formNode, matches[0], picture);
this._setProperties(formNode, matches[0]);
this._bindItems(formNode, matches[0]);
if (matches.length === 1) {
return;
}
const parent = formNode[$getParent]();
const name = formNode[$nodeName];
const pos = parent[$indexOf](formNode);
for (let i = 1, ii = matches.length; i < ii; i++) {
const match = matches[i];
const clone = baseClone[$clone]();
clone.occur.min = 1;
clone.occur.max = 1;
clone.occur.initial = 1;
parent[name].push(clone);
parent[$insertAt](pos + i, clone);
this._bindValue(clone, match, picture);
this._setProperties(clone, match);
this._bindItems(clone, match);
}
}
_createOccurrences(formNode) {
if (!this.emptyMerge) {
return;
}
const { occur } = formNode;
if (!occur || occur.initial <= 1) {
return;
}
const parent = formNode[$getParent]();
const name = formNode[$nodeName];
for (let i = 0, ii = occur.initial; i < ii; i++) {
const clone = formNode[$clone]();
clone.occur.min = 1;
clone.occur.max = 1;
clone.occur.initial = 1;
parent[name].push(clone);
parent[$appendChild](clone);
}
}
_getOccurInfo(formNode) {
const { occur } = formNode;
const dataName = formNode.name;
if (!occur || !dataName) {
return [1, 1];
}
const max = occur.max === -1 ? Infinity : occur.max;
return [occur.min, max];
}
_bindElement(formNode, dataNode) {
// Some nodes can be useless because min=0 so remove them
// after the loop to avoid bad things.
const uselessNodes = [];
this._createOccurrences(formNode);
for (const child of formNode[$getChildren]()) {
if (child[$data]) {
// Already bound.
continue;
}
if (this._mergeMode === undefined && child[$nodeName] === "subform") {
this._mergeMode = child.mergeMode === "consumeData";
// XFA specs p. 182:
// The highest-level subform and the data node representing
// the current record are special; they are always
// bound even if their names don't match.
const dataChildren = dataNode[$getChildren]();
if (dataChildren.length > 0) {
this._bindOccurrences(child, [dataChildren[0]], null);
} else if (this.emptyMerge) {
const dataChild = new XmlObject(
dataNode[$namespaceId],
child.name || "root"
);
dataNode[$appendChild](dataChild);
this._bindElement(child, dataChild);
}
continue;
}
if (!child[$isBindable]()) {
// The node cannot contain some new data so there is nothing
// to create in the data node.
continue;
}
let global = false;
let picture = null;
let ref = null;
let match = null;
if (child.bind) {
switch (child.bind.match) {
case "none":
this._bindElement(child, dataNode);
continue;
case "global":
global = true;
break;
case "dataRef":
if (!child.bind.ref) {
warn(`XFA - ref is empty in node ${child[$nodeName]}.`);
this._bindElement(child, dataNode);
continue;
}
ref = child.bind.ref;
break;
default:
break;
}
if (child.bind.picture) {
picture = child.bind.picture[$content];
}
}
const [min, max] = this._getOccurInfo(child);
if (ref) {
// Don't use a cache for searching: nodes can change during binding.
match = searchNode(
this.root,
dataNode,
ref,
true /* = dotDotAllowed */,
false /* = useCache */
);
if (match === null) {
// Nothing found: we must create some nodes in data in order
// to have something to match with the given expression.
// See http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.364.2157&rep=rep1&type=pdf#page=199
match = createDataNode(this.data, dataNode, ref);
if (this._isConsumeData()) {
match[$consumed] = true;
}
// Don't bind the value in newly created node because it's empty.
this._bindElement(child, match);
continue;
} else {
if (this._isConsumeData()) {
// Filter out consumed nodes.
match = match.filter(node => !node[$consumed]);
}
if (match.length > max) {
match = match.slice(0, max);
} else if (match.length === 0) {
match = null;
}
if (match && this._isConsumeData()) {
match.forEach(node => {
node[$consumed] = true;
});
}
}
} else {
if (!child.name) {
this._bindElement(child, dataNode);
continue;
}
if (this._isConsumeData()) {
// In consumeData mode, search for the next node with the given name.
// occurs.max gives us the max number of node to match.
const matches = [];
while (matches.length < max) {
const found = this._findDataByNameToConsume(
child.name,
child[$hasSettableValue](),
dataNode,
global
);
if (!found) {
break;
}
found[$consumed] = true;
matches.push(found);
}
match = matches.length > 0 ? matches : null;
} else {
// If we've an empty merge, there are no reason
// to make multiple bind so skip consumed nodes.
match = dataNode[$getRealChildrenByNameIt](
child.name,
/* allTransparent = */ false,
/* skipConsumed = */ this.emptyMerge
).next().value;
if (!match) {
// We're in matchTemplate mode so create a node in data to reflect
// what we've in template.
match = new XmlObject(dataNode[$namespaceId], child.name);
if (this.emptyMerge) {
match[$consumed] = true;
}
dataNode[$appendChild](match);
// Don't bind the value in newly created node because it's empty.
this._bindElement(child, match);
continue;
}
if (this.emptyMerge) {
match[$consumed] = true;
}
match = [match];
}
}
if (match) {
if (match.length < min) {
warn(
`XFA - Must have at least ${min} occurrences: ${formNode[$nodeName]}.`
);
continue;
}
this._bindOccurrences(child, match, picture);
} else if (min > 0) {
this._setProperties(child, dataNode);
this._bindItems(child, dataNode);
this._bindElement(child, dataNode);
} else {
uselessNodes.push(child);
}
}
uselessNodes.forEach(node => node[$getParent]()[$removeChild](node));
}
}
export { Binder };