diff --git a/app/components/reviews/previewComponents/PdfPreview.tsx b/app/components/reviews/previewComponents/PdfPreview.tsx new file mode 100644 index 0000000..1a4637f --- /dev/null +++ b/app/components/reviews/previewComponents/PdfPreview.tsx @@ -0,0 +1,472 @@ +/** + * 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 }>; // 字符位置信息(用于高亮显示) + isStructuredView?: boolean; // 是否结构化视图 + activeReviewPointResultId?: string | null; // 激活的评查点结果ID + pageOffset?: number; // 页码偏移量(用于调整 OCR 结果的页码) + onNumPagesChange?: (numPages: number) => void; // 页数变化回调 + onZoomChange?: (zoomLevel: number) => void; // 缩放变化回调 +} + +export function PdfPreview({ + filePath, + targetPage, + charPositions, + 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 = () => { + 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} + + )} +
+ + 比例:{zoomLevel}% + +
+
+ + {/* PDF内容区域 */} +
+
+ {loadError ? ( +
+

{loadError}

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