first commit

This commit is contained in:
2025-11-10 23:35:59 +03:00
commit 68165014ad
33 changed files with 2084 additions and 0 deletions

72
src/App.jsx Normal file
View File

@@ -0,0 +1,72 @@
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import {
Alert,
Box,
Container,
Paper,
Snackbar,
Step,
StepLabel,
Stepper,
Typography,
} from '@mui/material';
import { useMemo } from 'react';
import { useAppStore } from './store/useAppStore';
export const wizardSteps = [
{ label: 'Yükle', path: '/' },
{ label: 'Crop', path: '/crop' },
{ label: 'Toplu Crop', path: '/bulk-crop' },
{ label: 'OCR', path: '/ocr' },
{ label: 'EPUB Oluştur', path: '/epub' },
{ label: 'İndir', path: '/download' },
];
const App = () => {
const location = useLocation();
const navigate = useNavigate();
const error = useAppStore((state) => state.error);
const clearError = useAppStore((state) => state.clearError);
const handleSnackbarClose = (_, reason) => {
if (reason === 'clickaway') return;
clearError();
};
const activeStep = useMemo(() => {
const foundIndex = wizardSteps.findIndex((step) => step.path === location.pathname);
return foundIndex === -1 ? 0 : foundIndex;
}, [location.pathname]);
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box mb={4}>
<Typography variant="h4" gutterBottom sx={{ color: '#29615D', fontWeight: 700 }}>
imgPub EPUB Sihirbazı
</Typography>
<Typography color="text.secondary">
Görselleri sırayla işle, OCR ile metne dönüştür ve EPUB formatında indir.
</Typography>
</Box>
<Paper sx={{ p: { xs: 2, md: 4 }, mb: 4 }} elevation={0}>
<Stepper activeStep={activeStep} alternativeLabel>
{wizardSteps.map((step) => (
<Step key={step.path} onClick={() => navigate(step.path)} sx={{ cursor: 'pointer' }}>
<StepLabel>{step.label}</StepLabel>
</Step>
))}
</Stepper>
</Paper>
<Paper sx={{ p: { xs: 2, md: 4 }, minHeight: 400 }} elevation={0}>
<Outlet />
</Paper>
<Snackbar open={Boolean(error)} autoHideDuration={4000} onClose={handleSnackbarClose}>
<Alert onClose={handleSnackbarClose} severity="error" sx={{ width: '100%' }}>
{error}
</Alert>
</Snackbar>
</Container>
);
};
export default App;

View File

