改造PDF.js源码,实现审批系统对PDF阅读器的集成(二)——实现对批注的管理以及添加新批注

4,086 阅读29分钟

改造批注生命周期,添加API实现对批注的操作

PDF.js里面批注和页面是紧密结合在一起的。不同的批注类型实现方式不尽相同,且没有直接的API可以操控这些批注。在通常情况下,我们只能通过鼠标的点击操作来增加、删除、修改批注。但是这对于一个业务系统来说是远远不够的,我们想要的是能够通过API来自由的控制批注的添加、删除、修改、选中、定位等功能,从而实现将PDF.js完美融入到我们的业务系统当中去,而不仅仅是一个单独的PDF展示器。因此我们需要对PDF.js的批注实现逻辑进行一定的改动。

不过,在修改这些批注实现逻辑与生命周期之前,我们需要对批注的实现原理有一个清晰的认识。 首先,我们需要知道PDF单个页面的html的页面结构。下面是简化后的一个单个页面的组成结构:

<div class="page" data-page-number="1">
  <div class="canvasWrapper">
    <canvas role="presentation"/>
  </div>
  <div class="textLayer"></div>
  <div class="annotationLayer"></div>
  <div class="annotationEditorLayer"></div>
</div>

首先是div代表了一个页面,而data-page-number则代表的是页面号。

page div下面有多个子div,主要是canvasWrapper、textLayer、annotationLayer、annotationEditorLayer。 canvasWrapper就是PDF的底图,实际上就是一个canvas。负责展示最原始的PDF数据。而其它几层则分别承担着文字控制、批注展示等功能。

textLayer代表的是PDF的文字层。因为PDF的底图是一个canvas,而canvas实际上是一层图片,因此即使canvas中有文字也是无法被选中和操作的。但是在PDF中选中文字,是一个再平常不过的功能了。我们甚至要的也不仅仅是可以选中文字,我们还要能够对选中的文字进行进一步的操作,比如选中、复制、增加删除线、增加下划线等。PDF.js通过在底图canvas上贴一层隐形的文字图层来实现这个功能的。这一层就是textLayer层。在PDF中,文字的位置、大小、字体等这些原始信息,会被记录在PDF文件本身。当然,有一些PDF会保存,也有一些PDF不保存,如果PDF不保存文字的基本信息的话,那么就不太好用这种方式实现对文字的选中了。有了这些信息之后,PDF.js就能够知道文档中的每一个字所在的位置。因此,PDF.js在PDF的canvas之上,又增加了一层textLayer层。在textLayer这一层里面,PDF.js增加了大量的span标签,而这些标签的内容就是PDF里的文字。这些标签里文字的位置,和canvas里以图的形式展示的文字的位置,是一模一样的。但是这些标签里的文字却是透明的,但是是可以选中的。这些文字就像贴在canvas文字上的一层膜,虽然看不见,但是可以选中。文字可以选中之后,我们就可以进一步的围绕着文字添加一系列新功能,例如高亮、删除线、下划线等。从用户的视角来看,他们在选中文字的时候,看似是选中了canvas中的文字,实际上选中的是textLayer里面透明的文字。

文字选中.gif
通过textLayer实现文字选中

接下来是annotationLayer,这一层主要是PDF中的annotation,主要是存放一些附加信息、标记或者互动元素的特殊元素或功能。带有跳转功能的目录就是其中一种annotation。这一层更多的是PDF自带的内容,且这种内容一般不属于文本相关的内容,而是作为额外的信息存在。因此,这一层不是我们重点关注的对象。

最后是annotationEditorLayer,这一层存放的主要内容就是我们添加的各种各样的批注。因此,我们非常关注这一层。批注的种类较多,但是实现的方式各不相同。有的是通过canvas实现,有的是通过div实现。当我们添加了一些基于canvas实现的批注,这一层就会多出一些canvas元素。如果我们增加了一些基于div相关的批注,这一层底下就会多出一些div元素。

各种类型批注.gif
各式各样的批注都在AnnotationLayer层上绘制

