代码环境基于:vue3 setup 语法糖、TypeScript

最近在实习的过程中接到了一个需求,需要实现 纯前端(Vue3 + TS)在 el-dialog 上对 PDF 文档进行预览,并且为其添加水印与分页展示。而我本人由于之前对文件预览与 canvas 方面的知识较为欠缺,在这次的实践上实际上也是踩了比较多的坑,在这里稍微记录一下吧。

对于第一个需求,实际上相关的解决方案比较多也容易实践,现在市面上有非常多的 Vue 相关 PDF 预览插件,比如 PDF.js、vue-pdf-app 等,基本上按照它们提供的官方教程一步步跟下来就可以实现,又或者是直接使用 iframe 来触发浏览器的原生 PDF 渲染功能。因此本篇文章的重点将放在 如何纯前端实现 PDF 水印的添加

# “水印” 到底是什么?

在正式开始之前,我们首先需要了解一下所谓的 “水印” 到底是什么?

用过飞书的大家应该都知道飞书文档背面斜起来的一个个文字,实际上就是以你自己的飞书用户名为文本的一段段不断重复的图像,将其作为背景图片放置在其他内容之下。

而转为我们前端领域的语言,则是使用 canvas,将水印图片绘制到 canvas 上,然后将其以 DOM 的形式插入到想要添加水印的位置。

# 前端实现 PDF 水印添加

# 水印的绘制

了解上述水印的定义后,我们就清楚前端要做的工作了:使用 canvas 绘制指定文字内容的水印图片。

Canvas API 进行一些调研后,发现了其提供的 fillText 方法能够将文字内容转换为图像。具体而言,我们可以通过一个离屏 Canvas(Offscreen Canvas)来绘制水印,它是在内存中进行渲染的用户不可见的 Canvas 元素。我们可以在这个离屏 Canvas 上生成水印图案,然后将 PDF 内容绘制在这个水印之上即可。

# PDF 绘制到水印上

在成功使用 canvas 完成对水印的绘制之后,接下来的问题便是怎么将 PDF 的内容整合到水印的 canvas 上面。基于先前完成的预览功能,我们使用了各种插件,或者是 iframe 来自动预览 PDF,我们的第一直觉是将水印 canvas 这个 DOM 节点直接置于实现预览的 DOM 上面,然后微调样式即可,从而在视觉效果上起到 “添加水印” 的效果。但是这种蒙混过关的方式的弊端也是显而易见的:

  • 不可保存:这种方法只是在浏览器中显示水印,但并不会真正将水印嵌入到 PDF 文件中。如果用户下载或打印 PDF 的内容,水印是不会被保留的。
  • ** 不稳定性和浏览器兼容性:** 这种方法实际上依赖于 iframe 或 PDF 预览插件的 DOM 结构和渲染方式,对于不同的浏览器可能会有不同的行为,局限性较大。
  • ** 缩放和滚动问题:** 如果用户在浏览器中缩放 PDF 页面,或者在 iframe 中滚动 PDF 内容,水印的定位可能会失准。你需要实时监控这些操作,并调整水印 canvas 的位置和大小,使其与 PDF 内容保持同步,这额外的处理工作又会增加手动编码的时间成本。
  • **PDF 页面变化:** 当用户在 PDF 预览器中切换页面时需要重新调整和绘制水印。这需要监听 PDF 预览器的页面切换事件,并确保水印正确应用于每一页。
  • ** 性能问题:** 由于每次页面切换或调整时都需要重新绘制水印,可能会影响页面的渲染性能,特别是在处理大文件或复杂布局的 PDF 时。

所以再经过进一步的调研,将 PDF 文件本身转为 canvas 的形式同样绘制到浏览器中,然后与之前创建好的离屏水印 canvas 进行整合,合为一个 canvas,从而实现完美的合二为一,这是现阶段比较完美的 PDF 水印添加方式。当今市面上的一款流行插件 PDF.js,则能够实现读取 PDF 内容并以 canvas 的形式将其绘制到指定的 DOM 上。

至此,我们大致的梳理完了整体的实现流程,可以进行一些总结:

  1. 初始化 PDF.js:导入 PDF.js 库,并设置工作器的路径 ( workerSrc )。
  2. 加载 PDF 文件:使用 PDFJS.getDocument() 从指定的 URL 加载 PDF 文件,并解析为 PDF 文档对象。
  3. 渲染 PDF 页面
    1. 获取要显示的页面 ( PDFDoc.getPage(pageNumber) ),并设置显示比例(缩放比例)。
    2. 根据页面尺寸和缩放比例,调整 canvas 的大小,并设置设备像素比(DPR)以确保清晰显示。
    3. 将 PDF 页面的内容渲染到 canvas 上。
  4. 创建水印
    1. 创建一个离屏 canvas ,并在其上绘制水印内容。
    2. 设置水印文本的字体、颜色、透明度和旋转角度等样式。
  5. 应用水印到 PDF 页面
    1. 将创建好的水印 canvas 转换为重复图案(pattern),并应用到 PDF 页面所在的 canvas 上。
    2. 使用 ctx.fillStyle 将水印覆盖在整个 PDF 页面内容上。
  6. 显示 PDF 页面:将处理后的 canvas 显示在页面中,并根据用户的操作(例如翻页)动态渲染新的页面并添加水印。

# 完整实现代码

