PDFSelectionTool.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. import React, { useState, useRef, useEffect } from 'react';
  2. import * as pdfjs from 'pdfjs-dist';
  3. // Set worker path for PDF.js
  4. pdfjs.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`;
  5. // Helper function to convert coordinates between different scales
  6. const convertCoordinates = (
  7. x: number,
  8. y: number,
  9. containerWidth: number,
  10. containerHeight: number,
  11. pdfWidth: number,
  12. pdfHeight: number,
  13. zoomLevel: number = 1.0
  14. ) => {
  15. // Calculate the scale factors to fit the PDF page in the container while maintaining aspect ratio
  16. const scaleX = containerWidth / pdfWidth;
  17. const scaleY = containerHeight / pdfHeight;
  18. let scale = Math.min(scaleX, scaleY);
  19. // Apply zoom level to the scale
  20. scale *= zoomLevel;
  21. // Calculate padding offsets to center the PDF page in the container
  22. const paddedWidth = pdfWidth * scale;
  23. const paddedHeight = pdfHeight * scale;
  24. const offsetX = (containerWidth - paddedWidth) / 2;
  25. const offsetY = (containerHeight - paddedHeight) / 2;
  26. // Convert from container coordinates to PDF page coordinates
  27. const pdfX = (x - offsetX) / scale;
  28. const pdfY = (y - offsetY) / scale;
  29. return { x: pdfX, y: pdfY, scale, offsetX, offsetY };
  30. };
  31. // Function to calculate how PDF page is laid out in container space
  32. const calculatePDFLayout = (
  33. containerWidth: number,
  34. containerHeight: number,
  35. pdfWidth: number,
  36. pdfHeight: number,
  37. zoomLevel: number = 1.0
  38. ) => {
  39. // Calculate the scale factors to fit the PDF page in the container while maintaining aspect ratio
  40. const scaleX = containerWidth / pdfWidth;
  41. const scaleY = containerHeight / pdfHeight;
  42. let pageScale = Math.min(scaleX, scaleY);
  43. // Apply zoom level to the page scale
  44. pageScale *= zoomLevel;
  45. // Calculate padding offsets to center the PDF page in the container
  46. const paddedWidth = pdfWidth * pageScale;
  47. const paddedHeight = pdfHeight * pageScale;
  48. const offsetX = (containerWidth - paddedWidth) / 2;
  49. const offsetY = (containerHeight - paddedHeight) / 2;
  50. return {
  51. pageScale,
  52. offsetX: Math.round(offsetX),
  53. offsetY: Math.round(offsetY),
  54. paddedWidth,
  55. paddedHeight
  56. };
  57. };
  58. // Enhanced function to calculate precise PDF page layout with improved accuracy
  59. const calculatePrecisePDFLayout = (
  60. containerWidth: number,
  61. containerHeight: number,
  62. pdfWidth: number,
  63. pdfHeight: number,
  64. zoomLevel: number = 1.0
  65. ) => {
  66. // Calculate scale to fit the PDF page in the container while maintaining aspect ratio
  67. const scaleX = containerWidth / pdfWidth;
  68. const scaleY = containerHeight / pdfHeight;
  69. let pageScale = Math.min(scaleX, scaleY);
  70. // Apply zoom level to the page scale
  71. pageScale *= zoomLevel;
  72. // Calculate exact page dimensions after scaling
  73. const scaledPageWidth = pdfWidth * pageScale;
  74. const scaledPageHeight = pdfHeight * pageScale;
  75. // Calculate padding to center the page in the container
  76. const offsetX = (containerWidth - scaledPageWidth) / 2;
  77. const offsetY = (containerHeight - scaledPageHeight) / 2;
  78. return {
  79. pageScale,
  80. offsetX: offsetX,
  81. offsetY: offsetY,
  82. scaledPageWidth,
  83. scaledPageHeight,
  84. containerWidth,
  85. containerHeight
  86. };
  87. };
  88. export interface SelectionCoordinates {
  89. x: number;
  90. y: number;
  91. width: number;
  92. height: number;
  93. }
  94. interface PDFSelectionToolProps {
  95. containerRef: React.RefObject<HTMLDivElement>;
  96. canvasRef: React.RefObject<HTMLCanvasElement>;
  97. onSelectionComplete: (screenshot: Blob, text: string) => void;
  98. onCancel: () => void;
  99. pdfBlob: Blob | null;
  100. pageNumber: number;
  101. authToken: string;
  102. zoomLevel?: number; // オプションのズームレベルパラメータ
  103. }
  104. export const PDFSelectionTool: React.FC<PDFSelectionToolProps> = ({
  105. containerRef,
  106. canvasRef,
  107. onSelectionComplete,
  108. onCancel,
  109. pdfBlob,
  110. pageNumber,
  111. authToken,
  112. zoomLevel = 1.0, // デフォルトのズームレベルは1.0
  113. }) => {
  114. const [isSelecting, setIsSelecting] = useState(false);
  115. const [startPoint, setStartPoint] = useState<{ x: number; y: number } | null>(null);
  116. const [currentPoint, setCurrentPoint] = useState<{ x: number; y: number } | null>(null);
  117. const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
  118. useEffect(() => {
  119. const handleKeyDown = (e: KeyboardEvent) => {
  120. if (e.key === 'Escape') {
  121. onCancel();
  122. }
  123. };
  124. window.addEventListener('keydown', handleKeyDown);
  125. return () => window.removeEventListener('keydown', handleKeyDown);
  126. }, [onCancel]);
  127. const handleMouseDown = (e: React.MouseEvent) => {
  128. if (!containerRef.current || !pdfBlob) return;
  129. const rect = containerRef.current.getBoundingClientRect();
  130. // コンテナに対する実際の座標を使用
  131. const x = e.clientX - rect.left;
  132. const y = e.clientY - rect.top;
  133. setStartPoint({ x, y });
  134. setCurrentPoint({ x, y });
  135. setIsSelecting(true);
  136. };
  137. const handleMouseMove = (e: React.MouseEvent) => {
  138. if (!isSelecting || !containerRef.current) return;
  139. const rect = containerRef.current.getBoundingClientRect();
  140. const x = e.clientX - rect.left;
  141. const y = e.clientY - rect.top;
  142. setCurrentPoint({ x, y });
  143. };
  144. const handleMouseUp = async () => {
  145. if (!isSelecting || !startPoint || !currentPoint || !containerRef.current || !canvasRef.current) return;
  146. setIsSelecting(false);
  147. // Calculate selection rectangle based on mouse events (in container coordinates)
  148. const startX = Math.min(startPoint.x, currentPoint.x);
  149. const startY = Math.min(startPoint.y, currentPoint.y);
  150. const endX = Math.max(startPoint.x, currentPoint.x);
  151. const endY = Math.max(startPoint.y, currentPoint.y);
  152. const width = endX - startX;
  153. const height = endY - startY;
  154. // Minimum selection size
  155. if (width < 10 || height < 10) {
  156. onCancel();
  157. return;
  158. }
  159. try {
  160. // Get the actual canvas element from PDFPreview
  161. const sourceCanvas = canvasRef.current;
  162. const containerRect = containerRef.current.getBoundingClientRect();
  163. console.log('=== Direct Canvas Capture ===');
  164. console.log('Container dimensions:', { width: containerRect.width, height: containerRect.height });
  165. console.log('Canvas dimensions:', { width: sourceCanvas.width, height: sourceCanvas.height });
  166. console.log('Selection in container space:', { startX, startY, endX, endY, width, height });
  167. // Get the canvas bounding rect to find where it's positioned within the container
  168. const canvasRect = sourceCanvas.getBoundingClientRect();
  169. const canvasOffsetX = canvasRect.left - containerRect.left;
  170. const canvasOffsetY = canvasRect.top - containerRect.top;
  171. console.log('Canvas position in container:', { offsetX: canvasOffsetX, offsetY: canvasOffsetY });
  172. console.log('Canvas display size:', { width: canvasRect.width, height: canvasRect.height });
  173. // Calculate selection relative to the canvas element
  174. const selectionRelativeToCanvas = {
  175. x: startX - canvasOffsetX,
  176. y: startY - canvasOffsetY,
  177. width: width,
  178. height: height
  179. };
  180. console.log('Selection relative to canvas:', selectionRelativeToCanvas);
  181. // Calculate the scale factor between canvas display size and actual pixel size
  182. // The canvas may be rendered at higher resolution (devicePixelRatio)
  183. const scaleX = sourceCanvas.width / canvasRect.width;
  184. const scaleY = sourceCanvas.height / canvasRect.height;
  185. console.log('Canvas scale factors:', { scaleX, scaleY });
  186. // Convert selection coordinates to canvas pixel coordinates
  187. const canvasX = Math.round(selectionRelativeToCanvas.x * scaleX);
  188. const canvasY = Math.round(selectionRelativeToCanvas.y * scaleY);
  189. const canvasWidth = Math.round(selectionRelativeToCanvas.width * scaleX);
  190. const canvasHeight = Math.round(selectionRelativeToCanvas.height * scaleY);
  191. console.log('Selection in canvas pixel space:', { canvasX, canvasY, canvasWidth, canvasHeight });
  192. // Ensure coordinates are within canvas bounds
  193. const safeX = Math.max(0, Math.min(canvasX, sourceCanvas.width));
  194. const safeY = Math.max(0, Math.min(canvasY, sourceCanvas.height));
  195. const safeWidth = Math.max(0, Math.min(canvasWidth, sourceCanvas.width - safeX));
  196. const safeHeight = Math.max(0, Math.min(canvasHeight, sourceCanvas.height - safeY));
  197. console.log('Safe coordinates:', { safeX, safeY, safeWidth, safeHeight });
  198. if (safeWidth === 0 || safeHeight === 0) {
  199. console.warn('Selection is outside canvas bounds');
  200. onCancel();
  201. return;
  202. }
  203. // Extract the selected region from the source canvas
  204. const ctx = sourceCanvas.getContext('2d');
  205. if (!ctx) {
  206. throw new Error('Could not get canvas context');
  207. }
  208. const imageData = ctx.getImageData(safeX, safeY, safeWidth, safeHeight);
  209. // Create a new canvas for the selected region
  210. const selectedCanvas = document.createElement('canvas');
  211. selectedCanvas.width = safeWidth;
  212. selectedCanvas.height = safeHeight;
  213. const selectedCtx = selectedCanvas.getContext('2d');
  214. if (!selectedCtx) {
  215. throw new Error('Could not create selection canvas context');
  216. }
  217. selectedCtx.putImageData(imageData, 0, 0);
  218. // Convert selected canvas to blob
  219. const screenshot = await new Promise<Blob>((resolve, reject) => {
  220. selectedCanvas.toBlob((blob) => {
  221. if (blob) {
  222. resolve(blob);
  223. } else {
  224. reject(new Error('Failed to create blob from canvas'));
  225. }
  226. }, 'image/jpeg', 0.98);
  227. });
  228. console.log('Screenshot created successfully');
  229. // Extract text from the selected area using OCR
  230. let extractedText = '';
  231. try {
  232. extractedText = await performOCR(screenshot, authToken);
  233. } catch (ocrError) {
  234. console.error('OCR extraction failed:', ocrError);
  235. }
  236. onSelectionComplete(screenshot, extractedText);
  237. } catch (error) {
  238. console.error('Failed to process selection:', error);
  239. onCancel();
  240. }
  241. };
  242. // Render PDF to canvas at specified scale
  243. const renderPDFToCanvas = async (
  244. pdfBlob: Blob,
  245. pageNumber: number,
  246. canvas: HTMLCanvasElement,
  247. containerWidth: number,
  248. containerHeight: number,
  249. renderScale: number,
  250. zoomLevel: number = 1.0
  251. ): Promise<{ offsetX: number; offsetY: number; pageScale: number; viewport: any }> => {
  252. const pdfData = await pdfBlob.arrayBuffer();
  253. const pdf = await pdfjs.getDocument({ data: pdfData }).promise;
  254. if (pageNumber < 1 || pageNumber > pdf.numPages) {
  255. throw new Error(`Invalid page number: ${pageNumber}`);
  256. }
  257. const page = await pdf.getPage(pageNumber);
  258. // Calculate the scale needed to render the PDF page to match the layout in container
  259. // We want the same aspect ratio and positioning as in the PDF viewer
  260. const originalViewport = page.getViewport({ scale: 1 });
  261. // Calculate scale factors to fit page within container while preserving aspect ratio
  262. const scaleX = containerWidth / originalViewport.width;
  263. const scaleY = containerHeight / originalViewport.height;
  264. let pageScale = Math.min(scaleX, scaleY);
  265. // Apply zoom level to the page scale
  266. pageScale *= zoomLevel;
  267. // Apply the render scale factor for high resolution
  268. const finalScale = pageScale * renderScale;
  269. // Create the viewport at this scale
  270. const viewport = page.getViewport({ scale: finalScale });
  271. const context = canvas.getContext('2d');
  272. if (!context) {
  273. // Return default values if context not available
  274. return { offsetX: 0, offsetY: 0, pageScale: 1, viewport: originalViewport };
  275. }
  276. // Calculate offset to center the page in the canvas
  277. const offsetX = Math.round((canvas.width - viewport.width) / 2);
  278. const offsetY = Math.round((canvas.height - viewport.height) / 2);
  279. // Render the page with anti-aliasing and smooth rendering for quality
  280. const renderContext = {
  281. canvasContext: context,
  282. viewport: viewport,
  283. transform: [1, 0, 0, 1, offsetX, offsetY],
  284. intent: 'display' as const
  285. };
  286. // Render the page with improved rendering quality
  287. await page.render(renderContext).promise;
  288. return { offsetX, offsetY, pageScale, viewport };
  289. };
  290. // Perform OCR on the captured image
  291. const performOCR = async (image: Blob, token: string): Promise<string> => {
  292. const formData = new FormData();
  293. formData.append('image', image);
  294. const response = await fetch('/api/ocr/recognize', {
  295. method: 'POST',
  296. headers: {
  297. 'Authorization': `Bearer ${token}`
  298. },
  299. body: formData,
  300. });
  301. if (!response.ok) {
  302. throw new Error('Failed to recognize text via OCR');
  303. }
  304. const data = await response.json();
  305. return data.text;
  306. };
  307. useEffect(() => {
  308. if (!overlayCanvasRef.current || !startPoint || !currentPoint || !containerRef.current) return;
  309. const canvas = overlayCanvasRef.current;
  310. const ctx = canvas.getContext('2d');
  311. if (!ctx) return;
  312. // Match the canvas dimensions to the container
  313. const containerRect = containerRef.current.getBoundingClientRect();
  314. canvas.width = containerRect.width;
  315. canvas.height = containerRect.height;
  316. // Clear canvas
  317. ctx.clearRect(0, 0, canvas.width, canvas.height);
  318. // Draw selection rectangle
  319. const x = Math.min(startPoint.x, currentPoint.x);
  320. const y = Math.min(startPoint.y, currentPoint.y);
  321. const width = Math.abs(currentPoint.x - startPoint.x);
  322. const height = Math.abs(currentPoint.y - startPoint.y);
  323. // Draw semi-transparent overlay
  324. ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
  325. ctx.fillRect(0, 0, canvas.width, canvas.height);
  326. // Clear selection area
  327. ctx.clearRect(x, y, width, height);
  328. // Draw selection border
  329. ctx.strokeStyle = '#3b82f6';
  330. ctx.lineWidth = 2;
  331. ctx.strokeRect(x, y, width, height);
  332. // Draw corner handles
  333. const handleSize = 8;
  334. ctx.fillStyle = '#3b82f6';
  335. ctx.fillRect(x - handleSize / 2, y - handleSize / 2, handleSize, handleSize);
  336. ctx.fillRect(x + width - handleSize / 2, y - handleSize / 2, handleSize, handleSize);
  337. ctx.fillRect(x - handleSize / 2, y + height - handleSize / 2, handleSize, handleSize);
  338. ctx.fillRect(x + width - handleSize / 2, y + height - handleSize / 2, handleSize, handleSize);
  339. }, [startPoint, currentPoint, containerRef]);
  340. return (
  341. <div
  342. className="absolute inset-0 z-50 cursor-crosshair bg-white/20"
  343. onMouseDown={handleMouseDown}
  344. onMouseMove={handleMouseMove}
  345. onMouseUp={handleMouseUp}
  346. >
  347. <canvas
  348. ref={overlayCanvasRef}
  349. className="absolute inset-0 pointer-events-none"
  350. />
  351. <div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-black bg-opacity-75 text-white px-4 py-2 rounded-lg text-sm z-[60]">
  352. 拖动鼠标选择区域 • 按 ESC 取消
  353. </div>
  354. </div>
  355. );
  356. };