上面简单的介绍了批注实现原理的dom结构,下面开始介绍负责操控批注的JS相关的类,这些类更好的展示了批注的实现逻辑。 首先是整个批注中两个最为关键的类,分别是AnnotationEditorUIManagerAnnotationEditorLayerAnnotationEditorUIManager是一个全局管理的类。它里面记录了全局批注的情况。AnnotationEditorLayer是单个页面的批注的管理。我们想要改变批注的实现逻辑,就必须要先改写并接管这两个类的控制权。AnnotationEditorUIManager在PDF加载的时候就会创建,操作起来较为容易。但是AnnotationEditorLayer是懒加载的。因为PDF.js的页面是懒加载的,而PDF的每一页都对应一个AnnotationEditorLayer,因此AnnotationEditorLayer也是懒加载的,即页面没有渲染的时候,对应页面的AnnotationEditorLayer也不存在。

当我们要向PDF添加一个批注的时候,PDF.js会先创建这个批注的对象,比如创建绘制图像的时候,就会先new一个InkEditor,高亮一段文字的时候,就会先new一个HighlightEditor。创建完毕后,再将这个批注加入到AnnotationEditorUIManager当中去,由AnnotationEditorUIManager来进行全局的管理,而AnnotationEditorUIManager最终会将这个批注加入到对应的页面管理对象AnnotationEditorLayer中,由AnnotationEditorLayer负责来管理各自所对应的页面的批注。

PDF.js一共自带了四个批注,分别是InkEditor(绘制图形批注)、HighlightEditor(高亮文字批注)、ImageEditor(图片批注)、TextEditor(文字批注)。他们的实现方式各不相同。创建和加入AnnotationEditorLayer的时机,也各不相同。因此当我们需要接管这些批注的时候,我们就要对其创建、修改、删除等流程有着详细的了解。

首先,阅读器有批注模式和非批注模式,非批注模式下,我们就是纯粹的阅读,不会对文档有任何改变。但是我们如果想对文档添加某种形式的批注,我们就要先点击对应的按钮,进入相应的批注模式。同样的,为了方便我们添加批注,在不同的批注模式下,PDF.js对PDF的文字的选中、点击等功能,有着不同程度的支撑。

下面点击绘制图形批注模式的例子,当我们点击绘制图像之后,就会执行下面这段代码:

  case AnnotationEditorType.INK:
    // 为了方便绘制图形而增加的
    this.addInkEditorIfNeeded(false);
    this.disableTextSelection();
    this.togglePointerEvents(true);
    this.disableClick();
    break;

当批注模式切换成INK模式之后,文字选中和点击功能就都不能使用了。但是PDF.js添在PDF页面上加了一个画板,我们可以在这个画板上自由自地绘制图形。

在初步了解PDF.js批注实现的基本原理之后,我们开始实现对批注的管理了。在此,我们有两个目标需要达成。第一个目标是通过改造PDF.js内部批注实现的方式,我们能够将一些批注操作暴露出来并封装成API,从而我们能够通过自己封装的API来控制批注的创建、删除、修改等。第二个目标是在批注的生命周期中加入一些钩子,让这些钩子能够将执行我们的代码。这样的话,批注在进行创建、删除、修改的时候,能够执行我们新加入的代码,告知我们批注发生了什么变化。从而我们能够及时根据用户的操作,来同步修改业务系统中的批注信息。

拦截.gif
加入钩子及时监听批注变化

第一个目标的实现较为简单,对于自身有的但没有暴露出来的API,我们将其暴露出来。对于不够标准的API,我们将其标准化。对于业务有需求的API,但是批注本身没有的,我们按照批注的逻辑编写一个API即可。

第二个目标有一定的挑战。因为批注本身都是用户通过操作页面添加出来的,而且每一个批注的生命周期和创建、修改逻辑都不尽相同,因此没有办法仅通过一种特别的方式或者添加少量代码,就能让所有的批注都能够在它们发生变化的时候及时将信息有效的传达开发者。即我们需要逐个分析PDF阅读器每一种批注的生命周期,在涉及到添加、修改、删除的时候都要添加上我们自己定义的钩子。然后再通过这些钩子,将PDF阅读器内部批注的信息传达给外部的操作者和调用者。最终我们才能够实现对PDF.js里所有的批注的生命周期的监听。也只有这样,当页面上的批注发生变化的时候,我们的业务系统才能够通过监听及时的作出响应,同步通知后端,让后端作出相应的操作。

单从生命周期的角度来看,实现逻辑最为简单的批注就是高亮。高亮批注对应的类是HighlightEditor。在PDF.js中,要想高亮一段文字,需要先进入高亮批注模式。进入高亮批注模式之后,我们通过选中想要进行高亮的文字,即可实现高亮功能。高亮功能本身不能够修改,也不能够移动,只能够删除和修改颜色,因此高亮模式是最容易处理的。我们给高亮模式在三个地方添加了钩子。

