diff --git a/Dockerfile b/Dockerfile index e04a168..f9b9a78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,9 @@ # Use Node.js slim image FROM node:slim +# Add ffmpeg +RUN apt-get update && apt-get install -y ffmpeg + # Create app directory WORKDIR /app diff --git a/README.md b/README.md index 64e2885..74a8fdb 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ services: 3. Configure the environment: ```bash - cp .env.template .env + cp template.env .env # Edit .env with your configuration settings ``` > Note: The base URL for the TTS API should be accessible and relative to the Next.js server diff --git a/src/app/api/audio/convert/route.ts b/src/app/api/audio/convert/route.ts new file mode 100644 index 0000000..b1bb2ff --- /dev/null +++ b/src/app/api/audio/convert/route.ts @@ -0,0 +1,239 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { spawn } from 'child_process'; +import { writeFile, mkdir, unlink, rmdir } from 'fs/promises'; +import { createReadStream } from 'fs'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; + +interface Chapter { + title: string; + buffer: number[]; +} + +interface ConversionRequest { + chapters: Chapter[]; +} + +async function getAudioDuration(filePath: string): Promise { + return new Promise((resolve, reject) => { + const ffprobe = spawn('ffprobe', [ + '-i', filePath, + '-show_entries', 'format=duration', + '-v', 'quiet', + '-of', 'csv=p=0' + ]); + + let output = ''; + ffprobe.stdout.on('data', (data) => { + output += data.toString(); + }); + + ffprobe.on('close', (code) => { + if (code === 0) { + const duration = parseFloat(output.trim()); + resolve(duration); + } else { + reject(new Error(`ffprobe process exited with code ${code}`)); + } + }); + + ffprobe.on('error', (err) => { + reject(err); + }); + }); +} + +async function runFFmpeg(args: string[]): Promise { + return new Promise((resolve, reject) => { + const ffmpeg = spawn('ffmpeg', args); + + ffmpeg.stderr.on('data', (data) => { + console.error(`ffmpeg stderr: ${data}`); + }); + + ffmpeg.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`FFmpeg process exited with code ${code}`)); + } + }); + + ffmpeg.on('error', (err) => { + reject(err); + }); + }); +} + +async function cleanup(files: string[], directories: string[]) { + await Promise.all([ + ...files.map(f => unlink(f).catch(console.error)), + ...directories.map(d => rmdir(d).catch(console.error)) + ]); +} + +export async function POST(request: NextRequest) { + const tempFiles: string[] = []; + const tempDirs: string[] = []; + + try { + // Parse the request body as a stream + const data: ConversionRequest = await request.json(); + + // Create temp directory if it doesn't exist + const tempDir = join(process.cwd(), 'temp'); + if (!existsSync(tempDir)) { + await mkdir(tempDir); + } + + // Generate unique filenames + const id = randomUUID(); + const outputPath = join(tempDir, `${id}.m4b`); + const metadataPath = join(tempDir, `${id}.txt`); + const intermediateDir = join(tempDir, `${id}-intermediate`); + + tempFiles.push(outputPath, metadataPath); + tempDirs.push(intermediateDir); + + // Create intermediate directory + if (!existsSync(intermediateDir)) { + await mkdir(intermediateDir); + } + + // Process chapters sequentially to avoid memory issues + const chapterFiles: { path: string; title: string; duration: number }[] = []; + let currentTime = 0; + + for (let i = 0; i < data.chapters.length; i++) { + const chapter = data.chapters[i]; + const inputPath = join(intermediateDir, `${i}-input.mp3`); + const outputPath = join(intermediateDir, `${i}.wav`); + + tempFiles.push(inputPath, outputPath); + + // Write the chapter audio to a temp file using a Buffer chunk size of 64KB + const chunkSize = 64 * 1024; // 64KB chunks + const buffer = Buffer.from(new Uint8Array(chapter.buffer)); + const chunks: Buffer[] = []; + + for (let offset = 0; offset < buffer.length; offset += chunkSize) { + chunks.push(buffer.slice(offset, offset + chunkSize)); + } + + await writeFile(inputPath, Buffer.concat(chunks)); + chunks.length = 0; // Clear chunks array + + // Convert to WAV with consistent format + await runFFmpeg([ + '-i', inputPath, + '-acodec', 'pcm_s16le', + '-ar', '44100', + '-ac', '2', + outputPath + ]); + + const duration = await getAudioDuration(outputPath); + + chapterFiles.push({ + path: outputPath, + title: chapter.title, + duration + }); + + // Clean up input file early + await unlink(inputPath).catch(console.error); + const index = tempFiles.indexOf(inputPath); + if (index > -1) { + tempFiles.splice(index, 1); + } + } + + // Create chapter metadata file + const metadata: string[] = []; + + chapterFiles.forEach((chapter) => { + const startMs = Math.floor(currentTime * 1000); + currentTime += chapter.duration; + const endMs = Math.floor(currentTime * 1000); + + metadata.push( + `[CHAPTER]`, + `TIMEBASE=1/1000`, + `START=${startMs}`, + `END=${endMs}`, + `title=${chapter.title}` + ); + }); + + await writeFile(metadataPath, ';FFMETADATA1\n' + metadata.join('\n')); + + // Create list file for concat + const listPath = join(tempDir, `${id}-list.txt`); + tempFiles.push(listPath); + + await writeFile( + listPath, + chapterFiles.map(f => `file '${f.path}'`).join('\n') + ); + + // Combine all files into a single M4B + await runFFmpeg([ + '-f', 'concat', + '-safe', '0', + '-i', listPath, + '-i', metadataPath, + '-map_metadata', '1', + '-c:a', 'aac', + '-b:a', '192k', + '-movflags', '+faststart', + outputPath + ]); + + // Create a readable stream from the output file + const fileStream = createReadStream(outputPath); + + // Create a web-compatible ReadableStream from the Node.js stream + const webStream = new ReadableStream({ + start(controller) { + fileStream.on('data', (chunk) => { + controller.enqueue(chunk); + }); + + fileStream.on('end', () => { + controller.close(); + // Clean up only after the stream has been fully sent + cleanup(tempFiles, tempDirs).catch(console.error); + }); + + fileStream.on('error', (error) => { + console.error('Stream error:', error); + controller.error(error); + cleanup(tempFiles, tempDirs).catch(console.error); + }); + }, + cancel() { + fileStream.destroy(); + cleanup(tempFiles, tempDirs).catch(console.error); + } + }); + + // Return the streaming response + return new NextResponse(webStream, { + headers: { + 'Content-Type': 'audio/mp4', + 'Transfer-Encoding': 'chunked' + }, + }); + + } catch (error) { + // Clean up in case of error + await cleanup(tempFiles, tempDirs).catch(console.error); + + console.error('Error converting audio:', error); + return NextResponse.json( + { error: 'Failed to convert audio format' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 9666b98..f7f3350 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -20,7 +20,7 @@ export async function POST(req: NextRequest) { // Initialize OpenAI client with abort signal const openai = new OpenAI({ apiKey: openApiKey, - baseURL: openApiBaseUrl || 'https://api.openai.com/v1', + baseURL: openApiBaseUrl, }); // Request audio from OpenAI and pass along the abort signal @@ -29,19 +29,23 @@ export async function POST(req: NextRequest) { voice: voice as "alloy", input: text, speed: speed, - }, { signal: req.signal }); // Pass the abort signal to OpenAI client + response_format: 'mp3', // Always use mp3 since we convert to WAV later if needed + }, { signal: req.signal }); // Get the audio data as array buffer - // This will also be aborted if the client cancels - const arrayBuffer = await response.arrayBuffer(); + const stream = response.body; // Return audio data with appropriate headers - return new NextResponse(arrayBuffer); + return new NextResponse(stream, { + headers: { + 'Content-Type': 'audio/mpeg' + } + }); } catch (error) { // Check if this was an abort error if (error instanceof Error && error.name === 'AbortError') { console.log('TTS request aborted by client'); - return new Response(null, { status: 499 }); // Use 499 status for client closed request + return new NextResponse(null, { status: 499 }); // Use 499 status for client closed request } console.error('Error generating TTS:', error); diff --git a/src/components/DocumentSettings.tsx b/src/components/DocumentSettings.tsx index 3fad0b4..1d72c87 100644 --- a/src/components/DocumentSettings.tsx +++ b/src/components/DocumentSettings.tsx @@ -1,9 +1,15 @@ 'use client'; -import { Fragment } from 'react'; -import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react'; +import { Fragment, useState, useRef, useCallback, useEffect } from 'react'; +import { Dialog, DialogPanel, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react'; import { useConfig, ViewType } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons'; +import { useEPUB } from '@/contexts/EPUBContext'; +import { usePDF } from '@/contexts/PDFContext'; +import { useTimeEstimation } from '@/hooks/useTimeEstimation'; +import { LoadingSpinner } from './Spinner'; + +const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; interface DocViewSettingsProps { isOpen: boolean; @@ -17,10 +23,109 @@ const viewTypes = [ { id: 'scroll', name: 'Continuous Scroll' }, ]; +const audioFormats = [ + { id: 'mp3', name: 'MP3' }, + { id: 'm4b', name: 'M4B' }, +]; + export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsProps) { - const { viewType, skipBlank, epubTheme, updateConfigKey } = useConfig(); + const { + viewType, + skipBlank, + epubTheme, + headerMargin, + footerMargin, + leftMargin, + rightMargin, + updateConfigKey + } = useConfig(); + const { createFullAudioBook, isAudioCombining } = useEPUB(); + const { createFullAudioBook: createPDFAudioBook, isAudioCombining: isPDFAudioCombining } = usePDF(); + const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); + const [isGenerating, setIsGenerating] = useState(false); + const [audioFormat, setAudioFormat] = useState<'mp3' | 'm4b'>('mp3'); + const [localMargins, setLocalMargins] = useState({ + header: headerMargin, + footer: footerMargin, + left: leftMargin, + right: rightMargin + }); + const abortControllerRef = useRef(null); const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0]; + // Sync local margins with global state + useEffect(() => { + setLocalMargins({ + header: headerMargin, + footer: footerMargin, + left: leftMargin, + right: rightMargin + }); + }, [headerMargin, footerMargin, leftMargin, rightMargin]); + + // Handler for slider change (updates local state only) + const handleMarginChange = (margin: keyof typeof localMargins) => (event: React.ChangeEvent) => { + setLocalMargins(prev => ({ + ...prev, + [margin]: Number(event.target.value) + })); + }; + + // Handler for slider release + const handleMarginChangeComplete = (margin: keyof typeof localMargins) => () => { + const value = localMargins[margin]; + const configKey = `${margin}Margin`; + if (value !== (useConfig)[configKey as keyof typeof useConfig]) { + updateConfigKey(configKey as 'headerMargin' | 'footerMargin' | 'leftMargin' | 'rightMargin', value); + } + }; + + const handleStartGeneration = useCallback(async () => { + setIsGenerating(true); + setProgress(0); + abortControllerRef.current = new AbortController(); + + try { + const audioBuffer = epub ? await createFullAudioBook( + (progress) => setProgress(progress), + abortControllerRef.current.signal, + audioFormat + ) : await createPDFAudioBook( + (progress) => setProgress(progress), + abortControllerRef.current.signal, + audioFormat + ); + + // Create and trigger download + const mimeType = audioFormat === 'mp3' ? 'audio/mp3' : 'audio/mp4'; + const blob = new Blob([audioBuffer], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `audiobook.${audioFormat}`; + document.body.appendChild(a); + a.click(); + + // Clean up + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 100); + } catch (error) { + console.error('Error generating audiobook:', error); + } finally { + setIsGenerating(false); + setProgress(0); + abortControllerRef.current = null; + } + }, [createFullAudioBook, createPDFAudioBook, epub, audioFormat, setProgress]); + + const handleCancel = () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + return ( setIsOpen(false)}> @@ -48,99 +153,249 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro leaveTo="opacity-0 scale-95" > - - View Settings - -
-
- {!epub &&
- - updateConfigKey('viewType', newView.id as ViewType)} + {isDev &&
+ {!isGenerating ? ( +
+ + setAudioFormat(format as 'mp3' | 'm4b')}> +
+ + {audioFormat === 'mp3' ? 'MP3' : 'M4B (Audiobook)'} + - - - {viewTypes.map((view) => ( - - `relative cursor-pointer select-none py-2 pl-10 pr-4 ${ - active ? 'bg-accent/10 text-accent' : 'text-foreground' - }` - } - value={view} - > - {({ selected }) => ( - <> - - {view.name} - - {selected ? ( - - - - ) : null} - - )} - - ))} - - + + {audioFormats.map((format) => ( + + `relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}` + } + > + {format.name} + + ))} +
- {selectedView.id === 'scroll' && ( -

- Note: Continuous scroll may perform poorly for larger documents. -

- )} -
} +
+ ) : ( +
+
+
+
+
+ + {Math.round(progress)}% complete + {estimatedTimeRemaining && ` • ${estimatedTimeRemaining} remaining`} + + +
+
+ )} +
} +
+ {!epub &&
+ +
+ {/* Header Margin */} +
+
+ Header + {Math.round(localMargins.header * 100)}% +
+ +
+ + {/* Footer Margin */} +
+
+ Footer + {Math.round(localMargins.footer * 100)}% +
+ +
+ + {/* Left Margin */} +
+
+ Left + {Math.round(localMargins.left * 100)}% +
+ +
+ + {/* Right Margin */} +
+
+ Right + {Math.round(localMargins.right * 100)}% +
+ +
+
+

+ Adjust margins to exclude content from edges of the page during text extraction (experimental) +

+
+ updateConfigKey('viewType', newView.id as ViewType)} + > +
+ + + {selectedView.name} + + + + + + + {viewTypes.map((view) => ( + + `relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-accent/10 text-accent' : 'text-foreground' + }` + } + value={view} + > + {({ selected }) => ( + <> + + {view.name} + + {selected ? ( + + + + ) : null} + + )} + + ))} + + + {selectedView.id === 'scroll' && ( +

+ Note: Continuous scroll may perform poorly for larger documents. +

+ )} +
+
+ +
} + +
+ +