接下来给出完整的使用 Vue3、 <script setup lang="ts"> 的实现组件:

<template>
  <div class="container">
    <el-button
      @click="
        showPdfWindow(
          'https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.0.3/Markdown.pdf'
        )
      "
      >打开弹窗</el-button
    >

    <el-dialog width="80%" v-model="dialogVisible" title="pdf预览">
      <div class="pdf-container">
        <canvas id="pdf-canvas"></canvas>
      </div>
      <div class="pagination-controls">
        <el-button @click="prevPage" :disabled="currentPage === 1"
          >上一页</el-button
        >
        <span>{{ currentPage }} / {{ pdfPages }}</span>
        <el-button @click="nextPage" :disabled="currentPage >= pdfPages"
          >下一页</el-button
        >
      </div>
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref, nextTick, watch } from "vue";
import * as PDFJS from "pdfjs-dist";

const dialogVisible = ref(false);

const pdfSrc = ref<string>("");
let pdfDoc: any = null;
const pdfPages = ref(0);
const pdfScale = ref(1.5);
const currentPage = ref(1);

/* ----------水印相关---------- */
const watermark = ref<string>("Sample Watermark");

const initWatermark = () => {
  let canvas = document.createElement("canvas");
  canvas.width = 200;
  canvas.height = 200;

  let ctx = canvas.getContext("2d");
  if (!ctx) return canvas;

  ctx.rotate((-18 * Math.PI) / 180);
  ctx.font = "14px Vedana";
  ctx.fillStyle = "rgba(200, 200, 200, 0.3)";
  ctx.textAlign = "left";
  ctx.textBaseline = "middle";
  ctx.fillText(watermark.value, 50, 50);

  return canvas;
};

const renderWatermark = (canvas: HTMLCanvasElement) => {
  const ctx = canvas.getContext("2d");
  if (!ctx) return;

  let watermarkCanvas = initWatermark();
  if (!watermarkCanvas) return;

  let pattern = ctx.createPattern(watermarkCanvas, "repeat");
  if (!pattern) return;

  ctx.rect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = pattern;
  ctx.fill();
};

/* ----------PDF 相关---------- */
// 加载pdf文件
const loadFile = async (url: string) => {
  PDFJS.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${PDFJS.version}/pdf.worker.min.mjs`;
  const response = await fetch(url);
  const arrayBuffer = await response.arrayBuffer();

  const loadingTask = PDFJS.getDocument(arrayBuffer);
  loadingTask.promise
    .then(async (pdf: any) => {
      pdf.loadingParams.disableAutoFetch = true;
      pdf.loadingParams.disableStream = true;
      pdfDoc = pdf; // 保存加载的pdf文件流
      pdfPages.value = pdfDoc.numPages; // 获取pdf文件的总页数
      currentPage.value = 1; // 初始化为第一页
      await nextTick(() => {
        renderPage(currentPage.value); // 渲染第一页
      });
    })
    .catch((error: any) => {
      console.warn(`pdfReader loadFile error: ${error}`);
    });
};

// 渲染当前页面
const renderPage = (num: number): void => {
  pdfDoc.getPage(num).then((page: any) => {
    const canvas: any = document.getElementById("pdf-canvas"); // 获取页面中的canvas元素
    // 以下canvas的使用过程
    const ctx: any = canvas.getContext("2d");
    const dpr = window.devicePixelRatio || 1;
    const bsr =
      ctx.webkitBackingStorePixelRatio ||
      ctx.mozBackingStorePixelRatio ||
      ctx.msBackingStorePixelRatio ||
      ctx.oBackingStorePixelRatio ||
      ctx.backingStorePixelRatio ||
      1;
    const ratio = dpr / bsr;
    const viewport = page.getViewport({ scale: pdfScale.value }); // 设置pdf文件显示比例
    canvas.width = viewport.width * ratio;
    canvas.height = viewport.height * ratio;
    canvas.style.width = viewport.width + "px";
    canvas.style.height = viewport.height + "px";
    ctx.setTransform(ratio, 0, 0, ratio, 0, 0); // 设置当pdf文件处于缩小或放大状态时,可以拖动
    const renderContext = {
      canvasContext: ctx,
      viewport: viewport,
    };
    // 将pdf文件的内容渲染到canvas中
    page.render(renderContext).promise.then(() => {
      // PDF渲染完成后,添加水印
      renderWatermark(canvas);
    });
  });
};

// 打开弹窗
const showPdfWindow = (src: string) => {
  pdfSrc.value = src;
};

watch(pdfSrc, () => {
  if (pdfSrc.value) {
    loadFile(pdfSrc.value);
    dialogVisible.value = true;
  }
});

// 下一页
const nextPage = () => {
  if (currentPage.value < pdfPages.value) {
    currentPage.value++;
    renderPage(currentPage.value);
  }
};

// 上一页
const prevPage = () => {
  if (currentPage.value > 1) {
    currentPage.value--;
    renderPage(currentPage.value);
  }
};
</script>

<style scoped>
.pdf-container {
  position: relative;
  width: 100%;
  overflow: hidden;
  display: flex;
  justify-content: center;
  align-items: center;
}

.pagination-controls {
  display: flex;
  justify-content: center;
  margin-top: 10px;
}

canvas {
  border: 1px solid #ccc;
  margin: 0 auto;
}
</style>