预览pdf文件(react-pdf/iframe/pdfjs+全屏)

前情提要

首先这是一个项目需求,负责人只说了让我实现一个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中引入:
pdfjs
pdfjs引入
screenfull包通过npm instll screenfull指令下载即可
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(); // 触发下载
     }
}

最后

大概是这么多了,想到哪写到哪吧。感谢大佬们的帮助,有错误欢迎指出,我们一起进步呀。

  • 1
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值