@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react';
import {
Alert,
Box,
Button,
LinearProgress,
Stack,
Typography,
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../store/useAppStore';
import { applyCropToImages } from '../utils/cropUtils';
const BulkCropStep = () => {
const navigate = useNavigate();
const uploadedImages = useAppStore((state) => state.uploadedImages);
const cropConfig = useAppStore((state) => state.cropConfig);
const setCroppedImages = useAppStore((state) => state.setCroppedImages);
const setError = useAppStore((state) => state.setError);
const croppedImages = useAppStore((state) => state.croppedImages);
const [processing, setProcessing] = useState(false);
useEffect(() => {
let cancelled = false;
const runCrop = async () => {
if (!uploadedImages.length || !cropConfig?.imageWidth) return;
setProcessing(true);
try {
const results = await applyCropToImages(uploadedImages, cropConfig);
if (!cancelled) {
setCroppedImages(results);
}
} catch (error) {
if (!cancelled) {
setError(error.message);
}
} finally {
if (!cancelled) {
setProcessing(false);
}
}
};
if (!croppedImages.length && uploadedImages.length && cropConfig?.imageWidth) {
runCrop();
}
return () => {
cancelled = true;
};
}, [cropConfig, croppedImages.length, setCroppedImages, setError, uploadedImages]);
if (!uploadedImages.length) {
return (
<Stack spacing={2}>
<Alert severity="info">Önce görselleri yükle ve crop alanını belirle.</Alert>
<Button variant="contained" onClick={() => navigate('/')}>
Başlangıca dön
</Button>
</Stack>
);
}
if (!cropConfig?.imageWidth) {
return (
<Stack spacing={2}>
<Alert severity="warning">Crop ayarını kaydetmeden bu adıma geçemezsin.</Alert>
<Button variant="contained" onClick={() => navigate('/crop')}>
Crop adımına dön
</Button>
</Stack>
);
}
return (
<Stack spacing={4}>
<Box textAlign="center">
<Typography variant="h5" gutterBottom>
Toplu crop işlemi
</Typography>
<Typography color="text.secondary">
Tüm görsellere referans crop ayarı uygulanıyor.
</Typography>
</Box>
<Box>
<LinearProgress
variant={processing ? 'indeterminate' : 'determinate'}
value={processing ? 0 : 100}
sx={{ height: 10, borderRadius: 5 }}
/>
<Typography mt={2} align="center">
{processing
? 'İşlem yapılıyor...'
: `${croppedImages.length} görsel hazır.`}
</Typography>
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between">
<Button variant="contained" onClick={() => navigate('/crop')}>
Geri dön
</Button>
<Button
variant="contained"
onClick={() => navigate('/ocr')}
disabled={!croppedImages.length || processing}
>
OCR&apos;ye geç
</Button>
</Stack>
</Stack>
);
};
export default BulkCropStep;

432
src/components/CropStep.jsx Normal file
View File

@@ -0,0 +1,432 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Box,
Button,
Grid,
Slider,
Stack,
TextField,
Typography,
} from '@mui/material';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../store/useAppStore';
const offsetFields = ['top', 'bottom', 'left', 'right'];
const MIN_SELECTION = 5;
const DEFAULT_SELECTION = { top: 10, left: 10, width: 80, height: 80 };
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const selectionFromConfig = (config) => {
if (!config?.imageWidth || !config?.imageHeight) return DEFAULT_SELECTION;
const fallback = {
left: (config.cropAreaX / config.imageWidth) * 100,
top: (config.cropAreaY / config.imageHeight) * 100,
width: (config.width / config.imageWidth) * 100,
height: (config.height / config.imageHeight) * 100,
};
return {
top: config.selection?.top ?? fallback.top,
left: config.selection?.left ?? fallback.left,
width: config.selection?.width ?? fallback.width,
height: config.selection?.height ?? fallback.height,
};
};
const CropStep = () => {
const navigate = useNavigate();
const uploadedImages = useAppStore((state) => state.uploadedImages);
const cropConfig = useAppStore((state) => state.cropConfig);
const updateCropConfig = useAppStore((state) => state.updateCropConfig);
const resetFromStep = useAppStore((state) => state.resetFromStep);
const [selectedImageId, setSelectedImageId] = useState(
cropConfig?.referenceImageId || uploadedImages[0]?.id || null,
);
const [offsetValues, setOffsetValues] = useState({
top: cropConfig?.top || 0,
bottom: cropConfig?.bottom || 0,
left: cropConfig?.left || 0,
right: cropConfig?.right || 0,
});
const [imageSize, setImageSize] = useState({
width: cropConfig?.imageWidth || 0,
height: cropConfig?.imageHeight || 0,
});
const [selection, setSelection] = useState(
cropConfig?.selection ? cropConfig.selection : DEFAULT_SELECTION,
);
const [previewScale, setPreviewScale] = useState(1);
const [saved, setSaved] = useState(false);
const containerRef = useRef(null);
const dragInfoRef = useRef(null);
const canProceed =
Boolean(cropConfig?.imageWidth) &&
cropConfig?.referenceImageId === selectedImageId;
const selectedImage = useMemo(
() => uploadedImages.find((img) => img.id === selectedImageId),
[selectedImageId, uploadedImages],
);
useEffect(() => {
if (!uploadedImages.length) return;
if (!selectedImage && uploadedImages[0]) {
setSelectedImageId(uploadedImages[0].id);
}
}, [selectedImage, uploadedImages]);
useEffect(() => {
if (!selectedImage) return;
const img = new Image();
img.onload = () => {
setImageSize({ width: img.width, height: img.height });
};
img.src = selectedImage.previewUrl;
}, [selectedImage]);
useEffect(() => {
if (
cropConfig?.referenceImageId === selectedImageId &&
cropConfig?.imageWidth
) {
setSelection(selectionFromConfig(cropConfig));
setOffsetValues({
top: cropConfig.top || 0,
bottom: cropConfig.bottom || 0,
left: cropConfig.left || 0,
right: cropConfig.right || 0,
});
setSaved(true);
} else {
setSelection(DEFAULT_SELECTION);
setSaved(false);
}
}, [cropConfig, selectedImageId]);
const applyMove = (base, deltaX, deltaY) => {
const maxLeft = 100 - base.width;
const maxTop = 100 - base.height;
return {
...base,
left: clamp(base.left + deltaX, 0, maxLeft),
top: clamp(base.top + deltaY, 0, maxTop),
};
};
const applyNorth = (base, deltaY) => {
const limit = base.top + base.height - MIN_SELECTION;
const newTop = clamp(base.top + deltaY, 0, limit);
const newHeight = clamp(base.height + (base.top - newTop), MIN_SELECTION, 100 - newTop);
return { ...base, top: newTop, height: newHeight };
};
const applySouth = (base, deltaY) => {
const newHeight = clamp(
base.height + deltaY,
MIN_SELECTION,
100 - base.top,
);
return { ...base, height: newHeight };
};
const applyWest = (base, deltaX) => {
const limit = base.left + base.width - MIN_SELECTION;
const newLeft = clamp(base.left + deltaX, 0, limit);
const newWidth = clamp(
base.width + (base.left - newLeft),
MIN_SELECTION,
100 - newLeft,
);
return { ...base, left: newLeft, width: newWidth };
};
const applyEast = (base, deltaX) => {
const newWidth = clamp(
base.width + deltaX,
MIN_SELECTION,
100 - base.left,
);
return { ...base, width: newWidth };
};
const resizeSelection = (base, type, deltaX, deltaY) => {
let draft = { ...base };
if (type.includes('n')) {
draft = applyNorth(draft, deltaY);
}
if (type.includes('s')) {
draft = applySouth(draft, deltaY);
}
if (type.includes('w')) {
draft = applyWest(draft, deltaX);
}
if (type.includes('e')) {
draft = applyEast(draft, deltaX);
}
return draft;
};
const handlePointerMove = useCallback(
(event) => {
const dragInfo = dragInfoRef.current;
if (!dragInfo || !containerRef.current) return;
const bounds = containerRef.current.getBoundingClientRect();
if (!bounds.width || !bounds.height) return;
const deltaX = ((event.clientX - dragInfo.startX) / bounds.width) * 100;
const deltaY = ((event.clientY - dragInfo.startY) / bounds.height) * 100;
let nextSelection = dragInfo.selection;
if (dragInfo.type === 'move') {
nextSelection = applyMove(dragInfo.selection, deltaX, deltaY);
} else {
nextSelection = resizeSelection(dragInfo.selection, dragInfo.type, deltaX, deltaY);
}
setSelection(nextSelection);
setSaved(false);
},
[],
);
const stopDragging = useCallback(() => {
dragInfoRef.current = null;
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', stopDragging);
}, [handlePointerMove]);
const startDrag = (type) => (event) => {
event.preventDefault();
event.stopPropagation();
if (!containerRef.current) return;
dragInfoRef.current = {
type,
startX: event.clientX,
startY: event.clientY,
selection,
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', stopDragging);
};
useEffect(
() => () => {
stopDragging();
},
[stopDragging],
);
const handleOffsetChange = (field) => (event) => {
const value = Number(event.target.value) || 0;
setOffsetValues((prev) => ({ ...prev, [field]: value }));
setSaved(false);
resetFromStep('crop');
};
const currentCropArea = useMemo(() => {
if (!imageSize.width || !imageSize.height) return null;
return {
width: (selection.width / 100) * imageSize.width,
height: (selection.height / 100) * imageSize.height,
x: (selection.left / 100) * imageSize.width,
y: (selection.top / 100) * imageSize.height,
};
}, [imageSize.height, imageSize.width, selection]);
const handleSaveCrop = () => {
if (!currentCropArea || !selectedImage) return;
const config = {
x: 0,
y: 0,
zoom: previewScale,
width: currentCropArea.width,
height: currentCropArea.height,
top: offsetValues.top,
bottom: offsetValues.bottom,
left: offsetValues.left,
right: offsetValues.right,
cropAreaX: currentCropArea.x,
cropAreaY: currentCropArea.y,
imageWidth: imageSize.width,
imageHeight: imageSize.height,
referenceImageId: selectedImage.id,
selection,
};
updateCropConfig(config);
resetFromStep('crop');
setSaved(true);
};
if (!uploadedImages.length) {
return (
<Stack spacing={2}>
<Alert severity="info">Önce görsel yüklemelisin.</Alert>
<Button variant="contained" onClick={() => navigate('/')}>
Yüklemeye dön
</Button>
</Stack>
);
}
const handleConfigs = [
{ key: 'n', type: 'n', style: { top: -6, left: '50%', transform: 'translate(-50%, -50%)', cursor: 'ns-resize' } },
{ key: 's', type: 's', style: { bottom: -6, left: '50%', transform: 'translate(-50%, 50%)', cursor: 'ns-resize' } },
{ key: 'w', type: 'w', style: { left: -6, top: '50%', transform: 'translate(-50%, -50%)', cursor: 'ew-resize' } },
{ key: 'e', type: 'e', style: { right: -6, top: '50%', transform: 'translate(50%, -50%)', cursor: 'ew-resize' } },
{ key: 'nw', type: 'nw', style: { top: -6, left: -6, cursor: 'nwse-resize' } },
{ key: 'ne', type: 'ne', style: { top: -6, right: -6, cursor: 'nesw-resize' } },
{ key: 'sw', type: 'sw', style: { bottom: -6, left: -6, cursor: 'nesw-resize' } },
{ key: 'se', type: 'se', style: { bottom: -6, right: -6, cursor: 'nwse-resize' } },
];
return (
<Stack spacing={4}>
<Typography variant="h6">Referans görseli seç</Typography>
<Grid container spacing={2}>
{uploadedImages.map((image) => (
<Grid item xs={6} sm={4} md={3} key={image.id}>
<Box
sx={{
border:
image.id === selectedImageId
? '3px solid #000'
: '2px solid rgba(0,0,0,0.1)',
borderRadius: 2,
overflow: 'hidden',
cursor: 'pointer',
}}
onClick={() => {
setSelectedImageId(image.id);
setSaved(false);
resetFromStep('crop');
}}
>
<img
src={image.previewUrl}
alt={image.filename}
style={{ width: '100%', display: 'block' }}
/>
</Box>
</Grid>
))}
</Grid>
{selectedImage && (
<Box sx={{ width: '100%', overflow: 'hidden', borderRadius: 2, backgroundColor: '#0000000a' }}>
<Box
ref={containerRef}
sx={{
position: 'relative',
width: '100%',
maxWidth: 410,
mx: 'auto',
transform: `scale(${previewScale})`,
transformOrigin: 'center top',
}}
>
<img
src={selectedImage.previewUrl}
alt={selectedImage.filename}
style={{ width: '100%', display: 'block', userSelect: 'none', pointerEvents: 'none' }}
/>
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
}}
>
<Box
role="presentation"
onPointerDown={startDrag('move')}
sx={{
position: 'absolute',
top: `${selection.top}%`,
left: `${selection.left}%`,
width: `${selection.width}%`,
height: `${selection.height}%`,
border: '3px solid #000',
boxShadow: '0 0 0 9999px rgba(0,0,0,0.45)',
cursor: 'move',
pointerEvents: 'auto',
}}
>
{handleConfigs.map((handle) => (
<Box
key={handle.key}
role="presentation"
onPointerDown={startDrag(handle.type)}
sx={{
position: 'absolute',
width: 14,
height: 14,
borderRadius: '50%',
backgroundColor: '#000',
border: '2px solid #fff',
...handle.style,
pointerEvents: 'auto',
}}
/>
))}
</Box>
</Box>
</Box>
</Box>
)}
<Box>
<Typography gutterBottom>Önizleme yakınlaştırması</Typography>
<Slider
min={1}
max={2}
step={0.05}
value={previewScale}
onChange={(_, value) => {
const newValue = Array.isArray(value) ? value[0] : value;
setPreviewScale(newValue);
}}
/>
</Box>
<Grid container spacing={2}>
{offsetFields.map((field) => (
<Grid key={field} item xs={6} md={3}>
<TextField
label={field.toUpperCase()}
type="number"
fullWidth
value={offsetValues[field]}
onChange={handleOffsetChange(field)}
/>
</Grid>
))}
</Grid>
{saved && (
<Stack direction="row" spacing={1} alignItems="center" color="success.main">
<CheckCircleIcon color="success" />
<Typography>Crop ayarı kaydedildi.</Typography>
</Stack>
)}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between">
<Button variant="contained" onClick={() => navigate('/')}>
Geri dön
</Button>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button variant="contained" onClick={handleSaveCrop} disabled={!currentCropArea}>
Bu crop ayarını tüm görsellere uygula
</Button>
<Button
variant="contained"
onClick={() => navigate('/bulk-crop')}
disabled={!canProceed}
>
Devam et
</Button>
</Stack>
</Stack>
</Stack>
);
};
export default CropStep;

