openreader/src/contexts/PDFContext.tsx
Richard R d6ae2baa6f refactor(tts,config,audiobook): centralize TTS provider catalog and extract audiobook pipeline
- Introduce a shared tts-provider-catalog to centralize provider/model/voice defaults
  and feature flags (supportsTtsInstructions, providerSupportsCustomModel, resolveProviderModels,
  getDefaultVoices, etc.). Replace ad-hoc provider logic across server and client with catalog calls.
- Replace inline voice/model UI logic in SettingsModal, AudiobookExportModal, TTSContext,
  generate server code, and voice hook to use catalog helpers (resolveTtsSettingsViewModel,
  supportsTtsInstructions, getDefaultVoices).
- Extract audiobook generation responsibilities into a small client-side pipeline:
  - Add createEpubAudiobookSourceAdapter and createPdfAudiobookSourceAdapter adapter modules
    to prepare chapters from EPUB/PDF sources.
  - Add runAudiobookGeneration and regenerateAudiobookChapter pipeline functions to handle
    chapter preparation, progress tracking, request header construction, and retry/abort behavior.
  - Wire adapters/pipeline into EPUBContext and PDFContext to simplify and centralize audiobook flow.
- Move config preference helpers out of ConfigContext into dedicated client modules:
  - buildSyncedPreferencePatch (lib/client/config/preferences.ts)
  - applyConfigUpdate and getVoicePreferenceKey (lib/client/config/updates.ts)
  - Use these helpers in ConfigContext to keep update logic small and testable.
- Add TTS settings view model resolver (lib/client/settings/tts-settings.ts) and tests
  for the tts-provider catalog and config helpers (tests/unit/tts-provider-catalog.spec.ts).
- Minor usages updated: AudiobookExportModal, SettingsModal, useVoiceManagement, TTSContext,
  server tts generation, and various context files to consume the new modules.

Why: Reduce duplication, improve testability, and separate concerns for provider metadata,
UI view-model logic, and audiobook generation flow. Breaking changes: none.
2026-04-04 17:53:57 -06:00

542 lines
17 KiB
TypeScript

/**
* 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<void>;
clearCurrDoc: () => void;
// PDF functionality
onDocumentLoadSuccess: (pdf: PDFDocumentProxy) => void;
highlightPattern: (text: string, pattern: string, containerRef: RefObject<HTMLDivElement>) => void;
clearHighlights: () => void;
clearWordHighlights: () => void;
highlightWordIndex: (
alignment: TTSSentenceAlignment | undefined,
wordIndex: number | null | undefined,
sentence: string | null | undefined,
containerRef: RefObject<HTMLDivElement>
) => void;
createFullAudioBook: (
onProgress: (progress: number) => void,
signal?: AbortSignal,
onChapterComplete?: (chapter: TTSAudiobookChapter) => void,
bookId?: string,
format?: TTSAudiobookFormat,
settings?: AudiobookGenerationSettings
) => Promise<string>;
regenerateChapter: (
chapterIndex: number,
bookId: string,
format: TTSAudiobookFormat,
signal: AbortSignal,
settings?: AudiobookGenerationSettings
) => Promise<TTSAudiobookChapter>;
isAudioCombining: boolean;
}
// Create the context
const PDFContext = createContext<PDFContextType | undefined>(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<string>();
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
const [currDocName, setCurrDocName] = useState<string>();
const [currDocText, setCurrDocText] = useState<string>();
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy>();
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<Map<number, string>>(new Map());
const [currDocPage, setCurrDocPage] = useState<number>(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<PDFDocumentProxy | undefined>(undefined);
const loadSeqRef = useRef(0);
const emptyRetryRef = useRef<{ page: number; attempt: number; timer: ReturnType<typeof setTimeout> | null } | null>(null);
// Guards for setCurrentDocument to prevent stale loads from overwriting newer selections.
const docLoadSeqRef = useRef(0);
const docLoadAbortRef = useRef<AbortController | null>(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<void>}
*/
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<string> => {
// 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<string | undefined>(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<void>}
*/
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
// --- 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<string>} 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<string> => {
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<TTSAudiobookChapter> => {
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 (
<PDFContext.Provider value={contextValue}>
{children}
</PDFContext.Provider>
);
}
/**
* 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;
}