65.9K
CodeProject 正在变化。 阅读更多。
Home

混合 PDF 与 HTML5

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (17投票s)

2012 年 9 月 26 日

CPOL

14分钟阅读

viewsIcon

117008

downloadIcon

4815

通过扩展 HTML5 中的 PDF.JS 来渲染可填写的 PDF 二进制流

Sample Image

引言

自从大约四年前我发布了“HTML5 融合 PDF”的技术文章之后,Web 已经发生了巨大的变化。除了 RIA(富互联网应用)中对浏览器插件的依赖越来越少甚至没有依赖,以及 RJA(富 JavaScript 应用)的兴起之外,一个特别的趋势是移动网络流量正在爆炸式增长,每天通过平板电脑访问网络的设备越来越多。如果你的 Web 应用是以电子表格为中心,并且填写的表格是用 PDF 开发的,同时还需要支持移动设备,尤其是平板电脑,那么是时候进行技术更新了:我们需要一种实用的解决方案,不仅能在浏览器中直接渲染可填写的 PDF 表格,而无需浏览器插件,而且还能无缝集成用户交互和数据交换。现在,随着 HTML5 和浏览器的新功能,以及来自 Mozilla Lab 的强大的开源 JavaScript PDF 查看器库 - PDF.JS,直接在浏览器中解析、渲染、布局和交互 PDF 电子表格成为可能。

本文讨论了一种实用而简单的客户端解决方案,通过扩展 PDF.JS,无需浏览器插件即可直接在浏览器中解析、渲染、布局和绑定 PDF 交互式表单数据。

PDF.JS 库负责在客户端以二进制流的形式处理 PDF 文件,还可以在 HTML5 Canvas 中渲染 PDF 的只读部分,包括文本、形状、线条、填充等。我将详细讨论如何扩展 PDF.JS 以支持表单元素的解析,不仅包括文本输入框和复选框,还包括单选按钮、单选按钮组、按钮和下拉列表(组合框)。我还会讨论原型中用于将这些交互式表单元素与 PDF 内容一起布局的技术。该原型的可运行实例可以在这里找到:http://www.hanray.com/sites/BlendPDFWithHTML5/

背景

PDF 格式已于 2008 年成为 ISO 标准,它被定义得很好并且有详细的文档,我们不会在这里讨论 PDF 格式,而是专注于在 PDF.JS 之上进行交互式表单元素的解析和渲染。核心思想是,PDF 电子表格可以通过客户端的 JavaScript 进行解析、渲染,并与用户交互和用户数据集成。这种方法的优点已在 Andreas Gal 的帖子 中有所记录,尽管他主要讨论的是以只读模式查看 PDF,而我们的目标是添加表单交互性和数据绑定。

在浏览器中渲染交互式 PDF 表格依赖于 HTML5 中的一些新功能。PDF.JS 使用 JavaScript 类型化数组XHR 2 级 进行 PDF 二进制流处理,还利用 Web WorkerHTML5 Canvas 以只读模式渲染 PDF。这意味着并非所有浏览器都兼容这种方法,尤其是 IE。尽管 IE9 支持 Canvas,但直到 IE10 才支持 类型化数组。对于支持这些 HTML5 功能的浏览器,包括最新版本的 Google Chrome、Firefox 和 Safari,该原型运行良好。

由于 PDF.JS 专注于为浏览器构建一个 JavaScript PDF 查看器(它是只读的),因此它不支持表单元素的解析和渲染。尽管它的一个示例提供了对 AcroForm 的一些基本支持,但它只支持文本输入框和复选框,因此对 PDF.JS 的扩展主要集中在解析单选按钮(组)、按钮和下拉列表(组合框)。一旦我们有了表单元素数据,我们就会将 HTML 表单布局在 Canvas 上的 PDF 内容之上,以实现用户交互和数据集成。

布局交互式表单元素的做法是将 PDF.JS 生成的 Canvas 绘制作为表单背景,然后在上面放置绝对定位的 HTML 表单控件。每个 PDF 页面的大小和位置以及表单元素的布局由 PDF 流控制,Canvas 和绝对定位表单层的 z-index 由 CSS 控制。目标是无需服务器端处理和客户端插件,即可将一个可填写的 PDF 表格文件放入 Web 服务器,我们的原型 Web 应用就可以渲染 PDF 内容和交互式表单,而无需更改代码。