+ Automatically skip pages with no text content +

+
+ {epub && ( +

- Automatically skip pages with no text content + Apply the current app theme to the EPUB viewer

- {epub && ( -
- -

- Apply the current app theme to the EPUB viewer -

-
- )} -
+ )}
diff --git a/src/components/EPUBViewer.tsx b/src/components/EPUBViewer.tsx index ec4a3f9..d8f6f55 100644 --- a/src/components/EPUBViewer.tsx +++ b/src/components/EPUBViewer.tsx @@ -1,15 +1,12 @@ 'use client'; import { useEffect, useRef, useCallback } from 'react'; -import { useParams } from 'next/navigation'; import dynamic from 'next/dynamic'; import { useEPUB } from '@/contexts/EPUBContext'; import { useTTS } from '@/contexts/TTSContext'; import { useConfig } from '@/contexts/ConfigContext'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import TTSPlayer from '@/components/player/TTSPlayer'; -import { setLastDocumentLocation } from '@/utils/indexedDB'; -import type { Rendition, Book, NavItem } from 'epubjs'; import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme'; import { useEPUBResize } from '@/hooks/epub/useEPUBResize'; @@ -23,88 +20,40 @@ interface EPUBViewerProps { } export function EPUBViewer({ className = '' }: EPUBViewerProps) { - const { id } = useParams(); - const { currDocData, currDocName, currDocPage, extractPageText } = useEPUB(); - const { skipToLocation, registerLocationChangeHandler, setIsEPUB, pause } = useTTS(); + const { + currDocData, + currDocName, + locationRef, + handleLocationChanged, + bookRef, + renditionRef, + tocRef, + setRendition, + extractPageText + } = useEPUB(); + const { registerLocationChangeHandler, pause } = useTTS(); const { epubTheme } = useConfig(); - const bookRef = useRef(null); - const rendition = useRef(undefined); - const toc = useRef([]); - const locationRef = useRef(currDocPage); - const { updateTheme } = useEPUBTheme(epubTheme, rendition.current); + const { updateTheme } = useEPUBTheme(epubTheme, renditionRef.current); const containerRef = useRef(null); - - const isEPUBSetOnce = useRef(false); const { isResizing, setIsResizing, dimensions } = useEPUBResize(containerRef); - const handleLocationChanged = useCallback((location: string | number) => { - // Set the EPUB flag once the location changes - if (!isEPUBSetOnce.current) { - setIsEPUB(true); - isEPUBSetOnce.current = true; - - rendition.current?.display(location.toString()); - - return; - } - - if (!bookRef.current?.isOpen || !rendition.current) return; - - // Handle special 'next' and 'prev' cases - if (location === 'next' && rendition.current) { - rendition.current.next(); - return; - } - if (location === 'prev' && rendition.current) { - rendition.current.prev(); - return; - } - - // Save the location to IndexedDB if not initial - if (id && locationRef.current !== 1) { - console.log('Saving location:', location); - setLastDocumentLocation(id as string, location.toString()); - } - - skipToLocation(location); - - locationRef.current = location; - extractPageText(bookRef.current, rendition.current); - - }, [id, skipToLocation, extractPageText, setIsEPUB]); - - const initialExtract = useCallback(() => { - if (!bookRef.current || !rendition.current?.location || isEPUBSetOnce.current) return; - extractPageText(bookRef.current, rendition.current, false); - }, [extractPageText]); - const checkResize = useCallback(() => { - if (isResizing && dimensions && bookRef.current?.isOpen && rendition.current && isEPUBSetOnce.current) { + if (isResizing && dimensions && bookRef.current?.isOpen && renditionRef.current) { pause(); // Only extract text when we have dimensions, ensuring the resize is complete - extractPageText(bookRef.current, rendition.current, true); + extractPageText(bookRef.current, renditionRef.current, true); setIsResizing(false); return true; } else { return false; } - }, [isResizing, setIsResizing, dimensions, pause, extractPageText]); + }, [isResizing, setIsResizing, dimensions, pause, bookRef, renditionRef, extractPageText]); // Check for isResizing to pause TTS and re-extract text useEffect(() => { if (checkResize()) return; - - // Load initial location when not resizing - initialExtract(); - }, [checkResize, initialExtract]); - - // Load the initial location - // useEffect(() => { - // if (!bookRef.current || !rendition.current || isEPUBSetOnce.current) return; - - // extractPageText(bookRef.current, rendition.current, false); - // }, [extractPageText]); + }, [checkResize]); // Register the location change handler useEffect(() => { @@ -127,12 +76,11 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) { locationChanged={handleLocationChanged} url={currDocData} title={currDocName} - tocChanged={(_toc) => (toc.current = _toc)} + tocChanged={(_toc) => (tocRef.current = _toc)} showToc={true} readerStyles={epubTheme && getThemeStyles() || undefined} - getRendition={(_rendition: Rendition) => { - bookRef.current = _rendition.book; - rendition.current = _rendition; + getRendition={(_rendition) => { + setRendition(_rendition); updateTheme(); }} /> diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 435473e..0086a1f 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -15,6 +15,11 @@ type ConfigValues = { voice: string; skipBlank: boolean; epubTheme: boolean; + textExtractionMargin: number; + headerMargin: number; + footerMargin: number; + leftMargin: number; + rightMargin: number; }; /** Interface defining the configuration context shape and functionality */ @@ -26,6 +31,11 @@ interface ConfigContextType { voice: string; skipBlank: boolean; epubTheme: boolean; + textExtractionMargin: number; + headerMargin: number; + footerMargin: number; + leftMargin: number; + rightMargin: number; updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise; updateConfigKey: (key: K, value: ConfigValues[K]) => Promise; isLoading: boolean; @@ -49,6 +59,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const [voice, setVoice] = useState('af_sarah'); const [skipBlank, setSkipBlank] = useState(true); const [epubTheme, setEpubTheme] = useState(false); + const [textExtractionMargin, setTextExtractionMargin] = useState(0.07); + const [headerMargin, setHeaderMargin] = useState(0.07); + const [footerMargin, setFooterMargin] = useState(0.07); + const [leftMargin, setLeftMargin] = useState(0.07); + const [rightMargin, setRightMargin] = useState(0.07); const [isLoading, setIsLoading] = useState(true); const [isDBReady, setIsDBReady] = useState(false); @@ -68,6 +83,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const cachedVoice = await getItem('voice'); const cachedSkipBlank = await getItem('skipBlank'); const cachedEpubTheme = await getItem('epubTheme'); + const cachedMargin = await getItem('textExtractionMargin'); + const cachedHeaderMargin = await getItem('headerMargin'); + const cachedFooterMargin = await getItem('footerMargin'); + const cachedLeftMargin = await getItem('leftMargin'); + const cachedRightMargin = await getItem('rightMargin'); // Only set API key and base URL if they were explicitly saved by the user if (cachedApiKey) { @@ -85,6 +105,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) { setVoice(cachedVoice || 'af_sarah'); setSkipBlank(cachedSkipBlank === 'false' ? false : true); setEpubTheme(cachedEpubTheme === 'true'); + setTextExtractionMargin(parseFloat(cachedMargin || '0.07')); + setHeaderMargin(parseFloat(cachedHeaderMargin || '0.07')); + setFooterMargin(parseFloat(cachedFooterMargin || '0.07')); + setLeftMargin(parseFloat(cachedLeftMargin || '0.07')); + setRightMargin(parseFloat(cachedRightMargin || '0.07')); // Only save non-sensitive settings by default if (!cachedViewType) { @@ -96,6 +121,13 @@ export function ConfigProvider({ children }: { children: ReactNode }) { if (cachedEpubTheme === null) { await setItem('epubTheme', 'false'); } + if (cachedMargin === null) { + await setItem('textExtractionMargin', '0.07'); + } + if (cachedHeaderMargin === null) await setItem('headerMargin', '0.07'); + if (cachedFooterMargin === null) await setItem('footerMargin', '0.07'); + if (cachedLeftMargin === null) await setItem('leftMargin', '0.0'); + if (cachedRightMargin === null) await setItem('rightMargin', '0.0'); } catch (error) { console.error('Error initializing:', error); @@ -170,6 +202,21 @@ export function ConfigProvider({ children }: { children: ReactNode }) { case 'epubTheme': setEpubTheme(value as boolean); break; + case 'textExtractionMargin': + setTextExtractionMargin(value as number); + break; + case 'headerMargin': + setHeaderMargin(value as number); + break; + case 'footerMargin': + setFooterMargin(value as number); + break; + case 'leftMargin': + setLeftMargin(value as number); + break; + case 'rightMargin': + setRightMargin(value as number); + break; } } catch (error) { console.error(`Error updating config key ${key}:`, error); @@ -186,6 +233,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) { voice, skipBlank, epubTheme, + textExtractionMargin, + headerMargin, + footerMargin, + leftMargin, + rightMargin, updateConfig, updateConfigKey, isLoading, diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index c765f41..58edec8 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -7,11 +7,18 @@ import { ReactNode, useCallback, useMemo, + useRef, + RefObject, } from 'react'; import { indexedDBService } from '@/utils/indexedDB'; import { useTTS } from '@/contexts/TTSContext'; import { Book, Rendition } from 'epubjs'; import { createRangeCfi } from '@/utils/epub'; +import type { NavItem } from 'epubjs'; +import { setLastDocumentLocation } from '@/utils/indexedDB'; +import { SpineItem } from 'epubjs/types/section'; +import { useParams } from 'next/navigation'; +import { useConfig } from './ConfigContext'; interface EPUBContextType { currDocData: ArrayBuffer | undefined; @@ -22,6 +29,14 @@ interface EPUBContextType { setCurrentDocument: (id: string) => Promise; clearCurrDoc: () => void; extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise; + createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise; + bookRef: RefObject; + renditionRef: RefObject; + tocRef: RefObject; + locationRef: RefObject; + handleLocationChanged: (location: string | number) => void; + setRendition: (rendition: Rendition) => void; + isAudioCombining: boolean; } const EPUBContext = createContext(undefined); @@ -33,12 +48,27 @@ const EPUBContext = createContext(undefined); * @param {ReactNode} props.children - Child components to be wrapped by the provider */ export function EPUBProvider({ children }: { children: ReactNode }) { - const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop } = useTTS(); - + const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS(); + const { id } = useParams(); + // Configuration context to get TTS settings + const { + apiKey, + baseUrl, + voiceSpeed, + voice, + } = useConfig(); // Current document state const [currDocData, setCurrDocData] = useState(); const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); + const [isAudioCombining, setIsAudioCombining] = useState(false); + + // Add new refs + const bookRef = useRef(null); + const renditionRef = useRef(undefined); + const tocRef = useRef([]); + const locationRef = useRef(currDocPage); + const isEPUBSetOnce = useRef(false); /** * Clears all current document state and stops any active TTS @@ -62,7 +92,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { if (doc) { console.log('Retrieved document size:', doc.size); console.log('Retrieved ArrayBuffer size:', doc.data.byteLength); - + if (doc.data.byteLength === 0) { console.error('Retrieved ArrayBuffer is empty'); throw new Error('Empty document data'); @@ -87,18 +117,22 @@ export function EPUBProvider({ children }: { children: ReactNode }) { * @returns {Promise} The extracted text content */ const extractPageText = useCallback(async (book: Book, rendition: Rendition, shouldPause = false): Promise => { - try { + try { const { start, end } = rendition?.location; if (!start?.cfi || !end?.cfi || !book || !book.isOpen || !rendition) return ''; - + const rangeCfi = createRangeCfi(start.cfi, end.cfi); const range = await book.getRange(rangeCfi); + if (!range) { + console.warn('Failed to get range from CFI:', rangeCfi); + return ''; + } const textContent = range.toString().trim(); - + setTTSText(textContent, shouldPause); setCurrDocText(textContent); - + return textContent; } catch (error) { console.error('Error extracting EPUB text:', error); @@ -106,6 +140,250 @@ export function EPUBProvider({ children }: { children: ReactNode }) { } }, [setTTSText]); + /** + * Extracts text content from the entire EPUB book + * @returns {Promise} Array of text content from each section + */ + const extractBookText = useCallback(async (): Promise => { + try { + if (!bookRef.current || !bookRef.current.isOpen) return ['']; + + const book = bookRef.current; + const spine = book.spine; + const promises: Promise[] = []; + + spine.each((item: SpineItem) => { + const url = item.href || ''; + if (!url) return; + + const promise = book.load(url) + .then((section) => (section as Document)) + .then((section) => { + const textContent = section.body.textContent || ''; + return textContent; + }) + .catch((err) => { + console.error(`Error loading section ${url}:`, err); + return ''; + }); + + promises.push(promise); + }); + + const textArray = await Promise.all(promises); + const filteredArray = textArray.filter(text => text.trim() !== ''); + console.log('Extracted entire EPUB text array:', filteredArray); + return filteredArray; + } catch (error) { + console.error('Error extracting EPUB text:', error); + return ['']; + } + }, []); + + /** + * Creates a complete audiobook by processing all text through NLP and TTS + */ + const createFullAudioBook = useCallback(async ( + onProgress: (progress: number) => void, + signal?: AbortSignal, + format: 'mp3' | 'm4b' = 'mp3' + ): Promise => { + try { + const textArray = await extractBookText(); + if (!textArray.length) throw new Error('No text content found in book'); + + // Calculate total text length for accurate progress tracking + const totalLength = textArray.reduce((sum, text) => sum + text.trim().length, 0); + const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = []; + let processedLength = 0; + let currentTime = 0; + + // Get TOC for chapter titles if available + const chapters = tocRef.current || []; + const spine = bookRef.current?.spine; + + for (const text of textArray) { + if (signal?.aborted) { + const partialBuffer = await combineAudioChunks(audioChunks, format); + return partialBuffer; + } + + try { + const trimmedText = text.trim(); + if (!trimmedText) continue; + + const ttsResponse = await fetch('/api/tts', { + method: 'POST', + headers: { + 'x-openai-key': apiKey, + 'x-openai-base-url': baseUrl, + }, + body: JSON.stringify({ + text: trimmedText, + voice: voice, + speed: voiceSpeed, + format: 'audiobook' + }), + signal + }); + + if (!ttsResponse.ok) { + throw new Error(`TTS processing failed with status ${ttsResponse.status}`); + } + + const audioBuffer = await ttsResponse.arrayBuffer(); + if (audioBuffer.byteLength === 0) { + throw new Error('Received empty audio buffer from TTS'); + } + + // Find matching chapter title from TOC if available + let chapterTitle; + if (spine && chapters.length > 0) { + let spineIndex = processedLength; + let currentSpineHref: string | undefined; + + spine.each((item: SpineItem) => { + if (spineIndex === 0) { + currentSpineHref = item.href; + } + spineIndex--; + }); + + const matchingChapter = chapters.find(chapter => + chapter.href && currentSpineHref?.includes(chapter.href) + ); + chapterTitle = matchingChapter?.label || `Section ${processedLength + 1}`; + } else { + chapterTitle = `Section ${processedLength + 1}`; + } + + audioChunks.push({ + buffer: audioBuffer, + title: chapterTitle, + startTime: currentTime + }); + + // Add silence between sections + const silenceBuffer = new ArrayBuffer(48000); + audioChunks.push({ + buffer: silenceBuffer, + startTime: currentTime + (audioBuffer.byteLength / 48000) + }); + + currentTime += (audioBuffer.byteLength + 48000) / 48000; + + // Update progress based on processed text length + processedLength += trimmedText.length; + onProgress((processedLength / totalLength) * 100); + + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + console.log('TTS request aborted'); + const partialBuffer = await combineAudioChunks(audioChunks, format); + return partialBuffer; + } + console.error('Error processing section:', error); + } + } + + if (audioChunks.length === 0) { + throw new Error('No audio was generated from the book content'); + } + + return combineAudioChunks(audioChunks, format); + } catch (error) { + console.error('Error creating audiobook:', error); + throw error; + } + }, [extractBookText, apiKey, baseUrl, voice, voiceSpeed]); + + const combineAudioChunks = async ( + audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[], + format: 'mp3' | 'm4b' + ): Promise => { + setIsAudioCombining(true); + try { + if (format === 'm4b') { + // Convert to M4B format using the audio conversion API + const response = await fetch('/api/audio/convert', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chapters: audioChunks + .filter(chunk => chunk.title) // Only include chunks with titles + .map(chunk => ({ + title: chunk.title, + buffer: Array.from(new Uint8Array(chunk.buffer)) + })) + }), + }); + + if (!response.ok) { + throw new Error('Failed to convert audio to M4B format'); + } + + return response.arrayBuffer(); + } + + // For MP3, just concatenate the buffers + const totalLength = audioChunks.reduce((acc, chunk) => acc + chunk.buffer.byteLength, 0); + const combinedBuffer = new Uint8Array(totalLength); + + let offset = 0; + for (const chunk of audioChunks) { + combinedBuffer.set(new Uint8Array(chunk.buffer), offset); + offset += chunk.buffer.byteLength; + } + + return combinedBuffer.buffer; + } finally { + setIsAudioCombining(false); + } + } + + const setRendition = useCallback((rendition: Rendition) => { + bookRef.current = rendition.book; + renditionRef.current = rendition; + }, []); + + const handleLocationChanged = useCallback((location: string | number) => { + // Set the EPUB flag once the location changes + if (!isEPUBSetOnce.current) { + setIsEPUB(true); + isEPUBSetOnce.current = true; + + renditionRef.current?.display(location.toString()); + return; + } + + if (!bookRef.current?.isOpen || !renditionRef.current) return; + + // Handle special 'next' and 'prev' cases + if (location === 'next' && renditionRef.current) { + renditionRef.current.next(); + return; + } + if (location === 'prev' && renditionRef.current) { + renditionRef.current.prev(); + return; + } + + // Save the location to IndexedDB if not initial + if (id && locationRef.current !== 1) { + console.log('Saving location:', location); + setLastDocumentLocation(id as string, location.toString()); + } + + skipToLocation(location); + + locationRef.current = location; + if (bookRef.current && renditionRef.current) { + extractPageText(bookRef.current, renditionRef.current); + } + }, [id, skipToLocation, extractPageText, setIsEPUB]); + // Context value memoization const contextValue = useMemo( () => ({ @@ -117,6 +395,14 @@ export function EPUBProvider({ children }: { children: ReactNode }) { currDocText, clearCurrDoc, extractPageText, + createFullAudioBook, + bookRef, + renditionRef, + tocRef, + locationRef, + handleLocationChanged, + setRendition, + isAudioCombining, }), [ setCurrentDocument, @@ -127,6 +413,10 @@ export function EPUBProvider({ children }: { children: ReactNode }) { currDocText, clearCurrDoc, extractPageText, + createFullAudioBook, + handleLocationChanged, + setRendition, + isAudioCombining, ] ); diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index e6ae4b0..f167941 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -26,6 +26,7 @@ import { import { indexedDBService } from '@/utils/indexedDB'; import { useTTS } from '@/contexts/TTSContext'; +import { useConfig } from '@/contexts/ConfigContext'; import { extractTextFromPDF, convertPDFDataToURL, @@ -61,6 +62,8 @@ interface PDFContextType { stopAndPlayFromIndex: (index: number) => void, isProcessing: boolean ) => void; + createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise; + isAudioCombining: boolean; } // Create the context @@ -76,13 +79,30 @@ const PDFContext = createContext(undefined); * @param {ReactNode} props.children - Child components to be wrapped by the provider */ export function PDFProvider({ children }: { children: ReactNode }) { - const { setText: setTTSText, stop, currDocPageNumber: currDocPage, currDocPages, setCurrDocPages } = useTTS(); + const { + setText: setTTSText, + stop, + currDocPageNumber: currDocPage, + currDocPages, + setCurrDocPages + } = useTTS(); + const { + headerMargin, + footerMargin, + leftMargin, + rightMargin, + apiKey, + baseUrl, + voiceSpeed, + voice, + } = useConfig(); // Current document state const [currDocURL, setCurrDocURL] = useState(); const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); const [pdfDocument, setPdfDocument] = useState(); + const [isAudioCombining, setIsAudioCombining] = useState(false); /** * Handles successful PDF document load @@ -104,7 +124,12 @@ export function PDFProvider({ children }: { children: ReactNode }) { const loadCurrDocText = useCallback(async () => { try { if (!pdfDocument) return; - const text = await extractTextFromPDF(pdfDocument, currDocPage); + const text = await extractTextFromPDF(pdfDocument, currDocPage, { + header: headerMargin, + footer: footerMargin, + left: leftMargin, + right: rightMargin + }); // Only update TTS text if the content has actually changed // This prevents unnecessary resets of the sentence index if (text !== currDocText || text === '') { @@ -114,7 +139,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { } catch (error) { console.error('Error loading PDF text:', error); } - }, [pdfDocument, currDocPage, setTTSText, currDocText]); + }, [pdfDocument, currDocPage, setTTSText, currDocText, headerMargin, footerMargin, leftMargin, rightMargin]); /** * Effect hook to update document text when the page changes @@ -159,6 +184,168 @@ export function PDFProvider({ children }: { children: ReactNode }) { stop(); }, [setCurrDocPages, 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 {string} format - Optional format for the audiobook ('mp3' or 'm4b') + * @returns {Promise} The complete audiobook as an ArrayBuffer + */ + const createFullAudioBook = useCallback(async ( + onProgress: (progress: number) => void, + signal?: AbortSignal, + format: 'mp3' | 'm4b' = 'mp3' + ): Promise => { + try { + if (!pdfDocument) { + throw new Error('No PDF document loaded'); + } + + // First pass: extract and measure all text + const textPerPage: string[] = []; + let totalLength = 0; + + for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) { + const text = await extractTextFromPDF(pdfDocument, pageNum, { + header: headerMargin, + footer: footerMargin, + left: leftMargin, + right: rightMargin + }); + const trimmedText = text.trim(); + if (trimmedText) { + textPerPage.push(trimmedText); + totalLength += trimmedText.length; + } + } + + if (totalLength === 0) { + throw new Error('No text content found in PDF'); + } + + const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = []; + let processedLength = 0; + let currentTime = 0; + + // Second pass: process text into audio + for (let i = 0; i < textPerPage.length; i++) { + if (signal?.aborted) { + const partialBuffer = await combineAudioChunks(audioChunks, format); + return partialBuffer; + } + + const text = textPerPage[i]; + try { + const ttsResponse = await fetch('/api/tts', { + method: 'POST', + headers: { + 'x-openai-key': apiKey, + 'x-openai-base-url': baseUrl, + }, + body: JSON.stringify({ + text, + voice: voice, + speed: voiceSpeed, + format: 'audiobook' + }), + signal + }); + + if (!ttsResponse.ok) { + throw new Error(`TTS processing failed with status ${ttsResponse.status}`); + } + + const audioBuffer = await ttsResponse.arrayBuffer(); + if (audioBuffer.byteLength === 0) { + throw new Error('Received empty audio buffer from TTS'); + } + + audioChunks.push({ + buffer: audioBuffer, + title: `Page ${i + 1}`, + startTime: currentTime + }); + + // Add a small pause between pages (1s of silence) + const silenceBuffer = new ArrayBuffer(48000); + audioChunks.push({ + buffer: silenceBuffer, + startTime: currentTime + (audioBuffer.byteLength / 48000) + }); + + currentTime += (audioBuffer.byteLength + 48000) / 48000; + + // Update progress based on processed text length + processedLength += text.length; + onProgress((processedLength / totalLength) * 100); + + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + console.log('TTS request aborted'); + const partialBuffer = await combineAudioChunks(audioChunks, format); + return partialBuffer; + } + console.error('Error processing page:', error); + } + } + + if (audioChunks.length === 0) { + throw new Error('No audio was generated from the PDF content'); + } + + return combineAudioChunks(audioChunks, format); + } catch (error) { + console.error('Error creating audiobook:', error); + throw error; + } + }, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, voice, voiceSpeed]); + + const combineAudioChunks = async ( + audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[], + format: 'mp3' | 'm4b' + ): Promise => { + setIsAudioCombining(true); + try { + if (format === 'm4b') { + // Convert to M4B format using the audio conversion API + const response = await fetch('/api/audio/convert', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chapters: audioChunks + .filter(chunk => chunk.title) // Only include chunks with titles + .map(chunk => ({ + title: chunk.title, + buffer: Array.from(new Uint8Array(chunk.buffer)) + })) + }), + }); + + if (!response.ok) { + throw new Error('Failed to convert audio to M4B format'); + } + + return response.arrayBuffer(); + } + + // For MP3, just concatenate the buffers + const totalLength = audioChunks.reduce((acc, chunk) => acc + chunk.buffer.byteLength, 0); + const combinedBuffer = new Uint8Array(totalLength); + + let offset = 0; + for (const chunk of audioChunks) { + combinedBuffer.set(new Uint8Array(chunk.buffer), offset); + offset += chunk.buffer.byteLength; + } + + return combinedBuffer.buffer; + } finally { + setIsAudioCombining(false); + } + } + // Context value memoization const contextValue = useMemo( () => ({ @@ -174,6 +361,8 @@ export function PDFProvider({ children }: { children: ReactNode }) { clearHighlights, handleTextClick, pdfDocument, + createFullAudioBook, + isAudioCombining, }), [ onDocumentLoadSuccess, @@ -185,6 +374,8 @@ export function PDFProvider({ children }: { children: ReactNode }) { currDocText, clearCurrDoc, pdfDocument, + createFullAudioBook, + isAudioCombining, ] ); diff --git a/src/hooks/useTimeEstimation.ts b/src/hooks/useTimeEstimation.ts new file mode 100644 index 0000000..1adb3c2 --- /dev/null +++ b/src/hooks/useTimeEstimation.ts @@ -0,0 +1,192 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +export interface TimeEstimation { + progress: number; + setProgress: (progress: number) => void; + estimatedTimeRemaining: string | null; +} + +export function useTimeEstimation(): TimeEstimation { + const [progress, setProgressState] = useState(0); + const [estimatedTimeRemaining, setEstimatedTimeRemaining] = useState(null); + + // Store timing data to avoid dependency cycles + const startTimeRef = useRef(null); + const progressHistoryRef = useRef>([]); + const lastProgressUpdateRef = useRef(0); + const lastEstimateUpdateRef = useRef(0); + + const resetState = useCallback(() => { + startTimeRef.current = null; + progressHistoryRef.current = []; + setEstimatedTimeRemaining(null); + lastEstimateUpdateRef.current = 0; + lastProgressUpdateRef.current = 0; + }, []); + + const formatTimeRemaining = useCallback((seconds: number): string => { + if (seconds < 30) { + return '1m'; + } else if (seconds < 3600) { + const minutes = Math.round(seconds / 60); + return `${minutes}m`; + } else { + const totalMinutes = Math.round(seconds / 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return minutes > 0 + ? `${hours}h ${minutes}m` + : `${hours}h`; + } + }, []); + + const calculateMovingAverage = useCallback((data: number[], windowSize: number) => { + const result = []; + for (let i = 0; i < data.length; i++) { + const start = Math.max(0, i - windowSize + 1); + const window = data.slice(start, i + 1); + const average = window.reduce((sum, val) => sum + val, 0) / window.length; + result.push(average); + } + return result; + }, []); + + const calculateTimeRemaining = useCallback((newProgress: number, currentTime: number) => { + // Initialize start time if this is the first meaningful update + if (startTimeRef.current === null && newProgress > 0) { + startTimeRef.current = currentTime; + progressHistoryRef.current = [{ time: currentTime, progress: newProgress }]; + lastProgressUpdateRef.current = currentTime; + return; + } + + // Skip if no progress + if (newProgress <= 0) { + resetState(); + return; + } + + // Check if we should update the estimate + const timeSinceLastUpdate = currentTime - lastProgressUpdateRef.current; + const shouldSkipUpdate = timeSinceLastUpdate < 3000; // Reduced from 10s to 3s for more responsive updates + + const lastDataPoint = progressHistoryRef.current[progressHistoryRef.current.length - 1]; + const timeSinceLastDataPoint = lastDataPoint ? currentTime - lastDataPoint.time : Infinity; + const progressDifference = lastDataPoint ? newProgress - lastDataPoint.progress : Infinity; + + // Store data point if significant time or progress change + if (timeSinceLastDataPoint > 2000 || Math.abs(progressDifference) > 1) { + progressHistoryRef.current.push({ time: currentTime, progress: newProgress }); + + // Keep a sliding window of data points + const maxDataPoints = 20; // Increased from 5 for smoother averaging + if (progressHistoryRef.current.length > maxDataPoints) { + progressHistoryRef.current = [ + progressHistoryRef.current[0], + ...progressHistoryRef.current.slice(-maxDataPoints + 1) + ]; + } + } + + lastProgressUpdateRef.current = currentTime; + + if (shouldSkipUpdate) { + return; + } + + const history = progressHistoryRef.current; + + if (history.length < 2) { + return; + } + + // Calculate progress rates for smoothing + const rates: number[] = []; + for (let i = 1; i < history.length; i++) { + const timeSpan = (history[i].time - history[i-1].time) / 1000; // Convert to seconds + const progressSpan = history[i].progress - history[i-1].progress; + if (timeSpan > 0) { + rates.push(progressSpan / timeSpan); + } + } + + if (rates.length === 0) return; + + // Apply moving average smoothing to the rates + const smoothedRates = calculateMovingAverage(rates, Math.min(5, rates.length)); + const currentRate = smoothedRates[smoothedRates.length - 1]; + + if (currentRate > 0) { + const remainingProgress = 100 - newProgress; + const estimatedSeconds = remainingProgress / currentRate; + + if (isFinite(estimatedSeconds) && estimatedSeconds > 0) { + // Apply adaptive dampening based on progress + const dampingFactor = Math.max(0.2, Math.min(0.8, newProgress / 100)); + const previousEstimate = getSecondsFromEstimate(estimatedTimeRemaining); + + const smoothedSeconds = previousEstimate + ? (estimatedSeconds * dampingFactor) + (previousEstimate * (1 - dampingFactor)) + : estimatedSeconds; + + setEstimatedTimeRemaining(formatTimeRemaining(smoothedSeconds)); + lastEstimateUpdateRef.current = currentTime; + } + } + }, [estimatedTimeRemaining, formatTimeRemaining, resetState, calculateMovingAverage]); + + const getSecondsFromEstimate = (estimate: string | null): number | null => { + if (!estimate) return null; + + let seconds = 0; + + if (estimate.includes('h')) { + const hours = parseInt(estimate.split('h')[0], 10); + seconds += hours * 3600; + estimate = estimate.split('h')[1]; + } + + if (estimate?.includes('m')) { + const minutes = parseInt(estimate.split('m')[0], 10); + seconds += minutes * 60; + estimate = estimate.split('m')[1]; + } + + if (estimate?.includes('s')) { + const secs = parseInt(estimate.split('s')[0], 10); + seconds += secs; + } + + return seconds; + }; + + const updateProgress = useCallback((newProgress: number) => { + const currentTime = Date.now(); + const clampedProgress = Math.max(0, Math.min(100, newProgress)); + + setProgressState(clampedProgress); + + if (clampedProgress === 0) { + resetState(); + } else if (clampedProgress === 100) { + setEstimatedTimeRemaining(null); + lastEstimateUpdateRef.current = currentTime; + lastProgressUpdateRef.current = currentTime; + } else { + calculateTimeRemaining(clampedProgress, currentTime); + } + }, [calculateTimeRemaining, resetState]); + + // Reset time estimation when component unmounts + useEffect(() => { + return () => { + resetState(); + }; + }, [resetState]); + + return { + progress, + setProgress: updateProgress, + estimatedTimeRemaining + }; +} \ No newline at end of file diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts index 64e7210..24eac9a 100644 --- a/src/utils/pdf.ts +++ b/src/utils/pdf.ts @@ -26,14 +26,52 @@ export function convertPDFDataToURL(pdfData: Blob): Promise { } // Text Processing functions -export async function extractTextFromPDF(pdf: PDFDocumentProxy, pageNumber: number): Promise { +export async function extractTextFromPDF( + pdf: PDFDocumentProxy, + pageNumber: number, + margins = { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 } +): Promise { try { const page = await pdf.getPage(pageNumber); const textContent = await page.getTextContent(); + + const viewport = page.getViewport({ scale: 1.0 }); + const pageHeight = viewport.height; + const pageWidth = viewport.width; - const textItems = textContent.items.filter((item): item is TextItem => - 'str' in item && 'transform' in item - ); + const textItems = textContent.items.filter((item): item is TextItem => { + if (!('str' in item && 'transform' in item)) return false; + + const [scaleX, skewX, skewY, scaleY, x, y] = item.transform; + + // Basic text filtering + if (Math.abs(scaleX) < 1 || Math.abs(scaleX) > 20) return false; + if (Math.abs(scaleY) < 1 || Math.abs(scaleY) > 20) return false; + if (Math.abs(skewX) > 0.5 || Math.abs(skewY) > 0.5) return false; + + // Calculate margins in PDF coordinate space (y=0 is at bottom) + const headerY = pageHeight * (1 - margins.header); // Convert from top margin to bottom-based Y + const footerY = pageHeight * margins.footer; // Footer Y stays as is since it's already bottom-based + const leftX = pageWidth * margins.left; + const rightX = pageWidth * (1 - margins.right); + + // Check margins - remember y=0 is at bottom of page in PDF coordinates + if (y > headerY || y < footerY) { // Y greater than headerY means it's in header area, less than footerY means footer area + return false; + } + + // Check horizontal margins + if (x < leftX || x > rightX) { + return false; + } + + // Sanity check for coordinates + if (x < 0 || x > pageWidth) return false; + + return item.str.trim().length > 0; + }); + + console.log('Filtered text items:', textItems); const tolerance = 2; const lines: TextItem[][] = []; diff --git a/.env.template b/template.env similarity index 100% rename from .env.template rename to template.env