js预览blod流pdf文件
前情提要
首先这是一个项目需求,负责人只说了让我实现一个pdf预览打印功能,后台数据格式,页面样式都没有。好吧,那我就按照我的想法来。
1. 通过react-pdf插件实现
因为没有数据,所以我先考虑了复杂但兼容性高的实现方式:react-pdf。这其实不是一个很复杂的插件。
1.1 基本的使用如下:
import React, { useState } from 'react';
import { Document, Page} from 'react-pdf/dist/esm/entry.webpack';
export default () => {
const [numPages, setNumPages] = useState(null);
const [pageNumber, setPageNumber] = useState(1);
// pdf加载成功
const onDocumentLoadSuccess = ({ numPages }) => {
setNumPages(numPages);
};
return (
<Document
file={pdfUrl} // 文件地址
className="pdf-viewer-show"
onLoadSuccess={onDocumentLoadSuccess}
>
<Page pageNumber={pageNumber} scale={1.8} />
</Document>
)
}
1.2 下载功能
既然有文件地址,那么下载就很简单啦。
//下载pdf
const downloadPdf = () => {
const link = document.createElement('a');
link.setAttribute('download', '');
link.href = pdfUrl;
link.click();
};
1.3 打印功能
其实打印功能是我实现起来比较麻烦的功能,要考虑的东西会更多,本质上是打印当前html的指定dom元素的内容。
首先要对打印的页面进行一些配置:
/**
* printHtml.js 打印当前html的指定dom元素的内容
* html 指定的dom元素
*/
export default function printHtml(html) {
let style = getStyle();
let container = getContainer(html);
document.body.appendChild(style);
document.body.appendChild(container);
getLoadPromise(container).then(() => {
window.print();
document.body.removeChild(style);
document.body.removeChild(container);
});
}
// 设置打印样式
function getStyle() {
let styleContent = `#print-container {
display: none;
}
@media print {
body > :not(.print-container) {
display: none;
}
html,
body {
display: block !important;
}
#print-container {
display: block;
}
}`;
let style = document.createElement('style');
style.innerHTML = styleContent;
return style;
}
// 清空打印内容
function cleanPrint() {
let div = document.getElementById('print-container');
if (!!div) {
document.querySelector('body').removeChild(div);
}
}
// 新建DOM,将需要打印的内容填充到DOM
function getContainer(html) {
cleanPrint();
let canvas = html.getElementsByTagName('canvas');
let container = document.createElement('div');
container.setAttribute('id', 'print-container');
let imgs = '';
for (let el = 0; el < canvas.length; el++) {
imgs += `<img src=${canvas[
el
].toDataURL()} alt='' style="page-break-after:always"/>`;
}
container.innerHTML = imgs;
return container;
}
// 图片完全加载后再调用打印方法
function getLoadPromise(dom) {
let imgs = dom.querySelectorAll('img');
imgs = [].slice.call(imgs);
if (imgs.length === 0) {
return Promise.resolve();
}
let finishedCount = 0;
return new Promise((resolve) => {
function check() {
finishedCount++;
if (finishedCount === imgs.length) {
resolve();
}
}
imgs.forEach((img) => {
img.addEventListener('load', check);
img.addEventListener('error', check);
});
});
}
然后在pdf组件里调用(相关的下载/打印/分页按钮我就不贴出来了):
// 打印pdf
const printPdf = () => {
let $dom = document.querySelector('.pdf-viewer');// pdf预览dom
printHtml($dom);
};
我是分页展示pdf,但是打印时又需要所以都打印出来,所以我同时生成了两个react-pdf组件:
{/* 展示用*/}
<Document
file={pdfBlob}
className="pdf-viewer-show"
onLoadSuccess={onDocumentLoadSuccess}
>
<Page pageNumber={pageNumber} scale={1.8} />
</Document>
{/* 下载用*/}
<Document file={pdfBlob} className="pdf-viewer">
{Array.from(new Array(numPages), (el, index) => (
<Page
key={`page_${index + 1}`}
pageNumber={index + 1}
scale={1.8}
/>
))}
</Document>
1.4 其他问题
1.4.1 电子签章展示问题
react-pdf默认是不展示电子签章的,要想展示签章,那么需要修改源码:
import { Document, Page, pdfjs } from 'react-pdf/dist/esm/entry.webpack';
//更改引入pdf.worker.js路径
//将pdf.worker.js中的this.setFlags(AnnotationFlag.HIDDEN);注释掉就会显示电子签章,反之不显示。
pdfjs.GlobalWorkerOptions.workerSrc = `./pdf.worker.js`;
关于pdf.worker.js文件的获取,emmmm,我是从node_module的react-pdf文件夹里复制出来的(其实这个功能我还没有试验过,大佬们说可行)
1.4.2 同时生成多个pdf组件
由于在我同时多次调用pdf组件时出现了一个奇怪的问题:某个组件成功展示pdf那么下一个pdf组件必然无法展示pdf,再下一个组件必然可以展示pdf(像是pdf只能间隔展示)。在网上查了很多,大概猜测是由于展示的react-pdf组件未渲染完成即关闭时promise任务报错造成了任务的阻塞,我的解决办法是打开新的pdf组件时延迟渲染react-pdf组件:
const [renderStatus, setRenderStatus] = useState(false);
useEffect(() => {
// 延迟渲染react-pdf组件,
//解决react-pdf组件未渲染完成即关闭时promise任务报错造成的任务的阻塞
// 报错存在,但是不会影响页面渲染
setTimeout(() => {
setRenderStatus(true);
}, 200);
}, []);
return (
{
renderStatus&&(...react-pdf组件)
}
)
2. iframe实现预览pdf
在我哼哧哼哧学习了react-pdf的使用后,我拿到了后台返回的数据:blod流pdf文件。突然就觉得我之前花费的功夫都白搭了,直接使用iframe预览pdf不香,自带下载打印功能,都不用自己开发了(没有要求样式,那还不是怎么简单怎么来,丑就丑点吧)。
由于后台的文件请求地址上可以直接带上token,我也不需要考虑token的问题了
import React from 'react';
export default () => {
return (
<iframe
src={pdfUrl} //文件地址
className="pdf-iframe"
title="pdf预览"
frameBorder="no"
/>
)
}
3. iframe预览pdf+token
3.1 header添加token
老实说,将token拼接在iframe的src里是一个极不安全的做法,token当然要放在header里啦。但是,iframe的src无法设置header,只能另辟蹊径,将pdf文件的内容通过请求获取下来,通过URL.createObjectURL()
方法将内容转化为一个url赋值给iframe:
import React, { useState, useRffect } from 'react';
export default () => {
// 将请求返回的pdf内容转化为blod格式时,
//有可能还未转化成功就执行URL.createObjectURL()操作了,
//所以增加一个转化是否完成的判断
const [pdfLoading, setPdfLoading] = useState(false);
// pdf 预览
useEffect(() => {
setPdfLoading(true);
function handler (res) {
let { status, response } = res.currentTarget;
if (status === 200) {
if (response && new Blob([response]).size > 4) {
setPdfLoading(false);
}
const binaryData = [];
binaryData.push(response);
let data_url = window.URL.createObjectURL(
new Blob(binaryData, { type: 'application/pdf' })
);
document.querySelector('#pdf-iframe').src = data_url;
} else {
console.error('no pdf :(');
}
}
let xhr = new XMLHttpRequest();
xhr.open(
'GET',
pdfUrl
);
// 添加token
xhr.setRequestHeader(
'Authorization',
sessionStorage.getItem('Authorization')
);
xhr.onreadystatechange = handler;
xhr.responseType = 'arraybuffer';
xhr.send();
},[])
return (
<iframe
className="pdf-iframe"
title="pdf预览"
frameBorder="no"
style={{display:pdfLoading?'none':'block'}}
/>
)
}
3.2 其他问题
- 隐藏工具栏的方法是,在PDF文件url地址后面 拼接 #scrollbars=0&toolbar=0&statusbar=0 参数
- 工具栏中的 title 不正确,原因是 工具栏中的title读取的是url 地址中最后一个 ‘/’ 后面的参数作为title值
4 pdfjs+全屏预览(2023-07-20)
需求总是迭代的。最新的pdf预览需求是我新负责的一个vue2项目,目前的需求是缩略图平铺展示+全屏预览翻页及放大缩小。
4.1 工具
这次事项主要使用了pdfjs插件和screenfull插件,其中pdfjs为js文件(可自行前往pdfjs官网下载),screenfull为npm包。
pdfjs放置在项目的public目录下,并在index.html中引入:
screenfull包通过npm instll screenfull
指令下载即可
4.2 开发
首先该项目使用了antd公共组件库,所以有部分代码中包含antd组件的调用。
样式方面可以按照自己的需求进行调整,目前我只提供自己的代码仅供参考:
template>
<div v-if="!pdfUrl" style="margin:100px 0;textAlign:center;width:100%">
<span>请选择要预览的文件</span>
</div>
<div style="width:100%;height: 100%;" v-else>
<a-spin :spinning="pageRendering" style="width:100%;height: 100%;">
<div style="height: 100%;">
<div id="the-canvas"></div>
</div>
</a-spin>
</div>
</template>
<script>
import screenfull from 'screenfull' // 引入全屏插件
export default {
data(){
return {
pdfUrl: 'pdfurl', // 要展示的pdf文件地址,
currentPage: 0, // 全屏时展示的当前页
totalPage: 0, // 总页数
pdfScale: 1.2, // 当前缩放尺寸
pageRendering: false, // pdf渲染状态
pdfRef: null // pdfjs插件实例
}
},
watch: {
pdfUrl: {
handler(val){
// pdf文件地址变化时重新渲染
if(val) this.getNumPages()
},
immediate: true
}
},
methods: {
// 计算PDF页码总数并显示
getNumPages(){},
// 全屏展示
screenfullView(){},
// 放大缩小
scaleRender(type){},
// PDF渲染
pdfRender(){}
}
}
</script>
<style lang="less" scoped>
#the-canvas {
display: block;
width: 100%;
/deep/canvas {
float: left;
width: calc(33% - 16px);
border: 1px solid #dddddd;
box-sizing: border-box;
margin: 0 16px 16px 0;
}
/deep/.the-canvas-full{
.canvas-content{
overflow: auto;
background-color: #525659;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: calc(100% - 56px);
}
canvas{
width: auto;
margin: auto;
}
.tool-box{
width: 100%;
height: 56px;
background: #323639;
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
}
.pages, .scale{
color: #fff;
font-size: 20px;
background-color: #292b2c;
padding: 4px 8px;
margin: 0 8px;
}
.divider{
width: 2px;
height: 45%;
background-color: #626262;
}
.prev-page, .next-page, .bigger, .smaller{
width: 40px;
height: 40px;
color: #fff;
cursor: pointer;
font-size: 32px;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
}
.prev-page.disabled,.next-page.disabled, .smaller.disabled, .bigger.disabled{
color: rgba(255,255,255,.45);
cursor: not-allowed;
}
}
}
</style>
4.3 详解主要方法
getNumPages
-计算PDF页码总数并显示
getNumPages() {
if (!this.pdfUrl) return // 不存在pdf地址直接返回,也可以在这里做错误提示
this.pageRendering = true
const pdfjsLib = window['pdfjs-dist/build/pdf']
pdfjsLib.GlobalWorkerOptions.workerSrc = `/js/pdf.worker.js` // 引入pdfjs文件,路径请按照实际位置进行调整
const loadingTask = pdfjsLib.getDocument({
url: this.pdfUrl,
// 引入pdf.js使用字体,如需要用到的字体不存在(展示文档缺少内容)可在/js/cmaps文件夹下增加对应的字体文件
// 步骤:将地址更换为https://cdn.jsdelivr.net/npm/pdfjs-dist@2.9.359/cmaps/,通过控制
// 台查看文档所需字体,再去该地址将对应字体文件下载至本地后加到/js/cmaps文件夹中
cMapUrl: `/js/cmaps/`,
cMapPacked: true
})
const that = this
loadingTask.promise.then(
function (pdf) {
that.pdfRef = pdf // 保存当前pdf实例
that.pageRendering = false
const canvasElms = document.getElementById('the-canvas').querySelectorAll('canvas')
if (canvasElms.length) {
for (let i = 0; i < canvasElms.length; i++) {
canvasElms[i].remove()
}
}
that.totalPage = pdf.numPages // 总页数
for (let i = 1; i <= pdf.numPages; i++) { // 循环渲染每一页
const canvasObj = document.createElement('canvas')
canvasObj.setAttribute('id', `the-canvas-${i}`)
canvasObj.addEventListener('click', () => { // 为每一页绑定全屏事件
that.currentPage = i
that.screenfullView()
})
document.getElementById('the-canvas').append(canvasObj)
pdf.getPage(i).then(function (page) { // 渲染PDF
const scale = that.pdfScale
const viewport = page.getViewport({ scale: scale })
const canvas = document.getElementById(`the-canvas-${i}`)
const context = canvas.getContext('2d')
canvas.height = viewport.height
canvas.width = viewport.width
const renderContext = {
canvasContext: context,
viewport: viewport
}
page.render(renderContext)
})
}
},
reason => {
// 这里为渲染失败的回调,可做错误提示
}
)
}
screenfullView
-全屏
screenfullView() {
if (!screenfull.isEnabled) return // 判断系统是否允许全屏,不允许直接返回
const parent = document.getElementById('the-canvas')
// 创建一个canvas副本
const canvas = document.createElement('canvas')
canvas.setAttribute('id', `the-canvas-full-${this.currentPage}`)
// 判断是否处在全屏状态
// 如果不是则创建一个dom展示要全屏展示的内容,如果存在则不创建,直接替换现有dom的内容
let fullDom = document.querySelector(`.the-canvas-full`)
if (!fullDom) {
fullDom = document.createElement('div')
fullDom.setAttribute('class', `the-canvas-full`)
parent.append(fullDom)
} else {
fullDom.innerHTML = ''
}
// 定义工具栏
const toolBox = document.createElement('div')
toolBox.setAttribute('class', 'tool-box')
// 翻页
let prePage = null
let nextPage = null
if (this.totalPage > 1) { // 总页数大于1时展示翻页
let prevClass = 'prev-page'
let nextClass = 'next-page'
prePage = document.createElement('div')
nextPage = document.createElement('div')
if (this.currentPage > 1) { // 当前页码大于1,为当前页的向前翻页dom绑定翻页事件
prePage.addEventListener('click', () => {
this.currentPage = this.currentPage - 1
this.screenfullView()
})
}
if (this.currentPage < this.totalPage) {// 当前页码小于总页码,为当前页的向后翻页dom绑定翻页事件
nextPage.addEventListener('click', () => {
this.currentPage = this.currentPage + 1
this.screenfullView()
})
}
// 页面等于1或等于总页码,对应翻页dom绑定禁用class
if (this.currentPage === 1) prevClass = 'prev-page disabled'
if (this.currentPage === this.totalPage) nextClass = 'next-page disabled'
prePage.setAttribute('class', prevClass)
nextPage.setAttribute('class', nextClass)
prePage.textContent = '<'
nextPage.textContent = '>'
// 页码展示器
const pages = document.createElement('div')
pages.setAttribute('class', 'pages')
pages.textContent = `${this.currentPage}/${this.totalPage}`
// 将页码dom添加进工具栏
toolBox.append(prePage)
toolBox.append(pages)
toolBox.append(nextPage)
// 分割线
const divider = document.createElement('div')
divider.setAttribute('class', 'divider')
toolBox.append(divider)
}
// 放大缩小
const bigger = document.createElement('div')
const smaller = document.createElement('div')
bigger.setAttribute('class', 'bigger')
smaller.setAttribute('class', 'smaller')
bigger.textContent = '+'
smaller.textContent = '-'
// 对应dom绑定放大缩小事件
smaller.addEventListener('click', () => this.scaleRender('smaller'))
bigger.addEventListener('click', () => this.scaleRender('bigger'))
// 缩放比例展示器
const scale = document.createElement('div')
scale.setAttribute('class', 'scale')
scale.textContent = `${parseInt(this.pdfScale * 100)}%`
// 将缩放dom添加进工具栏
toolBox.append(smaller)
toolBox.append(scale)
toolBox.append(bigger)
// 根据新的参数重新渲染PDF
const canvasContent = document.createElement('div')
canvasContent.setAttribute('class', 'canvas-content')
canvasContent.append(canvas)
fullDom.append(canvasContent)
this.pdfRender()
// 将工具栏添加进全屏dom
fullDom.append(toolBox)
if (!screenfull.isFullscreen) {
screenfull.request(fullDom)
}
// 关闭全屏状态监听回调事件
const reBack = () => {
this.$nextTick(() => { // 确保回调时获取的dom元素为最新的
const currentFull = document.querySelector(`.the-canvas-full`)
if (!currentFull || screenfull.isFullscreen) return
const parent = document.getElementById('the-canvas')
screenfull.off('change', reBack) // 取消监听
parent.removeChild(currentFull)
this.pdfScale = 1.2
})
}
screenfull.on('change', reBack) // 监听
}
scaleRender
-放大缩小
该方法存在一个入参,标识当前为放大还是缩小事件,并对js小数相加减的精准度问题进行了兼容。另外,里面存在较多常量,可根据实际需求进行调整。
scaleRender(type) {
let pdfScale = this.pdfScale
if ((pdfScale === 5 && type === 'bigger') || (pdfScale === 0.25 && type === 'smaller')) return // 最大比例为5,最小比例为0.25,可自行调整
if (type === 'bigger') { // 放大时,根据当前比例动态变化放大范围
if (pdfScale >= 2) {
pdfScale = pdfScale + 1
} else if (pdfScale >= 0.5) {
const m = Math.pow(10, 2)
const res = (pdfScale * m + 0.1 * m) / m
pdfScale = Math.round(res * m) / m
} else if (pdfScale >= 0.25) {
const m = Math.pow(10, 2)
const res = (pdfScale * m + 0.05 * m) / m
pdfScale = Math.round(res * m) / m
}
} else {// 缩小时,根据当前比例动态变化缩小范围
if (pdfScale <= 0.5) {
const m = Math.pow(10, 2)
const res = (pdfScale * m - 0.05 * m) / m
pdfScale = Math.round(res * m) / m
} else if (pdfScale <= 2) {
const m = Math.pow(10, 2)
const res = (pdfScale * m - 0.1 * m) / m
pdfScale = Math.round(res * m) / m
} else if (pdfScale <= 5) {
pdfScale = pdfScale - 1
}
}
this.pdfScale = pdfScale
// 重新渲染PDF并更新缩放比例展示器
this.pdfRender()
const scale = document.querySelector('.scale')
scale.textContent = `${parseInt(this.pdfScale * 100)}%`
const bigger = document.querySelector('.bigger')
const smaller = document.querySelector('.smaller')
// 到达所设定的缩放上下限时,禁用缩放dom
bigger.setAttribute('class', this.pdfScale === 5 ? 'bigger disabled' : 'bigger')
smaller.setAttribute('class', this.pdfScale === 0.25 ? 'smaller disabled' : 'smaller')
}
pdfRender
-pdf渲染
pdfRender() {
const that = this
this.pdfRef.getPage(this.currentPage).then(function (page) {
const scale = that.pdfScale
const viewport = page.getViewport({ scale: scale })
const canvas = document.getElementById(`the-canvas-full-${that.currentPage}`)
const context = canvas.getContext('2d')
canvas.height = viewport.height
canvas.width = viewport.width
const renderContext = {
canvasContext: context,
viewport: viewport
}
page.render(renderContext)
})
}
4.4 总结
其实pdfjs提供了相关的翻页放大缩小事件,但是为了兼容全屏并且实现较为灵活的配置,就自己实现了一下,总的来说,不考虑性能啥的,基本满足了我的需求,以下是效果图:
5 下载
附带一个我常用的文件下载方法,兼容IE
const download = () => {
// 创建Blob对象 传入一个合适的MIME类型
// file 为blob文件流,name为文件名称
const blob = new Blob([file], { type: 'application/vnd.ms-excel,charset=UTF-8' });
// 使用 Blob 创建一个指向类型化数组的URL
const csvUrl = URL.createObjectURL(blob);
// IE下载
if (window.navigator.msSaveBlob) {
try {
window.navigator.msSaveBlob(blob, name);
} catch (e) {
console.log(e);
}
} else {
let link = document.createElement('a');
link.download = name; //文件名字
link.href = csvUrl;
link.click(); // 触发下载
}
}
最后
大概是这么多了,想到哪写到哪吧。感谢大佬们的帮助,有错误欢迎指出,我们一起进步呀。