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 = ` + + + + + `; + 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"); + }); }); });