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,1022 @@
|
|
|
1
|
+
use serde::Serialize;
|
|
2
|
+
use std::fs;
|
|
3
|
+
use std::path::Path;
|
|
4
|
+
use std::process::Command;
|
|
5
|
+
use std::sync::{Mutex, OnceLock};
|
|
6
|
+
|
|
7
|
+
// Serialize heavy PDF operations so only one runs at a time, preventing RAM spikes.
|
|
8
|
+
static PDF_OP_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
9
|
+
|
|
10
|
+
fn pdf_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
11
|
+
PDF_OP_LOCK
|
|
12
|
+
.get_or_init(|| Mutex::new(()))
|
|
13
|
+
.lock()
|
|
14
|
+
.unwrap_or_else(|e| e.into_inner())
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ===== Result Types =====
|
|
18
|
+
|
|
19
|
+
#[derive(Serialize, Clone)]
|
|
20
|
+
pub struct CompressResult {
|
|
21
|
+
pub original_size: u64,
|
|
22
|
+
pub compressed_size: u64,
|
|
23
|
+
pub output_path: String,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[derive(Serialize, Clone)]
|
|
27
|
+
pub struct MergeResult {
|
|
28
|
+
pub file_count: usize,
|
|
29
|
+
pub output_path: String,
|
|
30
|
+
pub output_size: u64,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#[derive(Serialize, Clone)]
|
|
34
|
+
pub struct SplitResult {
|
|
35
|
+
pub output_path: String,
|
|
36
|
+
pub page_count: usize,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#[derive(Serialize, Clone)]
|
|
40
|
+
pub struct ToImageResult {
|
|
41
|
+
pub output_dir: String,
|
|
42
|
+
pub image_count: usize,
|
|
43
|
+
pub format: String,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#[derive(Serialize, Clone)]
|
|
47
|
+
pub struct ProtectResult {
|
|
48
|
+
pub output_path: String,
|
|
49
|
+
pub output_size: u64,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#[derive(Serialize, Clone)]
|
|
53
|
+
pub struct RotateResult {
|
|
54
|
+
pub output_path: String,
|
|
55
|
+
pub output_size: u64,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#[derive(Serialize, Clone)]
|
|
59
|
+
pub struct UnlockResult {
|
|
60
|
+
pub output_path: String,
|
|
61
|
+
pub output_size: u64,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#[derive(Serialize, Clone)]
|
|
65
|
+
pub struct WatermarkResult {
|
|
66
|
+
pub output_path: String,
|
|
67
|
+
pub output_size: u64,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#[derive(Serialize, Clone)]
|
|
71
|
+
pub struct ImageToPdfResult {
|
|
72
|
+
pub output_path: String,
|
|
73
|
+
pub output_size: u64,
|
|
74
|
+
pub image_count: usize,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#[derive(Serialize, Clone)]
|
|
78
|
+
pub struct CropResult {
|
|
79
|
+
pub output_path: String,
|
|
80
|
+
pub output_size: u64,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#[derive(Serialize, Clone)]
|
|
84
|
+
pub struct PdfMetadata {
|
|
85
|
+
pub title: String,
|
|
86
|
+
pub author: String,
|
|
87
|
+
pub pages: String,
|
|
88
|
+
pub file_size: String,
|
|
89
|
+
pub pdf_version: String,
|
|
90
|
+
pub creator: String,
|
|
91
|
+
pub producer: String,
|
|
92
|
+
pub page_size: String,
|
|
93
|
+
pub encrypted: String,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ===== Compress =====
|
|
97
|
+
|
|
98
|
+
#[tauri::command]
|
|
99
|
+
pub async fn compress_pdf(input_path: String, output_path: String, quality: String) -> Result<CompressResult, String> {
|
|
100
|
+
tokio::task::spawn_blocking(move || {
|
|
101
|
+
let _guard = pdf_lock();
|
|
102
|
+
|
|
103
|
+
let original_size = fs::metadata(&input_path)
|
|
104
|
+
.map_err(|e| format!("Gagal membaca file: {}", e))?
|
|
105
|
+
.len();
|
|
106
|
+
|
|
107
|
+
let pdf_setting = match quality.as_str() {
|
|
108
|
+
"high" => "/prepress",
|
|
109
|
+
"medium" => "/ebook",
|
|
110
|
+
"low" => "/screen",
|
|
111
|
+
_ => "/ebook",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
let output = Command::new("gs")
|
|
115
|
+
.args([
|
|
116
|
+
"-sDEVICE=pdfwrite",
|
|
117
|
+
"-dCompatibilityLevel=1.4",
|
|
118
|
+
&format!("-dPDFSETTINGS={}", pdf_setting),
|
|
119
|
+
"-dNOPAUSE", "-dQUIET", "-dBATCH",
|
|
120
|
+
"-dNumRenderingThreads=1",
|
|
121
|
+
"-dNOINTERPOLATE",
|
|
122
|
+
"-dMaxBitmap=50331648",
|
|
123
|
+
&format!("-sOutputFile={}", output_path),
|
|
124
|
+
"-c", "8388608 setvmthreshold",
|
|
125
|
+
&input_path,
|
|
126
|
+
])
|
|
127
|
+
.output()
|
|
128
|
+
.map_err(|e| format!("Gagal menjalankan Ghostscript: {}", e))?;
|
|
129
|
+
|
|
130
|
+
if !output.status.success() {
|
|
131
|
+
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
132
|
+
return Err(format!("Ghostscript error: {}", stderr));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let compressed_size = fs::metadata(&output_path)
|
|
136
|
+
.map_err(|e| format!("Gagal membaca file output: {}", e))?
|
|
137
|
+
.len();
|
|
138
|
+
|
|
139
|
+
Ok(CompressResult { original_size, compressed_size, output_path })
|
|
140
|
+
})
|
|
141
|
+
.await
|
|
142
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ===== Merge =====
|
|
146
|
+
|
|
147
|
+
#[tauri::command]
|
|
148
|
+
pub async fn merge_pdf(input_paths: Vec<String>, output_path: String) -> Result<MergeResult, String> {
|
|
149
|
+
tokio::task::spawn_blocking(move || {
|
|
150
|
+
let _guard = pdf_lock();
|
|
151
|
+
|
|
152
|
+
if input_paths.len() < 2 {
|
|
153
|
+
return Err("Minimal 2 file PDF diperlukan.".to_string());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let mut args = vec!["--empty".to_string(), "--pages".to_string()];
|
|
157
|
+
for path in &input_paths { args.push(path.clone()); }
|
|
158
|
+
args.push("--".to_string());
|
|
159
|
+
args.push(output_path.clone());
|
|
160
|
+
|
|
161
|
+
let output = Command::new("qpdf").args(&args).output()
|
|
162
|
+
.map_err(|e| format!("Gagal menjalankan qpdf: {}", e))?;
|
|
163
|
+
|
|
164
|
+
if !output.status.success() {
|
|
165
|
+
return Err(format!("qpdf error: {}", String::from_utf8_lossy(&output.stderr)));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let output_size = fs::metadata(&output_path)
|
|
169
|
+
.map_err(|e| format!("Gagal membaca output: {}", e))?.len();
|
|
170
|
+
|
|
171
|
+
Ok(MergeResult { file_count: input_paths.len(), output_path, output_size })
|
|
172
|
+
})
|
|
173
|
+
.await
|
|
174
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ===== Split =====
|
|
178
|
+
|
|
179
|
+
#[tauri::command]
|
|
180
|
+
pub async fn split_pdf(input_path: String, output_path: String, page_range: String) -> Result<SplitResult, String> {
|
|
181
|
+
tokio::task::spawn_blocking(move || {
|
|
182
|
+
let _guard = pdf_lock();
|
|
183
|
+
|
|
184
|
+
let output = Command::new("qpdf")
|
|
185
|
+
.args([&input_path, "--pages", &input_path, &page_range, "--", &output_path])
|
|
186
|
+
.output()
|
|
187
|
+
.map_err(|e| format!("Gagal menjalankan qpdf: {}", e))?;
|
|
188
|
+
|
|
189
|
+
if !output.status.success() {
|
|
190
|
+
return Err(format!("qpdf error: {}", String::from_utf8_lossy(&output.stderr)));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let pc = Command::new("qpdf").args(["--show-npages", &output_path]).output()
|
|
194
|
+
.map_err(|e| format!("Error: {}", e))?;
|
|
195
|
+
let page_count: usize = String::from_utf8_lossy(&pc.stdout).trim().parse().unwrap_or(0);
|
|
196
|
+
|
|
197
|
+
Ok(SplitResult { output_path, page_count })
|
|
198
|
+
})
|
|
199
|
+
.await
|
|
200
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ===== PDF to Image =====
|
|
204
|
+
|
|
205
|
+
#[tauri::command]
|
|
206
|
+
pub async fn pdf_to_image(input_path: String, output_dir: String, format: String) -> Result<ToImageResult, String> {
|
|
207
|
+
tokio::task::spawn_blocking(move || {
|
|
208
|
+
let _guard = pdf_lock();
|
|
209
|
+
|
|
210
|
+
fs::create_dir_all(&output_dir)
|
|
211
|
+
.map_err(|e| format!("Gagal membuat direktori: {}", e))?;
|
|
212
|
+
|
|
213
|
+
let output_prefix = format!("{}/page", output_dir);
|
|
214
|
+
let format_flag = if format == "jpg" || format == "jpeg" { "-jpeg" } else { "-png" };
|
|
215
|
+
|
|
216
|
+
let output = Command::new("pdftoppm")
|
|
217
|
+
.args([format_flag, "-r", "300", &input_path, &output_prefix])
|
|
218
|
+
.output()
|
|
219
|
+
.map_err(|e| format!("Gagal menjalankan pdftoppm: {}", e))?;
|
|
220
|
+
|
|
221
|
+
if !output.status.success() {
|
|
222
|
+
return Err(format!("pdftoppm error: {}", String::from_utf8_lossy(&output.stderr)));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let ext = if format == "jpg" || format == "jpeg" { "jpg" } else { "png" };
|
|
226
|
+
let image_count = fs::read_dir(&output_dir).map_err(|e| format!("Error: {}", e))?
|
|
227
|
+
.filter_map(|e| e.ok())
|
|
228
|
+
.filter(|e| e.path().extension().map(|x| x.to_string_lossy().to_lowercase() == ext).unwrap_or(false))
|
|
229
|
+
.count();
|
|
230
|
+
|
|
231
|
+
Ok(ToImageResult { output_dir, image_count, format: ext.to_string() })
|
|
232
|
+
})
|
|
233
|
+
.await
|
|
234
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ===== Protect =====
|
|
238
|
+
|
|
239
|
+
#[tauri::command]
|
|
240
|
+
pub async fn protect_pdf(input_path: String, output_path: String, password: String) -> Result<ProtectResult, String> {
|
|
241
|
+
tokio::task::spawn_blocking(move || {
|
|
242
|
+
let _guard = pdf_lock();
|
|
243
|
+
|
|
244
|
+
let output = Command::new("qpdf")
|
|
245
|
+
.args(["--encrypt", &password, &password, "256", "--", &input_path, &output_path])
|
|
246
|
+
.output()
|
|
247
|
+
.map_err(|e| format!("Gagal menjalankan qpdf: {}", e))?;
|
|
248
|
+
|
|
249
|
+
if !output.status.success() {
|
|
250
|
+
return Err(format!("qpdf error: {}", String::from_utf8_lossy(&output.stderr)));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let output_size = fs::metadata(&output_path)
|
|
254
|
+
.map_err(|e| format!("Error: {}", e))?.len();
|
|
255
|
+
|
|
256
|
+
Ok(ProtectResult { output_path, output_size })
|
|
257
|
+
})
|
|
258
|
+
.await
|
|
259
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ===== Rotate =====
|
|
263
|
+
|
|
264
|
+
#[tauri::command]
|
|
265
|
+
pub async fn rotate_pdf(input_path: String, output_path: String, angle: String, pages: String) -> Result<RotateResult, String> {
|
|
266
|
+
tokio::task::spawn_blocking(move || {
|
|
267
|
+
let _guard = pdf_lock();
|
|
268
|
+
|
|
269
|
+
let rotation = format!("+{}", angle);
|
|
270
|
+
let page_spec = if pages.is_empty() || pages == "all" { "1-z".to_string() } else { pages };
|
|
271
|
+
|
|
272
|
+
let output = Command::new("qpdf")
|
|
273
|
+
.args([&input_path, &output_path, "--rotate", &format!("{}:{}", rotation, page_spec)])
|
|
274
|
+
.output()
|
|
275
|
+
.map_err(|e| format!("Gagal menjalankan qpdf: {}", e))?;
|
|
276
|
+
|
|
277
|
+
if !output.status.success() {
|
|
278
|
+
return Err(format!("qpdf error: {}", String::from_utf8_lossy(&output.stderr)));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let output_size = fs::metadata(&output_path)
|
|
282
|
+
.map_err(|e| format!("Error: {}", e))?.len();
|
|
283
|
+
|
|
284
|
+
Ok(RotateResult { output_path, output_size })
|
|
285
|
+
})
|
|
286
|
+
.await
|
|
287
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ===== Unlock =====
|
|
291
|
+
|
|
292
|
+
#[tauri::command]
|
|
293
|
+
pub async fn unlock_pdf(input_path: String, output_path: String, password: String) -> Result<UnlockResult, String> {
|
|
294
|
+
tokio::task::spawn_blocking(move || {
|
|
295
|
+
let _guard = pdf_lock();
|
|
296
|
+
|
|
297
|
+
let output = Command::new("qpdf")
|
|
298
|
+
.args(["--decrypt", &format!("--password={}", password), &input_path, &output_path])
|
|
299
|
+
.output()
|
|
300
|
+
.map_err(|e| format!("Gagal menjalankan qpdf: {}", e))?;
|
|
301
|
+
|
|
302
|
+
if !output.status.success() {
|
|
303
|
+
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
304
|
+
if stderr.contains("invalid password") {
|
|
305
|
+
return Err("Password salah. Silakan coba lagi.".to_string());
|
|
306
|
+
}
|
|
307
|
+
return Err(format!("qpdf error: {}", stderr));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let output_size = fs::metadata(&output_path)
|
|
311
|
+
.map_err(|e| format!("Error: {}", e))?.len();
|
|
312
|
+
|
|
313
|
+
Ok(UnlockResult { output_path, output_size })
|
|
314
|
+
})
|
|
315
|
+
.await
|
|
316
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ===== Watermark =====
|
|
320
|
+
|
|
321
|
+
#[tauri::command]
|
|
322
|
+
pub async fn watermark_pdf(input_path: String, output_path: String, text: String) -> Result<WatermarkResult, String> {
|
|
323
|
+
tokio::task::spawn_blocking(move || {
|
|
324
|
+
let _guard = pdf_lock();
|
|
325
|
+
|
|
326
|
+
let temp_dir = std::env::temp_dir().join("pdf-tools");
|
|
327
|
+
fs::create_dir_all(&temp_dir).ok();
|
|
328
|
+
let ps_path = temp_dir.join("watermark.ps");
|
|
329
|
+
|
|
330
|
+
let ps_content = format!(
|
|
331
|
+
r#"<<
|
|
332
|
+
/EndPage {{
|
|
333
|
+
2 eq {{ pop false }}
|
|
334
|
+
{{
|
|
335
|
+
gsave
|
|
336
|
+
/Helvetica-Bold findfont 60 scalefont setfont
|
|
337
|
+
0.85 setgray
|
|
338
|
+
306 396 translate
|
|
339
|
+
45 rotate
|
|
340
|
+
0 0 moveto
|
|
341
|
+
({}) dup stringwidth pop -2 div -20 rmoveto show
|
|
342
|
+
grestore
|
|
343
|
+
true
|
|
344
|
+
}} ifelse
|
|
345
|
+
}} bind
|
|
346
|
+
>> setpagedevice"#,
|
|
347
|
+
text
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
fs::write(&ps_path, &ps_content)
|
|
351
|
+
.map_err(|e| format!("Gagal menulis watermark script: {}", e))?;
|
|
352
|
+
|
|
353
|
+
let output = Command::new("gs")
|
|
354
|
+
.args([
|
|
355
|
+
"-sDEVICE=pdfwrite",
|
|
356
|
+
"-dNOPAUSE", "-dQUIET", "-dBATCH",
|
|
357
|
+
"-dNumRenderingThreads=1",
|
|
358
|
+
"-dNOINTERPOLATE",
|
|
359
|
+
"-dMaxBitmap=50331648",
|
|
360
|
+
&format!("-sOutputFile={}", output_path),
|
|
361
|
+
"-c", "8388608 setvmthreshold",
|
|
362
|
+
&ps_path.to_string_lossy(),
|
|
363
|
+
&input_path,
|
|
364
|
+
])
|
|
365
|
+
.output()
|
|
366
|
+
.map_err(|e| format!("Gagal menjalankan Ghostscript: {}", e))?;
|
|
367
|
+
|
|
368
|
+
fs::remove_file(&ps_path).ok();
|
|
369
|
+
|
|
370
|
+
if !output.status.success() {
|
|
371
|
+
return Err(format!("Ghostscript error: {}", String::from_utf8_lossy(&output.stderr)));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let output_size = fs::metadata(&output_path)
|
|
375
|
+
.map_err(|e| format!("Error: {}", e))?.len();
|
|
376
|
+
|
|
377
|
+
Ok(WatermarkResult { output_path, output_size })
|
|
378
|
+
})
|
|
379
|
+
.await
|
|
380
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ===== Image to PDF =====
|
|
384
|
+
|
|
385
|
+
#[tauri::command]
|
|
386
|
+
pub async fn image_to_pdf(input_paths: Vec<String>, output_path: String) -> Result<ImageToPdfResult, String> {
|
|
387
|
+
let image_count = input_paths.len();
|
|
388
|
+
tokio::task::spawn_blocking(move || {
|
|
389
|
+
let _guard = pdf_lock();
|
|
390
|
+
|
|
391
|
+
// Memory limits prevent ImageMagick from consuming excessive RAM on large images.
|
|
392
|
+
let mut args = vec![
|
|
393
|
+
"-limit".to_string(), "memory".to_string(), "128MiB".to_string(),
|
|
394
|
+
"-limit".to_string(), "map".to_string(), "128MiB".to_string(),
|
|
395
|
+
];
|
|
396
|
+
args.extend(input_paths);
|
|
397
|
+
args.push("-quality".to_string());
|
|
398
|
+
args.push("90".to_string());
|
|
399
|
+
args.push(output_path.clone());
|
|
400
|
+
|
|
401
|
+
let output = Command::new("magick")
|
|
402
|
+
.args(&args)
|
|
403
|
+
.output()
|
|
404
|
+
.map_err(|e| format!("Gagal menjalankan ImageMagick: {}", e))?;
|
|
405
|
+
|
|
406
|
+
if !output.status.success() {
|
|
407
|
+
return Err(format!("ImageMagick error: {}", String::from_utf8_lossy(&output.stderr)));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
let output_size = fs::metadata(&output_path)
|
|
411
|
+
.map_err(|e| format!("Error: {}", e))?.len();
|
|
412
|
+
|
|
413
|
+
Ok(ImageToPdfResult { output_path, output_size, image_count })
|
|
414
|
+
})
|
|
415
|
+
.await
|
|
416
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ===== PDF Metadata =====
|
|
420
|
+
|
|
421
|
+
#[tauri::command]
|
|
422
|
+
pub async fn get_pdf_metadata(input_path: String) -> Result<PdfMetadata, String> {
|
|
423
|
+
tokio::task::spawn_blocking(move || {
|
|
424
|
+
let output = Command::new("pdfinfo")
|
|
425
|
+
.arg(&input_path)
|
|
426
|
+
.output()
|
|
427
|
+
.map_err(|e| format!("Gagal menjalankan pdfinfo: {}", e))?;
|
|
428
|
+
|
|
429
|
+
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
430
|
+
|
|
431
|
+
let get_field = |field: &str| -> String {
|
|
432
|
+
stdout.lines()
|
|
433
|
+
.find(|l| l.starts_with(field))
|
|
434
|
+
.map(|l| l[field.len()..].trim().to_string())
|
|
435
|
+
.unwrap_or_else(|| "-".to_string())
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
let file_size = fs::metadata(&input_path)
|
|
439
|
+
.map(|m| {
|
|
440
|
+
let s = m.len();
|
|
441
|
+
if s < 1048576 { format!("{:.1} KB", s as f64 / 1024.0) }
|
|
442
|
+
else { format!("{:.2} MB", s as f64 / 1048576.0) }
|
|
443
|
+
})
|
|
444
|
+
.unwrap_or_else(|_| "-".to_string());
|
|
445
|
+
|
|
446
|
+
Ok(PdfMetadata {
|
|
447
|
+
title: get_field("Title:"),
|
|
448
|
+
author: get_field("Author:"),
|
|
449
|
+
pages: get_field("Pages:"),
|
|
450
|
+
file_size,
|
|
451
|
+
pdf_version: get_field("PDF version:"),
|
|
452
|
+
creator: get_field("Creator:"),
|
|
453
|
+
producer: get_field("Producer:"),
|
|
454
|
+
page_size: get_field("Page size:"),
|
|
455
|
+
encrypted: get_field("Encrypted:"),
|
|
456
|
+
})
|
|
457
|
+
})
|
|
458
|
+
.await
|
|
459
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ===== Utility =====
|
|
463
|
+
|
|
464
|
+
#[tauri::command]
|
|
465
|
+
pub async fn get_pdf_info(input_path: String) -> Result<u64, String> {
|
|
466
|
+
tokio::task::spawn_blocking(move || {
|
|
467
|
+
fs::metadata(&input_path)
|
|
468
|
+
.map_err(|e| format!("Gagal membaca file: {}", e))
|
|
469
|
+
.map(|m| m.len())
|
|
470
|
+
})
|
|
471
|
+
.await
|
|
472
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
#[tauri::command]
|
|
476
|
+
pub async fn get_temp_dir() -> Result<String, String> {
|
|
477
|
+
let temp_dir = std::env::temp_dir().join("pdf-tools");
|
|
478
|
+
fs::create_dir_all(&temp_dir)
|
|
479
|
+
.map_err(|e| format!("Gagal membuat temp dir: {}", e))?;
|
|
480
|
+
Ok(temp_dir.to_string_lossy().to_string())
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
#[tauri::command]
|
|
484
|
+
pub async fn save_file_to(source: String, destination: String) -> Result<String, String> {
|
|
485
|
+
tokio::task::spawn_blocking(move || {
|
|
486
|
+
if let Some(parent) = Path::new(&destination).parent() {
|
|
487
|
+
fs::create_dir_all(parent).ok();
|
|
488
|
+
}
|
|
489
|
+
fs::copy(&source, &destination)
|
|
490
|
+
.map_err(|e| format!("Gagal menyalin file: {}", e))?;
|
|
491
|
+
Ok(destination)
|
|
492
|
+
})
|
|
493
|
+
.await
|
|
494
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ===== Crop =====
|
|
498
|
+
|
|
499
|
+
#[tauri::command]
|
|
500
|
+
pub async fn crop_pdf(
|
|
501
|
+
input_path: String,
|
|
502
|
+
output_path: String,
|
|
503
|
+
top: f64,
|
|
504
|
+
bottom: f64,
|
|
505
|
+
left: f64,
|
|
506
|
+
right: f64,
|
|
507
|
+
) -> Result<CropResult, String> {
|
|
508
|
+
tokio::task::spawn_blocking(move || {
|
|
509
|
+
let _guard = pdf_lock();
|
|
510
|
+
|
|
511
|
+
let mm2pt = 2.83465;
|
|
512
|
+
let t = top * mm2pt;
|
|
513
|
+
let b = bottom * mm2pt;
|
|
514
|
+
let l = left * mm2pt;
|
|
515
|
+
let r = right * mm2pt;
|
|
516
|
+
|
|
517
|
+
let info_out = Command::new("pdfinfo").arg(&input_path).output()
|
|
518
|
+
.map_err(|e| format!("Gagal menjalankan pdfinfo: {}", e))?;
|
|
519
|
+
let info_str = String::from_utf8_lossy(&info_out.stdout);
|
|
520
|
+
|
|
521
|
+
let (page_w, page_h) = info_str.lines()
|
|
522
|
+
.find(|l| l.starts_with("Page size:"))
|
|
523
|
+
.and_then(|line| {
|
|
524
|
+
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
525
|
+
if parts.len() >= 5 {
|
|
526
|
+
let w: f64 = parts[2].parse().unwrap_or(612.0);
|
|
527
|
+
let h: f64 = parts[4].parse().unwrap_or(792.0);
|
|
528
|
+
Some((w, h))
|
|
529
|
+
} else { None }
|
|
530
|
+
})
|
|
531
|
+
.unwrap_or((612.0, 792.0));
|
|
532
|
+
|
|
533
|
+
let crop_left = l;
|
|
534
|
+
let crop_bottom = b;
|
|
535
|
+
let crop_right = page_w - r;
|
|
536
|
+
let crop_top = page_h - t;
|
|
537
|
+
|
|
538
|
+
if crop_right <= crop_left || crop_top <= crop_bottom {
|
|
539
|
+
return Err("Margin terlalu besar — area crop tidak valid.".to_string());
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
let gs_script = format!(
|
|
543
|
+
"<< /CurPt [0 0] >> begin \n\
|
|
544
|
+
<< /EndPage {{ \n\
|
|
545
|
+
exch pop 0 eq {{ \n\
|
|
546
|
+
[{} {} {} {}] /CropBox pdfmark true \n\
|
|
547
|
+
}} {{ false }} ifelse \n\
|
|
548
|
+
}} bind >> setpagedevice",
|
|
549
|
+
crop_left, crop_bottom, crop_right, crop_top
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
let output = Command::new("gs")
|
|
553
|
+
.args([
|
|
554
|
+
"-sDEVICE=pdfwrite",
|
|
555
|
+
"-dNOPAUSE", "-dQUIET", "-dBATCH",
|
|
556
|
+
"-dNumRenderingThreads=1",
|
|
557
|
+
"-dNOINTERPOLATE",
|
|
558
|
+
"-dMaxBitmap=50331648",
|
|
559
|
+
&format!("-sOutputFile={}", output_path),
|
|
560
|
+
"-c", "8388608 setvmthreshold",
|
|
561
|
+
"-c", &gs_script,
|
|
562
|
+
"-f", &input_path,
|
|
563
|
+
])
|
|
564
|
+
.output()
|
|
565
|
+
.map_err(|e| format!("Gagal menjalankan Ghostscript: {}", e))?;
|
|
566
|
+
|
|
567
|
+
if !output.status.success() {
|
|
568
|
+
return Err(format!("Ghostscript error: {}", String::from_utf8_lossy(&output.stderr)));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
let output_size = fs::metadata(&output_path)
|
|
572
|
+
.map_err(|e| format!("Error: {}", e))?.len();
|
|
573
|
+
|
|
574
|
+
Ok(CropResult { output_path, output_size })
|
|
575
|
+
})
|
|
576
|
+
.await
|
|
577
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ===== Helper: parse "1,3,5-8" → HashSet<usize> =====
|
|
581
|
+
|
|
582
|
+
fn parse_page_set(range_str: &str, total: usize) -> Result<std::collections::HashSet<usize>, String> {
|
|
583
|
+
let mut pages = std::collections::HashSet::new();
|
|
584
|
+
for part in range_str.split(',') {
|
|
585
|
+
let part = part.trim();
|
|
586
|
+
if part.is_empty() { continue; }
|
|
587
|
+
if let Some((s, e)) = part.split_once('-') {
|
|
588
|
+
let start: usize = s.trim().parse()
|
|
589
|
+
.map_err(|_| format!("Nomor tidak valid: '{}'", s.trim()))?;
|
|
590
|
+
let end: usize = e.trim().parse()
|
|
591
|
+
.map_err(|_| format!("Nomor tidak valid: '{}'", e.trim()))?;
|
|
592
|
+
if start < 1 || end > total || start > end {
|
|
593
|
+
return Err(format!("Rentang tidak valid: {}-{} (total halaman: {})", start, end, total));
|
|
594
|
+
}
|
|
595
|
+
for p in start..=end { pages.insert(p); }
|
|
596
|
+
} else {
|
|
597
|
+
let p: usize = part.parse()
|
|
598
|
+
.map_err(|_| format!("Nomor halaman tidak valid: '{}'", part))?;
|
|
599
|
+
if p < 1 || p > total {
|
|
600
|
+
return Err(format!("Halaman {} tidak ada (total: {})", p, total));
|
|
601
|
+
}
|
|
602
|
+
pages.insert(p);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
Ok(pages)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ===== Helper: [1,2,4,5,6] → "1-2,4-6" =====
|
|
609
|
+
|
|
610
|
+
fn vec_to_range_string(pages: &[usize]) -> String {
|
|
611
|
+
if pages.is_empty() { return String::new(); }
|
|
612
|
+
let mut result: Vec<String> = Vec::new();
|
|
613
|
+
let mut start = pages[0];
|
|
614
|
+
let mut end = pages[0];
|
|
615
|
+
for &p in &pages[1..] {
|
|
616
|
+
if p == end + 1 { end = p; }
|
|
617
|
+
else {
|
|
618
|
+
if start == end { result.push(start.to_string()); }
|
|
619
|
+
else { result.push(format!("{}-{}", start, end)); }
|
|
620
|
+
start = p; end = p;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if start == end { result.push(start.to_string()); }
|
|
624
|
+
else { result.push(format!("{}-{}", start, end)); }
|
|
625
|
+
result.join(",")
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ===== Delete Pages =====
|
|
629
|
+
|
|
630
|
+
#[derive(Serialize, Clone)]
|
|
631
|
+
pub struct DeletePagesResult {
|
|
632
|
+
pub output_path: String,
|
|
633
|
+
pub output_size: u64,
|
|
634
|
+
pub pages_deleted: usize,
|
|
635
|
+
pub pages_remaining: usize,
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
#[tauri::command]
|
|
639
|
+
pub async fn delete_pages(
|
|
640
|
+
input_path: String,
|
|
641
|
+
output_path: String,
|
|
642
|
+
pages_to_delete: String,
|
|
643
|
+
) -> Result<DeletePagesResult, String> {
|
|
644
|
+
tokio::task::spawn_blocking(move || {
|
|
645
|
+
let _guard = pdf_lock();
|
|
646
|
+
|
|
647
|
+
let pc_out = Command::new("qpdf")
|
|
648
|
+
.args(["--show-npages", &input_path])
|
|
649
|
+
.output()
|
|
650
|
+
.map_err(|e| format!("Gagal membaca halaman: {}", e))?;
|
|
651
|
+
let total: usize = String::from_utf8_lossy(&pc_out.stdout)
|
|
652
|
+
.trim().parse()
|
|
653
|
+
.map_err(|_| "Gagal membaca jumlah halaman.".to_string())?;
|
|
654
|
+
|
|
655
|
+
let to_delete = parse_page_set(&pages_to_delete, total)?;
|
|
656
|
+
if to_delete.is_empty() {
|
|
657
|
+
return Err("Tidak ada halaman yang dipilih untuk dihapus.".to_string());
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
let mut to_keep: Vec<usize> = (1..=total).filter(|p| !to_delete.contains(p)).collect();
|
|
661
|
+
to_keep.sort_unstable();
|
|
662
|
+
|
|
663
|
+
if to_keep.is_empty() {
|
|
664
|
+
return Err("Tidak bisa menghapus semua halaman.".to_string());
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
let keep_range = vec_to_range_string(&to_keep);
|
|
668
|
+
let output = Command::new("qpdf")
|
|
669
|
+
.args([&input_path, "--pages", &input_path, &keep_range, "--", &output_path])
|
|
670
|
+
.output()
|
|
671
|
+
.map_err(|e| format!("Gagal menjalankan qpdf: {}", e))?;
|
|
672
|
+
|
|
673
|
+
if !output.status.success() {
|
|
674
|
+
return Err(format!("qpdf error: {}", String::from_utf8_lossy(&output.stderr)));
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
let output_size = fs::metadata(&output_path).map_err(|e| format!("Error: {}", e))?.len();
|
|
678
|
+
Ok(DeletePagesResult {
|
|
679
|
+
output_path,
|
|
680
|
+
output_size,
|
|
681
|
+
pages_deleted: to_delete.len(),
|
|
682
|
+
pages_remaining: to_keep.len(),
|
|
683
|
+
})
|
|
684
|
+
})
|
|
685
|
+
.await
|
|
686
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ===== Reorder Pages =====
|
|
690
|
+
|
|
691
|
+
#[derive(Serialize, Clone)]
|
|
692
|
+
pub struct ReorderResult {
|
|
693
|
+
pub output_path: String,
|
|
694
|
+
pub output_size: u64,
|
|
695
|
+
pub page_count: usize,
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
#[tauri::command]
|
|
699
|
+
pub async fn reorder_pages(
|
|
700
|
+
input_path: String,
|
|
701
|
+
output_path: String,
|
|
702
|
+
page_order: String,
|
|
703
|
+
) -> Result<ReorderResult, String> {
|
|
704
|
+
tokio::task::spawn_blocking(move || {
|
|
705
|
+
let _guard = pdf_lock();
|
|
706
|
+
|
|
707
|
+
let output = Command::new("qpdf")
|
|
708
|
+
.args([&input_path, "--pages", &input_path, &page_order, "--", &output_path])
|
|
709
|
+
.output()
|
|
710
|
+
.map_err(|e| format!("Gagal menjalankan qpdf: {}", e))?;
|
|
711
|
+
|
|
712
|
+
if !output.status.success() {
|
|
713
|
+
return Err(format!("qpdf error: {}", String::from_utf8_lossy(&output.stderr)));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
let pc = Command::new("qpdf").args(["--show-npages", &output_path]).output()
|
|
717
|
+
.map_err(|e| format!("Error: {}", e))?;
|
|
718
|
+
let page_count: usize = String::from_utf8_lossy(&pc.stdout).trim().parse().unwrap_or(0);
|
|
719
|
+
let output_size = fs::metadata(&output_path).map_err(|e| format!("Error: {}", e))?.len();
|
|
720
|
+
|
|
721
|
+
Ok(ReorderResult { output_path, output_size, page_count })
|
|
722
|
+
})
|
|
723
|
+
.await
|
|
724
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ===== Add Page Numbers =====
|
|
728
|
+
|
|
729
|
+
#[derive(Serialize, Clone)]
|
|
730
|
+
pub struct PageNumberResult {
|
|
731
|
+
pub output_path: String,
|
|
732
|
+
pub output_size: u64,
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
#[tauri::command]
|
|
736
|
+
pub async fn add_page_numbers(
|
|
737
|
+
input_path: String,
|
|
738
|
+
output_path: String,
|
|
739
|
+
position: String,
|
|
740
|
+
font_size: u32,
|
|
741
|
+
start_number: u32,
|
|
742
|
+
) -> Result<PageNumberResult, String> {
|
|
743
|
+
tokio::task::spawn_blocking(move || {
|
|
744
|
+
let _guard = pdf_lock();
|
|
745
|
+
|
|
746
|
+
let info = Command::new("pdfinfo").arg(&input_path).output()
|
|
747
|
+
.map_err(|e| format!("Gagal membaca info PDF: {}", e))?;
|
|
748
|
+
let info_str = String::from_utf8_lossy(&info.stdout);
|
|
749
|
+
let (page_w, page_h) = info_str.lines()
|
|
750
|
+
.find(|l| l.starts_with("Page size:"))
|
|
751
|
+
.and_then(|line| {
|
|
752
|
+
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
753
|
+
if parts.len() >= 5 {
|
|
754
|
+
Some((parts[2].parse().unwrap_or(595.0_f64), parts[4].parse().unwrap_or(842.0_f64)))
|
|
755
|
+
} else { None }
|
|
756
|
+
})
|
|
757
|
+
.unwrap_or((595.0, 842.0));
|
|
758
|
+
|
|
759
|
+
let margin = 35.0_f64;
|
|
760
|
+
let (tx, ty, align) = match position.as_str() {
|
|
761
|
+
"bottom-left" => (margin, margin, "left"),
|
|
762
|
+
"bottom-right" => (page_w - margin, margin, "right"),
|
|
763
|
+
"top-left" => (margin, page_h - margin, "left"),
|
|
764
|
+
"top-center" => (page_w / 2.0, page_h - margin, "center"),
|
|
765
|
+
"top-right" => (page_w - margin, page_h - margin, "right"),
|
|
766
|
+
_ => (page_w / 2.0, margin, "center"),
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
let show_cmd = match align {
|
|
770
|
+
"center" => "dup stringwidth pop 2 div neg 0 rmoveto show",
|
|
771
|
+
"right" => "dup stringwidth pop neg 0 rmoveto show",
|
|
772
|
+
_ => "show",
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
let ps_content = format!(
|
|
776
|
+
r#"/pn {} def
|
|
777
|
+
<< /EndPage {{
|
|
778
|
+
2 eq {{ pop false }} {{
|
|
779
|
+
/pn pn 1 add def
|
|
780
|
+
gsave
|
|
781
|
+
/Helvetica findfont {} scalefont setfont
|
|
782
|
+
0 setgray
|
|
783
|
+
{} {} moveto
|
|
784
|
+
pn 10 string cvs {}
|
|
785
|
+
grestore
|
|
786
|
+
pop true
|
|
787
|
+
}} ifelse
|
|
788
|
+
}} bind >> setpagedevice"#,
|
|
789
|
+
start_number - 1,
|
|
790
|
+
font_size,
|
|
791
|
+
tx, ty,
|
|
792
|
+
show_cmd,
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
let temp_dir = std::env::temp_dir().join("pdf-tools");
|
|
796
|
+
fs::create_dir_all(&temp_dir).ok();
|
|
797
|
+
let ps_path = temp_dir.join("pagenum.ps");
|
|
798
|
+
fs::write(&ps_path, &ps_content)
|
|
799
|
+
.map_err(|e| format!("Gagal menulis script: {}", e))?;
|
|
800
|
+
|
|
801
|
+
let output = Command::new("gs")
|
|
802
|
+
.args([
|
|
803
|
+
"-sDEVICE=pdfwrite",
|
|
804
|
+
"-dNOPAUSE", "-dQUIET", "-dBATCH",
|
|
805
|
+
"-dNumRenderingThreads=1",
|
|
806
|
+
"-dNOINTERPOLATE",
|
|
807
|
+
"-dMaxBitmap=50331648",
|
|
808
|
+
&format!("-sOutputFile={}", output_path),
|
|
809
|
+
"-c", "8388608 setvmthreshold",
|
|
810
|
+
&ps_path.to_string_lossy(),
|
|
811
|
+
&input_path,
|
|
812
|
+
])
|
|
813
|
+
.output()
|
|
814
|
+
.map_err(|e| format!("Gagal menjalankan Ghostscript: {}", e))?;
|
|
815
|
+
|
|
816
|
+
fs::remove_file(&ps_path).ok();
|
|
817
|
+
|
|
818
|
+
if !output.status.success() {
|
|
819
|
+
return Err(format!("Ghostscript error: {}", String::from_utf8_lossy(&output.stderr)));
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
let output_size = fs::metadata(&output_path).map_err(|e| format!("Error: {}", e))?.len();
|
|
823
|
+
Ok(PageNumberResult { output_path, output_size })
|
|
824
|
+
})
|
|
825
|
+
.await
|
|
826
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ===== PDF to Text =====
|
|
830
|
+
|
|
831
|
+
#[derive(Serialize, Clone)]
|
|
832
|
+
pub struct PdfToTextResult {
|
|
833
|
+
pub output_path: String,
|
|
834
|
+
pub output_size: u64,
|
|
835
|
+
pub char_count: usize,
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
#[tauri::command]
|
|
839
|
+
pub async fn pdf_to_text(input_path: String, output_path: String) -> Result<PdfToTextResult, String> {
|
|
840
|
+
tokio::task::spawn_blocking(move || {
|
|
841
|
+
let output = Command::new("pdftotext")
|
|
842
|
+
.args(["-layout", &input_path, &output_path])
|
|
843
|
+
.output()
|
|
844
|
+
.map_err(|e| format!("Gagal menjalankan pdftotext: {}", e))?;
|
|
845
|
+
|
|
846
|
+
if !output.status.success() {
|
|
847
|
+
return Err(format!("pdftotext error: {}", String::from_utf8_lossy(&output.stderr)));
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
let char_count = fs::read_to_string(&output_path)
|
|
851
|
+
.map(|s| s.chars().count())
|
|
852
|
+
.unwrap_or(0);
|
|
853
|
+
let output_size = fs::metadata(&output_path).map_err(|e| format!("Error: {}", e))?.len();
|
|
854
|
+
Ok(PdfToTextResult { output_path, output_size, char_count })
|
|
855
|
+
})
|
|
856
|
+
.await
|
|
857
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ===== Grayscale =====
|
|
861
|
+
|
|
862
|
+
#[derive(Serialize, Clone)]
|
|
863
|
+
pub struct GrayscaleResult {
|
|
864
|
+
pub output_path: String,
|
|
865
|
+
pub output_size: u64,
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
#[tauri::command]
|
|
869
|
+
pub async fn grayscale_pdf(input_path: String, output_path: String) -> Result<GrayscaleResult, String> {
|
|
870
|
+
tokio::task::spawn_blocking(move || {
|
|
871
|
+
let _guard = pdf_lock();
|
|
872
|
+
|
|
873
|
+
let output = Command::new("gs")
|
|
874
|
+
.args([
|
|
875
|
+
"-sDEVICE=pdfwrite",
|
|
876
|
+
"-dCompatibilityLevel=1.4",
|
|
877
|
+
"-sColorConversionStrategy=Gray",
|
|
878
|
+
"-dProcessColorModel=/DeviceGray",
|
|
879
|
+
"-dNOPAUSE", "-dQUIET", "-dBATCH",
|
|
880
|
+
"-dNumRenderingThreads=1",
|
|
881
|
+
"-dNOINTERPOLATE",
|
|
882
|
+
"-dMaxBitmap=50331648",
|
|
883
|
+
&format!("-sOutputFile={}", output_path),
|
|
884
|
+
"-c", "8388608 setvmthreshold",
|
|
885
|
+
&input_path,
|
|
886
|
+
])
|
|
887
|
+
.output()
|
|
888
|
+
.map_err(|e| format!("Gagal menjalankan Ghostscript: {}", e))?;
|
|
889
|
+
|
|
890
|
+
if !output.status.success() {
|
|
891
|
+
return Err(format!("Ghostscript error: {}", String::from_utf8_lossy(&output.stderr)));
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
let output_size = fs::metadata(&output_path).map_err(|e| format!("Error: {}", e))?.len();
|
|
895
|
+
Ok(GrayscaleResult { output_path, output_size })
|
|
896
|
+
})
|
|
897
|
+
.await
|
|
898
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// ===== OCR =====
|
|
902
|
+
|
|
903
|
+
#[derive(Serialize, Clone)]
|
|
904
|
+
pub struct OcrResult {
|
|
905
|
+
pub output_path: String,
|
|
906
|
+
pub output_size: u64,
|
|
907
|
+
pub page_count: usize,
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
#[tauri::command]
|
|
911
|
+
pub async fn ocr_pdf(input_path: String, output_path: String, language: String) -> Result<OcrResult, String> {
|
|
912
|
+
tokio::task::spawn_blocking(move || {
|
|
913
|
+
let _guard = pdf_lock();
|
|
914
|
+
|
|
915
|
+
let work_dir = std::env::temp_dir().join("pdf-tools").join("ocr_work");
|
|
916
|
+
fs::create_dir_all(&work_dir).map_err(|e| format!("Gagal membuat dir: {}", e))?;
|
|
917
|
+
|
|
918
|
+
// Step 1: extract pages as images (200 DPI is enough for OCR and saves RAM)
|
|
919
|
+
let prefix = work_dir.join("page").to_string_lossy().to_string();
|
|
920
|
+
let extract = Command::new("pdftoppm")
|
|
921
|
+
.args(["-png", "-r", "200", &input_path, &prefix])
|
|
922
|
+
.output()
|
|
923
|
+
.map_err(|e| format!("Gagal mengekstrak halaman: {}", e))?;
|
|
924
|
+
|
|
925
|
+
if !extract.status.success() {
|
|
926
|
+
let _ = fs::remove_dir_all(&work_dir);
|
|
927
|
+
return Err(format!("pdftoppm error: {}", String::from_utf8_lossy(&extract.stderr)));
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Collect and sort image files
|
|
931
|
+
let mut images: Vec<std::path::PathBuf> = fs::read_dir(&work_dir)
|
|
932
|
+
.map_err(|e| format!("Error: {}", e))?
|
|
933
|
+
.filter_map(|e| e.ok())
|
|
934
|
+
.filter(|e| e.path().extension().map(|x| x == "png").unwrap_or(false))
|
|
935
|
+
.map(|e| e.path())
|
|
936
|
+
.collect();
|
|
937
|
+
images.sort();
|
|
938
|
+
|
|
939
|
+
if images.is_empty() {
|
|
940
|
+
let _ = fs::remove_dir_all(&work_dir);
|
|
941
|
+
return Err("Tidak ada halaman yang diekstrak.".to_string());
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Step 2: OCR each image
|
|
945
|
+
let lang = if language.is_empty() { "eng".to_string() } else { language };
|
|
946
|
+
let mut ocr_pdfs: Vec<String> = Vec::new();
|
|
947
|
+
|
|
948
|
+
for (i, img_path) in images.iter().enumerate() {
|
|
949
|
+
let ocr_base = work_dir.join(format!("ocr_{:04}", i));
|
|
950
|
+
let result = Command::new("tesseract")
|
|
951
|
+
.args([
|
|
952
|
+
&img_path.to_string_lossy().to_string(),
|
|
953
|
+
&ocr_base.to_string_lossy().to_string(),
|
|
954
|
+
"-l", &lang,
|
|
955
|
+
"pdf",
|
|
956
|
+
])
|
|
957
|
+
.output()
|
|
958
|
+
.map_err(|e| format!("Gagal menjalankan tesseract: {}", e))?;
|
|
959
|
+
|
|
960
|
+
if !result.status.success() {
|
|
961
|
+
let _ = fs::remove_dir_all(&work_dir);
|
|
962
|
+
return Err(format!("Tesseract error halaman {}: {}", i + 1, String::from_utf8_lossy(&result.stderr)));
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
ocr_pdfs.push(format!("{}.pdf", ocr_base.to_string_lossy()));
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
let page_count = ocr_pdfs.len();
|
|
969
|
+
|
|
970
|
+
// Step 3: merge all OCR PDFs into one
|
|
971
|
+
if page_count == 1 {
|
|
972
|
+
fs::copy(&ocr_pdfs[0], &output_path)
|
|
973
|
+
.map_err(|e| format!("Gagal menyalin: {}", e))?;
|
|
974
|
+
} else {
|
|
975
|
+
let mut args = vec!["--empty".to_string(), "--pages".to_string()];
|
|
976
|
+
for p in &ocr_pdfs { args.push(p.clone()); }
|
|
977
|
+
args.push("--".to_string());
|
|
978
|
+
args.push(output_path.clone());
|
|
979
|
+
|
|
980
|
+
let merge = Command::new("qpdf").args(&args).output()
|
|
981
|
+
.map_err(|e| format!("Gagal menggabungkan hasil OCR: {}", e))?;
|
|
982
|
+
|
|
983
|
+
if !merge.status.success() {
|
|
984
|
+
let _ = fs::remove_dir_all(&work_dir);
|
|
985
|
+
return Err(format!("qpdf error: {}", String::from_utf8_lossy(&merge.stderr)));
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
let _ = fs::remove_dir_all(&work_dir);
|
|
990
|
+
let output_size = fs::metadata(&output_path).map_err(|e| format!("Error: {}", e))?.len();
|
|
991
|
+
Ok(OcrResult { output_path, output_size, page_count })
|
|
992
|
+
})
|
|
993
|
+
.await
|
|
994
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
#[tauri::command]
|
|
998
|
+
pub async fn cleanup_temp() -> Result<String, String> {
|
|
999
|
+
tokio::task::spawn_blocking(|| {
|
|
1000
|
+
let temp_dir = std::env::temp_dir().join("pdf-tools");
|
|
1001
|
+
if temp_dir.exists() {
|
|
1002
|
+
let mut count = 0u32;
|
|
1003
|
+
if let Ok(entries) = fs::read_dir(&temp_dir) {
|
|
1004
|
+
for entry in entries.flatten() {
|
|
1005
|
+
let path = entry.path();
|
|
1006
|
+
if path.is_file() {
|
|
1007
|
+
fs::remove_file(&path).ok();
|
|
1008
|
+
count += 1;
|
|
1009
|
+
} else if path.is_dir() {
|
|
1010
|
+
fs::remove_dir_all(&path).ok();
|
|
1011
|
+
count += 1;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
Ok(format!("{} file/folder temp dihapus.", count))
|
|
1016
|
+
} else {
|
|
1017
|
+
Ok("Tidak ada file temp.".to_string())
|
|
1018
|
+
}
|
|
1019
|
+
})
|
|
1020
|
+
.await
|
|
1021
|
+
.map_err(|e| format!("Task error: {}", e))?
|
|
1022
|
+
}
|