From 84f530f4cc87791647d8d4a341e3116a66677d6c Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Tue, 28 Jan 2025 21:51:45 -0700 Subject: [PATCH] Extract PDF context items to hooks --- src/contexts/PDFContext.tsx | 400 ++---------------------------- src/hooks/usePDFDocuments.ts | 76 ++++++ src/hooks/usePDFHighlighting.ts | 150 +++++++++++ src/hooks/usePDFTextClick.ts | 81 ++++++ src/hooks/usePDFTextProcessing.ts | 97 ++++++++ src/hooks/usePDFURLConversion.ts | 22 ++ 6 files changed, 441 insertions(+), 385 deletions(-) create mode 100644 src/hooks/usePDFDocuments.ts create mode 100644 src/hooks/usePDFHighlighting.ts create mode 100644 src/hooks/usePDFTextClick.ts create mode 100644 src/hooks/usePDFTextProcessing.ts create mode 100644 src/hooks/usePDFURLConversion.ts diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index c81d333..82c2dba 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -22,34 +22,15 @@ import { useCallback, useMemo, } from 'react'; -import { indexedDBService, type PDFDocument } from '@/services/indexedDB'; -import { v4 as uuidv4 } from 'uuid'; -import { pdfjs } from 'react-pdf'; -import stringSimilarity from 'string-similarity'; -import nlp from 'compromise'; -// Add the correct type import -import type { TextItem } from 'pdfjs-dist/types/src/display/api'; +import { indexedDBService, type PDFDocument } from '@/services/indexedDB'; import { useConfig } from '@/contexts/ConfigContext'; import { useTTS } from '@/contexts/TTSContext'; - -// Set worker from public directory -pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs'; - -/** - * Converts PDF binary data to a data URL for display - * - * @param {Blob} pdfData - The PDF binary data - * @returns {Promise} A data URL representing the PDF - */ -const convertPDFDataToURL = (pdfData: Blob): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = () => reject(reader.error); - reader.readAsDataURL(pdfData); - }); -}; +import { usePDFDocuments } from '@/hooks/usePDFDocuments'; +import { usePDFHighlighting } from '@/hooks/usePDFHighlighting'; +import { usePDFTextClick } from '@/hooks/usePDFTextClick'; +import { usePDFTextProcessing } from '@/hooks/usePDFTextProcessing'; +import { usePDFURLConversion } from '@/hooks/usePDFURLConversion'; /** * Interface defining all available methods and properties in the PDF context @@ -93,15 +74,6 @@ const PDFContext = createContext(undefined); * Handles document loading, text processing, and integration with TTS. */ export function PDFProvider({ children }: { children: ReactNode }) { - /** - * State Management - * - Document management - * - Current document state - * - Loading states - */ - const [documents, setDocuments] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const { isDBReady } = useConfig(); const { setText: setTTSText, @@ -110,72 +82,18 @@ export function PDFProvider({ children }: { children: ReactNode }) { setCurrDocPages, } = useTTS(); + // Initialize hooks + const { documents, isLoading, addDocument, removeDocument } = usePDFDocuments(isDBReady); + const { extractTextFromPDF } = usePDFTextProcessing(); + const { highlightPattern, clearHighlights } = usePDFHighlighting(); + const { handleTextClick } = usePDFTextClick(); + const { convertPDFDataToURL } = usePDFURLConversion(); + // Current document state const [currDocURL, setCurrDocURL] = useState(); const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); - /** - * Load documents from IndexedDB when the database is ready - */ - useEffect(() => { - const loadDocuments = async () => { - if (!isDBReady) return; - - try { - const docs = await indexedDBService.getAllDocuments(); - setDocuments(docs); - } catch (error) { - console.error('Failed to load documents:', error); - } finally { - setIsLoading(false); - } - }; - - loadDocuments(); - }, [isDBReady]); - - /** - * Adds a new document to IndexedDB - * - * @param {File} file - The PDF file to add - * @returns {Promise} The ID of the added document - */ - const addDocument = useCallback(async (file: File): Promise => { - const id = uuidv4(); - const newDoc: PDFDocument = { - id, - name: file.name, - size: file.size, - lastModified: file.lastModified, - data: new Blob([file], { type: file.type }), - }; - - try { - await indexedDBService.addDocument(newDoc); - setDocuments((prev) => [...prev, newDoc]); - return id; - } catch (error) { - console.error('Failed to add document:', error); - throw error; - } - }, []); - - /** - * Removes a document from IndexedDB - * - * @param {string} id - The ID of the document to remove - */ - const removeDocument = useCallback(async (id: string): Promise => { - try { - await indexedDBService.removeDocument(id); - setDocuments((prev) => prev.filter((doc) => doc.id !== id)); - } catch (error) { - console.error('Failed to remove document:', error); - throw error; - } - }, []); - /** * Handles successful document load * @@ -186,91 +104,6 @@ export function PDFProvider({ children }: { children: ReactNode }) { setCurrDocPages(numPages); }, [setCurrDocPages]); - /** - * Extracts text content from a specific page of the PDF - * - * @param {string} pdfURL - The URL of the PDF - * @param {number} currDocPage - The page number to extract - * @returns {Promise} The extracted text - */ - const extractTextFromPDF = useCallback(async (pdfURL: string, currDocPage: number): Promise => { - try { - const base64Data = pdfURL.split(',')[1]; - const binaryData = atob(base64Data); - const bytes = new Uint8Array(binaryData.length); - for (let i = 0; i < binaryData.length; i++) { - bytes[i] = binaryData.charCodeAt(i); - } - - const loadingTask = pdfjs.getDocument({ data: bytes }); - const pdf = await loadingTask.promise; - - // Get only the specified page - const page = await pdf.getPage(currDocPage); - const textContent = await page.getTextContent(); - - // Filter out non-text items and assert proper type - const textItems = textContent.items.filter((item): item is TextItem => - 'str' in item && 'transform' in item - ); - - // Group text items into lines based on their vertical position - const tolerance = 2; - const lines: TextItem[][] = []; - let currentLine: TextItem[] = []; - let currentY: number | null = null; - - textItems.forEach((item) => { - const y = item.transform[5]; - if (currentY === null) { - currentY = y; - currentLine.push(item); - } else if (Math.abs(y - currentY) < tolerance) { - currentLine.push(item); - } else { - lines.push(currentLine); - currentLine = [item]; - currentY = y; - } - }); - lines.push(currentLine); - - // Process each line to build text - let pageText = ''; - for (const line of lines) { - // Sort items horizontally within the line - line.sort((a, b) => a.transform[4] - b.transform[4]); - - let lineText = ''; - let prevItem: TextItem | null = null; - - for (const item of line) { - if (!prevItem) { - lineText = item.str; - } else { - const prevEndX = prevItem.transform[4] + (prevItem.width ?? 0); - const currentStartX = item.transform[4]; - const space = currentStartX - prevEndX; - - // Add space if gap is significant, otherwise concatenate directly - if (space > ((item.width ?? 0) * 0.3)) { - lineText += ' ' + item.str; - } else { - lineText += item.str; - } - } - prevItem = item; - } - pageText += lineText + ' '; - } - - return pageText.replace(/\s+/g, ' ').trim(); - } catch (error) { - console.error('Error extracting text from PDF:', error); - throw new Error('Failed to extract text from PDF'); - } - }, []); - /** * Loads and processes text from the current document page */ @@ -306,12 +139,11 @@ export function PDFProvider({ children }: { children: ReactNode }) { const url = await convertPDFDataToURL(doc.data); setCurrDocName(doc.name); setCurrDocURL(url); - //await loadCurrDocText(); } } catch (error) { console.error('Failed to get document URL:', error); } - }, []); + }, [convertPDFDataToURL]); /** * Clears the current document state @@ -320,212 +152,10 @@ export function PDFProvider({ children }: { children: ReactNode }) { setCurrDocName(undefined); setCurrDocURL(undefined); setCurrDocText(undefined); - - // Clear TTS text - setCurrDocPages(undefined); // Goes to TTS context + setCurrDocPages(undefined); setTTSText(''); - }, [setCurrDocPages, setTTSText]); - /** - * Removes all text highlights from the PDF viewer - */ - const clearHighlights = useCallback(() => { - const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span'); - textNodes.forEach((node) => { - const element = node as HTMLElement; - element.style.backgroundColor = ''; - element.style.opacity = '1'; - }); - }, []); - - /** - * Finds the best matching text segment using string similarity - * - * @param {Array} elements - Array of elements and their text content - * @param {string} targetText - The text to match against - * @param {number} maxCombinedLength - Maximum length of combined text to consider - */ - const findBestTextMatch = useCallback(( - elements: Array<{ element: HTMLElement; text: string }>, - targetText: string, - maxCombinedLength: number - ) => { - let bestMatch = { - elements: [] as HTMLElement[], - rating: 0, - text: '', - lengthDiff: Infinity, - }; - - for (let i = 0; i < elements.length; i++) { - let combinedText = ''; - const currentElements = []; - for (let j = i; j < Math.min(i + 10, elements.length); j++) { - const node = elements[j]; - const newText = combinedText ? `${combinedText} ${node.text}` : node.text; - if (newText.length > maxCombinedLength) break; - - combinedText = newText; - currentElements.push(node.element); - - const similarity = stringSimilarity.compareTwoStrings(targetText, combinedText); - const lengthDiff = Math.abs(combinedText.length - targetText.length); - const lengthPenalty = lengthDiff / targetText.length; - const adjustedRating = similarity * (1 - lengthPenalty * 0.5); - - if (adjustedRating > bestMatch.rating) { - bestMatch = { - elements: [...currentElements], - rating: adjustedRating, - text: combinedText, - lengthDiff, - }; - } - } - } - - return bestMatch; - }, []); - - /** - * Highlights matching text in the PDF viewer - * Uses a sliding context window for improved accuracy - * - * @param {string} text - The document text - * @param {string} pattern - The pattern to highlight - * @param {RefObject} containerRef - Reference to the container element - */ - const highlightPattern = useCallback((text: string, pattern: string, containerRef: React.RefObject) => { - clearHighlights(); - - if (!pattern?.trim()) return; - - const cleanPattern = pattern.trim().replace(/\s+/g, ' '); - const container = containerRef.current; - if (!container) return; - - const textNodes = container.querySelectorAll('.react-pdf__Page__textContent span'); - const allText = Array.from(textNodes).map((node) => ({ - element: node as HTMLElement, - text: (node.textContent || '').trim(), - })).filter((node) => node.text.length > 0); - - // Calculate the visible area of the container - const containerRect = container.getBoundingClientRect(); - const visibleTop = container.scrollTop; - const visibleBottom = visibleTop + containerRect.height; - - // Find nodes within the visible area and a buffer zone - const bufferSize = containerRect.height; // One screen height buffer - const visibleNodes = allText.filter(({ element }) => { - const rect = element.getBoundingClientRect(); - const elementTop = rect.top - containerRect.top + container.scrollTop; - return elementTop >= (visibleTop - bufferSize) && elementTop <= (visibleBottom + bufferSize); - }); - - // Search for the best match within the visible area first - let bestMatch = findBestTextMatch(visibleNodes, cleanPattern, cleanPattern.length * 2); - - // If no good match found in visible area, search the entire document - if (bestMatch.rating < 0.3) { - bestMatch = findBestTextMatch(allText, cleanPattern, cleanPattern.length * 2); - } - - const similarityThreshold = bestMatch.lengthDiff < cleanPattern.length * 0.3 ? 0.3 : 0.5; - - if (bestMatch.rating >= similarityThreshold) { - bestMatch.elements.forEach((element) => { - element.style.backgroundColor = 'grey'; - element.style.opacity = '0.4'; - }); - - if (bestMatch.elements.length > 0) { - const element = bestMatch.elements[0]; - const elementRect = element.getBoundingClientRect(); - const elementTop = elementRect.top - containerRect.top + container.scrollTop; - - // Only scroll if the element is outside the visible area - if (elementTop < visibleTop || elementTop > visibleBottom) { - container.scrollTo({ - top: elementTop - containerRect.height / 3, // Position the highlight in the top third - behavior: 'smooth', - }); - } - } - } - }, [clearHighlights, findBestTextMatch]); - - /** - * Handles text click events in the PDF viewer - * Integrates with TTS for synchronized playback - * - * @param {MouseEvent} event - The click event - * @param {string} pdfText - The text content of the page - * @param {RefObject} containerRef - Reference to the container element - * @param {Function} stopAndPlayFromIndex - Function to control TTS playback - * @param {boolean} isProcessing - Whether TTS is currently processing - */ - const handleTextClick = useCallback(( - event: MouseEvent, - pdfText: string, // Renamed from pdfText to pageText for clarity - containerRef: React.RefObject, - stopAndPlayFromIndex: (index: number) => void, - isProcessing: boolean - ) => { - if (isProcessing) return; - - const target = event.target as HTMLElement; - if (!target.matches('.react-pdf__Page__textContent span')) return; - - const parentElement = target.closest('.react-pdf__Page__textContent'); - if (!parentElement) return; - - const spans = Array.from(parentElement.querySelectorAll('span')); - const clickedIndex = spans.indexOf(target); - const contextWindow = 3; - const startIndex = Math.max(0, clickedIndex - contextWindow); - const endIndex = Math.min(spans.length - 1, clickedIndex + contextWindow); - const contextText = spans - .slice(startIndex, endIndex + 1) - .map((span) => span.textContent) - .join(' ') - .trim(); - - if (!contextText?.trim()) return; - - const cleanContext = contextText.trim().replace(/\s+/g, ' '); - const allText = Array.from(parentElement.querySelectorAll('span')).map((node) => ({ - element: node as HTMLElement, - text: (node.textContent || '').trim(), - })).filter((node) => node.text.length > 0); - - const bestMatch = findBestTextMatch(allText, cleanContext, cleanContext.length * 2); - const similarityThreshold = bestMatch.lengthDiff < cleanContext.length * 0.3 ? 0.3 : 0.5; - - if (bestMatch.rating >= similarityThreshold) { - const matchText = bestMatch.text; - // Use pageText instead of full PDF text for sentence splitting - const sentences = nlp(pdfText).sentences().out('array') as string[]; - let bestSentenceMatch = { sentence: '', rating: 0 }; - - for (const sentence of sentences) { - const rating = stringSimilarity.compareTwoStrings(matchText, sentence); - if (rating > bestSentenceMatch.rating) { - bestSentenceMatch = { sentence, rating }; - } - } - - if (bestSentenceMatch.rating >= 0.5) { - const sentenceIndex = sentences.findIndex((sentence) => sentence === bestSentenceMatch.sentence); - if (sentenceIndex !== -1) { - stopAndPlayFromIndex(sentenceIndex); - highlightPattern(pdfText, bestSentenceMatch.sentence, containerRef); - } - } - } - }, [highlightPattern, findBestTextMatch]); - // Context value memoization const contextValue = useMemo( () => ({ diff --git a/src/hooks/usePDFDocuments.ts b/src/hooks/usePDFDocuments.ts new file mode 100644 index 0000000..ff7baec --- /dev/null +++ b/src/hooks/usePDFDocuments.ts @@ -0,0 +1,76 @@ +import { useState, useCallback, useEffect } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { indexedDBService, type PDFDocument } from '@/services/indexedDB'; + +export function usePDFDocuments(isDBReady: boolean) { + const [documents, setDocuments] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + /** + * Load documents from IndexedDB when the database is ready + */ + useEffect(() => { + const loadDocuments = async () => { + if (!isDBReady) return; + + try { + const docs = await indexedDBService.getAllDocuments(); + setDocuments(docs); + } catch (error) { + console.error('Failed to load documents:', error); + } finally { + setIsLoading(false); + } + }; + + loadDocuments(); + }, [isDBReady]); + + /** + * Adds a new document to IndexedDB + * + * @param {File} file - The PDF file to add + * @returns {Promise} The ID of the added document + */ + const addDocument = useCallback(async (file: File): Promise => { + const id = uuidv4(); + const newDoc: PDFDocument = { + id, + name: file.name, + size: file.size, + lastModified: file.lastModified, + data: new Blob([file], { type: file.type }), + }; + + try { + await indexedDBService.addDocument(newDoc); + setDocuments((prev) => [...prev, newDoc]); + return id; + } catch (error) { + console.error('Failed to add document:', error); + throw error; + } + }, []); + + /** + * Removes a document from IndexedDB + * + * @param {string} id - The ID of the document to remove + */ + const removeDocument = useCallback(async (id: string): Promise => { + try { + await indexedDBService.removeDocument(id); + setDocuments((prev) => prev.filter((doc) => doc.id !== id)); + } catch (error) { + console.error('Failed to remove document:', error); + throw error; + } + }, []); + + return { + documents, + isLoading, + addDocument, + removeDocument, + }; +} diff --git a/src/hooks/usePDFHighlighting.ts b/src/hooks/usePDFHighlighting.ts new file mode 100644 index 0000000..5333a2c --- /dev/null +++ b/src/hooks/usePDFHighlighting.ts @@ -0,0 +1,150 @@ +import { useCallback } from 'react'; +import stringSimilarity from 'string-similarity'; + +interface TextMatch { + elements: HTMLElement[]; + rating: number; + text: string; + lengthDiff: number; +} + +export function usePDFHighlighting() { + /** + * Removes all text highlights from the PDF viewer + */ + const clearHighlights = useCallback(() => { + const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span'); + textNodes.forEach((node) => { + const element = node as HTMLElement; + element.style.backgroundColor = ''; + element.style.opacity = '1'; + }); + }, []); + + /** + * Finds the best matching text segment using string similarity + * + * @param {Array} elements - Array of elements and their text content + * @param {string} targetText - The text to match against + * @param {number} maxCombinedLength - Maximum length of combined text to consider + */ + const findBestTextMatch = useCallback(( + elements: Array<{ element: HTMLElement; text: string }>, + targetText: string, + maxCombinedLength: number + ): TextMatch => { + let bestMatch = { + elements: [] as HTMLElement[], + rating: 0, + text: '', + lengthDiff: Infinity, + }; + + for (let i = 0; i < elements.length; i++) { + let combinedText = ''; + const currentElements = []; + for (let j = i; j < Math.min(i + 10, elements.length); j++) { + const node = elements[j]; + const newText = combinedText ? `${combinedText} ${node.text}` : node.text; + if (newText.length > maxCombinedLength) break; + + combinedText = newText; + currentElements.push(node.element); + + const similarity = stringSimilarity.compareTwoStrings(targetText, combinedText); + const lengthDiff = Math.abs(combinedText.length - targetText.length); + const lengthPenalty = lengthDiff / targetText.length; + const adjustedRating = similarity * (1 - lengthPenalty * 0.5); + + if (adjustedRating > bestMatch.rating) { + bestMatch = { + elements: [...currentElements], + rating: adjustedRating, + text: combinedText, + lengthDiff, + }; + } + } + } + + return bestMatch; + }, []); + + /** + * Highlights matching text in the PDF viewer + * Uses a sliding context window for improved accuracy + * + * @param {string} text - The document text + * @param {string} pattern - The pattern to highlight + * @param {RefObject} containerRef - Reference to the container element + */ + const highlightPattern = useCallback(( + text: string, + pattern: string, + containerRef: React.RefObject + ) => { + clearHighlights(); + + if (!pattern?.trim()) return; + + const cleanPattern = pattern.trim().replace(/\s+/g, ' '); + const container = containerRef.current; + if (!container) return; + + const textNodes = container.querySelectorAll('.react-pdf__Page__textContent span'); + const allText = Array.from(textNodes).map((node) => ({ + element: node as HTMLElement, + text: (node.textContent || '').trim(), + })).filter((node) => node.text.length > 0); + + // Calculate the visible area of the container + const containerRect = container.getBoundingClientRect(); + const visibleTop = container.scrollTop; + const visibleBottom = visibleTop + containerRect.height; + + // Find nodes within the visible area and a buffer zone + const bufferSize = containerRect.height; // One screen height buffer + const visibleNodes = allText.filter(({ element }) => { + const rect = element.getBoundingClientRect(); + const elementTop = rect.top - containerRect.top + container.scrollTop; + return elementTop >= (visibleTop - bufferSize) && elementTop <= (visibleBottom + bufferSize); + }); + + // Search for the best match within the visible area first + let bestMatch = findBestTextMatch(visibleNodes, cleanPattern, cleanPattern.length * 2); + + // If no good match found in visible area, search the entire document + if (bestMatch.rating < 0.3) { + bestMatch = findBestTextMatch(allText, cleanPattern, cleanPattern.length * 2); + } + + const similarityThreshold = bestMatch.lengthDiff < cleanPattern.length * 0.3 ? 0.3 : 0.5; + + if (bestMatch.rating >= similarityThreshold) { + bestMatch.elements.forEach((element) => { + element.style.backgroundColor = 'grey'; + element.style.opacity = '0.4'; + }); + + if (bestMatch.elements.length > 0) { + const element = bestMatch.elements[0]; + const elementRect = element.getBoundingClientRect(); + const elementTop = elementRect.top - containerRect.top + container.scrollTop; + + // Only scroll if the element is outside the visible area + if (elementTop < visibleTop || elementTop > visibleBottom) { + container.scrollTo({ + top: elementTop - containerRect.height / 3, // Position the highlight in the top third + behavior: 'smooth', + }); + } + } + } + }, [clearHighlights, findBestTextMatch]); + + return { + highlightPattern, + clearHighlights, + findBestTextMatch, + }; +} diff --git a/src/hooks/usePDFTextClick.ts b/src/hooks/usePDFTextClick.ts new file mode 100644 index 0000000..9f1a9e2 --- /dev/null +++ b/src/hooks/usePDFTextClick.ts @@ -0,0 +1,81 @@ +import { useCallback } from 'react'; +import nlp from 'compromise'; +import stringSimilarity from 'string-similarity'; +import { usePDFHighlighting } from './usePDFHighlighting'; + +export function usePDFTextClick() { + const { highlightPattern, findBestTextMatch } = usePDFHighlighting(); + + /** + * Handles text click events in the PDF viewer + * Integrates with TTS for synchronized playback + * + * @param {MouseEvent} event - The click event + * @param {string} pdfText - The text content of the page + * @param {RefObject} containerRef - Reference to the container element + * @param {Function} stopAndPlayFromIndex - Function to control TTS playback + * @param {boolean} isProcessing - Whether TTS is currently processing + */ + const handleTextClick = useCallback(( + event: MouseEvent, + pdfText: string, + containerRef: React.RefObject, + stopAndPlayFromIndex: (index: number) => void, + isProcessing: boolean + ) => { + if (isProcessing) return; + + const target = event.target as HTMLElement; + if (!target.matches('.react-pdf__Page__textContent span')) return; + + const parentElement = target.closest('.react-pdf__Page__textContent'); + if (!parentElement) return; + + const spans = Array.from(parentElement.querySelectorAll('span')); + const clickedIndex = spans.indexOf(target); + const contextWindow = 3; + const startIndex = Math.max(0, clickedIndex - contextWindow); + const endIndex = Math.min(spans.length - 1, clickedIndex + contextWindow); + const contextText = spans + .slice(startIndex, endIndex + 1) + .map((span) => span.textContent) + .join(' ') + .trim(); + + if (!contextText?.trim()) return; + + const cleanContext = contextText.trim().replace(/\s+/g, ' '); + const allText = Array.from(parentElement.querySelectorAll('span')).map((node) => ({ + element: node as HTMLElement, + text: (node.textContent || '').trim(), + })).filter((node) => node.text.length > 0); + + const bestMatch = findBestTextMatch(allText, cleanContext, cleanContext.length * 2); + const similarityThreshold = bestMatch.lengthDiff < cleanContext.length * 0.3 ? 0.3 : 0.5; + + if (bestMatch.rating >= similarityThreshold) { + const matchText = bestMatch.text; + const sentences = nlp(pdfText).sentences().out('array') as string[]; + let bestSentenceMatch = { sentence: '', rating: 0 }; + + for (const sentence of sentences) { + const rating = stringSimilarity.compareTwoStrings(matchText, sentence); + if (rating > bestSentenceMatch.rating) { + bestSentenceMatch = { sentence, rating }; + } + } + + if (bestSentenceMatch.rating >= 0.5) { + const sentenceIndex = sentences.findIndex((sentence) => sentence === bestSentenceMatch.sentence); + if (sentenceIndex !== -1) { + stopAndPlayFromIndex(sentenceIndex); + highlightPattern(pdfText, bestSentenceMatch.sentence, containerRef); + } + } + } + }, [highlightPattern, findBestTextMatch]); + + return { + handleTextClick, + }; +} diff --git a/src/hooks/usePDFTextProcessing.ts b/src/hooks/usePDFTextProcessing.ts new file mode 100644 index 0000000..0a855d7 --- /dev/null +++ b/src/hooks/usePDFTextProcessing.ts @@ -0,0 +1,97 @@ +import { useCallback } from 'react'; +import { pdfjs } from 'react-pdf'; +import type { TextItem } from 'pdfjs-dist/types/src/display/api'; + +// Set worker from public directory +pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs'; + +export function usePDFTextProcessing() { + /** + * Extracts text content from a specific page of the PDF + * + * @param {string} pdfURL - The URL of the PDF + * @param {number} pageNumber - The page number to extract + * @returns {Promise} The extracted text + */ + const extractTextFromPDF = useCallback(async (pdfURL: string, pageNumber: number): Promise => { + try { + const base64Data = pdfURL.split(',')[1]; + const binaryData = atob(base64Data); + const bytes = new Uint8Array(binaryData.length); + for (let i = 0; i < binaryData.length; i++) { + bytes[i] = binaryData.charCodeAt(i); + } + + const loadingTask = pdfjs.getDocument({ data: bytes }); + const pdf = await loadingTask.promise; + + // Get only the specified page + const page = await pdf.getPage(pageNumber); + const textContent = await page.getTextContent(); + + // Filter out non-text items and assert proper type + const textItems = textContent.items.filter((item): item is TextItem => + 'str' in item && 'transform' in item + ); + + // Group text items into lines based on their vertical position + const tolerance = 2; + const lines: TextItem[][] = []; + let currentLine: TextItem[] = []; + let currentY: number | null = null; + + textItems.forEach((item) => { + const y = item.transform[5]; + if (currentY === null) { + currentY = y; + currentLine.push(item); + } else if (Math.abs(y - currentY) < tolerance) { + currentLine.push(item); + } else { + lines.push(currentLine); + currentLine = [item]; + currentY = y; + } + }); + lines.push(currentLine); + + // Process each line to build text + let pageText = ''; + for (const line of lines) { + // Sort items horizontally within the line + line.sort((a, b) => a.transform[4] - b.transform[4]); + + let lineText = ''; + let prevItem: TextItem | null = null; + + for (const item of line) { + if (!prevItem) { + lineText = item.str; + } else { + const prevEndX = prevItem.transform[4] + (prevItem.width ?? 0); + const currentStartX = item.transform[4]; + const space = currentStartX - prevEndX; + + // Add space if gap is significant, otherwise concatenate directly + if (space > ((item.width ?? 0) * 0.3)) { + lineText += ' ' + item.str; + } else { + lineText += item.str; + } + } + prevItem = item; + } + pageText += lineText + ' '; + } + + return pageText.replace(/\s+/g, ' ').trim(); + } catch (error) { + console.error('Error extracting text from PDF:', error); + throw new Error('Failed to extract text from PDF'); + } + }, []); + + return { + extractTextFromPDF, + }; +} diff --git a/src/hooks/usePDFURLConversion.ts b/src/hooks/usePDFURLConversion.ts new file mode 100644 index 0000000..544038b --- /dev/null +++ b/src/hooks/usePDFURLConversion.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; + +export function usePDFURLConversion() { + /** + * Converts PDF binary data to a data URL for display + * + * @param {Blob} pdfData - The PDF binary data + * @returns {Promise} A data URL representing the PDF + */ + const convertPDFDataToURL = useCallback((pdfData: Blob): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(pdfData); + }); + }, []); + + return { + convertPDFDataToURL, + }; +}