第一个是创建的时候,高亮类型的批注在创建的时候会调用钩子,直接将高亮中的文字、高亮位置、高亮颜色等信息传递给我们,我们通过它们就可以记录下高亮的主要信息了,并能够通过这些信息在后续的操作中还原高亮批注。

第二个是在修改高亮批注颜色的时候,也加入一个钩子,这个钩子会记录高亮批注修改后的信息。

第三个则是删除,删除这个地方增加钩子主要是为了让后台能同步删除掉批注,别的逻辑较少。通过上述的操作,我们就可以实现对高亮进行完全的把控了。

下面展示几段修改的代码:

// 该代码在负责管理每个页面的Editor管理的AnnotationEditorLayer当中
#createNewEditor(params) {
  const editorType = this.#currentEditorType;
  const retVal = editorType
    ? new editorType.prototype.constructor(params)
    : null;
  /** 在此处新增逻辑,editor对象构造完后执行 */
  if (retVal) {
    this.#uiManager.hook.postConstruct(retVal);
  }
  return retVal;
}
...
// 该方法在所有批注的基类Editor当中
remove(forHide = false) {
  // 原删除逻辑
  ...
  // 增加新逻辑,如果删除了对象,需要执行该钩子
  if (!forHide) {
    this._uiManager.hook.postDestory(this);
  }
}

由于高亮批注是最近的版本新加的,可能官方觉得还不够完善。因此在默认的生产环境中,该批注是不打开的,需要我们手动打开。需要修改的代码是AppOption下的enableHighlightEditor开关:

enableHighlightEditor: {
  // 这个开关是临时的,因为我们加了一些尚在实验的特性在里面。不过这个选项的关闭是暂时的。
  value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
  kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
}

当然,PDF的高亮批注并的原始信息中并没有记录高亮的文字有哪些,这对我们来说是有所欠缺的。我们通过追根溯源,找到高亮对象创建前的代码,从document对象上获取到的selection对象,并调用selection.toString()获取到选中的文字,并传给HighlightEditor。通过这种操作,开发者就能获取到用户高亮时的文字了。

下面是简化的代码:

pointerUpAfterSelection(event) {
  const selection = document.getSelection();
    ...
  if (boxes.length !== 0) {
    this.#createAndAddNewEditor(event, false, {
      boxes, selectedText: selection.toString()
    });
  }
}

当用户在高亮模式下按下左键时,就可以开始选中文字了。当用户松开左键时,就会触发高亮批注的创建,最终就会执行这里代码。即获取选中区域,通过选中区域来构建选中批注。我们在这里添加代码获取了选中的文本,并传递了下去。最终这些文本会向用户展示,也会记录到数据库当中去,也可能会供数据分析使用。

除了高亮之外,还有一个批注类型也比较简单,叫做StampEditor。这个批注是负责向PDF中加入图片对象的批注。与上面的高亮批注不同的是,当我们选择图片的时候,批注的管理器会立刻创建一个StampEditor的对象。但是这个对象是空壳子,既没有图片信息,也不包含准确的位置、图片大小、宽高等信息,因此也不是我们想要保存的对象。而上述提到这些关于图像批注所必须要有的信息,会在我们选择完图片,并且图片加载完毕之后,才初始化,才有了真正需要记录的数据。因此这个批注和高亮批注的处理方式会有所不同。在高亮的时候,对象一创建就可以执行我们添加的钩子,但是对于图像批注来说不行。对于图像批注来说,我们需要在这个图像初始化完毕之后再执行创建钩子。同样的,我们能找到StampEditor初始化完毕的地方,并在这个地方加入钩子,记录图像的初始信息。通过一番定位,我们了阅读器在初始化完毕之后会执行Resize操作,来完成图像渲染的最后一步。因此我们在这个地方加入我们的逻辑。具体修改的地方如下:

#createObserver() {
  this.#observer = new ResizeObserver(entries => {
    const rect = entries[0].contentRect;
    if (rect.width && rect.height) {
      this.#setDimensions(rect.width, rect.height);
    }
    // 增加出初始化的逻辑
    if (!this.fromCommand && !this.hasRecord) {
      this.imgBase64 = this.#canvas.toDataURL();
      this._uiManager.hook.postInitialize(this);
      this.hasRecord = true;
    }
  });
  this.#observer.observe(this.div);
}