还有一个需要补充的说明是,这种方法并未解决 PDF 打印问题,示例代码也不包含数据库支持。尽管我们解决了用户在前后导航时的数据持久化问题,但当前的数据存储是通过 HTML5 data-* 属性在 DOM 存储中实现的,这仅用于概念验证。您可以查看示例项目 http://www.hanray.com/sites/BlendPDFWithHTML5/ 来了解其工作原理。

示例项目结构

在深入讨论表单元素解析之前,我们可以简要介绍一下示例项目的结构,以便更好地描述整个应用程序的工作方式。这个 Web 应用是用 backbonejs + underscorejs + requirejs + AMD + jQuery 构建的。我使用 backbone 作为 MVC 框架,并利用了 underscorejs 提供的许多函数式编程能力(backbone 也依赖于 underscorejs),当然 jQuery 在 DOM 操作方面也提供了很大的帮助。

尽管所有示例代码都兼容 AMD(异步模块定义)并通过 requireJS 加载,但 PDF.JS 库文件不是异步加载的,它们通过 index.html 中的 script 标签引用。就像所有 AMD 兼容项目一样,示例项目的启动配置在 main.js 中定义。下面展示了 requireJS 如何配置以加载其他非 AMD 兼容的 JS 库

    requirejs.config({
        paths:{
            // Major libraries
            underscore:'lib/underscore', // https://github.com/amdjs
            backbone:'lib/backbone', // https://github.com/amdjs
            text:'lib/text', // Require.js plugins
            template: '../template'
        },
        shim:{
            underscore: {
                exports:'_'
            },
            backbone: {
                deps:['underscore'],
                exports:'Backbone'
            }
        },
        waitSeconds: 90,
        urlArgs: "v=1.0.5"
    });

main.js 中,它会实例化 AppView(在 view/app.js 中定义)和 AppRouter(router.js)作为应用程序的入口点。AppRouter 会监控 URL 的变化,然后根据 URL 的哈希码确定要渲染的视图。

    define([
        'underscore',
        'backbone',
        'vm'
    ], function(_, Backbone, Vm) {
        "use strict";
        var AppRouter = Backbone.Router.extend({
            routes: {
                "pdf/:formid": "renderFedPDFForm",
                "*actions": "defaultRoute"
            },
            renderFedPDFForm: function(formid) {
                var mcView = Vm.getChildView(this.options.appView, 'MainContentView');
                mcView.render({viewName:'PDFViewer', id:'fed/' + formid});
            },
            defaultRoute: function(actions){
                if (!actions) {
                    this.navigate("#/pdf/f1040ezt");
                }
            },
            initialize : function(options){
                this.options = options;
                Backbone.history.start();
            }
        });
        return AppRouter;
    });

defaultRoute 函数开始,当 URL 中没有指定 PDF 表单 ID 时,它会尝试渲染 f1040ezt.pdf 文件。renderFedPDFForm 函数是所有 PDF 表单渲染的主要入口,它通过 MainContentView(在 view/MainContentView.js 中定义)来实例化和渲染 PDFView 类。这是 PDFView 的 render 函数的代码:

    render:function (options) {
        var self = this;

        self.forms = [];
        self.pdfPageTemplate = _.template(VM.getTemplate('tmpl-pdfpage'));

        require(['model/PDFParser'], function(PDFParser){
            self.pdfParser = new PDFParser();
            self.pdfParser.set('formInstanceID', options.id.toLowerCase() + ".pdf");
            self.pdfParser.on('pdfDocumentReady', _renderPDF, self);
            self.pdfParser.on('pdfDocumentError', _errorPDF, self);
            self.pdfParser.getPDFDocument("data/");
        });
    }

