1
0
Fork 0
mirror of https://github.com/mozilla/pdf.js.git synced 2025-04-22 16:18:08 +02:00

XFA - Add a layer to display XFA forms (#13069)

- add an option to enable XFA rendering if any;
  - for now, let the canvas layer: it could be useful to implement XFAF forms (embedded pdf in xml stream for the background and xfa form for the foreground);
  - ui elements in template DOM are pretty close to their html counterpart so we generate a fake html DOM from template one:
    - it makes easier to translate template properties to html ones;
    - it makes faster the creation of the html element in the main thread.
This commit is contained in:
calixteman 2021-03-19 10:11:40 +01:00 committed by GitHub
parent a164941351
commit 24e598a895
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 760 additions and 27 deletions

View file

@ -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,
});
}));
}

View file

@ -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,

View file

@ -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");
});

View file

@ -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) {

View file

@ -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 };

View file

@ -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 {

View file

@ -74,7 +74,7 @@ function getMeasurement(str, def = "0") {
}
return {
value: sign === "-" ? -value : value,
unit,
unit: unit || "pt",
};
}

View file

@ -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,

View file

@ -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<PDFPageProxy>} A promise that is resolved with
@ -1165,6 +1176,16 @@ class PDFPageProxy {
));
}
/**
* @returns {Promise<Object | null>} 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);
}

89
src/display/xfa_layer.js Normal file
View file

@ -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 };

View file

@ -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,
};