Files
leaudit-platform-frontend/app/components/reviews/previewComponents/PdfPreview.tsx
T
DocAuditAI Dev ebcaf05625 revert: reset to 32bee87 for clean text_bbox baseline
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:14:11 +08:00

504 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<number | null>(null);
const [zoomLevel, setZoomLevel] = useState(100);
const [loadError, setLoadError] = useState<string | null>(null);
const [pageInputValue, setPageInputValue] = useState<string>('');
// 坐标校准参数
const [coordinateScale, setCoordinateScale] = useState(0.83); // 坐标缩放系数(默认0.83
const [pdfOriginalWidth, setPdfOriginalWidth] = useState<number>(0); // PDF原始尺寸
const [isScaleAutoCalculated, setIsScaleAutoCalculated] = useState(false); // 是否已自动计算缩放
// 引用
const contentRef = useRef<HTMLDivElement>(null);
const prevTargetPageRef = useRef<number | undefined>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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(
<div key={i} id={pageId} style={pageContainerStyle}>
<div className="text-center text-gray-500 text-sm mb-2"> {i} </div>
<div
className="page-wrapper"
style={{
position: 'relative',
display: 'inline-block',
margin: '0 auto',
}}
>
<Page
pageNumber={i}
scale={zoomLevel / 100}
devicePixelRatio={window.devicePixelRatio || 1}
renderTextLayer={false}
renderAnnotationLayer={false}
className="border border-gray-300 shadow-md"
onLoadSuccess={onPageLoadSuccess}
/>
{/* 坐标高亮层 - 渲染连贯的高亮区域 */}
{shouldShowHighlight && highlight && (
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 12
}}
>
<rect
x={highlight.x}
y={highlight.y}
width={highlight.width}
height={highlight.height}
fill="#00AA00"
fillOpacity="0.1"
stroke="#00684a"
strokeWidth="0.5"
rx="2"
>
<title>{`高亮文本: ${highlight.text}`}</title>
</rect>
</svg>
)}
</div>
</div>
);
}
return pages;
};
// ============ 主渲染 ============
return (
<div className="pdf-preview-component">
{/* 工具栏 */}
<div className="file-preview-header px-2 text-xs sm:text-xs md:text-sm max-w-full flex items-center justify-between min-w-0">
<div className="flex items-center min-w-0 flex-shrink-0">
<i className={`${isStructuredView ? 'ri-file-list-line' : 'ri-file-text-line'} text-primary mr-2 flex-shrink-0`}></i>
<span className="font-medium text-primary truncate max-w-[120px]" title={isStructuredView ? '模板预览' : '文件预览'}>
{isStructuredView ? '模板预览' : '文件预览'}
</span>
</div>
<div className="file-preview-actions flex items-center ml-2 min-w-0 flex-1 justify-end overflow-hidden gap-2">
<button
className="flex items-center justify-center px-2 py-1 text-xs text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 hover:border-primary hover:text-primary transition-colors duration-200 flex-shrink-0 outline-none"
onClick={handleScrollToTop}
title="返回顶部"
>
<i className="ri-arrow-up-double-line text-sm"></i>
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap"></span>
</button>
<button
className="flex items-center justify-center w-7 h-7 text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 hover:border-primary hover:text-primary transition-colors duration-200 flex-shrink-0 outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white disabled:hover:border-gray-300 disabled:hover:text-gray-700"
onClick={handleZoomIn}
title="放大"
disabled={zoomLevel >= 200}
>
<i className="ri-zoom-in-line text-sm"></i>
</button>
<button
className="flex items-center justify-center w-7 h-7 text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 hover:border-primary hover:text-primary transition-colors duration-200 flex-shrink-0 outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white disabled:hover:border-gray-300 disabled:hover:text-gray-700"
onClick={handleZoomOut}
title="缩小"
disabled={zoomLevel <= 50}
>
<i className="ri-zoom-out-line text-sm"></i>
</button>
{/* 页码跳转控件
<div className="inline-flex items-center flex-shrink-0 gap-1">
<input
type="text"
className="w-12 h-7 px-2 text-xs text-center text-gray-700 bg-white border border-gray-300 rounded outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-colors duration-200"
placeholder="页码"
value={pageInputValue}
onChange={handlePageInputChange}
onKeyDown={handlePageInputKeyDown}
/>
<button
className="flex items-center justify-center w-7 h-7 text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 hover:border-primary hover:text-primary transition-colors duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white disabled:hover:border-gray-300 disabled:hover:text-gray-700"
onClick={handlePageJump}
disabled={!numPages}
title="跳转到页面"
>
<i className="ri-arrow-right-line text-sm"></i>
</button>
{numPages && (
<span className="ml-1 text-xs text-gray-500 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap">
/ {numPages}
</span>
)}
</div> */}
{/* 页码跳转控件 */}
<div className="inline-flex items-center bg-white border border-gray-300 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 flex-shrink-0 overflow-hidden">
<div className="flex items-center px-2 py-1">
<i className="ri-file-list-line text-sm text-gray-400 mr-1.5"></i>
<input
type="text"
className="w-10 h-5 px-1 text-xs text-center text-gray-700 bg-transparent border-0 outline-none focus:text-primary transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="页码"
value={pageInputValue}
onChange={handlePageInputChange}
onKeyDown={handlePageInputKeyDown}
/>
{numPages && (
<span className="text-xs text-gray-400 mx-0.5">/</span>
)}
{numPages && (
<span className="text-xs text-gray-500 font-medium min-w-[1.5rem] text-center">
{numPages}
</span>
)}
</div>
<button
className="flex items-center justify-center h-7 px-2.5 text-white bg-primary hover:bg-primary-hover transition-colors duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-primary border-l border-primary-hover/20"
onClick={handlePageJump}
disabled={!numPages}
title="跳转"
>
<i className="ri-skip-forward-mini-line text-sm"></i>
</button>
</div>
<span className="text-xs text-gray-500 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap flex-shrink-0">
{zoomLevel}%
</span>
</div>
</div>
{/* PDF内容区域 */}
<div
className="file-preview-content"
ref={contentRef}
style={{
maxHeight: 'calc(100vh - 150px)',
overflowY: 'auto',
overflowX: 'auto',
}}
>
<div
className="pdf-interactive-container"
style={{
position: 'relative',
height: '100%',
width: '100%',
display: 'block',
textAlign: 'center',
padding: 0
}}
>
{loadError ? (
<div className="text-red-500 p-4">
<p>{loadError}</p>
</div>
) : (
<div
style={{
...styles.pdfContainer,
width: '100%',
overflow: 'visible'
}}
>
<Document
file={`/api/pdf-proxy?path=${encodeURIComponent(filePath)}`}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
className="w-full"
error={<div className="text-red-500">PDF文档加载失败</div>}
noData={<div></div>}
loading={<div className="text-center py-10">PDF加载中...</div>}
>
{renderAllPages()}
</Document>
</div>
)}
</div>
</div>
</div>
);
}