PDFView 的 render 函数会根据 URL 的哈希码指示其模型 `pdfParser`(`PDFParser` 类的实例)加载 PDF 文件,当 PDF 二进制流准备好时,会调用 _renderPDF 函数。当调用这个回调函数时,它首先会指示 PDF.JS 在 Canvas 上渲染只读部分,然后调用回调来渲染交互式表单元素。以下 _setupForm 函数在每个 PDF 页面渲染完成后调用。

    var _setupForm = function(page, formIdx, callback) {
        var self = this;
        self.ffs = $('<div></div>') ;
        page.getAnnotations().then(function(fields){
            _.each(fields, _addField, self);
            var $form = self.forms[formIdx];
            $form.append(self.ffs.html());
            _handleUserData.call(self, formIdx);
            callback();
        });
    };

注意 `self.ffs` 变量,它允许所有表单元素都被添加到内存中的 jQuery 对象中。一旦所有元素都插入完毕,它就会将其内容添加到 DOM 中的 form 标签中。这种内存操作主要是为了提高渲染性能。

_handleUserData 是将表单元素布局在 Canvas 上的 PDF 内容之上关键,它假定 `fields` 中的所有项都已解析出来。我们需要扩展 PDF.JS 来支持所有类型的表单元素,而不仅仅是文本输入框和复选框。在讨论用户交互以进行数据交换之前,让我们先讨论这些元素的解析。

扩展 PDF.JS 以支持表单元素

根据 Adobe 的 PDF 参考文档,交互式表单元素在第 8.6 节第 640 页被定义为 Widget Annotations(控件注释)。PDF.JS 在 core.js 文件中实现了 getAnnotations。我首先在那里修改的是删除用 "." 连接的项目名称的代码,而是使用注释字典中的 "T" 值。

    item.fullName = stringToPDFString(getInheritableProperty(annotation,'T') || '');

在这里,`fullName` 将作为 HTML 标签中表单元素的 `name` 属性,这对于用户数据集成至关重要(我们稍后会讨论)。此外,根据 Widget Annotation 的类型,我们需要解析出 PDF.JS 当前不支持的其他属性。从 core.js 的第 411 行开始是我的扩展。

    //MQZ.Sep.19.2012: adding field value
      if (item.fieldType == 'Btn') { //PDF Spec p.675
          if (item.flags & 32768) {
              setupRadioButton(annotation, item);
          }
          else if (item.flags & 65536) {
              setupPushButton(annotation, item);
          }
          else {
              setupCheckBox(annotation, item);
          }
      }
      else if (item.fieldType == 'Ch') {
          setupDropDown(annotation, item);
      }

上面 4 个 `setup...` 函数被定义为 `getAnnotation` 中的闭包函数,以保持其私有性。让我们逐一来看。

setupRadioButton

通常,单选按钮会成组,这样一次只能选择一个。根据 PDF 规范,单选按钮组中的每个单选按钮都定义为一个 Widget Annotation,它们的组名是我们之前提到的 `item.fullName`。我们唯一需要的是与每个单选按钮关联的 "value",以下是获取方法:

    function setupRadioButton(annotation, item) {
        //PDF Spec p.606: get appearance dictionary
        var ap = annotation.get('AP');
        //PDF Spec p.614 get normal appearance
        var nVal = ap.get('N');
        //PDF Spec p.689
        var i = 0;
        nVal.forEach(function(key, value){
            i++;
            if (i == 2) {
                item.value = key; //value if selected for the radio button
            }
        });
    }

稍后在用户交互部分,我们将讨论如何将这些 `item.value` 和 `item.fullName` 布局为 HTML 单选按钮输入标签。现在继续讨论 PushButton 的解析。

setupPushButton

对于 PushButton,在将其放置在交互式表单中时,我们需要两个信息:按钮标签和按钮操作。在我的用例中,按钮操作始终是导航到 URL,因此第二个数据是 AcroForm 中设置的 URL 字符串。更多细节请参见代码。

    function setupPushButton(annotation, item) {
        //button label: PDF Spec p.640
        var mk = annotation.get('MK');
        item.value = mk.get('CA') || '';

        //button action: url when mouse up: PDF Spec:p.642
        item.FL = "";
        var ap = annotation.get('A');
        if (ap) {
            var sp = ap.get('S');
            item.FL = ap.get(sp.name);
        }
    }

setupCheckBox

扩展 CheckBox 解析的目的是获取与字段关联的 "value"。这是代码:

    function setupCheckBox(annotation, item) {
        //PDF Spec p.606: get appearance dictionary
        var ap = annotation.get('AP');
        //PDF Spec p.614 get normal appearance
        var nVal = ap.get('N');
        //PDF Spec p.689
        var i = 0;
        nVal.forEach(function(key, value){
            i++;
            if (i == 1) //value when selected
                item.value = key;
        });
    }

setupDropDown

下拉列表或组合框元素包含一个项目列表,每个项目的标签显示给用户,而相应的 "value" 是选中的实际数据表示。PDF 将这些信息存储在一个字典条目中,作为一个字符串数组,这使得解析下拉列表变得最简单。

    function setupDropDown(annotation, item) {
        //PDF Spec p.688
        item.value = annotation.get('Opt') || [];
    }

借助 PDF.JS,我们可以渲染 Canvas 中的 PDF 只读部分,并且我们还扩展了它以解析表单元素信息。让我们看看如何利用这些信息在 Canvas 上布局 HTML 表单。

交互式表单布局

正如我们在“背景”部分所讨论的,布局表单元素的方法是确保表单标签的 `z-index` 高于 Canvas(PDF 内容绘制的地方),然后根据从 PDF.JS 获取的坐标绝对定位每个元素。具体来说,每个 PDF 页面都插入到 DOM 中,其基础是一个 underscoreJS HTML 模板:(定义在 template/template.html):

    <script id='tmpl-formviewer' type='text/template'>
        <canvas id="formViewer" width="<%= width %>px" height="<%= height %>px"></canvas>
        <form class="formFields" width="<%= width %>px" height="<%= height %>px"></form>
    </script>

以下 CSS 规则将确保 z-index 和定位正确

