H5 渲染 PDF 并添加高亮的两种方案【附源码】

背景

最近在 H5 项目中做了一个 PDF 阅读器,需要同时满足两个需求:

  • 在手机端正常渲染 PDF;
  • 根据后端返回的坐标,在 PDF 页面上显示文本高亮。

最终我尝试了两种方案,底层都是基于 pdfjs-dist

  1. vue-virtual-scroller + pdfjs-dist,自己实现 PDF 页面的虚拟滚动;
  2. 直接使用 PDF.js 官方 PDFViewer,让 PDF.js 自己管理页面的渲染和虚拟化。

两种方案都可以实现,但是实际使用下来,我更推荐第二种。

注意: pdfjs-dist 5.x 在 H5 环境中的兼容性相对更好,而 6.x 在部分低端手机环境下出现了 PDF 页面无法正常渲染。

方案一:选择 vue-virtual-scroller

最开始的想法其实比较简单:PDF.js 负责把每一页渲染到 Canvas,Vue 负责页面,高度和滚动交给虚拟滚动 vue-virtual-scroller

因为实际项目中可能会遇到上百页的 PDF,如果一次性把所有页面都渲染成 Canvas,内存占用会比较高。

问题1. 不要假设 PDF 每一页都是 A4

最开始我直接按照 A4 的比例给 PDF 页面设置高度:

html 复制代码
<div class="aspect-[3/4]">

后来发现这是不可靠的。PDF 的每一页都可以拥有不同的尺寸和比例,并不一定是 A4。如果直接使用固定的 aspect-[3/4],会造成虚拟滚动错乱渲染问题。

问题2. 虚拟滚动带来的竞态问题

vue-virtual-scroller 会复用组件,同一个 Canvas 可能上一秒显示第 1 页,下一秒就被复用去显示第 20 页。

由于 PDF 渲染是异步的,旧页面的渲染任务可能比新页面更晚结束,最终把已经渲染好的 Canvas 覆盖掉,造成页面内容错乱。

解决方式很简单:给每次渲染分配一个唯一的 renderId,只有最新的一次渲染才允许落到 Canvas,旧任务直接丢弃。

ts 复制代码
let currentRenderId = 0;

const renderPage = async () => {
  const renderId = ++currentRenderId;

  const page = await props.pdfDoc.getPage(props.pageIndex + 1);

  // 当前组件已经被复用,之前的请求作废
  if (renderId !== currentRenderId) {
    page.cleanup();
    return;
  }

  // 开始真正的 Canvas 渲染
};

问题3. PDF 页面是纯图片时,虚拟列表可能出现高度问题

如果 PDF 的某一页是纯图片,在快速滑动渲染的时候,可能会出现图片显示不完整的问题。

排查下来是 vue-virtual-scroller 对于每项里面的高度的监听方式有问题,即使修改了 DynamicScrollerItem 内部元素的高度,它也不会触发重新计算。

最后用了一个比较 hack 的方式,通过增加一个临时的 1px 元素,让 DOM 的尺寸发生一次变化,从而触发 DynamicScrollerItem 重新测量:

html 复制代码
<div v-show="sizeTrigger" class="h-px" />

<script setup type='tx'>
const triggerSizeUpdate = () => {
  sizeTrigger.value = true;

  requestAnimationFrame(() => {
    sizeTrigger.value = false;
  });
};
</script>

注意下面的触发还不能使用 nextTick,还必须使用 requestAnimationFrame

问题4. PDF 高亮如何定位?

后端返回的高亮数据如下:

ts 复制代码
interface HighlightData {
  bbox: [number, number, number, number];
  pageNo: number;
  pageSize: [number, number];
}

bbox 代表高亮的上下左右的位置,pageNo 是高亮的页码,pageSize 是页面的宽和高。

这里不建议直接使用 px。因为 PDF 页面会发生缩放,而高亮应该跟着 PDF 页面一起缩放。

所以使用百分比来定位:

ts 复制代码
const getHighlightStyle = (hl: HighlightData) => {
  const [left, top, right, bottom] = hl.bbox;
  const [pageWidth, pageHeight] = hl.pageSize;

  return {
    left: `${(left / pageWidth) * 100}%`,
    top: `${(top / pageHeight) * 100}%`,
    width: `${((right - left) / pageWidth) * 100}%`,
    height: `${((bottom - top) / pageHeight) * 100}%`,
  };
};

问题5. 高亮层和 Canvas 必须严格保持一致

错误的结构类似:

html 复制代码
<div class="relative pb-2">
  <canvas />
  <div class="absolute inset-0">
    <!-- highlight -->
  </div>
</div>

因为 padding-bottom 会参与父元素的尺寸计算,而 absolute inset-0 又是相对于父元素定位的,高亮的坐标就可能整体产生偏移。

解决方案是把 padding 独立出来:

html 复制代码
<div class="pb-2">
  <div class="relative">
    <canvas class="block w-full"></canvas>
    <div class="absolute inset-0">
      <!-- highlight -->
    </div>
  </div>
</div>

问题6. 自己处理"滚动到高亮"

