Tools/ocr/v2 (#4055)

# Description of Changes

- Added the OCR tool 
- Added language mappings file to map selected browser language -> OCR
language and OCR language codes -> english display values. TODO: Use the
translation function to translate the languages rather than mapping them
to english be default
- Added chevron icons to tool step to show expand and collapsed state
more visibly
- Added a re-usable dropdown picker with a footer component

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
EthanHealy01
2025-08-01 14:22:19 +01:00
committed by GitHub
co-authored by Anthony Stirling
parent 8802daf67f
commit 8881f19b03
17 changed files with 2421 additions and 51 deletions
@@ -0,0 +1,90 @@
import React from 'react';
import { Stack, Text, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { OCRParameters } from './OCRSettings';
export interface AdvancedOCRParameters {
advancedOptions: string[];
}
interface AdvancedOption {
value: string;
label: string;
isSpecial: boolean;
}
interface AdvancedOCRSettingsProps {
advancedOptions: string[];
ocrRenderType?: string;
onParameterChange: (key: keyof OCRParameters, value: any) => void;
disabled?: boolean;
}
const AdvancedOCRSettings: React.FC<AdvancedOCRSettingsProps> = ({
advancedOptions,
ocrRenderType = 'hocr',
onParameterChange,
disabled = false
}) => {
const { t } = useTranslation();
// Define the advanced options available
const advancedOptionsData: AdvancedOption[] = [
{ value: 'compatibilityMode', label: t('ocr.settings.compatibilityMode.label', 'Compatibility Mode'), isSpecial: true },
{ value: 'sidecar', label: t('ocr.settings.advancedOptions.sidecar', 'Create a text file'), isSpecial: false },
{ value: 'deskew', label: t('ocr.settings.advancedOptions.deskew', 'Deskew pages'), isSpecial: false },
{ value: 'clean', label: t('ocr.settings.advancedOptions.clean', 'Clean input file'), isSpecial: false },
{ value: 'cleanFinal', label: t('ocr.settings.advancedOptions.cleanFinal', 'Clean final output'), isSpecial: false },
];
// Handle individual checkbox changes
const handleCheckboxChange = (optionValue: string, checked: boolean) => {
const option = advancedOptionsData.find(opt => opt.value === optionValue);
if (option?.isSpecial) {
// Handle special options (like compatibility mode) differently
if (optionValue === 'compatibilityMode') {
onParameterChange('ocrRenderType', checked ? 'sandwich' : 'hocr');
}
} else {
// Handle regular advanced options
const newOptions = checked
? [...advancedOptions, optionValue]
: advancedOptions.filter(option => option !== optionValue);
onParameterChange('additionalOptions', newOptions);
}
};
// Check if a special option is selected
const isSpecialOptionSelected = (optionValue: string) => {
if (optionValue === 'compatibilityMode') {
return ocrRenderType === 'sandwich';
}
return false;
};
return (
<Stack gap="md">
<div>
<Text size="sm" fw={500} mb="md">
{t('ocr.settings.advancedOptions.label', 'Processing Options')}
</Text>
<Stack gap="sm">
{advancedOptionsData.map((option) => (
<Checkbox
key={option.value}
checked={option.isSpecial ? isSpecialOptionSelected(option.value) : advancedOptions.includes(option.value)}
onChange={(event) => handleCheckboxChange(option.value, event.currentTarget.checked)}
label={option.label}
disabled={disabled}
size="sm"
/>
))}
</Stack>
</div>
</Stack>
);
};
export default AdvancedOCRSettings;
@@ -0,0 +1,126 @@
/* 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: var(--mantine-color-white); /* Use Mantine color variable */
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: var(--mantine-color-dark-6); /* Use Mantine dark color instead of hardcoded */
}
.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: var(--mantine-color-dark-5); /* Use Mantine color variable */
}
.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: var(--mantine-color-white); /* Use Mantine color variable */
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: var(--mantine-color-dark-6);
}
.languagePickerOption {
padding: 6px 10px;
cursor: pointer;
border-radius: var(--radius-xs);
font-size: 13px;
color: var(--text-primary);
transition: background-color 0.2s ease;
}
.languagePickerOptionWithCheckbox {
display: flex;
align-items: center;
justify-content: space-between;
}
.languagePickerCheckbox {
margin-left: auto;
}
.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: var(--mantine-color-dark-5);
}
/* Additional helper classes for the component */
.languagePickerTarget {
width: 100%;
}
.languagePickerContent {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.languagePickerText {
flex: 1;
text-align: left;
}
.languagePickerScrollArea {
max-height: 180px;
border-bottom: 1px solid var(--border-default);
padding-bottom: 8px;
}
.languagePickerFooter {
padding: 8px;
text-align: center;
font-size: 12px;
}
.languagePickerLink {
color: var(--mantine-color-blue-6);
text-decoration: underline;
cursor: pointer;
}
/* Dark mode link */
[data-mantine-color-scheme="dark"] .languagePickerLink {
color: var(--mantine-color-blue-4);
}
@@ -0,0 +1,151 @@
import React, { useState, useEffect } from 'react';
import { Text, Loader } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { tempOcrLanguages, getAutoOcrLanguage } from '../../../utils/languageMapping';
import DropdownListWithFooter, { DropdownItem } from '../../shared/DropdownListWithFooter';
export interface LanguageOption {
value: string;
label: string;
}
export interface LanguagePickerProps {
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
disabled?: boolean;
label?: string;
languagesEndpoint?: string;
autoFillFromBrowserLanguage?: boolean;
}
const LanguagePicker: React.FC<LanguagePickerProps> = ({
value,
onChange,
placeholder = 'Select languages',
disabled = false,
label,
languagesEndpoint = '/api/v1/ui-data/ocr-pdf',
autoFillFromBrowserLanguage = true,
}) => {
const { t, i18n } = useTranslation();
const [availableLanguages, setAvailableLanguages] = useState<DropdownItem[]>([]);
const [isLoadingLanguages, setIsLoadingLanguages] = useState(true);
const [hasAutoFilled, setHasAutoFilled] = useState(false);
useEffect(() => {
// Fetch available languages from backend
const fetchLanguages = async () => {
try {
const response = await fetch(languagesEndpoint);
if (response.ok) {
const data: { languages: string[] } = await response.json();
const languages = data.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;
return {
value: lang,
name: displayName
};
});
setAvailableLanguages(languageOptions);
} 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);
}
};
fetchLanguages();
}, [languagesEndpoint]);
// Auto-fill OCR language based on browser language when languages are loaded
useEffect(() => {
const shouldAutoFillLanguage = autoFillFromBrowserLanguage && !isLoadingLanguages && availableLanguages.length > 0 && !hasAutoFilled && value.length === 0;
if (shouldAutoFillLanguage) {
// Use the comprehensive language mapping from languageMapping.ts
const suggestedOcrLanguages = getAutoOcrLanguage(i18n.language);
if (suggestedOcrLanguages.length > 0) {
// Find the first suggested language that's available in the backend
const matchingLanguage = availableLanguages.find(lang =>
suggestedOcrLanguages.includes(lang.value)
);
if (matchingLanguage) {
onChange([matchingLanguage.value]);
}
}
setHasAutoFilled(true);
}
}, [autoFillFromBrowserLanguage, isLoadingLanguages, availableLanguages, hasAutoFilled, value.length, i18n.language, onChange]);
if (isLoadingLanguages) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Loader size="xs" />
<Text size="sm">Loading available languages...</Text>
</div>
);
}
const footer = (
<>
<div className="flex flex-col items-center gap-1 text-center">
<Text size="xs" c="dimmed" className="text-center">
{t('ocr.languagePicker.additionalLanguages', 'Looking for additional languages?')}
</Text>
<Text
size="xs"
style={{
color: '#3b82f6',
cursor: 'pointer',
textDecoration: 'underline',
textAlign: 'center'
}}
onClick={() => window.open('https://docs.stirlingpdf.com/Advanced%20Configuration/OCR', '_blank')}
>
{t('ocr.languagePicker.viewSetupGuide', 'View setup guide →')}
</Text>
</div>
</>
);
return (
<DropdownListWithFooter
value={value}
onChange={(newValue) => onChange(newValue as string[])}
items={availableLanguages}
placeholder={placeholder}
disabled={disabled}
label={label}
footer={footer}
multiSelect={true}
maxHeight={300}
searchable={true}
/>
);
};
export default LanguagePicker;
@@ -0,0 +1,54 @@
import React from 'react';
import { Stack, Select, Text, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LanguagePicker from './LanguagePicker';
export interface OCRParameters {
languages: string[];
ocrType: string;
ocrRenderType: string;
additionalOptions: string[];
}
interface OCRSettingsProps {
parameters: OCRParameters;
onParameterChange: (key: keyof OCRParameters, value: any) => void;
disabled?: boolean;
}
const OCRSettings: React.FC<OCRSettingsProps> = ({
parameters,
onParameterChange,
disabled = false
}) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Select
label={t('ocr.settings.ocrMode.label', 'OCR Mode')}
value={parameters.ocrType}
onChange={(value) => onParameterChange('ocrType', value || 'skip-text')}
data={[
{ value: 'skip-text', label: t('ocr.settings.ocrMode.auto', 'Auto (skip text layers)') },
{ value: 'force-ocr', label: t('ocr.settings.ocrMode.force', 'Force (re-OCR all, replace text)') },
{ value: 'Normal', label: t('ocr.settings.ocrMode.strict', 'Strict (abort if text found)') },
]}
disabled={disabled}
/>
<Divider />
<LanguagePicker
value={parameters.languages || []}
onChange={(value) => onParameterChange('languages', value)}
placeholder={t('ocr.settings.languages.placeholder', 'Select languages')}
disabled={disabled}
label={t('ocr.settings.languages.label', 'Languages')}
/>
</Stack>
);
};
export default OCRSettings;
@@ -1,5 +1,7 @@
import React, { createContext, useContext, useMemo, useRef } from 'react';
import { Paper, Text, Stack, Box } from '@mantine/core';
import { Paper, Text, Stack, Box, Flex } from '@mantine/core';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
interface ToolStepContextType {
visibleStepCount: number;
@@ -33,12 +35,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;
@@ -47,15 +50,45 @@ const ToolStep = ({
p="md"
withBorder
style={{
cursor: isCollapsed && onCollapsedClick ? 'pointer' : 'default',
opacity: isCollapsed ? 0.8 : 1,
transition: 'opacity 0.2s ease'
}}
onClick={isCollapsed && onCollapsedClick ? onCollapsedClick : undefined}
>
<Text fw={500} size="lg" mb="sm">
{shouldShowNumber ? `${stepNumber}. ` : ''}{title}
</Text>
{/* Chevron icon to collapse/expand the step */}
<Flex
align="center"
justify="space-between"
mb="sm"
style={{
cursor: onCollapsedClick ? 'pointer' : 'default'
}}
onClick={onCollapsedClick}
>
<Flex align="center" gap="sm">
{shouldShowNumber && (
<Text fw={500} size="lg" c="dimmed">
{stepNumber}
</Text>
)}
<Text fw={500} size="lg">
{title}
</Text>
</Flex>
{isCollapsed ? (
<ChevronRightIcon style={{
fontSize: '1.2rem',
color: 'var(--mantine-color-dimmed)',
opacity: onCollapsedClick ? 1 : 0.5
}} />
) : (
<ExpandMoreIcon style={{
fontSize: '1.2rem',
color: 'var(--mantine-color-dimmed)',
opacity: onCollapsedClick ? 1 : 0.5
}} />
)}
</Flex>
{isCollapsed ? (
<Box>
@@ -96,7 +129,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++;
}
});