.formFields input, .formFields button, .formFields select, .formFields div { position: absolute; }
.pdfpage { position:relative; top: 0; left: 0; border: solid 1px black; margin: 0; }
.pdfpage > canvas { position: absolute; top: 0; left: 0; background-color: #F4F3EA; z-index: 0;}
.pdfpage > form { position: relative; z-index: 1; top: 0; left: 0; }

上述 CSS 规则定义在 css/layout.css 中,并由 sohaBase.jsAppView 加载时动态加载。sohaBase.js 是“面向服务的 HTML 应用程序”基本通用功能的 AMD 兼容版本,它尊重 requireJS 配置中的版本号,以确保在版本字符串更改时使用更新的 CSS 文件而不是缓存的文件。

现在我们有了 HTML 容器标签、CSS 规则和字段项数据,下一步就是获取具体字段数据,将其应用于相应的输入/按钮/选择模板(再次,定义在 template.html),然后将生成的 HTML 插入到表单标签中。以复选框为例,看看有多简单。

复选框标签与文本输入框、单选按钮和按钮共享相同的 HTML 输入模板,因为它们唯一的区别是 `type` 属性值。示例项目只处理 4 种不同的输入类型,它可以使用相同的代码和模板扩展以支持更多的 HTML5 输入类型,如日期、电子邮件、URL、密码、范围、搜索、时间等。下面是输入模板的定义:

    <script id='tmpl-inputbutton' type='text/template'>
        <input type="<%=type%>" name="<%=id%>" tabindex="<%=tabindex%>" value="<%=value%>" style="left:<%=x%>px;top:<%=y%>px;">
    </script>

对于每个单独的 CheckBox 字段项(我们已经从扩展 PDF.JS 中获得了项对象,请参见上面的 setupCheckBox 代码示例),将在其应用于模板之前,通过调用 model/PDFParser.js 中的 getCheckBoxData 来生成视图模型。getCheckBoxData 函数通过 _getFieldBaseDatagetFieldPosition 扩展了通用字段视图模型数据。

    var getFieldPosition = function(field) {
        var viewPort = this.get("viewport");
        var fieldRect = viewPort.convertToViewportRectangle(field.rect);
        var rect = Util.normalizeRect(fieldRect);
        return {
            x: Math.floor(rect[0]),
            y: Math.floor(rect[1]),
            width: Math.floor(rect[2] - rect[0]),
            height: Math.floor(rect[3] - rect[1])
        };
    };

    var _getFieldBaseData = function(field) {
        return _.extend({
            id: field.fullName,
            tabindex: _tabIndex++
        }, getFieldPosition.call(this, field));
    }; 
    getCheckBoxData: function(field) {
        return _.extend({
            type: "checkbox",
            value: field.value
        }, _getFieldBaseData.call(this, field));
    }

getCheckBoxData 返回后,view/PDFViewer.js 中的 _addCheckBox 将返回的数据应用于 tmpl-inputbutton 模板,然后将结果 HTML 插入到内存中的 jQuery 对象中。

    var _addCheckBox = function(field) {
        var cbData = this.pdfParser.getCheckBoxData(field);
        this.ffs.append(this.inputButtonTemplate(cbData));
    };

所有其他表单元素类型,文本输入框、单选/按钮和下拉列表,工作方式相同:数据从 PDF.JS 扩展的项对象转换为视图模型,然后将输入模板应用于视图模型,最终结果插入到内存中的 jQuery 对象中。一旦每个项完成相同的过程,我们就会得到一个布局在 Canvas 上绘制的 PDF 内容之上的交互式表单。

现在我们已经有了 PDF.JS 在 Canvas 中渲染的 PDF 只读部分,并且已经解析和布局了交互式表单,下一步是将数据绑定到表单。

数据绑定到表单

由于我们正在构建一个基于 PDF 表单的 Web 应用程序,我们的目标不仅仅是查看 PDF,我们需要启用表单和用户之间的交互以进行数据交换。在实际用例中,最终用户需要经过身份验证,然后连接到数据库以检索用户特定的数据。为了演示目的,我将省略这一步,仅通过使用 DOM 存储作为数据存储来证明数据绑定在此方法中的可行性和可靠性。您可以简单地将 DOM 数据存储替换为 Ajax 调用,以实现完全集成的 Web 应用程序。

表单数据绑定涉及两个方面:一个是以一种独立于表单的方式连接表单元素的更改事件,并在用户离开时将用户数据保存到数据存储中。另一方面是当表单布局完成时,需要检查数据存储,如果当前加载的表单有用户数据可用,则获取它并将其填充回正确的字段。

上述方法遵循“关注点分离”原则,不仅将逻辑(用户数据处理通常在服务器端)和表示(仅客户端)分开,还将在表示层内部分离模板数据(PDF 文件)和用户数据。由于模板数据对所有用户(这些 PDF 表格)都是相同的并且是公开的,而用户数据是用户特定的且安全的,因此这种进一步的分离使得内容和逻辑能够并行开发,并使用 Web 应用以松散耦合的方式将所有部分粘合在一起。

事件绑定

让我们看一下表单数据绑定的第一部分:在 view/PDFViewer.js 中,在内存中的 jQuery 对象的内容插入到 DOM 后,会设置输入字段的事件处理程序。

    var _handleUserData = function(formIdx) {
        var self =this;
        var formData = self.pdfParser.getFormUserData(formIdx);
        var $form = self.forms[formIdx];

        $form.find('input').bind('change', function(evt){
            if (this.type == 'checkbox')
                formData[this.name] = this.checked;
            else {//if (!this.type || this.type == 'text' || this.type == 'radio')
                formData[this.name] = this.value;
            }
        });

        $form.find('select').bind('change', function(evt){
            formData[this.name] = this.value;
        });

        _fillUserData.call(self, $form, formData);
    };

它会遍历当前表单中的所有输入框和选择框标签,并将每个更改字段的值放回一个键值对 JavaScript 对象中,其中 "name" 是字段的 ID,而这个键值对对象由 model/PDFParser.js 管理。下面是 model/PDFParser.js 中展示此对象如何初始化并保存到 DOM 存储的代码。

    //save user data to DOM storage
    updateUserData: function() {
        var uD = this.get('userData');
        $('body').data(this.get('formInstanceID'), uD);
    },
    //to make sure each pdf page has a userData object associated
    initUserData: function(pageCount) {
        var uD = [];
        for (var i = 0; i < pageCount; i++) {
            uD.push({});
        }
        this.set({userData: uD}, {silent: true});
    }

当用户导航到不同的表单时,view/PDFViewer.js 会调用 updateUserData 将所有收集的用户数据基于当前表单 ID 保存到 DOM 存储中。基于表单 ID 的存储键允许我们在新表单加载时检索用户数据。

数据绑定

当一个表单完成渲染和布局后,view/PDFViewer.js 会调用其模型 model/PDFParser.js 来读取当前表单之前保存的用户数据。

    //read user data from DOM storage by formID, and raise event of change:userData
    getFormUserData: function(formIDx) {
        var uD = $('body').data(this.get('formInstanceID'));
        if (uD) {
            this.set({userData: uD}, {silent: true});
        }
        else
            uD = this.get('userData');
        return uD[formIDx];
    }

一旦用户数据返回,view/PDFViewer.js 会继续根据字段 ID 填充表单。

    var _fillUserData = function($form, formData) {
        var self = this;
        $form.find('input').each(function(index, inputEle){
            if (_.has(formData, this.name)) {
                if (this.type == 'checkbox') {
                    this.checked = formData[this.name];
                }
                else if (this.type == 'radio') {
                    this.checked = this.value === formData[this.name];
                }
                else
                    this.value = formData[this.name];
            }
        });

        $form.find('select').each(function(i, s){
            if (_.has(formData, this.name)) {
                this.value = formData[this.name];
            }
        });
    };

至此,我们已经拥有了一个功能齐全、数据绑定的、基于 PDF 的交互式表单,可以在浏览器中运行,无需插件。这种基于 PDF 的交互式表单渲染、布局和数据绑定以一种简单、安全(浏览器沙箱)和通用的方式将 PDF 与 HTML5 融合在一起。您可以谷歌一些 PDF AcroForm 文件并将其放入 data/fed 文件夹中,看看如何在无需更改代码的情况下快速将新表单集成到应用程序中。

总结

Web 和技术的演变总会使一些技术过时,同时提供新的选项,以更高效、更安全的方式提供卓越的客户体验。除了对浏览器和 HTML5 新功能感到兴奋之外,还有一些注意事项和问题我想指出。

尽管 HTML5 发展迅速,但在项目中使用某些新功能之前,我们总是需要检查 W3C 和 WHATWG HTML5 规范。例如,在当前版本的 IE(IE9)中,二进制数据处理不可用,而 IE10 计划支持它。其他功能考虑也需要同样的谨慎,例如 IE8/IE7 中不可用的 Canvas,IE 中也不支持 Web Worker 等。好消息是这些功能正在逐渐添加,如果没有,总会有一些替代方案,例如 Chrome FrameHTML5 polyfills 等。

至于 PDF.JS 库本身,它功能强大且编写得很好,但它的体积相当大(我曾运行过构建脚本一次,组合起来仍然有 800+k),并且需要努力才能使其完全兼容 AMD。此外,它不支持交互式表单,它在 Canvas 中渲染所有文本内容,这使得文本不可选。由于 Canvas 是基于像素的,当需要可访问性时,这可能是一个问题。我希望看到 PDF.JS 开始提供可配置的选项,以 SVG 或纯 HTML 文本形式进行渲染,以实现可选性、可读性和可访问性。

本文附带的示例项目确实是第一个尝试解析、渲染、布局和数据绑定交互式表单的项目。我只关注 AcroForm,尚未研究 XFA 表格。即使对于 AcroForm,也并非所有元素的属性都已完全解析和利用,例如输入约束(最大长度、仅数字等)、验证以及 PDF 中的不同外观设置。稍后将会有更多工作。

总而言之,很高兴看到浏览器通过类型化数组和 XHR 2 拥有越来越多的处理二进制数据的能力,通过 Web Worker 异步运行某些逻辑,在 Canvas 中绘制图形和文本,并通过交互性无缝地与其他 HTML 内容融合。以当前的发展速度,HTML5 每天都在变得更好、更强大。

© . All rights reserved.