利用 pdfjs-dist 打造React版PDF神器:链接转单页PDF组件全解析

利用 pdfjs-dist 打造React版PDF神器:链接转单页PDF组件全解析

组件功能概述​

(一)整体功能介绍​

这个基于 React 开发的 PDF 组件,旨在解决实际应用中链接形式 PDF 原件处理不便的问题,它的核心功能是将左侧带预览、右侧是实际 PDF 内容(以链接形式呈现的原件)转换为单个 PDF 格式的内容。在许多在线文档管理系统或电子阅读平台中,常常会遇到链接形式的 PDF 文档,用户需要点击链接跳转到新页面查看,并且无法直接对文档进行本地保存和常规的文件操作。而通过这个组件,能够将这种链接形式的 PDF 转化为可独立使用的单个 PDF 文件,大大提高了文档的处理效率和便捷性 ,也提升了用户在文档查阅、保存和分享等方面的体验。

(二)关键特性列举​

  1. 缩放控制:用户可以根据自身需求对 PDF 页面进行缩放操作,通过点击 “+”“-” 按钮,能够在 1.5x - 2.5x 的缩放比例范围内灵活调整,以适应不同的阅读场景和视觉需求,比如在查看一些包含复杂图表或小字体的 PDF 时,可以放大页面看清细节 。​
  1. 翻页功能:提供了便捷的翻页按钮,点击 “上一页” 和 “下一页” 按钮,用户能够轻松在 PDF 的不同页面间切换,并且实时显示当前页码和总页数,让用户对文档阅读进度一目了然。​
  1. 页面渲染:利用pdfjs - dist库实现了高效的 PDF 页面渲染,能够准确地将 PDF 的每一页内容清晰地呈现在页面上,确保了文档内容展示的准确性和完整性 。​
  1. 高亮框绘制:支持根据传入的坐标信息绘制高亮框,在需要突出显示文档中的某些关键内容时,这一功能非常实用。例如在做文档批注或标记重点段落时,通过绘制高亮框可以快速吸引用户注意力 。

技术实现细节​

(一)依赖库与环境搭建​

在构建这个 PDF 转换组件时,我们主要依赖了 React、pdfjs - dist 等关键库。React 作为流行的前端框架,为组件化开发提供了强大支持,使得代码结构清晰、易于维护 。而 pdfjs - dist 则是实现 PDF 文件解析和渲染的核心库,它能够在浏览器环境中高效地处理 PDF 文件,将其内容准确地呈现在页面上。​

首先,通过 npm 安装所需依赖:

npm install react pdfjs - dist antd

安装完成后,在项目中引入相关库。对于 pdfjs - dist,需要特别注意设置 GlobalWorkerOptions.workerSrc 路径,这是因为 pdfjs - dist 使用 Web Worker 来处理 PDF 渲染任务,以避免阻塞主线程,提高性能。在代码中,我们设置:

import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";

这里将 workerSrc 指向项目中的 pdf.worker.min.js 文件,确保 Web Worker 能够正确加载和运行 ,从而顺利完成 PDF 的解析和渲染工作。

(二)核心代码解读​

  • 状态与引用管理:​
    • 使用useState钩子函数来管理组件的各种状态,比如pdfDoc用于存储加载的 PDF 文档对象,pageNum表示当前显示的页码,totalPages记录 PDF 的总页数,viewport保存页面的视口信息,scale控制页面的缩放比例等。这些状态的变化会触发组件的重新渲染,从而及时更新页面展示。​
    • useRef钩子用于创建可变的引用对象,canvasRef用于引用页面中的<canvas>元素,通过它可以获取<canvas>的上下文,进而进行 PDF 页面的绘制操作 ;containerRef则用于引用包含 PDF 预览区域的容器,在绘制高亮框时,用于控制容器的滚动,使高亮区域可见。​
  • useEffect 钩子的运用:​
    • PDF 加载:
useEffect(() => {
    const loadPDF = async () => {
        const url = pdfUrl;
        const loadingTask = getDocument(url);
        const pdf = await loadingTask.promise;
        setPdfDoc(pdf);
        setTotalPages(pdf.numPages);
        setPageNum(1);
    };
    loadPDF();
}, [pdfUrl]);

