Add configurable text extraction margin for PDF processing
This commit is contained in:
parent
b87b83310b
commit
f948601e70
4 changed files with 114 additions and 16 deletions
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { Fragment, useState, useRef } from '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';
|
||||
|
|
@ -21,13 +21,33 @@ const viewTypes = [
|
|||
];
|
||||
|
||||
export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsProps) {
|
||||
const { viewType, skipBlank, epubTheme, updateConfigKey } = useConfig();
|
||||
const { viewType, skipBlank, epubTheme, textExtractionMargin, updateConfigKey } = useConfig();
|
||||
const { createFullAudioBook } = useEPUB();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [localMargin, setLocalMargin] = useState(textExtractionMargin);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0];
|
||||
|
||||
//console.log(localMargin, textExtractionMargin);
|
||||
|
||||
// Sync local margin with global state
|
||||
useEffect(() => {
|
||||
setLocalMargin(textExtractionMargin);
|
||||
}, [textExtractionMargin]);
|
||||
|
||||
// Handler for slider change (updates local state only)
|
||||
const handleMarginChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalMargin(Number(event.target.value));
|
||||
}, []);
|
||||
|
||||
// Handler for slider release
|
||||
const handleMarginChangeComplete = useCallback(() => {
|
||||
if (localMargin !== textExtractionMargin) {
|
||||
updateConfigKey('textExtractionMargin', localMargin);
|
||||
}
|
||||
}, [localMargin, textExtractionMargin, updateConfigKey]);
|
||||
|
||||
const handleStartGeneration = async () => {
|
||||
setIsGenerating(true);
|
||||
setProgress(0);
|
||||
|
|
@ -132,13 +152,38 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
|
|||
</div>
|
||||
)}
|
||||
</div>}
|
||||
{!epub && <div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">Mode</label>
|
||||
{!epub && <div className="space-y-6">
|
||||
<div className="mt-4 space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Text Extraction Margin
|
||||
</label>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs">0%</span>
|
||||
<span className="text-xs font-bold">{Math.round(localMargin * 100)}%</span>
|
||||
<span className="text-xs">20%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="0.2"
|
||||
step="0.01"
|
||||
value={localMargin}
|
||||
onChange={handleMarginChange}
|
||||
onMouseUp={handleMarginChangeComplete}
|
||||
onKeyUp={handleMarginChangeComplete}
|
||||
onTouchEnd={handleMarginChangeComplete}
|
||||
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
||||
/>
|
||||
<p className="text-xs text-muted">
|
||||
{"Don't"} include content from outer rim of the page during text extraction (experimental)
|
||||
</p>
|
||||
</div>
|
||||
<Listbox
|
||||
value={selectedView}
|
||||
onChange={(newView) => updateConfigKey('viewType', newView.id as ViewType)}
|
||||
>
|
||||
<div className="relative z-10">
|
||||
<div className="relative z-10 space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">Mode</label>
|
||||
<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 transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
|
||||
<span className="block truncate">{selectedView.name}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
|
|
@ -177,14 +222,16 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
|
|||
))}
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
{selectedView.id === 'scroll' && (
|
||||
<p className="text-sm text-warning pt-2">
|
||||
Note: Continuous scroll may perform poorly for larger documents.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Listbox>
|
||||
{selectedView.id === 'scroll' && (
|
||||
<p className="text-sm text-warning pt-2">
|
||||
Note: Continuous scroll may perform poorly for larger documents.
|
||||
</p>
|
||||
)}
|
||||
|
||||
</div>}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ type ConfigValues = {
|
|||
voice: string;
|
||||
skipBlank: boolean;
|
||||
epubTheme: boolean;
|
||||
textExtractionMargin: number;
|
||||
};
|
||||
|
||||
/** Interface defining the configuration context shape and functionality */
|
||||
|
|
@ -26,6 +27,7 @@ interface ConfigContextType {
|
|||
voice: string;
|
||||
skipBlank: boolean;
|
||||
epubTheme: boolean;
|
||||
textExtractionMargin: number;
|
||||
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
|
||||
updateConfigKey: <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
|
|
@ -49,6 +51,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const [voice, setVoice] = useState<string>('af_sarah');
|
||||
const [skipBlank, setSkipBlank] = useState<boolean>(true);
|
||||
const [epubTheme, setEpubTheme] = useState<boolean>(false);
|
||||
const [textExtractionMargin, setTextExtractionMargin] = useState<number>(0.07);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDBReady, setIsDBReady] = useState(false);
|
||||
|
|
@ -68,6 +71,7 @@ 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');
|
||||
|
||||
// Only set API key and base URL if they were explicitly saved by the user
|
||||
if (cachedApiKey) {
|
||||
|
|
@ -85,6 +89,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
setVoice(cachedVoice || 'af_sarah');
|
||||
setSkipBlank(cachedSkipBlank === 'false' ? false : true);
|
||||
setEpubTheme(cachedEpubTheme === 'true');
|
||||
setTextExtractionMargin(parseFloat(cachedMargin || '0.07'));
|
||||
|
||||
// Only save non-sensitive settings by default
|
||||
if (!cachedViewType) {
|
||||
|
|
@ -96,6 +101,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
if (cachedEpubTheme === null) {
|
||||
await setItem('epubTheme', 'false');
|
||||
}
|
||||
if (cachedMargin === null) {
|
||||
await setItem('textExtractionMargin', '0.07');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing:', error);
|
||||
|
|
@ -170,6 +178,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
case 'epubTheme':
|
||||
setEpubTheme(value as boolean);
|
||||
break;
|
||||
case 'textExtractionMargin':
|
||||
setTextExtractionMargin(value as number);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating config key ${key}:`, error);
|
||||
|
|
@ -186,6 +197,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
voice,
|
||||
skipBlank,
|
||||
epubTheme,
|
||||
textExtractionMargin,
|
||||
updateConfig,
|
||||
updateConfigKey,
|
||||
isLoading,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
|
||||
import { indexedDBService } from '@/utils/indexedDB';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import {
|
||||
extractTextFromPDF,
|
||||
convertPDFDataToURL,
|
||||
|
|
@ -77,6 +78,7 @@ const PDFContext = createContext<PDFContextType | undefined>(undefined);
|
|||
*/
|
||||
export function PDFProvider({ children }: { children: ReactNode }) {
|
||||
const { setText: setTTSText, stop, currDocPageNumber: currDocPage, currDocPages, setCurrDocPages } = useTTS();
|
||||
const { textExtractionMargin } = useConfig();
|
||||
|
||||
// Current document state
|
||||
const [currDocURL, setCurrDocURL] = useState<string>();
|
||||
|
|
@ -104,7 +106,7 @@ 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, textExtractionMargin);
|
||||
// Only update TTS text if the content has actually changed
|
||||
// This prevents unnecessary resets of the sentence index
|
||||
if (text !== currDocText || text === '') {
|
||||
|
|
@ -114,7 +116,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
} catch (error) {
|
||||
console.error('Error loading PDF text:', error);
|
||||
}
|
||||
}, [pdfDocument, currDocPage, setTTSText, currDocText]);
|
||||
}, [pdfDocument, currDocPage, setTTSText, currDocText, textExtractionMargin]);
|
||||
|
||||
/**
|
||||
* Effect hook to update document text when the page changes
|
||||
|
|
|
|||
|
|
@ -26,14 +26,51 @@ export function convertPDFDataToURL(pdfData: Blob): Promise<string> {
|
|||
}
|
||||
|
||||
// Text Processing functions
|
||||
export async function extractTextFromPDF(pdf: PDFDocumentProxy, pageNumber: number): Promise<string> {
|
||||
export async function extractTextFromPDF(pdf: PDFDocumentProxy, pageNumber: number, margin = 0.07): Promise<string> {
|
||||
try {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const textContent = await page.getTextContent();
|
||||
|
||||
// Get page viewport to help with positioning
|
||||
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;
|
||||
|
||||
// Get all transform matrix values
|
||||
const [scaleX, skewX, skewY, scaleY, x, y] = item.transform;
|
||||
|
||||
// Check for reasonable scale values (not too small or too large)
|
||||
if (Math.abs(scaleX) < 1 || Math.abs(scaleX) > 20) return false;
|
||||
if (Math.abs(scaleY) < 1 || Math.abs(scaleY) > 20) return false;
|
||||
|
||||
// Check for reasonable skew values (should be close to 0 for normal text)
|
||||
if (Math.abs(skewX) > 0.5 || Math.abs(skewY) > 0.5) return false;
|
||||
|
||||
// Filter out positions in header/footer areas using configurable margin
|
||||
const topMargin = pageHeight * margin;
|
||||
const bottomMargin = pageHeight * (1 - margin);
|
||||
if (y < topMargin || y > bottomMargin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out positions in left/right margin areas
|
||||
const leftMargin = pageWidth * margin;
|
||||
const rightMargin = pageWidth * (1 - margin);
|
||||
if (x < leftMargin || x > rightMargin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for reasonable x position values
|
||||
if (x < 0 || x > pageWidth) return false;
|
||||
|
||||
// Filter out empty strings or strings with only whitespace
|
||||
return item.str.trim().length > 0;
|
||||
});
|
||||
|
||||
console.log('Filtered text items:', textItems);
|
||||
|
||||
const tolerance = 2;
|
||||
const lines: TextItem[][] = [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue