feat: image to text

This commit is contained in:
Ibrahima G. Coulibaly
2025-04-03 19:55:29 +00:00
parent 5545f0f344
commit 3b37b67474
9 changed files with 317 additions and 36 deletions

43
.idea/workspace.xml generated
View File

@@ -4,17 +4,16 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="chore: uninstall @jspawn/ghostscript-wasm">
<change afterPath="$PROJECT_DIR$/src/pages/tools/pdf/protect-pdf/index.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/pdf/protect-pdf/meta.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/pdf/protect-pdf/service.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/pdf/protect-pdf/types.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/pdf/utils.ts" afterDir="false" />
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="feat: protect pdf">
<change afterPath="$PROJECT_DIR$/src/pages/tools/image/generic/image-to-text/index.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/image/generic/image-to-text/meta.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/image/generic/image-to-text/service.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/image/generic/image-to-text/types.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/lib/ghostscript/background-worker.js" beforeDir="false" afterPath="$PROJECT_DIR$/src/lib/ghostscript/background-worker.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/lib/ghostscript/worker-init.js" beforeDir="false" afterPath="$PROJECT_DIR$/src/lib/ghostscript/worker-init.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/pdf/compress-pdf/service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/pdf/compress-pdf/service.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/pdf/index.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/pdf/index.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/result/ToolTextResult.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/result/ToolTextResult.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/generic/index.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/image/generic/index.ts" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -416,15 +415,7 @@
<workItem from="1743458576265" duration="13083000" />
<workItem from="1743690613245" duration="77000" />
<workItem from="1743691250813" duration="1550000" />
<workItem from="1743699386059" duration="6244000" />
</task>
<task id="LOCAL-00140" summary="style: optimizations">
<option name="closed" value="true" />
<created>1740936527951</created>
<option name="number" value="00140" />
<option name="presentableId" value="LOCAL-00140" />
<option name="project" value="LOCAL" />
<updated>1740936527951</updated>
<workItem from="1743699386059" duration="8791000" />
</task>
<task id="LOCAL-00141" summary="feat: ToolContent.tsx">
<option name="closed" value="true" />
@@ -810,7 +801,15 @@
<option name="project" value="LOCAL" />
<updated>1743691471368</updated>
</task>
<option name="localTasksCounter" value="189" />
<task id="LOCAL-00189" summary="feat: protect pdf">
<option name="closed" value="true" />
<created>1743705749057</created>
<option name="number" value="00189" />
<option name="presentableId" value="LOCAL-00189" />
<option name="project" value="LOCAL" />
<updated>1743705749057</updated>
</task>
<option name="localTasksCounter" value="190" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -857,7 +856,6 @@
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
<option name="CHECK_NEW_TODO" value="false" />
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
<MESSAGE value="refactor: file inputs" />
<MESSAGE value="feat: background removal" />
<MESSAGE value="feat: split pdf" />
<MESSAGE value="fix: typo" />
@@ -882,7 +880,8 @@
<MESSAGE value="fix: vite worker format" />
<MESSAGE value="fix: tests" />
<MESSAGE value="chore: uninstall @jspawn/ghostscript-wasm" />
<option name="LAST_COMMIT_MESSAGE" value="chore: uninstall @jspawn/ghostscript-wasm" />
<MESSAGE value="feat: protect pdf" />
<option name="LAST_COMMIT_MESSAGE" value="feat: protect pdf" />
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />

67
package-lock.json generated
View File