当pdfUrl发生变化时,这个useEffect会触发。它通过getDocument方法从指定的 URL 加载 PDF 文件,加载成功后,将 PDF 文档对象保存到pdfDoc状态中,并设置总页数和初始页码。

  • 页面渲染:
useEffect(() => {
    const renderPage = async (num) => {
        if (!pdfDoc ||!canvasRef.current) return;
        const page = await pdfDoc.getPage(num);
        const vp = page.getViewport({ scale });
        setViewport(vp);
        const canvas = canvasRef.current;
        const context = canvas.getContext("2d");
        canvas.height = vp.height;
        canvas.width = vp.width;
        await page.render({ canvasContext: context, viewport: vp }).promise;
    };
    renderPage(pageNum);
}, [pdfDoc, pageNum, scale]);

每当pdfDoc、pageNum或scale发生变化时,会调用此useEffect。它获取当前页码对应的 PDF 页面,根据缩放比例计算视口信息,然后设置<canvas>的大小,并将页面渲染到<canvas>上。

  • 外部页码同步:
useEffect(() => {
    if (externalPageNum && externalPageNum!== pageNum) {
        setPageNum(externalPageNum);
    }
}, [externalPageNum]);

当传入的externalPageNum(外部指定的页码)发生变化且与当前内部的pageNum不同时,将内部的pageNum更新为externalPageNum,实现外部页码与内部状态的同步。​

  • 高亮框绘制:
useEffect(() => {
    console.log("Highlight box:", highlightBox);
    if (!highlightBox ||!canvasRef.current ||!viewport) return;
    const canvas = canvasRef.current;
    const ctx = canvas.getContext("2d");
    setTimeout(() => {
        const xs = [
            Number(highlightBox[0]),
            Number(highlightBox[2]),
            Number(highlightBox[4]),
            Number(highlightBox[6]),
        ];
        const ys = [
            Number(highlightBox[1]),
            Number(highlightBox[3]),
            Number(highlightBox[5]),
            Number(highlightBox[7]),
        ];
        const minX = Math.min(...xs);
        const maxX = Math.max(...xs);
        const minY = Math.min(...ys);
        const maxY = Math.max(...ys);
        ctx.beginPath();
        ctx.rect(minX, minY, maxX - minX, maxY - minY);
        ctx.strokeStyle = "#1677ff";
        ctx.lineWidth = 2;
        ctx.globalAlpha = 0.51;
        ctx.stroke();
        ctx.closePath();
        if (containerRef.current) {
            const container = containerRef.current;
            const scrollToY = minY - 40 > 0? minY - 40 : 0;
            container.scrollTop = scrollToY;
        }
    }, 1000);
}, [highlightBox, viewport, pageNum]);

当highlightBox(高亮框的坐标信息)、viewport或pageNum发生变化时,会执行这个useEffect。它根据highlightBox的坐标信息,在<canvas>上绘制一个高亮框,并通过设置容器的scrollTop属性,使高亮区域在容器中可见 。这里使用setTimeout是为了确保在页面重新渲染完成后再绘制高亮框,避免出现闪烁等问题。​

(三)缩放与翻页功能实现​

  1. 缩放功能:​
  • 放大:
const handleZoomIn = () => setScale(s => Math.min(2.5, +(s + 0.1).toFixed(1)));

handleZoomIn函数用于实现放大功能。它通过setScale更新scale状态,每次放大时,将scale增加 0.1,但限制其最大值为 2.5。​

  • 缩小:
const handleZoomOut = () => setScale(s => Math.max(1.5, +(s - 0.1).toFixed(1)));

handleZoomOut函数实现缩小功能。它通过setScale更新scale状态,每次缩小将scale减少 0.1,但限制其最小值为 1.5。当scale状态发生变化时,会触发页面渲染的useEffect,重新计算视口并渲染页面,实现缩放效果。​

2. 翻页功能:​

  • 上一页:
