需求背景
需求背景是要写一个文档阅读器,分为顶部工具栏、侧边栏、阅读器三个部分,阅读器主要给用户展示原文和译文两篇文档,用户可以进行对照阅读,并利用顶部工具栏进行自由缩放和切换页码的操作,并能在原文选中文字进行单独翻译、术语矫正、高亮和添加笔记。整个页面由前端实现,服务端提供pdf文件,前端需要负责解析和渲染。
实现思路
pdf阅读器实现思路
我的实现思路是以阅读器为核心, 主要通过事件的方式进行通信,用户在顶部工具栏或侧边栏等位置的操作通过相应的操作事件传给阅读器,阅读器进行对应的处理。一些事件(如缩放、切换页码等)会同时通知原文和译文的展示组件,进行视图的更新,而阅读器的状态也会实时同步给顶部工具栏和侧边栏,给用户以反馈。一些事件会触发与服务端的交互,从服务端请求到数据后,阅读器将数据传给展示组件,组件展示新的内容。在文档展示组件内,包含负责展示pdf的pdf.js模块,解决最小字号限制问题的字号兼容模块,渲染高亮模块以及控制动态加载的模块。
其中,pdf.js是一个较为常用且功能强大的渲染pdf的js库。它能从指定url获取文档并生成一个DocumentProxy实例,我们可以从该实例中获取指定页码的页面,同时利用管理单个页面的类PDFPageViewer,将其展示出来。这里一个PDFPageViewer实例对应着pdf中的一页,我们只需要对这些PDFPageViewer实例进行操作,就能控制pdf页面的缩放、渲染和重置。实现思路和核心代码如下:
展示pdf思路示意
展示pdf的核心代码
页面渲染的性能问题
在文档翻译的应用场景中,pdf的页数可能很多,页数越多,渲染所需的内存等资源就越多,如果将一个几百页的pdf全部渲染出来,需要占用的内存将超过1GB,这很容易会导致客户端崩溃。因此,页面渲染的性能问题,是我们必须进行优化的一个点。
对于这个问题,我选择的解决方案是动态加载页面,即只渲染当前能看到的页面和几个邻近的页面,其它页面使用占位容器确保页面高度的正常。这个方案的实现依赖于PDFPageViewer类的三个方法:
-
PDFPageViewer.update(scale) 更新页面的缩放倍数,该方法仅改变页面的大小,不会重新渲染内容,可以确保缩放后,未渲染内容的页面和已渲染内容的页面具有同样的高度
-
PDFPageViewer.draw() 渲染页面,根据当前的缩放倍数进行内容渲染,内存占用和运行时间主要来自这个方法,因此尽可能减少调用次数
-
PDFPageViewer.reset() 重置页面,pdf页面在初始时,没有任何内容,仅有一个loading icon,调用该方法可以将已渲染的页面重置为初始状态以降低内存占用
基于这三个方法,我们可以通过监听滚动事件,判断当前哪个页面位于视窗内,确定需要渲染的页面(如前后3页),对不需要渲染的页面执行reset(),对需要渲染的页面执行draw();在进行缩放操作时,先对所有页面执行update(),让所有页面的大小发生改变,方便进行定位,再对需要渲染的页面执行draw()。这里注意用户能看到的当前页面要首先渲染,因为update()改变大小后页面的清晰度会受到影响,如果不首先渲染当前页的话,会因为渲染其它页面产生了延迟,导致明显的卡顿现象。
利用这个方案,实测在设置渲染总页数为7页时,内存可以稳定控制在50MB以内,渲染的内存问题基本解决。
浏览器最小字号限制问题
第二个问题是在实现文档高亮功能时遇到的。这次文档翻译的更新,一大亮点在于可以在文档上添加高亮,将翻译和学习更高效地结合起来。要实现这一功能,首先需要了解pdf.js的渲染机制。看一下pdf.js渲染出来的dom,文档每一页由canvasWrapper,textLayer和annotationLayer三层构成:
-
canvasWrapper,画布层,用一个canvas标签渲染我们看到的pdf的所有内容(包括文字、图片、高亮等);
-
textLayer文本层,是一层透明的文字,与文档中的文字一一对应,使用户可以对其中的文字进行选中、复制等操作;
-
annotationLayer标注层,也是一层透明的dom,它与文档中的高亮区域一一对应,点击对应位置时,会展示一个弹窗显示标注内容,但annotationLayer并不支持编辑,也无法改变画布层渲染的高亮,所以并不能用来实现添加高亮的需求。
pdf.js渲染的文档的DOM结构
为了实现添加高亮功能,我首先想到的是利用原生的Selection对象,确定用户选中的位置,然后在文档的上层绘制一个背景色为指定高亮颜色的方块。但在实现过程中发现,由于浏览器有最小字号的限制,当文档缩小到一定程度时,textLayer中的文字会比canvas中渲染的文字大,两者就无法一一对准了。
textLayer的字号与文档字号不一致
为了解决这个问题,我想到了常用的小于12号字体的实现方式:transform: scale()。我们需要在pdf页面渲染时,判断对textLayer中的所有文字进行判断,看它们的字号是否与实际渲染的字号一致,若不一致,则通过添加transform: scale()样式,缩放至二者一致。这里用到了getComputedStyle这个api,它能获取指定DOM元素被实际应用的css样式。而恰好,pdf.js设置字号的方式是给每一个标签加一个行内的font-size样式,我们可以直接用element.style拿到。 有了这两个数据,实现任意字号就没什么问题了。
因为动态加载的缘故,每次只需要检测几页文字,性能上没有受到太大的影响。这个办法虽然解决了问题,但总有点暴力解决的味道。后续会考虑通过js获取鼠标位置和文字位置,直接计算出选中的内容,但由于时间有限,第一版只能先采取上述的暴力方案了。
字体解析的坑
还有一个比较棘手的问题是在测试阶段发现的,负责测试的同事发现,在打开一小部分特定的文档时,很容易出现崩溃的情况。经过排查,我发现在渲染一些特殊的字体时,pdf.js会产生数百兆的瞬时内存占用,这与文档大小没有关系,即使文档只有一页,只要其中存在某些特定的字体,就可以复现。
这个问题在pdf.js的官方demo中也同样复现了(如下图),在渲染一篇仅有一页的文档时,chrome的调试工具显示,实例pdf.worker.js内存占用达到了884MB,文档渲染完成后,又恢复到10M以内,由此基本可以判断,这些内存是pdf.js解析文档时产生的。
pdf.js官方demo渲染特殊字体时的内存
那是否有办法可以避免这个问题呢?为了找到问题的来源,我对pdf.worker.js进行了调试。pdf.js解析pdf时,创建了一个worker,其中运行着pdf.worker.js,worker解析好的数据以stream的形式传给主线程进行渲染。我在pdf.worker.js打断点输出当前的内存占用,最终定位到一个名为CFFFont的类的构造函数中,调用了一个CFFCompiler类实例的compile()方法。该操作的作用是解析pdf中的CFF字体,CFF字体相关的介绍可以参考docs.microsoft.com/en-us/typog...,在这里不作深究。pdf.worker.js中的调用该方法的源码如下:
为了探究它的必要性,我尝试了两种修改方案,一是将69610行改为抛出一个错误,让程序始终执行到catch中,二是将69610行的compiler.compile()改为null,发现两种做法都能避免内存增长,在渲染结果方面,得到的运行结果如下:
如图,从左到右三个结果依次是未修改、修改方案一、修改方案二,其中除了"微软雅黑"和"Times",其它字体都是CFF字体,当不进行解析直接抛出错误时,应用这些字体的文字就显示成空白(图中为了看起来明显一些所以选中了文本),如果直接使解析结果返回null时,应用这些字体的文字会显示成默认字体,经过对源码的继续调试,发现这个内置的默认字体是"微软雅黑"。在实际应用中,相比内存骤增导致的性能风险,字体可能并没有那么重要,最终采用了方案二进行解决。