pdf.js优化:自定义文本层

背景

pdf.js是前端实现pdf阅读器时最常用的js库,它能解析pdf文件,并用canvas或svg的方式渲染出来。在一个项目中,我同样使用了pdf.js来实现pdf阅读器,但项目上线后,不少用户反馈选中体验不够友好,拖出文本范围就很容易出现选中全文的情况。经过测试,我发现这是浏览器的行为导致的。

如图,有一个包含了4个span标签的容器,当用户从span1的第一个字符开始选中,拖动到span3最后一个字符时,选中的内容是span1、span2、span3包含的所有文字,这是符合我们预期的。但是,当用户从span1的第一个字符开始选中,拖动到span1和span2之间的空白区域时,容器内的所有文字都会被选中,这是因为当前选区的起始点在span1内,而终点在父级div上,这时浏览器会将div的子元素列表中,排在span1后面的元素全部包含在选区内。在pdf.js渲染的pdf中,所有文字都是包裹在同一个容器内的,所以选中文本时,鼠标拖出文本区域会导致整页文字都被选中的情况。

文本选中示意图

面对这个问题,我尝试了设置css的user-select属性,但没有得到想要的效果,经过一番调研,我发现readpaper.com在这方面做得很不错,在参考了他们的解决方案后,我对pdf.js的文本层进行了重写,在优化选中操作的同时提高了渲染性能。下面我会简单分享一下这个方案的主要思路。

pdf.js的渲染逻辑

要对一个模块进行重写,首先需要了解它的内部原理。在pdf.js中,从viewer根据url取得文件,到渲染出完整的pdf页面,有如下的流程:

pdf渲染的过程

pdf文件会由pdf.worker.js进行解析,解析完成后会渲染三层DOM:

  1. canvasLayer是一个canvas元素,它绘制了pdf页面内所有我们能看见的内容;

  2. notationLayer是一些透明的dom,它与canvasLayer上的高亮标注一一对应,点击时会弹出一个弹窗展示标注的信息;

  3. textLayer(文本层)是一层透明的文字,它与canvasLayer上绘制的文字一一对应,其作用就是让用户可以选中、复制文字,也是我们这次优化的关键。pdf.worker.js解析到的文字信息以对象(textContent)或流(textContenStream)的形式传递给一个TextLayerBuilder类实例,它利用这些数据创建textLayer。

接下来我们看看textLayer具体是怎么创建的。我在项目中使用了pdf.js的PDFPageView类来实现pdf的展示,一个PDFPageView实例会负责pdf文档中一页的展示。如下图,在实例化该类时,构造函数会接收一个TextLayerFactory的实例对象作为参数,它会调用该实例对象的createTextLayerBuilder方法,以创建textLayer。

创建阅读器对象

PDFPageViewer源码中创建textLayer的部分

pdf.js提供了一个默认的TextLayerFactory类,源码如下。这个类也很简单,只是实现了一个函数,返回一个TextLayerBuilder实例。

默认的TextLayerFactory

TextLayerBuilder类的内容较多,核心是render方法,它主要做了三件事:创建文本层的DOM容器,调用渲染函数,渲染完成后通过eventbus发送消息。再进一步去看它调用的渲染函数,发现是创建一个TextLayerRenderTask类实例,然后调用_render方法,这个_render方法主要是读取textContent或textContentStream中的数据,经过解析后在container(即TextLayerBuilder创建的DOM容器)中渲染文本。

文本层DOM渲染函数

这样一来,整个渲染流程就比较清晰了,相关的类和DOM之间的关系大致可以表示成下图:

文本层渲染流程

实现思路

pdf.js的文本层渲染流程可以简单总结为,将textContent(或textContentStream)中的数据转化成DOM。要准确地渲染DOM,至少需要知道大小、位置等信息,所以我们推测textContent中,包含有文字的大小、位置等信息。如果我们知道每个文字的位置和大小的话,事实上并不需要渲染DOM,只需要捕捉鼠标事件,就能计算出当前鼠标在哪个文字上,用户进行拖动操作时,也能计算出选中了哪些文字,至于选中时的高亮效果,可以利用canvas在pdf上方绘制。这个方案可以完全避免意外选中一大堆文字的情况,也不需要考虑浏览器最小字号的限制,还能减少DOM操作,可谓一石三鸟。

textContent数据结构分析

textContent是我们的数据来源,通过修改TextLayerRenderTask类的源码,可以输出它的数据结构:

textContent的数据结构

它是一个很大的数组,其中的每一项是一行文本的信息,包括文本方向(dir),字体(fontName),结尾是否带换行(hasEOL),宽高(width, height),文本(str)和变换矩阵(transform)。其中变换矩阵与css中transform: matrix(a, b, c, d, tx, ty)类似,表示一个以当前pdf页的左上角为原点的线性变换,且仅包含平移、旋转、缩放三种变换,其中transform[0]到transform[5],分别与a, b, c, d, tx, ty对应。

在大多数pdf文档中,文字都不会出现旋转的情况,此时,transform[1]和transform[2]为0,如果以当前pdf页面的左上角为原点,y轴向下,x轴向右建立坐标系,(transform[4], transform[5])则是该行文本的基准线(baseline)最左侧的坐标。在一行文本中,基准线同英语字母X的下沿端对齐,相当于英语四线三格本子中的第三条线,要计算基准线到文本顶部的距离,可以单独创建一个canvas,按照当前文本的字体和字号,渲染一个"A",然后使用CanvasRenderingContext2D.measureText()这一API获取,具体可以参考MDN文档developer.mozilla.org/en-US/docs/...。计算出基准线到顶部的距离,配合width和height的值,我们就可以得到这一行文字的具体大小和位置了,从而可以判断当前鼠标是不是处于该行文本内。