View File

@@ -0,0 +1,44 @@
import { Alert, Box, Button, Stack, Typography } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../store/useAppStore';
import { downloadBlob } from '../utils/fileUtils';
const DownloadStep = () => {
const navigate = useNavigate();
const generatedEpub = useAppStore((state) => state.generatedEpub);
if (!generatedEpub) {
return (
<Stack spacing={2}>
<Alert severity="info">Önce EPUB dosyasını oluştur.</Alert>
<Button variant="contained" onClick={() => navigate('/epub')}>
EPUB adımına dön
</Button>
</Stack>
);
}
return (
<Stack spacing={4}>
<Box textAlign="center">
<Typography variant="h5">EPUB hazır</Typography>
<Typography color="text.secondary">
Tüm OCR metinleri tek bir EPUB dosyasında toplandı.
</Typography>
</Box>
<Button
variant="contained"
color="primary"
onClick={() => downloadBlob(generatedEpub.url, generatedEpub.filename)}
>
EPUB&apos;i indir
</Button>
<Button variant="text" onClick={() => navigate('/')}
>
Baştan başla
</Button>
</Stack>
);
};
export default DownloadStep;

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
import {
Alert,
Box,
Button,
LinearProgress,
Stack,
Typography,
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../store/useAppStore';
import { createEpubFromOcr } from '../utils/epubUtils';
const EpubStep = () => {
const navigate = useNavigate();
const ocrText = useAppStore((state) => state.ocrText);
const generatedEpub = useAppStore((state) => state.generatedEpub);
const setGeneratedEpub = useAppStore((state) => state.setGeneratedEpub);
const setError = useAppStore((state) => state.setError);
const [processing, setProcessing] = useState(false);
useEffect(() => {
let cancelled = false;
const run = async () => {
if (!ocrText?.trim() || generatedEpub) return;
setProcessing(true);
try {
const epub = await createEpubFromOcr(ocrText);
if (!cancelled) {
setGeneratedEpub(epub);
}
} catch (error) {
if (!cancelled) {
setError(error.message || 'EPUB oluşturulamadı.');
}
} finally {
if (!cancelled) {
setProcessing(false);
}
}
};
run();
return () => {
cancelled = true;
};
}, [generatedEpub, ocrText, setError, setGeneratedEpub]);
if (!ocrText?.trim()) {
return (
<Stack spacing={2}>
<Alert severity="info">Önce OCR adımını tamamla.</Alert>
<Button variant="contained" onClick={() => navigate('/ocr')}>
OCR adımına dön
</Button>
</Stack>
);
}
return (
<Stack spacing={4}>
<Box textAlign="center">
<Typography variant="h5">EPUB çıktısı</Typography>
<Typography color="text.secondary">
OCR sonucundaki tüm metinleri tek bir EPUB dosyasında topluyoruz.
</Typography>
</Box>
{processing && <LinearProgress />}
{generatedEpub && (
<Alert severity="success">
EPUB hazır: {generatedEpub.filename}
</Alert>
)}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between">
<Button variant="contained" onClick={() => navigate('/ocr')}>
Geri dön
</Button>
<Button
variant="contained"
disabled={!generatedEpub || processing}
onClick={() => navigate('/download')}
>
EPUB&apos;i indir
</Button>
</Stack>
</Stack>
);
};
export default EpubStep;

239
src/components/OcrStep.jsx Normal file
View File

@@ -0,0 +1,239 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Box,
Button,
LinearProgress,
Stack,
Typography,
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import Tesseract from 'tesseract.js';
import { useAppStore } from '../store/useAppStore';
import { correctTurkishCharacters } from '../utils/ocrUtils';
const OcrStep = () => {
const navigate = useNavigate();
const croppedImages = useAppStore((state) => state.croppedImages);
const ocrText = useAppStore((state) => state.ocrText);
const setOcrText = useAppStore((state) => state.setOcrText);
const setError = useAppStore((state) => state.setError);
const [status, setStatus] = useState('idle');
const [currentIndex, setCurrentIndex] = useState(0);
const [previewText, setPreviewText] = useState('');
const total = croppedImages.length;
const hasResults = useMemo(() => Boolean(ocrText?.length), [ocrText]);
const abortRef = useRef(false);
const assetBase = useMemo(() => {
const base = import.meta.env.BASE_URL ?? '/';
return base.endsWith('/') ? base.slice(0, -1) : base;
}, []);
const workerRef = useRef(null);
const [workerReady, setWorkerReady] = useState(false);
const previewRef = useRef(null);
const orderedImages = useMemo(
() => [...croppedImages].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
[croppedImages],
);
useEffect(() => {
if (!orderedImages.length) return undefined;
let cancelled = false;
const origin =
typeof window !== 'undefined' ? window.location.origin : '';
const prefix = `${origin}${assetBase}`;
const paths = {
workerPath: `${prefix}/tesseract/worker.min.js`,
corePath: `${prefix}/tesseract/tesseract-core-simd-lstm.wasm.js`,
langPath: `${prefix}/tesseract`,
};
const initWorker = async () => {
setWorkerReady(false);
try {
const worker = await Tesseract.createWorker(
'tur', // Dil doğrudan belirt
1, // OEM level (LSTM)
{
workerPath: paths.workerPath,
corePath: paths.corePath,
langPath: paths.langPath,
logger: m => console.log('Tesseract:', m), // Debug için log
},
);
// Türkçe karakter tanımını iyileştir
await worker.setParameters({
tessedit_char_whitelist: 'abcçdefgğhıijklmnoöprsştuüvyzâîûABCÇDEFGĞHIİJKLMNOÖPRSŞTUÜVYZÂÎÛ0123456789 .,;:!?\'"-_',
tessedit_pageseg_mode: '6', // Tek bir metin bloğu varsay
preserve_interword_spaces: '1',
});
if (cancelled) {
await worker.terminate();
return;
}
// Dil ve worker zaten createWorker sırasında yüklendi
console.log('Tesseract worker başarıyla oluşturuldu');
workerRef.current = worker;
setWorkerReady(true);
} catch (error) {
console.error('Tesseract başlatma hatası:', error);
let errorMessage;
if (error.message.includes('traineddata')) {
errorMessage = 'Tesseract dil dosyaları bulunamadı. Lütfen tarayıcı cache\'ini temizleyip sayfayı yenileyin.';
} else if (error.message.includes('TESSDATA_PREFIX')) {
errorMessage = 'Tesseract yapılandırma hatası: Lütfen sayfayı yenileyin.';
} else {
errorMessage = `Tesseract başlatılamadı: ${error.message}`;
}
setError(errorMessage);
setWorkerReady(false);
}
};
initWorker();
return () => {
cancelled = true;
if (workerRef.current) {
workerRef.current.terminate();
workerRef.current = null;
setWorkerReady(false);
}
};
}, [assetBase, orderedImages.length, setError]);
useEffect(() => {
setStatus('idle');
setCurrentIndex(0);
setPreviewText('');
setOcrText('');
}, [orderedImages, setOcrText]);
useEffect(() => {
if (previewRef.current) {
previewRef.current.scrollTop = previewRef.current.scrollHeight;
}
}, [previewText]);
useEffect(() => {
if (!total || status === 'done' || !workerReady) return;
abortRef.current = false;
const run = async () => {
setStatus('running');
setCurrentIndex(0);
const worker = workerRef.current;
if (!worker) return;
try {
let combinedText = '';
setOcrText('');
setPreviewText('');
for (let index = 0; index < orderedImages.length; index += 1) {
if (abortRef.current) break;
const image = orderedImages[index];
setCurrentIndex(index + 1);
// eslint-disable-next-line no-await-in-loop
const { data } = await worker.recognize(image.blob);
const correctedText = correctTurkishCharacters(data.text || '');
if (correctedText) {
combinedText = combinedText
? `${combinedText}\n\n${correctedText}`
: correctedText;
setPreviewText(combinedText);
}
}
if (!abortRef.current) {
setOcrText(combinedText);
setStatus('done');
}
} catch (error) {
if (!abortRef.current) {
setError(error.message);
setStatus('idle');
}
}
};
run();
return () => {
abortRef.current = true;
};
}, [orderedImages, setError, setOcrText, status, total, workerReady]);
if (!orderedImages.length) {
return (
<Stack spacing={2}>
<Alert severity="info">Önce görselleri cropla.</Alert>
<Button variant="contained" onClick={() => navigate('/bulk-crop')}>
Toplu Crop adımına dön
</Button>
</Stack>
);
}
const progressValue =
workerReady && total ? (currentIndex / total) * 100 : 0;
const progressVariant = workerReady ? 'determinate' : 'indeterminate';
const progressText = !workerReady
? 'OCR işçisi hazırlanıyor...'
: status === 'done'
? 'OCR işlemi tamamlandı.'
: `Şu an ${currentIndex}/${total} resim işleniyor`;
return (
<Stack spacing={4}>
<Box textAlign="center">
<Typography variant="h5">OCR işlemi</Typography>
<Typography color="text.secondary">
Tüm görseller sırayla işleniyor. Bu adım biraz sürebilir.
</Typography>
</Box>
<Box>
<LinearProgress
variant={progressVariant}
value={progressVariant === 'determinate' ? progressValue : undefined}
sx={{ height: 10, borderRadius: 5 }}
/>
<Typography mt={2} align="center">
{progressText}
</Typography>
</Box>
<Stack spacing={1}>
<Box sx={{ p: 2, borderRadius: 2, bgcolor: 'background.default' }}>
<Typography variant="subtitle1">Ön izleme</Typography>
<Box
ref={previewRef}
sx={{
mt: 1,
maxHeight: '8.5em',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
lineHeight: 1.5,
fontSize: '0.95rem',
color: 'text.secondary',
pr: 1,
}}
>
{previewText || 'Metin bekleniyor'}
</Box>
</Box>
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between">
<Button variant="contained" onClick={() => navigate('/bulk-crop')}>
Geri dön
</Button>
<Button
variant="contained"
onClick={() => navigate('/epub')}
disabled={!hasResults}
>
EPUB oluştur
</Button>
</Stack>
</Stack>
);
};
export default OcrStep;

View File

@@ -0,0 +1,124 @@
import { useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import {
Box,
Button,
Card,
CardActionArea,
CardContent,
CardMedia,
Grid,
Stack,
Typography,
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../store/useAppStore';
const dropzoneStyle = {
border: '2px dashed rgba(108, 155, 207, 0.7)',
borderRadius: 12,
padding: '32px',
textAlign: 'center',
backgroundColor: 'rgba(108, 155, 207, 0.08)',
cursor: 'pointer',
};
const UploadStep = () => {
const navigate = useNavigate();
const uploadedImages = useAppStore((state) => state.uploadedImages);
const setUploadedImages = useAppStore((state) => state.setUploadedImages);
const resetFromStep = useAppStore((state) => state.resetFromStep);
const onDrop = useCallback(
(acceptedFiles) => {
if (!acceptedFiles.length) return;
resetFromStep('upload');
const mapped = acceptedFiles.map((file, index) => ({
id: crypto.randomUUID(),
file,
previewUrl: URL.createObjectURL(file),
order: uploadedImages.length + index,
filename: file.name,
}));
setUploadedImages([...uploadedImages, ...mapped]);
},
[uploadedImages, resetFromStep, setUploadedImages],
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'image/png': ['.png'],
'image/jpeg': ['.jpg', '.jpeg'],
},
multiple: true,
});
return (
<Stack spacing={4}>
<Box {...getRootProps()} sx={dropzoneStyle}>
<input {...getInputProps()} />
<Typography variant="h5" gutterBottom>
Görselleri sürükleyip bırak veya tıkla
</Typography>
<Typography color="text.secondary" gutterBottom>
.png, .jpg, .jpeg formatlarında çoklu dosya yükleyebilirsin.
</Typography>
<Button variant="contained" color="primary">
Dosya seç
</Button>
{isDragActive && (
<Typography mt={2} fontWeight={600}>
Bırak ve yükleyelim!
</Typography>
)}
</Box>
<Box>
<Typography variant="h6" gutterBottom>
Yüklenen görseller ({uploadedImages.length})
</Typography>
{uploadedImages.length === 0 ? (
<Typography color="text.secondary">
Henüz görsel yüklenmedi.
</Typography>
) : (
<Grid container spacing={2}>
{uploadedImages.map((image) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={image.id}>
<Card>
<CardActionArea>
<CardMedia
component="img"
height="160"
image={image.previewUrl}
alt={image.filename}
/>
<CardContent>
<Typography variant="body2" noWrap>
{image.filename}
</Typography>
</CardContent>
</CardActionArea>
</Card>
</Grid>
))}
</Grid>
)}
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="flex-end">
<Button
variant="contained"
color="primary"
disabled={!uploadedImages.length}
onClick={() => navigate('/crop')}
>
Devam et
</Button>
</Stack>
</Stack>
);
};
export default UploadStep;

