From 9376e8af6dc8e16b9ea3ad930907aa765751e177 Mon Sep 17 00:00:00 2001 From: yorn <1057707203@qq.com> Date: Mon, 24 Nov 2025 18:41:14 +0800 Subject: [PATCH] =?UTF-8?q?1.=20=E6=B7=BB=E5=8A=A0=20mocano-editor=20demo?= =?UTF-8?q?=202.=20=E6=B7=BB=E5=8A=A0=20react-pdf=20=E9=AB=98=E4=BA=AE?= =?UTF-8?q?=E6=95=88=E6=9E=9C=E7=9A=84=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/home/home.ts | 2 +- app/components/reviews/FilePreview.tsx | 4 + app/root.tsx | 9 +- app/routes/monaco-demo.tsx | 377 ++++++ app/routes/pdf-demo.tsx | 1448 ++++++++++++++++++++++++ package-lock.json | 69 ++ package.json | 2 + public/testPDF/sample.pdf | Bin 0 -> 536823 bytes vite.config.ts | 2 +- 9 files changed, 1908 insertions(+), 5 deletions(-) create mode 100644 app/routes/monaco-demo.tsx create mode 100644 app/routes/pdf-demo.tsx create mode 100644 public/testPDF/sample.pdf diff --git a/app/api/home/home.ts b/app/api/home/home.ts index 269bb67..e3c4814 100644 --- a/app/api/home/home.ts +++ b/app/api/home/home.ts @@ -282,7 +282,7 @@ export async function getEntryModules(userRole: string | null | undefined, userA return []; } - console.log(`✅ [getEntryModules] 找到 ${modules.length} 个已启用的入口模块`); + // console.log(`✅ [getEntryModules] 找到 ${modules.length} 个已启用的入口模块`); // 为每个模块查询关联的 document_types const modulesWithTypes = await Promise.all( diff --git a/app/components/reviews/FilePreview.tsx b/app/components/reviews/FilePreview.tsx index be6ca10..42025d4 100644 --- a/app/components/reviews/FilePreview.tsx +++ b/app/components/reviews/FilePreview.tsx @@ -7,6 +7,10 @@ import { Document, Page, pdfjs } from 'react-pdf'; import { DOCUMENT_URL } from '~/api/axios-client'; import { CollaboraViewer } from '~/components/collabora/CollaboraViewer'; +// 导入react-pdf的CSS样式(文本层和注释层必需) +import 'react-pdf/dist/esm/Page/TextLayer.css'; +import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; + // 设置worker路径为public目录下的worker文件 // 使用已经下载的兼容版本 (pdfjs-dist v2.12.313) // 2025/09/28 使用新版本的pdfjs-dist v4.8.69 diff --git a/app/root.tsx b/app/root.tsx index d077e26..9837aa1 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -87,11 +87,14 @@ function isPathAllowed(pathname: string, allowedPaths: string[]): boolean { } } - // 根路径特殊处理 - if (pathname === '/' || pathname === '/home') { - return true; // 首页通常对所有已登录用户开放 + // 根路径特殊处理(仅根路径 '/' 对所有已登录用户开放) + if (pathname === '/') { + return true; // 根路径重定向到首页,始终允许 } + // /home 路由需要检查路由权限,不再特殊处理 + // 如果用户的 routes 数据中没有 /home,则返回 403 + return false; } diff --git a/app/routes/monaco-demo.tsx b/app/routes/monaco-demo.tsx new file mode 100644 index 0000000..8aaa4c6 --- /dev/null +++ b/app/routes/monaco-demo.tsx @@ -0,0 +1,377 @@ +/** + * Monaco Editor 差异对比演示页面 + * + * 功能: + * - 展示两份合同文本的差异对比 + * - 支持逐行高亮显示差异 + * - 提供差异导航功能 + * - 后续可扩展文件上传功能 + */ + +import { type MetaFunction } from "@remix-run/node"; +import { useState, useRef } from "react"; +import { DiffEditor } from "@monaco-editor/react"; +import type { editor } from "monaco-editor"; + +export const meta: MetaFunction = () => { + return [ + { title: "Monaco Diff Editor 演示 - 合同对比" }, + { name: "description", content: "使用 Monaco Editor 进行合同文本差异对比" } + ]; +}; + +// 示例合同文本 A(原始版本) +const CONTRACT_A = `中国烟草合同(原始版本) + +第一条 合同双方 +甲方:中国烟草总公司广东省公司 +乙方:XX供应商有限公司 + +第二条 合同标的 +甲方向乙方采购烟草包装材料,具体型号为: +1. 硬盒包装纸 10000箱 +2. 烟用滤棒 5000箱 +总金额:人民币壹佰万元整(¥1,000,000.00) + +第三条 交付时间 +乙方应在签订合同后30个工作日内完成全部交付。 + +第四条 质量标准 +产品应符合国家烟草行业标准 YC/T 207-2014。 + +第五条 付款方式 +甲方在收到货物并验收合格后,于15个工作日内支付全部款项。 + +第六条 违约责任 +1. 乙方延期交付,每延迟一天支付合同总额0.5%的违约金。 +2. 产品质量不合格,乙方应无偿更换并承担相应损失。 + +第七条 争议解决 +本合同履行过程中发生的争议,由双方协商解决;协商不成的,提交广州仲裁委员会仲裁。 + +第八条 其他约定 +本合同一式两份,甲乙双方各执一份,具有同等法律效力。 + +签订日期:2024年1月15日 +`; + +// 示例合同文本 B(修改版本) +const CONTRACT_B = `中国烟草合同(修订版本) + +第一条 合同双方 +甲方:中国烟草总公司广东省公司 +乙方:XX供应商有限公司 + +第二条 合同标的 +甲方向乙方采购烟草包装材料,具体型号为: +1. 硬盒包装纸 15000箱(数量增加) +2. 烟用滤棒 5000箱 +3. 商标纸 3000箱(新增项目) +总金额:人民币壹佰伍拾万元整(¥1,500,000.00) + +第三条 交付时间 +乙方应在签订合同后45个工作日内完成全部交付。 +如遇不可抗力,交付时间可顺延,但乙方应及时通知甲方。 + +第四条 质量标准 +产品应符合国家烟草行业标准 YC/T 207-2014 及甲方企业标准。 + +第五条 付款方式 +1. 签订合同后,甲方支付合同总额30%作为预付款; +2. 收到货物并验收合格后,于15个工作日内支付剩余70%款项。 + +第六条 违约责任 +1. 乙方延期交付,每延迟一天支付合同总额1%的违约金(违约金比例提高)。 +2. 产品质量不合格,乙方应无偿更换并承担相应损失。 +3. 甲方延期付款,每延迟一天支付未付款项0.05%的违约金。 + +第七条 保密条款(新增) +双方应对合同内容及执行过程中获悉的商业秘密承担保密义务,保密期限为合同终止后2年。 + +第八条 争议解决 +本合同履行过程中发生的争议,由双方协商解决;协商不成的,提交广州仲裁委员会仲裁。 + +第九条 其他约定 +本合同一式两份,甲乙双方各执一份,具有同等法律效力。 + +签订日期:2024年3月20日 +`; + +export default function MonacoDemoPage() { + const [originalText, setOriginalText] = useState(CONTRACT_A); + const [modifiedText, setModifiedText] = useState(CONTRACT_B); + const diffEditorRef = useRef(null); + const [diffCount, setDiffCount] = useState(0); + const [currentDiff, setCurrentDiff] = useState(0); + + // Monaco Editor 挂载后的回调 + const handleEditorDidMount = (editor: editor.IStandaloneDiffEditor) => { + diffEditorRef.current = editor; + + // 获取差异数量 + const lineChanges = editor.getLineChanges(); + if (lineChanges) { + setDiffCount(lineChanges.length); + console.log(`发现 ${lineChanges.length} 处差异:`, lineChanges); + } + }; + + // 跳转到下一个差异 + const goToNextDiff = () => { + if (!diffEditorRef.current) return; + + const lineChanges = diffEditorRef.current.getLineChanges(); + if (!lineChanges || lineChanges.length === 0) return; + + const nextIndex = (currentDiff + 1) % lineChanges.length; + const nextChange = lineChanges[nextIndex]; + + // 跳转到差异位置(修改后的编辑器) + const modifiedEditor = diffEditorRef.current.getModifiedEditor(); + modifiedEditor.revealLineInCenter(nextChange.modifiedStartLineNumber); + modifiedEditor.setPosition({ + lineNumber: nextChange.modifiedStartLineNumber, + column: 1 + }); + + setCurrentDiff(nextIndex); + }; + + // 跳转到上一个差异 + const goToPreviousDiff = () => { + if (!diffEditorRef.current) return; + + const lineChanges = diffEditorRef.current.getLineChanges(); + if (!lineChanges || lineChanges.length === 0) return; + + const prevIndex = currentDiff === 0 ? lineChanges.length - 1 : currentDiff - 1; + const prevChange = lineChanges[prevIndex]; + + // 跳转到差异位置(修改后的编辑器) + const modifiedEditor = diffEditorRef.current.getModifiedEditor(); + modifiedEditor.revealLineInCenter(prevChange.modifiedStartLineNumber); + modifiedEditor.setPosition({ + lineNumber: prevChange.modifiedStartLineNumber, + column: 1 + }); + + setCurrentDiff(prevIndex); + }; + + // 重置为示例文本 + const resetToExample = () => { + setOriginalText(CONTRACT_A); + setModifiedText(CONTRACT_B); + setCurrentDiff(0); + + // 重新计算差异数量 + setTimeout(() => { + if (diffEditorRef.current) { + const lineChanges = diffEditorRef.current.getLineChanges(); + if (lineChanges) { + setDiffCount(lineChanges.length); + } + } + }, 100); + }; + + return ( +
+ {/* 页面头部 */} +
+

