/** * PDF预览组件 * 从 FilePreview.tsx 中抽取的 PDF 专用渲染组件 */ import { useState, useEffect, useRef, ChangeEvent } from 'react'; import { Document, Page, pdfjs } from 'react-pdf'; import { toastService } from '~/components/ui/Toast'; // 导入react-pdf的CSS样式(文本层和注释层必需) import 'react-pdf/dist/esm/Page/TextLayer.css'; import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; // 设置worker路径 pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js'; /** * 自定义样式 * 这些样式解决了PDF页面在放大时互相重叠的问题 */ const styles = { pdfContainer: { display: 'flex', flexDirection: 'column' as const, alignItems: 'center', width: '100%', position: 'relative' as const, }, pageContainer: { display: 'flex', flexDirection: 'column' as const, alignItems: 'center', width: '100%', position: 'relative' as const, } }; interface PdfPreviewProps { filePath: string; // PDF 文件路径 targetPage?: number; // 目标页码 charPositions?: Array<{ box: number[][], char: string, score: number }>; // 字符位置信息(用于高亮显示) textBbox?: { x_min: number; y_min: number; x_max: number; y_max: number }; // GraphRAG段落级坐标 isStructuredView?: boolean; // 是否结构化视图 activeReviewPointResultId?: string | null; // 激活的评查点结果ID pageOffset?: number; // 页码偏移量(用于调整 OCR 结果的页码) onNumPagesChange?: (numPages: number) => void; // 页数变化回调 onZoomChange?: (zoomLevel: number) => void; // 缩放变化回调 } export function PdfPreview({ filePath, targetPage, charPositions, textBbox, isStructuredView = false, activeReviewPointResultId, pageOffset = 0, onNumPagesChange, onZoomChange }: PdfPreviewProps) { // 调试日志 // console.log('[PdfPreview] 组件渲染', { // filePath, // targetPage, // charPositions, // isStructuredView, // activeReviewPointResultId, // pageOffset // }); // ============ 状态管理 ============ const [numPages, setNumPages] = useState(null); const [zoomLevel, setZoomLevel] = useState(100); const [loadError, setLoadError] = useState(null); const [pageInputValue, setPageInputValue] = useState(''); // 坐标校准参数 const [coordinateScale, setCoordinateScale] = useState(0.83); // 坐标缩放系数(默认0.83) const [pdfOriginalWidth, setPdfOriginalWidth] = useState(0); // PDF原始尺寸 const [isScaleAutoCalculated, setIsScaleAutoCalculated] = useState(false); // 是否已自动计算缩放 // 引用 const contentRef = useRef(null); const prevTargetPageRef = useRef(undefined); // ============ 副作用 - 页数变化通知 ============ useEffect(() => { if (numPages && onNumPagesChange) { onNumPagesChange(numPages); } }, [numPages, onNumPagesChange]); // ============ 副作用 - 缩放变化通知 ============ useEffect(() => { if (onZoomChange) { onZoomChange(zoomLevel); } }, [zoomLevel, onZoomChange]); // ============ 副作用 - 页面跳转 ============ useEffect(() => { if (targetPage && numPages && targetPage <= numPages) { prevTargetPageRef.current = targetPage; const newTargetPage = targetPage + pageOffset; const pageElementId = `page-${newTargetPage}${isStructuredView ? '-structured' : ''}`; const pageElement = document.getElementById(pageElementId); if (pageElement) { // 直接跳转,不使用滚动动画 pageElement.scrollIntoView({ behavior: 'auto', block: 'start' }); } else { console.warn(`未找到页面元素: ${pageElementId}`); } } }, [targetPage, numPages, pageOffset, activeReviewPointResultId, isStructuredView]); // ============ 副作用 - 重置坐标缩放计算标志 ============ useEffect(() => { // 只在文件路径变化时重置自动计算标志 // zoom变化时不应该重新计算coordinateScale,因为它是基于PDF原始尺寸的固定值 setIsScaleAutoCalculated(false); }, [filePath]); // ============ PDF 加载事件处理 ============ const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => { setNumPages(numPages); console.log("PDF加载成功,页数:", numPages); }; const onDocumentLoadError = (error: Error) => { console.error("PDF加载错误:", error); setLoadError("PDF文档加载失败:" + (error.message || "未知错误")); }; // ============ 缩放控制 ============ const handleZoomIn = () => { if (zoomLevel < 200) { setZoomLevel(prevZoom => prevZoom + 10); } }; const handleZoomOut = () => { if (zoomLevel > 50) { setZoomLevel(prevZoom => prevZoom - 10); } }; // ============ 页码跳转控制 ============ const handlePageInputChange = (e: ChangeEvent) => { const value = e.target.value.replace(/\D/g, ''); setPageInputValue(value); }; const handlePageJump = () => { if (!pageInputValue || !numPages) return; const targetPageNum = parseInt(pageInputValue, 10); if (targetPageNum > 0 && targetPageNum <= numPages) { const pageElement = document.getElementById(`page-${targetPageNum}`); if (pageElement) { // 直接跳转,不使用滚动动画 pageElement.scrollIntoView({ behavior: 'auto', block: 'start' }); setPageInputValue(''); } } else { toastService.warning(`请输入有效页码 (1-${numPages})`); setPageInputValue(''); } }; const handlePageInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { handlePageJump(); } }; // ============ 滚动到顶部 ============ const handleScrollToTop = () => { if (contentRef.current) { // 直接跳转到顶部,不使用滚动动画 contentRef.current.scrollTo({ top: 0, behavior: 'auto' }); } }; // ============ 计算页面间距 ============ const calculatePageMargin = (zoomFactor: number) => { // 固定间距,不随缩放变化,避免页面偏移 return 32; // 相当于 Tailwind 的 mb-8 }; // ============ 页面加载成功处理(用于自动计算坐标缩放) ============ const onPageLoadSuccess = (page: any) => { // 只在第一页加载时计算一次坐标缩放系数 if (page.pageNumber === 1 && !isScaleAutoCalculated) { setTimeout(() => { // 获取PDF原始宽度(单位:点) const pdfOriginalWidthPt = page.view?.[2] || page.originalWidth || page.width; // 获取实际渲染的canvas元素 const canvas = document.querySelector('.react-pdf__Page__canvas') as HTMLCanvasElement; if (canvas && pdfOriginalWidthPt) { // 获取canvas在屏幕上的显示宽度 const canvasDisplayWidth = canvas.offsetWidth; // 获取当前的zoom因子 const currentScale = zoomLevel / 100; // 计算基于100% zoom的坐标缩放系数 // 需要除以当前的zoom因子,得到100%时的真实比例 const autoScale = (canvasDisplayWidth / currentScale) / pdfOriginalWidthPt; console.log('自动计算坐标缩放:', { pdfOriginalWidth: pdfOriginalWidthPt, canvasDisplayWidth, currentZoom: zoomLevel, currentScale, autoScale }); setPdfOriginalWidth(pdfOriginalWidthPt); setCoordinateScale(autoScale); setIsScaleAutoCalculated(true); } }, 200); } }; // ============ 处理字符位置数据,转换为高亮矩形 ============ const processCharPositionsToHighlights = () => { // GraphRAG fallback: charPositions 为空但有 textBbox 时,用段落级坐标画高亮 if ((!charPositions || charPositions.length === 0) && textBbox && targetPage) { const scale = zoomLevel / 100; return { x: textBbox.x_min * coordinateScale * scale, y: textBbox.y_min * coordinateScale * scale, width: (textBbox.x_max - textBbox.x_min) * coordinateScale * scale, height: (textBbox.y_max - textBbox.y_min) * coordinateScale * scale, text: '' }; } if (!charPositions || charPositions.length === 0 || !targetPage) { return null; } const scale = zoomLevel / 100; // 计算所有字符的边界框,形成一个连贯的高亮区域 let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; // 收集所有字符文本 const allChars = charPositions.map(cp => cp.char).join(''); // 遍历所有字符,找出整体的边界 charPositions.forEach(charPos => { const box = charPos.box; // box是一个4个点的数组: [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] // 从所有点中找出最小和最大的坐标 box.forEach(point => { const x = point[0]; const y = point[1]; if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; }); }); // 计算连贯的高亮矩形,应用坐标缩放和zoom缩放 const x = minX * coordinateScale * scale; const y = minY * coordinateScale * scale; const width = (maxX - minX) * coordinateScale * scale; const height = (maxY - minY) * coordinateScale * scale; return { x, y, width, height, text: allChars }; }; // ============ 渲染所有PDF页面 ============ const renderAllPages = () => { if (!numPages) return null; const pages = []; const zoomFactor = zoomLevel / 100; const highlight = processCharPositionsToHighlights(); for (let i = 1; i <= numPages; i++) { const pageContainerStyle = { ...styles.pageContainer, marginBottom: `${calculatePageMargin(zoomFactor)}px`, }; const pageId = isStructuredView ? `page-${i}-structured` : `page-${i}`; // 计算调整后的目标页码(考虑pageOffset) const adjustedTargetPage = targetPage ? targetPage + pageOffset : undefined; // 判断当前页是否应该显示高亮 const shouldShowHighlight = adjustedTargetPage === i && highlight !== null; pages.push(
第 {i} 页
{/* 坐标高亮层 - 渲染连贯的高亮区域 */} {shouldShowHighlight && highlight && ( {`高亮文本: ${highlight.text}`} )}
); } return pages; }; // ============ 主渲染 ============ return (
{/* 工具栏 */}
{isStructuredView ? '模板预览' : '文件预览'}
{/* 页码跳转控件
{numPages && ( / {numPages} )}
*/} {/* 页码跳转控件 */}
{numPages && ( / )} {numPages && ( {numPages} )}
比例:{zoomLevel}%
{/* PDF内容区域 */}
{loadError ? (

{loadError}

) : (
PDF文档加载失败,请检查链接或网络连接。
} noData={
无数据
} loading={
PDF加载中...
} > {renderAllPages()}
)}
); }