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.
Files changed (65) hide show
  1. package/README.md +165 -0
  2. package/bin/pdflinux +31 -0
  3. package/index.html +13 -0
  4. package/install.sh +254 -0
  5. package/package.json +57 -0
  6. package/public/favicon.svg +1 -0
  7. package/public/icons.svg +24 -0
  8. package/src/App.tsx +68 -0
  9. package/src/assets/hero.png +0 -0
  10. package/src/assets/react.svg +1 -0
  11. package/src/assets/vite.svg +1 -0
  12. package/src/components/Dropzone.tsx +90 -0
  13. package/src/components/FileItem.tsx +27 -0
  14. package/src/components/ProgressBar.tsx +18 -0
  15. package/src/components/Sidebar.tsx +92 -0
  16. package/src/hooks/useTheme.ts +23 -0
  17. package/src/index.css +675 -0
  18. package/src/main.tsx +10 -0
  19. package/src/pages/Dashboard.tsx +71 -0
  20. package/src/pages/ImageToPdf.tsx +100 -0
  21. package/src/pages/PdfCompress.tsx +175 -0
  22. package/src/pages/PdfCrop.tsx +112 -0
  23. package/src/pages/PdfDeletePages.tsx +151 -0
  24. package/src/pages/PdfGrayscale.tsx +100 -0
  25. package/src/pages/PdfInfo.tsx +71 -0
  26. package/src/pages/PdfMerge.tsx +135 -0
  27. package/src/pages/PdfOcr.tsx +136 -0
  28. package/src/pages/PdfPageNumbers.tsx +155 -0
  29. package/src/pages/PdfProtect.tsx +171 -0
  30. package/src/pages/PdfReorder.tsx +145 -0
  31. package/src/pages/PdfRotate.tsx +89 -0
  32. package/src/pages/PdfSplit.tsx +147 -0
  33. package/src/pages/PdfToImage.tsx +134 -0
  34. package/src/pages/PdfToText.tsx +107 -0
  35. package/src/pages/PdfUnlock.tsx +79 -0
  36. package/src/pages/PdfWatermark.tsx +77 -0
  37. package/src-tauri/Cargo.lock +5347 -0
  38. package/src-tauri/Cargo.toml +32 -0
  39. package/src-tauri/build.rs +3 -0
  40. package/src-tauri/capabilities/default.json +14 -0
  41. package/src-tauri/icons/128x128.png +0 -0
  42. package/src-tauri/icons/128x128@2x.png +0 -0
  43. package/src-tauri/icons/32x32.png +0 -0
  44. package/src-tauri/icons/Square107x107Logo.png +0 -0
  45. package/src-tauri/icons/Square142x142Logo.png +0 -0
  46. package/src-tauri/icons/Square150x150Logo.png +0 -0
  47. package/src-tauri/icons/Square284x284Logo.png +0 -0
  48. package/src-tauri/icons/Square30x30Logo.png +0 -0
  49. package/src-tauri/icons/Square310x310Logo.png +0 -0
  50. package/src-tauri/icons/Square44x44Logo.png +0 -0
  51. package/src-tauri/icons/Square71x71Logo.png +0 -0
  52. package/src-tauri/icons/Square89x89Logo.png +0 -0
  53. package/src-tauri/icons/StoreLogo.png +0 -0
  54. package/src-tauri/icons/icon.icns +0 -0
  55. package/src-tauri/icons/icon.ico +0 -0
  56. package/src-tauri/icons/icon.png +0 -0
  57. package/src-tauri/src/lib.rs +48 -0
  58. package/src-tauri/src/main.rs +6 -0
  59. package/src-tauri/src/pdf_engine.rs +1022 -0
  60. package/src-tauri/tauri.conf.json +48 -0
  61. package/tsconfig.app.json +28 -0
  62. package/tsconfig.json +7 -0
  63. package/tsconfig.node.json +26 -0
  64. package/uninstall.sh +65 -0
  65. 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
+ }