pdf阅读器开发

文章基于sumatrapdf的实现(当中mupdf中的内容不会太多涉及)。以及自己在此基础上做的
优化,扩展。详细效果能够參考百度阅读器精简版。




最NB的还是得属于foxit。渲染速度一流,展示大图片时非常快。




第一部分:PDF基础


第二部分:PDF功能实现


1.展示模式和坐标变换
pdf原生支持一些展示模式,在sumatrapdf的实现中又有一些展示模式,能够实现
pdf原生支持的这些模模式,并在此基础上扩展出一些展示模式。


而模式大概分为两类:
一类模式是有一个虚拟的Canvas。每个页面一行一行地排列在上面,每行可能有一个,两个,甚至
多个页面。Canvas有上下左右边距,页面间有水平和垂直的边距。这个时候,全部的页面都处于
可见状态。然后使用一个矩形框,矩形的边平行于Canvas的边,矩形框被称为Screen矩形。当中的
内容为用户可见。将Canvas的信息提供给上层,于是就能够控制ScreenRect在Canvas上移动。看当中
的pdf。当页面旋转,缩放时,Canvas的大小发生变化,这个时候通知上层Canvas发生变化。


还有一类模式是Canvas中仅仅包括有限的页面。典型的是仅仅有两个页面,用来模拟读书的效果。

这样Screen
矩形中仅仅能看到Canvas中的页面,通过点击页面,使得Canvas中包括的页面发生变化,达到切换页面的
目的。这样做能够降低Canvas排版时的开销。




在后面,仅仅讨论第一类模式下的pdf展示。




页面在Canvas上占领一个矩形。这个矩形称为Device。在页面内部。有一个坐标系。称
之为User坐标系,该坐标系在pdf文件内部使用。一个User坐标系中的点能够变换到Device矩形中。
当中Device的左上角为原点。

而Device中的点能够变换到Canvas中,以Canvas的左上角为原点。
相同的,以Screen矩形的左上角为原点。点的坐标又发生变化。更进一步。Screen相对于窗体的位置
知道,还能够计算出点在窗体中的坐标(一般而言Screen在窗体中铺满)。这样,能够通过
鼠标位置计算出来pdf内部的元素,进而实现一些功能。




在sumatrapdf中提供了一些基础的变换工具,通过一个A矩阵。
A = [a b 0;c d 0;e f 1]
来描写叙述变化。同一时候在高层实现时还提供了Device,Canvas等坐标系之间的高层次抽象的变换工具,其
实现是用较底层的实现的变换,比方:fz_concat,fz_translate。fz_scale等。

。。




2.主要的pdf展示
pdf展示能够按以下的层次组织api:


最底层应该是"Canvas布局",通过PageInfo数组来表示,PageInfo中记录了Page在Canvas中的全部信息。




接着一个层次称为"可见区域":给出了Screen在Canvas中的位置,以及Screen本身的大小,PageInfo中
含有很多其它的信息。比方可见部分的比例(用于计算),页面在Screen中的位置,第一个可见页面和最
后一个可见页面(自己加的用于优化)


在"可见区域"上是"渲染请求"。用于向渲染器请求開始渲染页面。


再接着就是"导航":上一页,下一页,最前一页,最后一页,缩放,滚动,旋转。
"导航"层依赖于其它层次,适当的时候发起渲染请求,窗体重绘请求。


在须要绘制时依据当前绘制信息("可见区域"层计算出来的东西)。从页面图像缓存中取出图像。
然后绘制。通常会先绘制Canvas背景,页面背景(比方阴影效果,书页效果),然后再是页面内容。




这样,整个展示逻辑比較清楚了(在某些导航下可能一些中间步骤不必要),分为两条线:


导航->布局->计算可见区域->发起渲染请求->发起重绘请求
接收渲染请求->渲染->缓存渲染结果->按需展示渲染结果


3.渲染器
在底层库的基础上,渲染器提供三个不同抽象层次的api:
runPage。renderPage,RenderBitmap
当中runPage是基础将pdf_page对象展示到fz_device中。能够控制剪裁矩阵,变换矩阵等。


在mupdf中,有称为display_list的设备。将page展示到这个设备的时候。会生成一个list,将
该list缓存起来后。能够通过fz_execute_display_list来加速渲染。


将pdf的内容视为源码,在解析pdf后形成的一些内部对象视为字节码,生成display_list时就
相当于把字节码翻译为机器码。


最基本fz_device的莫过于画图,当page对象展示到device中的时候就生成相应位图。利用device
这个抽象,还能够在展示时提取文字。提取图片(后面会讲)。计算页面内容占的大小。




renderPage在runPage之上。能够将page渲染到HDC上。


RenderBitmap会调用renderPage或runPage生成位图。

觉得在某些情况下使用gdi+有优势。


