diff --git a/src/core/xfa/builder.js b/src/core/xfa/builder.js
index ae30ffed9..aaed18ec8 100644
--- a/src/core/xfa/builder.js
+++ b/src/core/xfa/builder.js
@@ -14,19 +14,37 @@
*/
import { $buildXFAObject, NamespaceIds } from "./namespaces.js";
-import { $cleanup, $onChild, XFAObject } from "./xfa_object.js";
+import {
+ $cleanup,
+ $finalize,
+ $onChild,
+ $resolvePrototypes,
+ XFAObject,
+} from "./xfa_object.js";
import { NamespaceSetUp } from "./setup.js";
+import { Template } from "./template.js";
import { UnknownNamespace } from "./unknown.js";
import { warn } from "../../shared/util.js";
+const _ids = Symbol();
+
class Root extends XFAObject {
- constructor() {
+ constructor(ids) {
super(-1, "root", Object.create(null));
this.element = null;
+ this[_ids] = ids;
}
[$onChild](child) {
this.element = child;
+ return true;
+ }
+
+ [$finalize]() {
+ super[$finalize]();
+ if (this.element.template instanceof Template) {
+ this.element.template[$resolvePrototypes](this[_ids]);
+ }
}
}
@@ -35,7 +53,9 @@ class Empty extends XFAObject {
super(-1, "", Object.create(null));
}
- [$onChild](_) {}
+ [$onChild](_) {
+ return false;
+ }
}
class Builder {
@@ -51,8 +71,8 @@ class Builder {
this._currentNamespace = new UnknownNamespace(++this._nextNsId);
}
- buildRoot() {
- return new Root();
+ buildRoot(ids) {
+ return new Root(ids);
}
build({ nsPrefix, name, attributes, namespace, prefixes }) {
diff --git a/src/core/xfa/parser.js b/src/core/xfa/parser.js
index 7af2bd27c..d0e492d04 100644
--- a/src/core/xfa/parser.js
+++ b/src/core/xfa/parser.js
@@ -13,7 +13,7 @@
* limitations under the License.
*/
-import { $clean, $finalize, $onChild, $onText } from "./xfa_object.js";
+import { $clean, $finalize, $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";
@@ -23,7 +23,8 @@ class XFAParser extends XMLParserBase {
super();
this._builder = new Builder();
this._stack = [];
- this._current = this._builder.buildRoot();
+ this._ids = new Map();
+ this._current = this._builder.buildRoot(this._ids);
this._errorCode = XMLParserErrorCode.NoError;
this._whiteRegex = /^\s+$/;
}
@@ -35,6 +36,8 @@ class XFAParser extends XMLParserBase {
return undefined;
}
+ this._current[$finalize]();
+
return this._current.element;
}
@@ -101,7 +104,9 @@ class XFAParser extends XMLParserBase {
if (isEmpty) {
// No children: just push the node into its parent.
node[$finalize]();
- this._current[$onChild](node);
+ if (this._current[$onChild](node)) {
+ node[$setId](this._ids);
+ }
node[$clean](this._builder);
return;
}
@@ -114,7 +119,9 @@ class XFAParser extends XMLParserBase {
const node = this._current;
node[$finalize]();
this._current = this._stack.pop();
- this._current[$onChild](node);
+ if (this._current[$onChild](node)) {
+ node[$setId](this._ids);
+ }
node[$clean](this._builder);
}
diff --git a/src/core/xfa/template.js b/src/core/xfa/template.js
index ed5daedf0..c9808cb03 100644
--- a/src/core/xfa/template.js
+++ b/src/core/xfa/template.js
@@ -21,6 +21,7 @@ import {
$namespaceId,
$nodeName,
$onChild,
+ $setSetAttributes,
ContentObject,
Option01,
OptionObject,
@@ -1071,9 +1072,15 @@ class ExData extends ContentObject {
child[$namespaceId] === NamespaceIds.xhtml.id
) {
this[$content] = child;
- } else if (this.contentType === "text/xml") {
- this[$content] = child;
+ return true;
}
+
+ if (this.contentType === "text/xml") {
+ this[$content] = child;
+ return true;
+ }
+
+ return false;
}
}
@@ -2531,9 +2538,10 @@ class Text extends ContentObject {
[$onChild](child) {
if (child[$namespaceId] === NamespaceIds.xhtml.id) {
this[$content] = child;
- } else {
- warn(`XFA - Invalid content in Text: ${child[$nodeName]}.`);
+ return true;
}
+ warn(`XFA - Invalid content in Text: ${child[$nodeName]}.`);
+ return false;
}
}
@@ -2757,7 +2765,9 @@ class Variables extends XFAObject {
class TemplateNamespace {
static [$buildXFAObject](name, attributes) {
if (TemplateNamespace.hasOwnProperty(name)) {
- return TemplateNamespace[name](attributes);
+ const node = TemplateNamespace[name](attributes);
+ node[$setSetAttributes](attributes);
+ return node;
}
return undefined;
}
@@ -3215,4 +3225,4 @@ class TemplateNamespace {
}
}
-export { TemplateNamespace };
+export { Template, TemplateNamespace };
diff --git a/src/core/xfa/xfa_object.js b/src/core/xfa/xfa_object.js
index 0a3bf49ec..918b7d9f4 100644
--- a/src/core/xfa/xfa_object.js
+++ b/src/core/xfa/xfa_object.js
@@ -15,6 +15,7 @@
import { getInteger, getKeyword } from "./utils.js";
import { shadow, warn } from "../../shared/util.js";
+import { NamespaceIds } from "./namespaces.js";
// We use these symbols to avoid name conflict between tags
// and properties/methods names.
@@ -31,16 +32,25 @@ const $nodeName = Symbol("nodeName");
const $onChild = Symbol();
const $onChildCheck = Symbol();
const $onText = Symbol();
+const $resolvePrototypes = Symbol();
+const $setId = Symbol();
+const $setSetAttributes = Symbol();
const $text = Symbol();
+const _applyPrototype = Symbol();
const _attributes = Symbol();
const _attributeNames = Symbol();
const _children = Symbol();
+const _clone = Symbol();
+const _cloneAttribute = Symbol();
const _defaultValue = Symbol();
+const _getPrototype = Symbol();
+const _getUnsetAttributes = Symbol();
const _hasChildren = Symbol();
const _max = Symbol();
const _options = Symbol();
const _parent = Symbol();
+const _setAttributes = Symbol();
const _validator = Symbol();
class XFAObject {
@@ -54,23 +64,27 @@ class XFAObject {
[$onChild](child) {
if (!this[_hasChildren] || !this[$onChildCheck](child)) {
- return;
+ return false;
}
const name = child[$nodeName];
const node = this[name];
+
if (node instanceof XFAObjectArray) {
if (node.push(child)) {
child[_parent] = this;
this[_children].push(child);
+ return true;
}
} else if (node === null) {
this[name] = child;
child[_parent] = this;
this[_children].push(child);
- } else {
- warn(`XFA - node "${this[$nodeName]}" accepts only one child: ${name}`);
+ return true;
}
+
+ warn(`XFA - node "${this[$nodeName]}" has already enough "${name}"!`);
+ return false;
}
[$onChildCheck](child) {
@@ -80,6 +94,12 @@ class XFAObject {
);
}
+ [$setId](ids) {
+ if (this.id && this[$namespaceId] === NamespaceIds.template.id) {
+ ids.set(this.id, this);
+ }
+ }
+
[$onText](_) {}
[$finalize]() {}
@@ -151,6 +171,198 @@ class XFAObject {
return dumped;
}
+
+ [$setSetAttributes](attributes) {
+ if (attributes.use || attributes.id) {
+ // Just keep set attributes because this node uses a proto or is a proto.
+ this[_setAttributes] = new Set(Object.keys(attributes));
+ }
+ }
+
+ /**
+ * Get attribute names which have been set in the proto but not in this.
+ */
+ [_getUnsetAttributes](protoAttributes) {
+ const allAttr = this[_attributeNames];
+ const setAttr = this[_setAttributes];
+ return [...protoAttributes].filter(x => allAttr.has(x) && !setAttr.has(x));
+ }
+
+ /**
+ * Update the node with properties coming from a prototype and apply
+ * this function recursivly to all children.
+ */
+ [$resolvePrototypes](ids, ancestors = new Set()) {
+ for (const child of this[_children]) {
+ const proto = child[_getPrototype](ids, ancestors);
+ if (proto) {
+ // _applyPrototype will apply $resolvePrototypes with correct ancestors
+ // to avoid infinite loop.
+ child[_applyPrototype](proto, ids, ancestors);
+ } else {
+ child[$resolvePrototypes](ids, ancestors);
+ }
+ }
+ }
+
+ [_getPrototype](ids, ancestors) {
+ const { use } = this;
+ if (use && use.startsWith("#")) {
+ const id = use.slice(1);
+ const proto = ids.get(id);
+ this.use = "";
+ if (!proto) {
+ warn(`XFA - Invalid prototype id: ${id}.`);
+ return null;
+ }
+
+ if (proto[$nodeName] !== this[$nodeName]) {
+ warn(
+ `XFA - Incompatible prototype: ${proto[$nodeName]} !== ${this[$nodeName]}.`
+ );
+ return null;
+ }
+
+ if (ancestors.has(proto)) {
+ // We've a cycle so break it.
+ warn(`XFA - Cycle detected in prototypes use.`);
+ return null;
+ }
+
+ ancestors.add(proto);
+ // The prototype can have a "use" attribute itself.
+ const protoProto = proto[_getPrototype](ids, ancestors);
+ if (!protoProto) {
+ ancestors.delete(proto);
+ return proto;
+ }
+
+ proto[_applyPrototype](protoProto, ids, ancestors);
+ ancestors.delete(proto);
+
+ return proto;
+ }
+ // TODO: handle SOM expressions.
+
+ return null;
+ }
+
+ [_applyPrototype](proto, ids, ancestors) {
+ if (ancestors.has(proto)) {
+ // We've a cycle so break it.
+ warn(`XFA - Cycle detected in prototypes use.`);
+ return;
+ }
+
+ if (!this[$content] && proto[$content]) {
+ this[$content] = proto[$content];
+ }
+
+ const newAncestors = new Set(ancestors);
+ newAncestors.add(proto);
+
+ for (const unsetAttrName of this[_getUnsetAttributes](
+ proto[_setAttributes]
+ )) {
+ this[unsetAttrName] = proto[unsetAttrName];
+ if (this[_setAttributes]) {
+ this[_setAttributes].add(unsetAttrName);
+ }
+ }
+
+ for (const name of Object.getOwnPropertyNames(this)) {
+ if (this[_attributeNames].has(name)) {
+ continue;
+ }
+ const value = this[name];
+ const protoValue = proto[name];
+
+ if (value instanceof XFAObjectArray) {
+ for (const child of value[_children]) {
+ child[$resolvePrototypes](ids, ancestors);
+ }
+
+ for (
+ let i = value[_children].length, ii = protoValue[_children].length;
+ i < ii;
+ i++
+ ) {
+ const child = proto[_children][i][_clone]();
+ if (value.push(child)) {
+ child[_parent] = this;
+ this[_children].push(child);
+ child[$resolvePrototypes](ids, newAncestors);
+ } else {
+ // No need to continue: other nodes will be rejected.
+ break;
+ }
+ }
+ continue;
+ }
+
+ if (value !== null) {
+ value[$resolvePrototypes](ids, ancestors);
+ continue;
+ }
+
+ if (protoValue !== null) {
+ const child = protoValue[_clone]();
+ child[_parent] = this;
+ this[name] = child;
+ this[_children].push(child);
+ child[$resolvePrototypes](ids, newAncestors);
+ }
+ }
+ }
+
+ static [_cloneAttribute](obj) {
+ if (Array.isArray(obj)) {
+ return obj.map(x => XFAObject[_cloneAttribute](x));
+ }
+ if (obj instanceof Object) {
+ return Object.assign({}, obj);
+ }
+ return obj;
+ }
+
+ [_clone]() {
+ const clone = Object.create(Object.getPrototypeOf(this));
+ for (const $symbol of Object.getOwnPropertySymbols(this)) {
+ try {
+ clone[$symbol] = this[$symbol];
+ } catch (_) {
+ shadow(clone, $symbol, this[$symbol]);
+ }
+ }
+ clone[_children] = [];
+
+ for (const name of Object.getOwnPropertyNames(this)) {
+ if (this[_attributeNames].has(name)) {
+ clone[name] = XFAObject[_cloneAttribute](this[name]);
+ continue;
+ }
+ const value = this[name];
+ if (value instanceof XFAObjectArray) {
+ clone[name] = new XFAObjectArray(value[_max]);
+ } else {
+ clone[name] = null;
+ }
+ }
+
+ for (const child of this[_children]) {
+ const name = child[$nodeName];
+ const clonedChild = child[_clone]();
+ clone[_children].push(clonedChild);
+ clonedChild[_parent] = clone;
+ if (clone[name] === null) {
+ clone[name] = clonedChild;
+ } else {
+ clone[name][_children].push(clonedChild);
+ }
+ }
+
+ return clone;
+ }
}
class XFAObjectArray {
@@ -180,6 +392,12 @@ class XFAObjectArray {
? this[_children][0][$dump]()
: this[_children].map(x => x[$dump]());
}
+
+ [_clone]() {
+ const clone = new XFAObjectArray(this[_max]);
+ clone[_children] = this[_children].map(c => c[_clone]());
+ return clone;
+ }
}
class XmlObject extends XFAObject {
@@ -198,7 +416,9 @@ class XmlObject extends XFAObject {
this[$content] = "";
this[_children].push(node);
}
+ child[_parent] = this;
this[_children].push(child);
+ return true;
}
[$onText](str) {
@@ -308,6 +528,9 @@ export {
$onChild,
$onChildCheck,
$onText,
+ $resolvePrototypes,
+ $setId,
+ $setSetAttributes,
$text,
ContentObject,
IntegerObject,
diff --git a/test/unit/xfa_parser_spec.js b/test/unit/xfa_parser_spec.js
index 3257e2903..c5f5f9416 100644
--- a/test/unit/xfa_parser_spec.js
+++ b/test/unit/xfa_parser_spec.js
@@ -248,6 +248,54 @@ describe("XFAParser", function () {
expect(root[$dump]()).toEqual(expected);
});
+ it("should parse a xfa document and apply some prototypes", function () {
+ const xml = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ const root = new XFAParser().parse(xml)[$dump]();
+ let font = root.template.subform.field[0].font;
+ expect(font.typeface).toEqual("Foo");
+ expect(font.overline).toEqual(0);
+ expect(font.size).toEqual({ value: 123, unit: "pt" });
+ expect(font.weight).toEqual("bold");
+ expect(font.posture).toEqual("italic");
+ expect(font.fill.color.value).toEqual({ r: 1, g: 2, b: 3 });
+ expect(font.extras).toEqual(undefined);
+
+ font = root.template.subform.field[1].font;
+ expect(font.typeface).toEqual("Foo");
+ expect(font.overline).toEqual(0);
+ expect(font.size).toEqual({ value: 456, unit: "pt" });
+ expect(font.weight).toEqual("bold");
+ expect(font.posture).toEqual("normal");
+ expect(font.fill.color.value).toEqual({ r: 4, g: 5, b: 6 });
+ expect(font.extras.id).toEqual("id2");
+ });
+
it("should parse a xfa document with xhtml", function () {
const xml = `
@@ -280,5 +328,92 @@ describe("XFAParser", function () {
].join("")
);
});
+
+ it("should parse a xfa document and apply some prototypes with cycle", function () {
+ const xml = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ const root = new XFAParser().parse(xml)[$dump]();
+ const subform = root.template.subform[1];
+
+ expect(subform.id).toEqual("id1");
+ expect(subform.subform.id).toEqual("id1");
+ });
+
+ it("should parse a xfa document and apply some nested prototypes", function () {
+ const xml = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ const root = new XFAParser().parse(xml)[$dump]();
+ const font = root.template.subform.field.font;
+
+ expect(font.typeface).toEqual("helvetica");
+ expect(font.overline).toEqual(0);
+ expect(font.size).toEqual({ value: 31, unit: "pt" });
+ expect(font.weight).toEqual("normal");
+ expect(font.posture).toEqual("italic");
+ expect(font.fill.color.value).toEqual({ r: 7, g: 8, b: 9 });
+ });
+
+ it("should parse a xfa document and apply a prototype with content", function () {
+ const xml = `
+
+
+
+
+
+ default TEXT
+
+
+
+
+
+
+
+
+ Overriding text
+
+
+
+
+
+ `;
+ const root = new XFAParser().parse(xml)[$dump]();
+ let field = root.template.subform.field[0];
+ expect(field.value.text.$content).toEqual("default TEXT");
+
+ field = root.template.subform.field[1];
+ expect(field.value.text.$content).toEqual("Overriding text");
+ });
});
});