阅读 1223

PDF移动端预览平台探索

本文首发于微保技术公众号

因微保小程序的业务特性,经常需要用户在小程序中打开PDF用于浏览各保险条款或是产品介绍。原本的解决方案是在利用小程序的下载文档功能在新的页面下载并打开PDF文档,随着业务的发展,在H5以及iframe场景也有在线浏览PDF的需求。

为什么需要预览平台

H5大都有其自己处理PDF的方案并且小程序的浏览也已经解决了,为什么还需要考虑预览平台呢?

① 在H5页面内打开PDF,iOS系统通常会使用内置浏览器直接打开PDF,而安卓系统则大部分会自动下载PDF,并且不会提示用户。再加上小程序的处理方案,可以发现单单是打开PDF这一件事就有了三种不同的处理方式,这样分散且不可控的处理,不是一个可持续发展的模式。

② 在iframe上展示PDF的效果也不尽如人意,iOS系统中的iframe存在久远且影响浏览的Bug:在iOS的iframe中打开PDF会使iframe内的容器被无限撑大,最终导致PDF无法正常展示,更不要说用户的正常浏览了。

③ 由于平台上的PDF是由各保司提供的,不能保证PDF的字体、样式都是统一的。这就可能会出现因字体过于特殊,导致系统默认方式打开的PDF一片空白。

④ 与现在推崇的千人千面不一样,PDF浏览器对用户而言是一个工具,相比起多样化的操作,统一的交互以及界面将会更容易被用户接纳。同一位用户就算在三个不同的客户端打开我们的页面,也能用同一套操作理念进行浏览或操作。

该怎么实现这个预览平台?

预览平台的核心功能 -- 将PDF转换成客户端可正常浏览的格式,对小程序与H5而言,最通用且稳定的格式就是图片了。方向有了,我们后续的实现思路不外乎这两种:离线转换、实时渲染。

  • 离线转换

    本方案中,我们需要开发的是一个转换服务,这个服务负责将收集到的PDF转换成图片并储存到CDN,并且需要生成一份对应的PDF特征描述文件。

    前端则需要按规则请求对应的PDF特征文件,再根据文件中的PDF特征(PDF张数、PDF储存路径等)批量加载PDF图片。看起来该方案流程清晰并且不需要考虑兼容性问题(前端加载图片基本不需要考虑兼容性)。

    但这有一个很致命的缺点,就是无法应对新PDF的加载,我们必须将所有可能使用到的PDF都手动的推送到服务中,通知其转换并存储。但是在实际操作中,想做到确保全部PDF都成功存储几乎是不太现实的,随便一个临时改动的PDF文档都可能破坏其中的平衡,并且相应的开发人员还需要时刻注意PDF的更新。

  • 实时渲染

    本方案中,我们不需要开发后端服务,需要做的是开发一个PDF浏览“架子”,我们只需要传入PDF的链接,就可以在前端直接渲染出PDF文档。这个架子将会接收PDF链接,直接下载PDF文件并将其解析、渲染成canvas最后转换成图片展示在页面上。

    在本方案中,开发者继续不需要再次维护这个PDF项目,就算有新的PDF需要展示,也只需要在访问页面的时候把相应的url带上即可。

综合开发成本、实际使用考虑,我们比较倾向于实时渲染方案,这时候就轮到mozilla/pdf.js出场了。

具体实现过程

  • 界面布局

预期中浏览界面应该是全屏展示PDF文档内容,并允许用户通过滑动来浏览剩余的内容。

<body>
    <script src="https://unpkg.com/pdfjs-dist@2.2.228/build/pdf.js"></script>
    <script src="https://unpkg.com/pdfjs-dist@2.2.228/build/pdf.worker.js"></script>
    <script src="./pdf.wesure.js"></script>
</body>
复制代码

以上就是HTML部分的代码,可以看到body里面并没有任何内容,我们看到的PDF内容都是在JS中处理完之后再统一插入到DOM树中,接下来我们看一下生成PDF的逻辑。

  • PDF生成逻辑