另外,还有两个细节:
一个是页面分块,当页面太大的时候,会控制渲染粒度。
还有一方面在将图像展示到窗体时,可能出现缓冲未命中。这个时候须要通过返回码告诉上层。同一时候
还能够计算出预计的渲染完毕时间,让上层在完毕时再次Paint。




4.实现文本。图片选择
引入一个文本选择逻辑的类:
第一类选择方法会给出某个起点和当前点,这样内部通过计算两个点所在的glyph,然后把两个
glyph之间的glyph选中。

选中结果被描写叙述为页面和矩形的列表。表示在页面上有一个矩形是选中的。




第二类选择方法会给出两个页面,然后选中页面中的全部glyph。


构造器在上述保存一个绝对的选中结果的同一时候,须要提供方法。输入当前的Screen位置。返回一些
须要在当前Screen上绘制的矩形。


然后还得提供一个推断方法,表示当前鼠标是否在某个glyph上,以便于上层推断鼠标是否是在文字上
(这里的glyph都是文字)。

落在的文字能够是选中的也能够是未选中的。推断在选中的文字上用于
右键弹出菜单提示复杂文本,推断在未选中的文字上用于改变鼠标形状,发起文本选择的拖动。


此外。还得有个方法取出选中的文本。




文本选择器是能够优化的,主要是在全部选中时,这个时候维护的数据结构量大,影响效率。能够配合
pdf模块。在全选状态下。仅仅生成当前可见页面的选择数据。当然,在一些用户行为下须要将全选状态
清除掉。




图片选择同理,仅仅是内部关心的glyph变成了图片,并且图片选择器和文本选择器须要协同工作(在后
面还会提到文本搜索,这个逻辑也应该和图片选择文本选择协同工作)。




如今还有个问题。怎样导出pdf中的图片。


首先依据当前页面(假设有多个页面须要知道鼠标位置所在的页面),拿到一个图像信息列表。接着
依据当前鼠标位置按一定策略计算出选中的图像。

于是就得到选中信息了。

另外还要提供获取图像数
据的接口。(这里不考虑按住ctrl选中多个图片。由于永远在当前页面操作)。


怎样获取图像信息列表,怎样获取当前图像呢?


前文提到能够将页面展示在某个fz_device上,我们能够新建一个device。
图像device须要实现fill_image成员,这样在runPage的时候在遇到图像会调用fill_image。




device在工作模式为获取图像列表的时候,每调用一次则为图像分配ID,记录图像位置。


device在工作模式为获取图像数据时,须要知道相应的图像ID,这样在展示在该device时每调用
一次还是分配一次ID,直到ID和目标ID相同,这时将数据保存下来。




最后。选择结果的显示应该是在OnPaint时,在绘制完当前Screen内容后。再合成上去的。似乎不能
原生地在渲染时也绘制选择结果。


5.实现文字搜索
一个任务队列就可以实现。每个任务就是一个搜索请求,任务过程中不断向主线程发进度消息。
搜索模块在主线程中收到进度消息时从搜索任务中取结果(注意搜索结果是多线程訪问的)。
在收到进度消息时,假设原有的结果选择为空。则导航到搜索结果页面,展示时会显示这个搜索结果。
同一时候有必要向外通知当前的选中的搜索结果发生变化。

另外还应该向外界通知搜索进度发生变化。




假设有连续的多个搜索请求,仅仅须要把前一个任务停止(搜索任务要能即时停止,仅仅须要在任务中加
一个事件。须要停止时地主线程中激发这个事件),然后再加一个新任务。


6.实现pdf朗读
首先要求pdf中是有文本的,而朗读的实现MS有提供:SAPI。

从前面的讨论能够知道,能知道当前页面
中的文本,于是就能朗读。假设SAPI能够回调当前朗读位置,则能够实现页面同步滚动。假设不能
回调当前朗读位置,也能够通过每次增加一小段须要朗读文本的方法,实现按文本段落同步滚动。甚至
玩得花哨一点,还能够把当前朗读文本高亮起来。


第三部分 pdf优化


7.1首次展示优化
7.1.1 明白什么时候pdf開始绘制
在展示pdf时有非常多非常多配置项,最好要求上层有一个统一的初始化,在初始化完毕后就能够開始渲染。


比方。影响pdf展示的有ScreenRect大小。起始页面,背景图(颜色),边距信息等。




要注意两个点。一个点是什么时候開始渲染,最好是有明白的接口。在接口调用前pdf处于一个初始
化的状态。依据上层调用来初始化配置。

在接口调用后就開始渲染pdf。

还有一个点是上层不要频繁变
化配置,否则会导致上次渲染结果失效。

比方上层在通知pdf開始展示后再把历史记录中的上次位置
应用到pdf上,比方显示的窗体(影响ScreenRect)发生变化。