因为虚拟列表只负责页面,所以如果打开 PDF 后,希望自动定位到第一个高亮,还需要自己处理一个异步流程。

最终的流程是:

text 复制代码
1. 加载 PDF
      ↓
2. 找到第一个高亮所在的页面
      ↓
3. DynamicScroller 滚动到该页面
      ↓
4. 等待第一个高亮的页面渲染完成
      ↓
5. 滚动到页面内部的具体高亮位置

方案二:直接使用 PDF.js 官方 PDFViewer

PDF.js 官方 Viewer 的整体结构和方案一有很大的区别。

1. PDFViewer 的基本使用

核心代码如下,其中 eventBus 是用来做 pdf 渲染生命周期的事件派发。

ts 复制代码
const eventBus = new pdfjsViewer.EventBus();

const pdfViewer = new pdfjsViewer.PDFViewer({
  container: viewerContainerRef.value,
  viewer: viewerRef.value,
  eventBus,
});

模板只需要提供 PDF.js 需要的容器:

html 复制代码
<div ref="viewerContainerRef">
  <div ref="viewerRef" class="pdfViewer"></div>
</div>

这里需要特别注意:

html 复制代码
class="pdfViewer"

这个 class 是 PDF.js Viewer 需要的,不要随意修改。

2. 利用 pagesinit 设置初始缩放

PDF.js 提供了很多事件,其中一个比较重要的是 pagesinit

ts 复制代码
eventBus.on('pagesinit', function () {
  //...
});

这个事件触发时,PDF 页面已经初始化完成,可以拿到第一张页面的 viewport。根据这个 viewport 可以计算一个刚好适应屏幕宽度的 scale。

3. 使用 Teleport 添加高亮

第二种方案中,PDF 页面由 PDF.js 的 PDFViewer 负责渲染,而高亮部分仍然希望使用 Vue 组件来实现。

这时候就需要解决一个问题:

如何把 Vue 的高亮组件添加到 PDF.js 动态创建的页面 DOM 中?

PDF.js 在 pagerendered 事件中,可以获取当前页面对应的 pageView,通过 pageView.div 就可以拿到当前 PDF 页面的 DOM:

ts 复制代码
eventBus.on('pagerendered', (evt: PageRenderedEvent) => {
  const pageView = evt.source;
  const pageDiv = pageView.div;
});

然后在当前页面中创建一个专门用于挂载高亮的容器,有了这个容器以后,就可以使用 Vue 的 Teleport,把高亮组件直接渲染到 PDF.js 对应的页面中。

4. cMapUrl 不要直接写死根路径

执行以下命令把字体拷贝到 public 目录下:

bash 复制代码
cp -r node_modules/pdfjs-dist/cmaps public/

使用 VITE_BASE_URL 来指定 canvas 字体加载的路径。

ts 复制代码
cMapUrl: `${import.meta.env.BASE_URL}cmaps/`,
cMapPacked: true,

总结

这次做 H5 PDF 高亮,最大的体会是:

不要急着自己实现 PDF Viewer,先看看 PDF.js 官方已经帮你做了什么。

第一种方案:

text 复制代码
vue-virtual-scroller
        +
pdfjs-dist

优点是自由度高,可以完全按照自己的业务模型组织 PDF 页面。

但是需要自己解决:

text 复制代码
虚拟滚动
页面高度
Canvas 生命周期
渲染竞态
页面清理
滚动定位
缩放

第二种方案:

text 复制代码
pdfjs-dist
      +
PDFViewer
      +
Teleport

则可以把 PDF 相关的底层问题尽可能交给 PDF.js,业务层只需要在 pagesinitpagerendered 等关键生命周期节点接入自己的逻辑。

对于普通的:

H5 PDF 阅读 + 缩放 + 高亮 + 定位

我最终更推荐 第二种方案

而第一种方案也并不是没有价值------如果 PDF 页面本身需要和业务虚拟列表深度融合,或者每一页都有大量自定义业务组件,那么自己控制页面仍然是更灵活的选择。

源码地址

github.com/zm8/wechat-...

相关推荐
因_崔斯汀2 小时前
用 AI 生成 Three.js 沉浸式网站,代码与 Skill 均已开源
前端·人工智能
葡萄城技术团队2 小时前
一键触发工作表中所有异步函数:用依赖单元格刷新 SpreadJS 公式
前端
Bigger2 小时前
谁动了我的 URL?——记一次微前端"灵异 Bug"的排查实录
前端·ai编程·vue-router
JavaGuide2 小时前
轻量开源版 IDEA 来了!
前端·后端
张洪权3 小时前
nest.js websocket 群聊----私聊功能
前端·nestjs
流光D3 小时前
AI Era: Building Web Sites and Configuring Nginx Reverse Proxy Process
前端·人工智能·nginx
sakidd3 小时前
VS Code 的 AI Chat 现在已经这么能干了?
前端
计算机魔术师3 小时前
英伟达砸130亿美元买下一个平台,黄仁勋到底在怕什么?
前端
leoZ2313 小时前
第 2 篇:搭建地基——Vue3 + Vite + Tailwind v4 + shadcn-vue
前端·javascript·vue.js·人工智能·目标检测·数据挖掘·语音识别