Better text extraction

This commit is contained in:
Richard Roberson 2025-01-21 18:15:18 -07:00
parent f7fd360a1b
commit b978e8c58a
4 changed files with 121 additions and 66 deletions

View file

@ -1,7 +1,7 @@
import { usePDF } from '@/contexts/PDFContext'; import { usePDF } from '@/contexts/PDFContext';
import Link from 'next/link'; import Link from 'next/link';
import { Dialog } from '@headlessui/react'; import { Dialog } from '@headlessui/react';
import { Transition } from '@headlessui/react'; import { Transition, TransitionChild, DialogPanel, DialogTitle } from '@headlessui/react';
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
export function DocumentList() { export function DocumentList() {
@ -102,7 +102,7 @@ export function DocumentList() {
className="relative z-50" className="relative z-50"
onClose={() => setIsDeleteDialogOpen(false)} onClose={() => setIsDeleteDialogOpen(false)}
> >
<Transition.Child <TransitionChild
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
@ -112,11 +112,11 @@ export function DocumentList() {
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 bg-black bg-opacity-25" /> <div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child> </TransitionChild>
<div className="fixed inset-0 overflow-y-auto"> <div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center"> <div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child <TransitionChild
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
@ -125,13 +125,13 @@ export function DocumentList() {
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-background p-6 text-left align-middle shadow-xl transition-all"> <DialogPanel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-background p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title <DialogTitle
as="h3" as="h3"
className="text-lg font-medium leading-6 text-foreground" className="text-lg font-medium leading-6 text-foreground"
> >
Delete Document Delete Document
</Dialog.Title> </DialogTitle>
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Are you sure you want to delete "{documentToDelete?.name}"? This action cannot be undone. Are you sure you want to delete "{documentToDelete?.name}"? This action cannot be undone.
@ -154,8 +154,8 @@ export function DocumentList() {
Delete Delete
</button> </button>
</div> </div>
</Dialog.Panel> </DialogPanel>
</Transition.Child> </TransitionChild>
</div> </div>
</div> </div>
</Dialog> </Dialog>

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { Dialog, Transition, Listbox } from '@headlessui/react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
interface SettingsModalProps { interface SettingsModalProps {
@ -22,7 +22,7 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
return ( return (
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}> <Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
<Transition.Child <TransitionChild
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
@ -32,11 +32,11 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" /> <div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
</Transition.Child> </TransitionChild>
<div className="fixed inset-0 overflow-y-auto"> <div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center"> <div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child <TransitionChild
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
@ -45,20 +45,20 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all"> <DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title <DialogTitle
as="h3" as="h3"
className="text-lg font-semibold leading-6 text-foreground" className="text-lg font-semibold leading-6 text-foreground"
> >
Settings Settings
</Dialog.Title> </DialogTitle>
<div className="mt-4"> <div className="mt-4">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Theme</label> <label className="block text-sm font-medium text-foreground">Theme</label>
<Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}> <Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}>
<div className="relative"> <div className="relative">
<Listbox.Button className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"> <ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent">
<span className="block truncate">{selectedTheme.name}</span> <span className="block truncate">{selectedTheme.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg <svg
@ -74,16 +74,16 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
/> />
</svg> </svg>
</span> </span>
</Listbox.Button> </ListboxButton>
<Transition <Transition
as={Fragment} as={Fragment}
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<Listbox.Options className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none"> <ListboxOptions className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
{themes.map((theme) => ( {themes.map((theme) => (
<Listbox.Option <ListboxOption
key={theme.id} key={theme.id}
className={({ active }) => className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${ `relative cursor-pointer select-none py-2 pl-10 pr-4 ${
@ -115,9 +115,9 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
) : null} ) : null}
</> </>
)} )}
</Listbox.Option> </ListboxOption>
))} ))}
</Listbox.Options> </ListboxOptions>
</Transition> </Transition>
</div> </div>
</Listbox> </Listbox>
@ -137,8 +137,8 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
Close Close
</button> </button>
</div> </div>
</Dialog.Panel> </DialogPanel>
</Transition.Child> </TransitionChild>
</div> </div>
</div> </div>
</Dialog> </Dialog>

View file

@ -34,13 +34,13 @@ const speedOptions = [
export default function TTSPlayer() { export default function TTSPlayer() {
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
const { const {
isPlaying, isPlaying,
togglePlay, togglePlay,
skipForward, skipForward,
skipBackward, skipBackward,
isProcessing, isProcessing,
speed, speed,
setSpeedAndRestart, setSpeedAndRestart,
voice, voice,
setVoiceAndRestart, setVoiceAndRestart,
@ -50,12 +50,34 @@ export default function TTSPlayer() {
//console.log(availableVoices); //console.log(availableVoices);
return ( return (
<div <div
className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 ${ className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 ${isVisible ? 'opacity-100' : 'opacity-0'
isVisible ? 'opacity-100' : 'opacity-0' } transition-opacity duration-300`}
} transition-opacity duration-300`}
> >
<div className="bg-base dark:bg-base rounded-full shadow-lg px-4 py-1 flex items-center space-x-1 relative"> <div className="bg-base dark:bg-base rounded-full shadow-lg px-4 py-1 flex items-center space-x-1 relative">
<div className="relative">
<Listbox value={speed} onChange={setSpeedAndRestart}>
<ListboxButton className="flex items-center space-x-1 bg-transparent text-foreground text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-2 pr-1 py-1">
<span>{speed}x</span>
<ChevronUpDownIcon className="h-3 w-3" />
</ListboxButton>
<ListboxOptions className="absolute bottom-full mb-1 w-24 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{speedOptions.map((option) => (
<ListboxOption
key={option.value}
value={option.value}
className={({ active, selected }) =>
`relative cursor-pointer select-none py-2 px-3 ${active ? 'bg-offbase' : ''
} ${selected ? 'font-medium' : ''}`
}
>
{option.label}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
</div>
<Button <Button
onClick={skipBackward} onClick={skipBackward}
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50" className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
@ -64,7 +86,7 @@ export default function TTSPlayer() {
> >
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon />} {isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon />}
</Button> </Button>
<Button <Button
onClick={togglePlay} onClick={togglePlay}
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none" className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none"
@ -82,29 +104,6 @@ export default function TTSPlayer() {
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon />} {isProcessing ? <LoadingSpinner /> : <SkipForwardIcon />}
</Button> </Button>
<div className="relative">
<Listbox value={speed} onChange={setSpeedAndRestart}>
<ListboxButton className="flex items-center space-x-1 bg-transparent text-foreground text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-2 pr-1 py-1">
<span>{speed}x</span>
<ChevronUpDownIcon className="h-3 w-3" />
</ListboxButton>
<ListboxOptions className="absolute bottom-full mb-1 w-24 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{speedOptions.map((option) => (
<ListboxOption
key={option.value}
value={option.value}
className={({ active, selected }) =>
`relative cursor-pointer select-none py-2 px-3 ${
active ? 'bg-offbase' : ''
} ${selected ? 'font-medium' : ''}`
}
>
{option.label}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
</div>
<div className="relative"> <div className="relative">
<Listbox value={voice} onChange={setVoiceAndRestart}> <Listbox value={voice} onChange={setVoiceAndRestart}>
@ -117,10 +116,8 @@ export default function TTSPlayer() {
<ListboxOption <ListboxOption
key={voiceId} key={voiceId}
value={voiceId} value={voiceId}
className={({ active, selected }) => className={({ active, selected }) =>
`relative cursor-pointer select-none py-2 px-3 ${ `relative cursor-pointer select-none py-2 px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}`
active ? 'bg-offbase' : ''
} ${selected ? 'font-medium' : ''}`
} }
> >
<span>{voiceId}</span> <span>{voiceId}</span>

View file

@ -15,6 +15,9 @@ import { pdfjs } from 'react-pdf';
import stringSimilarity from 'string-similarity'; import stringSimilarity from 'string-similarity';
import nlp from 'compromise'; import nlp from 'compromise';
// Add the correct type import
import type { TextContent, TextItem } from 'pdfjs-dist/types/src/display/api';
// Set worker from public directory // Set worker from public directory
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs'; pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs';
@ -135,11 +138,66 @@ export function PDFProvider({ children }: { children: ReactNode }) {
for (let i = 1; i <= pdf.numPages; i++) { for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i); const page = await pdf.getPage(i);
const textContent = await page.getTextContent(); const textContent = await page.getTextContent();
const pageText = textContent.items.map((item: any) => item.str).join(' ');
fullText += pageText + ' '; // 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 + ' ';
}
fullText += pageText + '\n';
} }
return fullText; return fullText.replace(/\s+/g, ' ').trim();
} catch (error) { } catch (error) {
console.error('Error extracting text from PDF:', error); console.error('Error extracting text from PDF:', error);
throw new Error('Failed to extract text from PDF'); throw new Error('Failed to extract text from PDF');