改造PDF.js源码,实现审批系统对PDF阅读器的集成(三)——在PDF.js中修改与追加参数

1,395 阅读13分钟

PDF阅读器的参数与我们新加入的参数

PDF阅读器中有一个app_option.js文件,其中的defaultOptions对象中包含了大量的默认参数。通过阅读这个文件的源码,我们可以知道PDF阅读器里,有哪些功能是我们可以进行通过改动参数来实现一定程度的定制的。在后续的分片加载过程中,我们就做了不少对默认参数的改动。值得注意的是,当我们在为PDF.js增加新代码的时候,如果有需要添加新的参数,我们也应该在这里添加。这样,可以保证我们的代码和PDF阅读器的代码风格是一致的。

以实现分片加载为例,我们简单的介绍如何在PDF.js中实现和追加参数。我们为了良好的实现分片加载的功能,我们修改了多个内置的参数:

disableAutoFetch: {
  /** @type {boolean} */
  value: true,
  kind: OptionKind.API + OptionKind.PREFERENCE,
},
disableRange: {
  /** @type {boolean} */
  value: false,
  kind: OptionKind.API + OptionKind.PREFERENCE,
},
disableStream: {
  /** @type {boolean} */
  value: true,
  kind: OptionKind.API + OptionKind.PREFERENCE,
},

第一个参数是disableAutoFetch,默认情况下是关闭的,但是为了实现分片加载,我们将其打开,避免PDF阅读器一次性就将一个文档的全部内容都加载出来。否则的话,不仅会效率低下,还会浪费大量的流量和带宽,用户体验也不好。

第二个参数是disableRange这个选项关闭掉,打开这个选项之后,就允许PDF.js能够分片加载数据了。

最后一个参数是disableStream,这个选项我们也将其打开,限定分片加载数据的方式。如果不禁止这个选项,会对我们分片加载数据有一定的影响。

以上就是一个简单的案例,我们通过修改一些默认属性,来实现对PDF.js的定制。

但是分片加载每次加载的长度,也是一个关键的参数,并且这个参数并没有在defaultOption里定义。在实际的代码中,PDF.js直接将这个值固定死了(后续的版本里面或许会更改)。我们一开始试图在PDF.js自带的选项中寻找能够代表每次加载的长度的参数,找了一圈之后,发现并没有这个参数。于是我们对加载过程的源代码进行了一个详细的分析之后,发现了PDF.js中定义在代码中定义了一个值为64K常量,来控制每次加载PDF文档的长度。具体代码如下:

const DEFAULT_RANGE_CHUNK_SIZE = 65536; // 2^16 = 65536

于是我们根据实际的需要,自定义了一个新的参数,用来控制PDF.js在分片加载的时候,每次加载的数据长度。具体内容如下:

rangeChunkSize: {
  value: 100000,
  kind: OptionKind.API,
}

有了这个参数之后,我们在通过调试寻找最合适的分片大小的时候,变得更加方便了。我们根据业务所需要的实际情况,将参数值设置为了100000。这样在PDF.js每次从后台拉取数据的时候,只能加载略低于100KB的数据。定义完这个参数之后,我们还需要将这个参数加入到代码当中去,让其能够发挥其作用。通过调试代码,我们找到了能够操作分片加载的地方:

// 这里获取所有参数,将所有类型为OptionKind.API的参数都传进去
const apiParams = AppOptions.getAll(OptionKind.API);
const loadingTask = getDocument({
  ...apiParams,
  ...args,
});

// 这里赋予参数
function getDocument(src) {
  .....
  const url = src.url ? getUrlProp(src.url) : null;
  .....
  // 有值读值,没有就用默认值
  // 之前使用的都是默认值,现在可以使用自定义的值了
  const rangeChunkSize =
    Number.isInteger(src.rangeChunkSize) && src.rangeChunkSize > 0
      ? src.rangeChunkSize
      : DEFAULT_RANGE_CHUNK_SIZE;
  .....
}

通过修改读取参数和使用参数的两个地方的代码之后,我们实现了对单次请求分片的大小的控制。 不过上述的都是静态的值,即修改一次。适用所有的PDF阅读器的使用者。仅仅能够修改这些值还是远远不够的。客户向我们又提出了一些新的要求。主要有以下两点:

  1. 在PDF阅读器打开的时候,可以通过参数来控制PDF从第几页开始展示。如果没有指定展示的页数,沿用PDF.js原有逻辑。
  2. 打开PDF阅读器的时候,可以控制用户是否有编辑的权限,如果没有权限,编辑的按钮不可以使用。

下面我们开始详细讲述这两个功能的实现方式。

指定跳转页——通过添加loadPageIndex参数实现