7.1.2主动触发重绘
在首次渲染完毕后能够通过自己定义消息强制重绘。不必等上层等到Timer再触发绘制。


7.1.3 outline载入
pdf_load_outline这函数没有必要在pdf载入时调用。等须要时再调用。




7.1.4 字体载入
create_system_font_list会扫描一下系统的字体,然后得到某个数据结构。大概会扫描几百兆文件,
文件数量也非常多。扫描过程中会在文件里跳着读一些信息。RP好的时候非常快。和系统及磁盘的缓存
机制有关。RP差的时候可能得十几秒,无法忍受。

所以。这里的数据能够自己缓存起来。




另外mupdf中还有一些宏。控制着一些内建字体数据,能够把这些数据丢掉,以降低pdf模块大小。可是
可能会造成少量的pdf文件乱码。


7.1.5 图片背景颜色识别
大图片渲染慢。我们能够展示和页面背景类似的颜色,这样,在展示时会先显示背景色,过一会儿
再展示内容,那么这种闪烁较小。怎样识别背景颜色呢?在页面上先几个矩形区域,计算主元素
得到的值能够觉得就是当前页面的颜色。而依据已经渲染过的页面的颜色能够预測没有渲染过的页面
的颜色。而渲染过的页面颜色能够记录在内存中。

当然,不同页面的背景色不同,或者没有背景色(
背景有非常多颜色,可是不存在某种颜色占优势),识别了也没实用。


7.2 选择绘制优化
选择就是给出一堆矩形。然后绘制出矩形的并。

由于不能原生地绘制选择效果。所以是在pdf渲染完毕后。
后期做AlphaBlend。gdi+能够依据region来绘制。只是实际效果不太好,或者是有些參数没有设置正确。


绘制矩形并的问题是。同一块区域两次AlphaBlend。和一次AlphaBlend的效果不同。所以必须保证一个区域
仅仅做一次AlphaBlend。

一方案是先把矩形集合绘制到一个图上,然后两张图做AlphaBlend。还有一个方案是先
计算出矩形的并。然后分别单独绘制这些矩形。计算矩形并不是常简单,扫描线算法。在y方向上做离散化。然后
在x方向上扫描。


7.3 多渲染模式
回顾前面说的展示pdf的接口的层次,"布局"。"可见区域","渲染请求","导航"。我们从"渲染请求"
这层入手引入多渲染模式。

这里"渲染请求"简单地说仅仅是渲染一些页面。渲染器会渲染并缓存起来(有
可能的话页面分分块),等待展示时再显示出来,假设在展示时发现丢失则自己主动发起请求。考虑这样一
个情形,拖动滚动栏:pdf模块在收到请求后,依据当前位置发起渲染请求。

然后收到OnPaint消息后进
行绘制,绘制时和收到请求时有一个时间差,在这段时间内,可能收到新的请求,当前的Screen位置已
经发生变化,于是。绘制失败。仅仅显示背景色。所以须要引入新的渲染模式。




上面提到的已有的渲染缓存绘制的图像可能比較多,由于是按页分块绘制。假定一个页就是一块,那么
在同一个Screen中能看到两个页的时候,就须要绘制两页。

假设页面有分块,比方一个页面分为4块。
会使得绘制效率有所提升,额外的渲染少一点。

自然而然,能够引入一个渲染模式,仅仅绘制当前Screen
中的部分。考虑仅仅绘制Screen的内容的特殊性,我们将渲染请求队列的限制大小为1,也就是说绘制的内
容永远是最后一次请求时应该显示的画面。尽管有这种渲染结果,可是我们无法决定显示哪个结果。
所以还须要引入一个状态控制变量,控制变量会控制在OnPaint时选择哪个结果缓存中的内容(这个变量并
不控制哪种渲染模式工作,哪种渲染模式不工作,而是负责控制取哪个渲染模式的结果,从后面的分析
能够看到。两个渲染模式能够都工作。并行的)。将两种渲染请求分别种为"普通渲染请求","Screen渲染
请求"。渲染结果称为"普通渲染缓存","Screen渲染缓存"。


当拖动的时候。将状态切换为显示Screen缓存中的内容。这个时候显示的最大特点就是和OnPaint时的
Screen的位置无关,显示是强制的。假设全部是拖动请求,那么显示的将是拖动过程中遇到的全部画面的
子集,显示的页面越多,说明渲染速度越快,达到了尽可能向用户呈现结果的目的。假设在老的渲染模式
中主动丢一些帧。可是也不能达到这种效果。显示时和渲染时的时间差是硬伤,所以引入一个状态控制
变量,表示显示的东西和当前的Screen位置无关。




"渲染请求"层的API依据当前的展示模式,发起不同的渲染请求。

