diff --git a/src/core/document.js b/src/core/document.js index 439256d83..60613fc04 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -15,6 +15,7 @@ import { assert, + bytesToString, FormatError, info, InvalidPDFException, @@ -28,6 +29,7 @@ import { shadow, stringToBytes, stringToPDFString, + stringToUTF8String, unreachable, Util, warn, @@ -56,6 +58,7 @@ import { calculateMD5 } from "./crypto.js"; import { Linearization } from "./parser.js"; import { OperatorList } from "./operator_list.js"; import { PartialEvaluator } from "./evaluator.js"; +import { XFAFactory } from "./xfa/factory.js"; const DEFAULT_USER_UNIT = 1.0; const LETTER_SIZE_MEDIABOX = [0, 0, 612, 792]; @@ -79,6 +82,7 @@ class Page { builtInCMapCache, globalImageCache, nonBlendModesSet, + xfaFactory, }) { this.pdfManager = pdfManager; this.pageIndex = pageIndex; @@ -91,6 +95,7 @@ class Page { this.nonBlendModesSet = nonBlendModesSet; this.evaluatorOptions = pdfManager.evaluatorOptions; this.resourcesPromise = null; + this.xfaFactory = xfaFactory; const idCounters = { obj: 0, @@ -137,6 +142,11 @@ class Page { } _getBoundingBox(name) { + if (this.xfaData) { + const { width, height } = this.xfaData.attributes.style; + return [0, 0, parseInt(width), parseInt(height)]; + } + const box = this._getInheritableProperty(name, /* getArray = */ true); if (Array.isArray(box) && box.length === 4) { @@ -231,6 +241,13 @@ class Page { return stream; } + get xfaData() { + if (this.xfaFactory) { + return shadow(this, "xfaData", this.xfaFactory.getPage(this.pageIndex)); + } + return shadow(this, "xfaData", null); + } + save(handler, task, annotationStorage) { const partialEvaluator = new PartialEvaluator({ xref: this.xref, @@ -695,6 +712,9 @@ class PDFDocument { } get numPages() { + if (this.xfaFactory) { + return shadow(this, "numPages", this.xfaFactory.numberPages); + } const linearization = this.linearization; const num = linearization ? linearization.numPages : this.catalog.numPages; return shadow(this, "numPages", num); @@ -732,6 +752,80 @@ class PDFDocument { }); } + get xfaData() { + const acroForm = this.catalog.acroForm; + if (!acroForm) { + return null; + } + + const xfa = acroForm.get("XFA"); + const entries = { + "xdp:xdp": "", + template: "", + datasets: "", + config: "", + connectionSet: "", + localeSet: "", + stylesheet: "", + "/xdp:xdp": "", + }; + if (isStream(xfa) && !xfa.isEmpty) { + try { + entries["xdp:xdp"] = stringToUTF8String(bytesToString(xfa.getBytes())); + return entries; + } catch (_) { + warn("XFA - Invalid utf-8 string."); + return null; + } + } + + if (!Array.isArray(xfa) || xfa.length === 0) { + return null; + } + + for (let i = 0, ii = xfa.length; i < ii; i += 2) { + let name; + if (i === 0) { + name = "xdp:xdp"; + } else if (i === ii - 2) { + name = "/xdp:xdp"; + } else { + name = xfa[i]; + } + + if (!entries.hasOwnProperty(name)) { + continue; + } + const data = this.xref.fetchIfRef(xfa[i + 1]); + if (!isStream(data) || data.isEmpty) { + continue; + } + try { + entries[name] = stringToUTF8String(bytesToString(data.getBytes())); + } catch (_) { + warn("XFA - Invalid utf-8 string."); + return null; + } + } + return entries; + } + + get xfaFactory() { + if ( + this.pdfManager.enableXfa && + this.formInfo.hasXfa && + !this.formInfo.hasAcroForm + ) { + const data = this.xfaData; + return shadow(this, "xfaFactory", data ? new XFAFactory(data) : null); + } + return shadow(this, "xfaFaxtory", null); + } + + get isPureXfa() { + return this.xfaFactory !== null; + } + get formInfo() { const formInfo = { hasFields: false, hasAcroForm: false, hasXfa: false }; const acroForm = this.catalog.acroForm; @@ -918,6 +1012,24 @@ class PDFDocument { } const { catalog, linearization } = this; + if (this.xfaFactory) { + return Promise.resolve( + new Page({ + pdfManager: this.pdfManager, + xref: this.xref, + pageIndex, + pageDict: Dict.empty, + ref: null, + globalIdFactory: this._globalIdFactory, + fontCache: catalog.fontCache, + builtInCMapCache: catalog.builtInCMapCache, + globalImageCache: catalog.globalImageCache, + nonBlendModesSet: catalog.nonBlendModesSet, + xfaFactory: this.xfaFactory, + }) + ); + } + const promise = linearization && linearization.pageFirst === pageIndex ? this._getLinearizationPage(pageIndex) @@ -935,6 +1047,7 @@ class PDFDocument { builtInCMapCache: catalog.builtInCMapCache, globalImageCache: catalog.globalImageCache, nonBlendModesSet: catalog.nonBlendModesSet, + xfaFactory: null, }); })); } diff --git a/src/core/pdf_manager.js b/src/core/pdf_manager.js index 1cdcc03fa..de5d5abdd 100644 --- a/src/core/pdf_manager.js +++ b/src/core/pdf_manager.js @@ -106,13 +106,14 @@ class BasePdfManager { } class LocalPdfManager extends BasePdfManager { - constructor(docId, data, password, evaluatorOptions, docBaseUrl) { + constructor(docId, data, password, evaluatorOptions, enableXfa, docBaseUrl) { super(); this._docId = docId; this._password = password; this._docBaseUrl = docBaseUrl; this.evaluatorOptions = evaluatorOptions; + this.enableXfa = enableXfa; const stream = new Stream(data); this.pdfDocument = new PDFDocument(this, stream); @@ -141,7 +142,14 @@ class LocalPdfManager extends BasePdfManager { } class NetworkPdfManager extends BasePdfManager { - constructor(docId, pdfNetworkStream, args, evaluatorOptions, docBaseUrl) { + constructor( + docId, + pdfNetworkStream, + args, + evaluatorOptions, + enableXfa, + docBaseUrl + ) { super(); this._docId = docId; @@ -149,6 +157,7 @@ class NetworkPdfManager extends BasePdfManager { this._docBaseUrl = docBaseUrl; this.msgHandler = args.msgHandler; this.evaluatorOptions = evaluatorOptions; + this.enableXfa = enableXfa; this.streamManager = new ChunkedStreamManager(pdfNetworkStream, { msgHandler: args.msgHandler, diff --git a/src/core/worker.js b/src/core/worker.js index 25950cf73..8f2a23afd 100644 --- a/src/core/worker.js +++ b/src/core/worker.js @@ -188,14 +188,15 @@ class WorkerMessageHandler { await pdfManager.ensureDoc("checkFirstPage"); } - const [numPages, fingerprint] = await Promise.all([ + const [numPages, fingerprint, isPureXfa] = await Promise.all([ pdfManager.ensureDoc("numPages"), pdfManager.ensureDoc("fingerprint"), + pdfManager.ensureDoc("isPureXfa"), ]); - return { numPages, fingerprint }; + return { numPages, fingerprint, isPureXfa }; } - function getPdfManager(data, evaluatorOptions) { + function getPdfManager(data, evaluatorOptions, enableXfa) { var pdfManagerCapability = createPromiseCapability(); let newPdfManager; @@ -207,6 +208,7 @@ class WorkerMessageHandler { source.data, source.password, evaluatorOptions, + enableXfa, docBaseUrl ); pdfManagerCapability.resolve(newPdfManager); @@ -246,6 +248,7 @@ class WorkerMessageHandler { rangeChunkSize: source.rangeChunkSize, }, evaluatorOptions, + enableXfa, docBaseUrl ); // There may be a chance that `newPdfManager` is not initialized for @@ -277,6 +280,7 @@ class WorkerMessageHandler { pdfFile, source.password, evaluatorOptions, + enableXfa, docBaseUrl ); pdfManagerCapability.resolve(newPdfManager); @@ -399,7 +403,7 @@ class WorkerMessageHandler { fontExtraProperties: data.fontExtraProperties, }; - getPdfManager(data, evaluatorOptions) + getPdfManager(data, evaluatorOptions, data.enableXfa) .then(function (newPdfManager) { if (terminated) { // We were in a process of setting up the manager, but it got @@ -487,6 +491,16 @@ class WorkerMessageHandler { }); }); + handler.on("GetPageXfa", function wphSetupGetXfa({ pageIndex }) { + return pdfManager.getPage(pageIndex).then(function (page) { + return pdfManager.ensure(page, "xfaData"); + }); + }); + + handler.on("GetIsPureXfa", function wphSetupGetIsPureXfa(data) { + return pdfManager.ensureDoc("isPureXfa"); + }); + handler.on("GetOutline", function wphSetupGetOutline(data) { return pdfManager.ensureCatalog("documentOutline"); }); diff --git a/src/core/xfa/factory.js b/src/core/xfa/factory.js index e78bdf044..27e7c19c4 100644 --- a/src/core/xfa/factory.js +++ b/src/core/xfa/factory.js @@ -13,13 +13,27 @@ * limitations under the License. */ +import { $toHTML } from "./xfa_object.js"; import { Binder } from "./bind.js"; import { XFAParser } from "./parser.js"; class XFAFactory { constructor(data) { - this.root = new XFAParser().parse(XFAFactory._createDocument(data)); - this.form = new Binder(this.root).bind(); + try { + this.root = new XFAParser().parse(XFAFactory._createDocument(data)); + this.form = new Binder(this.root).bind(); + this.pages = this.form[$toHTML](); + } catch (e) { + console.log(e); + } + } + + getPage(pageIndex) { + return this.pages.children[pageIndex]; + } + + get numberPages() { + return this.pages.children.length; } static _createDocument(data) { diff --git a/src/core/xfa/html_utils.js b/src/core/xfa/html_utils.js new file mode 100644 index 000000000..83aaa9a35 --- /dev/null +++ b/src/core/xfa/html_utils.js @@ -0,0 +1,69 @@ +/* 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. + */ + +const converters = { + pt: x => x, + cm: x => Math.round((x / 2.54) * 72), + mm: x => Math.round((x / (10 * 2.54)) * 72), + in: x => Math.round(x * 72), +}; + +function measureToString(m) { + const conv = converters[m.unit]; + if (conv) { + return `${conv(m.value)}px`; + } + return `${m.value}${m.unit}`; +} + +function setWidthHeight(node, style) { + if (node.w) { + style.width = measureToString(node.w); + } else { + if (node.maxW && node.maxW.value > 0) { + style.maxWidth = measureToString(node.maxW); + } + if (node.minW && node.minW.value > 0) { + style.minWidth = measureToString(node.minW); + } + } + + if (node.h) { + style.height = measureToString(node.h); + } else { + if (node.maxH && node.maxH.value > 0) { + style.maxHeight = measureToString(node.maxH); + } + if (node.minH && node.minH.value > 0) { + style.minHeight = measureToString(node.minH); + } + } +} + +function setPosition(node, style) { + style.transform = ""; + if (node.rotate) { + style.transform = `rotate(-${node.rotate}deg) `; + style.transformOrigin = "top left"; + } + + if (node.x !== "" || node.y !== "") { + style.position = "absolute"; + style.left = node.x ? measureToString(node.x) : "0pt"; + style.top = node.y ? measureToString(node.y) : "0pt"; + } +} + +export { measureToString, setPosition, setWidthHeight }; diff --git a/src/core/xfa/template.js b/src/core/xfa/template.js index 0c09abaee..78f66c0e4 100644 --- a/src/core/xfa/template.js +++ b/src/core/xfa/template.js @@ -15,8 +15,11 @@ import { $appendChild, + $childrenToHTML, $content, + $extra, $finalize, + $getParent, $hasItem, $hasSettableValue, $isTransparent, @@ -26,6 +29,8 @@ import { $removeChild, $setSetAttributes, $setValue, + $toHTML, + $uid, ContentObject, Option01, OptionObject, @@ -45,6 +50,7 @@ import { getRelevant, getStringOption, } from "./utils.js"; +import { measureToString, setPosition, setWidthHeight } from "./html_utils.js"; import { warn } from "../../shared/util.js"; const TEMPLATE_NS_ID = NamespaceIds.template.id; @@ -656,6 +662,29 @@ class ContentArea extends XFAObject { this.desc = null; this.extras = null; } + + [$toHTML]() { + // TODO: incomplete. + const left = measureToString(this.x); + const top = measureToString(this.y); + + const style = { + position: "absolute", + left, + top, + width: measureToString(this.w), + height: measureToString(this.h), + }; + return { + name: "div", + children: [], + attributes: { + style, + className: "xfa-contentarea", + id: this[$uid], + }, + }; + } } class Corner extends XFAObject { @@ -1946,6 +1975,41 @@ class PageArea extends XFAObject { this.field = new XFAObjectArray(); this.subform = new XFAObjectArray(); } + + [$toHTML]() { + // TODO: incomplete. + if (this.contentArea.children.length === 0) { + return null; + } + + const children = this[$childrenToHTML]({ + filter: new Set(["area", "draw", "field", "subform", "contentArea"]), + include: true, + }); + + // TODO: handle the case where there are several content areas. + const contentArea = children.find( + node => node.attributes.className === "xfa-contentarea" + ); + + const style = Object.create(null); + if (this.medium && this.medium.short.value && this.medium.long.value) { + style.width = measureToString(this.medium.short); + style.height = measureToString(this.medium.long); + } else { + // TODO: compute it from contentAreas + } + + return { + name: "div", + children, + attributes: { + id: this[$uid], + style, + }, + contentArea, + }; + } } class PageSet extends XFAObject { @@ -1970,6 +2034,20 @@ class PageSet extends XFAObject { this.pageArea = new XFAObjectArray(); this.pageSet = new XFAObjectArray(); } + + [$toHTML]() { + // TODO: incomplete. + return { + name: "div", + children: this[$childrenToHTML]({ + filter: new Set(["pageArea", "pageSet"]), + include: true, + }), + attributes: { + id: this[$uid], + }, + }; + } } class Para extends XFAObject { @@ -2465,6 +2543,64 @@ class Subform extends XFAObject { this.subform = new XFAObjectArray(); this.subformSet = new XFAObjectArray(); } + + [$toHTML]() { + // TODO: incomplete. + this[$extra] = Object.create(null); + + 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 = Object.create(null); + setWidthHeight(this, style); + setPosition(this, style); + + const attributes = { + style, + id: this[$uid], + }; + + if (this.name) { + attributes["xfa-name"] = this.name; + } + + const children = this[$childrenToHTML]({ + // TODO: exObject & exclGroup + filter: new Set(["area", "draw", "field", "subform", "subformSet"]), + include: true, + }); + + const html = { + name: "div", + attributes, + children, + }; + + if (page) { + page.contentArea.children.push(html); + delete page.contentArea; + return page; + } + + return html; + } } class SubformSet extends XFAObject { @@ -2580,8 +2716,32 @@ class Template extends XFAObject { "interactiveForms", ]); this.extras = null; + + // Spec is unclear: + // A container element that describes a single subform capable of + // enclosing other containers. + // Can we have more than one subform ? this.subform = new XFAObjectArray(); } + + [$finalize]() { + if (this.subform.children.length === 0) { + warn("XFA - No subforms in template node."); + } + if (this.subform.children.length >= 2) { + warn("XFA - Several subforms in template node: please file a bug."); + } + } + + [$toHTML]() { + if (this.subform.children.length > 0) { + return this.subform.children[0][$toHTML](); + } + return { + name: "div", + children: [], + }; + } } class Text extends ContentObject { diff --git a/src/core/xfa/utils.js b/src/core/xfa/utils.js index 2ea243698..67e137a87 100644 --- a/src/core/xfa/utils.js +++ b/src/core/xfa/utils.js @@ -74,7 +74,7 @@ function getMeasurement(str, def = "0") { } return { value: sign === "-" ? -value : value, - unit, + unit: unit || "pt", }; } diff --git a/src/core/xfa/xfa_object.js b/src/core/xfa/xfa_object.js index 37a0fd7ca..611af1df0 100644 --- a/src/core/xfa/xfa_object.js +++ b/src/core/xfa/xfa_object.js @@ -20,6 +20,7 @@ import { NamespaceIds } from "./namespaces.js"; // We use these symbols to avoid name conflict between tags // and properties/methods names. const $appendChild = Symbol(); +const $childrenToHTML = Symbol(); const $clean = Symbol(); const $cleanup = Symbol(); const $clone = Symbol(); @@ -27,6 +28,7 @@ const $consumed = Symbol(); const $content = Symbol("content"); const $data = Symbol("data"); const $dump = Symbol(); +const $extra = Symbol("extra"); const $finalize = Symbol(); const $getAttributeIt = Symbol(); const $getChildrenByClass = Symbol(); @@ -56,6 +58,8 @@ const $setId = Symbol(); const $setSetAttributes = Symbol(); const $setValue = Symbol(); const $text = Symbol(); +const $toHTML = Symbol(); +const $uid = Symbol("uid"); const _applyPrototype = Symbol(); const _attributes = Symbol(); @@ -73,6 +77,8 @@ const _parent = Symbol("parent"); const _setAttributes = Symbol(); const _validator = Symbol(); +let uid = 0; + class XFAObject { constructor(nsId, name, hasChildren = false) { this[$namespaceId] = nsId; @@ -80,6 +86,7 @@ class XFAObject { this[_hasChildren] = hasChildren; this[_parent] = null; this[_children] = []; + this[$uid] = `${name}${uid++}`; } [$onChild](child) { @@ -252,6 +259,23 @@ class XFAObject { return dumped; } + [$toHTML]() { + return null; + } + + [$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); + } + } + }); + return res; + } + [$setSetAttributes](attributes) { if (attributes.use || attributes.id) { // Just keep set attributes because this node uses a proto or is a proto. @@ -604,6 +628,17 @@ class XmlObject extends XFAObject { } } + [$toHTML]() { + if (this[$nodeName] === "#text") { + return { + name: "#text", + value: this[$content], + }; + } + + return null; + } + [$getChildren](name = null) { if (!name) { return this[_children]; @@ -766,6 +801,7 @@ class Option10 extends IntegerObject { export { $appendChild, + $childrenToHTML, $clean, $cleanup, $clone, @@ -773,6 +809,7 @@ export { $content, $data, $dump, + $extra, $finalize, $getAttributeIt, $getChildren, @@ -801,6 +838,8 @@ export { $setSetAttributes, $setValue, $text, + $toHTML, + $uid, ContentObject, IntegerObject, Option01, diff --git a/src/display/api.js b/src/display/api.js index 690193ade..fdc1404a2 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -162,6 +162,8 @@ function setPDFNetworkStreamFactory(pdfNetworkStreamFactory) { * parsed font data from the worker-thread. This may be useful for debugging * purposes (and backwards compatibility), but note that it will lead to * increased memory usage. The default value is `false`. + * @property {boolean} [enableXfa] - Render Xfa forms if any. + * The default value is `false`. * @property {HTMLDocument} [ownerDocument] - Specify an explicit document * context to create elements with and to load resources, such as fonts, * into. Defaults to the current document. @@ -284,6 +286,7 @@ function getDocument(src) { params.ignoreErrors = params.stopAtErrors !== true; params.fontExtraProperties = params.fontExtraProperties === true; params.pdfBug = params.pdfBug === true; + params.enableXfa = params.enableXfa === true; if (!Number.isInteger(params.maxImageSize)) { params.maxImageSize = -1; @@ -438,6 +441,7 @@ function _fetchDocument(worker, source, pdfDataRangeTransport, docId) { ignoreErrors: source.ignoreErrors, isEvalSupported: source.isEvalSupported, fontExtraProperties: source.fontExtraProperties, + enableXfa: source.enableXfa, }) .then(function (workerId) { if (worker.destroyed) { @@ -674,6 +678,13 @@ class PDFDocumentProxy { return this._pdfInfo.fingerprint; } + /** + * @type {boolean} True if only XFA form. + */ + get isPureXfa() { + return this._pdfInfo.isPureXfa; + } + /** * @param {number} pageNumber - The page number to get. The first page is 1. * @returns {Promise} A promise that is resolved with @@ -1165,6 +1176,16 @@ class PDFPageProxy { )); } + /** + * @returns {Promise} A promise that is resolved with + * an {Object} with a fake DOM object (a tree structure where elements + * are {Object} with a name, attributes (class, style, ...), value and + * children, very similar to a HTML DOM tree), or `null` if no XFA exists. + */ + getXfa() { + return (this._xfaPromise ||= this._transport.getPageXfa(this._pageIndex)); + } + /** * Begins the process of rendering a page to the desired context. * @@ -2709,6 +2730,12 @@ class WorkerTransport { }); } + getPageXfa(pageIndex) { + return this.messageHandler.sendWithPromise("GetPageXfa", { + pageIndex, + }); + } + getOutline() { return this.messageHandler.sendWithPromise("GetOutline", null); } diff --git a/src/display/xfa_layer.js b/src/display/xfa_layer.js new file mode 100644 index 000000000..cfedad885 --- /dev/null +++ b/src/display/xfa_layer.js @@ -0,0 +1,89 @@ +/* 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. + */ + +class XfaLayer { + static setAttributes(html, attrs) { + for (const [key, value] of Object.entries(attrs)) { + if (value === null || value === undefined) { + continue; + } + + if (key !== "style") { + html.setAttribute(key, value); + } else { + Object.assign(html.style, value); + } + } + } + + static render(parameters) { + const root = parameters.xfa; + const rootHtml = document.createElement(root.name); + if (root.attributes) { + XfaLayer.setAttributes(rootHtml, root.attributes); + } + const stack = [[root, -1, rootHtml]]; + + parameters.div.appendChild(rootHtml); + const coeffs = parameters.viewport.transform.join(","); + parameters.div.style.transform = `matrix(${coeffs})`; + + while (stack.length > 0) { + const [parent, i, html] = stack[stack.length - 1]; + if (i + 1 === parent.children.length) { + stack.pop(); + continue; + } + + const child = parent.children[++stack[stack.length - 1][1]]; + if (child === null) { + continue; + } + + const { name } = child; + if (name === "#text") { + html.appendChild(document.createTextNode(child.value)); + continue; + } + + const childHtml = document.createElement(name); + html.appendChild(childHtml); + if (child.attributes) { + XfaLayer.setAttributes(childHtml, child.attributes); + } + + if (child.children && child.children.length > 0) { + stack.push([child, -1, childHtml]); + } else if (child.value) { + childHtml.appendChild(document.createTextNode(child.value)); + } + } + } + + /** + * Update the xfa layer. + * + * @public + * @param {XfaLayerParameters} parameters + * @memberof XfaLayer + */ + static update(parameters) { + const transform = `matrix(${parameters.viewport.transform.join(",")})`; + parameters.div.style.transform = transform; + parameters.div.hidden = false; + } +} + +export { XfaLayer }; diff --git a/src/pdf.js b/src/pdf.js index af6bfc96d..0e42eaa61 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -56,6 +56,7 @@ import { apiCompatibilityParams } from "./display/api_compatibility.js"; import { GlobalWorkerOptions } from "./display/worker_options.js"; import { renderTextLayer } from "./display/text_layer.js"; import { SVGGraphics } from "./display/svg.js"; +import { XfaLayer } from "./display/xfa_layer.js"; /* eslint-disable-next-line no-unused-vars */ const pdfjsVersion = @@ -167,4 +168,6 @@ export { renderTextLayer, // From "./display/svg.js": SVGGraphics, + // From "./display/xfa_layer.js": + XfaLayer, }; diff --git a/test/unit/xfa_parser_spec.js b/test/unit/xfa_parser_spec.js index e84574288..6bfbb78c2 100644 --- a/test/unit/xfa_parser_spec.js +++ b/test/unit/xfa_parser_spec.js @@ -81,9 +81,9 @@ describe("XFAParser", function () { }; const mediumAttributes = { id: "", - long: { value: 0, unit: "" }, + long: { value: 0, unit: "pt" }, orientation: "portrait", - short: { value: 0, unit: "" }, + short: { value: 0, unit: "pt" }, stock: "", trayIn: "auto", trayOut: "auto", @@ -116,17 +116,17 @@ describe("XFAParser", function () { allowMacro: 0, anchorType: "topLeft", colSpan: 1, - columnWidths: [{ value: 0, unit: "" }], - h: { value: 0, unit: "" }, + columnWidths: [{ value: 0, unit: "pt" }], + h: { value: 0, unit: "pt" }, hAlign: "left", id: "", layout: "position", locale: "", - maxH: { value: 0, unit: "" }, - maxW: { value: 0, unit: "" }, + maxH: { value: 0, unit: "pt" }, + maxW: { value: 0, unit: "pt" }, mergeMode: "consumeData", - minH: { value: 0, unit: "" }, - minW: { value: 0, unit: "" }, + minH: { value: 0, unit: "pt" }, + minW: { value: 0, unit: "pt" }, name: "", presence: "visible", relevant: [], @@ -134,14 +134,14 @@ describe("XFAParser", function () { scope: "name", use: "", usehref: "", - w: { value: 0, unit: "" }, - x: { value: 0, unit: "" }, - y: { value: 0, unit: "" }, + w: { value: 0, unit: "pt" }, + x: { value: 0, unit: "pt" }, + y: { value: 0, unit: "pt" }, proto: { area: { ...attributes, colSpan: 1, - x: { value: 0, unit: "" }, + x: { value: 0, unit: "pt" }, y: { value: -3.14, unit: "in" }, relevant: [ { excluded: true, viewname: "foo" }, @@ -162,7 +162,7 @@ describe("XFAParser", function () { { ...mediumAttributes, imagingBBox: { - x: { value: 1, unit: "" }, + x: { value: 1, unit: "pt" }, y: { value: 2, unit: "in" }, width: { value: 3.4, unit: "cm" }, height: { value: 5.67, unit: "px" }, @@ -171,10 +171,10 @@ describe("XFAParser", function () { { ...mediumAttributes, imagingBBox: { - x: { value: -1, unit: "" }, - y: { value: -1, unit: "" }, - width: { value: -1, unit: "" }, - height: { value: -1, unit: "" }, + x: { value: -1, unit: "pt" }, + y: { value: -1, unit: "pt" }, + width: { value: -1, unit: "pt" }, + height: { value: -1, unit: "pt" }, }, }, ], diff --git a/web/app.js b/web/app.js index 6bdcaef98..d8a2e432a 100644 --- a/web/app.js +++ b/web/app.js @@ -518,6 +518,7 @@ const PDFViewerApplication = { useOnlyCssZoom: AppOptions.get("useOnlyCssZoom"), maxCanvasPixels: AppOptions.get("maxCanvasPixels"), enableScripting: AppOptions.get("enableScripting"), + enableXfa: AppOptions.get("enableXfa"), }); pdfRenderingQueue.setViewer(this.pdfViewer); pdfLinkService.setViewer(this.pdfViewer); diff --git a/web/app_options.js b/web/app_options.js index f88932034..72ad6d091 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -205,6 +205,11 @@ const defaultOptions = { value: "", kind: OptionKind.API, }, + enableXfa: { + /** @type {boolean} */ + value: false, + kind: OptionKind.API, + }, fontExtraProperties: { /** @type {boolean} */ value: false, diff --git a/web/base_viewer.js b/web/base_viewer.js index c6d5cb557..a57bd50c5 100644 --- a/web/base_viewer.js +++ b/web/base_viewer.js @@ -42,6 +42,7 @@ import { NullL10n } from "./l10n_utils.js"; import { PDFPageView } from "./pdf_page_view.js"; import { SimpleLinkService } from "./pdf_link_service.js"; import { TextLayerBuilder } from "./text_layer_builder.js"; +import { XfaLayerBuilder } from "./xfa_layer_builder.js"; const DEFAULT_CACHE_SIZE = 10; @@ -478,6 +479,7 @@ class BaseViewer { if (!pdfDocument) { return; } + const isPureXfa = pdfDocument.isPureXfa; const pagesCount = pdfDocument.numPages; const firstPagePromise = pdfDocument.getPage(1); // Rendering (potentially) depends on this, hence fetching it immediately. @@ -523,6 +525,7 @@ class BaseViewer { const viewport = firstPdfPage.getViewport({ scale: scale * CSS_UNITS }); const textLayerFactory = this.textLayerMode !== TextLayerMode.DISABLE ? this : null; + const xfaLayerFactory = isPureXfa ? this : null; for (let pageNum = 1; pageNum <= pagesCount; ++pageNum) { const pageView = new PDFPageView({ @@ -536,6 +539,7 @@ class BaseViewer { textLayerFactory, textLayerMode: this.textLayerMode, annotationLayerFactory: this, + xfaLayerFactory, imageResourcesPath: this.imageResourcesPath, renderInteractiveForms: this.renderInteractiveForms, renderer: this.renderer, @@ -1308,6 +1312,18 @@ class BaseViewer { }); } + /** + * @param {HTMLDivElement} pageDiv + * @param {PDFPage} pdfPage + * @returns {XfaLayerBuilder} + */ + createXfaLayerBuilder(pageDiv, pdfPage) { + return new XfaLayerBuilder({ + pageDiv, + pdfPage, + }); + } + /** * @type {boolean} Whether all pages of the PDF document have identical * widths and heights. diff --git a/web/interfaces.js b/web/interfaces.js index 6e3ce2afb..adf9116c2 100644 --- a/web/interfaces.js +++ b/web/interfaces.js @@ -204,6 +204,18 @@ class IPDFAnnotationLayerFactory { ) {} } +/** + * @interface + */ +class IPDFXfaLayerFactory { + /** + * @param {HTMLDivElement} pageDiv + * @param {PDFPage} pdfPage + * @returns {XfaLayerBuilder} + */ + createXfaLayerBuilder(pageDiv, pdfPage) {} +} + /** * @interface */ @@ -243,5 +255,6 @@ export { IPDFHistory, IPDFLinkService, IPDFTextLayerFactory, + IPDFXfaLayerFactory, IRenderableView, }; diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 49dc21d47..c3afeadce 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -48,6 +48,7 @@ import { viewerCompatibilityParams } from "./viewer_compatibility.js"; * behaviour is enabled. The constants from {TextLayerMode} should be used. * The default value is `TextLayerMode.ENABLE`. * @property {IPDFAnnotationLayerFactory} annotationLayerFactory + * @property {IPDFXfaLayerFactory} xfaLayerFactory * @property {string} [imageResourcesPath] - Path for image resources, mainly * for annotation icons. Include trailing slash. * @property {boolean} renderInteractiveForms - Turns on rendering of @@ -102,6 +103,7 @@ class PDFPageView { this.renderingQueue = options.renderingQueue; this.textLayerFactory = options.textLayerFactory; this.annotationLayerFactory = options.annotationLayerFactory; + this.xfaLayerFactory = options.xfaLayerFactory; this.renderer = options.renderer || RendererType.CANVAS; this.enableWebGL = options.enableWebGL || false; this.l10n = options.l10n || NullL10n; @@ -116,6 +118,7 @@ class PDFPageView { this.annotationLayer = null; this.textLayer = null; this.zoomLayer = null; + this.xfaLayer = null; const div = document.createElement("div"); div.className = "page"; @@ -164,6 +167,24 @@ class PDFPageView { } } + /** + * @private + */ + async _renderXfaLayer() { + let error = null; + try { + await this.xfaLayer.render(this.viewport, "display"); + } catch (ex) { + error = ex; + } finally { + this.eventBus.dispatch("xfalayerrendered", { + source: this, + pageNumber: this.id, + error, + }); + } + } + /** * @private */ @@ -197,9 +218,14 @@ class PDFPageView { const currentZoomLayerNode = (keepZoomLayer && this.zoomLayer) || null; const currentAnnotationNode = (keepAnnotations && this.annotationLayer?.div) || null; + const currentXfaLayerNode = this.xfaLayer?.div || null; for (let i = childNodes.length - 1; i >= 0; i--) { const node = childNodes[i]; - if (currentZoomLayerNode === node || currentAnnotationNode === node) { + if ( + currentZoomLayerNode === node || + currentAnnotationNode === node || + currentXfaLayerNode === node + ) { continue; } div.removeChild(node); @@ -393,6 +419,10 @@ class PDFPageView { if (redrawAnnotations && this.annotationLayer) { this._renderAnnotationLayer(); } + + if (this.xfaLayer) { + this._renderXfaLayer(); + } } get width() { @@ -553,6 +583,17 @@ class PDFPageView { } this._renderAnnotationLayer(); } + + if (this.xfaLayerFactory) { + if (!this.xfaLayer) { + this.xfaLayer = this.xfaLayerFactory.createXfaLayerBuilder( + div, + pdfPage + ); + } + this._renderXfaLayer(); + } + div.setAttribute("data-loaded", true); this.eventBus.dispatch("pagerender", { diff --git a/web/pdf_viewer.css b/web/pdf_viewer.css index 15187667d..94ce50f13 100644 --- a/web/pdf_viewer.css +++ b/web/pdf_viewer.css @@ -14,6 +14,7 @@ */ @import url(text_layer_builder.css); @import url(annotation_layer_builder.css); +@import url(xfa_layer_builder.css); .pdfViewer .canvasWrapper { overflow: hidden; diff --git a/web/xfa_layer_builder.css b/web/xfa_layer_builder.css new file mode 100644 index 000000000..b891225f4 --- /dev/null +++ b/web/xfa_layer_builder.css @@ -0,0 +1,22 @@ +*/* 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. + */ + +.xfaLayer { + position: absolute; + top: 0; + left: 0; + z-index: 200; + transform-origin: 0 0; +} diff --git a/web/xfa_layer_builder.js b/web/xfa_layer_builder.js new file mode 100644 index 000000000..b875db21e --- /dev/null +++ b/web/xfa_layer_builder.js @@ -0,0 +1,97 @@ +/* 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 { XfaLayer } from "pdfjs-lib"; + +/** + * @typedef {Object} XfaLayerBuilderOptions + * @property {HTMLDivElement} pageDiv + * @property {PDFPage} pdfPage + */ + +class XfaLayerBuilder { + /** + * @param {XfaLayerBuilderOptions} options + */ + constructor({ pageDiv, pdfPage }) { + this.pageDiv = pageDiv; + this.pdfPage = pdfPage; + + this.div = null; + this._cancelled = false; + } + + /** + * @param {PageViewport} viewport + * @param {string} intent (default value is 'display') + * @returns {Promise} A promise that is resolved when rendering of the + * annotations is complete. + */ + render(viewport, intent = "display") { + return this.pdfPage.getXfa().then(xfa => { + if (this._cancelled) { + return; + } + + const parameters = { + viewport: viewport.clone({ dontFlip: true }), + div: this.div, + xfa, + page: this.pdfPage, + }; + + if (this.div) { + XfaLayer.update(parameters); + } else { + // Create an xfa layer div and render the form + this.div = document.createElement("div"); + this.div.className = "xfaLayer"; + this.pageDiv.appendChild(this.div); + parameters.div = this.div; + + XfaLayer.render(parameters); + } + }); + } + + cancel() { + this._cancelled = true; + } + + hide() { + if (!this.div) { + return; + } + this.div.hidden = true; + } +} + +/** + * @implements IPDFXfaLayerFactory + */ +class DefaultXfaLayerFactory { + /** + * @param {HTMLDivElement} pageDiv + * @param {PDFPage} pdfPage + */ + createXfaLayerBuilder(pageDiv, pdfPage) { + return new XfaLayerBuilder({ + pageDiv, + pdfPage, + }); + } +} + +export { DefaultXfaLayerFactory, XfaLayerBuilder };