毫无疑问,如果我们要想指定PDF.js在启动的时候跳转到某一页,那么首先要对PDF.js初始化过程中跳转的逻辑进行一个详细的分析。在前面启动流程的分析当中,我们已经知道了PDF阅读器在默认的情况下是跳转到第1页的。但是后续还有另一段逻辑,会让PDF从默认的第1页跳转到其它页面去。当我们使用PDF.js阅读PDF的时候,PDF阅读器会将我们阅读到哪一页这种信息,存到浏览器本地存储当中去。当我们下次再次打开PDF阅读器的时候,阅读器会将本地存储的历史数据信息加载出来,然后根据加载出来的数据重新将页面从第1页跳转到上次浏览的页上面去。对于这一段逻辑,我们需要进行一定程度的改造,以达到这样一个目的:客户阅读到的页数,要记录在服务器后端。当客户再次打开同一个PDF的时候,无论是在原来那台机器上还是在一台新的机器上,都能够跳到之前在后端记录的那个页面上。

我们在通过给PDF.js增加新的参数,实现了这个目的:

通过参数定位到指定页.gif

详细的实现过程

首先,我们在PDF阅读器打开的URL上新增了一个参数loadPageIndex,因此URL会变成类似于下面的这种形式:

http://127.0.0.1:8080/web/viewer.html?file=/load/0&loadPageIndex=1

file表示我们要加载的文件路径,loadPageIndex则是我们要跳转的页面。

紧接着,我们需要修改PDF.js的跳转逻辑,将我们的这个参数逻辑加入进去。具体的代码如下:

// 是否需要从指定位置开始读
const queryString = document.location.search.substring(1);
const params = parseQueryString(queryString);
const loadPageIndex = params.get("loadpageindex");
const designatePage = loadPageIndex && !isNaN(parseInt(loadPageIndex));
...
// 如果没有指定页数,保持原有逻辑
if (!designatePage) {
  this._initializePdfHistory({...});
} else {
  this.initialBookmark = `page=${loadPageIndex}`;
}

const initialBookmark = this.initialBookmark;
...
}

_initializePdfHistory方法中,PDF.js通过获取一些参数,并将这些参数组织成bookmark的形式。后续的代码在拿到这个bookmark之后,会对这个bookmark做一个解析,获取到自己想要的参数。initialBookmark中最重要的参数,就是page,即初始化结束后PDF需要跳转到得的页面数。我们在这里增加了一段逻辑,通过偷梁换柱的方式实现了我们的目标。我们通过重新组装initialBookmark,来让后续的代码需要读初始化页面下标的时候,不是从历史记录里读,而是从我们指定的地方页面读。

加入权限控制,让批注具备“只读”的功能——通过增加参数permitToEdit实现

因为我们的目标是将PDF.js的代码进过改造之后,能够完美的融入到业务系统当中去。因此有一系列的业务系统所必要的特性,PDF阅读器也要同步添加进来。而权限控制就是这样一种特性。这一点很容易理解,因为同一份PDF上可以由很多人加很多个批注。对于这些批注,有的人可以看,但不可以操作,有的人既可以看也可以操作。因此我们需要将批注的权限管理加入到PDF.js当中来。批注的权限管理又分为全局的权限和单个批注的权限,这一点我们要兼顾到。

下面我们展示权限功能的实现。我们实现的效果如下:

批注的权限开启与关闭.gif

为了实现全局的权限控制,我们在打开阅读器的URL上增加了一个新的参数permitToEdit

http://127.0.0.1:8080/web/viewer.html?permitToEdit=false

全局权限的实现并不复杂。在我们添加完这个权限控制之后,需要做两件事。第一件事是将URL中的权限选项加入到应用选项当中去,第二件事则是在渲染批注的逻辑中加入权限相关的判断——如果打开的阅读器是只读的,那么要将所有操作批注的按钮全部都隐藏掉:

const PDFViewerApplication = {
  ...
  // 添加全局变量,是否允许编辑
  permitToEdit: window.location.search.toLowerCase().indexOf("permittoedit=false") == -1,
  ...
}
...
// 如果不允许编辑,那么我们要隐藏所有可以编辑的按钮
if(!this.permitToEdit){
  for (const id of ["editorModeButtons"]) {
    document.getElementById(id)?.classList.add("hidden");
  }
}

除此之外,我们还要给每一个批注都增加一个方法来判断该批注是否具有被编辑的权限:

class AnnotationEditor{
  // ..
  hasPermitToEdit(){
    const globalPermit = window.PDFViewerApplication.permitToEdit;
    return globalPermit && this.permitToEdit;
  }
}

当用户点击一个批注的时候,PDF.js需要判断这个权限是否能够被编辑。这个判断,既需要看全局的权限,也需要看点击的这个批注的权限。如果二者有一个是禁止编辑的,那么这个权限就是不可被编辑的。

不过在控制权限这件事上,这个仅仅在前端做是不够的,后端需要进行一个同步的配合。否则用户是有可能绕过前端来直接对后端发起请求的。

顶部的权限按钮工具条

在PDF阅读器的顶部,有一系列添加批注的按钮。如果全局的权限是关闭的,那么这个工具条应该不显示。这就需要让我们在PDF初始化的过程中加上一段逻辑,让阅读器能够根据权限信息来决定展示还是隐藏权限按钮。这个逻辑也非常简单,只需要在PDFViewerApplication#_initializeViewerComponents中加上一段权限判定逻辑即可。当不具备权限的时候,直接隐藏批注按钮。