const handlePrev = () => setPageNum((p) => Math.max(p - 1, 1));

handlePrev函数用于实现上一页功能。它通过setPageNum更新pageNum状态,将当前页码减 1,但确保页码不小于 1。​

  • 下一页:
const handleNext = () => setPageNum((p) => Math.min(p + 1, totalPages));

handleNext函数实现下一页功能。它通过setPageNum更新pageNum状态,将当前页码加 1,但确保页码不超过总页数。当pageNum状态发生变化时,会触发页面渲染的useEffect,加载并渲染新的页面。

完整代码

import { useEffect, useRef, useState } from "react";
import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
import { Button, Flex } from "antd";
import styles from "./styles.module.scss";

GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";

export default function PDFViewer({
  pdfUrl,
  pageNum: externalPageNum,
  highlightBox,
  className,
}) {
  const canvasRef = useRef(null);
  const containerRef = useRef(null);
  const [pdfDoc, setPdfDoc] = useState(null);
  const [pageNum, setPageNum] = useState(1);
  const [totalPages, setTotalPages] = useState(0);
  const [viewport, setViewport] = useState(null);
  const [scale, setScale] = useState(1.5);

  // 缩放控制
  const handleZoomIn = () => setScale(s => Math.min(2.5, +(s + 0.1).toFixed(1)));
  const handleZoomOut = () => setScale(s => Math.max(1.5, +(s - 0.1).toFixed(1)));

  // 外部 pageNum 变化时同步内部页码
  useEffect(() => {
    if (externalPageNum && externalPageNum !== pageNum) {
      setPageNum(externalPageNum);
    }
  }, [externalPageNum]);

  // 加载 PDF
  useEffect(() => {
    const loadPDF = async () => {
      const url = pdfUrl;
      const loadingTask = getDocument(url);
      const pdf = await loadingTask.promise;
      setPdfDoc(pdf);
      setTotalPages(pdf.numPages);
      setPageNum(1);
    };
    loadPDF();
  }, [pdfUrl]);

  // 渲染页面
  useEffect(() => {
    const renderPage = async (num) => {
      if (!pdfDoc || !canvasRef.current) return;
      const page = await pdfDoc.getPage(num);
      const vp = page.getViewport({ scale });
      setViewport(vp);
      const canvas = canvasRef.current;
      const context = canvas.getContext("2d");
      canvas.height = vp.height;
      canvas.width = vp.width;
      await page.render({ canvasContext: context, viewport: vp }).promise;
    };
    renderPage(pageNum);
  }, [pdfDoc, pageNum, scale]);

  // 绘制高亮框
  useEffect(() => {
    console.log("Highlight box:", highlightBox);
    if (!highlightBox || !canvasRef.current || !viewport) return;
    const canvas = canvasRef.current;
    const ctx = canvas.getContext("2d");
    // 重新渲染后再画高亮
    setTimeout(() => {
      // 8点坐标,取最小x/y和最大x/y为矩形
      const xs = [
        Number(highlightBox[0]),
        Number(highlightBox[2]),
        Number(highlightBox[4]),
        Number(highlightBox[6]),
      ];
      const ys = [
        Number(highlightBox[1]),
        Number(highlightBox[3]),
        Number(highlightBox[5]),
        Number(highlightBox[7]),
      ];
      const minX = Math.min(...xs);
      const maxX = Math.max(...xs);
      const minY = Math.min(...ys);
      const maxY = Math.max(...ys);
      ctx.beginPath();
      ctx.rect(minX, minY, maxX - minX, maxY - minY);

      ctx.strokeStyle = "#1677ff";
      ctx.lineWidth = 2;
      ctx.globalAlpha = 0.51;
      ctx.stroke(); // 只画边框,不会填充
      ctx.closePath();

      // 滚动container使高亮区域可见
      if (containerRef.current) {
        const container = containerRef.current;
        const scrollToY = minY - 40 > 0 ? minY - 40 : 0;
        container.scrollTop = scrollToY;
      }
    }, 1000);
  }, [highlightBox, viewport, pageNum]);

  // 翻页按钮依然可用
  const handlePrev = () => setPageNum((p) => Math.max(p - 1, 1));
  const handleNext = () => setPageNum((p) => Math.min(p + 1, totalPages));

  return (
    <Flex vertical align="center" gap={10} className={`${styles.container} ${className}`} ref={containerRef}>
      {/* 缩放控制按钮 */}
      <div style={{ marginBottom: 8 }}>
        <Button size="small" onClick={handleZoomOut} disabled={scale <= 1.5} style={{ marginRight: 8 }}>-</Button>
        <span style={{ minWidth: 40, display: "inline-block", textAlign: "center" }}>缩放:{(scale - 0.5).toFixed(1)}x</span>
        <Button size="small" onClick={handleZoomIn} disabled={scale >= 2.5} style={{ marginLeft: 8 }}>+</Button>
      </div>
      {/* 左侧 PDF 预览 */}
      <Flex className={styles.canvasBox}>
        <canvas ref={canvasRef} />
      </Flex>
      {/* 分页按钮 */}
      <div className={styles.pagination}>
        <Button
          type="primary"
          ghost={false}
          onClick={handlePrev}
          disabled={pageNum === 1}
          style={{ marginRight: 8 }}
        >
          上一页
        </Button>
        <span className={styles.pageInfoWrapper}>
          <span className={styles.pageLabel}>第</span>
          <span className={pageNum ? styles.pageNumActive : styles.pageNum}>
            {pageNum}
          </span>
          <span className={styles.pageLabel}>页 / 共 {totalPages} 页</span>
        </span>
        <Button
          type="primary"
          ghost={false}
          onClick={handleNext}
          disabled={pageNum === totalPages}
          style={{ marginLeft: 8 }}
        >
          下一页
        </Button>
      </div>
    </Flex>
  );
}
.container {
  width: 100%;
  height: 100%;
  flex: 1;
  overflow: auto;
  padding-bottom: 62px;
  max-height: 100vh;

  .pagination {
    position: absolute;
    width: 100%;
    left: 0;
    bottom: 0;
    height: 62px;
    justify-content: center;
    background: rgba(242, 245, 255, 1);
    display: flex;
    align-items: center;
    margin-top: 16px;
  }

  .canvasBox {
    width: 100%;
    overflow: auto;
    canvas {
      margin: 0 auto;
    }
  }

  .pageInfoWrapper {
    display: inline-flex;
    align-items: center;
    font-size: 16px;
    margin: 0 10px;
  }

  .pageLabel {
    color: #666;
    margin: 0 2px;
  }

  .pageNum {
    color: #333;
    font-weight: 500;
    margin: 0 2px;
    padding: 0 4px;
    border-radius: 4px;
  }

  .pageNumActive {
    color: #1677ff;
    background: #e6f4ff;
    font-weight: bold;
    margin: 0 2px;
    padding: 0 6px;
    border-radius: 4px;
    transition: background 0.2s;
  }
}

最终实现

接口以链接形式返回的原PDF文件(前端这么显示显然不合理)

转换为

下载PDF文件(可选)

pdf 解析之后,通过按钮点击实现 PDF 下载:

const downloadAsPDF = async () => {
    // 检查是否为简历页面
      if (selectApplyDetail?.fileUrl) {
        fetch(selectApplyDetail.fileUrl)
          .then(response => response.blob())
          .then(blob => {
            // 创建Blob URL
            const url = URL.createObjectURL(blob);
            const link = document.createElement('a');
            link.href = url;
            link.download = '简历原件.pdf';
            link.click();
            // 释放Blob URL
            URL.revokeObjectURL(url);
          })
          .catch(error => {
            console.warn('下载简历PDF失败:', error);
          });
      } else {
        console.warn('没有找到简历PDF链接');
      }
      return;
  };
转载请说明出处内容投诉
CSS教程网 » 利用 pdfjs-dist 打造React版PDF神器:链接转单页PDF组件全解析

发表评论

欢迎 访客 发表评论

一个令你着迷的主题!

查看演示 官网购买