first commit
This commit is contained in:
63
.gitignore
vendored
Normal file
63
.gitignore
vendored
Normal 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
96
README.md
Normal 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 Vite’s 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
13
index.html
Normal 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
29
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/fonts/DejaVuSans.b64
Normal file
1
public/fonts/DejaVuSans.b64
Normal file
File diff suppressed because one or more lines are too long
1
public/fonts/DejaVuSans.ttf
Normal file
1
public/fonts/DejaVuSans.ttf
Normal file
@@ -0,0 +1 @@
|
|||||||
|
404: Not Found
|
||||||
1
public/fonts/Roboto-Regular-base64.txt
Normal file
1
public/fonts/Roboto-Regular-base64.txt
Normal file
File diff suppressed because one or more lines are too long
1
public/fonts/Roboto-Regular-clean.b64
Normal file
1
public/fonts/Roboto-Regular-clean.b64
Normal file
File diff suppressed because one or more lines are too long
1
public/fonts/Roboto-Regular.b64
Normal file
1
public/fonts/Roboto-Regular.b64
Normal file
File diff suppressed because one or more lines are too long
BIN
public/fonts/Roboto-Regular.ttf
Normal file
BIN
public/fonts/Roboto-Regular.ttf
Normal file
Binary file not shown.
1
public/tesseract/README.txt
Normal file
1
public/tesseract/README.txt
Normal 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.
|
||||||
BIN
public/tesseract/eng.traineddata.gz
Normal file
BIN
public/tesseract/eng.traineddata.gz
Normal file
Binary file not shown.
BIN
public/tesseract/tesseract-core-simd-lstm.wasm
Executable file
BIN
public/tesseract/tesseract-core-simd-lstm.wasm
Executable file
Binary file not shown.
282
public/tesseract/tesseract-core-simd-lstm.wasm.js
Normal file
282
public/tesseract/tesseract-core-simd-lstm.wasm.js
Normal file
File diff suppressed because one or more lines are too long
BIN
public/tesseract/tur.traineddata.gz
Normal file
BIN
public/tesseract/tur.traineddata.gz
Normal file
Binary file not shown.
3
public/tesseract/worker.min.js
vendored
Normal file
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
4
public/vite.svg
Normal 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
62
server/index.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
.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
16
server/package.json
Normal 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
72
src/App.jsx
Normal 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;
|
||||||
111
src/components/BulkCropStep.jsx
Normal file
111
src/components/BulkCropStep.jsx
Normal 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'ye geç
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BulkCropStep;
|
||||||
432
src/components/CropStep.jsx
Normal file
432
src/components/CropStep.jsx
Normal 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;
|
||||||
44
src/components/DownloadStep.jsx
Normal file
44
src/components/DownloadStep.jsx
Normal 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'i indir
|
||||||
|
</Button>
|
||||||
|
<Button variant="text" onClick={() => navigate('/')}
|
||||||
|
>
|
||||||
|
Baştan başla
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DownloadStep;
|
||||||
90
src/components/EpubStep.jsx
Normal file
90
src/components/EpubStep.jsx
Normal 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'i indir
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EpubStep;
|
||||||
239
src/components/OcrStep.jsx
Normal file
239
src/components/OcrStep.jsx
Normal 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;
|
||||||
124
src/components/UploadStep.jsx
Normal file
124
src/components/UploadStep.jsx
Normal 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
127
src/main.jsx
Normal 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
83
src/store/useAppStore.js
Normal 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
103
src/utils/cropUtils.js
Normal 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
46
src/utils/epubUtils.js
Normal 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
9
src/utils/fileUtils.js
Normal 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
6
src/utils/ocrUtils.js
Normal 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
24
vite.config.js
Normal 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']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user