if(!this.permitToEdit){
  for (const id of ["editorModeButtons"]) {
    document.getElementById(id)?.classList.add("hidden");
  }
}

开启权限.png

开启权限时右上角有编辑按钮

关闭权限.png

关闭权限时右上角编辑按钮消失

单个批注权限处理的细节

对于每一个批注,我们都为它定义了一个新的字段——permitToEdit,用来决定这个批注是否具备被编辑的权限。于此同时,对于所有和批注修改相关的操作我们都必须逐一将禁止编辑的逻辑加入进去。

工具条与删除按钮

我们首先关注的是工具条,一旦禁止用户操作该批注之后,操作相关的工具条就要被隐藏起来。工具条会在点击批注的时候弹出来,我们需要修改此处的逻辑,让工具条按照实际情况弹出来。删除功能就在工具条的上面,因此隐藏了工具条之后,用户就不能删除了。(事实上,用户还可以通过快捷键绕过权限设置来进行删除,但是我们不开放某些快捷键)。

工具条与删除按钮.png

修改代码,实现选中时不允许生成工具条:

class AnnotationEditor{
  ...
  select() {
    this.makeResizable();
    this.div?.classList.add("selectedEditor");
    // 没有权限选择的时候,不走添加工具条相关的逻辑
    if(!this.hasPermitToEdit()){
      return;
    }
    ...
  }
  ...
}

对于删除功能,通过权限控制,让工具条直接不生成删除按钮,也是一个不错的主意。只需要在工具条的渲染时候,给是否允许向工具条添加删除按钮加上一个判定条件就可以了。

if(this.#editor.hasPermitToEdit()){
  this.#addDeleteButton();
}
批注的拖拽

对于大部分批注都具备的移动功能,也需要加入禁止的逻辑。一旦一个批注禁止编辑之后,这个批注可以选中,但是不可以移动。在PDF.js中,已经有一个开关能够用来控制是否可以拖拽批注,我们需要在这个开关当中,加入我们自己的逻辑。

是否允许拖拽.gif

修改代码,实现在没有权限的情况下,禁止拖拽:

get _isDraggable() {
  return this.#isDraggable;
}

set _isDraggable(value) {
  // 如果不可以编辑,则不能拖拽
  if(!this.hasPermitToEdit() && value){
    return;
  }
  this.#isDraggable = value;
  this.div?.classList.toggle("draggable", value);
}
批注的缩放

批注的放缩和批注的移动实现原理大同小异的。PDF.js通过resizer来实现放缩功能。当一个批注可以被放缩的时候,点击这个批注,四周会出现用于放缩的小方块。选中小方块拖动后就可以进行放缩了。为了实现批注的权限管理功能,我们在PDF.js创建resizer的时候加入了一段逻辑——即只有拥有批注的权限的时候,PDF.js才会在resizer上创建用于放缩的小方块。

缩放小方块.png

修改代码,实现在没有批注权限的情况下,不创建这些小方块:

#createResizers() {
  ...
  // 如果有权限,则可以创建resize相关的html元素,否则的话不创建。
  const classes = []
  if (this.hasPermitToEdit()) {
    const specific = this.resizePoints();
    if (specific) {
      classes = specific;
    } else {
      classes = this._willKeepAspectRatio
       ? ["topLeft", "topRight", "bottomRight", "bottomLeft"] : [....]
    }
  }
  ...
}
批注的编辑

接下是改变字体大小、颜色等一系列的操作。这些都属于对批注的编辑,这个也比较简单,我们可以直接控制批注的编辑权限。一旦编辑权限被禁止了,所有编辑相关的逻辑就都不走了。

enableEditMode() {
  if (this.isInEditMode()) {
    return;
  }
  if (!this.hasPermitToEdit()) {
    return;
  }
  ....
}
批注的批量处理

PDF.js还有统一编辑的功能,即对PDF进行多选之后,统一修改他们的字体、颜色等信息,这些也要根据是否有权限修改加以改动。这个代码的逻辑在于AnnotationEditorUIManager#updateParams中,我们需要对选中批注的批量操作做一个限制,即只允许操作具备修改权限的批注。

选中多个,同时处理.png

修改代码,在选中多个的情况下,只允许对有修改权限的批注进行统一的操作:

....
for (const editor of this.#selectedEditors) {
  if(editor.hasPermitToEdit()){
    editor.updateParams(type, value);
  }
}
....

最后,我们还需要关注点击批注之后自动触发的一些开关和控制窗口,这些开关和控制窗口有可能会修改。选中批注后也可能会自动打开一些控制窗口,这个也是需要注意的。对于这些窗口,我们也一样需要加上权限的控制。

以上就是单个批注权限需要注意到的点,增加单个批注的权限是个很需要耐心的工作。因为必须要逐个分析每一种批注类型可能涉及到的修改操作。只有完完整整的将批注中相关的权限全部都调整到位了,才能使得PDF.js具备一个完整的批注权限管理的功能。