需要注意的地方有几点。第一是因为我们的图片要记录到后台去,但是我们并不想为图片再做一个对象管理的功能。因此直接将图片转换成base64位存进数据库(需要注意的是,这个文本可能会比较大)。第二是我们的对象初始化的时候,如果是用户手动在UI上点击创建的,需要执行初始化代码。但是如果是我们根据从后台读取的图像信息渲染到用户的界面上的,那么是不需要执行这个代码的。

能对图片批注进行修改的操作,主要有两个,一个是移动位置,一个是修改大小。这两个都是通过的修改操作,因此只要在父类Editor上作统一的修改即可,无需作特别的操作。

接下来是文字批注,文字批注也比较特别。选择添加文字批注的模式之后,就可以在页面上添加文字了。当用户在页面上点击的时候,会自动添加一个文本框。这个文本框的出现对应的也是FreeTextEditor的创建。需要注意的是,如果此时在文本框上不输入任何文字,就将焦点移动到其它的元素上的时候,那么这个文本框会自动进行销毁。即AnnotationEditorLayer自动执行这个批注的remove方法。这一点对我们想要来进行批注的管控非常的不友好,因此我们将这一段逻辑移除掉。

FreeTextEditor创建的逻辑非常简单,在对象创建完成后就会立刻完成初始化,不像图片批注,在对象创建完之后还有一段初始化逻辑,只有等初始化逻辑走完之后,对象自身的属性才算是完整。相比其它的批注类型,文本类型的批注可以移动,但无法手动修改文本框的大小。文本类型的批注可以修改文字的内容,因此在文本类型文字内容文字发生变化的时候,我们要执行相应的逻辑。但是又不能在批注每次文字发生变化的时候,都执行变化的逻辑,这样会导致修改的逻辑执行的过于频繁。因此我们只在文本编辑器输入文字结束后,才执行确认修改的逻辑。文本编辑器在输入文字结束后,会执行失焦的代码,即focusout(event)方法,因此我们通过修改这边的逻辑,将我们的钩子加入到文本批注的创建之中。

focusout(event) {
  const editMode = this.isInEditMode();
  const target = event.relatedTarget;
  const targetRight = !target?.closest(`#${this.id}`);
  super.focusout(event);
  if (this._focusEventsAllowed && editMode && targetRight) {
    this._uiManager.hook.postModifyConfirm(this);
  }
}

除了修改字体之外,文本还有修改颜色和字体这两种操作。不过这种操作在父类Editor里面有一些公共的逻辑,因此我们统一处理掉了。

最后,也是难度最高的,自由绘制的批注,对应的是PDF.js里的InkEditor。在用户选择了绘制模式之后,在所有的AnnotationEditorLayer上都会增加一个画板,供我们绘制。同样的,也创建一系列与这些画板一一对应的InkEditor对象。如果用户在这些画板上没有执行任何操作,就切换到其它模式去了。那么这些空白的画板也会和文本批注一样被文本批注一样被自动删除掉。

图片上的画板.gif
点击绘制线条批注的时候,每一页PDF上都添加了一个画板

因此我们无法像处理高亮批注一样,直接在创建后就开始执行我们的代码。我们也需要等InkEditor初始化完毕之后才能执行。InkEditor需要等到用户绘制完毕并主动失焦后才能完成初始化。在原始的PDF.js中,用户可以在一次绘制中绘制多条线。这个设计不太符合客户的需求,客户需要用户只要绘制完一条线就要创建一个InkEditor。因此我们对绘制的逻辑做了一定的改动,保证用户在绘制完一条线之后就会生成一个新的InkEditor。InkEditor绘制完毕后,PDF.js还会做一个自适应的操作,将画板的宽高改为适合图形大小的操作。我们在画板的宽高发生变化之后的地方,添加了一段代码。通过这段代码,把我们的逻辑加入到PDF.js绘制线条的逻辑当中去了。

canvasPointerup(event) {
  event.preventDefault();
  this.#endDrawing(event);
  this.focusout(event);
  if (this.drawingIsBegin) {
    this._uiManager.hook.postInitialize(this);
  }
}

