Contents

前端实现PDF预览下载打印

为了实现前端预览pdf文件,在vue3和react中都进行了尝试

React

react-pdf

在react中用到的组件是react-pdf,我们可以对组件进行二次封装来使组件支持放大,缩小,旋转,下载等功能

import { useState } from 'react';
import './styles.css';
import { Document, Page, pdfjs } from "react-pdf";
import { Spin, Watermark } from 'antd';
import {
  LeftCircleOutlined,
  RightCircleOutlined,
  ZoomInOutlined,
  ZoomOutOutlined,
  CompressOutlined,
  ExpandOutlined,
  CloseOutlined,
  LoadingOutlined,
  DownloadOutlined,
  RedoOutlined
} from '@ant-design/icons'
console.log('pdfjs.version', pdfjs.version);

pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.js`;
type OptionType = {
  show: boolean;
  url: string;
}
type SetOptionType = React.Dispatch<React.SetStateAction<OptionType>>
type Props = {
  option: OptionType,
  setOption: SetOptionType,
  WatermarkCon?: string,
  downLoadHandle?: () => void
  httpHeaders?: object | null | undefined;
  withCredentials?: boolean | null | undefined;
}

type AngleType = 0 | 90 | 180 | 270
const antIcon = <LoadingOutlined style={{ fontSize: 24, color: 'white' }} spin />;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function throttle<T extends (...args: any[]) => any>(func: T, wait: number = 400): T {
  let timeout: ReturnType<typeof setTimeout> | null = null;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  let context: any;
  let args: IArguments | null;

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const throttled = function (this: any) {
    // eslint-disable-next-line @typescript-eslint/no-this-alias
    context = this;
    // eslint-disable-next-line prefer-rest-params
    args = arguments;
    if (!timeout) {
      timeout = setTimeout(() => {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        func.apply(context, args as any);
        timeout = null;
      }, wait);
    }
  };

  return throttled as T;
}

const PdfPreview = ({ option, setOption, downLoadHandle, WatermarkCon, httpHeaders, withCredentials }: Props) => {
  const [pageNumber, setPageNumber] = useState(1)
  const [pageNumberInput, setPageNumberInput] = useState(1)
  const [pageNumberFocus, setPageNumberFocus] = useState(false)
  const [numPages, setNumPages] = useState(1)
  const [pageWidth, setPageWidth] = useState(503)
  const [fullscreen, setFullscreen] = useState(false)
  const [angle, setAngle] = useState<AngleType>(0)
  const cleanUp = () => {
    setPageNumber(1)
    setPageNumberInput(1)
    setPageNumberFocus(false)
    setNumPages(1)
    setPageWidth(503)
    setFullscreen(false)
    setAngle(0)
  }
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const onDocumentLoadSuccess = (data: any) => {
    const totalPages = data['_transport']['_numPages'] || 1
    setNumPages(totalPages)
  }
  const rotatePage = () => {
    setAngle((oldAngle) => {
      switch (oldAngle) {
        case 0:
          return 90
        case 90:
          return 180
        case 180:
          return 270
        case 270:
          return 0
      }
    })
  }
  const downLoad = () => {
    downLoadHandle && downLoadHandle()
  }
  const lastPage = () => {
    if (pageNumber === 1) return;
    const page = pageNumber - 1;
    setPageNumber(page)
    setPageNumberInput(page)
  }
  const nextPage = () => {
    if (pageNumber === numPages) return;
    const page = pageNumber + 1;
    setPageNumber(page)
    setPageNumberInput(page)
  }
  const onPageNumberFocus = () => {
    setPageNumberFocus(true)
  }
  const onPageNumberBlur = () => {
    setPageNumberFocus(false)
    setPageNumberInput(pageNumber)
  }
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const onPageNumberChange = (e: any) => {
    let value = Number(e.target.value);
    value = value <= 0 ? 1 : value;
    value = value >= numPages ? numPages : value;
    setPageNumber(value)
    setPageNumberInput(value)
  }
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const toPage = (e: any) => {
    if (e.keyCode === 13) {
      setPageNumber(Number(e.target.value))
    }
  }
  const pageZoomOut = () => {
    if (pageWidth <= 503) return;
    setPageWidth((oldv) => oldv * 0.8)

  }
  const pageZoomIn = () => {
    setPageWidth((oldv) => oldv * 1.2)
  }
  const pageFullscreen = () => {
    if (fullscreen) {
      setFullscreen(false)
      setPageWidth(600)
    } else {
      setFullscreen(true)
      setPageWidth(window.screen.width - 40)
    }
  }
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const closeViewer = (e: any, model?: 'pdf-view') => {
    if (e) {
      e.stopPropagation();
      e.nativeEvent.stopImmediatePropagation();
    }
    const initOption: OptionType = {
      show: false,
      url: ''
    }
    if (e.target?.className === model || !model) {
      setOption && setOption(initOption)
      cleanUp()
    }
  }
  return (
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    <div className={option.show ? 'show' : 'notShow'} onClick={(e: any) => closeViewer(e, 'pdf-view')} >
      <div className={'mask-close'} onClick={(e) => closeViewer(e)}>
        <CloseOutlined style={{}} />
      </div>

      <div className="pdf-view">
        <div className="container">
          <Watermark content={WatermarkCon ? WatermarkCon : 'Watermark'}>
            <Document
              key={option.url}
              className={'view-dom'}
              noData={<h2 className='top300'>pdf数据...</h2>}
              loading={<Spin className='top300' indicator={antIcon} />}
              file={option.url}
              options={{
                httpHeaders: httpHeaders || null,
                withCredentials: withCredentials || null
              }}
              onLoadSuccess={(data) => onDocumentLoadSuccess(data)}
            >
              <Page
                key={pageNumber}
                rotate={angle}
                width={pageWidth}
                renderTextLayer={false}
                pageNumber={pageNumber}
                renderAnnotationLayer={false}
                onLoadSuccess={(data) => onDocumentLoadSuccess(data)}
              />
            </Document>
          </Watermark>
        </div>
        <div className="page-tool">
          {downLoadHandle && <div className="page-tool-item" onClick={throttle(downLoad)}>
            <DownloadOutlined style={{}} />
          </div>}
          <div className="page-tool-item" onClick={throttle(rotatePage)}>
            {/* 旋转 */}
            <RedoOutlined style={{}} />
          </div>
          <div className="page-tool-item" onClick={throttle(lastPage)}>
            {/* 上一页 */}
            <LeftCircleOutlined style={{}} />
          </div>
          <div className="page-tool-item" onClick={throttle(nextPage)}>
            {/* 下一页 */}
            <RightCircleOutlined style={{}} />
          </div>
          <div className="input">
            <input
              value={pageNumberFocus ? pageNumberInput : pageNumber}
              onFocus={onPageNumberFocus}
              onBlur={onPageNumberBlur}
              onChange={(e) => onPageNumberChange(e)}
              onKeyDown={toPage}
              type="number"
            />
            / &nbsp; {numPages} &nbsp;
          </div>
          <div className="page-tool-item" onClick={throttle(pageZoomIn)}>
            {/* 放大 */}
            <ZoomInOutlined style={{}} />
          </div>
          <div className="page-tool-item" onClick={throttle(pageZoomOut)}>
            {/* 缩小 */}
            <ZoomOutOutlined style={{}} />
          </div>
          <div className="page-tool-item" onClick={throttle(pageFullscreen)}>
            {/* {fullscreen ? "恢复默认" : "适合窗口"} */}
            {fullscreen ? <CompressOutlined style={{}} /> : <ExpandOutlined style={{}} />}
          </div>
        </div>
      </div>
    </div >
  );
};

export default PdfPreview;

引入

import { useState } from 'react'
import { Button } from 'antd'
import { PdfPreview as PDFViwer } from '../components'
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
const PdfPreview = (props: any) => {
  const [pdfOption, setPdfOption] = useState({
    show: false,
    url: ''
  })
  return (
    <>
      <div style={{
        width: 400,
        display: 'flex',
        justifyContent: 'space-between',
        alignItems: 'center',
        margin: '20px auto',
      }}>
        <PDFViwer
          option={pdfOption}
          setOption={setPdfOption}
          downLoadHandle={() => {
            console.log('downLoadHandle');
          }}
        />
      </div>
    </>

  )
}

export default PdfPreview

react-pdf的使用参考了掘金博主一颗Potato的文章,并在此基础上进一步做了改进

vue3

在vue3中尝试用过了两个组件

因为没有找到显示签名的具体解决办法,部分签名仍未显示,所以最后选择了pdfjs

vue3-pdf-app

https://www.npmjs.com/package/vue3-pdf-app

vue3-pdf-app对vue-pdf-app做了vue3的兼容,是一个基于Vue 3和PDF.js的PDF阅读器组件,可以方便地在Vue应用程序中嵌入PDF文件并预览。

在vue3-pdf-app如果要预览pdf签名的话需要对下列代码进行注释

if (data.fieldType === "Sig") {
    data.fieldValue = null;
    // this.setFlags(_util.AnnotationFlag.HIDDEN); // 全局搜索_util.AnnotationFlag.HIDDEN 注释相关行代码
}

pdfjs

https://gitcode.gitcode.host/docs-cn/pdf.js-docs-cn/getting_started/index.html

PDF.js是Mozilla开发的一个JavaScript库,用于在Web浏览器中解析和显示PDF文档。它允许开发人员将PDF文件嵌入到网页中,并使用JavaScript来控制PDF的呈现和交互。PDF.js的主要优点是它可以在任何支持JavaScript的浏览器中运行,不需要任何插件或下载。

我们可以使用pdfjs打包好的文件,通过iframe的方式来进行pdf的预览

使用pdfjs的原因是pdfjs打包后的文件可以完美的展示pdf签名,

进行简单封装

<script setup lang='ts'>
import { onMounted, ref } from 'vue';

const props = withDefaults(
  defineProps<{
    width: string,
    height: string,
    src: string | BlobPart
  }>(),
  {
    width: "100%",
    height: "100%",
    src: ''
  }
)
const pdfUrl = ref<string>()
onMounted(() => {
  if (typeof props.src === 'string') {
    pdfUrl.value = props.src
  } else {
    pdfUrl.value = window.URL.createObjectURL(new Blob([props.src]))
  }
})

</script>

<template>
  <iframe v-bind="props" :src="`./pdfjs/web/viewer.html?file=${props.src}`" frameborder="0"></iframe>
</template>

<style scoped  lang='less'></style>

对于传入流式二进制文件将其转化为blob地址进行预览

仓库地址: https://github.com/Plumliil/pdf-preview

在线预览地址: https://pdf.plumliil.cn/