技术文章

使用 PDFium 组件在 Delphi 中创建连续滚动的多页 PDF 查看器

为了达到让人舒适阅读的缩放级别,单独渲染一页 A4 纸大小的内容,差不多就能挥霍掉几兆字节的 32 位位图空间。要是把这笔账乘上一份厚达 400 页的合同,那这就算不上是个抽象的数学题了:你要是胆敢一上来就把所有页面全给渲染了,那就等于是在厚着脸皮向 Windows 讨要足足超过 1 个 G 的位图内存,而用户明明只能一屏一屏地去看。其下场不是 32 位架构下应用程序的寻址空间直接被撑爆,就是程序刚启动的那头几秒钟活活卡死成植物人——全是因为 GPU 和页面解析器在那里吭哧吭哧地嚼着那些压根就没人滚过去看的页面。一个称职的连续滚动阅读器,在手感上必须得像一条由页面串成的望不到头的长长绸带,但在骨子里,它绝对不可能真的把这长长的一卷全给塞进内存里硬扛

这种表面假象与底层现实的拉扯,正是咱们这回要死磕的核心难题。PDFium 组件在 TPdfView 内部已经替你把这事儿给平了,所以你大部分的活儿,无非就是挑个合适的显示模式,再摸清这组件背地里到底在替你折腾些啥。至于那些它没法代劳的边角料——比如怎么调整页面尺寸好让阅读流淌得更顺畅,怎么在飞速滚动中保住那份丝滑感——这时候才轮到你写的那点代码出面挣表现分了。要是你还得接着凑那些外围的零碎(工具栏啊、缩略图啊、搜索框啊),那篇关于 构建功能丰富的查看器实战演练 已经把地盘给扫过了;咱们这回的焦点就死死咬住“滚动”这两个字

布局就是一种显示模式,别把它当成一堆位图硬拼的面板

只要是摸过 VCL 窗体开发的,第一本能就是抄起个 ScrollBox 然后往里头狂塞 Image 控件,一页配一个。趁早把这冲动按死在摇篮里。这种土法炼钢的套路会逼得你同时接盘页面定位、滚动轴的算术题,外加那要命的内存黑洞,而且我敢打包票,这里头的每一道题你都会重造出最蹩脚的轮子。TPdfView 早就在底层把文档当成了一串连绵不断的页面流给供起来了,并大大方方地通过它的 DisplayMode 属性把这套布局给端了出来

Pdf := TPdf.Create(Self);
PdfView := TPdfView.Create(Self);
PdfView.Parent := Self;
PdfView.Align := alClient;
PdfView.Pdf := Pdf;

PdfView.DisplayMode := dmSingleContinuous;   // one page wide, scrolls vertically

Pdf.FileName := 'contract.pdf';
Pdf.Active := True;
if not Pdf.Active then
  ShowMessage('Could not open the document');

连续滚动的全部身家性命全在这儿了。dmSingleContinuous 会把那些页面乖乖排成单列的一字长蛇阵,页面跟页面中间的缝隙它自己就给抹平了,整个视图就顺着这条长蛇阵像滑冰一样往下溜。这中间根本用不着你去给每一页拉电线配控件,也用不着你去写什么滚动事件处理函数来伺候那些普通的翻页操作。瞅见赋值后跟着的那句 Pdf.Active 校验没:打开文档这事儿是从来不会大呼小叫着抛异常的,要是碰上个烂尾楼文件或者是上了锁的,那 Active 属性就会死皮赖脸地赖在 False 上,连个能让你接住的异常都没,要是哪个不长心的查看器把这句校验给省了,那就只能守着块白板面壁思过了

这同一个属性里还揣着好几种铺开来的模式(spread modes)。dmTwoPageContinuous 会把页面肩并肩地凑成一对,两页占一行,专门伺候某些文档那种书本式的阅读体验;dmTwoPageContinuousWithCover 耍的也是同一套把戏,但它会大度地让第一页孤零零地当个封面,好让后头的铺页全都严丝合缝地踩在自然书页那“偶数-奇数”的步调上。这哥仨全都是连续滚动的命。在它们之间横跳只需一句赋值就够了,这也就意味着你日后想加个显示模式的下拉框,简直就跟闹着玩似的

只对肉眼看得见的页面下手去光栅化