+ + Monaco Editor - 合同差异对比演示 +

+

+ 使用 Monaco Diff Editor 逐行对比两份合同文本的差异 +

+
+ + {/* 工具栏 */} +
+
+ {/* 差异统计 */} +
+ + 发现 {diffCount} 处差异 + {diffCount > 0 && ` (当前: ${currentDiff + 1}/${diffCount})`} +
+ + {/* 导航按钮 */} + + + +
+ +
+ {/* 重置按钮 */} + + + {/* 未来扩展:上传按钮 */} + +
+
+ + {/* 说明信息 */} +
+
+ +
+ 差异高亮说明: +
    +
  • 绿色:新增的内容
  • +
  • 红色:删除的内容
  • +
  • 黄色背景:修改的行内差异
  • +
+
+
+
+ + {/* Diff Editor 主体 */} +
+ +
+ + {/* 页面底部信息 */} +
+ + 基于 Monaco Editor (VS Code 核心编辑器) + + + 提示:可使用鼠标滚轮缩放,Ctrl+F 搜索 + +
+
+ ); +} diff --git a/app/routes/pdf-demo.tsx b/app/routes/pdf-demo.tsx new file mode 100644 index 0000000..51a8bc8 --- /dev/null +++ b/app/routes/pdf-demo.tsx @@ -0,0 +1,1448 @@ +/** + * React-PDF 功能测试 Demo + * 探索 react-pdf 库的各种内置功能 + */ +import { useState, useRef, useCallback, useEffect } from 'react'; +import { Document, Page, pdfjs } from 'react-pdf'; +import { type MetaFunction } from '@remix-run/node'; +import { Card } from '~/components/ui/Card'; +import { Button } from '~/components/ui/Button'; +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'; + +export const meta: MetaFunction = () => { + return [ + { title: 'React-PDF 功能测试 - 文档审核系统' }, + { name: 'description', content: 'React-PDF库功能测试和演示' }, + ]; +}; + +// 高亮区域类型(基于文本选择) +interface HighlightArea { + id: string; + pageNumber: number; + text: string; + rects: Array<{ + left: number; + top: number; + width: number; + height: number; + }>; + color: string; +} + +// 基于坐标的字符数据 +interface CharacterBox { + box: [number, number][]; // 4个点:左上、右上、右下、左下 + char: string; + page: number; +} + +// 行数据(一行文字) +interface TextLine { + chars: CharacterBox[]; // 这行的所有字符 + text: string; // 这行的文本 + rect: { + // 整行的矩形区域(从第一个字到最后一个字) + x1: number; // 左上角 X + y1: number; // 左上角 Y + x2: number; // 右下角 X + y2: number; // 右下角 Y + }; +} + +// 基于坐标的高亮区域 +interface CoordinateHighlight { + id: string; + pageNumber: number; + text: string; + lines: TextLine[]; // 按行存储 + color: string; +} + +export default function PdfDemo() { + // PDF文件URL(使用示例PDF) + // const [pdfUrl] = useState('/testPDF/sample.pdf'); // 使用包含真实文本层的PDF + // const [pdfUrl] = useState('/api/pdf-proxy?path=documents/mz/行政处罚决定书/2025/11月13日/第71号--未在当地烟草专卖批发企业进货_02时58分36秒/第71号--未在当地烟草专卖批发企业进货.pdf'); // 使用项目中的示例PDF + const [pdfUrl] = useState('/api/pdf-proxy?path=documents/mz/行政处罚决定书/2025/11月22日/第35号--无烟草专卖品准运证运输烟草专卖品_15时15分24秒/第35号--无烟草专卖品准运证运输烟草专卖品.pdf') + + // PDF状态 + const [numPages, setNumPages] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [scale, setScale] = useState(1.0); + const [rotation, setRotation] = useState(0); + + // 文本层和注释层开关 + const [renderTextLayer, setRenderTextLayer] = useState(true); + const [renderAnnotationLayer, setRenderAnnotationLayer] = useState(true); + + // 调试:检测文本层是否渲染 + useEffect(() => { + if (numPages && renderTextLayer) { + // 增加延迟时间,等待文本内容加载 + const checkTextLayer = () => { + const textLayers1 = document.querySelectorAll('.react-pdf__Page__textContent'); + const textLayers2 = document.querySelectorAll('.textLayer'); + const canvasLayers = document.querySelectorAll('.react-pdf__Page__canvas'); + + console.log('🔍 检测到的文本层数量:'); + console.log(' - .react-pdf__Page__textContent:', textLayers1.length); + console.log(' - .textLayer:', textLayers2.length); + console.log(' - .react-pdf__Page__canvas:', canvasLayers.length); + + const textLayer = textLayers1[0] || textLayers2[0]; + const canvas = canvasLayers[0]; + + if (textLayer) { + const styles = window.getComputedStyle(textLayer as Element); + console.log('✅ 文本层已渲染'); + console.log('📝 文本层关键样式:', { + className: (textLayer as Element).className, + pointerEvents: styles.pointerEvents, + zIndex: styles.zIndex, + opacity: styles.opacity, + position: styles.position, + userSelect: styles.userSelect, + }); + + // 检查文本层中的 span 元素 + const spans = textLayer.querySelectorAll('span'); + console.log('📝 文本层中的 span 数量:', spans.length); + + if (spans.length === 0) { + console.warn('⚠️⚠️⚠️ 关键问题:文本层容器存在,但里面没有 span 元素!'); + console.warn('这意味着 PDF.js 没有提取出文本内容。'); + console.log('🔍 文本层 HTML:', (textLayer as Element).innerHTML.substring(0, 500)); + } else { + const spanStyles = window.getComputedStyle(spans[0]); + console.log('📝 第一个 span 的样式:', { + pointerEvents: spanStyles.pointerEvents, + cursor: spanStyles.cursor, + userSelect: spanStyles.userSelect, + }); + console.log('📝 第一个 span 的文本内容:', (spans[0] as HTMLElement).textContent); + } + } else { + console.warn('⚠️ 文本层未找到!'); + } + + if (canvas) { + const canvasStyles = window.getComputedStyle(canvas as Element); + console.log('🎨 Canvas 层样式:', { + pointerEvents: canvasStyles.pointerEvents, + zIndex: canvasStyles.zIndex, + position: canvasStyles.position, + }); + } + }; + + // 多次检查,看文本内容是否会延迟加载 + setTimeout(checkTextLayer, 1000); + setTimeout(() => { + console.log('🔄 2秒后再次检查...'); + checkTextLayer(); + }, 2000); + setTimeout(() => { + console.log('🔄 5秒后最后检查...'); + checkTextLayer(); + }, 5000); + } + }, [numPages, renderTextLayer]); + + // 页面渲染模式 + const [renderMode, setRenderMode] = useState<'canvas' | 'svg'>('canvas'); + + // 高亮功能 + const [highlights, setHighlights] = useState([]); + const [selectedText, setSelectedText] = useState(''); + + // 文本搜索功能 + const [searchText, setSearchText] = useState(''); + const [searchResults, setSearchResults] = useState([]); + + // 基于坐标的高亮(用于扫描版PDF) + const [coordinateHighlights, setCoordinateHighlights] = useState([]); + const [coordinateInput, setCoordinateInput] = useState(''); + + // 坐标校准参数 + const [coordinateScale, setCoordinateScale] = useState(0.83); // 坐标缩放系数(默认0.83) + const [coordinateOffsetX, setCoordinateOffsetX] = useState(0); // X轴偏移 + const [coordinateOffsetY, setCoordinateOffsetY] = useState(0); // Y轴偏移 + + // PDF原始尺寸 + const [pdfOriginalWidth, setPdfOriginalWidth] = useState(0); + const [isScaleAutoCalculated, setIsScaleAutoCalculated] = useState(false); // 是否已自动计算缩放 + + // 加载状态 + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + + // 引用 + const containerRef = useRef(null); + const pageRefs = useRef>(new Map()); + + // ============ PDF 加载事件 ============ + const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => { + setNumPages(numPages); + setIsLoading(false); + setLoadError(null); + toastService.success(`PDF加载成功!共 ${numPages} 页`); + console.log('✅ PDF加载成功,总页数:', numPages); + }; + + const onDocumentLoadError = (error: Error) => { + setIsLoading(false); + setLoadError(error.message); + toastService.error('PDF加载失败: ' + error.message); + console.error('❌ PDF加载失败:', error); + }; + + const onDocumentLoadProgress = ({ loaded, total }: { loaded: number; total: number }) => { + const progress = Math.round((loaded / total) * 100); + console.log(`📥 PDF加载进度: ${progress}%`); + }; + + // ============ 页面渲染事件 ============ + const onPageLoadSuccess = (page: any) => { + console.log('✅ 页面渲染成功:', page.pageNumber); + + // 只在第一页加载时自动计算坐标缩放比例 + if (page.pageNumber === 1 && !isScaleAutoCalculated) { + // 延迟一点确保DOM完全渲染 + setTimeout(() => { + // 获取PDF原始尺寸(以点为单位,1 point ≈ 1/72 inch) + // page.view 是 [x, y, width, height] 数组,表示PDF页面的原始坐标系 + const pdfOriginalWidthPt = page.view?.[2] || page.originalWidth || page.width; + const pdfOriginalHeightPt = page.view?.[3] || page.originalHeight || page.height; + + // 获取实际渲染的Canvas元素 + const canvas = document.querySelector('.react-pdf__Page__canvas') as HTMLCanvasElement; + + // 获取Page容器(SVG实际渲染的坐标空间) + const pageContainer = canvas?.closest('.react-pdf__Page') as HTMLElement; + + if (canvas && pageContainer && pdfOriginalWidthPt) { + // Canvas 内部绘制尺寸(考虑了 devicePixelRatio) + const canvasInternalWidth = canvas.width; + const canvasInternalHeight = canvas.height; + + // Canvas 显示尺寸(浏览器中实际占用的像素) + const canvasDisplayWidth = canvas.offsetWidth; + const canvasDisplayHeight = canvas.offsetHeight; + + // Page容器尺寸(SVG高亮渲染的实际坐标空间) + const pageContainerWidth = pageContainer.offsetWidth; + const pageContainerHeight = pageContainer.offsetHeight; + + // 尝试多种计算方式 + const scale1_canvasDisplay = canvasDisplayWidth / pdfOriginalWidthPt; + const scale2_canvasInternal = canvasInternalWidth / pdfOriginalWidthPt; + const scale3_pageContainer = pageContainerWidth / pdfOriginalWidthPt; + + // 尝试反向计算:如果OCR尺寸比渲染尺寸大(需要缩小) + const scale4_inverseCanvasInternal = canvasDisplayWidth / canvasInternalWidth; + const scale5_inversePage = canvasDisplayWidth / pageContainerWidth; + + // 计算如果要达到 0.83 的缩放比例,OCR原始尺寸应该是多少 + const expectedOcrWidth = canvasDisplayWidth / 0.83; + + console.log('📏 尺寸信息汇总:'); + console.log(' 1️⃣ PDF原始尺寸 (page.view):', pdfOriginalWidthPt, 'x', pdfOriginalHeightPt, 'pt'); + console.log(' 2️⃣ Page容器尺寸:', pageContainerWidth, 'x', pageContainerHeight, 'px'); + console.log(' 3️⃣ Canvas显示尺寸:', canvasDisplayWidth, 'x', canvasDisplayHeight, 'px'); + console.log(' 4️⃣ Canvas内部尺寸:', canvasInternalWidth, 'x', canvasInternalHeight, 'px'); + console.log(' 5️⃣ 用户缩放 (scale):', scale); + console.log(' 6️⃣ devicePixelRatio:', window.devicePixelRatio || 1); + console.log(''); + console.log('🎯 各种计算方式:'); + console.log(' 方案1️⃣: Canvas显示 / PDF原始 =', scale1_canvasDisplay.toFixed(3), 'x'); + console.log(' 方案2️⃣: Canvas内部 / PDF原始 =', scale2_canvasInternal.toFixed(3), 'x'); + console.log(' 方案3️⃣: Page容器 / PDF原始 =', scale3_pageContainer.toFixed(3), 'x'); + console.log(' 方案4️⃣: Canvas显示 / Canvas内部 =', scale4_inverseCanvasInternal.toFixed(3), 'x ⬅ 可能是这个!'); + console.log(' 方案5️⃣: Canvas显示 / Page容器 =', scale5_inversePage.toFixed(3), 'x'); + console.log(''); + console.log('🔍 目标值分析:'); + console.log(' - 手动校准的正确值: 0.83'); + console.log(' - 反推OCR图像尺寸:', expectedOcrWidth.toFixed(0), 'x', (canvasDisplayHeight / 0.83).toFixed(0), 'px'); + console.log(' - 比较: ', expectedOcrWidth.toFixed(0), 'vs Canvas内部', canvasInternalWidth); + + // 使用最接近0.83的方案 + let autoScale = scale1_canvasDisplay; + let scaleMethod = '方案1 (Canvas显示/PDF原始)'; + + // 检查哪个方案最接近0.83 + const diff1 = Math.abs(scale1_canvasDisplay - 0.83); + const diff2 = Math.abs(scale2_canvasInternal - 0.83); + const diff3 = Math.abs(scale3_pageContainer - 0.83); + const diff4 = Math.abs(scale4_inverseCanvasInternal - 0.83); + const diff5 = Math.abs(scale5_inversePage - 0.83); + + const minDiff = Math.min(diff1, diff2, diff3, diff4, diff5); + + if (minDiff === diff4) { + autoScale = scale4_inverseCanvasInternal; + scaleMethod = '方案4 (Canvas显示/Canvas内部)'; + } else if (minDiff === diff5) { + autoScale = scale5_inversePage; + scaleMethod = '方案5 (Canvas显示/Page容器)'; + } else if (minDiff === diff2) { + autoScale = scale2_canvasInternal; + scaleMethod = '方案2 (Canvas内部/PDF原始)'; + } else if (minDiff === diff3) { + autoScale = scale3_pageContainer; + scaleMethod = '方案3 (Page容器/PDF原始)'; + } + + console.log(''); + console.log('✅ 自动选择:', scaleMethod, '=', autoScale.toFixed(3), 'x (最接近0.83)'); + + // 保存原始宽度和自动计算的缩放比例 + setPdfOriginalWidth(pdfOriginalWidthPt); + setCoordinateScale(autoScale); + setIsScaleAutoCalculated(true); + + toastService.success(`自动校准完成: ${autoScale.toFixed(3)}x (${scaleMethod})`); + } else { + console.warn('⚠️ 无法获取Canvas元素、Page容器或原始尺寸'); + console.log('调试信息:', { + hasCanvas: !!canvas, + hasPageContainer: !!pageContainer, + pdfOriginalWidthPt, + pageWidth: page.width, + pageHeight: page.height, + pageView: page.view, + pageOriginalWidth: page.originalWidth, + pageObject: Object.keys(page) + }); + } + }, 200); // 延迟200ms确保渲染完成 + } + }; + + const onPageLoadError = (error: Error) => { + console.error('❌ 页面渲染失败:', error); + }; + + // ============ 缩放控制 ============ + const handleZoomIn = () => { + if (scale < 3.0) { + setScale(prev => Math.min(prev + 0.25, 3.0)); + toastService.success(`放大至 ${Math.round((scale + 0.25) * 100)}%`); + } + }; + + const handleZoomOut = () => { + if (scale > 0.5) { + setScale(prev => Math.max(prev - 0.25, 0.5)); + toastService.success(`缩小至 ${Math.round((scale - 0.25) * 100)}%`); + } + }; + + const handleResetZoom = () => { + setScale(1.0); + toastService.success('重置缩放至 100%'); + }; + + // ============ 旋转控制 ============ + const handleRotateLeft = () => { + setRotation(prev => (prev - 90) % 360); + }; + + const handleRotateRight = () => { + setRotation(prev => (prev + 90) % 360); + }; + + // ============ 页面导航 ============ + const handlePreviousPage = () => { + if (currentPage > 1) { + setCurrentPage(prev => prev - 1); + } + }; + + const handleNextPage = () => { + if (numPages && currentPage < numPages) { + setCurrentPage(prev => prev + 1); + } + }; + + const handleGoToPage = (pageNum: number) => { + if (numPages && pageNum >= 1 && pageNum <= numPages) { + setCurrentPage(pageNum); + } + }; + + // ============ 文本选择和高亮 ============ + const handleTextSelection = useCallback(() => { + const selection = window.getSelection(); + if (!selection || selection.isCollapsed) { + setSelectedText(''); + return; + } + + const text = selection.toString(); + setSelectedText(text); + console.log('📝 选中文本:', text); + + // 获取选区的范围 + try { + const range = selection.getRangeAt(0); + const rects = range.getClientRects(); + + // 查找所属页面 + const pageElement = range.startContainer.parentElement?.closest('[data-page-number]'); + if (!pageElement) return; + + const pageNumber = parseInt(pageElement.getAttribute('data-page-number') || '1'); + const pageRect = pageElement.getBoundingClientRect(); + + const highlightRects = Array.from(rects).map(rect => ({ + left: (rect.left - pageRect.left) / scale, + top: (rect.top - pageRect.top) / scale, + width: rect.width / scale, + height: rect.height / scale + })); + + console.log('📍 高亮区域:', { pageNumber, rects: highlightRects }); + } catch (error) { + console.error('❌ 获取选区位置失败:', error); + } + }, [scale]); + + const handleAddHighlight = () => { + if (!selectedText) { + toastService.warning('请先选择要高亮的文本'); + return; + } + + const selection = window.getSelection(); + if (!selection || selection.isCollapsed) return; + + try { + const range = selection.getRangeAt(0); + const rects = range.getClientRects(); + + const pageElement = range.startContainer.parentElement?.closest('[data-page-number]'); + if (!pageElement) return; + + const pageNumber = parseInt(pageElement.getAttribute('data-page-number') || '1'); + const pageRect = pageElement.getBoundingClientRect(); + + const highlightRects = Array.from(rects).map(rect => ({ + left: (rect.left - pageRect.left) / scale, + top: (rect.top - pageRect.top) / scale, + width: rect.width / scale, + height: rect.height / scale + })); + + const newHighlight: HighlightArea = { + id: `highlight-${Date.now()}`, + pageNumber, + text: selectedText, + rects: highlightRects, + color: '#FFFF00' // 黄色 + }; + + setHighlights(prev => [...prev, newHighlight]); + toastService.success('已添加高亮'); + selection.removeAllRanges(); + setSelectedText(''); + } catch (error) { + console.error('❌ 添加高亮失败:', error); + toastService.error('添加高亮失败'); + } + }; + + const handleClearHighlights = () => { + setHighlights([]); + toastService.success('已清除所有高亮'); + }; + + // ============ 文本搜索和高亮 ============ + const handleSearchAndHighlight = () => { + if (!searchText.trim()) { + toastService.warning('请输入要搜索的文本'); + return; + } + + // 清除之前的搜索结果 + setSearchResults([]); + const results: HighlightArea[] = []; + + // 遍历所有页面的文本层 + const textLayers = document.querySelectorAll('.textLayer, .react-pdf__Page__textContent'); + + textLayers.forEach((textLayer, index) => { + const pageNumber = index + 1; + const pageElement = textLayer.closest('[data-page-number]'); + if (!pageElement) return; + + const pageRect = pageElement.getBoundingClientRect(); + + // 获取文本层中的所有文本 + const textContent = textLayer.textContent || ''; + + // 搜索所有匹配的文本 + let searchIndex = 0; + while (searchIndex < textContent.length) { + const foundIndex = textContent.toLowerCase().indexOf(searchText.toLowerCase(), searchIndex); + if (foundIndex === -1) break; + + // 找到匹配的文本,现在需要找到对应的 DOM 元素 + try { + // 使用 TreeWalker 遍历文本节点 + const walker = document.createTreeWalker( + textLayer, + NodeFilter.SHOW_TEXT, + null + ); + + let currentNode = walker.nextNode(); + let currentOffset = 0; + const targetStart = foundIndex; + const targetEnd = foundIndex + searchText.length; + + const matchedRanges: Range[] = []; + + while (currentNode) { + const nodeLength = currentNode.textContent?.length || 0; + const nodeStart = currentOffset; + const nodeEnd = currentOffset + nodeLength; + + // 检查这个节点是否包含匹配的文本 + if (nodeEnd > targetStart && nodeStart < targetEnd) { + const range = document.createRange(); + range.selectNode(currentNode); + + const startOffset = Math.max(0, targetStart - nodeStart); + const endOffset = Math.min(nodeLength, targetEnd - nodeStart); + + range.setStart(currentNode, startOffset); + range.setEnd(currentNode, endOffset); + + matchedRanges.push(range); + } + + currentOffset = nodeEnd; + currentNode = walker.nextNode(); + } + + // 获取所有匹配文本的矩形区域 + const allRects: Array<{left: number; top: number; width: number; height: number}> = []; + matchedRanges.forEach(range => { + const rects = range.getClientRects(); + Array.from(rects).forEach(rect => { + allRects.push({ + left: (rect.left - pageRect.left) / scale, + top: (rect.top - pageRect.top) / scale, + width: rect.width / scale, + height: rect.height / scale + }); + }); + }); + + if (allRects.length > 0) { + results.push({ + id: `search-${pageNumber}-${foundIndex}`, + pageNumber, + text: searchText, + rects: allRects, + color: '#FFFF00' // 黄色高亮 + }); + } + + } catch (error) { + console.error('搜索文本时出错:', error); + } + + searchIndex = foundIndex + 1; + } + }); + + setSearchResults(results); + + if (results.length > 0) { + toastService.success(`找到 ${results.length} 处匹配的文本`); + console.log('🔍 搜索结果:', results); + } else { + toastService.warning('未找到匹配的文本'); + } + }; + + const handleClearSearch = () => { + setSearchResults([]); + setSearchText(''); + toastService.success('已清除搜索结果'); + }; + + // ============ 基于坐标的高亮(扫描版PDF)============ + const handleAddCoordinateHighlight = () => { + if (!coordinateInput.trim()) { + toastService.warning('请输入坐标数据'); + return; + } + + try { + // 解析JSON数据 + const data = JSON.parse(coordinateInput); + + let allBoxes: CharacterBox[] = []; + + // 存储按页面和行组织的数据 + const pageLineData: Record = {}; + + // 检测数据格式 + if (data.ocr_result) { + // 新格式:嵌套结构(按行处理) + console.log('🔍 检测到嵌套OCR格式(按行处理)'); + + // 遍历所有文档类型(如"现场笔录") + Object.keys(data.ocr_result).forEach(docType => { + const docData = data.ocr_result[docType]; + + if (docData.single_char_boxes) { + // 遍历所有页面(如"page_7") + Object.entries(docData.single_char_boxes).forEach(([pageKey, pageData]: [string, any]) => { + // 从 "page_7" 提取页码 + const pageMatch = pageKey.match(/page_(\d+)/); + const pageNumber = pageMatch ? parseInt(pageMatch[1]) : 1; + + if (!pageLineData[pageNumber]) { + pageLineData[pageNumber] = []; + } + + // pageData 是一个二维数组,每个子数组代表一行 + if (Array.isArray(pageData)) { + pageData.forEach((line: any) => { + if (Array.isArray(line) && line.length > 0) { + // 解析这一行的所有字符 + const lineChars: CharacterBox[] = []; + line.forEach((charData: any) => { + if (charData.box && charData.char) { + lineChars.push({ + box: charData.box, + char: charData.char, + page: pageNumber + }); + } + }); + + if (lineChars.length > 0) { + // 计算整行的矩形区域 + const firstChar = lineChars[0]; + const lastChar = lineChars[lineChars.length - 1]; + + // 第一个字的左上角 + 最后一个字的右下角 + const rect = { + x1: firstChar.box[0][0], // 左上角 X + y1: firstChar.box[0][1], // 左上角 Y + x2: lastChar.box[2][0], // 右下角 X + y2: lastChar.box[2][1] // 右下角 Y + }; + + pageLineData[pageNumber].push({ + chars: lineChars, + text: lineChars.map(c => c.char).join(''), + rect + }); + } + } + }); + } + }); + } + }); + } else if (Array.isArray(data)) { + // 旧格式:简单数组(兼容处理) + console.log('🔍 检测到简单数组格式(按字符高亮)'); + allBoxes = data.filter(item => item.box && item.char && item.page); + + // 转换为行格式(所有字符作为一行) + allBoxes.forEach(box => { + const page = box.page; + if (!pageLineData[page]) { + pageLineData[page] = []; + } + // 每个字符单独成一行 + pageLineData[page].push({ + chars: [box], + text: box.char, + rect: { + x1: box.box[0][0], + y1: box.box[0][1], + x2: box.box[2][0], + y2: box.box[2][1] + } + }); + }); + } else if (data.box && data.char && data.page) { + // 单个对象 + console.log('🔍 检测到单个字符对象'); + const page = data.page; + pageLineData[page] = [{ + chars: [data], + text: data.char, + rect: { + x1: data.box[0][0], + y1: data.box[0][1], + x2: data.box[2][0], + y2: data.box[2][1] + } + }]; + } else { + toastService.error('无法识别的数据格式'); + return; + } + + // 验证数据 + const totalPages = Object.keys(pageLineData).length; + const totalLines = Object.values(pageLineData).reduce((sum, lines) => sum + lines.length, 0); + const totalChars = Object.values(pageLineData).reduce((sum, lines) => + sum + lines.reduce((lineSum, line) => lineSum + line.chars.length, 0), 0 + ); + + if (totalPages === 0 || totalChars === 0) { + toastService.error('坐标数据为空或格式不正确'); + return; + } + + console.log(`✅ 解析成功: ${totalPages} 页, ${totalLines} 行, ${totalChars} 个字符`); + + // 为每个页面创建高亮 + const newHighlights: CoordinateHighlight[] = []; + Object.entries(pageLineData).forEach(([page, lines]) => { + const text = lines.map(line => line.text).join('\n'); + newHighlights.push({ + id: `coord-${Date.now()}-page-${page}`, + pageNumber: parseInt(page), + text, + lines, + color: '#00FF00' // 绿色,区别于其他高亮 + }); + }); + + setCoordinateHighlights(prev => [...prev, ...newHighlights]); + toastService.success(`已添加 ${totalPages} 页坐标高亮,共 ${totalLines} 行 ${totalChars} 个字符`); + console.log('📍 坐标高亮已添加:', newHighlights); + + } catch (error) { + console.error('解析坐标数据失败:', error); + toastService.error('坐标数据格式错误,请检查JSON格式'); + } + }; + + const handleClearCoordinateHighlights = () => { + setCoordinateHighlights([]); + toastService.success('已清除坐标高亮'); + }; + + const handleFillTestCoordinates = () => { + // 填充测试坐标数据(使用新的嵌套格式) + const testData = { + "ocr_result": { + "现场笔录": { + "single_char_boxes": { + "page_7": [ + [ + { + "box": [[184, 567], [202, 567], [202, 597], [184, 597]], + "char": "站", + "score": 0.99857 + }, + { + "box": [[209, 567], [227, 567], [227, 597], [209, 597]], + "char": "民", + "score": 0.99702 + }, + { + "box": [[234, 567], [252, 567], [252, 597], [234, 597]], + "char": "善", + "score": 0.33934 + }, + { + "box": [[259, 567], [278, 567], [278, 597], [259, 597]], + "char": "在", + "score": 0.98556 + }, + { + "box": [[279, 567], [298, 567], [298, 597], [279, 597]], + "char": "车", + "score": 0.92309 + }, + { + "box": [[304, 567], [323, 567], [323, 597], [304, 597]], + "char": "牌", + "score": 0.50887 + } + ], + [ + { + "box": [[110, 596], [132, 596], [132, 629], [110, 629]], + "char": "轿", + "score": 0.9266 + }, + { + "box": [[132, 596], [151, 596], [151, 629], [132, 629]], + "char": "车", + "score": 0.96376 + }, + { + "box": [[151, 596], [170, 596], [170, 629], [151, 629]], + "char": "上", + "score": 0.99372 + }, + { + "box": [[176, 596], [198, 596], [198, 629], [176, 629]], + "char": "查", + "score": 0.50258 + }, + { + "box": [[198, 596], [220, 596], [220, 629], [198, 629]], + "char": "获", + "score": 0.60755 + } + ] + ] + } + } + } + }; + setCoordinateInput(JSON.stringify(testData, null, 2)); + }; + + // ============ 渲染模式切换 ============ + const handleToggleRenderMode = () => { + setRenderMode(prev => prev === 'canvas' ? 'svg' : 'canvas'); + toastService.success(`切换到 ${renderMode === 'canvas' ? 'SVG' : 'Canvas'} 渲染模式`); + }; + + // ============ 渲染PDF ============ + const renderPdfPages = () => { + if (!numPages) return null; + + return Array.from({ length: numPages }, (_, i) => i + 1).map(pageNum => ( +
{ + if (el) pageRefs.current.set(pageNum, el); + }} + data-page-number={pageNum} + className="mb-8 flex flex-col items-center" + > +
+ 第 {pageNum} 页 +
+ +
+ + + {/* 渲染手动高亮层 */} + {highlights + .filter(h => h.pageNumber === pageNum) + .map(highlight => ( +
+ {highlight.rects.map((rect, idx) => ( +
+ ))} +
+ ))} + + {/* 渲染搜索结果高亮层 */} + {searchResults + .filter(h => h.pageNumber === pageNum) + .map(highlight => ( +
+ {highlight.rects.map((rect, idx) => ( +
+ ))} +
+ ))} + + {/* 渲染基于坐标的高亮层(扫描版PDF - 按行高亮)*/} + {coordinateHighlights + .filter(h => h.pageNumber === pageNum) + .map(highlight => ( + + {highlight.lines.map((line, idx) => { + // 应用校准参数:坐标缩放 + 偏移 + PDF缩放 + const x = (line.rect.x1 * coordinateScale + coordinateOffsetX) * scale; + const y = (line.rect.y1 * coordinateScale + coordinateOffsetY) * scale; + const width = ((line.rect.x2 - line.rect.x1) * coordinateScale) * scale; + const height = ((line.rect.y2 - line.rect.y1) * coordinateScale) * scale; + + return ( + + {`行高亮: ${line.text}`} + + ); + })} + + ))} +
+
+ )); + }; + + return ( +
+ {/* 强制文本层样式 - 确保文本可以被选择 */} + + +
+

React-PDF 功能测试 Demo

+

探索 react-pdf v9.2.1 的各种内置功能

+
+ +
+ {/* 左侧控制面板 */} +
+ +
+ {/* 基础信息 */} +
+

PDF信息

+
+
总页数: {numPages || '-'}
+
当前页: {currentPage}
+
缩放: {Math.round(scale * 100)}%
+
旋转: {rotation}°
+
渲染模式: {renderMode.toUpperCase()}
+
+
+ + {/* 缩放控制 */} +
+

缩放控制

+
+ + + +
+
+ + {/* 旋转控制 */} +
+

旋转控制

+
+ + +
+
+ + {/* 页面导航 */} +
+

页面导航

+
+ + + handleGoToPage(parseInt(e.target.value) || 1)} + className="w-full px-2 py-1 text-sm border border-gray-300 rounded" + placeholder="跳转到页码" + /> +
+
+ + {/* 图层控制 */} +
+

图层控制

+
+ + +
+
+ + {/* 渲染模式 */} +
+

渲染模式

+ +
+ + {/* 文本搜索 */} +
+

文本搜索

+
+