前端 PDF 预览方案

产品需求描述

后端返回 pdf 文件链接,前端预览,要求不允许用户下载、复制、打印。

初步方案

  1. 浏览器支持 pdf 文件预览功能,通过 window.open 的方式打开新的链接,效果如下:

问题:浏览器提供的下载、打印控件以及复制内容、右键下载等操作无法干预

  1. 以 iframe 的方式加载文件,并禁用 iframe 的右键:
    1
    <iframe ref="iframe" :src="pdfUrl" />

    网上找到的方案大多为 document.oncontextmenu = function() { return false; },但实测发现该方法仅适用于子页面内容没加载之前,如果资源加载完成则右键操作由子页面本身控制。

思路:在 iframe 加载成功后,为子页面注册对应的事件处理函数。

1
2
3
4
let iframe = this.$refs.iframe
iframe.onload = () => {
window.frames[0].contentDocument.oncontextmenu = () => false
}

问题:提示跨域

原因分析:网页地址与资源链接的域名不一致,导致 iframe 跨域。

解决方案:由后端配置同源头解决跨域问题,但使用 iframe 无法解决用户复制文字的问题。

  1. 使用 embed 标签,禁止右键:
1
2
3
<embed :src="pdfUrl" enableContextMenu="false" />
<!-- 或者 -->
<embed :src="pdfUrl" oncontextmenu="window.event.returnValue=false" />

问题:仅在音视频资源下生效

思考

分析

以上方案无法解决问题的原因在于利用了浏览器的默认特性,且这些特性是无法干预的。因此需要转换思路,将不可控的特性转换为已有的可控特性。

思路

利用图片无法选择复制的特性,将 pdf 转成图片,并限制用户无法右键保存。

解决方案

基于 pdf.js 将 pdf 按页转换为一张张图片,通过 img 标签渲染,并禁用右键和图片拖拽。

Step1 读取 pdf 内容

1
window.pdfjsLib.getDocument(pdfUrl)

由于资源链接不在本地,pdf.js 会报跨域的错误。

方案:参考该链接,需要手动修改 pdf.js 的逻辑,并要求服务端配合解决跨域的问题。

修改 pdf.js 逻辑不利于后期升级和维护,因此我们换一种思路:基于 ajax 请求。

1
2
3
4
5
6
7
axios.request({
url: imgUrl,
type: 'get',
responseType: 'blob'
}).then(res => {
window.pdfjsLib.getDocument(res.data)
})

成功解决跨域问题,并返回了 blob 对象,但在初始化 pdf.js 时报了如下错误:

查询源码得知,pdf.js 不支持读取 blob 对象,因此需要将 blob 转为 url:

1
window.pdfjsLib.getDocument(window.URL.createObjectURL(res.data))

Step2 解析文件,渲染到 canvas

调用 pdf.js 的 api 进行解析:

1
2
3
4
5
6
7
window.pdfjsLib.getDocument(window.URL.createObjectURL(res.data)).promise.then(pdf => {
// 解析第一页
pdf.getPage(1).then(page => {
let scale = 1
let viewport = page.getViewport({ scale })
})
})

渲染到 canvas:

1
2
3
4
5
6
7
8
9
10
11
12
13
pdf.getPage(1).then(page => {
let scale = 1
let viewport = page.getViewport({ scale })
let canvas = this.$refs.canvas
let context = canvas.getContext('2d')
canvas.width = viewport.width
canvas.height = viewport.height
let renderContext = {
canvasContext: context,
viewport: viewport
}
page.render(renderContext)
})

Step3 渲染图片

1
<img :src="pdfUrl" />
1
2
page.render(renderContext)
this.pdfUrl = canvas.toDataURL('image/png')

Step4 禁止右键和复制

1
<img :src="pdfUrl" :draggable="false" oncontextmenu="return false;" />

Step5 将 pdf 的每一页转换为图片

上述步骤已经完成大体逻辑,但在 step2 中只是将 pdf 的第一页解析成了图片。实际需求需要解析每一页,然后通过轮播的方式显示图片。因此需要做以下改造:

1
2
3
4
5
<Carousel v-if="pdfImgsShow">
<CarouselItem v-for="(item, index) in pdfImgs" :key="index">
<img :src="item" :draggable="false" oncontextmenu="return false;" />
</CarouselItem>
</Carousel>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
window.pdfjsLib.getDocument(window.URL.createObjectURL(res.data)).promise.then(async pdf => {
for(let i = 1; i <= pdf.numPages; i++) {
let page = await pdf.getPage(i)
let scale = 1
let viewport = page.getViewport({ scale })
let canvas = this.$refs.canvas
let context = canvas.getContext('2d')
canvas.width = viewport.width
canvas.height = viewport.height
let renderContext = {
canvasContext: context,
viewport: viewport
}
page.render(renderContext)
this.pdfImgs.push(canvas.toDataURL('image/png'))
}
this.pdfImgsShow = true
})

如下图所示,已成功生成图片:

但当 pdf 页数大于 1 时,控制台会报如下错误:

查询源码得知,page.render 方法是异步函数,在循环体内部调用 render 方法会导致同时存在多个未执行完的 render,引发上述错误。

解决方法:

1
2
await page.render(renderContext).promise
this.pdfImgs.push(canvas.toDataURL('image/png'))

Step6 调整清晰度

实际测试发现,canvas 导出的图片清晰度较差。
查询资料得知是因为 dpi 的问题,参考该文章,调整 canvas 画布的大小:

1
2
3
4
5
6
7
8
let UNITS = 2
canvas.width = Math.floor(viewport.width * UNITS)
canvas.height = Math.floor(viewport.height * UNITS)
let renderContext = {
transform: [UNITS, 0,0, UNITS, 0, 0],
canvasContext: context,
viewport: viewport
}

完整代码

1
2
3
4
5
6
<Carousel v-if="pdfImgsShow">
<CarouselItem v-for="(item, index) in pdfImgs" :key="index">
<img :src="item" :draggable="false" oncontextmenu="return false;" />
</CarouselItem>
</Carousel>
<canvas ref="canvas" style="display: none;" />
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
axios.request({
url: imgUrl,
type: 'get',
responseType: 'blob'
}).then(res => {
window.pdfjsLib.getDocument(window.URL.createObjectURL(res.data)).promise.then(async pdf => {
let UNITS = 2
for(let i = 1; i <= pdf.numPages; i++) {
let page = await pdf.getPage(i)
let scale = 1
let viewport = page.getViewport({ scale })
let canvas = this.$refs.canvas
let context = canvas.getContext('2d')
canvas.width = Math.floor(viewport.width * UNITS)
canvas.height = Math.floor(viewport.height * UNITS)
let renderContext = {
transform: [UNITS, 0,0, UNITS, 0, 0],
canvasContext: context,
viewport: viewport
}
await page.render(renderContext).promise
this.pdfImgs.push(canvas.toDataURL('image/png'))
context.clearRect(0, 0, viewport.width, viewport.height)
this.pdfImgsShow = true
}
})
})