这玩意儿之所以面对 400 页的大块头还能面不改色,秘诀全在那个“虚(virtual)”字上,那条长蛇阵根本就是个海市蜃楼。TPdfView 早从文档的页面树那里把每一页有多高都给摸透了,所以它压根用不着去费力气光栅化哪怕一丁点儿东西,就能算出滚轮到底能滚出多远,以及每一页该在什么坑位里蹲着。光栅化(Rasterization)——也就是那套把页面的内容指令流活活熬成像素的烧钱把戏——只有在对付那些刚好一头撞进视口(viewport)的页面,外加一点点提前量作为缓冲的边缘地带时才会开火,这样一来,等页面真滚到眼皮底下时,它早就穿戴整齐了。随着你一路往下滚,新闯进视口的页面会被拉去熬成像素,而那些被甩在身后的页面,它们身上的位图立马就会被扒下来扔掉。内存花销永远死死跟着屏幕上能塞下多少东西的量走,而不是被文档有多长给牵着鼻子走

这套逻辑绝对值得你刻在脑门上,因为它会彻底颠覆你对“花销”这两个字的认知。打开一份 400 页的文档其实是一笔贱得离谱的买卖:它只是去把骨架给过了一遍,肉压根就没碰。真正的花销是按页来算的,而且透着一股子“不见兔子不撒鹰”的懒劲儿,非得等你滚到跟前了才肯掏钱。一个刚打开时快如闪电、滚起来又如丝般顺滑的查看器,可不是因为它的工作量比别人少,而是因为它把活儿全都撒在了用户视线扫过的轨迹上,然后头也不回地把留在身后的垃圾全给扔了。实战中你能摸出的门道就是,你几乎绝对不要去干那种赶在用户前面强行把页面给渲染出来的蠢事。到底该让谁抛头露面,放权让视图自己去拿捏

先把页面的腰围勒到跟面板一样宽,剩下的缩放比例随它去吧

对于一条讲究连贯的阅读长廊来说,页面尺寸应该死死咬住面板的宽度不放,而不是被死死钉在某个绝对的缩放比例柱子上。FitMode 干的就是这活,而且哪怕你把窗体当橡皮筋一样拉来拽去,它也照样不撒手

PdfView.FitMode := pfmFitWidth;   // each page fills the column width; height follows

有了 pfmFitWidth 这个定海神针,这组件就会在视图每次变换身段时重新盘算缩放比例,这么一来,那一字长蛇阵就能永远把留给它的那点宽度给霸占得严严实实,而页面的高度,还有跟着受牵连的滚动跨度,自然也就顺理成章地跟着变了。这里头有个专门埋坑等人的地方:一旦你直接对 Zoom 动了手脚,FitMode 立马就会翻脸不认人,直接被重置回 pfmNone。人家这可是故意这么设计的,毕竟你又是手动硬拽缩放、又是想要系统全自动铺满,这就跟非要让猫去拉车一样荒唐,但这也就意味着,要是你代码里哪个犄角旮旯跑丢了一句 PdfView.Zoom := 1.0,那“自适应宽度”的功能当场就得悄无声息地暴毙,下一次窗体再怎么拉扯它都不会跟着变了。你要是既想摆个缩放控制台,又想留个自适应按钮,那就把它俩当成一碰就切的模式开关来伺候:按了这个那个就失效,谁能当家做主全看你的规矩

如果你非得弄那种看着顺眼的绝对缩放控制键,视图早就把那些用来搞自适应的缩放比例当成现成的数值端出来了,你可以直接拿去用或者挂在屏幕上:PageWidthZoom[PageNumber] 能掏出那个把这页恰好塞满宽度的缩放比例,而它隔壁那个 PageZoom 掏出的则是能把整页全给塞进去的比例。只要去读读这俩哥们儿,你就能凑齐一个“适应宽度(Fit Width)” / “适应页面(Fit Page)”的菜单,根本用不着去写死那些一旦碰上横向排版或者非主流大页面立马就全线崩盘的魔法百分比

拿渐进式渲染去换取飞速滚动中的那丝顺滑手感