127
src/main.jsx Normal file
View File

@@ -0,0 +1,127 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { CssBaseline, ThemeProvider, createTheme } from '@mui/material';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import App, { wizardSteps } from './App';
import UploadStep from './components/UploadStep';
import CropStep from './components/CropStep';
import BulkCropStep from './components/BulkCropStep';
import OcrStep from './components/OcrStep';
import EpubStep from './components/EpubStep';
import DownloadStep from './components/DownloadStep';
const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#E7C179',
contrastText: '#30281B',
},
secondary: {
main: '#29615D',
},
background: {
default: '#F9F7F4',
paper: '#FAF8F6',
},
text: {
primary: '#30281B',
secondary: '#666057',
},
success: {
main: '#80A19F',
contrastText: '#30281B',
},
},
shape: {
borderRadius: 16,
},
components: {
MuiBox: {
styleOverrides: {
root: {
color: '#80A19F',
},
},
},
MuiButton: {
styleOverrides: {
root: {
fontWeight: 600,
borderRadius: 999,
textTransform: 'none',
boxShadow: '0 6px 20px rgba(231, 193, 121, 0.35)',
'&:hover': {
backgroundColor: '#d7b16a',
},
},
},
},
MuiPaper: {
styleOverrides: {
root: {
backgroundColor: '#FAF8F6',
boxShadow: '0 20px 45px rgba(0,0,0,0.08)',
borderRadius: 24,
},
},
},
MuiAlert: {
styleOverrides: {
root: {
backgroundColor: '#80A19F',
color: '#30281B',
borderRadius: 16,
},
},
},
MuiSvgIcon: {
styleOverrides: {
root: {
color: '#B5AD9A',
},
},
},
MuiStepIcon: {
styleOverrides: {
text: {
fill: '#F9F7F3',
fontWeight: 700,
},
root: {
color: '#B5AD9A',
'&.Mui-active': {
color: '#E7C179',
},
'&.Mui-completed': {
color: '#E7C179',
},
},
},
},
},
});
const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
{ index: true, element: <UploadStep /> },
{ path: wizardSteps[1].path, element: <CropStep /> },
{ path: wizardSteps[2].path, element: <BulkCropStep /> },
{ path: wizardSteps[3].path, element: <OcrStep /> },
{ path: wizardSteps[4].path, element: <EpubStep /> },
{ path: wizardSteps[5].path, element: <DownloadStep /> },
],
},
]);
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<RouterProvider router={router} />
</ThemeProvider>
</React.StrictMode>,
);

