项目背景
最近收到了客户的一个订单——他们想要在自己的系统里面增加一套基于文档的审批流程。 审批流程的逻辑是这样的:首先被审批人会发起审批流,并上传相关的Word或PDF文档。这个审批流在进入审批状态后,将会被一个或多个审批人审批。审批人可以在被审批人上传的PDF文档上增加、删除、修改、查看、移动、定位各种批注信息。并且对于不同的批注,不同的审批人具有不同的操作权限。
如果要想实现这个功能,就必须要把PDF的展示和批注管理这一块给实现好。这个也确实是一个比较有挑战的点。研发团队计划打算通过引入pdf.js来完成。但是pdf.js本身提供的功能是面向页面展示这一块的,除了展示相关的功能之外,只提供了较少的批注功能,并且没有直接操作批注的API。也只留下了较少扩展的接口让我们将自己的代码加入到pdf.js的流程当中去。这让我们的系统要想能将pdf.js集成进去也比较困难。因此我们需要对pdf.js进行一系列的改造,进而将pdf.js完全的融入到业务系统当中去,并且能够给使用者一个完美的功能体验。
我们对pdf.js及其阅读器的改造
为了实现上面提到的功能和目标,我们在pdf.js-4.0.379版本的源码的基础上做了较多的改动和也添加了不少新代码。虽然我们的改动也涉及了比较多的内容,但是大体上可以通过两个视角来看待这些内容。 一个是功能的视角,一个是代码的视角。从功能的视角来看,我们做出的改动和新增的功能有:
- 批注分离功能。通过改造批注相关的类、函数和生成逻辑,添加了一系列的API,让使用者直接高效的操作批注。
- 新增四类批注——框选、下划线、删除、指向箭头。
- 根据实际使用情况,对现有批注进行改进,增加一些新的信息,如高亮时的文本信息等,并对原来的批注做一定的改动。
- 新增一些参数供用户自由的操作pdf.js。包括指定加载页数参数:loadPageIndex。控制批注操作权限:permitToEdit。控制分片大小rangeChunkSize。
- 初始化批注功能,在页面加载完毕后,自动将历史的批注从后台读取并渲染出来。
- 高效批注分片加载,PDF的Catalog组织情况、PDF对象引用结构调整以及解决跨域问题。
- 给批注控制增加权限管理,业务系统可以通过权限字段控制批注是否可编辑。
从程序的角度来看,改造有:
- 管理批注生命周期,在批注创建、初始化、修改、删除等时刻增加钩子,供调用者加入业务逻辑。
- 管理页面加载周期,并在页面开放周期过程中,添加钩子,供用户植入业务逻辑。如文档加载完后的渲染。
基于我们改造后的阅读器,还可以开发一些更多的功能,如:
- 同时开启多个阅读器,进行多文档比对。
- 控制历史批注,按版本支持对批注的展示。
在此博客和后续的博客中,我们会持续的讲述原理、分享源码并展示实际的效果。
改造后的代码已经上传至gitee。地址如下:
下面是我们实现的一些效果:
批注分离(添加API让开发者能够通过代码控制批注):
下划线与删除线批注(新增加的下划线与删除线批注):
分页加载(按需加载页面,对于无需渲染的页面不加载):
pdf.js简介
官网的介绍是相对简单的,他们这样介绍pdf.js: “pdf.js是一个基于HTML5开发的PDF阅读器。pdf.js是一个由Mozilla支持、社区驱动的项目。我们的目标是基于Web标准创建一个能够渲染、解析通用PDF的平台。”
通过pdf.js这个介绍,我们可以知道pdf.js的从功能上讲就是一个PDF阅读器。主要的功能就是展示PDF,至于修改PDF之类的功能是涉及的还是比较少的。作为一个PDF的阅读器,pdf.js已经默认加入到了Firefox的功能里去了。且VSCode中的PDF阅读器插件也是基于pdf.js开发的。pdf.js的阅读器由两部分组成,一部分是提供基础功能的源码,这个源码在src目录下。另一部分是一个具体的、可用的阅读器,在web目录下。后续我们会对这二者都进行一定程度的改动。后续的内容中我们用PDF阅读器来指代它们。
而我们为优化PDF展示和批注管理所做的开发,基本是通过改造PDF阅读器来实现的。有一部分修改PDF目录结构是通过Java的PDFBox库实现的。
话不多说,直接进入主题,接下来我们将通过一步一步的分析,来向读者展示我们如何实现对pdf.js改造的。
逐步深入pdf.js源码
在开始实现我们的目标之前,我们对pdf.js是几乎一无所知的。pdf.js相关文档比较少,这给我们对pdf.js的研究带来了一定的困难。因此我们决定直接从源码下手,通过分析代码目录和结构、调试pdf.js启动流程,从而来对pdf.js有一个全面且有一定深度的了解。
源码结构与pdf.js启动加载PDF流程
首先,我们看一下pdf.js的源码目录(即src目录)结构,里面的类和js文件很多。但是我们只需要关注其中的两个目录以及其下一些对阅读器逻辑影响比较大的文件即可。我们要关注的两个目录分别是core目录和display目录。
└── src
├── core
│ ├── ......
│ ├── catalog.js
│ ├── document.js
│ ├── ......
├── display
│ ├── ....
│ ├── draw_layer.js
│ ├── editor
│ │ ├── .......
│ │ ├── annotation_editor_layer.js
│ │ ├── editor.js
│ │ ├── freetext.js
│ │ ├── ......
core目录下存放的是整个pdf.js最底层、最基础的js文件。该目录下的文件直接操作和解析PDF文件本身。通过读取PDF文件,该目录下的代码既能够解析出如PDF文档的Catalog(目录)、PageTree(页面树)、XRef(交叉引用表)之类的元信息,也能够解析出每一个PDF Page(页面)的数据以及该页面中引用对象的信息。PDF的元数据信息及其组织形式,是决定PDF是否能够有效分片加载的关键。在后续的实践过程中,我们发现通过不同工具生成的PDF,其具备的分片加载的能力是不同的(这一点我们会在后续的博客中会详细的介绍)。有的能够高效分片加载,有的则完全不能够分片加载。除此之外,当阅读器对PDF的解析出现问题的时候,我们就需要对这一层的代码进行调试,才能更加有效的定位到问题。不过这一层的代码我们主要是调试用,至于改动,则是非常少的。我们在这个目录下修改的基本上只是一些参数信息而不包含解析PDF相关的逻辑信息。
display目录代表的是展示层。展示层位于核心层之上,展示层使用核心层的API来获取文档和页面的数据并渲染到HTML页面上去。其代码包含的主要功能有通过读取PDF文件流、构建PDF展示结构、生成相关批注、提供一系列操作PDF相关的API等。这一层是我们要改造的重点。display目录的editor子目录存放了一系列批注的实现代码和控制代码。pdf.js自带的批注有绘图、图像、文本、高亮。其中高亮是新添加的,按官方的说法应该还不完善,在生产环境下默认不打开,需要手动调整参数才能打开。对于editor子目录,我们做了大量的改动,新增了不少内容。
上面提到的两根部分是pdf.js本身处理PDF文档信息和展示的源代码。在这些源代码的基础上,pdf.js又开发了一个优秀且完整的PDF阅读器。这个阅读器我们前面已经提到过了,它主要的实现代码不在src目录下,而是在web目录下面。其主要结构如下:
└── web
├── ......
├── viewer.css
├── viewer.html
├── viewer.js
├── ......
web目录下,最主要的就是viewer.html、viewer.js、viewer.css以及与他们相关的文件。这些文件也是我们改造的重点,通过改造这些文件,我们可以有效的将pdf.js作为一个PDF阅读器嵌入到现有的系统当中去。并且可以根据实际业务需要,添加更多定制化的功能。
pdf.js阅读器启动过程
pdf.js的阅读器的启动是整个pdf.js最复杂的部分。熟悉了整个pdf.js阅读器的启动流程,对我们改造PDF.js阅读器有很大帮助。但是但是想要将整个PDF.js阅读器的启动流程给弄得清楚明白,确实是一个不小的挑战。
pdf.js里面有着大量的异步操作,也大量的使用了Promise来处理各种逻辑。这些特性给调试带来了一些难度。不过这些问题我们还们还能hold住。在这里我们介绍pdf.js阅读器的启动过程以及启动中主要涉及到的对我们来说比较关键的点。
我们若想调试好pdf.js的阅读器的启动流程,需要跨越很多层的代码。不过无论代码是怎样的巨大和庞杂,终究也还是要从第一行代码开始执行。因此我们就从第一行代码开始、进行调试。
pdf.js的第一行代码,始于viewer.js。首先它先定义了一些对象和类,但是没有初始化,并且将这些类挂在了全局对象window下,我们可以通过windiow来访问这些对象。下面是pdf.js阅读器逻辑开始的地方:
// 整个应用最主要的类,它即对应阅读器本身
window.PDFViewerApplication = PDFViewerApplication;
// 这里主要是一些常量
window.PDFViewerApplicationConstants = AppConstants;
// 这里面包含了App的一些配置,尤其是大量的默认配置都在这里面
// 想要给阅读器添加参数或修改参数
// 不要直接操作,而是通过AppOptions来操作
window.PDFViewerApplicationOptions = AppOptions;
初始化几个重要且基本的对象之后,viewer.js读取了页面的dom信息,并用这些dom信息初始化PDFViewApplication对象。页面上的组件dom特别的多,在这里我们不全部展开。但是如果我们要想加一些自己的按钮或功能上去,并希望能够通过将逻辑加入到pdf.js当中去,那么下面就是我们第一个要改造的点。在实际的开发过程中,我们增加了四个按钮,因而也在这里增加了四个dom对象。
最开始的启动过程中,初始化PDFViewApplication主要就靠下面两个方法:
function getViewerConfiguration() {
return {
appContainer: document.body,
mainContainer: document.getElementById("viewerContainer"),
viewerContainer: document.getElementById("viewer"),
...
}
}
const PDFViewerApplication = {
...
async run(config) {
...
await this.initialize(config);
...
}
async initialize(appConfig) {
...
await this._initializeViewerComponents();
this.bindEvents();
this.bindWindowEvents();
}
}
一个负责读取dom信息,一个负责利用dom信息来初始化PDFViewerApplication对象。
初始化方法initialize里面包含了太多内容,如夜间模式、页面朝向(这个是针对手机的)、多语言(我们不考虑这个)等等。这些我们暂时先不展开来讲,我们只考虑对我们比较重要的那一部分。也就是上面的代码里展示的几行比较关键的代码。
_initializeViewerComponents这个方法从方法名上来看比较容易理解,主要就是初始化阅读器里面的一系列组件。主要的的是EventBus、PDFViewer。
EventBus是PDFViewerApplication的一个重要成员。PDF阅读器使用了EventBus作为总线,来达到方法本身和调用者解耦的目的。那它具体是怎么解耦的呢?很简单,假如我们想要调用PDFViewerApplication的API,我们无需知道API在哪里、具体实现是怎样的,我们只要告知EventBus我们想要调用什么API、参数是什么,EventBus会帮我们找到对应的API的调用者,并让其根据参数来执行相应的代码。当然,这个调用者可能不存在,也可能有多个。假如我们现在的PDF页面在100页,但是你希望跳转到200页,通常情况下,开发者会使用类似于下面的代码viewer.jumpToPage(200)来达成自己的跳转的目标。但是在pdf.js里面并不是这样,pdf.js里,如果你想要跳转到某一页,你应该想EventBus发送请求,告知EventBus,你要跳转到这一页。EventBus会根据你的请求类型和参数来帮你找到合适的代码执行器并执行跳转逻辑。
下面是两段简短的代码,用来说明在pdf.js种如何通过EventBus来实现功能调用:
// 调用方式
const eventBus = getEventBus();
// 我想跳转到200页,我通过向eventBus发送消息来达成我的目的
// jumpToPage只是举例用的,pdf.js里并没有这个消息类型
eventBus.dispatch("jumpToPage", { page : 200 })
// eventBus收到了你发来的"jumpToPage"信息,开始准备处理
// 它先找到能处理jumpToPage命令的处理器
const listeners = eventBus.findListeners(type);
// 然后逐个执行
for(const listener of listeners)
listener.listen(params);
这种调用方式用至少两个好处:
第一个好处是让方法调用变得统一而简单。用户无需了解整个PDF的实现原理,也无需接触pdf.js内部的类,只需要知道有哪些API可以调用,参数分别是什么,就可以了。搜索PDF内容、跳转页面、放缩页面大小等等一系列操作,我们都可以通过这种方式来进行调用。不过略有遗憾的是,我们目前没有找到一个写的比较好的PDF的API文档,我们想要通过这种方式去调用pdf.js内部的API,必须要自己去研究pdf.js内部注册了哪些API的处理器。
第二个好处是我们可以自己增加处理逻辑,让程序在执行到一定的阶段的时候,可以把我们的代码也一并执行了。即我们不仅可以作为调用者来使用EventBus,我们也可以向EventBus注册自己的处理方法,让自己成为被调用者。举个例子,在UI处理器加载完毕后。pdf.js会通过EventBus发送annotationeditoruimanager来告知系统UI处理器已经加载完毕了,EventBus里注册的处理器在收到消息之后,就会执行处理代码了。在EventBus初始化的过程中,我们也可以添加一个自己的处理器,来监听类型为annotationeditoruimanager的消息。这样在EventBus收到annotationeditoruimanager处理请求的时候,就不仅仅会处理它自己的逻辑,还会把我们新增的逻辑也执行了。我们就可以通过这种方式将自己的逻辑加入到viewer的启动过程中去了。通过这种方式,我们增加了一系列的钩子,通过这些钩子增加PDF阅读器的扩展能力。除此之外,我们还监听了文档加载完毕的消息,在文档加载完毕后,我们会从从数据库拉取一系列的批注,并加载到阅读器上面去。
整个EventBus的实现有点类似于标准设计模式里面的中介者模式,EventBus本身就是一个中介,一方面被调用者可以向他注册,让自己具备被调用的能力,另一方面调用者,可以直接通过它来调用已经注册过的处理器。pdf.js在EventBus中注册了大量的的处理器。上述的代码中调用的ths.bindEvents()方法就是在向EventBus注册处理器。下面的代码是我们从中截取的一部分:
bindEvents() {
const { eventBus, _boundEvents } = this;
...
eventBus._on("resize", webViewerResize);
eventBus._on("hashchange", webViewerHashchange);
eventBus._on("beforeprint", _boundEvents.beforePrint);
eventBus._on("afterprint", _boundEvents.afterPrint);
eventBus._on("pagerender", webViewerPageRender);
eventBus._on("pagerendered", webViewerPageRendered)
...
}
EventBus是我们实现对PDF阅读器进一步控制和操作的重要组件。 PDFViewer是另一个重要的对象,从它的名字就可以看出来它是整个阅读器的核心部分。里面包含的东西非常之多,有的重要,有的不重要。它涉及到整个PDFViewer的生命周期,因此在这里我们只对它做简单的介绍。在后续启动过程中,会有很多涉及到它的地方,那时候我们再做详细的介绍。
加载文档
在上面基本的对象初始化完成之后,PDFViewerApplication就开始了最重要的环节,加载PDF文档。
// 加载文档的关键代码
this.open({ url: file });
// 打开文档的关键函数
// 在这个函数中,PDF完成了从网络中加载数据
// 并将数据转换成PDF文档
function getDocument(src);
因为我们的文档都是在服务器上的,因此我们只考虑加载服务器上的文档这一种情况。 viewer请求加载远程服务器上的文件有非常多的参数,如url、httpHeaders、password、disableRange、rangeChunkSize等等。如果想要对viewer请求服务器这个逻辑进行调参,可以调试getDocument里组装参数相关的代码。
其请求文档的时候,也没有直接调用请求文档相关的逻辑,而是通过MessageHandler(类似于前面的EventBus,但不是EventBus,是专门用于请求相关的调用。因为请求相关的应用比直接调用的逻辑要更复杂一些,因此不直接使用EventBus来操作)发送GetDocRequest来请求具体的文档数据。
MessageHandler对GetDocRequest的处理是至关重要的。其处理的主要逻辑只有一行代码,就是创建了DocumentHandler,实际代码如下:
handler.on("GetDocRequest", function (data) {
return WorkerMessageHandler.createDocumentHandler(data, port);
});
参数data是前面组装viewer请求加载远程服务器上的文件的参数。createDocumentHandler这个函数里面的逻辑是比较多的。但是最主要的方法仍然是容易找到的,就是如下方法:
async function loadDocument(recoveryMode) {
....
}
在后续的代码中,我们只考虑分片加载的情况,不再考虑其它情况。createDocumentHandler最重要的逻辑就是创建了一个重要的handler对象,但是并没有执行这个handler除了构造函数之外的任何方法。这个handler里面的方法要稍等一会儿才会执行,因为它需要先获得到一个关键的参数才能够开始执行。这个关键的参数就是PDF文档的长度。因为PDF阅读器在基于分片加载的情况下,需要加载文档头部和尾部的元数据信息,并且验证他们的完整性和正确性,因此是需要知道PDF文档的长度的,并且是要在最开始的时候就要加载的。PDF文档还会根据文档的长度和分片长度做一些优化。
在准备工作完毕后,MessagerHandler发送了一个Ready信息,这个消息会驱动相关的类向后台请求文档长度:
messageHandler.send("Ready", null);
在收到这个Ready信息后,viewer一路创建并调用对象,直到创建PDFFetchStreamReader类型的对象,然后发出第一个获取文档的请求——这个请求会获取文档的最基本信息,即文档长度。在默认的情况下,PDF阅读器希望后台返回两个数据,第一个数据是文档的长度,放在http请求头的头部的ContentLength字段。另一个是整个文档信息,放在请求体即body中,但是这个body有时候会被读取,有时候不被读取直接丢弃。当body大小小于两次分片长度的时候,pdf.js就会读这个body,不然的话pdf.js会直接丢弃这个body。这么做有比较大的弊端,会导致一些计算资源的浪费,而且难以优化。后续的博客,在分片加载那一块中,我们会详细的说明这个问题,而且我们将这里的实现方式直接改掉。下面是具体请求的代码:
fetch(
url,
createFetchOptions(...)
).then(response => {
...
const { allowRangeRequests, suggestedLength } =
validateRangeRequestCapabilities({
getResponseHeader,
...
});
this._isRangeSupported = allowRangeRequests;
// Setting right content length.
this._contentLength = suggestedLength || this._contentLength;
我们获取到文档长度之后,就可以开始进入读取文档的阶段。即我们上面提到的方法loadDocument,下面我们开始分析该方法:
async function loadDocument(recoveryMode) {
await pdfManager.ensureDoc("checkHeader");
await pdfManager.ensureDoc("parseStartXRef");
await pdfManager.ensureDoc("parse", [recoveryMode]);
await pdfManager.ensureDoc("checkFirstPage", [recoveryMode]);
await pdfManager.ensureDoc("checkLastPage", [recoveryMode]);
const isPureXfa = await pdfManager.ensureDoc("isPureXfa");
....
}
上述的代码是读取PDF文档二进制数据并解析出文档相关信息的关键所在了。不过里面的内容相对枯燥一点,毕竟转换二进制的文件流相对来说已经到了最底层了,通常情况下我们很难涉及到这一部分。
上述的代码调的都是一个方法,即pdfManager.ensureDoc。不过实际调用的方法却是PDFDocument这个对象的一系列方法。上面的ensureDoc的参数如checkHader、parseStartXRef、parse、checkFirstPage、checkLastPage等,最终会调用PDFDocument#checkHeader、PDFDocument#parseStartXRef、PDFDocument#parse、PDFDocument#checkFirstPage、PDFDocument#checkLastPage这些方法。PDFDocument是core层下的一个重要的对象,其可以认为代表了PDF文档(不包括视图那一部分)本身。通过它我们可以进行一系列的对文档的操作。
下面简单的介绍一下这些方法的用处,帮助读者更好的了解pdf.js内部到底做了些什么事:
首先是checkHeader,这个方法其实非常简单,只做了两件事。第一件事是判断PDFHeader是否有头部的魔数(或者称之为签名),然后查看PDF的版本信息。如果用文本编辑器打开PDF文件,我们在开头会得到类似以下的文本:
%PDF-1.5
5 0 obj
<</Type /Page /Parent 3 0 R /Contents 6 0 R /MediaBox [0.0 0.0 595.0 841.0] >>
endobj
6 0 obj
......
其中%PDF-就是签名,1.5就是版本号。
然后是parseStartXRef,这个方法也比较简单,实际上就是将XRef表(交叉引用表)开始偏移的位置找出来。交叉引用表对整个PDF很重要。交叉引用表一般在PDF的尾部。PDF的结构比较特殊,开头是一些元信息,结尾则是另外一些信息,而中间的部分才是内容。交叉引用表在PDF中的结构大概如下:
xref
0 698
0000000000 65535 f
0000303435 00000 n
0000303540 00000 n
0000303630 00000 n
0014586920 00000 n
0000000010 00000 n
0000000107 00000 n
......
接下来是parse方法,上面的两个方法,就是简单的验证和读取一些信息。而parse方法开始真正的处理数据了。parse方法首先读取并记录了XRef表的信息。在这过程中,PDF还对加密文件做了特殊处理。不过对于pdf.js来说,最重要的还是要读取PDF的目录、页面索引、对象索引信息。因为它们是pdf.js能否支撑对大的PDF文件进行分片加载,即按需加载的关键。PDF的目录内容对我们来说是至关重要的,它记录了每个页的位置基本偏移、引用等关键信息。如果PDF目录结构组织的比较好的话,加载PDF的时候我们就只需要加载对应页的信息、只加载对应页实际引用的信息,而不需要将不显示的页面信息、未被实际使用的引用对象也加载出来。这样对于几百MB甚至几G的PDF文件都可以实现秒加载。在这个方法中通过分析出来的文档的基本信息,pdf.js构建了一个Catalog对象。不管是在前端我们用pdf.js还是在后端用PDFBox来处理PDF,还是PDF文件本身,它们都是用Catalog来表示PDF本身的目录信息。当然了,如果不想要分片加载,那这里目录组织的方式到底是否良好,就不太重要了。
再接下来是checkFirstPage方法,在调用这个方法的时候,作者还加了一段注释,如下:
// Check that at least the first page can be successfully loaded,
// since otherwise the XRef table is definitely not valid.
// 至少检查一下第一页是否能够成功加载,否则的话XRef表是有问题的。
await pdfManager.ensureDoc("checkFirstPage", [recoveryMode]);
不过此处加载第一页的逻辑,是异步执行的。它创建了一个新的Page对象,这个Page对象在加载过程中未做特殊处理。 在加载了第一页之后,pdf.js又加载了最后一页。同样的,作者也用注释说明了这一点:
// Check that the last page can be successfully loaded, to ensure that
// `numPages` is correct, and fallback to walking the entire /Pages-tree.
// 确认最后一页也可以被成功加载,这样说明numPages也是对的。
await pdfManager.ensureDoc("checkLastPage", [recoveryMode]);
除了上面这些处理以外,还有一些其它的处理,但是对我们分析pdf.js的启动流程影响不是很大,因此我们在此处暂时略过。
在上述基本信息搞定了之后,阅读器开始开始正式加载文档。此处的加载文档不同于上面对文档的基本信息进行处理,此处更偏向于对页面的渲染。在这里我们简单的截取一段代码:
load(pdfDocument) {
...
const pageLayoutPromise = pdfDocument.getPageLayout().catch(() => {});
const pageModePromise = pdfDocument.getPageMode().catch(() => {});
const openActionPromise = pdfDocument.getOpenAction().catch(() => {});
...
this.toolbar?.setPagesCount(pdfDocument.numPages, false);
this.secondaryToolbar?.setPagesCount(pdfDocument.numPages);
...
const pdfViewer = this.pdfViewer;
/* 不是简单的set,里面包含了大量的逻辑*/
pdfViewer.setDocument(pdfDocument);
const { firstPagePromise, onePageRendered, pagesPromise } = pdfViewer;
...
}
由于整个load函数内部多且庞杂,因此不宜完全展开讲。因为这里就是大段的处理渲染逻辑的地方。如检查pdf.js是运行在什么平台上、设置页面数量、从第几页开始加载、加载完成之后做一些什么事情等等。对于想要改造pdf.js的开发者而言,如果想要增加或删除一些渲染前后的逻辑,是可以在这里进行改动的。我们就在这里加上了自己的逻辑。客户希望我们的PDF组件能够根据URL的参数来决定开始的时候直接跳转到第几页。因此我们在这里增加了逻辑。如果请求阅读器的请求中有loadPageIndex参数的时候。我们会按照这个参数跳转到指定的页数。这样阅读器的使用者,在第一次阅读了一部分页面并离开后,第二次再打开阅读器的时候,可以直接调到上次阅读的地方。这样的话,会有一个比较好的体验感。
load里面的函数调用有一个特点,就是基本上使用的都是异步Promise来实现的。因此调试起来,也有一定难度。
load里面有一行比较关键的代码——pdfViewer.setDocument(pdfDocument),这个代码里有大量的逻辑。而pdfViewer就是我们前面提到的一个初始化的对象,他代表了整个PDF阅读器。而这里的代码看似是set对象,实际上里面执行了类似于通过pdfDocument来初始化pdfViewer对象的逻辑。这里面大量的逻辑也都是对阅读器的展示进行处理的。
下面代码包含了PDFViewer的一个重要逻辑,就是获取第一页。不过是通过异步来获取的:
setDocument(pdfDocument) {
...
const pagesCount = pdfDocument.numPages;
const firstPagePromise = pdfDocument.getPage(1);
...
}
获取完毕后,根据获取到的信息开始对阅读器进行渲染。获取信息结束后,开始渲染的主要逻辑有接下来这么几段:
setDocument(pdfDocument) {
...
const pagesCount = pdfDocument.numPages;
const firstPagePromise = pdfDocument.getPage(1);
...
Promise.all([firstPagePromise, permissionsPromise])
.then(([firstPdfPage, permissions]) => {
...
// 初始化批注管理器 —— 我们能够给pdf.js增加如高亮、文本、绘图等批注都要依赖它
this.#annotationEditorUIManager = new AnnotationEditorUIManager(...)
...
// 根据Viewport设置页面大小
const viewport = firstPdfPage.getViewport({
scale: scale * PixelsPerInch.PDF_TO_CSS_UNITS,
});
...
// 开始为每一个页面添加一个阅读器对象
// 每一个PDFPageView代表了每一个页面,
// 给PDFViewer增加这些页面对象的时候,这些PDFPageViewer对象本身会创造大量的空白div
// 给每一个页面作为底的存在
for (let pageNum = 1; pageNum <= pagesCount; ++pageNum) {
const pageView = new PDFPageView({
container: viewerElement,
eventBus: this.eventBus,
id: pageNum,
...
});
this._pages.push(pageView);
}
...
// 默认加载第一页
const firstPageView = this._pages[0];
if (firstPageView) {
firstPageView.setPdfPage(firstPdfPage);
...
}
};
}
上面简化过的代码,向用户展示了pdfViewer.setDocument过程中做了哪些事。
首先一个比较重要的是事初始化了AnnotationEditorUIManager这个对象。因为我们特别关注阅读器对批注的管理,因此AnnotationEditorUIManager对象对我们来说非常重要,在它初始化好了之后,我们对其进行了一轮改造,赋予其一些新的能力。后续在我们给pdf.js追加新功能的时候,大量使用和修改了这个对象。
其次是循环创建了一系列的PDFPageView对象,这些对象代表了PDF阅读器中的一个个页面。这边对它们只是做了初始化的处理,即创建了一个个空白的div,并没有做渲染处理。
在创建了一系列的PDFPageView对象之后,页面获取并初始化了第一个页面。值得注意的是,在后面的逻辑中,可能还会打开别的页。从而将打开第一页这个逻辑给覆盖掉。
在利用PDFDocuement初始化结束pdfViewer对象之后,阅读器还有一些逻辑要处理,这些逻辑的代码依旧在load(pdfDocument)方法中,代码简化后效果如下:
load(pdfDocument) {
...
firstPagePromise.then(pdfPage => {
...
promiseAll.push(pageLayoutPromise, pageModePromise, openActionPromise);
Promise.all(promiseAll)
.then(async ([...]) => {
...
// initialBookMark中包含了一些参数
// 我们通过修改他来达到页面跳转效果
const initialBookmark = this.initialBookmark;
...
// 根据信息调整要展示的页面
// 这个方法里面除了设置一些基本信息外,还会创建canvas,即PDF显示的canvas
// 渲染完成之后,会将一些基本的视图信息渲染出来
this.setInitialView(hash, {
rotation,
sidebarView,
scrollMode,
spreadMode,
});
...
await Promise.race([
pagesPromise,
new Promise(resolve => {
setTimeout(resolve, FORCE_PAGES_LOADED_TIMEOUT);
}),
]);
...
this.setInitialView(hash);
}
}
}
这里面用了多个Promise,核心逻辑还在最后的Promise.all(...).then()的方法里面。这里面最重要的方法是setInitialView方法。这个方法负责了canvas对象的创建,以及一部分页面的渲染。同样的,他也会管理一些其它和视图相关的内容。
setInitialView方法通过多层调用,最后实现了页面的绘制,具体的调用过程如下:
PDFViewerApplication#setInitialView -> PDFLinkService#setHash -> PDFViewer#scrollPageIntoView
-> PDFView#set currentScaleValue -> PDFViewer#setScale -> PDFViewer#setScaleUpdatePages
-> EventBus#dispatch -> #webViewerScaleChanging -> PDFViewer#update
-> PDFRenderingQueue#renderHighestPriority -> PDFViewer#forceRendering
-> PDFRenderingQueue#renderView -> PDFPageView#draw
这个调用链相对来讲还是比较长的,但是最终是落到了PDFPageView#draw,这个方法上。点开这个方法,我们很容易的就看到了,它在这里创建了一个div类型的dom元素canvasWrapper,并在这个dom元素下又创建了canvas对象,并声明了一个渲染方法:
async draw() {
...
const canvasWrapper = document.createElement("div");
canvasWrapper.classList.add("canvasWrapper");
div.append(canvasWrapper);
...
const canvas = document.createElement("canvas");
canvas.setAttribute("role", "presentation");
canvas.hidden = true;
...
canvasWrapper.append(canvas);
this.canvas = canvas;
...
const renderTask = (this.renderTask = this.pdfPage.render(renderContext));
renderTask.promise.then(
async () => {
showCanvas?.(true);
await this.#finishRenderTask(renderTask);
...
this.#renderTextLayer();
await this.#renderAnnotationLayer();
await this.#renderDrawLayer();
this.#renderAnnotationEditorLayer();
...
});
}
通过这里的代码,我们可以看到在页面渲染完成之后。pdf.js还会在被渲染的页面对象之上添加多个层级的div。这些层级有绘图层、文本层、批注层等,每一层的用处都是各不相同的。我们后续涉及到添加批注的时候,还会对PDFPageView的结构还会做进一步的研究,在这里,暂时不过多探讨。主要讲述它在启动过程中的流程。等多层的数据绘制完毕后,阅读器基本上也就加载完了。当然,需要注意的是,pdf.js的阅读器不仅是在这里绘制了不少的东西,在renderTask里面也绘制了很多东西,主要是XObject一类的。其主要代码如下:
首先,renderTask里面在requestAnimationFrame里增加了一段渲染的逻辑,这是它和其它绘制不太相同的地方:
_scheduleNext() {
if (this._useRequestAnimationFrame) {
window.requestAnimationFrame(() => {
this._nextBound().catch(this._cancelBound);
});
} else {
Promise.resolve().then(this._nextBound).catch(this._cancelBound);
}
}
而沿着这个渲染逻辑一直往下探究,可以追溯到CanvasGraphics#executeOperatorList。透过这个类和名字,我们大概可以推断出,这个类主要是负责以绘制Graphics为主。既包括Path这种线段,也包含Image这种对象。以绘制图像为例,我们可以看到,有下面的这段代码:
paintImageXObject(objId) {
if (!this.contentVisible) {
return;
}
const imgData = this.getObject(objId);
if (!imgData) {
warn("Dependent image isn't ready yet");
return;
}
this.paintInlineImageXObject(imgData);
}
至此,pdf.js的阅读器将第一个页面渲染完毕,这同样也代表着整个pdf.js阅读器初始化的逻辑就结束了。下面,我们做个总结,简单概括一下pdf.js阅读器的启动流程:
- 首先,和绝大部分程序一样,pdf.js阅读器需要先初始化一些最基础的类,然后从他们开始,一点一点的将程序启动起来。
- 随后,pdf.js开始加载文档。在我们开发的过程中,是默认按照远程、分片的形式来进行加载的。最初要获取的是整个文档的长度。有了文档长度之后,pdf.js先读取头部的数据信息,再读取尾部的数据信息。然后利用这些信息PDF文档的Catalog(目录)建立起来。
- 文档的Catalog加载完毕后,开始获取第1页的数据(或者指定页)。
- 获取完第1页信息后,根据PDF的页面尺码大小(viewport)、页码数等数据将这个PDF的基本结构创建出来。但是并不渲染,具体的渲染要等到页面被访问的时候。
- 开始渲染第1页。渲染的时候,主要有几部分。首先是底图,底图我们可以看作是PDF本身,在页面上具体的展示就是一个canvas。然后底图之上又有多个层级,如TextLayer,DrawLayer,AnnotationLayer等,这些层级丰富了PDF阅读器的功能,增加了一些交互。
THE END。此篇文章到此结束,后续还有多篇博客继续分析pdf.js。
标签:PDF,js,源码,文档,阅读器,pdf,加载 From: https://blog.csdn.net/2401_88352022/article/details/143587830