默认的渲染路子是个死脑筋,非得把一整页全给画囫囵了才肯交差。你要是只画一页,那自然是风平浪静。可要是在一份密密麻麻的厚文档里玩命地 flick-scroll 拨着滚轮飞奔,这招立马就得现出原形:那些像走马灯一样从屏幕上闪过去的页面,哪怕只露了一面,也会死皮赖脸地揪住系统要求来一整套光栅化大保健,要是用户的手速飙得比页面渲染还快,那些被积压下来的渲染活儿就会堆成山,面板直接卡成幻灯片——就因为系统在那里吭哧吭哧地为了那些早就滚出屏幕的页面卖命。解药就是把渲染活儿变成一个随时能喊停的差事,只要用户前脚刚走,后脚立马把活儿给丢了

RenderPageProgressive 就是那个把活儿切成一块一块干的泥瓦匠,它每干完一块就会去瞄一眼那张喊停的通缉令(cancellation token),这样一来,哪怕是一页刚被滚出视线的页面正画到一半,也能一脚把它踢飞,而不用再死脑筋地陪它耗到底了

type
  TFormMain = class(TForm)
    // ...
  private
    FRenderCancel: IPdfCancellationTokenSource;
    procedure RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
  end;

procedure TFormMain.RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
var
  Status: TPdfProgressiveStatus;
begin
  // Cancel whatever was rendering; the old token is now signaled.
  if Assigned(FRenderCancel) then
    FRenderCancel.Cancel;
  FRenderCancel := TPdfCancellationTokenSource.New;

  Pdf.PageNumber := PageNo;
  Status := Pdf.RenderPageProgressive(Bmp, 0, 0, Bmp.Width, Bmp.Height,
    FRenderCancel.Token);

  case Status of
    prsDone:      ;                    // bitmap is complete, paint it
    prsCancelled: Exit;                // superseded, discard this result
    prsFailed:    ShowMessage('Render failed for page ' + IntToStr(PageNo));
  end;
end;

真正看门道的地方在它扔回来的返回值里。prsDone 意思是这块位图已经连油漆都刷得晶光锃亮了,赶紧拿去屏幕上显摆吧;prsCancelled 是说一个更新鲜的滚动位置已经把这页拍死在沙滩上了,所以你只管把这块半成品扔进下水道,千万别拿去现眼;prsFailed 那就是这一页结结实实地撞上了见不得人的错误。因为喊停的信号是掐着块与块的接缝点去瞄的,而不是直接拔插头,所以从你喊出 Cancel 到它真正收手不干,这中间还得忍受个几十毫秒的延迟。但这笔买卖怎么算都比任由一个早就凉透了的整页渲染活儿像块石头一样堵死队列要划算得太多。要是你往里塞个 nil 当通缉令,那它又变回那个死磕到底的倔驴了,不过对于像打印预览这种一锤子买卖、压根就没人喊停的差事来说,这反倒是歪打正着的绝配

要是你非得绕道去使唤 RenderPage 那个能变出一个活蹦乱跳的 TBitmap 出来的函数版本,那可千万别忘了,变出来的这玩意儿归你管,你得自个儿去 Free 它。要是在一个见一页就生一个位图的滚动循环圈里,你把这茬给忘了,那这个大漏勺就会随着用户滚过的每一页越捅越大,这直接就等于是把你当初为了躲避无底洞内存而死乞白赖选的连续设计理念给生生扇了个大嘴巴子。能拿着一张旧位图翻来覆去地用,就别再去生新的了

一顿操作猛如虎,你到底还剩些啥

搞定一个连续滚动的阅读器,基本上全指望这个组件去给你把活儿端上来了。你只管把 dmSingleContinuous 点成布局选项,把 pfmFitWidth 给套上去好让它能跟着窗口一块儿收腰,最后再拿 Pdf.Active 去验明正身免得碰上烂文件死得不明不白。里头唯一值得你亲自下场去写的,就是那套随时能喊停的渲染机制,毕竟,一个阅读器行不行,全看当有个混球一把将滚动条拖到一份长文档的裤裆底时,那面板是能跟上节奏,还是干脆嗝屁了。只要跨过了这道坎,后面那些什么跨页框文本啊、搜索高亮啊、书签树啊之类的,全都是些浮在水面上的花拳绣腿,它们是长在这块滚动地皮外头的一层皮,而不是塞在它里头的馅儿

本文展示的 TPdfViewDisplayMode 以及 RenderPageProgressive 这些 API,统统都是专为 Delphi 和 Lazarus 打造的 PDFium 组件 家族的一员