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

63
.gitignore vendored Normal file
View File

@@ -0,0 +1,63 @@
# Node.js
node_modules/
.svelte-kit/
.serena/
.claude/
.vscode
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
package-lock.json
.pnpm-debug.log
# Build output
/build
/.svelte-kit
/dist
/public/build
/.output
# Environment files
.env
.env.*
!.env.example
# IDE / Editor
.vscode/
.idea/
*.swp
*.swo
*.sublime-project
*.sublime-workspace
# OS generated files
.DS_Store
Thumbs.db
# TypeScript
*.tsbuildinfo
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
pnpm-debug.log*
# Misc
coverage/
.cache/
.sass-cache/
.eslintcache
.stylelintcache
# SvelteKit specific
.vercel
.netlify
# Database files
*.db
db/*.db
yakit_takip.db

96
README.md Normal file
View File

@@ -0,0 +1,96 @@
# imgPub OCR to EPUB Technical Overview
This document describes how the imgPub application converts page photos into a final EPUB book. It covers the frontend wizard, the Node.js backend that performs EPUB generation, and the most important implementation details so new contributors can extend the project confidently.
## 1. Stack Summary
| Layer | Technology | Notes |
| --- | --- | --- |
| Frontend build | **Vite + React 18** | SPA wizard experience |
| UI | **Material UI v6** | Theme, Stepper, responsive grid |
| Routing | **react-router-dom v7** | Each wizard step is a route |
| State | **Zustand** | `useAppStore` centralises workflow data |
| Upload | **react-dropzone** | Drag & drop multiple images |
| Crop | **Custom overlay** | iOS-style handles, percentage-based selection |
| OCR | **tesseract.js 5** | Uses local worker/core/lang assets (eng + tur) |
| EPUB generation | **Node.js + express + epub-gen** | Backend service builds EPUB and streams back base64 |
## 2. Folder Layout Highlights
```
src/
components/ (UploadStep, CropStep, BulkCropStep, OcrStep, EpubStep, DownloadStep)
store/useAppStore.js (global state)
utils/
cropUtils.js (canvas cropping)
ocrUtils.js (date extraction, sorting)
epubUtils.js (API client for EPUB)
fileUtils.js (download helper)
server/
index.js (Express app with /generate-epub)
package.json (epub-gen, express, cors, uuid)
public/
tesseract/ (worker.min.js, tesseract-core-simd-lstm.wasm(.js), eng.traineddata, tur.traineddata)
```
## 3. Frontend Wizard Flow
1. **Upload** Drag/drop `.png/.jpg/.jpeg` files, previews stored with `URL.createObjectURL` (revoked during resets).
2. **Crop** Choose a reference image, adjust selection handles, optional numeric offsets. Crop config saved in store with relative ratios.
3. **Bulk Crop** Applies saved ratios to every upload via `<canvas>`, storing cropped blobs and URLs.
4. **OCR** Sequential Tesseract worker (`tur` language, fallback `eng`). Each cropped image is processed in upload order and the cleaned text is appended to a single in-memory string (with a single-space separator). Only that cumulative string is persisted in the store to keep CPU/RAM usage minimal.
5. **EPUB** After OCR, the frontend sends the full concatenated string to the backend, waits for the resulting EPUB blob, and stores it for download.
6. **Download** Displays the EPUB metadata and lets the user download or restart the process.
## 4. EPUB Backend Service
- Located in `/server`. Run with `cd server && npm install && npm run dev` (defaults to port **4000**).
- Exposes `POST /generate-epub` accepting `{ text, meta }` where `text` is the single, concatenated OCR output.
- Uses [`epub-gen`](https://www.npmjs.com/package/epub-gen) to build one chapter containing the entire text and writes it to a temporary file.
- Returns `{ filename, data }` where `data` is base64-encoded EPUB bytes. Frontend decodes to `Blob` and stores in Zustand (`generatedEpub`).
- CORS origin defaults to `http://localhost:5173` and can be overridden via `CLIENT_ORIGIN` env var.
## 5. Tesseract Assets
All heavy OCR assets are served locally to avoid CDN issues:
- `public/tesseract/worker.min.js`
- `public/tesseract/tesseract-core-simd-lstm.wasm(.js)`
- `public/tesseract/eng.traineddata`
- `public/tesseract/tur.traineddata`
The OCR step creates a single worker and reuses it for every cropped image to keep CPU usage predictable.
## 6. State Management & Cleanup
`useAppStore` tracks:
- `uploadedImages`, `cropConfig`, `croppedImages`, `ocrResults`
- `generatedEpub` (blob, URL, filename)
- `error`
`resetFromStep(step)` clears downstream data and revokes blob URLs (uploads, crops, EPUB) so memory usage stays bounded even after long sessions.
## 7. Running the Project
```bash
# Frontend
npm install
npm run dev
# Backend in another terminal
dcd server
npm install # already included, run once
npm run dev # starts on http://localhost:4000
```
Set `VITE_API_BASE_URL` in `.env` if the server runs on a different host/port.
`npm run build` still targets Vites static output (`dist/`). Chunk warnings are disabled by bumping `chunkSizeWarningLimit` (see `vite.config.js`).
## 8. Potential Enhancements
- Allow users to edit OCR text before sending it to the EPUB service.
- Add cover image generation per session.
- Persist workflow state (e.g. IndexedDB) so refreshes are less disruptive.
- Stream EPUB as soon as chapters are processed for better perceived speed.
The current setup keeps PDF logic out of the client entirely, ensuring consistent Turkish characters thanks to EPUB readers native font stacks or bundled fonts (if added later).

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="tr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>imgPub - PDF OCR Sihirbazı</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "imgpub",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"server": "npm run dev --prefix server"
},
"dependencies": {
"@emotion/react": "^11.13.5",
"@emotion/styled": "^11.13.5",
"@mui/icons-material": "^6.1.1",
"@mui/material": "^6.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.2.3",
"react-easy-crop": "^5.0.7",
"react-router-dom": "^7.0.2",
"tesseract.js": "^5.1.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.5",
"vite": "^5.4.10"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
404: Not Found

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -0,0 +1 @@
Türkçe OCR modeli gibi ek veri dosyalarını buraya ekleyebilirsin. Tesseract.js bu klasörü temel adres olarak kullanacak.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

3
public/tesseract/worker.min.js vendored Normal file

File diff suppressed because one or more lines are too long

4
public/vite.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg width="410" height="404" viewBox="0 0 410 404" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M399.641 59.5253L215.643 388.545C211.828 395.338 202.171 395.378 198.313 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8055C397.439 43.2894 403.768 52.1434 399.641 59.5253Z" fill="#6C9BCF"/>
<path d="M292.965 1.5744L156.789 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8574L144.395 174.33C144.198 177.678 147.258 180.284 150.51 179.472L188.42 170.115C191.999 169.213 195.172 172.396 194.296 175.981L183.674 219.684C182.772 223.389 186.242 226.57 189.844 225.494L212.947 218.573C216.55 217.497 220.02 220.681 219.118 224.386L201.398 296.757C200.278 301.333 206.498 304.064 208.869 299.92L210.5 297.122L323.556 73.631C325.246 70.24 322.282 66.2817 318.547 67.0105L280.629 74.4269C276.988 75.1393 273.957 71.792 275.047 68.2252L301.508 1.91018C302.599 -1.6566 299.567 -5.00385 295.926 -4.29149Z" fill="#A6C48A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

62
server/index.js Normal file
View File

@@ -0,0 +1,62 @@
import express from 'express';
import cors from 'cors';
import { tmpdir } from 'os';
import { join } from 'path';
import { promises as fs } from 'fs';
import { v4 as uuidV4 } from 'uuid';
import Epub from 'epub-gen';
const app = express();
const PORT = process.env.PORT || 4000;
const ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173';
app.use(cors({ origin: ORIGIN, credentials: true }));
app.use(express.json({ limit: '10mb' }));
const sanitizeHtml = (text = '') =>
text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/\n/g, '<br/>');
app.post('/generate-epub', async (req, res) => {
const { text, meta } = req.body || {};
if (!text || !text.trim()) {
return res.status(400).json({ message: 'text is required' });
}
const title = meta?.title || 'imgPub OCR Export';
const author = meta?.author || 'imgPub';
const filename = meta?.filename || `imgpub${Date.now()}.epub`;
const content = [
{
title,
data: `<div>${sanitizeHtml(text)}</div>`,
},
];
const outputPath = join(tmpdir(), `imgpub-${uuidV4()}.epub`);
try {
const epub = new Epub({ title, author, content }, outputPath);
await epub.promise;
const buffer = await fs.readFile(outputPath);
await fs.unlink(outputPath).catch(() => {});
res.json({ filename, data: buffer.toString('base64') });
} catch (error) {
console.error('EPUB generation failed:', error);
res.status(500).json({ message: 'EPUB generation failed' });
}
});
app.get('/', (_, res) => {
res.json({ status: 'ok' });
});
app.listen(PORT, () => {
console.log(`imgPub EPUB server listening on port ${PORT}`);
});

16
server/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "imgpub-server",
"version": "1.0.0",
"type": "module",
"description": "EPUB generation server for imgPub",
"main": "index.js",
"scripts": {
"dev": "node index.js"
},
"dependencies": {
"cors": "^2.8.5",
"epub-gen": "^0.1.0",
"express": "^4.19.2",
"uuid": "^9.0.1"
}
}

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();

24
vite.config.js Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
publicDir: 'public',
base: './', // Relatif path için
server: {
fs: {
allow: ['..'] // Dışarıdaki dosyalara erişim
}
},
build: {
assetsDir: 'assets',
chunkSizeWarningLimit: 2000,
rollupOptions: {
output: {
manualChunks: {
tesseract: ['tesseract.js']
}
}
}
}
});