pdflinux 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -0
- package/bin/pdflinux +31 -0
- package/index.html +13 -0
- package/install.sh +254 -0
- package/package.json +57 -0
- package/public/favicon.svg +1 -0
- package/public/icons.svg +24 -0
- package/src/App.tsx +68 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/components/Dropzone.tsx +90 -0
- package/src/components/FileItem.tsx +27 -0
- package/src/components/ProgressBar.tsx +18 -0
- package/src/components/Sidebar.tsx +92 -0
- package/src/hooks/useTheme.ts +23 -0
- package/src/index.css +675 -0
- package/src/main.tsx +10 -0
- package/src/pages/Dashboard.tsx +71 -0
- package/src/pages/ImageToPdf.tsx +100 -0
- package/src/pages/PdfCompress.tsx +175 -0
- package/src/pages/PdfCrop.tsx +112 -0
- package/src/pages/PdfDeletePages.tsx +151 -0
- package/src/pages/PdfGrayscale.tsx +100 -0
- package/src/pages/PdfInfo.tsx +71 -0
- package/src/pages/PdfMerge.tsx +135 -0
- package/src/pages/PdfOcr.tsx +136 -0
- package/src/pages/PdfPageNumbers.tsx +155 -0
- package/src/pages/PdfProtect.tsx +171 -0
- package/src/pages/PdfReorder.tsx +145 -0
- package/src/pages/PdfRotate.tsx +89 -0
- package/src/pages/PdfSplit.tsx +147 -0
- package/src/pages/PdfToImage.tsx +134 -0
- package/src/pages/PdfToText.tsx +107 -0
- package/src/pages/PdfUnlock.tsx +79 -0
- package/src/pages/PdfWatermark.tsx +77 -0
- package/src-tauri/Cargo.lock +5347 -0
- package/src-tauri/Cargo.toml +32 -0
- package/src-tauri/build.rs +3 -0
- package/src-tauri/capabilities/default.json +14 -0
- package/src-tauri/icons/128x128.png +0 -0
- package/src-tauri/icons/128x128@2x.png +0 -0
- package/src-tauri/icons/32x32.png +0 -0
- package/src-tauri/icons/Square107x107Logo.png +0 -0
- package/src-tauri/icons/Square142x142Logo.png +0 -0
- package/src-tauri/icons/Square150x150Logo.png +0 -0
- package/src-tauri/icons/Square284x284Logo.png +0 -0
- package/src-tauri/icons/Square30x30Logo.png +0 -0
- package/src-tauri/icons/Square310x310Logo.png +0 -0
- package/src-tauri/icons/Square44x44Logo.png +0 -0
- package/src-tauri/icons/Square71x71Logo.png +0 -0
- package/src-tauri/icons/Square89x89Logo.png +0 -0
- package/src-tauri/icons/StoreLogo.png +0 -0
- package/src-tauri/icons/icon.icns +0 -0
- package/src-tauri/icons/icon.ico +0 -0
- package/src-tauri/icons/icon.png +0 -0
- package/src-tauri/src/lib.rs +48 -0
- package/src-tauri/src/main.rs +6 -0
- package/src-tauri/src/pdf_engine.rs +1022 -0
- package/src-tauri/tauri.conf.json +48 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/uninstall.sh +65 -0
- package/vite.config.ts +7 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { invoke } from '@tauri-apps/api/core';
|
|
4
|
+
import { save } from '@tauri-apps/plugin-dialog';
|
|
5
|
+
import Dropzone, { type PdfFile } from '../components/Dropzone';
|
|
6
|
+
import FileItem from '../components/FileItem';
|
|
7
|
+
import ProgressBar from '../components/ProgressBar';
|
|
8
|
+
|
|
9
|
+
interface GrayscaleResult {
|
|
10
|
+
output_path: string;
|
|
11
|
+
output_size: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function PdfGrayscale() {
|
|
15
|
+
const navigate = useNavigate();
|
|
16
|
+
const [file, setFile] = useState<PdfFile | null>(null);
|
|
17
|
+
const [processing, setProcessing] = useState(false);
|
|
18
|
+
const [progress, setProgress] = useState(0);
|
|
19
|
+
const [result, setResult] = useState<GrayscaleResult | null>(null);
|
|
20
|
+
const [error, setError] = useState('');
|
|
21
|
+
|
|
22
|
+
const handleFiles = (files: PdfFile[]) => { setFile(files[0]); setResult(null); setError(''); };
|
|
23
|
+
const removeFile = () => { setFile(null); setResult(null); setError(''); };
|
|
24
|
+
|
|
25
|
+
const handleProcess = async () => {
|
|
26
|
+
if (!file) return;
|
|
27
|
+
setProcessing(true);
|
|
28
|
+
setProgress(20);
|
|
29
|
+
setError('');
|
|
30
|
+
setResult(null);
|
|
31
|
+
try {
|
|
32
|
+
const tempDir = await invoke<string>('get_temp_dir');
|
|
33
|
+
setProgress(50);
|
|
34
|
+
const res = await invoke<GrayscaleResult>('grayscale_pdf', {
|
|
35
|
+
inputPath: file.path,
|
|
36
|
+
outputPath: `${tempDir}/grayscale_${file.name}`,
|
|
37
|
+
});
|
|
38
|
+
setProgress(100);
|
|
39
|
+
setResult(res);
|
|
40
|
+
} catch (err: any) {
|
|
41
|
+
setError(typeof err === 'string' ? err : err.message || 'Terjadi kesalahan.');
|
|
42
|
+
} finally {
|
|
43
|
+
setProcessing(false);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const handleSave = async () => {
|
|
48
|
+
if (!result) return;
|
|
49
|
+
const savePath = await save({
|
|
50
|
+
defaultPath: `grayscale_${file?.name || 'output.pdf'}`,
|
|
51
|
+
filters: [{ name: 'PDF Files', extensions: ['pdf'] }],
|
|
52
|
+
});
|
|
53
|
+
if (savePath) await invoke('save_file_to', { source: result.output_path, destination: savePath });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const formatSize = (b: number) =>
|
|
57
|
+
b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(2) + ' MB';
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
<div className="page-header">
|
|
62
|
+
<button className="btn-back" onClick={() => navigate('/')}>← Kembali</button>
|
|
63
|
+
<h2>◑ Grayscale PDF</h2>
|
|
64
|
+
<p>Ubah PDF berwarna menjadi hitam-putih untuk menghemat tinta saat mencetak.</p>
|
|
65
|
+
</div>
|
|
66
|
+
<div className="page-body">
|
|
67
|
+
<div className="tool-page">
|
|
68
|
+
{!file && <Dropzone onFilesSelected={handleFiles} />}
|
|
69
|
+
{file && <div className="file-list"><FileItem file={file} onRemove={removeFile} /></div>}
|
|
70
|
+
|
|
71
|
+
{processing && <ProgressBar progress={progress} status="Mengonversi ke grayscale..." />}
|
|
72
|
+
|
|
73
|
+
{error && (
|
|
74
|
+
<div className="result-card animate-in" style={{ borderColor: 'rgba(225,112,85,0.3)' }}>
|
|
75
|
+
<h4>✕ Error</h4>
|
|
76
|
+
<p style={{ color: 'var(--danger)', fontSize: '14px' }}>{error}</p>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{result && (
|
|
81
|
+
<div className="result-card success animate-in">
|
|
82
|
+
<h4>✓ Konversi Berhasil!</h4>
|
|
83
|
+
<div className="result-stats">
|
|
84
|
+
<div className="stat-item">
|
|
85
|
+
<div className="stat-value">{formatSize(result.output_size)}</div>
|
|
86
|
+
<div className="stat-label">Ukuran Output</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
<button className="btn-primary" onClick={handleSave}>Simpan File</button>
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{file && !processing && !result && !error && (
|
|
94
|
+
<button className="btn-primary" onClick={handleProcess}>Konversi ke Grayscale</button>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { invoke } from '@tauri-apps/api/core';
|
|
4
|
+
import Dropzone, { type PdfFile } from '../components/Dropzone';
|
|
5
|
+
import FileItem from '../components/FileItem';
|
|
6
|
+
|
|
7
|
+
interface PdfMetadata {
|
|
8
|
+
title: string; author: string; pages: string; file_size: string;
|
|
9
|
+
pdf_version: string; creator: string; producer: string; page_size: string; encrypted: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function PdfInfo() {
|
|
13
|
+
const navigate = useNavigate();
|
|
14
|
+
const [file, setFile] = useState<PdfFile | null>(null);
|
|
15
|
+
const [metadata, setMetadata] = useState<PdfMetadata | null>(null);
|
|
16
|
+
const [loading, setLoading] = useState(false);
|
|
17
|
+
const [error, setError] = useState('');
|
|
18
|
+
|
|
19
|
+
const handleFiles = async (files: PdfFile[]) => {
|
|
20
|
+
const f = files[0];
|
|
21
|
+
setFile(f); setMetadata(null); setError(''); setLoading(true);
|
|
22
|
+
try {
|
|
23
|
+
const meta = await invoke<PdfMetadata>('get_pdf_metadata', { inputPath: f.path });
|
|
24
|
+
setMetadata(meta);
|
|
25
|
+
} catch (err: any) { setError(typeof err === 'string' ? err : err.message || 'Error'); }
|
|
26
|
+
finally { setLoading(false); }
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const removeFile = () => { setFile(null); setMetadata(null); setError(''); };
|
|
30
|
+
|
|
31
|
+
const fields = metadata ? [
|
|
32
|
+
['Judul', metadata.title],
|
|
33
|
+
['Penulis', metadata.author],
|
|
34
|
+
['Halaman', metadata.pages],
|
|
35
|
+
['Ukuran', metadata.file_size],
|
|
36
|
+
['Versi PDF', metadata.pdf_version],
|
|
37
|
+
['Pembuat', metadata.creator],
|
|
38
|
+
['Produser', metadata.producer],
|
|
39
|
+
['Ukuran Halaman', metadata.page_size],
|
|
40
|
+
['Terenkripsi', metadata.encrypted],
|
|
41
|
+
] : [];
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<div className="page-header">
|
|
46
|
+
<button className="btn-back" onClick={() => navigate('/')}>← Kembali</button>
|
|
47
|
+
<h2>ⓘ Info PDF</h2>
|
|
48
|
+
<p>Lihat metadata dan informasi detail dari file PDF.</p>
|
|
49
|
+
</div>
|
|
50
|
+
<div className="page-body">
|
|
51
|
+
<div className="tool-page">
|
|
52
|
+
{!file && <Dropzone onFilesSelected={handleFiles} />}
|
|
53
|
+
{file && <div className="file-list"><FileItem file={file} onRemove={removeFile} /></div>}
|
|
54
|
+
{loading && <p style={{ textAlign: 'center', color: 'var(--text-muted)', marginTop: '16px', fontSize: '13px' }}>Memuat metadata...</p>}
|
|
55
|
+
{error && <div className="result-card animate-in" style={{ borderColor: 'rgba(239,68,68,0.3)' }}><h4>✕ Error</h4><p style={{ color: 'var(--danger)', fontSize: '14px' }}>{error}</p></div>}
|
|
56
|
+
{metadata && (
|
|
57
|
+
<div className="options-panel animate-in" style={{ marginTop: '16px' }}>
|
|
58
|
+
<h4>ⓘ Metadata</h4>
|
|
59
|
+
{fields.map(([label, value]) => (
|
|
60
|
+
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid var(--border-color)', fontSize: '13px' }}>
|
|
61
|
+
<span style={{ color: 'var(--text-secondary)', fontWeight: 500 }}>{label}</span>
|
|
62
|
+
<span style={{ fontWeight: 600, maxWidth: '60%', textAlign: 'right', wordBreak: 'break-word' }}>{value || '-'}</span>
|
|
63
|
+
</div>
|
|
64
|
+
))}
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { invoke } from '@tauri-apps/api/core';
|
|
4
|
+
import { save } from '@tauri-apps/plugin-dialog';
|
|
5
|
+
import Dropzone, { type PdfFile } from '../components/Dropzone';
|
|
6
|
+
import FileItem from '../components/FileItem';
|
|
7
|
+
import ProgressBar from '../components/ProgressBar';
|
|
8
|
+
|
|
9
|
+
interface MergeResult {
|
|
10
|
+
file_count: number;
|
|
11
|
+
output_path: string;
|
|
12
|
+
output_size: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function PdfMerge() {
|
|
16
|
+
const navigate = useNavigate();
|
|
17
|
+
const [files, setFiles] = useState<PdfFile[]>([]);
|
|
18
|
+
const [processing, setProcessing] = useState(false);
|
|
19
|
+
const [progress, setProgress] = useState(0);
|
|
20
|
+
const [result, setResult] = useState<MergeResult | null>(null);
|
|
21
|
+
const [error, setError] = useState('');
|
|
22
|
+
|
|
23
|
+
const handleFiles = (newFiles: PdfFile[]) => {
|
|
24
|
+
setFiles((prev) => [...prev, ...newFiles]);
|
|
25
|
+
setResult(null);
|
|
26
|
+
setError('');
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const removeFile = (index: number) => {
|
|
30
|
+
setFiles((prev) => prev.filter((_, i) => i !== index));
|
|
31
|
+
setResult(null);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const handleMerge = async () => {
|
|
35
|
+
if (files.length < 2) return;
|
|
36
|
+
|
|
37
|
+
setProcessing(true);
|
|
38
|
+
setProgress(20);
|
|
39
|
+
setError('');
|
|
40
|
+
setResult(null);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const tempDir = await invoke<string>('get_temp_dir');
|
|
44
|
+
const outputPath = `${tempDir}/merged_output.pdf`;
|
|
45
|
+
|
|
46
|
+
setProgress(50);
|
|
47
|
+
|
|
48
|
+
const res = await invoke<MergeResult>('merge_pdf', {
|
|
49
|
+
inputPaths: files.map((f) => f.path),
|
|
50
|
+
outputPath: outputPath,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
setProgress(100);
|
|
54
|
+
setResult(res);
|
|
55
|
+
} catch (err: any) {
|
|
56
|
+
setError(typeof err === 'string' ? err : err.message || 'Terjadi kesalahan.');
|
|
57
|
+
} finally {
|
|
58
|
+
setProcessing(false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleSave = async () => {
|
|
63
|
+
if (!result) return;
|
|
64
|
+
try {
|
|
65
|
+
const savePath = await save({
|
|
66
|
+
defaultPath: 'merged_output.pdf',
|
|
67
|
+
filters: [{ name: 'PDF Files', extensions: ['pdf'] }],
|
|
68
|
+
});
|
|
69
|
+
if (savePath) {
|
|
70
|
+
await invoke('save_file_to', { source: result.output_path, destination: savePath });
|
|
71
|
+
}
|
|
72
|
+
} catch (err: any) {
|
|
73
|
+
setError(typeof err === 'string' ? err : err.message || 'Gagal menyimpan file.');
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const formatSize = (bytes: number) => {
|
|
78
|
+
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
79
|
+
return (bytes / 1048576).toFixed(2) + ' MB';
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
<div className="page-header">
|
|
85
|
+
<button className="btn-back" onClick={() => navigate('/')}>← Kembali ke Dashboard</button>
|
|
86
|
+
<h2>🔗 Gabung PDF</h2>
|
|
87
|
+
<p>Gabungkan beberapa file PDF menjadi satu dokumen.</p>
|
|
88
|
+
</div>
|
|
89
|
+
<div className="page-body">
|
|
90
|
+
<div className="tool-page">
|
|
91
|
+
<Dropzone onFilesSelected={handleFiles} multiple />
|
|
92
|
+
|
|
93
|
+
{files.length > 0 && (
|
|
94
|
+
<div className="file-list">
|
|
95
|
+
{files.map((file, i) => (
|
|
96
|
+
<FileItem key={`${file.name}-${i}`} file={file} onRemove={() => removeFile(i)} />
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
{processing && <ProgressBar progress={progress} status="Menggabungkan file dengan qpdf..." />}
|
|
102
|
+
|
|
103
|
+
{error && (
|
|
104
|
+
<div className="result-card animate-in" style={{ borderColor: 'rgba(225,112,85,0.3)' }}>
|
|
105
|
+
<h4>❌ Error</h4>
|
|
106
|
+
<p style={{ color: 'var(--danger)', fontSize: '14px' }}>{error}</p>
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{result && (
|
|
111
|
+
<div className="result-card success animate-in">
|
|
112
|
+
<h4>✅ Penggabungan Berhasil!</h4>
|
|
113
|
+
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginBottom: '16px' }}>
|
|
114
|
+
{result.file_count} file berhasil digabungkan ({formatSize(result.output_size)}).
|
|
115
|
+
</p>
|
|
116
|
+
<button className="btn-primary" onClick={handleSave}>💾 Simpan File</button>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{files.length >= 2 && !processing && !result && !error && (
|
|
121
|
+
<button className="btn-primary" onClick={handleMerge}>
|
|
122
|
+
🚀 Mulai Gabung ({files.length} file)
|
|
123
|
+
</button>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{files.length === 1 && !processing && (
|
|
127
|
+
<p style={{ textAlign: 'center', color: 'var(--text-muted)', marginTop: '16px', fontSize: '13px' }}>
|
|
128
|
+
Tambahkan minimal 2 file untuk menggabungkan.
|
|
129
|
+
</p>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { invoke } from '@tauri-apps/api/core';
|
|
4
|
+
import { save } from '@tauri-apps/plugin-dialog';
|
|
5
|
+
import Dropzone, { type PdfFile } from '../components/Dropzone';
|
|
6
|
+
import FileItem from '../components/FileItem';
|
|
7
|
+
import ProgressBar from '../components/ProgressBar';
|
|
8
|
+
|
|
9
|
+
interface OcrResult {
|
|
10
|
+
output_path: string;
|
|
11
|
+
output_size: number;
|
|
12
|
+
page_count: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Language = 'eng' | 'ind' | 'eng+ind';
|
|
16
|
+
|
|
17
|
+
const languages: { key: Language; label: string; desc: string }[] = [
|
|
18
|
+
{ key: 'eng', label: 'Inggris', desc: 'English (eng)' },
|
|
19
|
+
{ key: 'ind', label: 'Indonesia', desc: 'Bahasa Indonesia (ind)' },
|
|
20
|
+
{ key: 'eng+ind', label: 'Inggris + Indonesia', desc: 'Kedua bahasa' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export default function PdfOcr() {
|
|
24
|
+
const navigate = useNavigate();
|
|
25
|
+
const [file, setFile] = useState<PdfFile | null>(null);
|
|
26
|
+
const [language, setLanguage] = useState<Language>('ind');
|
|
27
|
+
const [processing, setProcessing] = useState(false);
|
|
28
|
+
const [progress, setProgress] = useState(0);
|
|
29
|
+
const [result, setResult] = useState<OcrResult | null>(null);
|
|
30
|
+
const [error, setError] = useState('');
|
|
31
|
+
|
|
32
|
+
const handleFiles = (files: PdfFile[]) => { setFile(files[0]); setResult(null); setError(''); };
|
|
33
|
+
const removeFile = () => { setFile(null); setResult(null); setError(''); };
|
|
34
|
+
|
|
35
|
+
const handleOcr = async () => {
|
|
36
|
+
if (!file) return;
|
|
37
|
+
setProcessing(true);
|
|
38
|
+
setProgress(10);
|
|
39
|
+
setError('');
|
|
40
|
+
setResult(null);
|
|
41
|
+
try {
|
|
42
|
+
const tempDir = await invoke<string>('get_temp_dir');
|
|
43
|
+
setProgress(20);
|
|
44
|
+
const res = await invoke<OcrResult>('ocr_pdf', {
|
|
45
|
+
inputPath: file.path,
|
|
46
|
+
outputPath: `${tempDir}/ocr_${file.name}`,
|
|
47
|
+
language,
|
|
48
|
+
});
|
|
49
|
+
setProgress(100);
|
|
50
|
+
setResult(res);
|
|
51
|
+
} catch (err: any) {
|
|
52
|
+
setError(typeof err === 'string' ? err : err.message || 'Terjadi kesalahan.');
|
|
53
|
+
} finally {
|
|
54
|
+
setProcessing(false);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleSave = async () => {
|
|
59
|
+
if (!result) return;
|
|
60
|
+
const savePath = await save({
|
|
61
|
+
defaultPath: `ocr_${file?.name || 'output.pdf'}`,
|
|
62
|
+
filters: [{ name: 'PDF Files', extensions: ['pdf'] }],
|
|
63
|
+
});
|
|
64
|
+
if (savePath) await invoke('save_file_to', { source: result.output_path, destination: savePath });
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
<div className="page-header">
|
|
70
|
+
<button className="btn-back" onClick={() => navigate('/')}>← Kembali</button>
|
|
71
|
+
<h2>◎ OCR — Scan ke Teks</h2>
|
|
72
|
+
<p>Ubah PDF hasil scan menjadi PDF yang teksnya bisa dicari dan disalin.</p>
|
|
73
|
+
</div>
|
|
74
|
+
<div className="page-body">
|
|
75
|
+
<div className="tool-page">
|
|
76
|
+
{!file && <Dropzone onFilesSelected={handleFiles} />}
|
|
77
|
+
{file && <div className="file-list"><FileItem file={file} onRemove={removeFile} /></div>}
|
|
78
|
+
|
|
79
|
+
{file && !result && !error && (
|
|
80
|
+
<div className="options-panel animate-in">
|
|
81
|
+
<h4>Bahasa Dokumen</h4>
|
|
82
|
+
<div className="quality-options">
|
|
83
|
+
{languages.map((l) => (
|
|
84
|
+
<button
|
|
85
|
+
key={l.key}
|
|
86
|
+
className={`quality-btn ${language === l.key ? 'selected' : ''}`}
|
|
87
|
+
onClick={() => setLanguage(l.key)}
|
|
88
|
+
>
|
|
89
|
+
<span className="quality-label">{l.label}</span>
|
|
90
|
+
<span className="quality-desc">{l.desc}</span>
|
|
91
|
+
</button>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
<p style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '12px' }}>
|
|
95
|
+
Membutuhkan tesseract-ocr terinstal. OCR pada PDF banyak halaman memerlukan waktu lebih lama.
|
|
96
|
+
</p>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{processing && (
|
|
101
|
+
<ProgressBar
|
|
102
|
+
progress={progress}
|
|
103
|
+
status="Menjalankan OCR... (mungkin memerlukan beberapa menit)"
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{error && (
|
|
108
|
+
<div className="result-card animate-in" style={{ borderColor: 'rgba(225,112,85,0.3)' }}>
|
|
109
|
+
<h4>✕ Error</h4>
|
|
110
|
+
<p style={{ color: 'var(--danger)', fontSize: '14px' }}>{error}</p>
|
|
111
|
+
{error.includes('tesseract') && (
|
|
112
|
+
<p style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '8px' }}>
|
|
113
|
+
Pastikan tesseract-ocr sudah terinstal dan language pack tersedia.
|
|
114
|
+
</p>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{result && (
|
|
120
|
+
<div className="result-card success animate-in">
|
|
121
|
+
<h4>✓ OCR Berhasil!</h4>
|
|
122
|
+
<p style={{ fontSize: '14px', color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
|
123
|
+
{result.page_count} halaman berhasil diproses. Teks kini bisa dicari di dalam PDF.
|
|
124
|
+
</p>
|
|
125
|
+
<button className="btn-primary" onClick={handleSave}>Simpan PDF Terindeks</button>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{file && !processing && !result && !error && (
|
|
130
|
+
<button className="btn-primary" onClick={handleOcr}>Mulai OCR</button>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { invoke } from '@tauri-apps/api/core';
|
|
4
|
+
import { save } from '@tauri-apps/plugin-dialog';
|
|
5
|
+
import Dropzone, { type PdfFile } from '../components/Dropzone';
|
|
6
|
+
import FileItem from '../components/FileItem';
|
|
7
|
+
import ProgressBar from '../components/ProgressBar';
|
|
8
|
+
|
|
9
|
+
interface PageNumberResult {
|
|
10
|
+
output_path: string;
|
|
11
|
+
output_size: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Position = 'bottom-center' | 'bottom-left' | 'bottom-right' | 'top-left' | 'top-center' | 'top-right';
|
|
15
|
+
|
|
16
|
+
const positions: { key: Position; label: string }[] = [
|
|
17
|
+
{ key: 'bottom-center', label: 'Bawah Tengah' },
|
|
18
|
+
{ key: 'bottom-left', label: 'Bawah Kiri' },
|
|
19
|
+
{ key: 'bottom-right', label: 'Bawah Kanan' },
|
|
20
|
+
{ key: 'top-left', label: 'Atas Kiri' },
|
|
21
|
+
{ key: 'top-center', label: 'Atas Tengah' },
|
|
22
|
+
{ key: 'top-right', label: 'Atas Kanan' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export default function PdfPageNumbers() {
|
|
26
|
+
const navigate = useNavigate();
|
|
27
|
+
const [file, setFile] = useState<PdfFile | null>(null);
|
|
28
|
+
const [position, setPosition] = useState<Position>('bottom-center');
|
|
29
|
+
const [fontSize, setFontSize] = useState(12);
|
|
30
|
+
const [startNumber, setStartNumber] = useState(1);
|
|
31
|
+
const [processing, setProcessing] = useState(false);
|
|
32
|
+
const [progress, setProgress] = useState(0);
|
|
33
|
+
const [result, setResult] = useState<PageNumberResult | null>(null);
|
|
34
|
+
const [error, setError] = useState('');
|
|
35
|
+
|
|
36
|
+
const handleFiles = (files: PdfFile[]) => { setFile(files[0]); setResult(null); setError(''); };
|
|
37
|
+
const removeFile = () => { setFile(null); setResult(null); setError(''); };
|
|
38
|
+
|
|
39
|
+
const handleProcess = async () => {
|
|
40
|
+
if (!file) return;
|
|
41
|
+
setProcessing(true);
|
|
42
|
+
setProgress(20);
|
|
43
|
+
setError('');
|
|
44
|
+
setResult(null);
|
|
45
|
+
try {
|
|
46
|
+
const tempDir = await invoke<string>('get_temp_dir');
|
|
47
|
+
setProgress(40);
|
|
48
|
+
const res = await invoke<PageNumberResult>('add_page_numbers', {
|
|
49
|
+
inputPath: file.path,
|
|
50
|
+
outputPath: `${tempDir}/numbered_${file.name}`,
|
|
51
|
+
position,
|
|
52
|
+
fontSize,
|
|
53
|
+
startNumber,
|
|
54
|
+
});
|
|
55
|
+
setProgress(100);
|
|
56
|
+
setResult(res);
|
|
57
|
+
} catch (err: any) {
|
|
58
|
+
setError(typeof err === 'string' ? err : err.message || 'Terjadi kesalahan.');
|
|
59
|
+
} finally {
|
|
60
|
+
setProcessing(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const handleSave = async () => {
|
|
65
|
+
if (!result) return;
|
|
66
|
+
const savePath = await save({
|
|
67
|
+
defaultPath: `numbered_${file?.name || 'output.pdf'}`,
|
|
68
|
+
filters: [{ name: 'PDF Files', extensions: ['pdf'] }],
|
|
69
|
+
});
|
|
70
|
+
if (savePath) await invoke('save_file_to', { source: result.output_path, destination: savePath });
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
<div className="page-header">
|
|
76
|
+
<button className="btn-back" onClick={() => navigate('/')}>← Kembali</button>
|
|
77
|
+
<h2># Nomor Halaman</h2>
|
|
78
|
+
<p>Tambahkan nomor halaman secara otomatis ke setiap halaman PDF.</p>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="page-body">
|
|
81
|
+
<div className="tool-page">
|
|
82
|
+
{!file && <Dropzone onFilesSelected={handleFiles} />}
|
|
83
|
+
{file && <div className="file-list"><FileItem file={file} onRemove={removeFile} /></div>}
|
|
84
|
+
|
|
85
|
+
{file && !result && !error && (
|
|
86
|
+
<div className="options-panel animate-in">
|
|
87
|
+
<h4>Pengaturan Nomor Halaman</h4>
|
|
88
|
+
|
|
89
|
+
<div className="input-group" style={{ marginBottom: '16px' }}>
|
|
90
|
+
<label>Posisi</label>
|
|
91
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '8px', marginTop: '8px' }}>
|
|
92
|
+
{positions.map((p) => (
|
|
93
|
+
<button
|
|
94
|
+
key={p.key}
|
|
95
|
+
className={`quality-btn ${position === p.key ? 'selected' : ''}`}
|
|
96
|
+
style={{ padding: '8px', fontSize: '12px' }}
|
|
97
|
+
onClick={() => setPosition(p.key)}
|
|
98
|
+
>
|
|
99
|
+
<span className="quality-label" style={{ fontSize: '12px' }}>{p.label}</span>
|
|
100
|
+
</button>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
|
106
|
+
<div className="input-group">
|
|
107
|
+
<label>Ukuran Font (pt)</label>
|
|
108
|
+
<input
|
|
109
|
+
type="number"
|
|
110
|
+
min={6}
|
|
111
|
+
max={36}
|
|
112
|
+
value={fontSize}
|
|
113
|
+
onChange={(e) => setFontSize(Math.max(6, Math.min(36, parseInt(e.target.value) || 12)))}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
<div className="input-group">
|
|
117
|
+
<label>Mulai dari Nomor</label>
|
|
118
|
+
<input
|
|
119
|
+
type="number"
|
|
120
|
+
min={1}
|
|
121
|
+
value={startNumber}
|
|
122
|
+
onChange={(e) => setStartNumber(Math.max(1, parseInt(e.target.value) || 1))}
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{processing && <ProgressBar progress={progress} status="Menambahkan nomor halaman..." />}
|
|
130
|
+
|
|
131
|
+
{error && (
|
|
132
|
+
<div className="result-card animate-in" style={{ borderColor: 'rgba(225,112,85,0.3)' }}>
|
|
133
|
+
<h4>✕ Error</h4>
|
|
134
|
+
<p style={{ color: 'var(--danger)', fontSize: '14px' }}>{error}</p>
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{result && (
|
|
139
|
+
<div className="result-card success animate-in">
|
|
140
|
+
<h4>✓ Berhasil!</h4>
|
|
141
|
+
<p style={{ fontSize: '14px', color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
|
142
|
+
Nomor halaman berhasil ditambahkan.
|
|
143
|
+
</p>
|
|
144
|
+
<button className="btn-primary" onClick={handleSave}>Simpan File</button>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{file && !processing && !result && !error && (
|
|
149
|
+
<button className="btn-primary" onClick={handleProcess}>Tambah Nomor Halaman</button>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</>
|
|
154
|
+
);
|
|
155
|
+
}
|