@@ -40,6 +40,7 @@
"react-helmet": "^6.1.0",
"react-image-crop": "^11.0.7",
"react-router-dom": "^6.23.1",
"tesseract.js": "^6.0.0",
"type-fest": "^4.35.0",
"use-deep-compare-effect": "^1.8.1",
"yup": "^1.4.0"
@@ -6297,6 +6298,12 @@
"node": ">=0.10.0"
}
},
"node_modules/idb-keyval": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
"integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==",
"license": "Apache-2.0"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -6817,6 +6824,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -8102,6 +8115,15 @@
"protobufjs": "^7.2.4"
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -10091,6 +10113,36 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tesseract.js": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-6.0.0.tgz",
"integrity": "sha512-tqYCod1HwJzkeZw1l6XWx+ly2hhisGcBtak9MArhYwDAxL0NgeVhLJcUjqPxZMQtpgtVUzWcpZPryi+hnaQGVw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^6.0.0",
"wasm-feature-detect": "^1.2.11",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-6.0.0.tgz",
"integrity": "sha512-1Qncm/9oKM7xgrQXZXNB+NRh19qiXGhxlrR8EwFbK5SaUbPZnS5OMtP/ghtqfd23hsr1ZvZbZjeuAGcMxd/ooA==",
"license": "Apache-2.0"
},
"node_modules/tesseract.js/node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/text-extensions": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz",
@@ -10690,6 +10742,12 @@
"node": ">=12.0.0"
}
},
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@@ -11103,6 +11161,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/zod": {
"version": "3.24.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",

View File

@@ -57,6 +57,7 @@
"react-helmet": "^6.1.0",
"react-image-crop": "^11.0.7",
"react-router-dom": "^6.23.1",
"tesseract.js": "^6.0.0",
"type-fest": "^4.35.0",
"use-deep-compare-effect": "^1.8.1",
"yup": "^1.4.0"

View File

