remove OCR language picker from settings code to make it a little easier to read. Added the link to docs on using different languages with OCR

This commit is contained in:
EthanHealy01 2025-07-30 13:55:22 +01:00
parent 710b4837a0
commit a0e57655db
6 changed files with 512 additions and 109 deletions

View File

@ -0,0 +1,94 @@
/* Language Picker Component */
.languagePicker {
width: 100%;
display: flex;
flex-direction: row;
align-items: center; /* Center align items vertically */
height: 32px;
border: 1px solid var(--border-default);
background-color: white; /* Default white background for light mode */
color: var(--text-secondary);
border-radius: var(--radius-sm);
padding: 4px 8px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
}
/* Dark mode background */
[data-mantine-color-scheme="dark"] .languagePicker {
background-color: #2A2F36; /* Specific dark background color */
}
.languagePicker:hover {
border-color: var(--border-strong);
background-color: var(--mantine-color-gray-0); /* Light gray on hover for light mode */
}
/* Dark mode hover */
[data-mantine-color-scheme="dark"] .languagePicker:hover {
background-color: #3A3F46; /* Slightly lighter hover color */
}
.languagePicker:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.languagePickerIcon {
font-size: 16px;
color: var(--text-muted);
margin-left: auto;
display: flex;
align-items: center; /* Center the icon vertically */
}
.languagePickerDropdown {
background-color: white; /* Default white background for light mode */
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
padding: 4px;
}
/* Dark mode dropdown background */
[data-mantine-color-scheme="dark"] .languagePickerDropdown {
background-color: #2A2F36;
}
.languagePickerOption {
padding: 6px 10px;
cursor: pointer;
border-radius: var(--radius-xs);
font-size: 13px;
color: var(--text-primary);
transition: background-color 0.2s ease;
}
.languagePickerOption:hover {
background-color: var(--mantine-color-gray-0); /* Light gray on hover for light mode */
}
/* Dark mode option hover */
[data-mantine-color-scheme="dark"] .languagePickerOption:hover {
background-color: #3A3F46;
}
.languagePickerOption.selected {
background-color: var(--mantine-color-blue-1); /* Light blue for light mode */
color: var(--mantine-color-blue-9); /* Dark blue text for light mode */
}
/* Dark mode selected option */
[data-mantine-color-scheme="dark"] .languagePickerOption.selected {
background-color: var(--mantine-color-blue-8); /* Darker blue for better contrast */
color: white; /* White text for dark mode selected items */
}
.languagePickerOption.selected:hover {
background-color: var(--mantine-color-blue-2); /* Slightly darker on hover for light mode */
}
/* Dark mode selected option hover */
[data-mantine-color-scheme="dark"] .languagePickerOption.selected:hover {
background-color: var(--mantine-color-blue-7); /* Slightly lighter on hover */
}

View File