由上述的代码可知,在线条绘制完毕之后,我们添加了一个钩子。同样的,这个钩子只针对用户手动添加的曲线,不包含由API生成的曲线。

在上述的四个批注中,我们添加了一系列的钩子,从而能够在用户操作批注的时候,及时的感知并执行相关的代码。这样用户创建、修改、删除批注的操作会全部调用我们的钩子,然后执行对应的命令——这个命令一般是调用后台接口,将批注信息实时同步到数据库当中去。有了这些数据之后,我们可以利用这些数据,在PDF展示的时候,同步将批注渲染出来。这样就实现了对PDF批注的完全控制。除此之外,我们还为批注特地开发了一批功能,主要包括展示、隐藏、跳转、选中四个功能。通过这四个功能,开发人员可以更好地控制PDF阅读器上的批注。展示、隐藏、选中都是调用PDF.js自带的API实现的。跳转是通过计算PDF页面所在的位置、批注在PDF页面上的位置、批注的高度这三者得出来的,最后滚动至相应的位置来实现的。

在处理前面的批注的时候,我们加了大量的钩子,那么钩子是在什么时候初始化的呢?我们在AnnotationUIManager创建结束的时候,通过EventBus添加一段新的逻辑,这一段逻辑能够将我们的钩子加入到AnnotationUIManager里面去了。下面是具体的代码:

// 这个命令在AnnotationEditorManager对象初始化结束后创建
getEventBus().on("annotationeditoruimanager", ()=>{
  const properties = getApplication().pdfViewer._layerProperties;
  const manager = properties.annotationEditorUIManager;

  // 增加四段逻辑,分别在批注构造函数执行完、初始化完、修改后、删除后执行
  manager.hook.postConstruct = postConstruct;
  manager.hook.postModifyConfirm = postModifyConfirm;
  manager.hook.postDestory = postDestory;
  manager.hook.postInitialize = postInitialize;

  const params = window.initPdfDocumentAnnos();
  // 保存到editorManager里面去
  editorManager.initEditorParameters(params, manager);
  controller.renderPreparedLayerAnnotations(editorManager.map);
});

总结一下,我们实现对批注的完全控制的方法。首先我们研究了批注每个批注的生命周期,并在批注创建、初始化、修改、删除这些比较关键的节点上增加了钩子,将实现将我们的代码植入到批注处理逻辑去。钩子除了要在对应的生命周期点通知后台之外,还要收集好每一个批注的参数,有了这些批注参数之后,我们就可以在下次打开PDF的时候,自动渲染这些批注并展示在页面上了。除此之外,我们还可以通过批注本身的API来控制这些批注,展示、选中、隐藏或跳转到他们。同时要注意的是,直接使用我们收集的参数来创建批注对象,相较于用户手动绘制,还是有一定区别的。因此我们在批注的初始化逻辑上做了一些兼容性的改动。这些改动不多也不复杂。

新增批注类型 —— 框选、下划线、删除线、箭头

当然,仅仅有自带的几个批注类型,对一个PDF阅读器来说,还是远远不够的。因此我们还需要在PDF.js现有的基础上添加更多的批注。经过记录分析后,我们发现大约有四个批注需要新增,分别是框选BoxCheckEditor、下划线UnderlineEditor、删除线StrikethroughEditor和箭头ArrowEditor。基框选的实现方式不同于上面所有的Editor,下划线和删除线的实现方式和高亮的实现方式类似,而箭头ArrowEditor的实现方式则是和绘制批注InkEditor有一定的相似之处。

四个新添加的批注.gif
新增的批注——框选、箭头、删除线、下划线

因为我们需要实现自定义的批注,因此知晓整个批注的处理逻辑对我们来说非常重要。在前面的分析中,我们已经分析了不少关于批注流程相关的内容以及具体每个批注实现的原理了。接下来我们将对批注的处理流程进行更深一步的分析。想要向PDF添加某种类型的批注,需要先将模式切换到希望添加的批注的模式上去。即点击右上角的对应的批注类型的按钮。

点击按钮之后,PDF.js会调用切换模式的代码。具体的代码简化后如下:

