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,171 @@
|
|
|
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 ProtectResult {
|
|
10
|
+
output_path: string;
|
|
11
|
+
output_size: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function PdfProtect() {
|
|
15
|
+
const navigate = useNavigate();
|
|
16
|
+
const [file, setFile] = useState<PdfFile | null>(null);
|
|
17
|
+
const [password, setPassword] = useState('');
|
|
18
|
+
const [confirmPassword, setConfirmPassword] = useState('');
|
|
19
|
+
const [processing, setProcessing] = useState(false);
|
|
20
|
+
const [progress, setProgress] = useState(0);
|
|
21
|
+
const [result, setResult] = useState<ProtectResult | null>(null);
|
|
22
|
+
const [error, setError] = useState('');
|
|
23
|
+
|
|
24
|
+
const handleFiles = (files: PdfFile[]) => {
|
|
25
|
+
setFile(files[0]);
|
|
26
|
+
setResult(null);
|
|
27
|
+
setError('');
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const removeFile = () => {
|
|
31
|
+
setFile(null);
|
|
32
|
+
setResult(null);
|
|
33
|
+
setPassword('');
|
|
34
|
+
setConfirmPassword('');
|
|
35
|
+
setError('');
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const handleProtect = async () => {
|
|
39
|
+
if (!file) return;
|
|
40
|
+
if (!password || password !== confirmPassword) {
|
|
41
|
+
setError('Password tidak cocok atau kosong.');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setProcessing(true);
|
|
46
|
+
setProgress(20);
|
|
47
|
+
setError('');
|
|
48
|
+
setResult(null);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const tempDir = await invoke<string>('get_temp_dir');
|
|
52
|
+
const outputPath = `${tempDir}/protected_${file.name}`;
|
|
53
|
+
|
|
54
|
+
setProgress(50);
|
|
55
|
+
|
|
56
|
+
const res = await invoke<ProtectResult>('protect_pdf', {
|
|
57
|
+
inputPath: file.path,
|
|
58
|
+
outputPath: outputPath,
|
|
59
|
+
password: password,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
setProgress(100);
|
|
63
|
+
setResult(res);
|
|
64
|
+
} catch (err: any) {
|
|
65
|
+
setError(typeof err === 'string' ? err : err.message || 'Terjadi kesalahan.');
|
|
66
|
+
} finally {
|
|
67
|
+
setProcessing(false);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleSave = async () => {
|
|
72
|
+
if (!result) return;
|
|
73
|
+
try {
|
|
74
|
+
const savePath = await save({
|
|
75
|
+
defaultPath: `protected_${file?.name || 'output.pdf'}`,
|
|
76
|
+
filters: [{ name: 'PDF Files', extensions: ['pdf'] }],
|
|
77
|
+
});
|
|
78
|
+
if (savePath) {
|
|
79
|
+
await invoke('save_file_to', { source: result.output_path, destination: savePath });
|
|
80
|
+
}
|
|
81
|
+
} catch (err: any) {
|
|
82
|
+
setError(typeof err === 'string' ? err : err.message || 'Gagal menyimpan file.');
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const formatSize = (bytes: number) => {
|
|
87
|
+
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
88
|
+
return (bytes / 1048576).toFixed(2) + ' MB';
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const passwordsMatch = password.length > 0 && password === confirmPassword;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<>
|
|
95
|
+
<div className="page-header">
|
|
96
|
+
<button className="btn-back" onClick={() => navigate('/')}>← Kembali ke Dashboard</button>
|
|
97
|
+
<h2>🔒 Proteksi PDF</h2>
|
|
98
|
+
<p>Tambahkan password untuk mengamankan dokumen PDF Anda dari akses tidak sah.</p>
|
|
99
|
+
</div>
|
|
100
|
+
<div className="page-body">
|
|
101
|
+
<div className="tool-page">
|
|
102
|
+
{!file && <Dropzone onFilesSelected={handleFiles} />}
|
|
103
|
+
|
|
104
|
+
{file && (
|
|
105
|
+
<div className="file-list">
|
|
106
|
+
<FileItem file={file} onRemove={removeFile} />
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{file && !result && !error && (
|
|
111
|
+
<div className="options-panel animate-in">
|
|
112
|
+
<h4>🔑 Atur Password</h4>
|
|
113
|
+
<div className="input-group">
|
|
114
|
+
<label>Password</label>
|
|
115
|
+
<input
|
|
116
|
+
type="password"
|
|
117
|
+
placeholder="Masukkan password"
|
|
118
|
+
value={password}
|
|
119
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="input-group">
|
|
123
|
+
<label>Konfirmasi Password</label>
|
|
124
|
+
<input
|
|
125
|
+
type="password"
|
|
126
|
+
placeholder="Ulangi password"
|
|
127
|
+
value={confirmPassword}
|
|
128
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
{confirmPassword.length > 0 && !passwordsMatch && (
|
|
132
|
+
<p style={{ color: 'var(--danger)', fontSize: '12px', marginTop: '4px' }}>
|
|
133
|
+
⚠️ Password tidak cocok.
|
|
134
|
+
</p>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{processing && <ProgressBar progress={progress} status="Mengenkripsi file dengan qpdf..." />}
|
|
140
|
+
|
|
141
|
+
{error && (
|
|
142
|
+
<div className="result-card animate-in" style={{ borderColor: 'rgba(225,112,85,0.3)' }}>
|
|
143
|
+
<h4>❌ Error</h4>
|
|
144
|
+
<p style={{ color: 'var(--danger)', fontSize: '14px' }}>{error}</p>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{result && (
|
|
149
|
+
<div className="result-card success animate-in">
|
|
150
|
+
<h4>✅ Proteksi Berhasil!</h4>
|
|
151
|
+
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginBottom: '16px' }}>
|
|
152
|
+
File PDF Anda kini dilindungi password ({formatSize(result.output_size)}).
|
|
153
|
+
</p>
|
|
154
|
+
<button className="btn-primary" onClick={handleSave}>💾 Simpan File Terproteksi</button>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{file && !processing && !result && !error && (
|
|
159
|
+
<button
|
|
160
|
+
className="btn-primary"
|
|
161
|
+
onClick={handleProtect}
|
|
162
|
+
disabled={!passwordsMatch}
|
|
163
|
+
>
|
|
164
|
+
🚀 Proteksi Sekarang
|
|
165
|
+
</button>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
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 ReorderResult {
|
|
10
|
+
output_path: string;
|
|
11
|
+
output_size: number;
|
|
12
|
+
page_count: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function PdfReorder() {
|
|
16
|
+
const navigate = useNavigate();
|
|
17
|
+
const [file, setFile] = useState<PdfFile | null>(null);
|
|
18
|
+
const [pageOrder, setPageOrder] = useState('');
|
|
19
|
+
const [totalPages, setTotalPages] = useState<number | null>(null);
|
|
20
|
+
const [processing, setProcessing] = useState(false);
|
|
21
|
+
const [progress, setProgress] = useState(0);
|
|
22
|
+
const [result, setResult] = useState<ReorderResult | null>(null);
|
|
23
|
+
const [error, setError] = useState('');
|
|
24
|
+
|
|
25
|
+
const handleFiles = async (files: PdfFile[]) => {
|
|
26
|
+
const f = files[0];
|
|
27
|
+
setFile(f);
|
|
28
|
+
setResult(null);
|
|
29
|
+
setError('');
|
|
30
|
+
try {
|
|
31
|
+
const meta = await invoke<{ pages: string }>('get_pdf_metadata', { inputPath: f.path });
|
|
32
|
+
const n = parseInt(meta.pages) || null;
|
|
33
|
+
setTotalPages(n);
|
|
34
|
+
if (n) setPageOrder(Array.from({ length: n }, (_, i) => i + 1).join(','));
|
|
35
|
+
} catch {
|
|
36
|
+
setTotalPages(null);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const removeFile = () => {
|
|
41
|
+
setFile(null);
|
|
42
|
+
setResult(null);
|
|
43
|
+
setPageOrder('');
|
|
44
|
+
setTotalPages(null);
|
|
45
|
+
setError('');
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const handleReorder = async () => {
|
|
49
|
+
if (!file || !pageOrder.trim()) return;
|
|
50
|
+
setProcessing(true);
|
|
51
|
+
setProgress(20);
|
|
52
|
+
setError('');
|
|
53
|
+
setResult(null);
|
|
54
|
+
try {
|
|
55
|
+
const tempDir = await invoke<string>('get_temp_dir');
|
|
56
|
+
setProgress(50);
|
|
57
|
+
const res = await invoke<ReorderResult>('reorder_pages', {
|
|
58
|
+
inputPath: file.path,
|
|
59
|
+
outputPath: `${tempDir}/reordered_${file.name}`,
|
|
60
|
+
pageOrder: pageOrder.trim(),
|
|
61
|
+
});
|
|
62
|
+
setProgress(100);
|
|
63
|
+
setResult(res);
|
|
64
|
+
} catch (err: any) {
|
|
65
|
+
setError(typeof err === 'string' ? err : err.message || 'Terjadi kesalahan.');
|
|
66
|
+
} finally {
|
|
67
|
+
setProcessing(false);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleSave = async () => {
|
|
72
|
+
if (!result) return;
|
|
73
|
+
const savePath = await save({
|
|
74
|
+
defaultPath: `reordered_${file?.name || 'output.pdf'}`,
|
|
75
|
+
filters: [{ name: 'PDF Files', extensions: ['pdf'] }],
|
|
76
|
+
});
|
|
77
|
+
if (savePath) {
|
|
78
|
+
await invoke('save_file_to', { source: result.output_path, destination: savePath });
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
<div className="page-header">
|
|
85
|
+
<button className="btn-back" onClick={() => navigate('/')}>← Kembali</button>
|
|
86
|
+
<h2>⇅ Susun Ulang Halaman</h2>
|
|
87
|
+
<p>Ubah urutan halaman PDF sesuai keinginan.</p>
|
|
88
|
+
</div>
|
|
89
|
+
<div className="page-body">
|
|
90
|
+
<div className="tool-page">
|
|
91
|
+
{!file && <Dropzone onFilesSelected={handleFiles} />}
|
|
92
|
+
{file && <div className="file-list"><FileItem file={file} onRemove={removeFile} /></div>}
|
|
93
|
+
|
|
94
|
+
{file && !result && !error && (
|
|
95
|
+
<div className="options-panel animate-in">
|
|
96
|
+
<h4>Urutan Halaman Baru</h4>
|
|
97
|
+
{totalPages && (
|
|
98
|
+
<p style={{ fontSize: '13px', color: 'var(--text-muted)', marginBottom: '8px' }}>
|
|
99
|
+
Dokumen ini memiliki <strong>{totalPages}</strong> halaman.
|
|
100
|
+
</p>
|
|
101
|
+
)}
|
|
102
|
+
<div className="input-group">
|
|
103
|
+
<label>Masukkan nomor halaman sesuai urutan yang diinginkan</label>
|
|
104
|
+
<input
|
|
105
|
+
type="text"
|
|
106
|
+
placeholder="Contoh: 3,1,2,4 atau 4-1 (terbalik)"
|
|
107
|
+
value={pageOrder}
|
|
108
|
+
onChange={(e) => setPageOrder(e.target.value)}
|
|
109
|
+
/>
|
|
110
|
+
<p className="page-range-hint">
|
|
111
|
+
Pisahkan dengan koma. Halaman bisa diulang. Contoh "2,2,1,3" akan menduplikasi halaman 2.
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{processing && <ProgressBar progress={progress} status="Menyusun ulang halaman..." />}
|
|
118
|
+
|
|
119
|
+
{error && (
|
|
120
|
+
<div className="result-card animate-in" style={{ borderColor: 'rgba(225,112,85,0.3)' }}>
|
|
121
|
+
<h4>✕ Error</h4>
|
|
122
|
+
<p style={{ color: 'var(--danger)', fontSize: '14px' }}>{error}</p>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{result && (
|
|
127
|
+
<div className="result-card success animate-in">
|
|
128
|
+
<h4>✓ Berhasil!</h4>
|
|
129
|
+
<p style={{ fontSize: '14px', color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
|
130
|
+
{result.page_count} halaman telah disusun ulang.
|
|
131
|
+
</p>
|
|
132
|
+
<button className="btn-primary" onClick={handleSave}>Simpan File</button>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{file && !processing && !result && !error && (
|
|
137
|
+
<button className="btn-primary" onClick={handleReorder} disabled={!pageOrder.trim()}>
|
|
138
|
+
Susun Ulang
|
|
139
|
+
</button>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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 RotateResult {
|
|
10
|
+
output_path: string;
|
|
11
|
+
output_size: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const angles = [
|
|
15
|
+
{ value: '90', label: '90°', desc: 'Putar ke kanan' },
|
|
16
|
+
{ value: '180', label: '180°', desc: 'Balik terbalik' },
|
|
17
|
+
{ value: '270', label: '270°', desc: 'Putar ke kiri' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export default function PdfRotate() {
|
|
21
|
+
const navigate = useNavigate();
|
|
22
|
+
const [file, setFile] = useState<PdfFile | null>(null);
|
|
23
|
+
const [angle, setAngle] = useState('90');
|
|
24
|
+
const [processing, setProcessing] = useState(false);
|
|
25
|
+
const [progress, setProgress] = useState(0);
|
|
26
|
+
const [result, setResult] = useState<RotateResult | null>(null);
|
|
27
|
+
const [error, setError] = useState('');
|
|
28
|
+
|
|
29
|
+
const handleFiles = (files: PdfFile[]) => { setFile(files[0]); setResult(null); setError(''); };
|
|
30
|
+
const removeFile = () => { setFile(null); setResult(null); setError(''); };
|
|
31
|
+
|
|
32
|
+
const handleRotate = async () => {
|
|
33
|
+
if (!file) return;
|
|
34
|
+
setProcessing(true); setProgress(20); setError(''); setResult(null);
|
|
35
|
+
try {
|
|
36
|
+
const tempDir = await invoke<string>('get_temp_dir');
|
|
37
|
+
setProgress(50);
|
|
38
|
+
const res = await invoke<RotateResult>('rotate_pdf', {
|
|
39
|
+
inputPath: file.path, outputPath: `${tempDir}/rotated_${file.name}`, angle, pages: 'all',
|
|
40
|
+
});
|
|
41
|
+
setProgress(100); setResult(res);
|
|
42
|
+
} catch (err: any) { setError(typeof err === 'string' ? err : err.message || 'Error'); }
|
|
43
|
+
finally { setProcessing(false); }
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleSave = async () => {
|
|
47
|
+
if (!result) return;
|
|
48
|
+
const savePath = await save({ defaultPath: `rotated_${file?.name}`, filters: [{ name: 'PDF', extensions: ['pdf'] }] });
|
|
49
|
+
if (savePath) await invoke('save_file_to', { source: result.output_path, destination: savePath });
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
<div className="page-header">
|
|
55
|
+
<button className="btn-back" onClick={() => navigate('/')}>← Kembali</button>
|
|
56
|
+
<h2>↻ Rotasi PDF</h2>
|
|
57
|
+
<p>Putar semua halaman PDF ke sudut yang diinginkan.</p>
|
|
58
|
+
</div>
|
|
59
|
+
<div className="page-body">
|
|
60
|
+
<div className="tool-page">
|
|
61
|
+
{!file && <Dropzone onFilesSelected={handleFiles} />}
|
|
62
|
+
{file && <div className="file-list"><FileItem file={file} onRemove={removeFile} /></div>}
|
|
63
|
+
{file && !result && !error && (
|
|
64
|
+
<div className="options-panel animate-in">
|
|
65
|
+
<h4>⚙ Sudut Rotasi</h4>
|
|
66
|
+
<div className="quality-options">
|
|
67
|
+
{angles.map((a) => (
|
|
68
|
+
<button key={a.value} className={`quality-btn ${angle === a.value ? 'selected' : ''}`} onClick={() => setAngle(a.value)}>
|
|
69
|
+
<span className="quality-label">{a.label}</span>
|
|
70
|
+
<span className="quality-desc">{a.desc}</span>
|
|
71
|
+
</button>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
{processing && <ProgressBar progress={progress} status="Memutar halaman..." />}
|
|
77
|
+
{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>}
|
|
78
|
+
{result && (
|
|
79
|
+
<div className="result-card success animate-in">
|
|
80
|
+
<h4>✓ Rotasi Berhasil!</h4>
|
|
81
|
+
<button className="btn-primary" onClick={handleSave}>Simpan File</button>
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
{file && !processing && !result && !error && <button className="btn-primary" onClick={handleRotate}>Mulai Rotasi</button>}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
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 SplitResult {
|
|
10
|
+
output_path: string;
|
|
11
|
+
page_count: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function PdfSplit() {
|
|
15
|
+
const navigate = useNavigate();
|
|
16
|
+
const [file, setFile] = useState<PdfFile | null>(null);
|
|
17
|
+
const [pageRange, setPageRange] = useState('');
|
|
18
|
+
const [processing, setProcessing] = useState(false);
|
|
19
|
+
const [progress, setProgress] = useState(0);
|
|
20
|
+
const [result, setResult] = useState<SplitResult | null>(null);
|
|
21
|
+
const [error, setError] = useState('');
|
|
22
|
+
|
|
23
|
+
const handleFiles = (files: PdfFile[]) => {
|
|
24
|
+
setFile(files[0]);
|
|
25
|
+
setResult(null);
|
|
26
|
+
setError('');
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const removeFile = () => {
|
|
30
|
+
setFile(null);
|
|
31
|
+
setResult(null);
|
|
32
|
+
setPageRange('');
|
|
33
|
+
setError('');
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const handleSplit = async () => {
|
|
37
|
+
if (!file || !pageRange.trim()) return;
|
|
38
|
+
|
|
39
|
+
setProcessing(true);
|
|
40
|
+
setProgress(20);
|
|
41
|
+
setError('');
|
|
42
|
+
setResult(null);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const tempDir = await invoke<string>('get_temp_dir');
|
|
46
|
+
const outputPath = `${tempDir}/split_${file.name}`;
|
|
47
|
+
|
|
48
|
+
setProgress(50);
|
|
49
|
+
|
|
50
|
+
const res = await invoke<SplitResult>('split_pdf', {
|
|
51
|
+
inputPath: file.path,
|
|
52
|
+
outputPath: outputPath,
|
|
53
|
+
pageRange: pageRange.trim(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
setProgress(100);
|
|
57
|
+
setResult(res);
|
|
58
|
+
} catch (err: any) {
|
|
59
|
+
setError(typeof err === 'string' ? err : err.message || 'Terjadi kesalahan.');
|
|
60
|
+
} finally {
|
|
61
|
+
setProcessing(false);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const handleSave = async () => {
|
|
66
|
+
if (!result) return;
|
|
67
|
+
try {
|
|
68
|
+
const savePath = await save({
|
|
69
|
+
defaultPath: `split_${file?.name || 'output.pdf'}`,
|
|
70
|
+
filters: [{ name: 'PDF Files', extensions: ['pdf'] }],
|
|
71
|
+
});
|
|
72
|
+
if (savePath) {
|
|
73
|
+
await invoke('save_file_to', { source: result.output_path, destination: savePath });
|
|
74
|
+
}
|
|
75
|
+
} catch (err: any) {
|
|
76
|
+
setError(typeof err === 'string' ? err : err.message || 'Gagal menyimpan file.');
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
<div className="page-header">
|
|
83
|
+
<button className="btn-back" onClick={() => navigate('/')}>← Kembali ke Dashboard</button>
|
|
84
|
+
<h2>✂️ Pisah PDF</h2>
|
|
85
|
+
<p>Pisahkan halaman tertentu dari file PDF menjadi dokumen baru.</p>
|
|
86
|
+
</div>
|
|
87
|
+
<div className="page-body">
|
|
88
|
+
<div className="tool-page">
|
|
89
|
+
{!file && <Dropzone onFilesSelected={handleFiles} />}
|
|
90
|
+
|
|
91
|
+
{file && (
|
|
92
|
+
<div className="file-list">
|
|
93
|
+
<FileItem file={file} onRemove={removeFile} />
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{file && !result && !error && (
|
|
98
|
+
<div className="options-panel animate-in">
|
|
99
|
+
<h4>📄 Rentang Halaman</h4>
|
|
100
|
+
<div className="input-group">
|
|
101
|
+
<label>Masukkan nomor halaman yang ingin dipisahkan</label>
|
|
102
|
+
<input
|
|
103
|
+
type="text"
|
|
104
|
+
placeholder="Contoh: 1-5,8,11-15"
|
|
105
|
+
value={pageRange}
|
|
106
|
+
onChange={(e) => setPageRange(e.target.value)}
|
|
107
|
+
/>
|
|
108
|
+
<p className="page-range-hint">
|
|
109
|
+
Gunakan tanda koma untuk memisahkan seleksi, dan tanda hubung untuk rentang.
|
|
110
|
+
</p>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{processing && <ProgressBar progress={progress} status="Memisahkan halaman dengan qpdf..." />}
|
|
116
|
+
|
|
117
|
+
{error && (
|
|
118
|
+
<div className="result-card animate-in" style={{ borderColor: 'rgba(225,112,85,0.3)' }}>
|
|
119
|
+
<h4>❌ Error</h4>
|
|
120
|
+
<p style={{ color: 'var(--danger)', fontSize: '14px' }}>{error}</p>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{result && (
|
|
125
|
+
<div className="result-card success animate-in">
|
|
126
|
+
<h4>✅ Pemisahan Berhasil!</h4>
|
|
127
|
+
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginBottom: '16px' }}>
|
|
128
|
+
{result.page_count} halaman berhasil dipisahkan dari dokumen asli.
|
|
129
|
+
</p>
|
|
130
|
+
<button className="btn-primary" onClick={handleSave}>💾 Simpan File</button>
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{file && !processing && !result && !error && (
|
|
135
|
+
<button
|
|
136
|
+
className="btn-primary"
|
|
137
|
+
onClick={handleSplit}
|
|
138
|
+
disabled={!pageRange.trim()}
|
|
139
|
+
>
|
|
140
|
+
🚀 Mulai Pisah
|
|
141
|
+
</button>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
import ProgressBar from '../components/ProgressBar';
|
|
7
|
+
|
|
8
|
+
interface ToImageResult {
|
|
9
|
+
output_dir: string;
|
|
10
|
+
image_count: number;
|
|
11
|
+
format: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function PdfToImage() {
|
|
15
|
+
const navigate = useNavigate();
|
|
16
|
+
const [file, setFile] = useState<PdfFile | null>(null);
|
|
17
|
+
const [format, setFormat] = useState<'png' | 'jpg'>('png');
|
|
18
|
+
const [processing, setProcessing] = useState(false);
|
|
19
|
+
const [progress, setProgress] = useState(0);
|
|
20
|
+
const [result, setResult] = useState<ToImageResult | null>(null);
|
|
21
|
+
const [error, setError] = useState('');
|
|
22
|
+
|
|
23
|
+
const handleFiles = (files: PdfFile[]) => {
|
|
24
|
+
setFile(files[0]);
|
|
25
|
+
setResult(null);
|
|
26
|
+
setError('');
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const removeFile = () => {
|
|
30
|
+
setFile(null);
|
|
31
|
+
setResult(null);
|
|
32
|
+
setError('');
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const handleConvert = async () => {
|
|
36
|
+
if (!file) return;
|
|
37
|
+
|
|
38
|
+
setProcessing(true);
|
|
39
|
+
setProgress(15);
|
|
40
|
+
setError('');
|
|
41
|
+
setResult(null);
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const tempDir = await invoke<string>('get_temp_dir');
|
|
45
|
+
const baseName = file.name.replace(/\.pdf$/i, '');
|
|
46
|
+
const outputDir = `${tempDir}/images_${baseName}`;
|
|
47
|
+
|
|
48
|
+
setProgress(30);
|
|
49
|
+
|
|
50
|
+
const res = await invoke<ToImageResult>('pdf_to_image', {
|
|
51
|
+
inputPath: file.path,
|
|
52
|
+
outputDir: outputDir,
|
|
53
|
+
format: format,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
setProgress(100);
|
|
57
|
+
setResult(res);
|
|
58
|
+
} catch (err: any) {
|
|
59
|
+
setError(typeof err === 'string' ? err : err.message || 'Terjadi kesalahan.');
|
|
60
|
+
} finally {
|
|
61
|
+
setProcessing(false);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<>
|
|
67
|
+
<div className="page-header">
|
|
68
|
+
<button className="btn-back" onClick={() => navigate('/')}>← Kembali ke Dashboard</button>
|
|
69
|
+
<h2>🖼️ PDF ke Gambar</h2>
|
|
70
|
+
<p>Konversikan setiap halaman PDF menjadi file gambar berkualitas tinggi (300 DPI).</p>
|
|
71
|
+
</div>
|
|
72
|
+
<div className="page-body">
|
|
73
|
+
<div className="tool-page">
|
|
74
|
+
{!file && <Dropzone onFilesSelected={handleFiles} />}
|
|
75
|
+
|
|
76
|
+
{file && (
|
|
77
|
+
<div className="file-list">
|
|
78
|
+
<FileItem file={file} onRemove={removeFile} />
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
{file && !result && !error && (
|
|
83
|
+
<div className="options-panel animate-in">
|
|
84
|
+
<h4>🎨 Format Output</h4>
|
|
85
|
+
<div className="quality-options">
|
|
86
|
+
<button
|
|
87
|
+
className={`quality-btn ${format === 'png' ? 'selected' : ''}`}
|
|
88
|
+
onClick={() => setFormat('png')}
|
|
89
|
+
>
|
|
90
|
+
<span className="quality-label">PNG</span>
|
|
91
|
+
<span className="quality-desc">Kualitas tinggi, cocok untuk dokumen</span>
|
|
92
|
+
</button>
|
|
93
|
+
<button
|
|
94
|
+
className={`quality-btn ${format === 'jpg' ? 'selected' : ''}`}
|
|
95
|
+
onClick={() => setFormat('jpg')}
|
|
96
|
+
>
|
|
97
|
+
<span className="quality-label">JPG</span>
|
|
98
|
+
<span className="quality-desc">Ukuran kecil, cocok untuk berbagi</span>
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
{processing && <ProgressBar progress={progress} status="Mengonversi halaman dengan pdftoppm..." />}
|
|
105
|
+
|
|
106
|
+
{error && (
|
|
107
|
+
<div className="result-card animate-in" style={{ borderColor: 'rgba(225,112,85,0.3)' }}>
|
|
108
|
+
<h4>❌ Error</h4>
|
|
109
|
+
<p style={{ color: 'var(--danger)', fontSize: '14px' }}>{error}</p>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{result && (
|
|
114
|
+
<div className="result-card success animate-in">
|
|
115
|
+
<h4>✅ Konversi Berhasil!</h4>
|
|
116
|
+
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginBottom: '8px' }}>
|
|
117
|
+
{result.image_count} halaman berhasil dikonversi ke format <strong>.{result.format.toUpperCase()}</strong> (300 DPI).
|
|
118
|
+
</p>
|
|
119
|
+
<p style={{ color: 'var(--text-muted)', fontSize: '12px', marginBottom: '16px', wordBreak: 'break-all' }}>
|
|
120
|
+
📁 Lokasi: {result.output_dir}
|
|
121
|
+
</p>
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
{file && !processing && !result && !error && (
|
|
126
|
+
<button className="btn-primary" onClick={handleConvert}>
|
|
127
|
+
🚀 Mulai Konversi
|
|
128
|
+
</button>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</>
|
|
133
|
+
);
|
|
134
|
+
}
|