@ -0,0 +1,221 @@
import React, { useState, useEffect } from 'react';
import { Stack, Text, Loader, Popover, useMantineTheme, useMantineColorScheme, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { tempOcrLanguages } from '../../../utils/tempOcrLanguages';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
export interface LanguageOption {
value: string;
label: string;
}
export interface LanguagePickerProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
label?: string;
languagesEndpoint?: string;
}
const LanguagePicker: React.FC<LanguagePickerProps> = ({
value,
onChange,
placeholder = 'Select language',
disabled = false,
label,
languagesEndpoint = '/api/v1/ui-data/ocr-pdf'
}) => {
const { t } = useTranslation();
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const [availableLanguages, setAvailableLanguages] = useState<LanguageOption[]>([]);
const [isLoadingLanguages, setIsLoadingLanguages] = useState(true);
useEffect(() => {
// Fetch available languages from backend
const fetchLanguages = async () => {
console.log('[LanguagePicker] Starting language fetch...');
console.log('[LanguagePicker] Fetching from URL:', languagesEndpoint);
try {
const response = await fetch(languagesEndpoint);
console.log('[LanguagePicker] Response received:', {
status: response.status,
statusText: response.statusText,
ok: response.ok,
headers: Object.fromEntries(response.headers.entries())
});
if (response.ok) {
const data: { languages: string[] } = await response.json();
const languages = data.languages;
console.log('[LanguagePicker] Raw response data:', languages);
console.log('[LanguagePicker] Response type:', typeof languages, 'Array?', Array.isArray(languages));
const languageOptions = languages.map(lang => {
// TODO: Use actual language translations when they become available
// For now, use temporary English translations
const translatedName = tempOcrLanguages.lang[lang as keyof typeof tempOcrLanguages.lang] || lang;
const displayName = translatedName;
console.log(`[LanguagePicker] Language mapping: ${lang} -> ${displayName} (translated: ${!!translatedName})`);
return {
value: lang,
label: displayName
};
});
console.log('[LanguagePicker] Transformed language options:', languageOptions);
setAvailableLanguages(languageOptions);
console.log('[LanguagePicker] Successfully set', languageOptions.length, 'languages');
} else {
console.error('[LanguagePicker] Response not OK:', response.status, response.statusText);
const errorText = await response.text();
console.error('[LanguagePicker] Error response body:', errorText);
}
} catch (error) {
console.error('[LanguagePicker] Fetch failed with error:', error);
console.error('[LanguagePicker] Error details:', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
} finally {
setIsLoadingLanguages(false);
console.log('[LanguagePicker] Language loading completed');
}
};
fetchLanguages();
}, [languagesEndpoint]);
if (isLoadingLanguages) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Loader size="xs" />
<Text size="sm">Loading available languages...</Text>
</div>
);
}
const selectedLanguage = availableLanguages.find(lang => lang.value === value);
// Get appropriate background colors based on color scheme
const getBackgroundColor = () => {
if (colorScheme === 'dark') {
return '#2A2F36'; // Specific dark background color
}
return 'white'; // White background for light mode
};
const getSelectedItemBackgroundColor = () => {
if (colorScheme === 'dark') {
return 'var(--mantine-color-blue-8)'; // Darker blue for better contrast
}
return 'var(--mantine-color-blue-1)'; // Light blue for light mode
};
const getSelectedItemTextColor = () => {
if (colorScheme === 'dark') {
return 'white'; // White text for dark mode selected items
}
return 'var(--mantine-color-blue-9)'; // Dark blue text for light mode
};
return (
<Box>
{label && (
<Text size="sm" fw={500} mb={4}>
{label}
</Text>
)}
<Popover width="target" position="bottom" withArrow={false} shadow="md">
<Popover.Target>
<Box
style={{
width: '100%',
display: 'flex',
flexDirection: 'row',
alignItems: 'center', // Center align items vertically
height: '32px',
border: `1px solid var(--border-default)`,
backgroundColor: getBackgroundColor(),
color: 'var(--text-secondary)',
borderRadius: 'var(--radius-sm)',
padding: '4px 8px',
fontSize: '13px',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.6 : 1,
transition: 'all 0.2s ease'
}}
>
<Text size="sm" style={{ flex: 1 }}>
{selectedLanguage?.label || placeholder}
</Text>
<UnfoldMoreIcon style={{
fontSize: '16px',
color: 'var(--text-muted)',
marginLeft: 'auto',
display: 'flex',
alignItems: 'center'
}} />
</Box>
</Popover.Target>
<Popover.Dropdown style={{
backgroundColor: getBackgroundColor(),
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-sm)',
padding: '4px'
}}>
<Stack gap="xs">
<Box style={{
maxHeight: '180px',
overflowY: 'auto',
borderBottom: '1px solid var(--border-default)',
paddingBottom: '8px'
}}>
{availableLanguages.map((lang) => (
<Box
key={lang.value}
style={{
padding: '6px 10px',
cursor: 'pointer',
borderRadius: 'var(--radius-xs)',
fontSize: '13px',
color: value === lang.value ? getSelectedItemTextColor() : 'var(--text-primary)',
backgroundColor: value === lang.value ? getSelectedItemBackgroundColor() : 'transparent',
transition: 'background-color 0.2s ease'
}}
onClick={() => onChange(lang.value)}
>
<Text size="sm">{lang.label}</Text>
</Box>
))}
</Box>
<Box style={{
padding: '8px',
textAlign: 'center',
fontSize: '12px'
}}>
<Text size="xs" c="dimmed" mb={4}>
Looking for additional languages?
</Text>
<Text
size="xs"
c="blue"
style={{ textDecoration: 'underline', cursor: 'pointer' }}
onClick={() => window.open('https://docs.stirlingpdf.com/Advanced%20Configuration/OCR', '_blank')}
>
View setup guide
</Text>
</Box>
</Stack>
</Popover.Dropdown>
</Popover>
</Box>
);
};
export default LanguagePicker;

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Stack, Select, MultiSelect, Text, Loader } from '@mantine/core';
import React from 'react';
import { Stack, Select, MultiSelect, Text, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { tempOcrLanguages } from '../../../utils/tempOcrLanguages';
import LanguagePicker from './LanguagePicker';
export interface OCRParameters {
languages: string[];
@ -22,8 +22,6 @@ const OCRSettings: React.FC<OCRSettingsProps> = ({
disabled = false
}) => {
const { t } = useTranslation();
const [availableLanguages, setAvailableLanguages] = useState<{value: string, label: string}[]>([]);
const [isLoadingLanguages, setIsLoadingLanguages] = useState(true);
// Define the additional options available
const additionalOptionsData = [
@ -31,102 +29,36 @@ const OCRSettings: React.FC<OCRSettingsProps> = ({
{ value: 'deskew', label: 'Deskew pages' },
{ value: 'clean', label: 'Clean input file' },
{ value: 'cleanFinal', label: 'Clean final output' },
{ value: 'removeImagesAfter', label: 'Remove images after OCR' },
];
useEffect(() => {
// Fetch available languages from backend
const fetchLanguages = async () => {
console.log('[OCR Languages] Starting language fetch...');
const url = '/api/v1/ui-data/ocr-pdf';
console.log('[OCR Languages] Fetching from URL:', url);
try {
const response = await fetch(url);
console.log('[OCR Languages] Response received:', {
status: response.status,
statusText: response.statusText,
ok: response.ok,
headers: Object.fromEntries(response.headers.entries())
});
if (response.ok) {
const data: { languages: string[] } = await response.json();
const languages = data.languages;
console.log('[OCR Languages] Raw response data:', languages);
console.log('[OCR Languages] Response type:', typeof languages, 'Array?', Array.isArray(languages));
const languageOptions = languages.map(lang => {
// TODO: Use actual language translations when they become available
// For now, use temporary English translations
const translatedName = tempOcrLanguages.lang[lang as keyof typeof tempOcrLanguages.lang] || lang;
const displayName = translatedName;
console.log(`[OCR Languages] Language mapping: ${lang} -> ${displayName} (translated: ${!!translatedName})`);
return {
value: lang,
label: displayName
};
});
console.log('[OCR Languages] Transformed language options:', languageOptions);
setAvailableLanguages(languageOptions);
console.log('[OCR Languages] Successfully set', languageOptions.length, 'languages');
} else {
console.error('[OCR Languages] Response not OK:', response.status, response.statusText);
const errorText = await response.text();
console.error('[OCR Languages] Error response body:', errorText);
}
} catch (error) {
console.error('[OCR Languages] Fetch failed with error:', error);
console.error('[OCR Languages] Error details:', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
} finally {
setIsLoadingLanguages(false);
console.log('[OCR Languages] Language loading completed');
}
};
fetchLanguages();
}, [t]); // Add t to dependencies since we're using it in the effect
return (
<Stack gap="md">
<Text size="sm" fw={500}>OCR Configuration</Text>
{isLoadingLanguages ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Loader size="xs" />
<Text size="sm">Loading available languages...</Text>
</div>
) : (
<Select
label="Languages"
placeholder="Select primary language for OCR"
value={parameters.languages[0] || ''}
onChange={(value) => onParameterChange('languages', value ? [value] : [])}
data={availableLanguages}
disabled={disabled}
clearable
/>
)}
<Select
label="OCR Mode"
value={parameters.ocrType}
onChange={(value) => onParameterChange('ocrType', value || 'skip-text')}
data={[
{ value: 'skip-text', label: 'Auto (skip text layers)' },
{ value: 'force-ocr', label: 'Force OCR - Process all pages' },
{ value: 'Normal', label: 'Normal - Error if text exists' },
{ value: 'force-ocr', label: 'Force (re-OCR all, replace text)' },
{ value: 'Normal', label: 'Strict (abort if text found)' },
]}
disabled={disabled}
/>
<Divider />
<LanguagePicker
value={parameters.languages[0] || ''}
onChange={(value) => onParameterChange('languages', [value])}
placeholder="Select primary language for OCR"
disabled={disabled}
label="Languages"
/>
<Divider />
<Select
label="Output"
value={parameters.ocrRenderType}
@ -138,23 +70,18 @@ const OCRSettings: React.FC<OCRSettingsProps> = ({
disabled={disabled}
/>
<Divider />
<MultiSelect
label="Additional Options"
placeholder="Select additional options"
placeholder="Select Options"
value={parameters.additionalOptions}
onChange={(value) => onParameterChange('additionalOptions', value)}
data={additionalOptionsData}
disabled={disabled}
clearable
styles={{
input: {
backgroundColor: 'var(--mantine-color-gray-1)',
borderColor: 'var(--mantine-color-gray-3)',
},
dropdown: {
backgroundColor: 'var(--mantine-color-gray-1)',
}
}}
comboboxProps={{ position: 'top', middlewares: { flip: false, shift: false } }}
/>
</Stack>
);

View File

@ -33,12 +33,13 @@ const ToolStep = ({
}: ToolStepProps) => {
if (!isVisible) return null;
const parent = useContext(ToolStepContext);
// Auto-detect if we should show numbers based on sibling count
const shouldShowNumber = useMemo(() => {
if (showNumber !== undefined) return showNumber;
const parent = useContext(ToolStepContext);
return parent ? parent.visibleStepCount >= 3 : false;
}, [showNumber]);
}, [showNumber, parent]);
const stepNumber = useContext(ToolStepContext)?.getStepNumber?.() || 1;
@ -96,7 +97,7 @@ export const ToolStepContainer = ({ children }: ToolStepContainerProps) => {
let count = 0;
React.Children.forEach(children, (child) => {
if (React.isValidElement(child) && child.type === ToolStep) {
const isVisible = child.props.isVisible !== false;
const isVisible = (child.props as ToolStepProps).isVisible !== false;
if (isVisible) count++;
}
});

View File

@ -5,6 +5,42 @@ import { useFileContext } from '../../../contexts/FileContext';
import { FileOperation } from '../../../types/fileContext';
import { OCRParameters } from '../../../components/tools/ocr/OCRSettings';
//Extract files from a ZIP blob
async function extractZipFile(zipBlob: Blob): Promise<File[]> {
const JSZip = await import('jszip');
const zip = new JSZip.default();
const arrayBuffer = await zipBlob.arrayBuffer();
const zipContent = await zip.loadAsync(arrayBuffer);
const extractedFiles: File[] = [];
for (const [filename, file] of Object.entries(zipContent.files)) {
if (!file.dir) {
const content = await file.async('blob');
const extractedFile = new File([content], filename, { type: getMimeType(filename) });
extractedFiles.push(extractedFile);
}
}
return extractedFiles;
}
//Get MIME type based on file extension
function getMimeType(filename: string): string {
const ext = filename.toLowerCase().split('.').pop();
switch (ext) {
case 'pdf':
return 'application/pdf';
case 'txt':
return 'text/plain';
case 'zip':
return 'application/zip';
default:
return 'application/octet-stream';
}
}
export interface OCROperationHook {
files: File[];
thumbnails: string[];
@ -155,16 +191,79 @@ export const useOCROperation = (): OCROperationHook => {
try {
const { formData, endpoint } = buildFormData(parameters, file);
const response = await axios.post(endpoint, formData, { responseType: "blob" });
const response = await axios.post(endpoint, formData, {
responseType: "blob",
timeout: 300000 // 5 minute timeout for OCR
});
// Check for HTTP errors
if (response.status >= 400) {
// Try to read error response as text
const errorText = await response.data.text();
throw new Error(`OCR service HTTP error ${response.status}: ${errorText.substring(0, 300)}`);
}
// Validate response
if (!response.data || response.data.size === 0) {
throw new Error('Empty response from OCR service');
}
const contentType = response.headers['content-type'] || 'application/pdf';
// Check if response is actually a PDF by examining the first few bytes
const arrayBuffer = await response.data.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const header = new TextDecoder().decode(uint8Array.slice(0, 4));
// Check if it's a ZIP file (OCR service returns ZIP when sidecar is enabled or for multi-file results)
if (header.startsWith('PK')) {
try {
// Extract ZIP file contents
const zipFiles = await extractZipFile(response.data);
// Add extracted files to processed files
processedFiles.push(...zipFiles);
} catch (extractError) {
// Fallback to treating as single ZIP file
const blob = new Blob([response.data], { type: 'application/zip' });
const processedFile = new File([blob], `ocr_${file.name}.zip`, { type: 'application/zip' });
processedFiles.push(processedFile);
}
continue; // Skip the PDF validation for ZIP files
}
if (!header.startsWith('%PDF')) {
// Check if it's an error response
const text = new TextDecoder().decode(uint8Array.slice(0, 500));
if (text.includes('error') || text.includes('Error') || text.includes('exception') || text.includes('html')) {
// Check for specific OCR tool unavailable error
if (text.includes('OCR tools') && text.includes('not installed')) {
throw new Error('OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.');
}
throw new Error(`OCR service error: ${text.substring(0, 300)}`);
}
// Check if it's an HTML error page
if (text.includes('<html') || text.includes('<!DOCTYPE')) {
// Try to extract error message from HTML
const errorMatch = text.match(/<title[^>]*>([^<]+)<\/title>/i) ||
text.match(/<h1[^>]*>([^<]+)<\/h1>/i) ||
text.match(/<body[^>]*>([^<]+)<\/body>/i);
const errorMessage = errorMatch ? errorMatch[1].trim() : 'Unknown error';
throw new Error(`OCR service error: ${errorMessage}`);
}
throw new Error(`Response is not a valid PDF file. Header: "${header}"`);
}
const blob = new Blob([response.data], { type: contentType });
const processedFile = new File([blob], `ocr_${file.name}`, { type: contentType });
processedFiles.push(processedFile);
} catch (fileError) {
console.error(`Failed to process OCR for ${file.name}:`, fileError);
failedFiles.push(file.name);
const errorMessage = fileError instanceof Error ? fileError.message : 'Unknown error';
failedFiles.push(`${file.name} (${errorMessage})`);
}
}
@ -175,7 +274,19 @@ export const useOCROperation = (): OCROperationHook => {
if (failedFiles.length > 0) {
setStatus(`Processed ${processedFiles.length}/${validFiles.length} files. Failed: ${failedFiles.join(', ')}`);
} else {
setStatus(`OCR completed successfully for ${processedFiles.length} file(s)`);
const hasPdfFiles = processedFiles.some(file => file.name.endsWith('.pdf'));
const hasTxtFiles = processedFiles.some(file => file.name.endsWith('.txt'));
let statusMessage = `OCR completed successfully for ${processedFiles.length} file(s)`;
if (hasPdfFiles && hasTxtFiles) {
statusMessage += ' (Extracted PDF and text files)';
} else if (hasPdfFiles) {
statusMessage += ' (Extracted PDF files)';
} else if (hasTxtFiles) {
statusMessage += ' (Extracted text files)';
}
setStatus(statusMessage);
}
setFiles(processedFiles);
@ -186,18 +297,34 @@ export const useOCROperation = (): OCROperationHook => {
// Cleanup old blob URLs
cleanupBlobUrls();
// Create download URL
// Create download URL - for multiple files, we'll create a new ZIP
if (processedFiles.length === 1) {
const url = window.URL.createObjectURL(processedFiles[0]);
setDownloadUrl(url);
setBlobUrls([url]);
setDownloadFilename(`ocr_${selectedFiles[0].name}`);
setDownloadFilename(processedFiles[0].name);
} else {
// For multiple files, we could create a zip, but for now just handle the first file
const url = window.URL.createObjectURL(processedFiles[0]);
setDownloadUrl(url);
setBlobUrls([url]);
setDownloadFilename(`ocr_${validFiles.length}_files.pdf`);
// For multiple files, create a new ZIP containing all extracted files
try {
const JSZip = await import('jszip');
const zip = new JSZip.default();
for (const file of processedFiles) {
zip.file(file.name, file);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
const url = window.URL.createObjectURL(zipBlob);
setDownloadUrl(url);
setBlobUrls([url]);
setDownloadFilename(`ocr_extracted_files.zip`);
} catch (zipError) {
// Fallback to first file
const url = window.URL.createObjectURL(processedFiles[0]);
setDownloadUrl(url);
setBlobUrls([url]);
setDownloadFilename(processedFiles[0].name);
}
}
markOperationApplied(fileId, operationId);

View File

@ -167,6 +167,39 @@ export const mantineTheme = createTheme({
},
},
MultiSelect: {
styles: {
input: {
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-default)',
color: 'var(--text-primary)',
'&:focus': {
borderColor: 'var(--color-primary-500)',
boxShadow: '0 0 0 1px var(--color-primary-500)',
},
},
label: {
color: 'var(--text-secondary)',
fontWeight: 'var(--font-weight-medium)',
},
dropdown: {
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-subtle)',
boxShadow: 'var(--shadow-lg)',
},
option: {
color: 'var(--text-primary)',
'&[data-hovered]': {
backgroundColor: 'var(--hover-bg)',
},
'&[data-selected]': {
backgroundColor: 'var(--color-primary-100)',
color: 'var(--color-primary-900)',
},
},
},
},
Checkbox: {
styles: {
input: {