diff --git a/src/core/xfa/bind.js b/src/core/xfa/bind.js new file mode 100644 index 000000000..209c837bd --- /dev/null +++ b/src/core/xfa/bind.js @@ -0,0 +1,593 @@ +/* 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, + $getParent, + $getRealChildrenByNameIt, + $global, + $hasSettableValue, + $indexOf, + $insertAt, + $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.emptyMerge = false; + this.data = root.datasets.data; + } else { + this.emptyMerge = true; + this.data = new XmlObject(NamespaceIds.datasets.id, "data"); + } + 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. + + if (formNode[$hasSettableValue]()) { + if (data[$isDataValue]()) { + const value = data[$content].trim(); + // TODO: use picture. + formNode[$setValue](createText(value)); + formNode[$data] = data; + } 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)); + formNode[$data] = data; + } else if (this._isConsumeData()) { + warn(`XFA - Nodes haven't the same type.`); + } + } else if (!data[$isDataValue]() || this._isMatchTemplate()) { + this._bindElement(formNode, data); + formNode[$data] = data; + } else { + warn(`XFA - Nodes haven't the same type.`); + } + } + + _findDataByNameToConsume(name, 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 + ); + match = generator.next().value; + if (match) { + 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.datasets[$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: + // + // + // + // + // + + 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: + // + // + // + // + + 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"; + } + + let global = false; + let picture = null; + let ref = null; + let match = null; + if (child.bind) { + switch (child.bind.match) { + case "none": + continue; + case "global": + global = true; + break; + case "dataRef": + if (!child.bind.ref) { + warn(`XFA - ref is empty in node ${child[$nodeName]}.`); + 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; + } + match = [match]; + } 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, + dataNode, + global + ); + if (!found) { + break; + } + found[$consumed] = true; + matches.push(found); + } + match = matches.length > 0 ? matches : null; + } else { + match = dataNode[$getRealChildrenByNameIt]( + child.name, + /* allTransparent = */ false, + /* skipConsumed = */ false + ).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); + dataNode[$appendChild](match); + } + 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._bindElement(child, dataNode); + } else { + uselessNodes.push(child); + } + } + + uselessNodes.forEach(node => node[$getParent]()[$removeChild](node)); + } +} + +export { Binder }; diff --git a/src/core/xfa/builder.js b/src/core/xfa/builder.js index aaed18ec8..511a814bf 100644 --- a/src/core/xfa/builder.js +++ b/src/core/xfa/builder.js @@ -17,6 +17,7 @@ import { $buildXFAObject, NamespaceIds } from "./namespaces.js"; import { $cleanup, $finalize, + $nsAttributes, $onChild, $resolvePrototypes, XFAObject, @@ -88,6 +89,25 @@ class Builder { this._addNamespacePrefix(prefixes); } + if (attributes.hasOwnProperty($nsAttributes)) { + // Only support xfa-data namespace. + const dataTemplate = NamespaceSetUp.datasets; + const nsAttrs = attributes[$nsAttributes]; + let xfaAttrs = null; + for (const [ns, attrs] of Object.entries(nsAttrs)) { + const nsToUse = this._getNamespaceToUse(ns); + if (nsToUse === dataTemplate) { + xfaAttrs = { xfa: attrs }; + break; + } + } + if (xfaAttrs) { + attributes[$nsAttributes] = xfaAttrs; + } else { + delete attributes[$nsAttributes]; + } + } + const namespaceToUse = this._getNamespaceToUse(nsPrefix); const node = (namespaceToUse && namespaceToUse[$buildXFAObject](name, attributes)) || diff --git a/src/core/xfa/datasets.js b/src/core/xfa/datasets.js index b7ad09699..33f80ecc9 100644 --- a/src/core/xfa/datasets.js +++ b/src/core/xfa/datasets.js @@ -13,14 +13,16 @@ * limitations under the License. */ -import { $buildXFAObject, NamespaceIds } from "./namespaces.js"; import { + $appendChild, + $global, $namespaceId, $nodeName, - $onChildCheck, + $onChild, XFAObject, XmlObject, } from "./xfa_object.js"; +import { $buildXFAObject, NamespaceIds } from "./namespaces.js"; const DATASETS_NS_ID = NamespaceIds.datasets.id; @@ -37,15 +39,18 @@ class Datasets extends XFAObject { this.Signature = null; } - [$onChildCheck](child) { + [$onChild](child) { const name = child[$nodeName]; - if (name === "data") { - return child[$namespaceId] === DATASETS_NS_ID; + if ( + (name === "data" && child[$namespaceId] === DATASETS_NS_ID) || + (name === "Signature" && + child[$namespaceId] === NamespaceIds.signature.id) + ) { + this[name] = child; + } else { + child[$global] = true; } - if (name === "Signature") { - return child[$namespaceId] === NamespaceIds.signature.id; - } - return false; + this[$appendChild](child); } } diff --git a/src/core/xfa/factory.js b/src/core/xfa/factory.js new file mode 100644 index 000000000..e78bdf044 --- /dev/null +++ b/src/core/xfa/factory.js @@ -0,0 +1,33 @@ +/* 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 { Binder } from "./bind.js"; +import { XFAParser } from "./parser.js"; + +class XFAFactory { + constructor(data) { + this.root = new XFAParser().parse(XFAFactory._createDocument(data)); + this.form = new Binder(this.root).bind(); + } + + static _createDocument(data) { + if (!data["/xdp:xdp"]) { + return data["xdp:xdp"]; + } + return Object.values(data).join(""); + } +} + +export { XFAFactory }; diff --git a/src/core/xfa/parser.js b/src/core/xfa/parser.js index d0e492d04..ebc83e827 100644 --- a/src/core/xfa/parser.js +++ b/src/core/xfa/parser.js @@ -13,7 +13,14 @@ * limitations under the License. */ -import { $clean, $finalize, $onChild, $onText, $setId } from "./xfa_object.js"; +import { + $clean, + $finalize, + $nsAttributes, + $onChild, + $onText, + $setId, +} from "./xfa_object.js"; import { XMLParserBase, XMLParserErrorCode } from "../xml_parser.js"; import { Builder } from "./builder.js"; import { warn } from "../../shared/util.js"; @@ -57,7 +64,7 @@ class XFAParser extends XMLParserBase { // namespaces information. let namespace = null; let prefixes = null; - const attributeObj = Object.create(null); + const attributeObj = Object.create({}); for (const { name, value } of attributes) { if (name === "xmlns") { if (!namespace) { @@ -72,7 +79,23 @@ class XFAParser extends XMLParserBase { } prefixes.push({ prefix, value }); } else { - attributeObj[name] = value; + const i = name.indexOf(":"); + if (i === -1) { + attributeObj[name] = value; + } else { + // Attributes can have their own namespace. + // For example in data, we can have + let nsAttrs = attributeObj[$nsAttributes]; + if (!nsAttrs) { + nsAttrs = attributeObj[$nsAttributes] = Object.create(null); + } + const [ns, attrName] = [name.slice(0, i), name.slice(i + 1)]; + let attrs = nsAttrs[ns]; + if (!attrs) { + attrs = nsAttrs[ns] = Object.create(null); + } + attrs[attrName] = value; + } } } diff --git a/src/core/xfa/som.js b/src/core/xfa/som.js index d0d394fa1..3fb2d04f5 100644 --- a/src/core/xfa/som.js +++ b/src/core/xfa/som.js @@ -14,11 +14,14 @@ */ import { + $appendChild, $getChildrenByClass, $getChildrenByName, $getParent, + $namespaceId, XFAObject, XFAObjectArray, + XmlObject, } from "./xfa_object.js"; import { warn } from "../../shared/util.js"; @@ -33,17 +36,18 @@ const operators = { }; const shortcuts = new Map([ - ["$data", root => root.datasets.data], - ["$template", root => root.template], - ["$connectionSet", root => root.connectionSet], - ["$form", root => root.form], - ["$layout", root => root.layout], - ["$host", root => root.host], - ["$dataWindow", root => root.dataWindow], - ["$event", root => root.event], - ["!", root => root.datasets], - ["$xfa", root => root], - ["xfa", root => root], + ["$data", (root, current) => root.datasets.data], + ["$template", (root, current) => root.template], + ["$connectionSet", (root, current) => root.connectionSet], + ["$form", (root, current) => root.form], + ["$layout", (root, current) => root.layout], + ["$host", (root, current) => root.host], + ["$dataWindow", (root, current) => root.dataWindow], + ["$event", (root, current) => root.event], + ["!", (root, current) => root.datasets], + ["$xfa", (root, current) => root], + ["xfa", (root, current) => root], + ["$", (root, current) => current], ]); const somCache = new WeakMap(); @@ -138,17 +142,24 @@ function parseExpression(expr, dotDotAllowed) { return parsed; } -function searchNode(root, container, expr, dotDotAllowed = true) { +function searchNode( + root, + container, + expr, + dotDotAllowed = true, + useCache = true +) { const parsed = parseExpression(expr, dotDotAllowed); if (!parsed) { return null; } + const fn = shortcuts.get(parsed[0].name); let i = 0; let isQualified; if (fn) { isQualified = true; - root = [fn(root)]; + root = [fn(root, container)]; i = 1; } else { isQualified = container === null; @@ -163,13 +174,17 @@ function searchNode(root, container, expr, dotDotAllowed = true) { continue; } - let cached = somCache.get(node); - if (!cached) { - cached = new Map(); - somCache.set(node, cached); + let children, cached; + + if (useCache) { + cached = somCache.get(node); + if (!cached) { + cached = new Map(); + somCache.set(node, cached); + } + children = cached.get(cacheName); } - let children = cached.get(cacheName); if (!children) { switch (operator) { case operators.dot: @@ -189,7 +204,9 @@ function searchNode(root, container, expr, dotDotAllowed = true) { default: break; } - cached.set(cacheName, children); + if (useCache) { + cached.set(cacheName, children); + } } if (children.length > 0) { @@ -222,11 +239,72 @@ function searchNode(root, container, expr, dotDotAllowed = true) { return null; } - if (root.length === 1) { - return root[0]; - } - return root; } -export { searchNode }; +function createNodes(root, path) { + let node = null; + for (const { name, index } of path) { + for (let i = 0; i <= index; i++) { + node = new XmlObject(root[$namespaceId], name); + root[$appendChild](node); + } + + root = node; + } + return node; +} + +function createDataNode(root, container, expr) { + const parsed = parseExpression(expr); + if (!parsed) { + return null; + } + + if (parsed.some(x => x.operator === operators.dotDot)) { + return null; + } + + const fn = shortcuts.get(parsed[0].name); + let i = 0; + if (fn) { + root = fn(root, container); + i = 1; + } else { + root = container || root; + } + + for (let ii = parsed.length; i < ii; i++) { + const { cacheName, index } = parsed[i]; + if (!isFinite(index)) { + parsed[i].index = 0; + return createNodes(root, parsed.slice(i)); + } + + const cached = somCache.get(root); + if (!cached) { + warn(`XFA - createDataNode must be called after searchNode.`); + return null; + } + + const children = cached.get(cacheName); + if (children.length === 0) { + return createNodes(root, parsed.slice(i)); + } + + if (index < children.length) { + const child = children[index]; + if (!(child instanceof XFAObject)) { + warn(`XFA - Cannot create a node.`); + return null; + } + root = child; + } else { + parsed[i].index = children.length - index; + return createNodes(root, parsed.slice(i)); + } + } + return null; +} + +export { createDataNode, searchNode }; diff --git a/src/core/xfa/template.js b/src/core/xfa/template.js index c9808cb03..0c09abaee 100644 --- a/src/core/xfa/template.js +++ b/src/core/xfa/template.js @@ -13,15 +13,19 @@ * limitations under the License. */ -import { $buildXFAObject, NamespaceIds } from "./namespaces.js"; import { + $appendChild, $content, $finalize, + $hasItem, + $hasSettableValue, $isTransparent, $namespaceId, $nodeName, $onChild, + $removeChild, $setSetAttributes, + $setValue, ContentObject, Option01, OptionObject, @@ -29,6 +33,7 @@ import { XFAObject, XFAObjectArray, } from "./xfa_object.js"; +import { $buildXFAObject, NamespaceIds } from "./namespaces.js"; import { getBBox, getColor, @@ -44,6 +49,15 @@ import { warn } from "../../shared/util.js"; const TEMPLATE_NS_ID = NamespaceIds.template.id; +function _setValue(templateNode, value) { + if (!templateNode.value) { + const nodeValue = new Value({}); + templateNode[$appendChild](nodeValue); + templateNode.value = nodeValue; + } + templateNode.value[$setValue](value); +} + class AppearanceFilter extends StringObject { constructor(attributes) { super(TEMPLATE_NS_ID, "appearanceFilter"); @@ -496,6 +510,10 @@ class Caption extends XFAObject { this.para = null; this.value = null; } + + [$setValue](value) { + _setValue(this, value); + } } class Certificate extends StringObject { @@ -586,6 +604,10 @@ class Color extends XFAObject { this.value = getColor(attributes.value); this.extras = null; } + + [$hasSettableValue]() { + return false; + } } class Comb extends XFAObject { @@ -869,6 +891,10 @@ class Draw extends XFAObject { this.value = null; this.setProperty = new XFAObjectArray(); } + + [$setValue](value) { + _setValue(this, value); + } } class Edge extends XFAObject { @@ -1045,7 +1071,7 @@ class Event extends XFAObject { } class ExData extends ContentObject { - constructor(builder, attributes) { + constructor(attributes) { super(TEMPLATE_NS_ID, "exData"); this.contentType = attributes.contentType || ""; this.href = attributes.href || ""; @@ -1188,6 +1214,32 @@ class ExclGroup extends XFAObject { this.field = new XFAObjectArray(); this.setProperty = new XFAObjectArray(); } + + [$hasSettableValue]() { + return true; + } + + [$setValue](value) { + for (const field of this.field.children) { + if (!field.value) { + const nodeValue = new Value({}); + field[$appendChild](nodeValue); + field.value = nodeValue; + } + + const nodeBoolean = new BooleanElement({}); + nodeBoolean[$content] = 0; + + for (const item of field.items.children) { + if (item[$hasItem](value)) { + nodeBoolean[$content] = 1; + break; + } + } + + field.value[$setValue](nodeBoolean); + } + } } class Execute extends XFAObject { @@ -1294,6 +1346,8 @@ class Field extends XFAObject { this.extras = null; this.font = null; this.format = null; + // For a choice list, one list is used to have display entries + // and the other for the exported values this.items = new XFAObjectArray(2); this.keep = null; this.margin = null; @@ -1307,6 +1361,10 @@ class Field extends XFAObject { this.event = new XFAObjectArray(); this.setProperty = new XFAObjectArray(); } + + [$setValue](value) { + _setValue(this, value); + } } class Fill extends XFAObject { @@ -1590,6 +1648,15 @@ class Items extends XFAObject { this.text = new XFAObjectArray(); this.time = new XFAObjectArray(); } + + [$hasItem](value) { + return ( + this.hasOwnProperty(value[$nodeName]) && + this[value[$nodeName]].children.some( + node => node[$content] === value[$content] + ) + ); + } } class Keep extends XFAObject { @@ -1917,10 +1984,7 @@ class Para extends XFAObject { "right", ]); this.id = attributes.id || ""; - this.lineHeight = getMeasurement(attributes.lineHeight, [ - "0pt", - "measurement", - ]); + this.lineHeight = getMeasurement(attributes.lineHeight, "0pt"); this.marginLeft = getMeasurement(attributes.marginLeft, "0"); this.marginRight = getMeasurement(attributes.marginRight, "0"); this.orphans = getInteger({ @@ -2735,6 +2799,26 @@ class Value extends XFAObject { this.text = null; this.time = null; } + + [$setValue](value) { + const valueName = value[$nodeName]; + if (this[valueName] !== null) { + this[valueName][$content] = value[$content]; + return; + } + + // Reset all the properties. + for (const name of Object.getOwnPropertyNames(this)) { + const obj = this[name]; + if (obj instanceof XFAObject) { + this[name] = null; + this[$removeChild](obj); + } + } + + this[value[$nodeName]] = value; + this[$appendChild](value); + } } class Variables extends XFAObject { @@ -3225,4 +3309,13 @@ class TemplateNamespace { } } -export { Template, TemplateNamespace }; +export { + BindItems, + Field, + Items, + SetProperty, + Template, + TemplateNamespace, + Text, + Value, +}; diff --git a/src/core/xfa/xfa_object.js b/src/core/xfa/xfa_object.js index e081b71b8..37a0fd7ca 100644 --- a/src/core/xfa/xfa_object.js +++ b/src/core/xfa/xfa_object.js @@ -19,43 +19,57 @@ import { NamespaceIds } from "./namespaces.js"; // We use these symbols to avoid name conflict between tags // and properties/methods names. +const $appendChild = Symbol(); const $clean = Symbol(); const $cleanup = Symbol(); +const $clone = Symbol(); +const $consumed = Symbol(); const $content = Symbol("content"); +const $data = Symbol("data"); const $dump = Symbol(); const $finalize = Symbol(); -const $isDataValue = Symbol(); const $getAttributeIt = Symbol(); const $getChildrenByClass = Symbol(); const $getChildrenByName = Symbol(); const $getChildrenByNameIt = Symbol(); +const $getRealChildrenByNameIt = Symbol(); const $getChildren = Symbol(); const $getParent = Symbol(); +const $global = Symbol(); +const $hasItem = Symbol(); +const $hasSettableValue = Symbol(); +const $indexOf = Symbol(); +const $insertAt = Symbol(); +const $isDataValue = Symbol(); +const $isDescendent = Symbol(); const $isTransparent = Symbol(); const $lastAttribute = Symbol(); const $namespaceId = Symbol("namespaceId"); const $nodeName = Symbol("nodeName"); +const $nsAttributes = Symbol(); const $onChild = Symbol(); const $onChildCheck = Symbol(); const $onText = Symbol(); +const $removeChild = Symbol(); const $resolvePrototypes = Symbol(); const $setId = Symbol(); const $setSetAttributes = Symbol(); +const $setValue = Symbol(); const $text = Symbol(); const _applyPrototype = Symbol(); const _attributes = Symbol(); const _attributeNames = Symbol(); -const _children = Symbol(); -const _clone = Symbol(); +const _children = Symbol("_children"); const _cloneAttribute = Symbol(); +const _dataValue = Symbol(); const _defaultValue = Symbol(); const _getPrototype = Symbol(); const _getUnsetAttributes = Symbol(); const _hasChildren = Symbol(); const _max = Symbol(); const _options = Symbol(); -const _parent = Symbol(); +const _parent = Symbol("parent"); const _setAttributes = Symbol(); const _validator = Symbol(); @@ -78,18 +92,27 @@ class XFAObject { if (node instanceof XFAObjectArray) { if (node.push(child)) { - child[_parent] = this; - this[_children].push(child); + this[$appendChild](child); return true; } - } else if (node === null) { + } else { + // IRL it's possible to already have a node. + // So just replace it with the last version. + if (node !== null) { + this[$removeChild](node); + } this[name] = child; - child[_parent] = this; - this[_children].push(child); + this[$appendChild](child); return true; } - warn(`XFA - node "${this[$nodeName]}" has already enough "${name}"!`); + let id = ""; + if (this.id) { + id = ` (id: ${this.id})`; + } else if (this.name) { + id = ` (name: ${this.name} ${this.h.value})`; + } + warn(`XFA - node "${this[$nodeName]}"${id} has already enough "${name}"!`); return false; } @@ -106,6 +129,22 @@ class XFAObject { } } + [$appendChild](child) { + child[_parent] = this; + this[_children].push(child); + } + + [$removeChild](child) { + const i = this[_children].indexOf(child); + this[_children].splice(i, 1); + } + + [$hasSettableValue]() { + return this.hasOwnProperty("value"); + } + + [$setValue](_) {} + [$onText](_) {} [$finalize]() {} @@ -118,6 +157,19 @@ class XFAObject { } } + [$hasItem]() { + return false; + } + + [$indexOf](child) { + return this[_children].indexOf(child); + } + + [$insertAt](i, child) { + child[_parent] = this; + this[_children].splice(i, 0, child); + } + [$isTransparent]() { return this.name === ""; } @@ -126,6 +178,13 @@ class XFAObject { return ""; } + [$text]() { + if (this[_children].length === 0) { + return this[$content]; + } + return this[_children].map(c => c[$text]()).join(""); + } + get [_attributeNames]() { // Lazily get attributes names const proto = Object.getPrototypeOf(this); @@ -145,6 +204,17 @@ class XFAObject { return shadow(this, _attributeNames, proto._attributes); } + [$isDescendent](parent) { + let node = this; + while (node) { + if (node === parent) { + return true; + } + node = node[$getParent](); + } + return false; + } + [$getParent]() { return this[_parent]; } @@ -297,7 +367,7 @@ class XFAObject { i < ii; i++ ) { - const child = proto[_children][i][_clone](); + const child = proto[_children][i][$clone](); if (value.push(child)) { child[_parent] = this; this[_children].push(child); @@ -316,7 +386,7 @@ class XFAObject { } if (protoValue !== null) { - const child = protoValue[_clone](); + const child = protoValue[$clone](); child[_parent] = this; this[name] = child; this[_children].push(child); @@ -335,7 +405,7 @@ class XFAObject { return obj; } - [_clone]() { + [$clone]() { const clone = Object.create(Object.getPrototypeOf(this)); for (const $symbol of Object.getOwnPropertySymbols(this)) { try { @@ -361,7 +431,7 @@ class XFAObject { for (const child of this[_children]) { const name = child[$nodeName]; - const clonedChild = child[_clone](); + const clonedChild = child[$clone](); clone[_children].push(clonedChild); clonedChild[_parent] = clone; if (clone[name] === null) { @@ -444,15 +514,19 @@ class XFAObjectArray { : this[_children].map(x => x[$dump]()); } - [_clone]() { + [$clone]() { const clone = new XFAObjectArray(this[_max]); - clone[_children] = this[_children].map(c => c[_clone]()); + clone[_children] = this[_children].map(c => c[$clone]()); return clone; } get children() { return this[_children]; } + + clear() { + this[_children].length = 0; + } } class XFAAttribute { @@ -460,6 +534,7 @@ class XFAAttribute { this[_parent] = node; this[$nodeName] = name; this[$content] = value; + this[$consumed] = false; } [$getParent]() { @@ -473,27 +548,46 @@ class XFAAttribute { [$text]() { return this[$content]; } + + [$isDescendent](parent) { + return this[_parent] === parent || this[_parent][$isDescendent](parent); + } } class XmlObject extends XFAObject { - constructor(nsId, name, attributes = null) { + constructor(nsId, name, attributes = {}) { super(nsId, name); this[$content] = ""; + this[_dataValue] = null; if (name !== "#text") { - this[_attributes] = attributes; + const map = new Map(); + this[_attributes] = map; + for (const [attrName, value] of Object.entries(attributes)) { + map.set(attrName, new XFAAttribute(this, attrName, value)); + } + if (attributes.hasOwnProperty($nsAttributes)) { + // XFA attributes. + const dataNode = attributes[$nsAttributes].xfa.dataNode; + if (dataNode !== undefined) { + if (dataNode === "dataGroup") { + this[_dataValue] = false; + } else if (dataNode === "dataValue") { + this[_dataValue] = true; + } + } + } } + this[$consumed] = false; } [$onChild](child) { if (this[$content]) { const node = new XmlObject(this[$namespaceId], "#text"); - node[_parent] = this; + this[$appendChild](node); node[$content] = this[$content]; this[$content] = ""; - this[_children].push(node); } - child[_parent] = this; - this[_children].push(child); + this[$appendChild](child); return true; } @@ -504,20 +598,12 @@ class XmlObject extends XFAObject { [$finalize]() { if (this[$content] && this[_children].length > 0) { const node = new XmlObject(this[$namespaceId], "#text"); - node[_parent] = this; + this[$appendChild](node); node[$content] = this[$content]; - this[_children].push(node); delete this[$content]; } } - [$text]() { - if (this[_children].length === 0) { - return this[$content]; - } - return this[_children].map(c => c[$text]()).join(""); - } - [$getChildren](name = null) { if (!name) { return this[_children]; @@ -527,7 +613,7 @@ class XmlObject extends XFAObject { } [$getChildrenByClass](name) { - const value = this[_attributes][name]; + const value = this[_attributes].get(name); if (value !== undefined) { return value; } @@ -535,9 +621,9 @@ class XmlObject extends XFAObject { } *[$getChildrenByNameIt](name, allTransparent) { - const value = this[_attributes][name]; - if (value !== undefined) { - yield new XFAAttribute(this, name, value); + const value = this[_attributes].get(name); + if (value) { + yield value; } for (const child of this[_children]) { @@ -551,19 +637,57 @@ class XmlObject extends XFAObject { } } - *[$getAttributeIt](name) { - const value = this[_attributes][name]; - if (value !== undefined) { - yield new XFAAttribute(this, name, value); + *[$getAttributeIt](name, skipConsumed) { + const value = this[_attributes].get(name); + if (value && (!skipConsumed || !value[$consumed])) { + yield value; } - for (const child of this[_children]) { - yield* child[$getAttributeIt](name); + yield* child[$getAttributeIt](name, skipConsumed); + } + } + + *[$getRealChildrenByNameIt](name, allTransparent, skipConsumed) { + for (const child of this[_children]) { + if (child[$nodeName] === name && (!skipConsumed || !child[$consumed])) { + yield child; + } + + if (allTransparent) { + yield* child[$getRealChildrenByNameIt]( + name, + allTransparent, + skipConsumed + ); + } } } [$isDataValue]() { - return this[_children].length === 0; + if (this[_dataValue] === null) { + return this[_children].length === 0; + } + return this[_dataValue]; + } + + [$dump]() { + const dumped = Object.create(null); + if (this[$content]) { + dumped.$content = this[$content]; + } + dumped.$name = this[$nodeName]; + + dumped.children = []; + for (const child of this[_children]) { + dumped.children.push(child[$dump]()); + } + + dumped.attributes = Object.create(null); + for (const [name, value] of this[_attributes]) { + dumped.attributes[name] = value[$content]; + } + + return dumped; } } @@ -641,9 +765,13 @@ class Option10 extends IntegerObject { } export { + $appendChild, $clean, $cleanup, + $clone, + $consumed, $content, + $data, $dump, $finalize, $getAttributeIt, @@ -652,16 +780,26 @@ export { $getChildrenByName, $getChildrenByNameIt, $getParent, + $getRealChildrenByNameIt, + $global, + $hasItem, + $hasSettableValue, + $indexOf, + $insertAt, $isDataValue, + $isDescendent, $isTransparent, $namespaceId, $nodeName, + $nsAttributes, $onChild, $onChildCheck, $onText, + $removeChild, $resolvePrototypes, $setId, $setSetAttributes, + $setValue, $text, ContentObject, IntegerObject, diff --git a/test/unit/xfa_parser_spec.js b/test/unit/xfa_parser_spec.js index a8eba122a..e84574288 100644 --- a/test/unit/xfa_parser_spec.js +++ b/test/unit/xfa_parser_spec.js @@ -20,6 +20,7 @@ import { $getChildrenByName, $text, } from "../../src/core/xfa/xfa_object.js"; +import { Binder } from "../../src/core/xfa/bind.js"; import { searchNode } from "../../src/core/xfa/som.js"; import { XFAParser } from "../../src/core/xfa/parser.js"; @@ -507,39 +508,45 @@ describe("XFAParser", function () { `; const root = new XFAParser().parse(xml); - expect(searchNode(root, null, "$template..Description.id")[$text]()).toBe( - "a" - ); - expect(searchNode(root, null, "$template..Description.id")[$text]()).toBe( - "a" - ); expect( - searchNode(root, null, "$template..Description[0].id")[$text]() + searchNode(root, null, "$template..Description.id")[0][$text]() ).toBe("a"); expect( - searchNode(root, null, "$template..Description[1].id")[$text]() + searchNode(root, null, "$template..Description.id")[0][$text]() + ).toBe("a"); + expect( + searchNode(root, null, "$template..Description[0].id")[0][$text]() + ).toBe("a"); + expect( + searchNode(root, null, "$template..Description[1].id")[0][$text]() ).toBe("e"); expect( - searchNode(root, null, "$template..Description[2].id")[$text]() + searchNode(root, null, "$template..Description[2].id")[0][$text]() ).toBe("p"); - expect(searchNode(root, null, "$template.Receipt.id")[$text]()).toBe("l"); + expect(searchNode(root, null, "$template.Receipt.id")[0][$text]()).toBe( + "l" + ); expect( - searchNode(root, null, "$template.Receipt.Description[1].id")[$text]() + searchNode(root, null, "$template.Receipt.Description[1].id")[0][ + $text + ]() ).toBe("e"); expect(searchNode(root, null, "$template.Receipt.Description[2]")).toBe( null ); expect( - searchNode(root, null, "$template.Receipt.foo.Description.id")[$text]() + searchNode(root, null, "$template.Receipt.foo.Description.id")[0][ + $text + ]() ).toBe("p"); expect( - searchNode(root, null, "$template.#subform.Sub_Total.id")[$text]() + searchNode(root, null, "$template.#subform.Sub_Total.id")[0][$text]() ).toBe("i"); expect( - searchNode(root, null, "$template.#subform.Units.id")[$text]() + searchNode(root, null, "$template.#subform.Units.id")[0][$text]() ).toBe("b"); expect( - searchNode(root, null, "$template.#subform.Units.parent.id")[$text]() + searchNode(root, null, "$template.#subform.Units.parent.id")[0][$text]() ).toBe("m"); }); @@ -620,10 +627,10 @@ describe("XFAParser", function () { searchNode(root, receipt, "Detail[*].Total_Price").map(x => x[$text]()) ).toEqual(["250.00", "60.00"]); - const units = searchNode(root, receipt, "Detail[1].Units"); + const [units] = searchNode(root, receipt, "Detail[1].Units"); expect(units[$text]()).toBe("5"); - let found = searchNode(root, units, "Total_Price"); + let [found] = searchNode(root, units, "Total_Price"); expect(found[$text]()).toBe("60.00"); found = searchNode(root, units, "Total_Pric"); @@ -645,18 +652,503 @@ describe("XFAParser", function () { `; const root = new XFAParser().parse(xml); - expect(searchNode(root, null, "$data.Receipt.Detail")[$text]()).toBe( + expect(searchNode(root, null, "$data.Receipt.Detail")[0][$text]()).toBe( "Acme" ); - expect(searchNode(root, null, "$data.Receipt.Detail[0]")[$text]()).toBe( - "Acme" + expect( + searchNode(root, null, "$data.Receipt.Detail[0]")[0][$text]() + ).toBe("Acme"); + expect( + searchNode(root, null, "$data.Receipt.Detail[1]")[0][$text]() + ).toBe("foo"); + expect( + searchNode(root, null, "$data.Receipt.Detail[2]")[0][$text]() + ).toBe("bar"); + }); + }); + + describe("Bind data into form", function () { + it("should make a basic binding", function () { + const xml = ` + + + + + + + xyz + + + + + `; + const root = new XFAParser().parse(xml); + const form = new Binder(root).bind(); + + expect( + searchNode(form, form, "A.B.C.value.text")[0][$dump]().$content + ).toBe("xyz"); + }); + + it("should make another basic binding", function () { + const xml = ` + + + + + + + Jack + Spratt + + 99 Candlestick Lane + London + UK + SW1 + + + + + `; + const root = new XFAParser().parse(xml); + const form = new Binder(root).bind(); + + expect( + searchNode(form, form, "registration.first..text")[0][$dump]().$content + ).toBe("Jack"); + expect( + searchNode(form, form, "registration.last..text")[0][$dump]().$content + ).toBe("Spratt"); + expect( + searchNode(form, form, "registration.apt..text")[0][$dump]().$content + ).toBe(undefined); + expect( + searchNode(form, form, "registration.street..text")[0][$dump]().$content + ).toBe("99 Candlestick Lane"); + expect( + searchNode(form, form, "registration.city..text")[0][$dump]().$content + ).toBe("London"); + expect( + searchNode(form, form, "registration.country..text")[0][$dump]() + .$content + ).toBe("UK"); + expect( + searchNode(form, form, "registration.postalcode..text")[0][$dump]() + .$content + ).toBe("SW1"); + }); + + it("should make basic binding with extra subform", function () { + const xml = ` + + + + + + + Jack + Spratt + + 99 Candlestick Lane + London + UK + SW1 + + + + + `; + const root = new XFAParser().parse(xml); + const form = new Binder(root).bind(); + + expect( + searchNode(form, form, "registration..first..text")[0][$dump]().$content + ).toBe("Jack"); + expect( + searchNode(form, form, "registration..last..text")[0][$dump]().$content + ).toBe("Spratt"); + expect( + searchNode(form, form, "registration..apt..text")[0][$dump]().$content + ).toBe(undefined); + expect( + searchNode(form, form, "registration..street..text")[0][$dump]() + .$content + ).toBe("99 Candlestick Lane"); + expect( + searchNode(form, form, "registration..city..text")[0][$dump]().$content + ).toBe("London"); + expect( + searchNode(form, form, "registration..country..text")[0][$dump]() + .$content + ).toBe("UK"); + expect( + searchNode(form, form, "registration..postalcode..text")[0][$dump]() + .$content + ).toBe("SW1"); + }); + + it("should make basic binding with extra subform", function () { + const xml = ` + + + + + + + Jack + Spratt +
+ 7 + 99 Candlestick Lane + London +
+
+
+
+
+ `; + const root = new XFAParser().parse(xml); + const form = new Binder(root).bind(); + + expect( + searchNode(form, form, "registration..first..text")[0][$dump]().$content + ).toBe("Jack"); + expect( + searchNode(form, form, "registration..last..text")[0][$dump]().$content + ).toBe("Spratt"); + expect( + searchNode(form, form, "registration..apt..text")[0][$dump]().$content + ).toBe("7"); + expect( + searchNode(form, form, "registration..street..text")[0][$dump]() + .$content + ).toBe("99 Candlestick Lane"); + expect( + searchNode(form, form, "registration..city..text")[0][$dump]().$content + ).toBe("London"); + }); + + it("should make basic binding with same names in different parts", function () { + const xml = ` + + + + + + + Abott + + Costello + + + + + + `; + const root = new XFAParser().parse(xml); + const form = new Binder(root).bind(); + + expect( + searchNode(form, form, "application.sponsor.lastname..text")[0][$dump]() + .$content + ).toBe("Costello"); + expect( + searchNode(form, form, "application.lastname..text")[0][$dump]() + .$content + ).toBe("Abott"); + }); + + it("should make binding and create nodes in data", function () { + const xml = ` + + + + + + + + 1 + + + + + + `; + const root = new XFAParser().parse(xml); + const binder = new Binder(root); + const form = binder.bind(); + const data = binder.getData(); + + expect(searchNode(form, form, "root..b..text")[0][$dump]().$content).toBe( + "1" ); - expect(searchNode(root, null, "$data.Receipt.Detail[1]")[$text]()).toBe( - "foo" + expect(searchNode(data, data, "root.A.a")[0][$dump]().$name).toBe("a"); + expect(searchNode(data, data, "root.A.B.c")[0][$dump]().$name).toBe("c"); + expect(searchNode(data, data, "root.A.B.d")[0][$dump]().$name).toBe("d"); + expect(searchNode(data, data, "root.A.B.C.e")[0][$dump]().$name).toBe( + "e" ); - expect(searchNode(root, null, "$data.Receipt.Detail[2]")[$text]()).toBe( - "bar" + expect(searchNode(data, data, "root.A.B.C.f")[0][$dump]().$name).toBe( + "f" ); }); + + it("should make binding and set properties", function () { + const xml = ` + + + + + + + foo + +
+ + + Give the name! + +
+
+
+
+ `; + const root = new XFAParser().parse(xml); + const form = new Binder(root).bind(); + + expect( + searchNode(form, form, "Id.LastName..text")[0][$dump]().$content + ).toBe("foo"); + expect( + searchNode(form, form, "Id.LastName.font.typeface")[0][$text]() + ).toBe("myfont"); + expect( + searchNode(form, form, "Id.LastName.font.size")[0][$text]() + ).toEqual({ + value: 123.4, + unit: "pt", + }); + expect( + searchNode(form, form, "Id.LastName.assist.toolTip")[0][$dump]() + .$content + ).toBe("Give the name!"); + }); + + it("should make binding and bind items", function () { + const xml = ` + + + + + +
+ + + + + + MC +
+
+
+
+ `; + const root = new XFAParser().parse(xml); + const form = new Binder(root).bind(); + expect( + searchNode(form, form, "subform.CardName.items[*].text[*]").map(x => + x[$text]() + ) + ).toEqual([ + "Visa", + "Mastercard", + "American Express", + "VISA", + "MC", + "AMEX", + ]); + }); + + it("should make binding with occurrences in consumeData mode", function () { + const xml = ` + + + + + + +
+ item1 +
+
+ item2 +
+
+
+
+
+ `; + const root = new XFAParser().parse(xml); + const form = new Binder(root).bind(); + + expect( + searchNode(form, form, "root.section[*].id").map(x => x[$text]()) + ).toEqual(["section1", "section1"]); + + expect( + searchNode(form, form, "root.section[*].line-item..text").map(x => + x[$text]() + ) + ).toEqual(["item1", "item2"]); + }); + + it("should make binding with occurrences in matchTemplate mode", function () { + const xml = ` + + + + + + +
+ item1 +
+
+ item2 +
+
+
+
+
+ `; + const root = new XFAParser().parse(xml); + const form = new Binder(root).bind(); + + expect( + searchNode(form, form, "root.section[*].id").map(x => x[$text]()) + ).toEqual(["section1", "section1", "section2", "section2"]); + + expect( + searchNode(form, form, "root.section[*].line-item..text").map(x => + x[$text]() + ) + ).toEqual(["item1", "item2", "item1", "item2"]); + }); }); });