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模式不變的情況下都要刪除上一次的緩存,比如前面說的,拖動。拖動就是要
利用上一次渲染的結果,使得拖動時不會太難看。


在引入新的模式後,在拖動時會切換展示模式,但是不是所有的文檔都需要切啊,如果渲染速度很快,我們
就不切切。這個很簡單,根據已有的渲染結果預測渲染速度,根據速度來決定展示模式的切換策略。


關於多渲染模式的更多思考:這樣的模式能應用於更多的文檔展示。能在模式中加入第三個模式,但是之間
同步的複雜度會更高。進一步思考可以知道,在做一件事的時候可以多策略結合,相互補充。


7.4 圖像顯示


大圖像顯示是個難題。記得mupdf在讀完圖像流的時候會直接解碼爲位圖。可以嘗試直接把壓縮的圖像保存起
來,等到最終展示的時候再顯示。但是整個顯示過程過於複雜,各種變換,因此也只能在顯示前圖像解碼。
整個過程只是把解碼時間推遲了。這樣做有個好處,在圖像緩存時內存佔用少。(另外sumatrapdf應該在
pdf_image.c中加載圖像的函數中,限制緩存圖像的大小,否則展示一些大量圖像構成的pdf時會內存不夠)。


後來也考慮過intel性能元件庫,ffmpeg中某些實現,不過效果都不理想。


猜想,要解決這個問題需要從整個pdf的渲染框架出發。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章