function document() {
    var loadingTask = pdfjsLib.getDocument({
        url: path,
    })
    loadingTask.promise.then(function (pdf) {
        var index = 1;
        var div = document.createElement('DIV');
        var canvas = document.createElement("CANVAS");
        var className = 'container the-canvas-' + index;
        div.setAttribute('class', className)
        canvas.id = 'the-canvas-' + index;
        div.appendChild(canvas)
        document.body.appendChild(div)
        pdf.getPage(index).then(function (page) {
            var scale = 1;
            viewport = page.getViewport({
                scale: scale
            });

            var canvas = document.getElementById('the-canvas-' + index);
            var context = canvas.getContext('2d');
            canvas.height = viewport.height;
            canvas.width = viewport.width;

            var renderContext = {
                canvasContext: context,
                viewport: viewport
            };

            page.render(renderContext)
    });
}
复制代码

首先,我们通过pdfjs的getDocument方法将目标PDF下载下来,并获得PDF的相关配置(大小、页数等)。然后开始"组装"我们的PDF页面,从上面的代码不难看出,一张PDF的内容的包裹关系如下:DIV > CANVAS > PDF-CONTENT。将上面的代码执行后,我们会得到下面这种效果的PDF浏览页。

我们会发现,PDF显示不全并且也不像是移动端的显示模式。用户想要看到完整的内容只能通过放缩,这未免体验太差了。

既然显示的结果是内容过大,那我们能否在渲染的时候就将其缩小呢?

  • 适配方案

在考虑适配方案之前我们先看看viewport里面得到的是什么内容。

结合打印出的内容以及API文档上的介绍,我们可以知道这是PDF本身的属性,因为我们传入的scale是1,所以我们应该得到的是“一倍图”PDF的尺寸,说到“一倍图”,我们很容易能联想到在web端处理小于12px字体的情况,实际上它们的处理方案确实很像。

当我们需要在页面显示小于12px的字体时,我们有一个方案就是将那部分字体大小先放大一倍(假如需要10px的字体,我们会先得到20px的字体,然后再transform: scale(0.5, 0.5)),然后在将其缩小一倍,然后处理它的位置。

思路有了,我们要怎么将其运用到PDF浏览里面呢?我们尝试将所有的canvas都缩小,效果如下。

这是怎么回事呢?PDF显示的位置偏移的十分离谱,这就不得不说一下我们的transfrom-scale,我们在进行变形时,css会默认将其放缩的基点放在整项的中间,就等于我们在设计时会说到的中心放缩。

所以我们在进行放缩类的操作后,需要进行一下变形基点的设定,也就是transform-origin属性。

canvas {
    transform: scale(0.5, 0.5);
    transform-origin: 0 0;
}
复制代码

解决了位置偏移的问题后,又有新的问题出现了 -- 这个PDF缩放后太小了,没法铺满屏幕,那么我们是否可以通过与页面宽度得出一个关系,让其可以铺满屏幕呢?

就拿我们上面的测试PDF来说,scale为1的时候,PDF宽度为750,然而视窗是iPhone8 plus的尺寸,所以将canvas缩小一半会令其无法横向铺满视窗。现在有两个方案,一个是动态的transform放缩尺寸,另一个则是getViewport时动态计算scale数值。从css对小数数值的兼容性考虑,最终我选择了后者。

//  初始scale数值
var scale = 1;

//  获取PDF在“一倍图”时的尺寸
var viewport = page.getViewport({
    scale: scale
});

//  获取body宽度
var width = document.body.clientWidth;

/** 
 * width / viewport.width > 1
 * 视窗 > PDF一倍宽度最终得到scale > 2
 * 反之则会得到小于等于1的scale
 * 最终再*2是为了得到更清晰的渲染
*/
scale = scale * width / viewport.width * 2

//  重新定义scale之后再次getViewport
viewport = page.getViewport({
    scale: scale
});
复制代码

其他问题

  • PDF内的印章无法显示

完成上述的开发后,一个可阅读的PDF页面已经完成得差不多了,但是对比原件之后惊奇的发现,印章没了。

去翻了一下项目的issue发现这是个有一定年份(2012年就已经有相关issue)的问题了,看了下源码还是作者有意将电子签名及印章隐藏的,原因是项目还没具备验证电子签名及印章的能力。

最终的解决方案倒比较简单,只需找到源码设置HIDDEN属性的代码,将其注释即可。

var parent = Annotation.prototype;
  Util.inherit(WidgetAnnotation, Annotation, {
    isViewable: function WidgetAnnotation_isViewable() {
     /* if (this.data.fieldType === 'Sig') {
        warn('unimplemented annotation type: Widget signature');
        return false;
      }*/

      return parent.isViewable.call(this);
    }
  });
复制代码
  • 特殊字符无法显示

在调试其他PDF文档是发现,有些页面会是一片空白,一开始以为原件就是如此,但是对照之后发现这一页是用了特殊字体。

在搜索解决方案的时候看到getDocument有这样一个参数disableFontFace,这个参数的默认值是false,看起来将其设为true就可以使用默认字体了。事实上并不是的,这个参数是负责控制是否使用内置的字体渲染器来渲染。

随着搜索的深入,看到这样一个解决方案 --

pdfjsLib.getDocument({
    url: path,
    cMapPacked: true,
    cMapUrl: 'https://unpkg.com/pdfjs-dist@2.2.228/cmaps/'
})
复制代码

里面涉及到cMapPackedcMapUrl两个参数,前者表明用到的cmap是二进制类型的,后者这是设定cmap的请求地址。笔者对这一块配置的理解是,如果遇到不支持的字体,将会去指定的地址获取默认字体的bcmap用于渲染替代特殊字体的默认字体。

  • 体验优化

因为之前都是用iPhone进行调试的,所以一直没感觉到卡顿的情况,借用了测试机之后发现,打开页数较多的PDF会出现卡顿情况。总结了一下,原因大概是并行了过多的渲染。一开始的写法是在getDocument之后拿到PDF页数直接for循环将所有的page同时输出。

//  伪代码
pdfjsLib.getDocument({ url: 'xxx' }).promise.then(function (pdf) {
    numPages = pdf._pdfInfo.numPages
    for (var i = 0;i < numPages;i++) {
        pdfCreator(pdf, i + 1)
    }
});
复制代码

既然同时输出会引起卡顿,能否优化成一张一张顺序渲染呢?自然是可以的,我们可以通过递归的方式将PDF一页页输出。

var numPages = 0;
var renderFlag = 0;
//  ......
pdfjsLib.getDocument({ url: 'xxx' }).promise.then(function (pdf) {
    numPages = pdf._pdfInfo.numPages
    pdfCreator(pdf, 1)
});

function pdfCreator(pdf, index) {
    //  ......
    pdf.getPage(index).then(function (page) {
        //  ......
        page.render(renderContext).promise.then(() => {
            renderFlag = index
            if (renderFlag < numPages) {
                pdfCreator(pdf, renderFlag + 1)
            }
        });
    })
}
复制代码

小结

一个功能相对完整的PDF浏览页面就完成了,还是有需要后续优化的地方,例如page.render的容错处理、PDF下载功能,甚至还可以新增懒加载功能。

参考链接 --

getDocument参数介绍

what-is-bcmap?有关bcmap的解释

关注下面的标签,发现更多相似文章
评论