代码环境基于: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 上。
至此,我们大致的梳理完了整体的实现流程,可以进行一些总结:
- 初始化 PDF.js:导入 PDF.js 库,并设置工作器的路径 (
workerSrc
)。 - 加载 PDF 文件:使用
PDFJS.getDocument()
从指定的 URL 加载 PDF 文件,并解析为 PDF 文档对象。 - 渲染 PDF 页面:
- 获取要显示的页面 (
PDFDoc.getPage(pageNumber)
),并设置显示比例(缩放比例)。 - 根据页面尺寸和缩放比例,调整
canvas
的大小,并设置设备像素比(DPR)以确保清晰显示。 - 将 PDF 页面的内容渲染到
canvas
上。
- 获取要显示的页面 (
- 创建水印:
- 创建一个离屏
canvas
,并在其上绘制水印内容。 - 设置水印文本的字体、颜色、透明度和旋转角度等样式。
- 创建一个离屏
- 应用水印到 PDF 页面:
- 将创建好的水印
canvas
转换为重复图案(pattern),并应用到 PDF 页面所在的canvas
上。 - 使用
ctx.fillStyle
将水印覆盖在整个 PDF 页面内容上。
- 将创建好的水印
- 显示 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>