在发起Screen渲染请求时,要注意请求的
设计中要能描写叙述当前页面的全部状态。一般包括,显示的页面信息。文字选中信息,图像选中信息,搜索
结果显示。同一时候在发起Screen渲染请求时。能够顺便再发一个普通的渲染请求,尽量保证在切换到展示"普
通渲染结果"时有结果。不会出现白屏。而在发起普通渲染请求时能够把当前页面附近的放在前面,然后顺
便放一些当前可见页面前后的页面渲染请求。




还须要提供放弃"Screen渲染缓存"的API。由于有的时候须要放弃,见后面分析。


可是引入两种模式会带来新的问题:


问题1. 怎样实现两种模式的渲染?
两种渲染能够在同一个线程。可是问题有两种请求时怎样决定先渲染谁,一定是Screen请求吗?这个难以
回答,所以开个线程中,两个线程一起干活。

非常不幸。pdf的渲染内核不是线程安全的。于是就在上面加
个锁吧。渲染本质是单线程的。可是通过系统来决定渲染谁,系统锁的算法,两个线程的工作状态将影响
谁先渲染。当年。就姑且这样干了,如今回忆起来。还有方案的:多进程渲染。渲染进程分两种,一种是
传统的渲染方法。还有一种是按Screen渲染的方法。传统渲染的能够开多个进程,有可能的话。在主进程还
有个渲染线程。而Screen渲染的按其定义应该仅仅开一个进程。

这样,渲染模块在处理Screen渲染上没大变化,
而传统渲染涉及将请求分布到渲染线程,渲染进程上。在显示时,要知道渲染线程。渲染进程的缓存有哪
些,然后绘制,有可能的话。再次派发请求。

不折腾的话。就留一个渲染线程,开一个Screen渲染进程足
矣。终于策略应该取决于性能分析结果。


于是。实现问题搞定,使用两个线程假并行。




问题2. 怎样实现两种模式的无缝切换?
显示。Screen渲染不是万能的,两种模式各有优点。两种展示模式之间可能切换。而当中的一类问题是。
从展示Screen渲染缓存切换到展示普通渲染缓存时,普通渲染缓存不命中。
上面已经提到,在"渲染请求"时。在发起"Screen渲染请求"时也顺便有普通的渲染请求。这能对问题的
解决起促进作用。

还有一点是能够在Screen渲染完毕时。发出一个"假Paint消息",收到这个消息时,渲染
器负责把当前的Screen展示到NULL的dc上。其作用是更新缓存。




当然。上面两个策略能尽量降低Screen渲染展示到普通渲染展示时的白屏现象。不能彻底解决。




从普通渲染缓存展示切换到Screen渲染缓存展示会有什么问题呢?
假设切换到展示Screen渲染缓存时。已经有缓存结果了,在新的Screen没有渲染出来时,收到OnPaint消息,
于是旧的结果就被展示,呈现出一些古怪的,令人啼笑皆非的现象。

这个问题非常好解决,提供一个放弃
"Screen渲染缓存"的API,仅仅要切换到Screen渲染缓存。则要事先运行一次放弃缓存的逻辑。

当然,有可能
在放弃后,又有新的渲染结果被填进去,这个不用考虑。




在Screen模式不变的情况下也可能出现故障:
比方跳到第5页。使用展示Screen渲染缓存的模式。然后再跳到第7页。

这个时候也应该删除一次Screen渲染
缓存。

当然不是说有的Screen模式不变的情况下都要删除上一次的缓存,比方前面说的,拖动。拖动就是要
利用上一次渲染的结果,使得拖动时不会太难看。


在引入新的模式后。在拖动时会切换展示模式,可是不是全部的文档都须要切啊。假设渲染速度非常快,我们
就不切切。

这个非常easy,依据已有的渲染结果预測渲染速度。依据速度来决定展示模式的切换策略。




关于多渲染模式的很多其它思考:这种模式能应用于很多其它的文档展示。

能在模式中增加第三个模式,可是之间
同步的复杂度会更高。

进一步思考能够知道。在做一件事的时候能够多策略结合。相互补充。


7.4 图像显示


大图像显示是个难题。记得mupdf在读完图像流的时候会直接解码为位图。

能够尝试直接把压缩的图像保存起
来。等到终于展示的时候再显示。

可是整个显示过程过于复杂,各种变换,因此也仅仅能在显示前图像解码。


整个过程仅仅是把解码时间推迟了。

这样做有个优点。在图像缓存时内存占用少。(另外sumatrapdf应该在
pdf_image.c中载入图像的函数中,限制缓存图像的大小。否则展示一些大量图像构成的pdf时会内存不够)。


后来也考虑过intel性能元件库,ffmpeg中某些实现,只是效果都不理想。




猜想,要解决问题须要从整个pdf的渲染框架出发。

猜你喜欢

转载自www.cnblogs.com/mqxnongmin/p/10734936.html