无旋转文本的坐标

当文字出现旋转时,文字的坐标不变,只是多了一个旋转角度,假设是顺时针旋转角度α,要判断鼠标位置是否在旋转后的文字上,可以将鼠标的坐标饶文字左上角逆时针旋转角度α,看是否落在旋转之前的文字上。那这个角度怎么得到呢?假设当前缩放倍数是λ,那么,transform[0] = cosα * λ,transform[1] = sinα * λ,从而我们可以反推出,α = arctan(transform[1] / transform[0])。

带旋转文本的坐标及角度

比较遗憾的是,textContent中并不包含每一个字符的大小和位置,我们只能利用它判断当前鼠标是否在某一行文本内,而无法判断它在哪一个字符上。为了确定每个字符的位置,还需要对pdf.js的源码进行一些改动。

确定每个字符的位置

pdf.worker.js是pdf.js中用于解析pdf文档的脚本,位于目录pdfjs-dist/build下,其中文本层的解析主要由PartialEvaluator类完成,生成上一节所提到的textContent的核心是PartialEvaluator.getTextContent方法,该方法内定义了变量textContentItem,该变量最终会被push到textContent数组中,成为文本层的数据来源。而getTextContent方法中定义的函数buildTextContentItem则负责确定了textContentItem中多个重要字段的值,包括transform、宽高等。好消息是,buildTextContentItem做的主要工作,就是将一行文字中,一个个字符的大小和位置数据,整合成整行文字的数据,我们只需要将该函数里面一个个字符的数据都传出去,就可以得到每个字符的信息了!源码及核心修改如下:

buildTextContentItem源码

主要修改内容(将每个字符的坐标等信息传出去)

修改完之后,让我们看看得到的字符信息:

单个字符的信息

需要注意的是,受到源码的限制,这里的transform不是字符的基准线左侧的坐标,而是右侧。但有这个数据,就已经足够定位到每个字符了。我们可以整理出选中功能的实现思路:

封装方案

捋顺了选中功能的实现思路之后,我再介绍一下我整体的封装实现方案。

依照pdf.js的渲染逻辑,我们需要自己实现一个TextLayerFactory类,传给PDFPageView的构造函数,该类需要实现一个createTextLayerBuilder方法,该方法返回我们自定义的TextLayerBuilder实例。

自定义的TextLayerFactory类

TextLayerBuilder类因为内容较多,我们需要重写的又只有render方法,因此可以使用继承的方式实现。创建一个继承自pdf.js提供的TextLayerBuilder的类,并重新定义render方法,以及新增一系列我们需要的新方法。在render函数中,同样是做三件事:创建textLayer的DOM,解析textContent中的数据,完成回调。与源码不同的是,我们只需要创建一个canvas就可以完成DOM渲染了,对于textContent中的数据,只需要简单调整一下数据结构方便后续使用,然后存起来即可。

重写后的render方法

这里还定义了一个TextLayerParseTask类专门进行数据解析,它的parse方法会读取并解析数据,这里textContent和textContentStream是只会存在一个的,其中textContent是普通数据,可以直接解析,textContentStream则是流式数据,需要用reader进行读取。解析数据时调用的是buildTextContentBounds方法,该方法的主要是将数据变成我们喜欢的模样,上面已经介绍过textContent的数据结构和一些重要数据的计算方式了,在这里就不再赘述。

解析数据用的parse方法

最后,我定义了一个TextLayerSelector类,处理选中相关的逻辑,通过监听鼠标事件判断用户的选中操作,将选中的起点和终点实时传给TextLayerBuilder类实例,由TextLayerBuilder绘制选中效果,并通过eventBus将选中的事件传递到阅读器外层,触发弹出菜单等操作。整体结构大致如下:

自定义文本层的结构

小结

这次对pdf.js文本层的优化很好地解决了选中pdf内容时意外选中大片文字的问题,同时克服了浏览器最小字号的限制,无论多小的字号,都能实现精准的选中效果。纵观整个解决方案,我认为最大的难点在于确定字符的位置,这部分需要对pdf.js的源码进行较为深入的挖掘,我在翻阅源码和分析各个数据的含义上花费了许多时间。解决了这个问题之后,后续的实现思路基本就是在源码的渲染流程上舔砖加瓦。另外,我认为这种利用js判断选中内容的思路不仅仅局限于pdf文档展示的场景,在涉及到定制选中操作的场景中都可以使用。譬如在一些不希望用户复制内容的页面中,同样可以将文字渲染在canvas上,然后自定义一套选中的逻辑等。

相关推荐
WebInfra18 分钟前
Rspack 1.3 发布:内存大幅优化,生态加速发展
前端·javascript·github
zoahxmy092933 分钟前
Canvas 实现单指拖动、双指拖动和双指缩放
前端·javascript
花花鱼33 分钟前
vue3 动态组件 实例的说明,及相关的代码的优化
前端·javascript·vue.js
Riesenzahn35 分钟前
CSS的伪类和伪对象有什么不同?
前端·javascript
Riesenzahn35 分钟前
请描述下null和undefined的区别是什么?这两者分别运用在什么场景?
前端·javascript
__不想说话__36 分钟前
前端视角下的AI应用:技术融合与工程实践指南
前端·javascript·aigc
niusir36 分钟前
使用 useCallback 和 useMemo 进行 React 性能优化
前端·javascript·react.js
六月的可乐1 小时前
【干货】前端实现文件保存总结
前端·javascript·面试
mCell1 小时前
每秒打印一个数字:从简单到晦涩的多种实现
前端·javascript·面试
Carlos_sam1 小时前
OpenLayers:如何使用渐变色
前端·javascript