diff --git a/src/core/xfa/bind.js b/src/core/xfa/bind.js index 209c837bd..e6f227ebe 100644 --- a/src/core/xfa/bind.js +++ b/src/core/xfa/bind.js @@ -22,6 +22,7 @@ import { $finalize, $getAttributeIt, $getChildren, + $getDataValue, $getParent, $getRealChildrenByNameIt, $global, @@ -88,7 +89,7 @@ class Binder { if (formNode[$hasSettableValue]()) { if (data[$isDataValue]()) { - const value = data[$content].trim(); + const value = data[$getDataValue](); // TODO: use picture. formNode[$setValue](createText(value)); formNode[$data] = data; @@ -114,7 +115,7 @@ class Binder { } } - _findDataByNameToConsume(name, dataNode, global) { + _findDataByNameToConsume(name, isValue, dataNode, global) { if (!name) { return null; } @@ -130,9 +131,16 @@ class Binder { /* allTransparent = */ false, /* skipConsumed = */ true ); - match = generator.next().value; - if (match) { - return match; + // 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 && @@ -149,7 +157,7 @@ class Binder { // Secondly, if global try to find it just under the root of datasets // (which is the location of global variables). - generator = this.datasets[$getRealChildrenByNameIt]( + generator = this.data[$getRealChildrenByNameIt]( name, /* allTransparent = */ false, /* skipConsumed = */ false @@ -478,6 +486,7 @@ class Binder { if (child.bind) { switch (child.bind.match) { case "none": + this._bindElement(child, dataNode); continue; case "global": global = true; @@ -485,6 +494,7 @@ class Binder { case "dataRef": if (!child.bind.ref) { warn(`XFA - ref is empty in node ${child[$nodeName]}.`); + this._bindElement(child, dataNode); continue; } ref = child.bind.ref; @@ -545,6 +555,7 @@ class Binder { while (matches.length < max) { const found = this._findDataByNameToConsume( child.name, + child[$hasSettableValue](), dataNode, global ); @@ -580,6 +591,8 @@ class Binder { } 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); diff --git a/src/core/xfa/builder.js b/src/core/xfa/builder.js index 511a814bf..89fe62482 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, + $ids, $nsAttributes, $onChild, $resolvePrototypes, @@ -27,13 +28,11 @@ import { Template } from "./template.js"; import { UnknownNamespace } from "./unknown.js"; import { warn } from "../../shared/util.js"; -const _ids = Symbol(); - class Root extends XFAObject { constructor(ids) { super(-1, "root", Object.create(null)); this.element = null; - this[_ids] = ids; + this[$ids] = ids; } [$onChild](child) { @@ -44,7 +43,8 @@ class Root extends XFAObject { [$finalize]() { super[$finalize](); if (this.element.template instanceof Template) { - this.element.template[$resolvePrototypes](this[_ids]); + this.element.template[$resolvePrototypes](this[$ids]); + this.element.template[$ids] = this[$ids]; } } } diff --git a/src/core/xfa/html_utils.js b/src/core/xfa/html_utils.js index 3aa06df08..d20ca1477 100644 --- a/src/core/xfa/html_utils.js +++ b/src/core/xfa/html_utils.js @@ -13,18 +13,38 @@ * limitations under the License. */ -import { $extra, $getParent, $toStyle, XFAObject } from "./xfa_object.js"; +import { + $extra, + $getParent, + $nodeName, + $toStyle, + XFAObject, +} from "./xfa_object.js"; +import { getMeasurement } from "./utils.js"; import { warn } from "../../shared/util.js"; +const wordNonWordRegex = new RegExp( + "([\\p{N}\\p{L}\\p{M}]+)|([^\\p{N}\\p{L}\\p{M}]+)", + "gu" +); +const wordFirstRegex = new RegExp("^[\\p{N}\\p{L}\\p{M}]", "u"); + function measureToString(m) { if (typeof m === "string") { return "0px"; } + return Number.isInteger(m) ? `${m}px` : `${m.toFixed(2)}px`; } const converters = { anchorType(node, style) { + const parent = node[$getParent](); + if (!parent || (parent.layout && parent.layout !== "position")) { + // anchorType is only used in a positioned layout. + return; + } + if (!("transform" in style)) { style.transform = ""; } @@ -57,12 +77,28 @@ const converters = { }, dimensions(node, style) { const parent = node[$getParent](); - const extra = parent[$extra]; let width = node.w; - if (extra && extra.columnWidths) { - width = extra.columnWidths[extra.currentColumn]; - extra.currentColumn = - (extra.currentColumn + 1) % extra.columnWidths.length; + const height = node.h; + if (parent.layout && parent.layout.includes("row")) { + const extra = parent[$extra]; + const colSpan = node.colSpan; + let w; + if (colSpan === -1) { + w = extra.columnWidths + .slice(extra.currentColumn) + .reduce((a, x) => a + x, 0); + extra.currentColumn = 0; + } else { + w = extra.columnWidths + .slice(extra.currentColumn, extra.currentColumn + colSpan) + .reduce((a, x) => a + x, 0); + extra.currentColumn = + (extra.currentColumn + node.colSpan) % extra.columnWidths.length; + } + + if (!isNaN(w)) { + width = node.w = w; + } } if (width !== "") { @@ -72,17 +108,21 @@ const converters = { if (node.maxW > 0) { style.maxWidth = measureToString(node.maxW); } - style.minWidth = measureToString(node.minW); + if (parent.layout === "position") { + style.minWidth = measureToString(node.minW); + } } - if (node.h !== "") { - style.height = measureToString(node.h); + if (height !== "") { + style.height = measureToString(height); } else { style.height = "auto"; if (node.maxH > 0) { style.maxHeight = measureToString(node.maxH); } - style.minHeight = measureToString(node.minH); + if (parent.layout === "position") { + style.minHeight = measureToString(node.minH); + } } }, position(node, style) { @@ -118,22 +158,31 @@ const converters = { } }, hAlign(node, style) { - switch (node.hAlign) { - case "justifyAll": - style.textAlign = "justify-all"; - break; - case "radix": - // TODO: implement this correctly ! - style.textAlign = "left"; - break; - default: - style.textAlign = node.hAlign; + if (node[$nodeName] === "para") { + switch (node.hAlign) { + case "justifyAll": + style.textAlign = "justify-all"; + break; + case "radix": + // TODO: implement this correctly ! + style.textAlign = "left"; + break; + default: + style.textAlign = node.hAlign; + } + } else { + switch (node.hAlign) { + case "right": + case "center": + style.justifyContent = node.hAlign; + break; + } } }, borderMarginPadding(node, style) { // Get border width in order to compute margin and padding. const borderWidths = [0, 0, 0, 0]; - const marginWidths = [0, 0, 0, 0]; + const borderInsets = [0, 0, 0, 0]; const marginNode = node.margin ? [ node.margin.topInset, @@ -142,30 +191,211 @@ const converters = { node.margin.leftInset, ] : [0, 0, 0, 0]; + + let borderMargin; if (node.border) { - Object.assign(style, node.border[$toStyle](borderWidths, marginWidths)); + Object.assign(style, node.border[$toStyle](borderWidths, borderInsets)); + borderMargin = style.margin; + delete style.margin; } if (borderWidths.every(x => x === 0)) { - // No border: margin & padding are padding - if (node.margin) { - Object.assign(style, node.margin[$toStyle]()); + if (marginNode.every(x => x === 0)) { + return; } + + // No border: margin & padding are padding + Object.assign(style, node.margin[$toStyle]()); style.padding = style.margin; delete style.margin; - } else { - style.padding = - measureToString(marginNode[0] - borderWidths[0] - marginWidths[0]) + - " " + - measureToString(marginNode[1] - borderWidths[1] - marginWidths[1]) + - " " + - measureToString(marginNode[2] - borderWidths[2] - marginWidths[2]) + - " " + - measureToString(marginNode[3] - borderWidths[3] - marginWidths[3]); + delete style.outline; + delete style.outlineOffset; + return; } + + if (node.margin) { + Object.assign(style, node.margin[$toStyle]()); + style.padding = style.margin; + delete style.margin; + } + + if (!style.borderWidth) { + // We've an outline so no need to fake one. + return; + } + + style.borderData = { + borderWidth: style.borderWidth, + borderColor: style.borderColor, + borderStyle: style.borderStyle, + margin: borderMargin, + }; + + delete style.borderWidth; + delete style.borderColor; + delete style.borderStyle; }, }; +function layoutText(text, fontSize, space) { + // Try to guess width and height for the given text in taking into + // account the space where the text should fit. + // The computed dimensions are just an overestimation. + // TODO: base this estimation on real metrics. + let width = 0; + let height = 0; + let totalWidth = 0; + const lineHeight = fontSize * 1.5; + const averageCharSize = fontSize * 0.4; + const maxCharOnLine = Math.floor(space.width / averageCharSize); + const chunks = text.match(wordNonWordRegex); + let treatedChars = 0; + + let i = 0; + let chunk = chunks[0]; + while (chunk) { + const w = chunk.length * averageCharSize; + if (width + w <= space.width) { + width += w; + treatedChars += chunk.length; + chunk = chunks[i++]; + continue; + } + + if (!wordFirstRegex.test(chunk) || chunk.length > maxCharOnLine) { + const numOfCharOnLine = Math.floor( + (space.width - width) / averageCharSize + ); + chunk = chunk.slice(numOfCharOnLine); + treatedChars += numOfCharOnLine; + if (height + lineHeight > space.height) { + return { width: 0, height: 0, splitPos: treatedChars }; + } + totalWidth = Math.max(width, totalWidth); + width = 0; + height += lineHeight; + continue; + } + + if (height + lineHeight > space.height) { + return { width: 0, height: 0, splitPos: treatedChars }; + } + + totalWidth = Math.max(width, totalWidth); + width = w; + height += lineHeight; + chunk = chunks[i++]; + } + + if (totalWidth === 0) { + totalWidth = width; + } + + if (totalWidth !== 0) { + height += lineHeight; + } + + return { width: totalWidth, height, splitPos: -1 }; +} + +function computeBbox(node, html, availableSpace) { + let bbox; + if (node.w !== "" && node.h !== "") { + bbox = [node.x, node.y, node.w, node.h]; + } else { + if (!availableSpace) { + return null; + } + let width = node.w; + if (width === "") { + if (node.maxW === 0) { + const parent = node[$getParent](); + if (parent.layout === "position" && parent.w !== "") { + width = 0; + } else { + width = node.minW; + } + } else { + width = Math.min(node.maxW, availableSpace.width); + } + html.attributes.style.width = measureToString(width); + } + + let height = node.h; + if (height === "") { + if (node.maxH === 0) { + const parent = node[$getParent](); + if (parent.layout === "position" && parent.h !== "") { + height = 0; + } else { + height = node.minH; + } + } else { + height = Math.min(node.maxH, availableSpace.height); + } + html.attributes.style.height = measureToString(height); + } + + bbox = [node.x, node.y, width, height]; + } + return bbox; +} + +function fixDimensions(node) { + const parent = node[$getParent](); + if (parent.layout && parent.layout.includes("row")) { + const extra = parent[$extra]; + const colSpan = node.colSpan; + let width; + if (colSpan === -1) { + width = extra.columnWidths + .slice(extra.currentColumn) + .reduce((a, w) => a + w, 0); + } else { + width = extra.columnWidths + .slice(extra.currentColumn, extra.currentColumn + colSpan) + .reduce((a, w) => a + w, 0); + } + if (!isNaN(width)) { + node.w = width; + } + } + + if (parent.w && node.w) { + node.w = Math.min(parent.w, node.w); + } + + if (parent.h && node.h) { + node.h = Math.min(parent.h, node.h); + } + + if (parent.layout && parent.layout !== "position") { + // Useless in this context. + node.x = node.y = 0; + if (parent.layout === "tb") { + if ( + parent.w !== "" && + (node.w === "" || node.w === 0 || node.w > parent.w) + ) { + node.w = parent.w; + } + } + } + + if (node.layout === "position") { + // Acrobat doesn't take into account min, max values + // for containers with positioned layout (which makes sense). + node.minW = node.minH = 0; + node.maxW = node.maxH = Infinity; + } else { + if (node.layout === "table") { + if (node.w === "" && Array.isArray(node.columnWidths)) { + node.w = node.columnWidths.reduce((a, x) => a + x, 0); + } + } + } +} + function layoutClass(node) { switch (node.layout) { case "position": @@ -211,26 +441,145 @@ function toStyle(node, ...names) { return style; } -function addExtraDivForMargin(html) { +function addExtraDivForBorder(html) { const style = html.attributes.style; - if (style.margin) { - const padding = style.margin; - delete style.margin; - const width = style.width || "auto"; - const height = style.height || "auto"; + const data = style.borderData; + const children = []; - style.width = "100%"; - style.height = "100%"; + const attributes = { + class: "xfaWrapper", + style: Object.create(null), + }; - return { + for (const key of ["top", "left"]) { + if (style[key] !== undefined) { + attributes.style[key] = style[key]; + } + } + + delete style.top; + delete style.left; + + if (style.position === "absolute") { + attributes.style.position = "absolute"; + } else { + attributes.style.position = "relative"; + } + delete style.position; + + if (style.justifyContent) { + attributes.style.justifyContent = style.justifyContent; + delete style.justifyContent; + } + + if (data) { + delete style.borderData; + + let insets; + if (data.margin) { + insets = data.margin.split(" "); + delete data.margin; + } else { + insets = ["0px", "0px", "0px", "0px"]; + } + + let width = "100%"; + let height = width; + + if (insets[1] !== "0px" || insets[3] !== "0px") { + width = `calc(100% - ${parseInt(insets[1]) + parseInt(insets[3])}px`; + } + + if (insets[0] !== "0px" || insets[2] !== "0px") { + height = `calc(100% - ${parseInt(insets[0]) + parseInt(insets[2])}px`; + } + + const borderStyle = { + top: insets[0], + left: insets[3], + width, + height, + }; + + for (const [k, v] of Object.entries(data)) { + borderStyle[k] = v; + } + + if (style.transform) { + borderStyle.transform = style.transform; + } + + const borderDiv = { name: "div", attributes: { - style: { padding, width, height }, + class: "xfaBorderDiv", + style: borderStyle, }, - children: [html], }; + + children.push(borderDiv); } - return html; + + children.push(html); + + return { + name: "div", + attributes, + children, + }; } -export { addExtraDivForMargin, layoutClass, measureToString, toStyle }; +function fixTextIndent(styles) { + const indent = getMeasurement(styles.textIndent, "0px"); + if (indent >= 0) { + return; + } + + const align = styles.textAlign || "left"; + if (align === "left" || align === "right") { + const name = "margin" + (align === "left" ? "Left" : "Right"); + const margin = getMeasurement(styles[name], "0px"); + styles[name] = `${margin - indent}pt`; + } +} + +function getFonts(family) { + if (family.startsWith("'")) { + family = `"${family.slice(1, family.length - 1)}"`; + } else if (family.includes(" ") && !family.startsWith('"')) { + family = `"${family}"`; + } + + // TODO in case Myriad is not available we should generate a new + // font based on helvetica but where glyphs have been rescaled in order + // to have the exact same metrics. + const fonts = [family]; + switch (family) { + case `"Myriad Pro"`: + fonts.push( + `"Roboto Condensed"`, + `"Ubuntu Condensed"`, + `"Microsoft Sans Serif"`, + `"Apple Symbols"`, + "Helvetica", + `"sans serif"` + ); + break; + case "Arial": + fonts.push("Helvetica", `"Liberation Sans"`, "Arimo", `"sans serif"`); + break; + } + return fonts.join(","); +} + +export { + addExtraDivForBorder, + computeBbox, + fixDimensions, + fixTextIndent, + getFonts, + layoutClass, + layoutText, + measureToString, + toStyle, +}; diff --git a/src/core/xfa/layout.js b/src/core/xfa/layout.js new file mode 100644 index 000000000..b7eee56d8 --- /dev/null +++ b/src/core/xfa/layout.js @@ -0,0 +1,190 @@ +/* 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 { $extra, $flushHTML } from "./xfa_object.js"; +import { measureToString } from "./html_utils.js"; + +// Subform and ExclGroup have a layout so they share these functions. + +/** + * How layout works ? + * + * A container has an initial space (with a width and a height) to fit in, + * which means that once all the children have been added then + * the total width/height must be lower than the given ones in + * the initial space. + * So if the container has known dimensions and these ones are ok with the + * space then continue else we return HTMLResult.FAILURE: it's up to the + * parent to deal with this failure (e.g. if parent layout is lr-tb and + * we fail to add a child at end of line (lr) then we try to add it on the + * next line). + * And then we run through the children, each child gets its initial space + * in calling its parent $getAvailableSpace method + * (see _filteredChildrenGenerator and $childrenToHTML in xfa_object.js) + * then we try to layout child in its space. If everything is ok then we add + * the result to its parent through $addHTML which will recompute the available + * space in parent according to its layout property else we return + * HTMLResult.Failure. + * Before a failure some children may have been layed out: they've been saved in + * [$extra].children and [$extra] has properties generator and failingNode + * in order to save the state where we were before a failure. + * This [$extra].children property is useful when a container has to be splited. + * So if a container is unbreakable, we must delete its [$extra] property before + * returning. + */ + +function flushHTML(node) { + const attributes = node[$extra].attributes; + const html = { + name: "div", + attributes, + children: node[$extra].children, + }; + + if (node[$extra].failingNode) { + const htmlFromFailing = node[$extra].failingNode[$flushHTML](); + if (htmlFromFailing) { + html.children.push(htmlFromFailing); + } + } + + if (html.children.length === 0) { + return null; + } + + node[$extra].children = []; + delete node[$extra].line; + + return html; +} + +function addHTML(node, html, bbox) { + const extra = node[$extra]; + const availableSpace = extra.availableSpace; + + switch (node.layout) { + case "position": { + const [x, y, w, h] = bbox; + extra.width = Math.max(extra.width, x + w); + extra.height = Math.max(extra.height, y + h); + extra.children.push(html); + break; + } + case "lr-tb": + case "rl-tb": + if (!extra.line || extra.attempt === 1) { + extra.line = { + name: "div", + attributes: { + class: node.layout === "lr-tb" ? "xfaLr" : "xfaRl", + }, + children: [], + }; + extra.children.push(extra.line); + } + extra.line.children.push(html); + + if (extra.attempt === 0) { + // Add the element on the line + const [, , w, h] = bbox; + extra.currentWidth += w; + extra.height = Math.max(extra.height, extra.prevHeight + h); + } else { + const [, , w, h] = bbox; + extra.width = Math.max(extra.width, extra.currentWidth); + extra.currentWidth = w; + extra.prevHeight = extra.height; + extra.height += h; + + // The element has been added on a new line so switch to line mode now. + extra.attempt = 0; + } + break; + case "rl-row": + case "row": { + extra.children.push(html); + const [, , w, h] = bbox; + extra.width += w; + extra.height = Math.max(extra.height, h); + const height = measureToString(extra.height); + for (const child of extra.children) { + if (child.attributes.class === "xfaWrapper") { + child.children[child.children.length - 1].attributes.style.height = + height; + } else { + child.attributes.style.height = height; + } + } + break; + } + case "table": { + const [, , w, h] = bbox; + extra.width = Math.min(availableSpace.width, Math.max(extra.width, w)); + extra.height += h; + extra.children.push(html); + break; + } + case "tb": { + const [, , , h] = bbox; + extra.width = availableSpace.width; + extra.height += h; + extra.children.push(html); + break; + } + } +} + +function getAvailableSpace(node) { + const availableSpace = node[$extra].availableSpace; + + switch (node.layout) { + case "lr-tb": + case "rl-tb": + switch (node[$extra].attempt) { + case 0: + return { + width: availableSpace.width - node[$extra].currentWidth, + height: availableSpace.height - node[$extra].prevHeight, + }; + case 1: + return { + width: availableSpace.width, + height: availableSpace.height - node[$extra].height, + }; + default: + return { + width: Infinity, + height: availableSpace.height - node[$extra].prevHeight, + }; + } + case "rl-row": + case "row": + const width = node[$extra].columnWidths + .slice(node[$extra].currentColumn) + .reduce((a, x) => a + x); + return { width, height: availableSpace.height }; + case "table": + case "tb": + return { + width: availableSpace.width, + height: availableSpace.height - node[$extra].height, + }; + case "position": + default: + return availableSpace; + } +} + +export { addHTML, flushHTML, getAvailableSpace }; diff --git a/src/core/xfa/parser.js b/src/core/xfa/parser.js index 5bb7c0010..326cca0a6 100644 --- a/src/core/xfa/parser.js +++ b/src/core/xfa/parser.js @@ -35,6 +35,7 @@ class XFAParser extends XMLParserBase { this._current = this._builder.buildRoot(this._ids); this._errorCode = XMLParserErrorCode.NoError; this._whiteRegex = /^\s+$/; + this._nbsps = /\xa0+/g; } parse(data) { @@ -50,6 +51,9 @@ class XFAParser extends XMLParserBase { } onText(text) { + // Normally by definition a   is unbreakable + // but in real life Acrobat can break strings on  . + text = text.replace(this._nbsps, match => match.slice(1) + " "); if (this._current[$acceptWhitespace]()) { this._current[$onText](text); return; diff --git a/src/core/xfa/som.js b/src/core/xfa/som.js index 3fb2d04f5..9c8a8829c 100644 --- a/src/core/xfa/som.js +++ b/src/core/xfa/som.js @@ -275,19 +275,32 @@ function createDataNode(root, container, expr) { } for (let ii = parsed.length; i < ii; i++) { - const { cacheName, index } = parsed[i]; + const { name, operator, 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; + let children; + switch (operator) { + case operators.dot: + children = root[$getChildrenByName](name, false); + break; + case operators.dotDot: + children = root[$getChildrenByName](name, true); + break; + case operators.dotHash: + children = root[$getChildrenByClass](name); + if (children instanceof XFAObjectArray) { + children = children.children; + } else { + children = [children]; + } + break; + default: + break; } - const children = cached.get(cacheName); if (children.length === 0) { return createNodes(root, parsed.slice(i)); } diff --git a/src/core/xfa/template.js b/src/core/xfa/template.js index e1c836ca5..ef7e89e9f 100644 --- a/src/core/xfa/template.js +++ b/src/core/xfa/template.js @@ -14,21 +14,30 @@ */ import { + $addHTML, $appendChild, + $break, $childrenToHTML, $content, $extra, $finalize, + $flushHTML, + $getAvailableSpace, + $getChildren, + $getNextPage, $getParent, $hasItem, $hasSettableValue, + $ids, $isTransparent, $namespaceId, $nodeName, $onChild, $removeChild, + $searchNode, $setSetAttributes, $setValue, + $text, $toHTML, $toStyle, $uid, @@ -41,11 +50,17 @@ import { } from "./xfa_object.js"; import { $buildXFAObject, NamespaceIds } from "./namespaces.js"; import { - addExtraDivForMargin, + addExtraDivForBorder, + computeBbox, + fixDimensions, + fixTextIndent, + getFonts, layoutClass, + layoutText, measureToString, toStyle, } from "./html_utils.js"; +import { addHTML, flushHTML, getAvailableSpace } from "./layout.js"; import { getBBox, getColor, @@ -56,11 +71,20 @@ import { getRatio, getRelevant, getStringOption, + HTMLResult, } from "./utils.js"; import { stringToBytes, Util, warn } from "../../shared/util.js"; +import { searchNode } from "./som.js"; const TEMPLATE_NS_ID = NamespaceIds.template.id; +// In case of lr-tb (and rl-tb) layouts, we try: +// - to put the container at the end of a line +// - and if it fails we try on the next line. +// If both tries failed then it's up to the parent +// to handle the situation. +const MAX_ATTEMPTS_FOR_LRTB_LAYOUT = 2; + function _setValue(templateNode, value) { if (!templateNode.value) { const nodeValue = new Value({}); @@ -70,6 +94,37 @@ function _setValue(templateNode, value) { templateNode.value[$setValue](value); } +function getRoot(node) { + let parent = node[$getParent](); + while (!(parent instanceof Template)) { + parent = parent[$getParent](); + } + return parent; +} + +const NOTHING = 0; +const NOSPACE = 1; +const VALID = 2; +function checkDimensions(node, space) { + if (node.w !== "" && Math.round(node.w + node.x - space.width) > 1) { + const area = getRoot(node)[$extra].currentContentArea; + if (node.w + node.x > area.w) { + return NOTHING; + } + return NOSPACE; + } + + if (node.h !== "" && Math.round(node.h + node.y - space.height) > 1) { + const area = getRoot(node)[$extra].currentContentArea; + if (node.h + node.y > area.h) { + return NOTHING; + } + + return NOSPACE; + } + return VALID; +} + class AppearanceFilter extends StringObject { constructor(attributes) { super(TEMPLATE_NS_ID, "appearanceFilter"); @@ -113,7 +168,7 @@ class Area extends XFAObject { this.colSpan = getInteger({ data: attributes.colSpan, defaultValue: 1, - validate: n => n >= 1, + validate: n => n >= 1 || n === -1, }); this.id = attributes.id || ""; this.name = attributes.name || ""; @@ -137,10 +192,20 @@ class Area extends XFAObject { return true; } - [$toHTML]() { - // TODO: incomplete. - this[$extra] = Object.create(null); + [$addHTML](html, bbox) { + const [x, y, w, h] = bbox; + this[$extra].width = Math.max(this[$extra].width, x + w); + this[$extra].height = Math.max(this[$extra].height, y + h); + this[$extra].children.push(html); + } + + [$getAvailableSpace]() { + return this[$extra].availableSpace; + } + + [$toHTML](availableSpace) { + // TODO: incomplete. const style = toStyle(this, "position"); const attributes = { style, @@ -152,11 +217,36 @@ class Area extends XFAObject { attributes.xfaName = this.name; } - const children = this[$childrenToHTML]({ - // TODO: exObject & exclGroup - filter: new Set(["area", "draw", "field", "subform", "subformSet"]), - include: true, - }); + const children = []; + this[$extra] = { + children, + width: 0, + height: 0, + availableSpace, + }; + + if ( + !this[$childrenToHTML]({ + filter: new Set([ + "area", + "draw", + "field", + "exclGroup", + "subform", + "subformSet", + ]), + include: true, + }) + ) { + // Nothing to propose for the element which doesn't fit the + // available space. + delete this[$extra]; + // TODO: return failure or not ? + return HTMLResult.empty; + } + + style.width = measureToString(this[$extra].width); + style.height = measureToString(this[$extra].height); const html = { name: "div", @@ -164,7 +254,10 @@ class Area extends XFAObject { children, }; - return html; + const bbox = [this.x, this.y, this[$extra].width, this[$extra].height]; + delete this[$extra]; + + return HTMLResult.success(html, bbox); } } @@ -382,8 +475,8 @@ class BooleanElement extends Option01 { this.usehref = attributes.usehref || ""; } - [$toHTML]() { - return this[$content] === 1; + [$toHTML](availableSpace) { + return HTMLResult.success(this[$content] === 1); } } @@ -419,36 +512,43 @@ class Border extends XFAObject { } } - if (widths) { - for (let i = 0; i < 4; i++) { - widths[i] = edges[i].thickness; - } + widths = widths || [0, 0, 0, 0]; + for (let i = 0; i < 4; i++) { + widths[i] = edges[i].thickness; } - const edgeStyles = edges.map(node => node[$toStyle]()); - const cornerStyles = this.corner.children.map(node => node[$toStyle]()); + margins = margins || [0, 0, 0, 0]; + + const edgeStyles = edges.map(node => { + const style = node[$toStyle](); + style.color = style.color || "#000000"; + return style; + }); let style; if (this.margin) { style = this.margin[$toStyle](); - if (margins) { - margins[0] = this.margin.topInset; - margins[1] = this.margin.rightInset; - margins[2] = this.margin.bottomInset; - margins[3] = this.margin.leftInset; - } + margins[0] = this.margin.topInset; + margins[1] = this.margin.rightInset; + margins[2] = this.margin.bottomInset; + margins[3] = this.margin.leftInset; } else { style = Object.create(null); } + let isForUi = false; + const parent = this[$getParent](); + const grandParent = parent ? parent[$getParent]() : null; + if (grandParent instanceof Ui) { + isForUi = true; + } + if (this.fill) { Object.assign(style, this.fill[$toStyle]()); } - style.borderWidth = edgeStyles.map(s => s.width).join(" "); - style.borderColor = edgeStyles.map(s => s.color).join(" "); - style.borderStyle = edgeStyles.map(s => s.style).join(" "); - - if (cornerStyles.length > 0) { + let hasRadius = false; + if (this.corner.children.some(node => node.radius !== 0)) { + const cornerStyles = this.corner.children.map(node => node[$toStyle]()); if (cornerStyles.length === 2 || cornerStyles.length === 3) { const last = cornerStyles[cornerStyles.length - 1]; for (let i = cornerStyles.length; i < 4; i++) { @@ -457,16 +557,60 @@ class Border extends XFAObject { } style.borderRadius = cornerStyles.map(s => s.radius).join(" "); + hasRadius = true; } - switch (this.presence) { - case "invisible": - case "hidden": - style.borderStyle = ""; - break; - case "inactive": - style.borderStyle = "none"; - break; + const firstEdge = edgeStyles[0]; + if ( + !hasRadius && + (this.edge.children.length <= 1 || + (edgeStyles.every( + x => + x.style === firstEdge.style && + x.width === firstEdge.width && + x.color === firstEdge.color + ) && + margins.every(x => x === margins[0]))) + ) { + // Rectangular border and same values for each edge then we've an outline + // so no need to emulate it. + + let borderStyle; + switch (this.presence) { + case "invisible": + case "hidden": + borderStyle = ""; + break; + case "inactive": + borderStyle = "none"; + break; + default: + borderStyle = firstEdge.style; + break; + } + + style.outline = `${firstEdge.width} ${firstEdge.color} ${borderStyle}`; + const offset = edges[0].thickness + margins[0]; + style.outlineOffset = `-${measureToString(offset)}`; + if (isForUi) { + style.padding = `${measureToString(offset + 1)}`; + } + } else { + switch (this.presence) { + case "invisible": + case "hidden": + style.borderStyle = ""; + break; + case "inactive": + style.borderStyle = "none"; + break; + default: + style.borderStyle = edgeStyles.map(s => s.style).join(" "); + break; + } + + style.borderWidth = edgeStyles.map(s => s.width).join(" "); + style.borderColor = edgeStyles.map(s => s.color).join(" "); } return style; @@ -557,6 +701,11 @@ class BreakBefore extends XFAObject { this.usehref = attributes.usehref || ""; this.script = null; } + + [$toHTML](availableSpace) { + this[$extra] = {}; + return HTMLResult.FAILURE; + } } class Button extends XFAObject { @@ -574,15 +723,16 @@ class Button extends XFAObject { this.extras = null; } - [$toHTML]() { + [$toHTML](availableSpace) { // TODO: highlight. - return { + return HTMLResult.success({ name: "button", attributes: { class: "xfaButton", style: {}, }, - }; + children: [], + }); } } @@ -635,15 +785,15 @@ class Caption extends XFAObject { _setValue(this, value); } - [$toHTML]() { + [$toHTML](availableSpace) { // TODO: incomplete. if (!this.value) { - return null; + return HTMLResult.EMPTY; } - const value = this.value[$toHTML](); + const value = this.value[$toHTML](availableSpace).html; if (!value) { - return null; + return HTMLResult.EMPTY; } const children = []; if (typeof value === "string") { @@ -659,21 +809,30 @@ class Caption extends XFAObject { switch (this.placement) { case "left": case "right": - style.minWidth = measureToString(this.reserve); + if (this.reserve > 0) { + style.width = measureToString(this.reserve); + } else { + style.minWidth = measureToString(this.reserve); + } break; case "top": case "bottom": - style.minHeight = measureToString(this.reserve); + if (this.reserve > 0) { + style.height = measureToString(this.reserve); + } else { + style.minHeight = measureToString(this.reserve); + } break; } - return { + return HTMLResult.success({ name: "div", attributes: { style, + class: "xfaCaption", }, children, - }; + }); } } @@ -730,7 +889,7 @@ class CheckButton extends XFAObject { this.margin = null; } - [$toHTML]() { + [$toHTML](availableSpace) { // TODO: shape and mark == default. const style = toStyle(this, "border", "margin"); const size = measureToString(this.size); @@ -782,19 +941,26 @@ class CheckButton extends XFAObject { style.height = size; } - return { + const input = { + name: "input", + attributes: { + class: "xfaCheckbox", + type: "radio", + }, + }; + + const container = this[$getParent]()[$getParent]()[$getParent](); + if (container instanceof ExclGroup) { + input.attributes.name = container[$uid]; + } + + return HTMLResult.success({ name: "label", attributes: { class: "xfaLabel", }, children: [ - { - name: "input", - attributes: { - class: "xfaCheckbox", - type: "checkbox", - }, - }, + input, { name: "span", attributes: { @@ -804,7 +970,7 @@ class CheckButton extends XFAObject { }, }, ], - }; + }); } } @@ -831,10 +997,40 @@ class ChoiceList extends XFAObject { this.margin = null; } - [$toHTML]() { + [$toHTML](availableSpace) { // TODO: incomplete. const style = toStyle(this, "border", "margin"); - return { + const ui = this[$getParent](); + const field = ui[$getParent](); + const children = []; + + if (field.items.children.length > 0) { + const displayed = field.items.children[0][$toHTML]().html; + const values = field.items.children[1] + ? field.items.children[1][$toHTML]().html + : []; + + for (let i = 0, ii = displayed.length; i < ii; i++) { + children.push({ + name: "option", + attributes: { + value: values[i] || displayed[i], + }, + value: displayed[i], + }); + } + } + + const selectAttributes = { + class: "xfaSelect", + style, + }; + + if (this.open === "multiSelect") { + selectAttributes.multiple = true; + } + + return HTMLResult.success({ name: "label", attributes: { class: "xfaLabel", @@ -842,14 +1038,11 @@ class ChoiceList extends XFAObject { children: [ { name: "select", - attributes: { - class: "xfaSelect", - multiple: this.open === "multiSelect", - style, - }, + children, + attributes: selectAttributes, }, ], - }; + }); } } @@ -860,7 +1053,7 @@ class Color extends XFAObject { this.id = attributes.id || ""; this.use = attributes.use || ""; this.usehref = attributes.usehref || ""; - this.value = getColor(attributes.value); + this.value = attributes.value ? getColor(attributes.value) : ""; this.extras = null; } @@ -869,7 +1062,9 @@ class Color extends XFAObject { } [$toStyle]() { - return Util.makeHexColor(this.value.r, this.value.g, this.value.b); + return this.value + ? Util.makeHexColor(this.value.r, this.value.g, this.value.b) + : null; } } @@ -920,7 +1115,7 @@ class ContentArea extends XFAObject { this.extras = null; } - [$toHTML]() { + [$toHTML](availableSpace) { // TODO: incomplete. const left = measureToString(this.x); const top = measureToString(this.y); @@ -932,7 +1127,7 @@ class ContentArea extends XFAObject { width: measureToString(this.w), height: measureToString(this.h), }; - return { + return HTMLResult.success({ name: "div", children: [], attributes: { @@ -940,7 +1135,7 @@ class ContentArea extends XFAObject { class: "xfaContentarea", id: this[$uid], }, - }; + }); } } @@ -1004,8 +1199,8 @@ class DateElement extends ContentObject { this[$content] = new Date(this[$content].trim()); } - [$toHTML]() { - return this[$content].toString(); + [$toHTML](availableSpace) { + return HTMLResult.success(this[$content].toString()); } } @@ -1022,8 +1217,8 @@ class DateTime extends ContentObject { this[$content] = new Date(this[$content].trim()); } - [$toHTML]() { - return this[$content].toString(); + [$toHTML](availableSpace) { + return HTMLResult.success(this[$content].toString()); } } @@ -1045,7 +1240,7 @@ class DateTimeEdit extends XFAObject { this.margin = null; } - [$toHTML]() { + [$toHTML](availableSpace) { // TODO: incomplete. // When the picker is host we should use type=date for the input // but we need to put the buttons outside the text-field. @@ -1059,13 +1254,13 @@ class DateTimeEdit extends XFAObject { }, }; - return { + return HTMLResult.success({ name: "label", attributes: { class: "xfaLabel", }, children: [html], - }; + }); } } @@ -1093,8 +1288,10 @@ class Decimal extends ContentObject { this[$content] = isNaN(number) ? null : number; } - [$toHTML]() { - return this[$content] !== null ? this[$content].toString() : ""; + [$toHTML](availableSpace) { + return HTMLResult.success( + this[$content] !== null ? this[$content].toString() : "" + ); } } @@ -1170,7 +1367,7 @@ class Draw extends XFAObject { this.colSpan = getInteger({ data: attributes.colSpan, defaultValue: 1, - validate: x => x >= 1, + validate: n => n >= 1 || n === -1, }); this.h = attributes.h ? getMeasurement(attributes.h) : ""; this.hAlign = getStringOption(attributes.hAlign, [ @@ -1224,9 +1421,36 @@ class Draw extends XFAObject { _setValue(this, value); } - [$toHTML]() { - if (!this.value) { - return null; + [$toHTML](availableSpace) { + if ( + this.presence === "hidden" || + this.presence === "inactive" || + this.h === 0 || + this.w === 0 + ) { + return HTMLResult.EMPTY; + } + + fixDimensions(this); + + if (this.w !== "" && this.h === "" && this.value) { + const text = this.value[$text](); + if (text) { + const { height } = layoutText(text, this.font.size, { + width: this.w, + height: Infinity, + }); + this.h = height || ""; + } + } + + switch (checkDimensions(this, availableSpace)) { + case NOTHING: + return HTMLResult.EMPTY; + case NOSPACE: + return HTMLResult.FAILURE; + default: + break; } const style = toStyle( @@ -1256,35 +1480,59 @@ class Draw extends XFAObject { attributes.xfaName = this.name; } - let html = { + const html = { name: "div", attributes, children: [], }; - const value = this.value ? this.value[$toHTML]() : null; + const extra = addExtraDivForBorder(html); + const bbox = computeBbox(this, html, availableSpace); + + const value = this.value ? this.value[$toHTML](availableSpace).html : null; if (value === null) { - return html; + return HTMLResult.success(extra, bbox); } html.children.push(value); + if (value.attributes.class === "xfaRich") { + if (this.h === "") { + style.height = "auto"; + } + if (this.w === "") { + style.width = "auto"; + } + if (this.para) { + // By definition exData are external data so para + // has no effect on it. + attributes.style.display = "flex"; + attributes.style.flexDirection = "column"; + switch (this.para.vAlign) { + case "top": + attributes.style.justifyContent = "start"; + break; + case "bottom": + attributes.style.justifyContent = "end"; + break; + case "middle": + attributes.style.justifyContent = "center"; + break; + } - if (this.para && value.attributes.class === "xfaRich") { - const paraStyle = this.para[$toStyle](); - if (!value.attributes.style) { - value.attributes.style = paraStyle; - } else { - for (const [key, val] of Object.entries(paraStyle)) { - if (!(key in value.attributes.style)) { - value.attributes.style[key] = val; + const paraStyle = this.para[$toStyle](); + if (!value.attributes.style) { + value.attributes.style = paraStyle; + } else { + for (const [key, val] of Object.entries(paraStyle)) { + if (!(key in value.attributes.style)) { + value.attributes.style[key] = val; + } } } } } - html = addExtraDivForMargin(html); - - return html; + return HTMLResult.success(extra, bbox); } } @@ -1310,7 +1558,11 @@ class Edge extends XFAObject { "lowered", "raised", ]); - this.thickness = getMeasurement(attributes.thickness, "0.5pt"); + // Cheat the thickness to have something nice at the end + this.thickness = Math.max( + 1, + Math.round(getMeasurement(attributes.thickness, "0.5pt")) + ); this.use = attributes.use || ""; this.usehref = attributes.usehref || ""; this.color = null; @@ -1322,8 +1574,8 @@ class Edge extends XFAObject { const style = toStyle(this, "visibility"); Object.assign(style, { linecap: this.cap, - width: measureToString(this.thickness), - color: this.color ? this.color[$toHTML]() : "#000000", + width: measureToString(Math.max(1, Math.round(this.thickness))), + color: this.color ? this.color[$toStyle]() : "#000000", style: "", }); @@ -1542,13 +1794,13 @@ class ExData extends ContentObject { return false; } - [$toHTML]() { + [$toHTML](availableSpace) { if (this.contentType !== "text/html" || !this[$content]) { // TODO: fix other cases. - return null; + return HTMLResult.EMPTY; } - return this[$content][$toHTML](); + return this[$content][$toHTML](availableSpace); } } @@ -1602,7 +1854,7 @@ class ExclGroup extends XFAObject { this.colSpan = getInteger({ data: attributes.colSpan, defaultValue: 1, - validate: x => x >= 1, + validate: n => n >= 1 || n === -1, }); this.h = attributes.h ? getMeasurement(attributes.h) : ""; this.hAlign = getStringOption(attributes.hAlign, [ @@ -1683,29 +1935,159 @@ class ExclGroup extends XFAObject { } } - [$toHTML]() { - if (!this.value) { - return null; + [$flushHTML]() { + return flushHTML(this); + } + + [$addHTML](html, bbox) { + addHTML(this, html, bbox); + } + + [$getAvailableSpace]() { + return getAvailableSpace(this); + } + + [$toHTML](availableSpace) { + if ( + this.presence === "hidden" || + this.presence === "inactive" || + this.h === 0 || + this.w === 0 + ) { + return HTMLResult.EMPTY; } - const style = toStyle(this, "dimensions", "position", "anchorType"); + fixDimensions(this); + + const children = []; const attributes = { - style, id: this[$uid], - class: "xfaExclgroup", }; - const children = this[$childrenToHTML]({ - // TODO: exObject & exclGroup - filter: new Set(["field"]), - include: true, + if (!this[$extra]) { + this[$extra] = Object.create(null); + } + + Object.assign(this[$extra], { + children, + attributes, + attempt: 0, + availableSpace, + width: 0, + height: 0, + prevHeight: 0, + currentWidth: 0, }); - return { + switch (checkDimensions(this, availableSpace)) { + case NOTHING: + return HTMLResult.EMPTY; + case NOSPACE: + return HTMLResult.FAILURE; + default: + break; + } + + availableSpace = { + width: this.w === "" ? availableSpace.width : this.w, + height: this.h === "" ? availableSpace.height : this.h, + }; + + const filter = new Set(["field"]); + + if (this.layout === "row") { + const columnWidths = this[$getParent]().columnWidths; + if (Array.isArray(columnWidths) && columnWidths.length > 0) { + this[$extra].columnWidths = columnWidths; + this[$extra].currentColumn = 0; + } + } + + const style = toStyle( + this, + "anchorType", + "dimensions", + "position", + "presence", + "borderMarginPadding", + "hAlign" + ); + const clazz = ["xfaExclgroup"]; + const cl = layoutClass(this); + if (cl) { + clazz.push(cl); + } + + attributes.style = style; + attributes.class = clazz.join(" "); + + if (this.name) { + attributes.xfaName = this.name; + } + + let failure; + if (this.layout === "lr-tb" || this.layout === "rl-tb") { + for ( + ; + this[$extra].attempt < MAX_ATTEMPTS_FOR_LRTB_LAYOUT; + this[$extra].attempt++ + ) { + if ( + this[$childrenToHTML]({ + filter, + include: true, + }) + ) { + break; + } + } + + failure = this[$extra].attempt === 2; + } else { + failure = !this[$childrenToHTML]({ + filter, + include: true, + }); + } + + if (failure) { + return HTMLResult.FAILURE; + } + + let marginH = 0; + let marginV = 0; + if (this.margin) { + marginH = this.margin.leftInset + this.margin.rightInset; + marginV = this.margin.topInset + this.margin.bottomInset; + } + + if (this.w === "") { + style.width = measureToString(this[$extra].width + marginH); + } + if (this.h === "") { + style.height = measureToString(this[$extra].height + marginV); + } + + let html = { name: "div", attributes, children, }; + + html = addExtraDivForBorder(html); + let bbox; + if (this.w !== "" && this.h !== "") { + bbox = [this.x, this.y, this.w, this.h]; + } else { + const width = this.w === "" ? marginH + this[$extra].width : this.w; + const height = this.h === "" ? marginV + this[$extra].height : this.h; + + bbox = [this.x, this.y, width, height]; + } + + delete this[$extra]; + + return HTMLResult.success(html, bbox); } } @@ -1747,6 +2129,10 @@ class Extras extends XFAObject { this.text = new XFAObjectArray(); this.time = new XFAObjectArray(); } + + // (Spec) The XFA template grammar defines the extras and desc elements, + // which can be used to add human-readable or machine-readable + // data to a template. } class Field extends XFAObject { @@ -1773,7 +2159,7 @@ class Field extends XFAObject { this.colSpan = getInteger({ data: attributes.colSpan, defaultValue: 1, - validate: x => x >= 1, + validate: n => n >= 1 || n === -1, }); this.h = attributes.h ? getMeasurement(attributes.h) : ""; this.hAlign = getStringOption(attributes.hAlign, [ @@ -1837,9 +2223,26 @@ class Field extends XFAObject { _setValue(this, value); } - [$toHTML]() { - if (!this.ui) { - return null; + [$toHTML](availableSpace) { + if ( + !this.ui || + this.presence === "hidden" || + this.presence === "inactive" || + this.h === 0 || + this.w === 0 + ) { + return HTMLResult.EMPTY; + } + + fixDimensions(this); + + switch (checkDimensions(this, availableSpace)) { + case NOTHING: + return HTMLResult.EMPTY; + case NOSPACE: + return HTMLResult.FAILURE; + default: + break; } const style = toStyle( @@ -1850,7 +2253,8 @@ class Field extends XFAObject { "rotate", "anchorType", "presence", - "borderMarginPadding" + "borderMarginPadding", + "hAlign" ); const clazz = ["xfaField"]; @@ -1876,9 +2280,12 @@ class Field extends XFAObject { children, }; - const ui = this.ui ? this.ui[$toHTML]() : null; + const bbox = computeBbox(this, html, availableSpace); + html = addExtraDivForBorder(html); + + const ui = this.ui ? this.ui[$toHTML]().html : null; if (!ui) { - return html; + return HTMLResult.success(html, bbox); } if (!ui.attributes.style) { @@ -1886,13 +2293,24 @@ class Field extends XFAObject { } children.push(ui); - if (this.value && ui.name !== "button") { - ui.children[0].attributes.value = this.value[$toHTML]().value; + if (this.value) { + if (this.ui.imageEdit) { + ui.children.push(this.value[$toHTML]().html); + } else if (ui.name !== "button") { + const value = this.value[$toHTML]().html; + if (value) { + if (ui.children[0].name === "textarea") { + ui.children[0].attributes.textContent = value.value; + } else { + ui.children[0].attributes.value = value.value; + } + } + } } - const caption = this.caption ? this.caption[$toHTML]() : null; + const caption = this.caption ? this.caption[$toHTML]().html : null; if (!caption) { - return html; + return HTMLResult.success(html, bbox); } if (ui.name === "button") { @@ -1901,8 +2319,9 @@ class Field extends XFAObject { if (caption.name === "div") { caption.name = "span"; } - ui.children = [caption]; - return html; + ui.children.push(caption); + + return HTMLResult.success(html, bbox); } ui.children.splice(0, 0, caption); @@ -1914,9 +2333,11 @@ class Field extends XFAObject { ui.attributes.style.flexDirection = "row-reverse"; break; case "top": + ui.attributes.style.alignItems = "start"; ui.attributes.style.flexDirection = "column"; break; case "bottom": + ui.attributes.style.alignItems = "start"; ui.attributes.style.flexDirection = "column-reverse"; break; case "inline": @@ -1925,9 +2346,7 @@ class Field extends XFAObject { break; } - html = addExtraDivForMargin(html); - - return html; + return HTMLResult.success(html, bbox); } } @@ -1955,6 +2374,12 @@ class Fill extends XFAObject { } [$toStyle]() { + const parent = this[$getParent](); + let propName = "color"; + if (parent instanceof Border) { + propName = "background"; + } + const style = Object.create(null); for (const name of Object.getOwnPropertyNames(this)) { if (name === "extras" || name === "color") { continue; @@ -1964,18 +2389,15 @@ class Fill extends XFAObject { continue; } - return { - color: obj[$toStyle](this.color), - }; + style[propName] = obj[$toStyle](this.color); + return style; } if (this.color) { - return { - background: this.color[$toStyle](), - }; + style[propName] = this.color[$toStyle](); } - return {}; + return style; } } @@ -2024,8 +2446,10 @@ class Float extends ContentObject { this[$content] = isNaN(number) ? null : number; } - [$toHTML]() { - return this[$content] !== null ? this[$content].toString() : ""; + [$toHTML](availableSpace) { + return HTMLResult.success( + this[$content] !== null ? this[$content].toString() : "" + ); } } @@ -2088,18 +2512,17 @@ class Font extends XFAObject { [$toStyle]() { const style = toStyle(this, "fill"); - const color = style.background; + const color = style.color; if (color) { if (color === "#000000") { - delete style.background; + // Default font color. + delete style.color; } else if (!color.startsWith("#")) { // We've a gradient which is not possible for a font color // so use a workaround. + style.background = color; style.backgroundClip = "text"; style.color = "transparent"; - } else { - style.color = color; - delete style.background; } } @@ -2140,12 +2563,12 @@ class Font extends XFAObject { style.fontStyle = this.posture; } - const fontSize = measureToString(this.size); + const fontSize = measureToString(0.99 * this.size); if (fontSize !== "10px") { style.fontSize = fontSize; } - style.fontFamily = this.typeface; + style.fontFamily = getFonts(this.typeface); if (this.underline !== 0) { style.textDecoration = "underline"; @@ -2253,23 +2676,24 @@ class Image extends StringObject { // containing a picture. // In general, we don't get remote data and use what we have // in the pdf itself, so no picture for non null href. - return null; + return HTMLResult.EMPTY; } + // TODO: Firefox doesn't support natively tiff (and tif) format. if (this.transferEncoding === "base64") { const buffer = stringToBytes(atob(this[$content])); const blob = new Blob([buffer], { type: this.contentType }); - return { + return HTMLResult.success({ name: "img", attributes: { class: "xfaImage", style: {}, src: URL.createObjectURL(blob), }, - }; + }); } - return null; + return HTMLResult.EMPTY; } } @@ -2284,6 +2708,18 @@ class ImageEdit extends XFAObject { this.extras = null; this.margin = null; } + + [$toHTML](availableSpace) { + if (this.data === "embed") { + return HTMLResult.success({ + name: "div", + children: [], + attributes: {}, + }); + } + + return HTMLResult.EMPTY; + } } class Integer extends ContentObject { @@ -2300,8 +2736,10 @@ class Integer extends ContentObject { this[$content] = isNaN(number) ? null : number; } - [$toHTML]() { - return this[$content] !== null ? this[$content].toString() : ""; + [$toHTML](availableSpace) { + return HTMLResult.success( + this[$content] !== null ? this[$content].toString() : "" + ); } } @@ -2355,6 +2793,14 @@ class Items extends XFAObject { ) ); } + + [$toHTML]() { + const output = []; + for (const child of this[$getChildren]()) { + output.push(child[$text]()); + } + return HTMLResult.success(output); + } } class Keep extends XFAObject { @@ -2559,7 +3005,7 @@ class NumericEdit extends XFAObject { this.margin = null; } - [$toHTML]() { + [$toHTML](availableSpace) { // TODO: incomplete. const style = toStyle(this, "border", "font", "margin"); const html = { @@ -2571,13 +3017,13 @@ class NumericEdit extends XFAObject { }, }; - return { + return HTMLResult.success({ name: "label", attributes: { class: "xfaLabel", }, children: [html], - }; + }); } } @@ -2686,39 +3132,68 @@ class PageArea extends XFAObject { this.subform = new XFAObjectArray(); } - [$toHTML]() { - // TODO: incomplete. - if (this.contentArea.children.length === 0) { - return null; + [$getNextPage]() { + if (!this[$extra]) { + this[$extra] = { + numberOfUse: 1, + }; + } + const parent = this[$getParent](); + if (parent.relation === "orderedOccurrence") { + if ( + this.occur && + (this.occur.max === -1 || this[$extra].numberOfUse < this.occur.max) + ) { + this[$extra].numberOfUse += 1; + return this; + } } - const children = this[$childrenToHTML]({ - filter: new Set(["area", "draw", "field", "subform", "contentArea"]), - include: true, - }); + delete this[$extra]; + return parent[$getNextPage](); + } - // TODO: handle the case where there are several content areas. - const contentArea = children.find( - node => node.attributes.class === "xfaContentarea" - ); + [$getAvailableSpace]() { + return { width: Infinity, height: Infinity }; + } + + [$toHTML]() { + // TODO: incomplete. + if (!this[$extra]) { + this[$extra] = { + numberOfUse: 1, + }; + } + + const children = []; + this[$extra].children = children; const style = Object.create(null); if (this.medium && this.medium.short && this.medium.long) { style.width = measureToString(this.medium.short); style.height = measureToString(this.medium.long); + if (this.medium.orientation === "landscape") { + const x = style.width; + style.width = style.height; + style.height = x; + } } else { - // TODO: compute it from contentAreas + warn("XFA - No medium specified in pageArea: please file a bug."); } - return { + this[$childrenToHTML]({ + filter: new Set(["area", "draw", "field", "subform", "contentArea"]), + include: true, + }); + + return HTMLResult.success({ name: "div", children, attributes: { id: this[$uid], style, }, - contentArea, - }; + }); } } @@ -2745,18 +3220,71 @@ class PageSet extends XFAObject { this.pageSet = new XFAObjectArray(); } - [$toHTML]() { - // TODO: incomplete. - return { - name: "div", - children: this[$childrenToHTML]({ - filter: new Set(["pageArea", "pageSet"]), - include: true, - }), - attributes: { - id: this[$uid], - }, - }; + [$getNextPage]() { + if (!this[$extra]) { + this[$extra] = { + numberOfUse: 1, + currentIndex: -1, + }; + } + + if (this.relation === "orderedOccurrence") { + if (this[$extra].currentIndex + 1 < this.pageArea.children.length) { + this[$extra].currentIndex += 1; + return this.pageArea.children[this[$extra].currentIndex]; + } + + if (this[$extra].currentIndex + 1 < this.pageSet.children.length) { + this[$extra].currentIndex += 1; + return this.pageSet.children[this[$extra].currentIndex]; + } + + if ( + this.occur && + (this.occur.max === -1 || this[$extra].numberOfUse < this.occur.max) + ) { + this[$extra].numberOfUse += 1; + this[$extra].currentIndex = 0; + if (this.pageArea.children.length > 0) { + return this.pageArea.children[0]; + } + return this.pageSet.children[0][$getNextPage](); + } + + delete this[$extra]; + const parent = this[$getParent](); + if (parent instanceof PageSet) { + return parent[$getNextPage](); + } + + return this[$getNextPage](); + } + const pageNumber = getRoot(this)[$extra].pageNumber; + const parity = pageNumber % 2 === 0 ? "even" : "odd"; + const position = pageNumber === 0 ? "first" : "rest"; + + let page = this.pageArea.children.find( + p => p.oddOrEven === parity && p.pagePosition === position + ); + if (page) { + return page; + } + + page = this.pageArea.children.find( + p => p.oddOrEven === "any" && p.pagePosition === position + ); + if (page) { + return page; + } + + page = this.pageArea.children.find( + p => p.oddOrEven === "any" && p.pagePosition === "any" + ); + if (page) { + return page; + } + + return this.pageArea.children[0]; } } @@ -2837,11 +3365,10 @@ class Para extends XFAObject { } if (this.textIndent !== "") { style.textIndent = measureToString(this.textIndent); + fixTextIndent(style); } - // TODO: vAlign - - if (this.lineHeight !== "") { + if (this.lineHeight > 0) { style.lineHeight = measureToString(this.lineHeight); } @@ -3272,7 +3799,7 @@ class Subform extends XFAObject { this.colSpan = getInteger({ data: attributes.colSpan, defaultValue: 1, - validate: x => x >= 1, + validate: n => n >= 1 || n === -1, }); this.columnWidths = (attributes.columnWidths || "") .trim() @@ -3356,11 +3883,95 @@ class Subform extends XFAObject { this.subformSet = new XFAObjectArray(); } - [$toHTML]() { - // TODO: incomplete. - this[$extra] = Object.create(null); + [$flushHTML]() { + return flushHTML(this); + } - if (this.layout === "row") { + [$addHTML](html, bbox) { + addHTML(this, html, bbox); + } + + [$getAvailableSpace]() { + return getAvailableSpace(this); + } + + [$toHTML](availableSpace) { + if (this.name === "helpText") { + return HTMLResult.EMPTY; + } + if (this[$extra] && this[$extra].afterBreakAfter) { + const ret = this[$extra].afterBreakAfter; + delete this[$extra]; + return ret; + } + + if (this.presence === "hidden" || this.presence === "inactive") { + return HTMLResult.EMPTY; + } + + if ( + this.breakBefore.children.length > 1 || + this.breakAfter.children.length > 1 + ) { + // Specs are always talking about the breakBefore element + // and it doesn't really make sense to have several ones. + warn( + "XFA - Several breakBefore or breakAfter in subforms: please file a bug." + ); + } + + // TODO: implement usehref (probably in bind.js). + + // TODO: incomplete. + fixDimensions(this); + const children = []; + const attributes = { + id: this[$uid], + }; + + if (!this[$extra]) { + this[$extra] = Object.create(null); + } + + Object.assign(this[$extra], { + children, + attributes, + attempt: 0, + availableSpace, + width: 0, + height: 0, + prevHeight: 0, + currentWidth: 0, + }); + + if (this.breakBefore.children.length >= 1) { + const breakBefore = this.breakBefore.children[0]; + if (!breakBefore[$extra]) { + breakBefore[$extra] = true; + getRoot(this)[$break](breakBefore); + return HTMLResult.FAILURE; + } + } + + switch (checkDimensions(this, availableSpace)) { + case NOTHING: + return HTMLResult.EMPTY; + case NOSPACE: + return HTMLResult.FAILURE; + default: + break; + } + + const filter = new Set([ + "area", + "draw", + "exclGroup", + "field", + "subform", + "subformSet", + ]); + + if (this.layout.includes("row")) { const columnWidths = this[$getParent]().columnWidths; if (Array.isArray(columnWidths) && columnWidths.length > 0) { this[$extra].columnWidths = columnWidths; @@ -3368,62 +3979,98 @@ class Subform extends XFAObject { } } - const parent = this[$getParent](); - let page = null; - if (parent[$nodeName] === "template") { - // Root subform: should have page info. - if (this.pageSet !== null) { - this[$extra].pageNumber = 0; - } else { - // TODO - warn("XFA - No pageSet in root subform"); - } - } else if (parent[$extra] && parent[$extra].pageNumber !== undefined) { - // This subform is a child of root subform - // so push it in a new page. - const pageNumber = parent[$extra].pageNumber; - const pageAreas = parent.pageSet.pageArea.children; - parent[$extra].pageNumber = - (parent[$extra].pageNumber + 1) % pageAreas.length; - page = pageAreas[pageNumber][$toHTML](); - } - - const style = toStyle(this, "dimensions", "position", "presence"); + const style = toStyle( + this, + "anchorType", + "dimensions", + "position", + "presence", + "borderMarginPadding", + "hAlign" + ); const clazz = ["xfaSubform"]; const cl = layoutClass(this); if (cl) { clazz.push(cl); } - const attributes = { - style, - id: this[$uid], - class: clazz.join(" "), - }; + attributes.style = style; + attributes.class = clazz.join(" "); if (this.name) { attributes.xfaName = this.name; } - const children = this[$childrenToHTML]({ - // TODO: exObject & exclGroup - filter: new Set(["area", "draw", "field", "subform", "subformSet"]), - include: true, - }); + let failure; + if (this.layout === "lr-tb" || this.layout === "rl-tb") { + for ( + ; + this[$extra].attempt < MAX_ATTEMPTS_FOR_LRTB_LAYOUT; + this[$extra].attempt++ + ) { + if ( + this[$childrenToHTML]({ + filter, + include: true, + }) + ) { + break; + } + } - const html = { + failure = this[$extra].attempt === 2; + } else { + failure = !this[$childrenToHTML]({ + filter, + include: true, + }); + } + + if (failure) { + return HTMLResult.FAILURE; + } + + let marginH = 0; + let marginV = 0; + if (this.margin) { + marginH = this.margin.leftInset + this.margin.rightInset; + marginV = this.margin.topInset + this.margin.bottomInset; + } + + if (this.w === "") { + style.width = measureToString(this[$extra].width + marginH); + } + if (this.h === "") { + style.height = measureToString(this[$extra].height + marginV); + } + + let html = { name: "div", attributes, children, }; - if (page) { - page.contentArea.children.push(html); - delete page.contentArea; - return page; + html = addExtraDivForBorder(html); + let bbox; + if (this.w !== "" && this.h !== "") { + bbox = [this.x, this.y, this.w, this.h]; + } else { + const width = this.w === "" ? marginH + this[$extra].width : this.w; + const height = this.h === "" ? marginV + this[$extra].height : this.h; + + bbox = [this.x, this.y, width, height]; } - return html; + if (this.breakAfter.children.length >= 1) { + const breakAfter = this.breakAfter.children[0]; + getRoot(this)[$break](breakAfter); + this[$extra].afterBreakAfter = HTMLResult.success(html, bbox); + return HTMLResult.FAILURE; + } + + delete this[$extra]; + + return HTMLResult.success(html, bbox); } } @@ -3451,6 +4098,27 @@ class SubformSet extends XFAObject { this.subform = new XFAObjectArray(); this.subformSet = new XFAObjectArray(); } + + [$toHTML]() { + const children = []; + if (!this[$extra]) { + this[$extra] = Object.create(null); + } + this[$extra].children = children; + + this[$childrenToHTML]({ + filter: new Set(["subform", "subformSet"]), + include: true, + }); + + return HTMLResult.success({ + name: "div", + children, + attributes: { + id: this[$uid], + }, + }); + } } class SubjectDN extends ContentObject { @@ -3557,14 +4225,197 @@ class Template extends XFAObject { } } - [$toHTML]() { - if (this.subform.children.length > 0) { - return this.subform.children[0][$toHTML](); + [$break](node) { + this[$extra].breakingNode = node; + } + + [$searchNode](expr, container) { + if (expr.startsWith("#")) { + // This is an id. + return [this[$ids].get(expr.slice(1))]; } - return { + return searchNode(this, container, expr, true, true); + } + + [$toHTML]() { + if (!this.subform.children.length) { + return HTMLResult.success({ + name: "div", + children: [], + }); + } + + this[$extra] = { + breakingNode: null, + pageNumber: 1, + pagePosition: "first", + oddOrEven: "odd", + blankOrNotBlank: "nonBlank", + }; + + const root = this.subform.children[0]; + const pageAreas = root.pageSet.pageArea.children; + const mainHtml = { name: "div", children: [], }; + + let pageArea = null; + let breakBefore = null; + let breakBeforeTarget = null; + if (root.breakBefore.children.length >= 1) { + breakBefore = root.breakBefore.children[0]; + breakBeforeTarget = breakBefore.target; + } else if ( + root.subform.children.length >= 1 && + root.subform.children[0].breakBefore.children.length >= 1 + ) { + breakBefore = root.subform.children[0].breakBefore.children[0]; + breakBeforeTarget = breakBefore.target; + } else if (root.break && root.break.beforeTarget) { + breakBefore = root.break; + breakBeforeTarget = breakBefore.beforeTarget; + } else if ( + root.subform.children.length >= 1 && + root.subform.children[0].break && + root.subform.children[0].break.beforeTarget + ) { + breakBefore = root.subform.children[0].break; + breakBeforeTarget = breakBefore.beforeTarget; + } + + if (breakBefore) { + const target = this[$searchNode]( + breakBeforeTarget, + breakBefore[$getParent]() + ); + if (target instanceof PageArea) { + pageArea = target; + // Consume breakBefore. + breakBefore[$extra] = {}; + } + } + + if (!pageArea) { + pageArea = pageAreas[0]; + } + + const pageAreaParent = pageArea[$getParent](); + pageAreaParent[$extra] = { + numberOfUse: 1, + currentIndex: pageAreaParent.pageArea.children.indexOf(pageArea), + }; + + let targetPageArea; + let leader = null; + let trailer = null; + + while (true) { + targetPageArea = null; + const page = pageArea[$toHTML]().html; + mainHtml.children.push(page); + + if (leader) { + page.children.push(leader[$toHTML](page[$extra].space).html); + leader = null; + } + + if (trailer) { + page.children.push(trailer[$toHTML](page[$extra].space).html); + trailer = null; + } + + const contentAreas = pageArea.contentArea.children; + const htmlContentAreas = page.children.filter( + node => node.attributes.class === "xfaContentarea" + ); + for (let i = 0, ii = contentAreas.length; i < ii; i++) { + const contentArea = (this[$extra].currentContentArea = contentAreas[i]); + const space = { width: contentArea.w, height: contentArea.h }; + + if (leader) { + htmlContentAreas[i].children.push(leader[$toHTML](space).html); + leader = null; + } + + if (trailer) { + htmlContentAreas[i].children.push(trailer[$toHTML](space).html); + trailer = null; + } + + let html = root[$toHTML](space); + if (html.success) { + if (html.html) { + htmlContentAreas[i].children.push(html.html); + } + return mainHtml; + } + + // Check for breakBefore / breakAfter + let mustBreak = false; + if (this[$extra].breakingNode) { + const node = this[$extra].breakingNode; + this[$extra].breakingNode = null; + + if (node.targetType === "auto") { + // Just ignore the break and do layout again. + i--; + continue; + } + + const startNew = node.startNew === 1; + + if (node.leader) { + leader = this[$searchNode](node.leader, node[$getParent]()); + leader = leader ? leader[0] : null; + } + + if (node.trailer) { + trailer = this[$searchNode](node.trailer, node[$getParent]()); + trailer = trailer ? trailer[0] : null; + } + + let target = null; + if (node.target) { + target = this[$searchNode](node.target, node[$getParent]()); + target = target ? target[0] : target; + } + + if (node.targetType === "pageArea") { + if (startNew) { + mustBreak = true; + } else if (target === pageArea || !(target instanceof PageArea)) { + // Just ignore the break and do layout again. + i--; + continue; + } else { + // We must stop the contentAreas filling and go to the next page. + targetPageArea = target; + mustBreak = true; + } + } else if ( + target === "contentArea" || + !(target instanceof ContentArea) + ) { + // Just ignore the break and do layout again. + i--; + continue; + } + } + + html = root[$flushHTML](); + if (html) { + htmlContentAreas[i].children.push(html); + } + + if (mustBreak) { + break; + } + } + + this[$extra].pageNumber += 1; + pageArea = targetPageArea || pageArea[$getNextPage](); + } } } @@ -3592,7 +4443,7 @@ class Text extends ContentObject { return false; } - [$toHTML]() { + [$toHTML](availableSpace) { if (typeof this[$content] === "string") { // \u2028 is a line separator. // \u2029 is a paragraph separator. @@ -3652,10 +4503,10 @@ class Text extends ContentObject { }); } - return html; + return HTMLResult.success(html); } - return this[$content][$toHTML](); + return this[$content][$toHTML](availableSpace); } } @@ -3691,7 +4542,7 @@ class TextEdit extends XFAObject { this.margin = null; } - [$toHTML]() { + [$toHTML](availableSpace) { // TODO: incomplete. const style = toStyle(this, "border", "font", "margin"); let html; @@ -3714,13 +4565,13 @@ class TextEdit extends XFAObject { }; } - return { + return HTMLResult.success({ name: "label", attributes: { class: "xfaLabel", }, children: [html], - }; + }); } } @@ -3738,8 +4589,8 @@ class Time extends StringObject { this[$content] = new Date(this[$content]); } - [$toHTML]() { - return this[$content].toString(); + [$toHTML](availableSpace) { + return HTMLResult.success(this[$content].toString()); } } @@ -3828,7 +4679,7 @@ class Ui extends XFAObject { this.textEdit = null; } - [$toHTML]() { + [$toHTML](availableSpace) { // TODO: picture. for (const name of Object.getOwnPropertyNames(this)) { if (name === "extras" || name === "picture") { @@ -3839,9 +4690,9 @@ class Ui extends XFAObject { continue; } - return obj[$toHTML](); + return obj[$toHTML](availableSpace); } - return null; + return HTMLResult.EMPTY; } } @@ -3903,6 +4754,17 @@ class Value extends XFAObject { } [$setValue](value) { + const parent = this[$getParent](); + if (parent instanceof Field) { + if (parent.ui && parent.ui.imageEdit) { + if (!this.image) { + this.image = new Image({}); + } + this.image[$content] = value[$content]; + return; + } + } + const valueName = value[$nodeName]; if (this[valueName] !== null) { this[valueName][$content] = value[$content]; @@ -3922,17 +4784,33 @@ class Value extends XFAObject { this[$appendChild](value); } - [$toHTML]() { + [$text]() { + if (this.exData) { + return this.exData[$content][$text]().trim(); + } + for (const name of Object.getOwnPropertyNames(this)) { + if (name === "image") { + continue; + } + const obj = this[name]; + if (obj instanceof XFAObject) { + return (obj[$content] || "").toString().trim(); + } + } + return null; + } + + [$toHTML](availableSpace) { for (const name of Object.getOwnPropertyNames(this)) { const obj = this[name]; if (!(obj instanceof XFAObject)) { continue; } - return obj[$toHTML](); + return obj[$toHTML](availableSpace); } - return null; + return HTMLResult.EMPTY; } } diff --git a/src/core/xfa/utils.js b/src/core/xfa/utils.js index 872fff44f..585ed5464 100644 --- a/src/core/xfa/utils.js +++ b/src/core/xfa/utils.js @@ -18,6 +18,7 @@ const dimConverters = { cm: x => (x / 2.54) * 72, mm: x => (x / (10 * 2.54)) * 72, in: x => x * 72, + px: x => x, }; const measurementPattern = /([+-]?[0-9]+\.?[0-9]*)(.*)/; @@ -163,6 +164,21 @@ function getBBox(data) { return { x, y, width, height }; } +class HTMLResult { + constructor(success, html, bbox) { + this.success = success; + this.html = html; + this.bbox = bbox; + } + + static success(html, bbox = null) { + return new HTMLResult(true, html, bbox); + } +} + +HTMLResult.FAILURE = new HTMLResult(false, null, null); +HTMLResult.EMPTY = new HTMLResult(true, null, null); + export { getBBox, getColor, @@ -173,4 +189,5 @@ export { getRatio, getRelevant, getStringOption, + HTMLResult, }; diff --git a/src/core/xfa/xfa_object.js b/src/core/xfa/xfa_object.js index 8c167a679..4d62c20ea 100644 --- a/src/core/xfa/xfa_object.js +++ b/src/core/xfa/xfa_object.js @@ -13,14 +13,16 @@ * limitations under the License. */ -import { getInteger, getKeyword } from "./utils.js"; +import { getInteger, getKeyword, HTMLResult } 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. const $acceptWhitespace = Symbol(); +const $addHTML = Symbol(); const $appendChild = Symbol(); +const $break = Symbol(); const $childrenToHTML = Symbol(); const $clean = Symbol(); const $cleanup = Symbol(); @@ -31,16 +33,21 @@ const $data = Symbol("data"); const $dump = Symbol(); const $extra = Symbol("extra"); const $finalize = Symbol(); +const $flushHTML = Symbol(); const $getAttributeIt = Symbol(); +const $getAvailableSpace = Symbol(); const $getChildrenByClass = Symbol(); const $getChildrenByName = Symbol(); const $getChildrenByNameIt = Symbol(); +const $getDataValue = Symbol(); const $getRealChildrenByNameIt = Symbol(); const $getChildren = Symbol(); +const $getNextPage = Symbol(); const $getParent = Symbol(); const $global = Symbol(); const $hasItem = Symbol(); const $hasSettableValue = Symbol(); +const $ids = Symbol(); const $indexOf = Symbol(); const $insertAt = Symbol(); const $isDataValue = Symbol(); @@ -55,6 +62,7 @@ const $onChildCheck = Symbol(); const $onText = Symbol(); const $removeChild = Symbol(); const $resolvePrototypes = Symbol(); +const $searchNode = Symbol(); const $setId = Symbol(); const $setSetAttributes = Symbol(); const $setValue = Symbol(); @@ -70,6 +78,7 @@ const _children = Symbol("_children"); const _cloneAttribute = Symbol(); const _dataValue = Symbol(); const _defaultValue = Symbol(); +const _filteredChildrenGenerator = Symbol(); const _getPrototype = Symbol(); const _getUnsetAttributes = Symbol(); const _hasChildren = Symbol(); @@ -270,20 +279,67 @@ class XFAObject { } [$toHTML]() { + return HTMLResult.EMPTY; + } + + *[_filteredChildrenGenerator](filter, include) { + for (const node of this[$getChildren]()) { + if (!filter || include === filter.has(node[$nodeName])) { + const availableSpace = this[$getAvailableSpace](); + const res = node[$toHTML](availableSpace); + if (!res.success) { + this[$extra].failingNode = node; + } + yield res; + } + } + } + + [$flushHTML]() { return null; } + [$addHTML](html, bbox) { + this[$extra].children.push(html); + } + + [$getAvailableSpace]() {} + [$childrenToHTML]({ filter = null, include = true }) { - const res = []; - this[$getChildren]().forEach(node => { - if (!filter || include === filter.has(node[$nodeName])) { - const html = node[$toHTML](); - if (html) { - res.push(html); - } + if (!this[$extra].generator) { + this[$extra].generator = this[_filteredChildrenGenerator]( + filter, + include + ); + } else { + const availableSpace = this[$getAvailableSpace](); + const res = this[$extra].failingNode[$toHTML](availableSpace); + if (!res.success) { + return false; } - }); - return res; + if (res.html) { + this[$addHTML](res.html, res.bbox); + } + delete this[$extra].failingNode; + } + + while (true) { + const gen = this[$extra].generator.next(); + if (gen.done) { + break; + } + const res = gen.value; + if (!res.success) { + return false; + } + if (res.html) { + this[$addHTML](res.html, res.bbox); + } + } + + this[$extra].generator = null; + + return true; } [$setSetAttributes](attributes) { @@ -640,13 +696,13 @@ class XmlObject extends XFAObject { [$toHTML]() { if (this[$nodeName] === "#text") { - return { + return HTMLResult.success({ name: "#text", value: this[$content], - }; + }); } - return null; + return HTMLResult.EMPTY; } [$getChildren](name = null) { @@ -710,11 +766,27 @@ class XmlObject extends XFAObject { [$isDataValue]() { if (this[_dataValue] === null) { - return this[_children].length === 0; + return ( + this[_children].length === 0 || + this[_children][0][$namespaceId] === NamespaceIds.xhtml.id + ); } return this[_dataValue]; } + [$getDataValue]() { + if (this[_dataValue] === null) { + if (this[_children].length === 0) { + return this[$content].trim(); + } + if (this[_children][0][$namespaceId] === NamespaceIds.xhtml.id) { + return this[_children][0][$text]().trim(); + } + return null; + } + return this[$content].trim(); + } + [$dump]() { const dumped = Object.create(null); if (this[$content]) { @@ -811,7 +883,9 @@ class Option10 extends IntegerObject { export { $acceptWhitespace, + $addHTML, $appendChild, + $break, $childrenToHTML, $clean, $cleanup, @@ -822,16 +896,21 @@ export { $dump, $extra, $finalize, + $flushHTML, $getAttributeIt, + $getAvailableSpace, $getChildren, $getChildrenByClass, $getChildrenByName, $getChildrenByNameIt, + $getDataValue, + $getNextPage, $getParent, $getRealChildrenByNameIt, $global, $hasItem, $hasSettableValue, + $ids, $indexOf, $insertAt, $isDataValue, @@ -845,6 +924,7 @@ export { $onText, $removeChild, $resolvePrototypes, + $searchNode, $setId, $setSetAttributes, $setValue, diff --git a/src/core/xfa/xhtml.js b/src/core/xfa/xhtml.js index 3dfdfa997..efa1b8ecd 100644 --- a/src/core/xfa/xhtml.js +++ b/src/core/xfa/xhtml.js @@ -17,6 +17,7 @@ import { $acceptWhitespace, $childrenToHTML, $content, + $extra, $nodeName, $onText, $text, @@ -24,8 +25,8 @@ import { XmlObject, } from "./xfa_object.js"; import { $buildXFAObject, NamespaceIds } from "./namespaces.js"; -import { getMeasurement } from "./utils.js"; -import { measureToString } from "./html_utils.js"; +import { fixTextIndent, getFonts, measureToString } from "./html_utils.js"; +import { getMeasurement, HTMLResult } from "./utils.js"; const XHTML_NS_ID = NamespaceIds.xhtml.id; @@ -79,14 +80,16 @@ const StyleMapping = new Map([ ], ["xfa-spacerun", ""], ["xfa-tab-stops", ""], - ["font-size", value => measureToString(getMeasurement(value))], + ["font-size", value => measureToString(1 * getMeasurement(value))], ["letter-spacing", value => measureToString(getMeasurement(value))], - ["line-height", value => measureToString(getMeasurement(value))], + ["line-height", value => measureToString(0.99 * getMeasurement(value))], ["margin", value => measureToString(getMeasurement(value))], ["margin-bottom", value => measureToString(getMeasurement(value))], ["margin-left", value => measureToString(getMeasurement(value))], ["margin-right", value => measureToString(getMeasurement(value))], ["margin-top", value => measureToString(getMeasurement(value))], + ["text-indent", value => measureToString(getMeasurement(value))], + ["font-family", value => getFonts(value)], ]); const spacesRegExp = /\s+/g; @@ -121,6 +124,8 @@ function mapStyle(styleStr) { newValue; } } + + fixTextIndent(style); return style; } @@ -162,16 +167,27 @@ class XhtmlObject extends XmlObject { } } - [$toHTML]() { - return { + [$toHTML](availableSpace) { + const children = []; + this[$extra] = { + children, + }; + + this[$childrenToHTML]({}); + + if (children.length === 0 && !this[$content]) { + return HTMLResult.EMPTY; + } + + return HTMLResult.success({ name: this[$nodeName], attributes: { href: this.href, style: mapStyle(this.style), }, - children: this[$childrenToHTML]({}), + children, value: this[$content] || "", - }; + }); } } @@ -193,10 +209,15 @@ class Body extends XhtmlObject { super(attributes, "body"); } - [$toHTML]() { - const html = super[$toHTML](); + [$toHTML](availableSpace) { + const res = super[$toHTML](availableSpace); + const { html } = res; + if (!html) { + return HTMLResult.EMPTY; + } + html.name = "div"; html.attributes.class = "xfaRich"; - return html; + return res; } } @@ -209,10 +230,10 @@ class Br extends XhtmlObject { return "\n"; } - [$toHTML]() { - return { + [$toHTML](availableSpace) { + return HTMLResult.success({ name: "br", - }; + }); } } @@ -221,34 +242,39 @@ class Html extends XhtmlObject { super(attributes, "html"); } - [$toHTML]() { - const children = this[$childrenToHTML]({}); + [$toHTML](availableSpace) { + const children = []; + this[$extra] = { + children, + }; + + this[$childrenToHTML]({}); if (children.length === 0) { - return { + return HTMLResult.success({ name: "div", attributes: { class: "xfaRich", style: {}, }, value: this[$content] || "", - }; + }); } if (children.length === 1) { const child = children[0]; if (child.attributes && child.attributes.class === "xfaRich") { - return child; + return HTMLResult.success(child); } } - return { + return HTMLResult.success({ name: "div", attributes: { class: "xfaRich", style: {}, }, children, - }; + }); } } @@ -274,6 +300,10 @@ class P extends XhtmlObject { constructor(attributes) { super(attributes, "p"); } + + [$text]() { + return super[$text]() + "\n"; + } } class Span extends XhtmlObject { diff --git a/src/display/xfa_layer.js b/src/display/xfa_layer.js index 7bd7d3620..5949d5661 100644 --- a/src/display/xfa_layer.js +++ b/src/display/xfa_layer.js @@ -21,7 +21,11 @@ class XfaLayer { } if (key !== "style") { - html.setAttribute(key, value); + if (key === "textContent") { + html.textContent = value; + } else { + html.setAttribute(key, value); + } } else { Object.assign(html.style, value); } diff --git a/test/unit/xfa_parser_spec.js b/test/unit/xfa_parser_spec.js index a83f661e5..603933d7d 100644 --- a/test/unit/xfa_parser_spec.js +++ b/test/unit/xfa_parser_spec.js @@ -332,7 +332,7 @@ describe("XFAParser", function () { [ " The first line of this paragraph is indented a half-inch.\n", " Successive lines are not indented.\n", - " This is the last line of the paragraph.\n ", + " This is the last line of the paragraph.\n \n", ].join("") ); }); diff --git a/test/unit/xfa_tohtml_spec.js b/test/unit/xfa_tohtml_spec.js index ee408a064..7176fe0df 100644 --- a/test/unit/xfa_tohtml_spec.js +++ b/test/unit/xfa_tohtml_spec.js @@ -28,7 +28,7 @@ describe("XFAFactory", function () { - + @@ -43,6 +43,7 @@ describe("XFAFactory", function () { + @@ -73,18 +74,23 @@ describe("XFAFactory", function () { position: "absolute", }); - const draw = page1.children[1]; + const wrapper = page1.children[1]; + const draw = wrapper.children[0]; + + expect(wrapper.attributes.class).toEqual("xfaWrapper"); + expect(wrapper.attributes.style).toEqual({ + left: "2px", + position: "absolute", + top: "1px", + }); + expect(draw.attributes.class).toEqual("xfaDraw xfaFont"); expect(draw.attributes.style).toEqual({ color: "#0c1722", - fontFamily: "Arial", - fontSize: "7px", + fontFamily: "FooBar", + fontSize: "6.93px", height: "22px", - left: "2px", padding: "1px 4px 2px 3px", - position: "absolute", - textAlign: "left", - top: "1px", transform: "rotate(-90deg)", transformOrigin: "top left", verticalAlign: "2px", @@ -93,7 +99,7 @@ describe("XFAFactory", function () { // draw element must be on each page. expect(draw.attributes.style).toEqual( - factory.getPage(1).children[1].attributes.style + factory.getPage(1).children[1].children[0].attributes.style ); }); }); diff --git a/web/xfa_layer_builder.css b/web/xfa_layer_builder.css index ab1deb375..a5e01bdf7 100644 --- a/web/xfa_layer_builder.css +++ b/web/xfa_layer_builder.css @@ -24,6 +24,8 @@ .xfaLayer * { color: inherit; font: inherit; + font-style: inherit; + font-weight: inherit; font-kerning: inherit; letter-spacing: inherit; text-align: inherit; @@ -33,6 +35,14 @@ background: transparent; } +.xfaLayer a { + color: blue; +} + +.xfaRich li { + margin-left: 3em; +} + .xfaFont { color: black; font-weight: normal; @@ -58,6 +68,7 @@ .xfaRich { z-index: 300; + line-height: 1.2; } .xfaSubform { @@ -76,23 +87,52 @@ flex: 1 1 auto; } +.xfaBorderDiv { + background: transparent; + position: absolute; + pointer-events: none; +} + +.xfaWrapper { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: auto; + height: auto; +} + +.xfaContentArea { + overflow: hidden; +} + +.xfaTextfield, +.xfaSelect { + background-color: rgba(0, 54, 255, 0.13); +} + +.xfaTextfield:focus, +.xfaSelect:focus { + background-color: transparent; +} + .xfaTextfield, .xfaSelect { width: 100%; height: 100%; - flex: 1 1 auto; + flex: 100 1 0; border: none; resize: none; } -.xfaLabel > input[type="checkbox"] { +.xfaLabel > input[type="radio"] { /* Use this trick to make the checkbox invisible but but still focusable. */ position: absolute; left: -99999px; } -.xfaLabel > input[type="checkbox"]:focus + .xfaCheckboxMark { +.xfaLabel > input[type="radio"]:focus + .xfaCheckboxMark { box-shadow: 0 0 5px rgba(0, 0, 0, 0.7); } @@ -133,19 +173,48 @@ white-space: pre-wrap; } -.xfaImage, -.xfaRich { +.xfaImage { width: 100%; height: 100%; } -.xfaLrTb, -.xfaRlTb, -.xfaTb, +.xfaRich { + width: 100%; + height: auto; +} + .xfaPosition { display: block; } +.xfaLrTb, +.xfaRlTb, +.xfaTb { + display: flex; + flex-direction: column; + align-items: stretch; +} + +.xfaLr, +.xfaRl, +.xfaTb > div { + flex: 1 1 auto; +} + +.xfaTb > div { + justify-content: left; +} + +.xfaLr > div { + display: inline; + float: left; +} + +.xfaRl > div { + display: inline; + float: right; +} + .xfaPosition { position: relative; }