/** * PDF Context Provider * * This module provides a React context for managing PDF document functionality. * It handles document loading, text extraction, highlighting, and integration with TTS. * * Key features: * - PDF document management (add/remove/load) * - Text extraction and processing * - Text highlighting and navigation * - Document state management */ 'use client'; import { createContext, useContext, useState, ReactNode, useEffect, useCallback, useMemo, RefObject, useRef, } from 'react'; import type { PDFDocumentProxy } from 'pdfjs-dist'; import { getDocumentMetadata } from '@/lib/client/api/documents'; import { createPdfAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/pdf'; import { regenerateAudiobookChapter, runAudiobookGeneration } from '@/lib/client/audiobooks/pipeline'; import { ensureCachedDocument } from '@/lib/client/cache/documents'; import { useTTS } from '@/contexts/TTSContext'; import { useConfig } from '@/contexts/ConfigContext'; import { extractTextFromPDF, highlightPattern, clearHighlights, clearWordHighlights, highlightWordIndex, } from '@/lib/client/pdf'; import type { TTSSentenceAlignment, TTSAudiobookFormat, TTSAudiobookChapter, } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; /** * Interface defining all available methods and properties in the PDF context */ interface PDFContextType { // Current document state currDocId: string | undefined; currDocData: ArrayBuffer | undefined; currDocName: string | undefined; currDocPages: number | undefined; currDocPage: number; currDocText: string | undefined; pdfDocument: PDFDocumentProxy | undefined; setCurrentDocument: (id: string) => Promise; clearCurrDoc: () => void; // PDF functionality onDocumentLoadSuccess: (pdf: PDFDocumentProxy) => void; highlightPattern: (text: string, pattern: string, containerRef: RefObject) => void; clearHighlights: () => void; clearWordHighlights: () => void; highlightWordIndex: ( alignment: TTSSentenceAlignment | undefined, wordIndex: number | null | undefined, sentence: string | null | undefined, containerRef: RefObject ) => void; createFullAudioBook: ( onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat, settings?: AudiobookGenerationSettings ) => Promise; regenerateChapter: ( chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal, settings?: AudiobookGenerationSettings ) => Promise; isAudioCombining: boolean; } // Create the context const PDFContext = createContext(undefined); const EMPTY_TEXT_RETRY_DELAY_MS = 120; const EMPTY_TEXT_MAX_RETRIES = 6; /** * PDFProvider Component * * Main provider component that manages PDF state and functionality. * Handles document loading, text processing, and integration with TTS. * * @param {Object} props - Component props * @param {ReactNode} props.children - Child components to be wrapped by the provider */ export function PDFProvider({ children }: { children: ReactNode }) { const { setText: setTTSText, stop, currDocPageNumber, currDocPages, setCurrDocPages, setIsEPUB, registerVisualPageChangeHandler, } = useTTS(); const { headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, ttsProvider, smartSentenceSplitting, } = useConfig(); // Current document state const [currDocId, setCurrDocId] = useState(); const [currDocData, setCurrDocData] = useState(); const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); const [pdfDocument, setPdfDocument] = useState(); const [isAudioCombining] = useState(false); const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({ pdfDocument, margins: { header: headerMargin, footer: footerMargin, left: leftMargin, right: rightMargin, }, smartSentenceSplitting, }), [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting]); const pageTextCacheRef = useRef>(new Map()); const [currDocPage, setCurrDocPage] = useState(currDocPageNumber); // Used to cancel/ignore in-flight text extraction when the document changes // or when react-pdf tears down and recreates its internal worker. const pdfDocGenerationRef = useRef(0); const pdfDocumentRef = useRef(undefined); const loadSeqRef = useRef(0); const emptyRetryRef = useRef<{ page: number; attempt: number; timer: ReturnType | null } | null>(null); // Guards for setCurrentDocument to prevent stale loads from overwriting newer selections. const docLoadSeqRef = useRef(0); const docLoadAbortRef = useRef(null); useEffect(() => { pdfDocumentRef.current = pdfDocument; }, [pdfDocument]); useEffect(() => { setCurrDocPage(currDocPageNumber); }, [currDocPageNumber]); /** * Handles successful PDF document load * * @param {PDFDocumentProxy} pdf - The loaded PDF document proxy object */ const onDocumentLoadSuccess = useCallback((pdf: PDFDocumentProxy) => { console.log('Document loaded:', pdf.numPages); pdfDocGenerationRef.current += 1; pdfDocumentRef.current = pdf; setCurrDocPages(pdf.numPages); setPdfDocument(pdf); }, [setCurrDocPages, setPdfDocument]); /** * Loads and processes text from the current document page * Extracts text from the PDF and updates both document text and TTS text states * * @returns {Promise} */ const loadCurrDocText = useCallback(async () => { try { const generation = pdfDocGenerationRef.current; const currentPdf = pdfDocumentRef.current; if (!currentPdf) return; const seq = ++loadSeqRef.current; const pageNumber = currDocPageNumber; const existingRetry = emptyRetryRef.current; if (existingRetry?.timer) { clearTimeout(existingRetry.timer); } emptyRetryRef.current = existingRetry && existingRetry.page === pageNumber ? { ...existingRetry, timer: null } : null; const margins = { header: headerMargin, footer: footerMargin, left: leftMargin, right: rightMargin }; const getPageText = async (pageNumber: number, shouldCache = false): Promise => { // Ignore stale/in-flight work if the document or worker changed. if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { throw new DOMException('Stale PDF extraction', 'AbortError'); } if (pageTextCacheRef.current.has(pageNumber)) { const cached = pageTextCacheRef.current.get(pageNumber)!; if (!shouldCache) { pageTextCacheRef.current.delete(pageNumber); } return cached; } const extracted = await extractTextFromPDF(currentPdf, pageNumber, margins); if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { throw new DOMException('Stale PDF extraction', 'AbortError'); } if (shouldCache) { pageTextCacheRef.current.set(pageNumber, extracted); } return extracted; }; const totalPages = currDocPages ?? currentPdf.numPages; const nextPageNumber = currDocPageNumber < totalPages ? currDocPageNumber + 1 : undefined; const [text, nextText] = await Promise.all([ getPageText(currDocPageNumber), nextPageNumber ? getPageText(nextPageNumber, true) : Promise.resolve(undefined), ]); if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { return; } if (seq !== loadSeqRef.current || pageNumber !== currDocPageNumber) { return; } const trimmed = text.trim(); if (!trimmed) { const prevAttempt = emptyRetryRef.current?.page === pageNumber ? emptyRetryRef.current.attempt : 0; const attempt = prevAttempt + 1; // Avoid pushing empty text into TTS immediately; transient empty extractions can happen // during page turns or react-pdf worker churn. Retry a few times before treating it as // a truly blank page. if (attempt <= EMPTY_TEXT_MAX_RETRIES) { const timer = setTimeout(() => { if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { return; } if (pageNumber !== currDocPageNumber) { return; } void loadCurrDocText(); }, EMPTY_TEXT_RETRY_DELAY_MS); emptyRetryRef.current = { page: pageNumber, attempt, timer }; return; } } else { emptyRetryRef.current = null; } if (text !== currDocText || text === '') { setCurrDocText(text); setTTSText(text, { location: currDocPageNumber, nextLocation: nextPageNumber, nextText: nextText, }); } } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { return; } console.error('Error loading PDF text:', error); } }, [ currDocPageNumber, currDocPages, setTTSText, currDocText, headerMargin, footerMargin, leftMargin, rightMargin, ]); /** * Effect hook to update document text when the page changes * Triggers text extraction and processing when either the document URL or page changes */ useEffect(() => { if (currDocData && pdfDocument) { loadCurrDocText(); } }, [currDocPageNumber, currDocData, pdfDocument, loadCurrDocText]); /** * Sets the current document based on its ID * Retrieves document from IndexedDB * * @param {string} id - The unique identifier of the document to set * @returns {Promise} */ const setCurrentDocument = useCallback(async (id: string): Promise => { // --- race-condition guard --- const seq = ++docLoadSeqRef.current; docLoadAbortRef.current?.abort(); const controller = new AbortController(); docLoadAbortRef.current = controller; try { // Reset any state tied to the previously loaded PDF. This prevents calling // `getPage()` on a stale/destroyed PDFDocumentProxy after login redirects // or fast refresh. pdfDocGenerationRef.current += 1; loadSeqRef.current += 1; if (emptyRetryRef.current?.timer) { clearTimeout(emptyRetryRef.current.timer); } emptyRetryRef.current = null; pageTextCacheRef.current.clear(); setPdfDocument(undefined); setCurrDocPages(undefined); setCurrDocText(undefined); setCurrDocId(id); setCurrDocName(undefined); setCurrDocData(undefined); const meta = await getDocumentMetadata(id, { signal: controller.signal }); if (seq !== docLoadSeqRef.current) return; // stale if (!meta) { console.error('Document not found on server'); return; } const doc = await ensureCachedDocument(meta, { signal: controller.signal }); if (seq !== docLoadSeqRef.current) return; // stale if (doc.type !== 'pdf') { console.error('Document is not a PDF'); return; } setCurrDocName(doc.name); // IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the // buffer passed into the worker; we always pass clones to react-pdf. setCurrDocData(doc.data.slice(0)); } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') return; console.error('Failed to get document:', error); } finally { // Clean up the controller only if it's still ours (a newer call hasn't replaced it). if (docLoadAbortRef.current === controller) { docLoadAbortRef.current = null; } } }, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument]); /** * Clears the current document state * Resets all document-related states and stops any ongoing TTS playback */ const clearCurrDoc = useCallback(() => { pdfDocGenerationRef.current += 1; pdfDocumentRef.current = undefined; loadSeqRef.current += 1; // Invalidate any in-flight setCurrentDocument load. docLoadSeqRef.current += 1; docLoadAbortRef.current?.abort(); docLoadAbortRef.current = null; if (emptyRetryRef.current?.timer) { clearTimeout(emptyRetryRef.current.timer); } emptyRetryRef.current = null; setCurrDocId(undefined); setCurrDocName(undefined); setCurrDocData(undefined); setCurrDocText(undefined); setCurrDocPages(undefined); setPdfDocument(undefined); pageTextCacheRef.current.clear(); stop(); }, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument, stop]); /** * Creates a complete audiobook by processing all PDF pages through NLP and TTS * @param {Function} onProgress - Callback for progress updates * @param {AbortSignal} signal - Optional signal for cancellation * @param {Function} onChapterComplete - Optional callback for when a chapter completes * @returns {Promise} The bookId for the generated audiobook */ const createFullAudioBook = useCallback(async ( onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, providedBookId?: string, format: TTSAudiobookFormat = 'mp3', settings?: AudiobookGenerationSettings ): Promise => { try { return await runAudiobookGeneration({ adapter: audiobookAdapter, apiKey, baseUrl, defaultProvider: ttsProvider, onProgress, signal, onChapterComplete, providedBookId, format, settings, }); } catch (error) { console.error('Error creating audiobook:', error); throw error; } }, [audiobookAdapter, apiKey, baseUrl, ttsProvider]); /** * Regenerates a specific chapter (page) of the PDF audiobook */ const regenerateChapter = useCallback(async ( chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal, settings?: AudiobookGenerationSettings ): Promise => { try { return await regenerateAudiobookChapter({ adapter: audiobookAdapter, chapterIndex, bookId, format, signal, apiKey, baseUrl, defaultProvider: ttsProvider, settings, }); } catch (error) { if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) { throw new Error('Page regeneration cancelled'); } console.error('Error regenerating page:', error); throw error; } }, [audiobookAdapter, apiKey, baseUrl, ttsProvider]); /** * Effect hook to initialize TTS as non-EPUB mode */ useEffect(() => { setIsEPUB(false); }, [setIsEPUB]); useEffect(() => { registerVisualPageChangeHandler(location => { if (typeof location !== 'number') return; if (!pdfDocument) return; const totalPages = currDocPages ?? pdfDocument.numPages; const clamped = Math.min(Math.max(location, 1), totalPages); setCurrDocPage(clamped); }); }, [registerVisualPageChangeHandler, currDocPages, pdfDocument]); // Context value memoization const contextValue = useMemo( () => ({ onDocumentLoadSuccess, setCurrentDocument, currDocId, currDocData, currDocName, currDocPages, currDocPage, currDocText, clearCurrDoc, highlightPattern, clearHighlights, clearWordHighlights, highlightWordIndex, pdfDocument, createFullAudioBook, regenerateChapter, isAudioCombining, }), [ onDocumentLoadSuccess, setCurrentDocument, currDocId, currDocData, currDocName, currDocPages, currDocPage, currDocText, clearCurrDoc, pdfDocument, createFullAudioBook, regenerateChapter, isAudioCombining, ] ); return ( {children} ); } /** * Custom hook to consume the PDF context * Ensures the context is used within a provider * * @throws {Error} If used outside of PDFProvider * @returns {PDFContextType} The PDF context value containing all PDF-related functionality */ export function usePDF() { const context = useContext(PDFContext); if (context === undefined) { throw new Error('usePDF must be used within a PDFProvider'); } return context; }