PDFSelectionTool.tsx 16 KB

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