@@ -1,21 +1,24 @@
import { Box, TextField } from '@mui/material';
import { Box, CircularProgress, TextField, Typography } from '@mui/material';
import React, { useContext } from 'react';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import InputHeader from '../InputHeader';
import ResultFooter from './ResultFooter';
import { replaceSpecialCharacters } from '@utils/string';
import mime from 'mime';
import { globalInputHeight } from '../../config/uiConfig';
export default function ToolTextResult({
title = 'Result',
value,
extension = 'txt',
keepSpecialCharacters
keepSpecialCharacters,
loading
}: {
title?: string;
value: string;
extension?: string;
keepSpecialCharacters?: boolean;
loading?: boolean;
}) {
const { showSnackBar } = useContext(CustomSnackBarContext);
const handleCopy = () => {
@@ -46,18 +49,37 @@ export default function ToolTextResult({
return (
<Box>
<InputHeader title={title} />
<TextField
value={keepSpecialCharacters ? value : replaceSpecialCharacters(value)}
fullWidth
multiline
sx={{
'&.MuiTextField-root': {
backgroundColor: 'background.paper'
{loading ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: globalInputHeight
}}
>
<CircularProgress />
<Typography variant="body2" sx={{ mt: 2 }}>
Loading... This may take a moment.
</Typography>
</Box>
) : (
<TextField
value={
keepSpecialCharacters ? value : replaceSpecialCharacters(value)
}
}}
rows={10}
inputProps={{ 'data-testid': 'text-result' }}
/>
fullWidth
multiline
sx={{
'&.MuiTextField-root': {
backgroundColor: 'background.paper'
}
}}
rows={10}
inputProps={{ 'data-testid': 'text-result' }}
/>
)}
<ResultFooter handleCopy={handleCopy} handleDownload={handleDownload} />
</Box>
);

View File

@@ -0,0 +1,108 @@
import { Box } from '@mui/material';
import React, { useContext, useState } from 'react';
import * as Yup from 'yup';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolTextResult from '@components/result/ToolTextResult';
import { GetGroupsType } from '@components/options/ToolOptions';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import SelectWithDesc from '@components/options/SelectWithDesc';
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
import CircularProgress from '@mui/material/CircularProgress';
import { extractTextFromImage, getAvailableLanguages } from './service';
import { InitialValuesType } from './types';
import { CustomSnackBarContext } from '../../../../../contexts/CustomSnackBarContext';
const initialValues: InitialValuesType = {
language: 'eng',
detectParagraphs: true
};
const validationSchema = Yup.object({
language: Yup.string().required('Language is required')
});
export default function ImageToText({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<string>('');
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const { showSnackBar } = useContext(CustomSnackBarContext);
const compute = async (optionsValues: InitialValuesType, input: any) => {
if (!input) return;
setIsProcessing(true);
try {
const extractedText = await extractTextFromImage(input, optionsValues);
setResult(extractedText);
} catch (err: any) {
showSnackBar(
err.message || 'An error occurred while processing the image',
'error'
);
setResult('');
} finally {
setIsProcessing(false);
}
};
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => [
{
title: 'OCR Options',
component: (
<Box>
<SelectWithDesc
selected={values.language}
onChange={(val) => updateField('language', val)}
description={
'Select the primary language in the image for better accuracy'
}
options={getAvailableLanguages()}
/>
<CheckboxWithDesc
checked={values.detectParagraphs}
onChange={(value) => updateField('detectParagraphs', value)}
description={
'Attempt to preserve paragraph structure in the extracted text'
}
title={'Detect Paragraphs'}
/>
</Box>
)
}
];
return (
<ToolContent
title={title}
initialValues={initialValues}
getGroups={getGroups}
compute={compute}
input={input}
validationSchema={validationSchema}
inputComponent={
<ToolImageInput
value={input}
onChange={setInput}
accept={['image/jpeg', 'image/png']}
title={'Input Image'}
/>
}
resultComponent={
<ToolTextResult
title={'Extracted Text'}
value={result}
loading={isProcessing}
/>
}
toolInfo={{
title: 'Image to Text (OCR)',
description:
'This tool extracts text from images using Optical Character Recognition (OCR). Upload an image containing text, select the primary language, and get the extracted text. For best results, use clear images with good contrast.'
}}
/>
);
}

View File

@@ -0,0 +1,22 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Image to Text (OCR)',
path: 'image-to-text',
icon: 'mdi:text-recognition', // Iconify icon as a string
description:
'Extract text from images (JPG, PNG) using optical character recognition (OCR).',
shortDescription: 'Extract text from images using OCR.',
keywords: [
'ocr',
'optical character recognition',
'image to text',
'extract text',
'scan',
'tesseract',
'jpg',
'png'
],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,56 @@
import { createWorker } from 'tesseract.js';
import { InitialValuesType } from './types';
export const extractTextFromImage = async (
file: File,
options: InitialValuesType
): Promise<string> => {
try {
const { language, detectParagraphs } = options;
// Create a Tesseract worker
const worker = await createWorker(language);
// Convert file to URL
const imageUrl = URL.createObjectURL(file);
// Recognize text
const { data } = await worker.recognize(imageUrl);
// Clean up
URL.revokeObjectURL(imageUrl);
await worker.terminate();
// Process the result based on options
if (detectParagraphs) {
// Return text with paragraph structure preserved
return data.text;
} else {
// Return plain text with basic formatting
return data.text;
}
} catch (error) {
console.error('Error extracting text from image:', error);
throw new Error(
'Failed to extract text from image. Please try again with a clearer image.'
);
}
};
// Helper function to get available languages
export const getAvailableLanguages = (): { value: string; label: string }[] => {
return [
{ value: 'eng', label: 'English' },
{ value: 'fra', label: 'French' },
{ value: 'deu', label: 'German' },
{ value: 'spa', label: 'Spanish' },
{ value: 'ita', label: 'Italian' },
{ value: 'por', label: 'Portuguese' },
{ value: 'rus', label: 'Russian' },
{ value: 'jpn', label: 'Japanese' },
{ value: 'chi_sim', label: 'Chinese (Simplified)' },
{ value: 'chi_tra', label: 'Chinese (Traditional)' },
{ value: 'kor', label: 'Korean' },
{ value: 'ara', label: 'Arabic' }
];
};

View File

@@ -0,0 +1,4 @@
export type InitialValuesType = {
language: string;
detectParagraphs: boolean;
};

View File

@@ -5,6 +5,7 @@ import { tool as removeBackground } from './remove-background/meta';
import { tool as cropImage } from './crop/meta';
import { tool as changeOpacity } from './change-opacity/meta';
import { tool as createTransparent } from './create-transparent/meta';
import { tool as imageToText } from './image-to-text/meta';
export const imageGenericTools = [
resizeImage,
@@ -13,5 +14,6 @@ export const imageGenericTools = [
cropImage,
changeOpacity,
changeColors,
createTransparent
createTransparent,
imageToText
];