将 PDFJS 移植并扩展到 NodeJS





5.00/5 (6投票s)
将 PDF.js 移植到 Node.js 并支持交互式表单元素
引言
在我上一篇关于 Blend PDF with HTML5 的文章中,我们讨论了如何使用交互式表单元素扩展 PDF.js 以及如何在 HTML5 中渲染它们。在这篇文章中,我将讨论如何将客户端的 PDF.js 移植到 Node.js,以便我们可以在服务器端解析 PDF,并以轻量级 JSON 数据格式作为输出。
将 PDF 解析移至 Node.js 将简化客户端渲染并使其更加灵活,因为它不需要将整个 PDF.js 库加载到浏览器中,也不需要 JavaScript 类型数组、XHR L2、Canvas 和其他 HTML5 功能作为先决条件。在后续文章中,我将讨论如何使客户端 HTML5 渲染在 PDF.js 目前不起作用的浏览器中也能正常工作,本文将重点介绍将 PDF.js 移植到 Node.js,以及如何定义和构造解析结果数据格式。
当 PDF.js 移植到 Node.js 时,交互式表单元素解析扩展仍然可以进行,无需太多更改,因为我们只是将浏览器的 JavaScript 虚拟机替换为 Node.js 和 Google V8 引擎。在 Node.js 中运行的另一个好处是,我们可以将扩展的 PDF.js 作为命令行实用程序运行,将 PDF 转换为 JSON 文件。当需要动态 PDF 解析时,您也可以在 Web 服务中运行该库。
该项目已在 Github 上开源,模块名为 pdf2json。您也可以通过 NPM 安装 pdf2json 进行尝试。
背景
我的一个项目中有成百上千个已在 PDF 中创建的电子表单,需要一个基于表单的用户界面来收集和展示数据,并且需要作为 Web 应用程序运行。这些 PDF 电子表单文件在数据收集季节可能会经常更新。由于 PDF 已经是标准的电子格式,我们无需在其他工具或流程中重新创建这些表单,我们只需要一个通用的表单处理器,可以直接解析和渲染它们,以便将成百上千个表单与数据处理和其他服务集成在一起。
我创建这个“通用表单处理器”的第一个尝试是一个纯客户端解决方案,它扩展了 PDF.js 以支持表单元素解析和渲染,这在我之前的文章 Blend PDF with HTML5 中已有记录。这是一种非常高效实用的方法,使我们能够及时地将大量 PDF 表单引入我们的 Web 应用程序,并很好地处理界面集成、表单实时更新、可伸缩性和数据服务集成等问题。尽管它在现代浏览器中运行良好,但随着项目的增长,我们发现支持旧版浏览器既繁琐又麻烦,因为客户端渲染器依赖于 HTML5 中的新功能,包括 JavaScript 类型数组、XHR level 2、Web Worker 和 HTML5 Canvas。这促使我提出了一个更广泛的解决方案,可以在我们已有的基础上构建,以无缝且透明的方式支持旧版浏览器。
这个想法是将 PDF 解析移到服务器端。当客户端需要表单模板时,它将发送一个带有表单 ID 的请求到一个 Web 服务。该服务将定位 PDF,进行解析,然后发送一个包含代表 PDF 表单的 JSON payload 的响应。这样,客户端就可以专注于处理响应中的 JSON 数据,而无需在浏览器中解析 PDF 二进制文件,从而能够以跨平台、跨浏览器的方式更好地处理用户体验和通过 Ajax 进行数据服务集成,所有浏览器,无论是具有最新 HTML5 功能的现代浏览器还是 IE7 和 IE8 等旧版浏览器,都可以提供一致的用户体验和交互。
从架构上看,将 PDF 解析移到服务器端遵循“关注点分离”原则。当我们提供表单模板服务时,客户端专注于跨浏览器渲染,而服务器端则专注于如何检索表单定义,而无需担心这些信息是如何呈现的。在客户端和服务器之间,数据合同是 JSON,它以文本格式表示 PDF 表单。从技术上讲,只要 JSON 格式相同,即使表单不是在 PDF 中定义的,对客户端也没有影响,只有解析提供程序需要更新。
此外,将 PDF 解析移到服务器端还有其他好处。对于经常更新的 PDF 表单,我们可以比较或运行解析响应的 diff,因为 JSON 是文本格式,而 PDF 二进制文件难以找出版本之间的差异。对于相对稳定的表单,我们可以通过将 PDF 转换为 JSON 文件来预处理这些表单,然后只需将 JSON 文件部署到 Web 服务器即可,这可以节省服务器端的 CPU 周期,提高可伸缩性,并且不需要更改客户端渲染器。
另一个优点是它可以将用户数据与模板数据(表单模板对所有用户都是相同的)分开。表单模板数据可以从会话中取出,以简化和最小化用户会话,从而提高可伸缩性。一个 PDF 表单的解析输出可以被缓存并跨用户和会话重用。
将 PDF.js 移植到 Node.js 将解决旧版浏览器问题,同时确保上述好处。扩展 PDF.js 将确保我们仍然具有交互式表单元素解析功能,表单内容仍然可以在 PDF 中定义和编辑,而渲染仍然可以使用 HTML5 运行。挑战在于 PDF.js 的依赖项以及如何定义客户端和服务器之间简洁的基于文本的数据合同。
由于 PDF.js 被设计和开发为客户端库,它具有 Node.js 运行时所不具备的依赖项,例如 XHR level 2、Web Worker 和 HTML5 Canvas。在 PDF.js 内部,解析和渲染是交织在一起的。此外,解析输出应该尽可能简洁,以减少带宽使用,同时又应该足以表示渲染表单所需的所有信息。让我们详细讨论如何处理这些问题。
依赖项处理
作为客户端库,PDF.js 依赖于一些新的 HTML5 功能,我们在将其移植到 Node.js 时必须解决所有这些问题,因为 Node.js 和 Google V8 引擎都不支持它们,包括:
- XHR Level 2 - 通过 Ajax 传输二进制数据
- DOMParser - 解析 PDF 中的嵌入式 XML 元数据
- Web Worker - 使解析工作在单独的线程中运行
- Canvas - 在浏览器中绘制线条、填充、颜色、形状和文本
- 其他 - 如 Web 字体、Canvas 图像、DOM 操作等。
将全局变量移到模块中
在没有全局 window
对象的情况下,PDF.js 中的所有全局变量(如 PDFJS 和 globalScope)都需要包装到 node 模块的作用域中。在 core.js
中定义的全局变量被移动到 /pdf.js
。
var PDFJS = {};
var globalScope = {};
整个 PDF.js 被包装在一个 Node.js 模块中,名为 PDFJSClass
,实现在 /pdf.js
中。
////////////////////////////////Start of Node.js Module
var PDFJSClass = (function () {
'use strict';
// private static
var _nextId = 1;
var _name = 'PDFJSClass';
// constructor
var cls = function () {
nodeEvents.EventEmitter.call(this);
// private
var _id = _nextId++;
// public (every instance will have their own copy of these methods, needs to be lightweight)
this.get_id = function() { return _id; };
this.get_name = function() { return _name + _id; };
// public, this instance copies
this.pdfDocument = null;
this.formImage = null;
};
// inherit from event emitter
nodeUtil.inherits(cls, nodeEvents.EventEmitter);
cls.prototype.parsePDFData = function(arrayBuffer) {
var parameters = {password: '', data: arrayBuffer};
this.pdfDocument = null;
this.formImage = null;
var self = this;
PDFJS.getDocument(parameters).then(
function getDocumentCallback(pdfDocument) {
self.load(pdfDocument, 1);
},
function getDocumentError(message, exception) {
nodeUtil._logN.call(self, "An error occurred while parsing the PDF: " + message);
},
function getDocumentProgress(progressData) {
nodeUtil._logN.call(self, "Loading progress: " + progressData.loaded / progressData.total + "%");
}
);
};
cls.prototype.load = function(pdfDocument, scale) {
this.pdfDocument = pdfDocument;
var pages = this.pages = [];
this.pageWidth = 0;
var pagesCount = pdfDocument.numPages;
var pagePromises = [];
for (var i = 1; i <= pagesCount; i++)
pagePromises.push(pdfDocument.getPage(i));
var self = this;
var pagesPromise = PDFJS.Promise.all(pagePromises);
nodeUtil._logN.call(self, "PDF loaded. pagesCount = " + pagesCount);
pagesPromise.then(function(promisedPages) {
self.parsePage(promisedPages, 0, 1.5);
});
pdfDocument.getMetadata().then(function(data) {
var info = data.info, metadata = data.metadata;
self.documentInfo = info;
self.metadata = metadata;
var pdfTile = "";
if (metadata && metadata.has('dc:title')) {
pdfTile = metadata.get('dc:title');
}
else if (info && info['Title'])
pdfTile = info['Title'];
self.emit("pdfjs_parseDataReady", {Agency:pdfTile, Id: info});
});
};
cls.prototype.parsePage = function(promisedPages, id, scale) {
nodeUtil._logN.call(this, "start to parse page:" + (id+1));
var self = this;
var pdfPage = promisedPages[id];
var pageParser = new PDFPageParser(pdfPage, id, scale);
pageParser.parsePage(function() {
if (!self.pageWidth) //get PDF width
self.pageWidth = pageParser.width;
PDFField.checkRadioGroup(pageParser.Boxsets);
var page = {Height: pageParser.height,
HLines: pageParser.HLines,
VLines: pageParser.VLines,
Fills:pageParser.Fills,
Texts: pageParser.Texts,
Fields: pageParser.Fields,
Boxsets: pageParser.Boxsets
};
self.pages.push(page);
if (id == self.pdfDocument.numPages - 1) {
nodeUtil._logN.call(self, "complete parsing page:" + (id+1));
self.emit("pdfjs_parseDataReady", {Pages:self.pages, Width: self.pageWidth});
}
else {
process.nextTick(function(){
self.parsePage(promisedPages, ++id, scale);
});
}
});
};
cls.prototype.destroy = function() {
this.removeAllListeners();
if (this.pdfDocument)
this.pdfDocument.destroy();
this.pdfDocument = null;
this.formImage = null;
};
return cls;
})();
module.exports = PDFJSClass;
////////////////////////////////End of Node.js Module
用 FS 替换 XHR Level 2
在 Node.js 中,我不需要 Ajax 来异步加载 PDF 二进制文件,而是使用 node 的 fs(文件系统)从文件系统中加载 PDF 文件。pdfparser.js
是 pdf2json 模块的入口,这是它的代码:
var nodeUtil = require("util"),
nodeEvents = require("events"),
_ = require("underscore"),
fs = require('fs'),
PDFJS = require("./pdf.js");
nodeUtil._logN = function logWithClassName(msg) { nodeUtil.log(this.get_name() + " - " + msg);};
nodeUtil._backTrace = function logCallStack() {
try {
throw new Error();
} catch (e) {
var msg = e.stack ? e.stack.split('\n').slice(2).join('\n') : '';
nodeUtil.log(msg);
}
};
var PDFParser = (function () {
'use strict';
// private static
var _nextId = 1;
var _name = 'PDFParser';
var _binBuffer = {};
var _maxBinBufferCount = 10;
// constructor
var cls = function (context) {
//call constructor for super class
nodeEvents.EventEmitter.call(this);
// private
var _id = _nextId++;
// public (every instance will have their own copy of these methods, needs to be lightweight)
this.get_id = function() { return _id; };
this.get_name = function() { return _name + _id; };
this.context = context;
this.pdfFilePath = null; //current PDF file to load and parse, null means loading/parsing not started
this.data = null; //if file read success, data is PDF content; if failed, data is "err" object
this.PDFJS = new PDFJS();
this.parsePropCount = 0;
};
// inherit from event emitter
nodeUtil.inherits(cls, nodeEvents.EventEmitter);
// public static
cls.get_nextId = function () {
return _name + _nextId;
};
//private methods, needs to invoked by [funcName].call(this, ...)
var _onPDFJSParseDataReady = function(data) {
_.extend(this.data, data);
this.parsePropCount++;
if (this.parsePropCount >= 2) {
this.emit("pdfParser_dataReady", this);
nodeUtil._logN.call(this, "PDF parsing completed.");
}
};
var startPasringPDF = function() {
this.data = {};
this.parsePropCount = 0;
this.PDFJS.on("pdfjs_parseDataReady", _.bind(_onPDFJSParseDataReady, this));
this.PDFJS.parsePDFData(_binBuffer[this.pdfFilePath]);
};
var processBinaryCache = function() {
if (_.has(_binBuffer, this.pdfFilePath)) {
startPasringPDF.call(this);
return true;
}
var allKeys = _.keys(_binBuffer);
if (allKeys.length > _maxBinBufferCount) {
var idx = this.get_id() % _maxBinBufferCount;
var key = allKeys[idx];
_binBuffer[key] = null;
delete _binBuffer[key];
nodeUtil._logN.call(this, "re-cycled cache for " + key);
}
return false;
};
var processPDFContent = function(err, data) {
nodeUtil._logN.call(this, "Load PDF file status:" + (!!err ? "Error!" : "Success!") );
if (err) {
this.data = err;
this.emit("pdfParser_dataError", this);
}
else {
_binBuffer[this.pdfFilePath] = data;
startPasringPDF.call(this);
}
};
// public (every instance will share the same method, but has no access to private fields defined in constructor)
cls.prototype.loadPDF = function (pdfFilePath) {
var self = this;
self.pdfFilePath = pdfFilePath;
nodeUtil._logN.call(this, " is about to load PDF file " + pdfFilePath);
if (processBinaryCache.call(this))
return;
fs.readFile(pdfFilePath, _.bind(processPDFContent, self));
};
cls.prototype.destroy = function() {
this.removeAllListeners();
//context object will be set in Web Service project, but not in command line utility
if (this.context) {
this.context.destroy();
this.context = null;
}
this.pdfFilePath = null;
this.data = null;
this.PDFJS.destroy();
this.PDFJS = null;
this.parsePropCount = 0;
};
return cls;
})();
module.exports = PDFParser;
使用 XMLDOM 进行 DOMParser
pdf.js 实例化 DOMParser 来解析基于 XML 的 PDF 元数据,我用 xmldom 模块替换了它;PDFJSClass
有一个 load
方法,当它调用 pdfDocument.getMetadata
(见上文详情)时,xmldom
将被激活以解析出 XML 元数据。
var DOMParser = require('xmldom').DOMParser; //in pdf.js
//in metadata.js:
function Metadata(meta) {
if (typeof meta === 'string') {
// Ghostscript produces invalid metadata
meta = fixMetadata(meta);
var parser = new DOMParser();
meta = parser.parseFromString(meta, 'application/xml');
} else if (!(meta instanceof Document)) {
error('Metadata: Invalid metadata object');
}
this.metaDocument = meta;
this.metadata = {};
this.parse();
}
为 Web Worker 提供假 Worker
当 Web Worker 不可用时,PDF.js 会回退到“假 Worker”。代码已内置,无需太多工作,只需注意解析将在同一线程中发生,而不是在后台 Worker 线程中。根据我的测试,解析性能不是问题,无论是作为 Web 服务还是命令行运行,常规 PDF 表单(少于 8 页)通常在几百毫秒内完成解析和序列化。
'use strict';
//in worker.js:
//MQZ. Oct.11.2012. Add Worker's postMessage API. onmessage will be implemented on caller
globalScope.postMessage = function WorkerTransport_postMessage(obj) {
console.log("Inside globalScope.postMessage:" + JSON.stringify(obj));
};
“假 Worker”的理念是创建一个具有与 Web Worker 相同 API 的 JavaScript 对象,例如 postMessage, onmessage, terminate
等,以便调用代码无需更改即可调用相同的 API,而被调用者则在同一线程中执行工作。这种“使用对象创建相同 API”的技术也适用于 Canvas。
使用 HTML5 Canvas API 的 PDFCanvas
这是我花费时间最多的地方,因为 PDF.js 大量依赖 Canvas 来绘制线条、填充、颜色、形状和文本以输出到屏幕,而在 Node.js 中没有“Canvas”。我们的移植目的是将输出从屏幕更改为内存对象,然后我们可以将这些对象序列化为 JSON 字符串。为了尽可能保持 pdf.js 的“绘图”代码不变,我们创建了 PDFCanvas
来处理所有“绘图”指令,它会拦截操作并创建 JavaScript 对象,而不是绘制到屏幕上。
例如,在 pdfcanvas.js
中,我们有诸如
//private helper methods
var _drawPDFLine = function(p1, p2, lineWidth) {
var pL = new PDFLine(p1.x, p1.y, p2.x, p2.y, lineWidth);
pL.processLine(this.canvas);
};
var _drawPDFFill = function(cp, min, max, color) {
var width = max.x - min.x;
var height = max.y - min.y;
var pF = new PDFFill(cp.x, cp.y, width, height, color);
pF.processFill(this.canvas);
};
var contextPrototype = CanvasRenderingContext2D_.prototype;
contextPrototype.setFont = function(fontObj) {
if ((!!this.currentFont) && _.isFunction(this.currentFont.clean)) {
this.currentFont.clean();
this.currentFont = null;
}
this.currentFont = new PDFFont(fontObj);
};
contextPrototype.fillText = function(text, x, y, maxWidth, fontSize) {
var str = text.trim();
if (str.length < 1)
return;
var p = this.getCoords_(x, y);
var a = processStyle(this.fillStyle || this.strokeStyle);
var color = (!!a) ? a.color : '#000000';
this.currentFont.processText(p, text, maxWidth, color, fontSize, this.canvas, this.m_);
};
以 PDFFill 为例,它在 PDFFile.js
中实现。当调用 processFill
时,它会生成一个“fill”对象并将其插入到 targetData.Fills
集合中。
var nodeUtil = require("util"),
_ = require("underscore"),
PDFUnit = require('./pdfunit.js');
var PDFFill = (function PFPLineClosure() {
'use strict';
// private static
var _nextId = 1;
var _name = 'PDFFill';
// constructor
var cls = function (x, y, width, height, color) {
// private
var _id = _nextId++;
// public (every instance will have their own copy of these methods, needs to be lightweight)
this.get_id = function() { return _id; };
this.get_name = function() { return _name + _id; };
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.color = color;
};
// public static
cls.get_nextId = function () {
return _name + _nextId;
};
// public (every instance will share the same method, but has no access to private fields defined in constructor)
cls.prototype.processFill = function (targetData) {
var clrId = PDFUnit.findColorIndex(this.color);
var oneFill = {x:PDFUnit.toFormX(this.x),
y:PDFUnit.toFormY(this.y),
w:PDFUnit.toFormX(this.width),
h:PDFUnit.toFormY(this.height),
clr: clrId};
targetData.Fills.push(oneFill);
};
return cls;
})();
module.exports = PDFFill;
其他 PDF 处理类也以类似的方式实现,我在这里不再列出更多代码,这里有一些指向它们的链接:
- pdfcanvas.js: 替换 Node.js 中的 HTML5 Canvas;
- pdffield.js: 生成交互式表单字段(文本输入、单选按钮、推送按钮、复选框、组合框等);
- pdffill.js: 创建填充数据结构(带有颜色的矩形区域)
- pdffont.js: 匹配字体系列并处理文本内容;
- pdfline.js: 水平线和垂直线的 C数据结构;
- pdfunit.js: 单位转换和颜色定义
对 PDF.js 的扩展和修改
除了更改或替换依赖项之外,我还必须扩展或修改 PDF.js 中的一些代码,以适应 Node.js 中解析 PDF 的通用目的,包括:
字体
无需调用 ensureFonts
来确保下载字体,因为我们不在浏览器中运行。我们只需要解析出字体信息并将其设置在 JSON 的 texts 数组中。嵌入式/字形字体将被忽略,并根据字体名称映射到通用字体系列,因此解析输出并不总是与原始 PDF 字体匹配,但字体样式(大小、粗体或斜体)将得到保留。更多细节请参见 pdffont.js。
DOM
pdf.js 中的所有 DOM 操作代码都被注释掉了,包括创建用于屏幕渲染和字体下载的 Canvas 和 div。我们将把渲染相关的任务留给客户端渲染器,pdf2json 仅专注于提供表单模板数据。
表单元素
我在之前关于 Blend PDF with HTML5 的文章中已经扩展了 PDF.js 以支持交互式表单元素解析,尽管当时是作为客户端库完成的,但在移植到 Node.js 时仍然适用,我在这里不再重复这些细节。在将表单元素输出到内存对象时,pdffield.js 包含了处理它们的所有数据结构和操作。
嵌入式图像
由于我的用例主要集中在基于 PDF 的电子表单解析,我特意省略了所有嵌入式媒体,包括嵌入式字体和图像。如果您的项目需要,可以添加它们。
在上述更改和扩展之后,这个 pdf2json Node.js 模块可以在服务器环境或作为独立的命令行工具运行。我有一个使用 resitify 和 pdf2json 构建的 RESTful Web 服务,它运行在 Amazon EC2 实例上,同时命令行工具的工作方式类似于 Vows 单元测试。
输出格式
一旦我们开始在 Node.js 中运行 PDF.js,解析输出的 JSON 格式就成为客户端渲染器和 PDF 解析器之间的数据合同。通常,每个 PDF 解析输出在 JSON 中具有以下数据结构:
- 'Agency': PDF 文档的主要文本标识符
- 'Id': 嵌入在 PDF 文档中的 XML 元数据
- 'Pages': 'Page' 对象的数组,描述 PDF 的每一页,包括页面内的尺寸、线条、填充和文本。有关 'Page' 对象的更多信息可以在“Page Object Reference”部分找到。
- 'Width': PDF 页面的宽度(以页面单位表示)
而 'Pages' 数组中的每个页面对象都包含 5 个主要字段来描述页面元素和属性:
- 'Height': 页面的高度(以页面单位表示)
- 'HLines': 水平线数组,每条线都有相对坐标 'x'、'y' 用于定位,以及 'w' 表示宽度,还有一个 'l' 表示长度。宽度和长度都以页面单位表示。
- 'Vline': 垂直线数组,每条线都有相对坐标 'x'、'y' 用于定位,以及 'w' 表示宽度,还有一个 'l' 表示长度。宽度和长度都以页面单位表示。
- 'Fills': 一个带有实心填充的矩形区域的数组,与线条相同,每个 'fill' 对象都有相对坐标 'x'、'y' 用于定位,'w' 和 'h' 表示宽度和高度(以页面单位表示),还有一个 'clr' 来引用颜色字典中的颜色。有关“颜色字典”的更多信息可以在“Dictionary Reference”部分找到。
- 'Texts': 一个文本块数组,包含位置、实际文本和样式信息。
- 'x' 和 'y': 用于定位的相对坐标。
- 'clr': 颜色字典中的颜色索引,与 'Fill' 对象中的“clr”字段相同。
- 'A': 文本对齐方式,包括:
- 左侧
- center
- 右侧
- 'R': 文本运行数组,每个文本运行对象包含两个主要字段:
- 'T': 实际文本
- 'S': 样式字典中的样式索引。有关“样式字典”的更多信息可以在“Dictionary Reference”部分找到。
有关输出格式的更多文档,包括样式字典(以减小有效负载大小)、表单元素数据定义、文本输入格式化器、无样式字典的样式设置、旋转文本支持以及已知问题列表,可以在 pdf2json 项目页面上找到。
总结
将 PDF.js 移植到 Node.js 使得诸如通过 Web 服务或命令行实用程序解析 PDF 表单之类的用例成为可能,通过表单元素解析扩展 PDF.js 为 PDF.js 带来了交互性,它最终实现了一个通用的基于表单的用户界面,可以从现有 PDF 表单高效地构建体验,并且通过 Ajax 更高效、更灵活地集成数据服务。它为我的项目(包含数百个 PDF 表单)提供了极大的帮助,希望它也能对您有所帮助。