83
src/store/useAppStore.js Normal file
View File

@@ -0,0 +1,83 @@
import { create } from 'zustand';
const emptyCropConfig = {
x: 0,
y: 0,
zoom: 1,
width: 0,
height: 0,
top: 0,
bottom: 0,
left: 0,
right: 0,
cropAreaX: 0,
cropAreaY: 0,
imageWidth: 0,
imageHeight: 0,
referenceImageId: null,
};
export const useAppStore = create((set) => ({
uploadedImages: [],
cropConfig: emptyCropConfig,
croppedImages: [],
ocrText: '',
generatedEpub: null,
error: null,
setError: (message) => set({ error: message }),
clearError: () => set({ error: null }),
setUploadedImages: (images) => set({ uploadedImages: images }),
updateCropConfig: (config) => set({ cropConfig: { ...config } }),
setCroppedImages: (images) =>
set((state) => {
state.croppedImages.forEach((img) => {
if (img.url) URL.revokeObjectURL(img.url);
});
return { croppedImages: images };
}),
setOcrText: (text) => set({ ocrText: text }),
setGeneratedEpub: (epub) =>
set((state) => {
if (state.generatedEpub?.url) {
URL.revokeObjectURL(state.generatedEpub.url);
}
return { generatedEpub: epub };
}),
resetFromStep: (step) =>
set((state) => {
const draft = {};
if (step === 'upload') {
draft.cropConfig = emptyCropConfig;
state.croppedImages.forEach((img) => img.url && URL.revokeObjectURL(img.url));
draft.croppedImages = [];
draft.ocrText = '';
if (state.generatedEpub?.url) {
URL.revokeObjectURL(state.generatedEpub.url);
}
draft.generatedEpub = null;
}
if (step === 'crop') {
state.croppedImages.forEach((img) => img.url && URL.revokeObjectURL(img.url));
draft.croppedImages = [];
draft.ocrText = '';
if (state.generatedEpub?.url) {
URL.revokeObjectURL(state.generatedEpub.url);
}
draft.generatedEpub = null;
}
if (step === 'ocr') {
draft.ocrText = '';
if (state.generatedEpub?.url) {
URL.revokeObjectURL(state.generatedEpub.url);
}
draft.generatedEpub = null;
}
if (step === 'epub' || step === 'download') {
if (state.generatedEpub?.url) {
URL.revokeObjectURL(state.generatedEpub.url);
}
draft.generatedEpub = null;
}
return draft;
}),
}));

103
src/utils/cropUtils.js Normal file
View File

@@ -0,0 +1,103 @@
const loadImage = (file) =>
new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(image.src);
resolve(image);
};
image.onerror = (error) => {
URL.revokeObjectURL(image.src);
reject(error);
};
image.src = URL.createObjectURL(file);
});
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const normalizeCropConfig = (config) => {
if (!config?.imageWidth || !config?.imageHeight) {
throw new Error('Geçerli bir crop referansı bulunamadı.');
}
const safeWidth = Math.max(
1,
config.width - (config.left ?? 0) - (config.right ?? 0),
);
const safeHeight = Math.max(
1,
config.height - (config.top ?? 0) - (config.bottom ?? 0),
);
const xStart = Math.max(0, config.cropAreaX + (config.left ?? 0));
const yStart = Math.max(0, config.cropAreaY + (config.top ?? 0));
return {
xRatio: xStart / config.imageWidth,
yRatio: yStart / config.imageHeight,
widthRatio: safeWidth / config.imageWidth,
heightRatio: safeHeight / config.imageHeight,
};
};
const cropImage = async (file, normalizedConfig) => {
const image = await loadImage(file);
const { width: imgWidth, height: imgHeight } = image;
const cropWidth = clamp(
Math.round(normalizedConfig.widthRatio * imgWidth),
1,
imgWidth,
);
const cropHeight = clamp(
Math.round(normalizedConfig.heightRatio * imgHeight),
1,
imgHeight,
);
const startX = clamp(
Math.round(normalizedConfig.xRatio * imgWidth),
0,
imgWidth - cropWidth,
);
const startY = clamp(
Math.round(normalizedConfig.yRatio * imgHeight),
0,
imgHeight - cropHeight,
);
const canvas = document.createElement('canvas');
canvas.width = cropWidth;
canvas.height = cropHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, startX, startY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight);
const blob = await new Promise((resolve, reject) => {
canvas.toBlob((result) => {
if (result) {
resolve(result);
} else {
reject(new Error('Canvas blob oluşturulamadı.'));
}
}, 'image/png');
});
const url = URL.createObjectURL(blob);
return { blob, url };
};
export const applyCropToImages = async (images, config) => {
if (!images?.length) {
throw new Error('Önce görsel yüklemelisin.');
}
if (!config || !config.imageWidth) {
throw new Error('Crop ayarı bulunamadı.');
}
const normalized = normalizeCropConfig(config);
const results = [];
for (const image of images) {
const { blob, url } = await cropImage(image.file, normalized);
results.push({
id: image.id,
filename: image.file.name,
blob,
url,
order: image.order,
});
}
return results;
};