updateMode(mode = this.#uiManager.getMode()) {
  this.#cleanup();
  switch (mode) {
    case AnnotationEditorType.NONE:
      this.disableTextSelection();
      this.togglePointerEvents(true);
      this.disableClick();
      break;
    case AnnotationEditorType.INK:
      // We always want to have an ink editor ready to draw in.
      this.addInkEditorIfNeeded(false);
      this.disableTextSelection();
      this.togglePointerEvents(true);
      this.disableClick();
      break;
    case AnnotationEditorType.HIGHLIGHT:
      this.enableTextSelection();
      this.togglePointerEvents(false);
      this.disableClick();
      break;
    default:
      this.disableTextSelection();
      this.togglePointerEvents(true);
      this.enableClick();
  }
  ...
}

这一段的切换模式的代码相对来说是比较重要的,切换到不同模式,就可以选择不同类型的操作。有的模式可以点击的时候,就能触发点击相关的事件。有的模式可以选择文字,在文字选择结束后,就可以触发批注的创建。

在这里,我们深入分析一下enableClick()方法。通过分析这个方法,我们能够知道批注到底是怎么加上去的。下面是enableClick()的代码:

// 处理 鼠标按下和升起的事件的
enableClick() {
  this.div.addEventListener("pointerdown", this.#boundPointerdown);
  this.div.addEventListener("pointermove", this.#boundPointerMove);
  this.div.addEventListener("pointerleave", this.#boundPointerLeave);
  this.div.addEventListener("pointerup", this.#boundPointerup);
}

其实代码本身比较简单,就是给PDF页面对应的div增加四个监听器。分别处理指针按下、松开、移动和离开区域这四种事件。当阅读器所在的模式不相同情况下,这四个监听器执行的代码也不尽相同。其中为pointerleave事件添加的监听器,不是PDF阅读器自带的,而是我们新添加进去的。这是为了让框选批注的效果能够更好一些。 除了enableClick(),还需要注意一下InkEditor上有一段特别的代码:

this.addInkEditorIfNeeded(false);

这和前面提到的,绘制图形批注时做的特殊处理有关。当用户选择绘制图形的时候,PDF.js会给每一个页面,加上一个画板。然后让用户在这个画板上绘制图形。

在处理完切换完模式相关的逻辑之后,我们要开始处理添加批注相关的逻辑了。

框选批注实现

首先是框选批注,框选的批注实现方式非常的简单。当用户选中一段区域的时候,我们只需要将这段区域的位置,即x、y的值,和框选范围的宽高记录下来,就能得到得到一个框选批注了。具体的实现分三步走:

  1. 用户选择框选按钮,进入框选模式,PDF阅读器切换模式并添加点击事件。
  2. 用户点击页面开始框选,鼠标按下的时候创建框选批注BoxCheckEditor,创建一个具有一定透明度代表框选的div。
  3. 用户移动鼠标的时候,框选的范围(即div的大小)跟着用户的鼠标的变化而变化。
  4. 用户松开鼠标的时候,框选完成。框选对象也跟着初始化完成。

通过这种方式,框选功能就实现了。框选完成后,还可以拉伸和移动。这样,一个完整的框选功能就开发完了。其主要的逻辑涉及到两部分,一部分是新创建了一个BoxCheckEditor类,继承AnnotationEditor类。另一方面在AnnotationEditorLayer这个管理批注的类上将一些兼容性的功能加上去。这就是添加一个完整的框选功能的实现方式。

删除和下划线批注实现

这两个会与放在一起讲,因为它们看似是两个批注,实际上都是做一样的活儿,都是在做对选中文字进行画线这件事。只不过他们画的线一个实在文字的中间,一个实在文字的底部,实际并无太大区别。

实现删除线和下划线这两个批注,我们参考的是高亮批注。删除线和下划线在流程上和高亮是差不多的,有区别的地方是在删除线内部的实现上。高亮内部的实现相对是较为麻烦的。当我们在高亮模式下选中一部分文字之后,然后对这些文字进行高亮操作。从PDF阅读器的角度来看,其实非常简单。PDF阅读器首先通过document.getSelection()这个方法获取到选中的文字框选范围和文字本身,然后把这个信息丢给HighlightEditor就完事了。剩下的逻辑全部都在HighlightEditor里面。

HighlightEditor拿到的最为有效的信息,就是文字框选的范围。文字框选范围由一堆box组成,每一个box都是一个长方形。这些box可能是完全分离的,也可能是有部分重合的,也有可能一个box正好被另一个box完全覆盖住。因此HighlightEditor需要通过一个算法,来计算这些box组成了几个连在一起的区域。然后要计算出每一个区域的外边框轮廓,并且还要用svg将这些外边框的轮廓表示出来。除此之外,HighlightEditor还需要根据这些box的位置和大小,创建一个div,并保证这个div能够正正好的将所有的由box组成的区域全部包在一起。但是这样还不够,还有一个重要的目标没有达到。虽然创建了一个能够将所有box都包住的div,但是这个div里面只有box所在区域要被染色,可以被选中。因此需要用div的clip-path在div里面框选一部分区域,让这部分区域的颜色有所变化,从而达成这部分区域高亮的特效。除此之外,还要保证整个div并不是完全可以被选中的,只有div里面被框选的区域可以被选择,而未被框选的区域不可以被选中。HighlightEditor通过算法,根据一个或多个box计算出一个或多个区域,然后将这些区域的形状通过svg来表达出来。最后在div中引用svg,表示只有被svg包裹住的区域才能够有颜色改变和进行选中。这样一个完整的高亮功能就被实现了。

高亮批注中的clip-path、计算box组成的区域、转换svg等成分,在实现下划线和删除线的过程中,也都是我们需要的。首先从操作流程和接收到的参数这两点上,下划线、删除线和高亮面临的情况是一模一样的。不过高亮批注里面的处理的逻辑相较于下划线和删除线更为复杂一些。当我们拿到文字选中范围的一组box的时候,并不需要像高亮那样复杂的逻辑,我们只需要对这些box进行一系列的过滤,保留有效的框子即可。同一段文字,在被框选中的时候,可能会出现多个重复的box,因此我们需要对box进行去重。在多个重复box中,只保留一个。经过一轮过滤后,只剩下有效的box了。然后开始绘制svg,在每个box的中间都通过svg子标签path来绘制一条直线,就达成了删除线的效果了。在每个box的底部都绘制一条直线,就达成了下划线的效果了。对box的处理是高亮、删除线、下划线有比较大的区别的地方。其它的地方区别倒不是特别大。在处理完box之后,下划线、删除线后续的逻辑和高亮效果还是差不多的。要先计算出一个可以包裹所有的区域的div。然后要计算这些box组成区域的外边框,最后通过这个外边框框起来的区域限制div只能在一部分区域内进行点击、选中,其余部分不可以点击选中。 至此,删除线和下划线的实现也就基本完成了。本身要修改的代码并不多,但是要理解高亮的实现原理还是难度比较大的。

箭头

关于箭头的实现方式,我们想了多种方案。但是最后都被一一否决了。第一个方案是通过加入图像箭头来实现,这个方式不好,因为没办法实现箭头大小不动,而箭柄可以任意变长变短,其次操作起来不方便。第二次是用过div来实现,这个方法也不合适,当用户在一个点点击开始绘制箭头之后,它可能会向四面八方绘制箭头,这个时候div的处理就不太好办了。最后还是决定通过canvas来实现,效果最佳。而且我们也可以模仿绘制批注来简化开发。和绘制批注一样,当用户点击绘制模式的时候,PDF阅读器会在所有已经加载的页面上面增加一层画板供用户去绘制图形。因此我们也可以这么干,当用户切换到绘制箭头的模式的时候,我们也给所有已经加载的页面增加一层画板,供用户去绘制图形:

case AnnotationEditorType.ARROW:
  // 当切换到箭头模式的时候,给页面增加画板
  this.addArrowEditorIfNeeded(false);
  this.disableTextSelection();
  this.togglePointerEvents(true);
  this.disableClick();
  break;

有了画板以后就好办了,当用户开始点击的时候,就开始绘制,每当用户移动鼠标的时候,箭头就跟着鼠标移动。用户移动到哪里,箭头就移动到哪里。而箭头本身的三角形的倾斜角度会实时计算出来并做一定旋转。当用户松开鼠标或者鼠标移动到页面外面去的时候图形的绘制就结束了。此时我们的得到的就是一个用户绘制出来的,有效的箭头。绘制完毕之后,我们还需要做一些后置处理。首先就是根据用户绘制的区域将画板缩小到只能包住整个箭头为止(即箭柄和箭的头部这两个区域),然后再添加一个新的画板让用户继续去绘制。通过这几步操作,我们就可以完整的实现一个箭头功能。箭头本身可以移动、可以放缩。

通过以上的操作,我们又向阅读器添加了四种类型的批注,进一步丰富了PDF阅读器的批注功能。