组件功能概述
(一)整体功能介绍
这个基于 React 开发的 PDF 组件,旨在解决实际应用中链接形式 PDF 原件处理不便的问题,它的核心功能是将左侧带预览、右侧是实际 PDF 内容(以链接形式呈现的原件)转换为单个 PDF 格式的内容。在许多在线文档管理系统或电子阅读平台中,常常会遇到链接形式的 PDF 文档,用户需要点击链接跳转到新页面查看,并且无法直接对文档进行本地保存和常规的文件操作。而通过这个组件,能够将这种链接形式的 PDF 转化为可独立使用的单个 PDF 文件,大大提高了文档的处理效率和便捷性 ,也提升了用户在文档查阅、保存和分享等方面的体验。
(二)关键特性列举
- 缩放控制:用户可以根据自身需求对 PDF 页面进行缩放操作,通过点击 “+”“-” 按钮,能够在 1.5x - 2.5x 的缩放比例范围内灵活调整,以适应不同的阅读场景和视觉需求,比如在查看一些包含复杂图表或小字体的 PDF 时,可以放大页面看清细节 。
- 翻页功能:提供了便捷的翻页按钮,点击 “上一页” 和 “下一页” 按钮,用户能够轻松在 PDF 的不同页面间切换,并且实时显示当前页码和总页数,让用户对文档阅读进度一目了然。
- 页面渲染:利用pdfjs - dist库实现了高效的 PDF 页面渲染,能够准确地将 PDF 的每一页内容清晰地呈现在页面上,确保了文档内容展示的准确性和完整性 。
- 高亮框绘制:支持根据传入的坐标信息绘制高亮框,在需要突出显示文档中的某些关键内容时,这一功能非常实用。例如在做文档批注或标记重点段落时,通过绘制高亮框可以快速吸引用户注意力 。
技术实现细节
(一)依赖库与环境搭建
在构建这个 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是为了确保在页面重新渲染完成后再绘制高亮框,避免出现闪烁等问题。
(三)缩放与翻页功能实现
- 缩放功能:
- 放大:
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;
};