46
src/utils/epubUtils.js Normal file
View File

@@ -0,0 +1,46 @@
const base64ToBlob = (base64, mimeType) => {
const binary = atob(base64);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return new Blob([bytes], { type: mimeType });
};
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000';
export const createEpubFromOcr = async (text) => {
if (!text?.trim()) {
throw new Error('Önce OCR adımını tamamlamalısın.');
}
const response = await fetch(`${API_BASE}/generate-epub`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
meta: {
title: 'imgPub OCR Export',
author: 'imgPub',
filename: `imgpub${Date.now()}.epub`,
},
}),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || 'EPUB oluşturma isteği başarısız.');
}
const payload = await response.json();
const blob = base64ToBlob(payload.data, 'application/epub+zip');
const url = URL.createObjectURL(blob);
return {
filename: payload.filename,
blob,
url,
generatedAt: Date.now(),
};
};

9
src/utils/fileUtils.js Normal file
View File

@@ -0,0 +1,9 @@
export const downloadBlob = (blobUrl, filename) => {
const anchor = document.createElement('a');
anchor.href = blobUrl;
anchor.download = filename;
anchor.style.display = 'none';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
};

6
src/utils/ocrUtils.js Normal file
View File

@@ -0,0 +1,6 @@
export const correctTurkishCharacters = (text = '') =>
text
.replace(/İ/g, 'İ')
.replace(/i̇/g, 'i')
.replace(/\s+/g, ' ')
.trim();